[Refactor] Implement scriptable Lua API

Large architecture refactor of the scripting system.

Previous implementation required Lua scripts to return "action
tables" which were interpreted by a large and not very flexible at all
if-else ladder in C++. While fine for the initial implementation, it's
not scalable, and it makes it impossible for players to write their own
meaningful tools.
This commit is contained in:
Ritchie Cunningham 2025-09-27 21:18:05 +01:00
parent 4b21d30567
commit e06d6eec37
14 changed files with 235 additions and 153 deletions

View File

@ -4,6 +4,7 @@ if not filename then
return "" -- No arguments, return nothing. return "" -- No arguments, return nothing.
end end
local current_dir = bettola.get_current_dir(context)
local target_node = current_dir.children[filename] local target_node = current_dir.children[filename]
if not target_node then if not target_node then

View File

@ -4,4 +4,4 @@ if not target then
return "" -- No argument, just return to prompt. return "" -- No argument, just return to prompt.
end end
return { action = "cd", target = target } return bettola.cd(context, target)

View File

@ -18,7 +18,7 @@ if found_redirect then
return "echo: syntax error: expected filename after '>'" return "echo: syntax error: expected filename after '>'"
end end
local content = table.concat(content_parts, " ") local content = table.concat(content_parts, " ")
return { action = "write_file", target = filename, content = content } return bettola.write_file(context, filename, content)
else else
return table.concat(arg, " ") return table.concat(arg, " ")
end end

View File

@ -1,6 +1,6 @@
-- /bin/exit - Disconnects from a remote session or cloes terminal window. -- /bin/exit - Disconnects from a remote session or cloes terminal window.
if is_remote_session then if is_remote_session then
return { action = "disconnect" } return bettola.disconnect(context)
else else
return { action = "close_terminal" } return bettola.close_terminal(context)
end end

View File

@ -1,5 +1,5 @@
-- /bin/ls - Lists files in a directory. -- /bin/ls - Lists files in a directory.
local dir = current_dir -- Get directory object from C++. local dir = bettola.get_current_dir(context)
local output = "" local output = ""
-- Iterate over the 'children' map exposed via C++. -- Iterate over the 'children' map exposed via C++.

View File

@ -6,4 +6,4 @@ if not target_ip then
end end
-- TODO: Add args such as -sV for version detection etc. -- TODO: Add args such as -sV for version detection etc.
return { action = "scan", target = target_ip } return bettola.nmap(context, target_ip)

View File

@ -1,3 +1,3 @@
local file_to_remove = arg[1] local file_to_remove = arg[1]
if not file_to_remove then return "rm: missing operand" end if not file_to_remove then return "rm: missing operand" end
return { action = "rm", target = file_to_remove } return bettola.rm(context, file_to_remove)

View File

@ -1,4 +1,4 @@
-- /bin/ssh - Connects to a remote host. -- /bin/ssh - Connects to a remote host.
local target = arg[1] local target = arg[1]
if not target then return "ssh: missing operand" end if not target then return "ssh: missing operand" end
return { action = "ssh", target = target } return bettola.ssh(context, target)

View File

@ -24,6 +24,25 @@ vfs_node* CommandProcessor::get_current_dir(void) {
return _current_dir; return _current_dir;
} }
Machine* CommandProcessor::get_home_machine(void) { return _home_machine; }
Machine* CommandProcessor::get_session_machine(void) { return _session_machine; }
std::map<std::string, Machine*>&
CommandProcessor::get_world_machines(void) {
return _world_machines;
}
void CommandProcessor::set_current_dir(vfs_node* node) {
_current_dir = node;
}
void CommandProcessor::set_session_machine(Machine* machine) {
_session_machine = machine;
if(_session_machine) {
_current_dir = _session_machine->vfs_root;
}
}
std::string CommandProcessor::process_command(const std::string& command) { std::string CommandProcessor::process_command(const std::string& command) {
/* Trim trailing whitespace. */ /* Trim trailing whitespace. */
std::string cmd = command; std::string cmd = command;
@ -48,13 +67,9 @@ std::string CommandProcessor::process_command(const std::string& command) {
if(root->children.count("bin") && root->children["bin"]->children.count(script_filename)) { if(root->children.count("bin") && root->children["bin"]->children.count(script_filename)) {
vfs_node* script_node = root->children["bin"]->children[script_filename]; vfs_node* script_node = root->children["bin"]->children[script_filename];
bool is_remote = (_session_machine != _home_machine); bool is_remote = (_session_machine != _home_machine);
sol::object result = _lua->execute(script_node->content, _current_dir, args, is_remote); sol::object result = _lua->execute(script_node->content, *this, args, is_remote);
if(result.is<std::string>()) { return result.is<std::string>() ? result.as<std::string>() : "[Script returned an unexpected type]";
return result.as<std::string>();
} else if(result.is<sol::table>()) {
return _handle_vfs_action(result.as<sol::table>());
}
return "[Script returned an unexpected type]";
} }
return "Unknown command: " + command_name + "\n"; return "Unknown command: " + command_name + "\n";
} }
@ -83,145 +98,54 @@ vfs_node* find_node_by_path(vfs_node* root, const std::string& path) {
return current; return current;
} }
std::string CommandProcessor::_handle_vfs_action(sol::table action) { void CommandProcessor::ensure_vfs_is_writable(void) {
std::string action_name = action["action"].get_or<std::string>(""); if(!_session_machine->is_vfs_a_copy) {
/* Make the CoW check universal for any write operation. */ /* VFS shared, copy required. */
if(action_name == "rm" || action_name == "write_file") { if(_session_machine == _home_machine) {
if(!_session_machine->is_vfs_a_copy) { /* We are modifying our own home machine. */
/* VFS is shared, a copy is required. */ fprintf(stderr, "CoW: Write attempt on player's home machine."
if(_session_machine == _home_machine) { "Creating persistent copy.\n");
/* We are modifying our own home machine. */ std::string original_path = get_full_path(_current_dir);
fprintf(stderr, "CoW: Write attempt on player's home machine"
"Creating persistant copy.\n"); vfs_node* new_vfs_root = copy_vfs_node(_home_machine->vfs_root, nullptr);
auto* new_machine = new Machine(_home_machine->id, _home_machine->hostname);
new_machine->vfs_root = new_vfs_root;
new_machine->services = _home_machine->services;
new_machine->is_vfs_a_copy = true;
_home_machine = new_machine;
set_session_machine(new_machine);
_current_dir = find_node_by_path(_session_machine->vfs_root, original_path);
if(!_current_dir) { _current_dir = _session_machine->vfs_root; }
} else {
/* we are modifying a remote NPC machine. */
std::string remote_ip = "";
for(auto const& [ip, machine] : _world_machines) {
if(machine == _session_machine) {
remote_ip = ip;
break;
}
}
if(!remote_ip.empty()) {
fprintf(stderr, "CoW: Write attempt on remote machine '%s'."
"Creating persistent copy.\n",
remote_ip.c_str());
std::string original_path = get_full_path(_current_dir); std::string original_path = get_full_path(_current_dir);
vfs_node* new_vfs_root = copy_vfs_node(_home_machine->vfs_root, nullptr); vfs_node* new_vfs_root = copy_vfs_node(_session_machine->vfs_root, nullptr);
auto* new_machine = new Machine(_home_machine->id, _home_machine->hostname); auto* new_machine = new Machine(_session_machine->id, _session_machine->hostname);
new_machine->vfs_root = new_vfs_root; new_machine->vfs_root = new_vfs_root;
new_machine->services = _home_machine->services; new_machine->services = _session_machine->services;
new_machine->is_vfs_a_copy = true; /* Mark as copy. */ new_machine->is_vfs_a_copy = true;
_world_machines[remote_ip] = new_machine;
set_session_machine(new_machine);
_home_machine = new_machine;
_session_machine = new_machine;
_current_dir = find_node_by_path(_session_machine->vfs_root, original_path); _current_dir = find_node_by_path(_session_machine->vfs_root, original_path);
if(!_current_dir) { _current_dir = _session_machine->vfs_root; } if(!_current_dir) { _current_dir = _session_machine->vfs_root; }
} else {
std::string remote_ip = "";
/* Check if we are on a known remote system. */
for(auto const& [ip, machine] : _world_machines) {
if(machine == _session_machine) {
remote_ip = ip;
break;
}
}
if(!remote_ip.empty()) {
fprintf(stderr, "CoW: Write attempt on remote machine '%s'. "
"Creating persistant copy.\n",
remote_ip.c_str());
std::string original_path = get_full_path(_current_dir);
vfs_node* new_vfs_root = copy_vfs_node(_session_machine->vfs_root, nullptr);
auto* new_machine = new Machine(_session_machine->id, _session_machine->hostname);
new_machine->vfs_root = new_vfs_root;
new_machine->services = _session_machine->services;
new_machine->is_vfs_a_copy = true; /* Mark as copy. */
_world_machines[remote_ip] = new_machine;
_session_machine = new_machine;
_current_dir = find_node_by_path(_session_machine->vfs_root, original_path);
if(!_current_dir) { _current_dir = _session_machine->vfs_root; }
} else {
return "Permission denied: Filesystem is read-only and not part of the world.";
}
} }
} }
} }
if(action_name == "rm") {
std::string path = action["target"].get_or<std::string>("");
if(path.empty()) return "rm: missing operand";
if(!_current_dir->children.count(path)) {
return std::string("rm: cannot remove '") + path + "': No such file or directory.";
}
vfs_node* target_node = _current_dir->children[path];
if(target_node->type == DIR_NODE) {
return std::string("rm: cannot remove '") + path + "': Is a directory.";
}
_current_dir->children.erase(path);
delete target_node;
return ""; /* Success. */
} else if(action_name == "write_file") {
std::string filename = action["target"].get_or<std::string>("");
std::string content = action["content"].get_or<std::string>("");
if(filename.empty()) {
return "write_file: missing filename";
}
if(_current_dir->children.count(filename)) {
vfs_node* target_node = _current_dir->children[filename];
if(target_node->type == DIR_NODE) {
return std::string("cannot write to '") + filename + "': Is a directory.";
}
target_node->content = content;
} else {
vfs_node* new_file = new vfs_node {
.name = filename, .type = FILE_NODE, .content = content,
.parent = _current_dir
};
_current_dir->children[filename] = new_file;
}
return ""; /* Success. */
} else if(action_name == "ssh") {
std::string target_ip = action["target"].get_or<std::string>("");
if(_world_machines.count(target_ip)) {
_session_machine = _world_machines[target_ip];
_current_dir = _session_machine->vfs_root;
return "Connected to " + target_ip;
}
return "ssh: Could not resolve hostname " + target_ip + ": Name or service not known";
} else if(action_name == "cd") {
std::string target_dir_name = action["target"].get_or<std::string>("");
if(target_dir_name == "..") {
if(_current_dir->parent) {
_current_dir = _current_dir->parent;
}
} else if(_current_dir->children.count(target_dir_name)) {
vfs_node* target_node = _current_dir->children[target_dir_name];
if(target_node->type == DIR_NODE) {
_current_dir = target_node;
} else {
return std::string("cd: not a directory: ") + target_dir_name;
}
} else {
return std::string("cd: no such file or directory: ") + target_dir_name;
}
return ""; /* Success. */
} else if(action_name == "disconnect") {
_session_machine = _home_machine;
_current_dir = _home_machine->vfs_root;
return "Connection closed.";
} else if(action_name == "close_terminal") {
return "__CLOSE_CONNECTION__";
} else if(action_name == "scan") {
std::string target_ip = action["target"].get_or<std::string>("");
if(!_world_machines.count(target_ip)) {
return std::string("nmap: Could not resolve host: ") + target_ip;
}
Machine* target_machine = _world_machines[target_ip];
if(target_machine->services.empty()) {
return std::string("No open ports for ") + target_ip;
}
std::stringstream ss;
ss<<"Host: "<<target_ip<<"\n";
ss<<"PORT\tSTATE\tSERVICE\n";
for(auto const& [port, service_name] : target_machine->services) {
ss<<port<<"/tcp\t"<<"open\t"<<service_name<<"\n";
}
return ss.str();
}
return "Error: Unknown VFS action '" + action_name + "'";
} }

View File

@ -13,10 +13,19 @@ public:
~CommandProcessor(void); ~CommandProcessor(void);
std::string process_command(const std::string& command); std::string process_command(const std::string& command);
/* Public interface for API functions. */
vfs_node* get_current_dir(void); vfs_node* get_current_dir(void);
Machine* get_home_machine(void);
Machine* get_session_machine(void);
std::map<std::string, Machine*>& get_world_machines(void);
void set_current_dir(vfs_node* node);
void set_session_machine(Machine* machine);
void ensure_vfs_is_writable(void);
private: private:
std::string _handle_vfs_action(sol::table action);
Machine* _home_machine; Machine* _home_machine;
Machine* _session_machine; Machine* _session_machine;
vfs_node* _current_dir; vfs_node* _current_dir;

102
common/src/lua_api.cpp Normal file
View File

@ -0,0 +1,102 @@
#include <sstream>
#include "lua_api.h"
#include "command_processor.h"
#include "machine.h"
#include "vfs.h"
namespace api {
vfs_node* get_current_dir(CommandProcessor& context) {
return context.get_current_dir();
}
std::string rm(CommandProcessor& context, const std::string& filename) {
context.ensure_vfs_is_writable();
vfs_node* current_dir = context.get_current_dir();
if(!current_dir->children.count(filename)) {
return "rm: cannot remove '" + filename + "': No such file or directory.";
}
vfs_node* target_node = current_dir->children[filename];
if(target_node->type == DIR_NODE) {
return "rm: cannot remove '" + filename + "': Is a directory.";
}
current_dir->children.erase(filename);
delete target_node;
return "";
}
std::string write_file(CommandProcessor& context, const std::string& filename,
const std::string& content) {
context.ensure_vfs_is_writable();
vfs_node* current_dir = context.get_current_dir();
if(current_dir->children.count(filename)) {
vfs_node* target_node = current_dir->children[filename];
if(target_node->type == DIR_NODE) {
return "cannot write to '" + filename + "': Is a directory.";
}
target_node->content = content;
} else {
vfs_node* new_file = new vfs_node {
.name = filename, .type = FILE_NODE, .content = content, .parent = current_dir};
current_dir->children[filename] = new_file;
}
return "";
}
std::string cd(CommandProcessor& context, const std::string& path) {
vfs_node* current_dir = context.get_current_dir();
if(path == "..") {
if(current_dir->parent) {
context.set_current_dir(current_dir->parent);
}
} else if(current_dir->children.count(path)) {
vfs_node* target_node = current_dir->children[path];
if(target_node->type == DIR_NODE) {
context.set_current_dir(target_node);
} else {
return "cd: not a directory: " + path;
}
} else {
return "cd: no such file or directory: " + path;
}
return "";
}
std::string ssh(CommandProcessor& context, const std::string& ip) {
if(context.get_world_machines().count(ip)) {
context.set_session_machine(context.get_world_machines().at(ip));
return "Connected to " + ip;
}
return "ssh: Could not resolve hostname " + ip + ": Name or service not found";
}
std::string nmap(CommandProcessor& context, const std::string& ip) {
if(!context.get_world_machines().count(ip)) {
return "nmap: Could not resolve host: " + ip;
}
Machine* target_machine = context.get_world_machines().at(ip);
if(target_machine->services.empty()) {
return "No open ports for " + ip;
}
std::stringstream ss;
ss << "Host: " << ip << "\n";
ss << "PORT\tSTATE\tSERVICE\n";
for(auto const& [port, service_name] : target_machine->services) {
ss << port << "/tcp\t" << "open\t" << service_name << "\n";
}
return ss.str();
}
std::string disconnect(CommandProcessor& context) {
context.set_session_machine(context.get_home_machine());
return "Connection closed.";
}
std::string close_terminal(CommandProcessor& context) {
return "__CLOSE_CONNECTION__";
}
} /* namespace api */

25
common/src/lua_api.h Normal file
View File

@ -0,0 +1,25 @@
#pragma once
#include <string>
#include "vfs.h"
class CommandProcessor;
namespace api {
/* FILESYSTEM ACTIONS. */
vfs_node* get_current_dir(CommandProcessor& context);
std::string rm(CommandProcessor& context, const std::string& filename);
std::string write_file(CommandProcessor& context, const std::string& filename,
const std::string& content);
std::string cd(CommandProcessor& context, const std::string& path);
/* NETWORK ACTIONS. */
std::string ssh(CommandProcessor& context, const std::string& ip);
std::string nmap(CommandProcessor& context, const std::string& ip);
std::string disconnect(CommandProcessor& context);
/* SYSTEM ACTIONS. */
std::string close_terminal(CommandProcessor& context);
} /* namespace api */

View File

@ -1,8 +1,12 @@
#include "lua_processor.h"
#include <sol/forward.hpp> #include <sol/forward.hpp>
#include <sol/object.hpp> #include <sol/object.hpp>
#include <sol/protected_function_result.hpp> #include <sol/protected_function_result.hpp>
#include <sol/raii.hpp>
#include "lua_processor.h"
#include "vfs.h" #include "vfs.h"
#include "lua_api.h"
#include "command_processor.h"
LuaProcessor::LuaProcessor(void) { LuaProcessor::LuaProcessor(void) {
_lua.open_libraries(sol::lib::base, sol::lib::string, sol::lib::io, sol::lib::table); _lua.open_libraries(sol::lib::base, sol::lib::string, sol::lib::io, sol::lib::table);
@ -12,16 +16,31 @@ LuaProcessor::LuaProcessor(void) {
"name", &vfs_node::name, "name", &vfs_node::name,
"type", &vfs_node::type, "type", &vfs_node::type,
"children", &vfs_node::children, "children", &vfs_node::children,
"content", &vfs_node::content); } "content", &vfs_node::content);
/* Expose CommandProcessor to Lua. DON'T ALLOW SCRIPTS TO CREATE IT THOUGH! */
_lua.new_usertype<CommandProcessor>("CommandProcessor", sol::no_constructor);
/* Create the 'bettola' API table. */
sol::table bettola_api = _lua.create_named_table("bettola");
bettola_api["rm"] = &api::rm;
bettola_api["write_file"] = &api::write_file;
bettola_api["get_current_dir"]= &api::get_current_dir;
bettola_api["cd"] = &api::cd;
bettola_api["ssh"] = &api::ssh;
bettola_api["nmap"] = &api::nmap;
bettola_api["disconnect"] = &api::disconnect;
bettola_api["close_terminal"] = &api::close_terminal;
}
LuaProcessor::~LuaProcessor(void) {} LuaProcessor::~LuaProcessor(void) {}
sol::object LuaProcessor::execute(const std::string& script, vfs_node* current_dir, sol::object LuaProcessor::execute(const std::string& script, CommandProcessor& context,
const std::vector<std::string>& args, bool is_remote) { const std::vector<std::string>& args, bool is_remote) {
try { try {
/* Pass C++ objects/points into the Lua env. */ /* Pass C++ objects/points into the Lua env. */
_lua["is_remote_session"] = is_remote; _lua["is_remote_session"] = is_remote;
_lua["current_dir"] = current_dir; _lua["context"] = &context;
/* Create and populate the 'arg' table for the script. */ /* Create and populate the 'arg' table for the script. */
sol::table arg_table = _lua.create_table(); sol::table arg_table = _lua.create_table();

View File

@ -5,13 +5,15 @@
#include <vfs.h> #include <vfs.h>
#include <string> #include <string>
class CommandProcessor;
class LuaProcessor { class LuaProcessor {
public: public:
LuaProcessor(void); LuaProcessor(void);
~LuaProcessor(void); ~LuaProcessor(void);
/* Executes a string of lua code and returns result as a string. */ /* Executes a string of lua code and returns result as a string. */
sol::object execute(const std::string& script, vfs_node* current_dir, sol::object execute(const std::string& script, CommandProcessor& context,
const std::vector<std::string>& args, bool is_remote); const std::vector<std::string>& args, bool is_remote);
private: private:
sol::state _lua; sol::state _lua;