import asyncio import json import re import sys import traceback from pathlib import Path from typing import cast import httpx from wasmtime import ( Caller, Config, Engine, Func, FuncType, Linker, Module, Store, ValType, ) from lnbits.core.services import websocket_updater from lnbits.db import Database from lnbits.settings import settings _kv_schema_cache: dict[str, dict] = {} _http_permissions_cache: dict[str, set[tuple[str, str]]] = {} def _load_kv_schema(ext_id: str) -> dict: if ext_id in _kv_schema_cache: return _kv_schema_cache[ext_id] try: conf_path = Path( settings.lnbits_extensions_path, "extensions", ext_id, "config.json" ) if not conf_path.is_file(): _kv_schema_cache[ext_id] = {} return _kv_schema_cache[ext_id] with open(conf_path, "r+") as json_file: config_json = json.load(json_file) schema = config_json.get("kv_schema", {}) if not isinstance(schema, dict): schema = {} _kv_schema_cache[ext_id] = schema return schema except Exception: _kv_schema_cache[ext_id] = {} return _kv_schema_cache[ext_id] def _schema_for_key(schema: dict, key: str) -> dict | None: if not schema: return None entry = schema.get(key) return entry if isinstance(entry, dict) else None def _load_http_permissions(ext_id: str) -> set[tuple[str, str]]: if ext_id in _http_permissions_cache: return _http_permissions_cache[ext_id] try: conf_path = Path( settings.lnbits_extensions_path, "extensions", ext_id, "config.json" ) if not conf_path.is_file(): _http_permissions_cache[ext_id] = set() return _http_permissions_cache[ext_id] with open(conf_path, "r+") as json_file: config_json = json.load(json_file) permissions = config_json.get("permissions", []) allowed: set[tuple[str, str]] = set() if isinstance(permissions, list): for perm in permissions: perm_id = perm.get("id") if isinstance(perm, dict) else None if not isinstance(perm_id, str): continue if not perm_id.startswith("api."): continue try: method_part, path = perm_id.split(":", 1) method = method_part.replace("api.", "").upper() except ValueError: continue if not path.startswith("/"): continue allowed.add((method, path)) _http_permissions_cache[ext_id] = allowed return allowed except Exception: _http_permissions_cache[ext_id] = set() return _http_permissions_cache[ext_id] def _coerce_schema_value(schema_entry: dict, value: str): value_type = schema_entry.get("type", "string") if value_type == "int": return int(value) if value_type == "float": return float(value) if value_type == "bool": if value.lower() in {"true", "1", "yes", "y"}: return True if value.lower() in {"false", "0", "no", "n"}: return False raise ValueError("Invalid bool value") if value_type == "json": return json.loads(value) return value def _run(coro): return asyncio.run(coro) def _ensure_kv_table(db: Database, ext_id: str) -> str: table = _kv_table_name(db, ext_id) return f""" CREATE TABLE IF NOT EXISTS {table} ( key TEXT PRIMARY KEY, value TEXT ); """ def _ensure_secret_kv_table(db: Database, ext_id: str) -> str: table = _secret_kv_table_name(db, ext_id) return f""" CREATE TABLE IF NOT EXISTS {table} ( key TEXT PRIMARY KEY, value TEXT ); """ def _kv_table_name(db: Database, ext_id: str) -> str: table = f"{ext_id}.kv" if db.schema else "kv" if ( re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", table) is None ): raise RuntimeError("Invalid KV table name") return table def _secret_kv_table_name(db: Database, ext_id: str) -> str: table = f"{ext_id}.secret_kv" if db.schema else "secret_kv" if ( re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", table) is None ): raise RuntimeError("Invalid secret KV table name") return table def _get_memory(caller: Caller): memory = None if hasattr(caller, "get_export"): memory = caller.get_export("memory") # type: ignore[attr-defined] if memory is None: try: memory = caller.get("memory") except Exception: memory = None if memory is None: raise RuntimeError("WASM module does not export memory") return memory def _read_bytes(caller: Caller, ptr: int, length: int) -> bytes: memory = _get_memory(caller) # wasmtime's memory API is dynamically typed; keep pyright quiet return memory.read(caller, ptr, ptr + length) # type: ignore[attr-defined] def _write_bytes(caller: Caller, ptr: int, data: bytes) -> None: memory = _get_memory(caller) if isinstance(data, int): data = str(data).encode() elif isinstance(data, str): data = data.encode() try: memory.write(caller, data, ptr) # type: ignore[attr-defined] except Exception as exc: raise RuntimeError(f"memory.write failed for type={type(data)}") from exc async def _ws_publish(ext_id: str, topic: str, payload: str) -> int: if not topic.startswith(f"{ext_id}:"): raise RuntimeError("WS topic must be namespaced to extension") await websocket_updater(topic, payload) return 1 def _http_request( ext_id: str, caller: Caller, method_ptr: int, method_len: int, path_ptr: int, path_len: int, body_ptr: int, body_len: int, key_ptr: int, key_len: int, out_ptr: int, out_len: int, ) -> int: method = _read_bytes(caller, method_ptr, method_len).decode(errors="ignore").upper() path = _read_bytes(caller, path_ptr, path_len).decode(errors="ignore") body = _read_bytes(caller, body_ptr, body_len) if isinstance(body, int): body = str(body).encode() elif isinstance(body, str): body = body.encode() else: body = bytes(body) api_key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore") if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}: raise RuntimeError("Unsupported method") if not path.startswith("/") or "://" in path: raise RuntimeError("Invalid path") allowed = _load_http_permissions(ext_id) if (method, path) not in allowed: raise RuntimeError("HTTP permission denied") base_url = settings.lnbits_baseurl.rstrip("/") headers = {"accept": "application/json"} if body_len > 0: headers["content-type"] = "application/json" if api_key: headers["x-api-key"] = api_key with httpx.Client(base_url=base_url) as client: resp = client.request(method, path, headers=headers, content=body) data = resp.content or b"" if out_len > 0: data = data[: max(0, out_len)] _write_bytes(caller, out_ptr, data) return len(data) def _db_get( db: Database, ext_id: str, caller: Caller, key_ptr: int, key_len: int, out_ptr: int, out_len: int, ) -> int: key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore") _run(db.execute(_ensure_kv_table(db, ext_id))) table = _kv_table_name(db, ext_id) row = _run( db.fetchone( f"SELECT value FROM {table} WHERE key = :key", # noqa: S608 {"key": key}, ) ) if not row: return 0 value = str(row.get("value", "")) data = value.encode()[: max(0, out_len)] _write_bytes(caller, out_ptr, data) return len(data) def _db_set( db: Database, ext_id: str, caller: Caller, key_ptr: int, key_len: int, val_ptr: int, val_len: int, ) -> int: key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore") value = _read_bytes(caller, val_ptr, val_len).decode(errors="ignore") schema = _load_kv_schema(ext_id) entry = _schema_for_key(schema, key) if schema and not entry: raise RuntimeError("Key not allowed by schema") if entry: try: coerced = _coerce_schema_value(entry, value) except Exception as exc: raise RuntimeError("Invalid value for schema") from exc value = json.dumps(coerced) if entry.get("type") == "json" else str(coerced) _run(db.execute(_ensure_kv_table(db, ext_id))) table = _kv_table_name(db, ext_id) row = _run( db.fetchone( f"SELECT key FROM {table} WHERE key = :key", # noqa: S608 {"key": key}, ) ) if row: _run( db.execute( f"UPDATE {table} SET value = :value WHERE key = :key", # noqa: S608 {"key": key, "value": value}, ) ) else: _run( db.execute( f"INSERT INTO {table} (key, value) VALUES (:key, :value)", # noqa: S608 {"key": key, "value": value}, ) ) return len(value) def _secret_db_get( db: Database, ext_id: str, caller: Caller, key_ptr: int, key_len: int, out_ptr: int, out_len: int, ) -> int: key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore") _run(db.execute(_ensure_secret_kv_table(db, ext_id))) table = _secret_kv_table_name(db, ext_id) row = _run( db.fetchone( f"SELECT value FROM {table} WHERE key = :key", # noqa: S608 {"key": key}, ) ) if not row: return 0 value = str(row.get("value", "")) data = value.encode()[: max(0, out_len)] _write_bytes(caller, out_ptr, data) return len(data) def _secret_db_set( db: Database, ext_id: str, caller: Caller, key_ptr: int, key_len: int, val_ptr: int, val_len: int, ) -> int: key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore") value = _read_bytes(caller, val_ptr, val_len).decode(errors="ignore") _run(db.execute(_ensure_secret_kv_table(db, ext_id))) table = _secret_kv_table_name(db, ext_id) row = _run( db.fetchone( f"SELECT key FROM {table} WHERE key = :key", # noqa: S608 {"key": key}, ) ) if row: _run( db.execute( f"UPDATE {table} SET value = :value WHERE key = :key", # noqa: S608 {"key": key, "value": value}, ) ) else: _run( db.execute( f"INSERT INTO {table} (key, value) VALUES (:key, :value)", # noqa: S608 {"key": key, "value": value}, ) ) return len(value) def _load_module(module_path: Path, ext_id: str): config = Config() config.consume_fuel = settings.lnbits_wasm_fuel > 0 engine = Engine(config) store = Store(engine) if settings.lnbits_wasm_fuel > 0 and hasattr(store, "add_fuel"): store.add_fuel(settings.lnbits_wasm_fuel) # type: ignore[attr-defined] module = Module.from_file(engine, str(module_path)) db = Database(f"ext_{ext_id}") def db_get( caller: Caller, key_ptr: int, key_len: int, out_ptr: int, out_len: int ) -> int: return _db_get(db, ext_id, caller, key_ptr, key_len, out_ptr, out_len) def db_set( caller: Caller, key_ptr: int, key_len: int, val_ptr: int, val_len: int ) -> int: return _db_set(db, ext_id, caller, key_ptr, key_len, val_ptr, val_len) def db_secret_get( caller: Caller, key_ptr: int, key_len: int, out_ptr: int, out_len: int ) -> int: return _secret_db_get(db, ext_id, caller, key_ptr, key_len, out_ptr, out_len) def db_secret_set( caller: Caller, key_ptr: int, key_len: int, val_ptr: int, val_len: int ) -> int: return _secret_db_set(db, ext_id, caller, key_ptr, key_len, val_ptr, val_len) def linker_define(linker: Linker, module: str, name: str, func: Func) -> None: try: linker.define(store, module, name, func) except TypeError: linker.define(module, name, func) # type: ignore[call-arg, arg-type] linker = Linker(engine) linker_define( linker, "host", "db_get", Func( store, FuncType( [ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], [ValType.i32()], ), db_get, access_caller=True, ), ) linker_define( linker, "host", "db_set", Func( store, FuncType( [ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], [ValType.i32()], ), db_set, access_caller=True, ), ) linker_define( linker, "host", "db_secret_get", Func( store, FuncType( [ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], [ValType.i32()], ), db_secret_get, access_caller=True, ), ) linker_define( linker, "host", "db_secret_set", Func( store, FuncType( [ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], [ValType.i32()], ), db_secret_set, access_caller=True, ), ) def _http_request_wrapper( caller, method_ptr, method_len, path_ptr, path_len, body_ptr, body_len, key_ptr, key_len, out_ptr, out_len, ): return _http_request( ext_id, caller, method_ptr, method_len, path_ptr, path_len, body_ptr, body_len, key_ptr, key_len, out_ptr, out_len, ) linker_define( linker, "host", "http_request", Func( store, FuncType( [ ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32(), ], [ValType.i32()], ), _http_request_wrapper, access_caller=True, ), ) linker_define( linker, "host", "ws_publish", Func( store, FuncType( [ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], [ValType.i32()], ), lambda caller, topic_ptr, topic_len, payload_ptr, payload_len: _run( _ws_publish( ext_id, _read_bytes(caller, topic_ptr, topic_len).decode(errors="ignore"), _read_bytes(caller, payload_ptr, payload_len).decode( errors="ignore" ), ) ), access_caller=True, ), ) instance = linker.instantiate(store, module) return store, instance def main() -> int: if len(sys.argv) < 4: sys.stderr.write( "usage: runner.py [args...]\n" ) return 2 module_path = Path(sys.argv[1]) ext_id = sys.argv[2] function_name = sys.argv[3] args = sys.argv[4:] if not module_path.exists(): sys.stderr.write(f"module not found: {module_path}\n") return 2 try: store, instance = _load_module(module_path, ext_id) export = instance.exports(store)[function_name] if not isinstance(export, Func): raise RuntimeError(f"Export '{function_name}' is not callable") func = cast(Func, export) int_args = [int(a) for a in args] if hasattr(func, "call"): result = func.call(store, *int_args) # type: ignore[attr-defined] else: result = func(store, *int_args) # type: ignore[operator] except Exception as exc: payload = {"ok": False, "error": f"{exc}\n{traceback.format_exc()}"} sys.stdout.write(json.dumps(payload)) return 1 payload = {"ok": True, "result": int(result)} sys.stdout.write(json.dumps(payload)) return 0 if __name__ == "__main__": raise SystemExit(main())