pull almost all out to a parent extension
This commit is contained in:
@@ -72,7 +72,6 @@ FORWARDED_ALLOW_IPS="*"
|
||||
|
||||
# === WASM Extensions Sandbox Limits ===
|
||||
# LNBITS_WASM_TIMEOUT_SECONDS=3.0
|
||||
# LNBITS_WASM_FUEL=50000
|
||||
# LNBITS_WASM_MAX_MODULE_BYTES=1000000
|
||||
# LNBITS_WASM_MAX_DB_OPS_PER_MIN=120
|
||||
|
||||
|
||||
+1
-11
@@ -41,11 +41,7 @@ from lnbits.core.tasks import (
|
||||
from lnbits.core.tasks import (
|
||||
wait_for_paid_invoices as wait_for_paid_invoices_core,
|
||||
)
|
||||
from lnbits.core.wasm.extension_host import (
|
||||
handle_wasm_tag_payment,
|
||||
register_wasm_ext_routes,
|
||||
wasm_scheduler,
|
||||
)
|
||||
from lnbits.core.wasm.extension_host import register_wasm_ext_routes
|
||||
from lnbits.exceptions import register_exception_handlers
|
||||
from lnbits.helpers import version_parse
|
||||
from lnbits.settings import settings
|
||||
@@ -511,12 +507,6 @@ def register_async_tasks() -> None:
|
||||
register_invoice_listener(invoice_queue, "core")
|
||||
create_permanent_task(lambda: wait_for_paid_invoices_core(invoice_queue))
|
||||
|
||||
# wasm tag watcher listener
|
||||
create_permanent_task(
|
||||
wait_for_paid_invoices_listener("wasm_tags", handle_wasm_tag_payment)
|
||||
)
|
||||
create_permanent_task(wasm_scheduler)
|
||||
|
||||
create_permanent_task(run_by_the_minute_tasks)
|
||||
create_permanent_task(purge_audit_data)
|
||||
create_permanent_task(collect_exchange_rates_data)
|
||||
|
||||
@@ -909,6 +909,11 @@ def _ensure_payment_tags_allowed(required: list[str], granted: list[str]) -> Non
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"This extension does not declare any payment tags.",
|
||||
)
|
||||
if required and not granted:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Select at least one payment tag before enabling this extension.",
|
||||
)
|
||||
if not required or not granted:
|
||||
return
|
||||
invalid = [t for t in granted if t not in required]
|
||||
|
||||
@@ -1,87 +1 @@
|
||||
WASM_HOST_API_VERSION = "1.0"
|
||||
|
||||
WASM_HOST_MANIFEST = {
|
||||
"version": WASM_HOST_API_VERSION,
|
||||
"host_functions": [
|
||||
"db_get",
|
||||
"db_set",
|
||||
"db_secret_get",
|
||||
"db_secret_set",
|
||||
"http_request",
|
||||
"ws_publish",
|
||||
],
|
||||
"routes": [
|
||||
{"method": "GET", "path": "/{ext_id}/", "public": False},
|
||||
{"method": "GET", "path": "/{ext_id}/public/{key}", "public": True},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/{ext_id}/api/v1/kv/{key}",
|
||||
"permission": "ext.db.read_write",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/kv/{key}",
|
||||
"permission": "ext.db.read_write",
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/{ext_id}/api/v1/public/kv/{key}",
|
||||
"public": True,
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/secret/{key}",
|
||||
"permission": "ext.db.read_write",
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/{ext_id}/api/v1/secret/{key}",
|
||||
"permission": "ext.db.read_write",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/public/call/{handler}",
|
||||
"public": True,
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/watch",
|
||||
"permission": "ext.payments.watch",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/watch_tag",
|
||||
"permission": "ext.payments.watch",
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/{ext_id}/api/v1/watch_tag",
|
||||
"permission": "ext.payments.watch",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/schedule",
|
||||
"permission": "ext.tasks.schedule",
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/{ext_id}/api/v1/schedule",
|
||||
"permission": "ext.tasks.schedule",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/sql/query",
|
||||
"permission": "ext.db.sql",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/sql/exec",
|
||||
"permission": "ext.db.sql",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/proxy",
|
||||
"permission": "api.METHOD:/path",
|
||||
},
|
||||
],
|
||||
}
|
||||
from lnbits.extensions.wasm.wasm_host import * # noqa: F401,F403
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-591
@@ -1,591 +1 @@
|
||||
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())
|
||||
from lnbits.extensions.wasm.wasm_host.runner import * # noqa: F401,F403
|
||||
|
||||
@@ -1,91 +1 @@
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
WASM_RUNNER = Path(__file__).with_name("runner.py")
|
||||
|
||||
|
||||
class WasmExecutionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_module_path(ext_id: str, upgrade_hash: str | None = None) -> Path:
|
||||
if upgrade_hash:
|
||||
ext_dir = Path(
|
||||
settings.lnbits_extensions_upgrade_path, f"{ext_id}-{upgrade_hash}"
|
||||
)
|
||||
else:
|
||||
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
|
||||
wasm_dir = ext_dir / "wasm"
|
||||
wasm_path = wasm_dir / "module.wasm"
|
||||
if wasm_path.exists():
|
||||
if (
|
||||
settings.lnbits_wasm_max_module_bytes > 0
|
||||
and wasm_path.stat().st_size > settings.lnbits_wasm_max_module_bytes
|
||||
):
|
||||
raise WasmExecutionError("WASM module exceeds size limit.")
|
||||
return wasm_path
|
||||
wat_path = wasm_dir / "module.wat"
|
||||
if wat_path.exists():
|
||||
if (
|
||||
settings.lnbits_wasm_max_module_bytes > 0
|
||||
and wat_path.stat().st_size > settings.lnbits_wasm_max_module_bytes
|
||||
):
|
||||
raise WasmExecutionError("WASM module exceeds size limit.")
|
||||
return wat_path
|
||||
raise WasmExecutionError(f"No wasm module found for extension '{ext_id}'.")
|
||||
|
||||
|
||||
async def wasm_call(
|
||||
ext_id: str,
|
||||
function: str,
|
||||
args: list[int],
|
||||
timeout_s: float | None = None,
|
||||
upgrade_hash: str | None = None,
|
||||
) -> int:
|
||||
module_path = resolve_module_path(ext_id, upgrade_hash)
|
||||
if timeout_s is None:
|
||||
timeout_s = settings.lnbits_wasm_timeout_seconds
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
str(WASM_RUNNER),
|
||||
str(module_path),
|
||||
ext_id,
|
||||
function,
|
||||
*[str(a) for a in args],
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
|
||||
except asyncio.TimeoutError as exc:
|
||||
proc.kill()
|
||||
raise WasmExecutionError("WASM execution timed out") from exc
|
||||
|
||||
payload = {}
|
||||
if stdout:
|
||||
try:
|
||||
payload = json.loads(stdout.decode())
|
||||
except json.JSONDecodeError:
|
||||
payload = {}
|
||||
|
||||
if proc.returncode != 0:
|
||||
detail = payload.get("error")
|
||||
if not detail and stderr:
|
||||
detail = stderr.decode().strip()
|
||||
if not detail:
|
||||
detail = "WASM runner error"
|
||||
raise WasmExecutionError(detail)
|
||||
|
||||
if not payload:
|
||||
raise WasmExecutionError("Invalid WASM runner output")
|
||||
|
||||
if not payload.get("ok"):
|
||||
raise WasmExecutionError(payload.get("error", "WASM execution failed"))
|
||||
|
||||
return int(payload["result"])
|
||||
from lnbits.extensions.wasm.wasm_host.service import * # noqa: F401,F403
|
||||
|
||||
@@ -76,7 +76,6 @@ class ExtensionsSettings(LNbitsSettings):
|
||||
default="https://raw.githubusercontent.com/lnbits/extension_builder_stub/refs/heads/main/manifest.json"
|
||||
)
|
||||
lnbits_wasm_timeout_seconds: float = Field(default=3.0, ge=0.1)
|
||||
lnbits_wasm_fuel: int = Field(default=50_000, ge=0)
|
||||
lnbits_wasm_max_module_bytes: int = Field(default=1_000_000, ge=0)
|
||||
lnbits_wasm_max_db_ops_per_min: int = Field(default=120, ge=0)
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -272,6 +272,18 @@ window.PageExtensions = {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (extension.paymentTags && extension.paymentTags.length) {
|
||||
if (
|
||||
!extension._grantedPaymentTags ||
|
||||
!extension._grantedPaymentTags.length
|
||||
) {
|
||||
Quasar.Notify.create({
|
||||
type: 'warning',
|
||||
message: 'Select payment tags before enabling this extension.'
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if (extension.isPaymentRequired) {
|
||||
this.showPayToEnable(extension)
|
||||
return
|
||||
@@ -342,11 +354,15 @@ window.PageExtensions = {
|
||||
this.permissionsDialog.extension = extension
|
||||
this.permissionsDialog.checked = extension._grantedPermissions
|
||||
? extension._grantedPermissions.slice()
|
||||
: []
|
||||
: extension.grantedPermissions
|
||||
? extension.grantedPermissions.slice()
|
||||
: []
|
||||
this.permissionsDialog.missing = []
|
||||
this.permissionsDialog.tags = extension._grantedPaymentTags
|
||||
? extension._grantedPaymentTags.slice()
|
||||
: []
|
||||
: extension.grantedPaymentTags
|
||||
? extension.grantedPaymentTags.slice()
|
||||
: []
|
||||
this.permissionsDialog.tagOptions = []
|
||||
this.permissionsDialog.show = true
|
||||
},
|
||||
@@ -367,9 +383,17 @@ window.PageExtensions = {
|
||||
return
|
||||
}
|
||||
this.permissionsDialog.extension = extension
|
||||
this.permissionsDialog.checked = []
|
||||
this.permissionsDialog.checked = extension._grantedPermissions
|
||||
? extension._grantedPermissions.slice()
|
||||
: extension.grantedPermissions
|
||||
? extension.grantedPermissions.slice()
|
||||
: []
|
||||
this.permissionsDialog.missing = []
|
||||
this.permissionsDialog.tags = []
|
||||
this.permissionsDialog.tags = extension._grantedPaymentTags
|
||||
? extension._grantedPaymentTags.slice()
|
||||
: extension.grantedPaymentTags
|
||||
? extension.grantedPaymentTags.slice()
|
||||
: []
|
||||
this.permissionsDialog.tagOptions = []
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
@@ -408,7 +432,9 @@ window.PageExtensions = {
|
||||
}
|
||||
if (!ext) return
|
||||
ext._grantedPermissions = granted
|
||||
ext.grantedPermissions = granted
|
||||
ext._grantedPaymentTags = tags
|
||||
ext.grantedPaymentTags = tags
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
|
||||
@@ -150,49 +150,6 @@
|
||||
autocomplete="off"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<p>
|
||||
<span>WASM Sandbox Limits</span>
|
||||
</p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wasm_timeout_seconds"
|
||||
label="WASM timeout (seconds)"
|
||||
hint="Max wall time per WASM call"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wasm_fuel"
|
||||
label="WASM fuel limit"
|
||||
hint="Max instruction budget per call"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wasm_max_module_bytes"
|
||||
label="Max module size (bytes)"
|
||||
hint="Reject larger WASM/WAT modules"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wasm_max_db_ops_per_min"
|
||||
label="Max DB ops per minute"
|
||||
hint="Per user per extension"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
Reference in New Issue
Block a user