Files
lnbits/lnbits/core/wasm/runner.py
T
2026-02-25 16:20:26 +00:00

592 lines
17 KiB
Python

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 <module_path> <ext_id> <function> [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())