path proxy

This commit is contained in:
Arc
2026-02-21 11:26:38 +00:00
parent ca215063be
commit ec88cf483e
11 changed files with 327 additions and 46 deletions
+18 -1
View File
@@ -1,6 +1,7 @@
import asyncio
import glob
import importlib
import json
import os
import shutil
import sys
@@ -38,10 +39,10 @@ from lnbits.core.tasks import (
wait_for_paid_invoices,
wait_notification_messages,
)
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
from lnbits.core.wasm.extension_host import register_wasm_ext_routes
from lnbits.tasks import (
cancel_all_tasks,
create_permanent_task,
@@ -431,6 +432,8 @@ def register_ext_tasks(ext: Extension) -> None:
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
"""Register FastAPI routes for extension."""
if ext.extension_type != "wasm":
ext.extension_type = _load_extension_type(ext.code) or ext.extension_type
if ext.extension_type == "wasm":
settings.activate_extension_paths(ext.code, ext.upgrade_hash, [])
register_wasm_ext_routes(app, ext)
@@ -460,6 +463,20 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
app.include_router(router=ext_route, prefix=prefix)
def _load_extension_type(ext_id: str) -> str | None:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return None
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
return config_json.get("extension_type")
except Exception:
return None
async def check_and_register_extensions(app: FastAPI) -> None:
await check_installed_extensions(app)
for ext in await get_valid_extensions(False):
+30
View File
@@ -22,6 +22,8 @@ from lnbits.settings import settings
async def migrate_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None
):
if _is_wasm_extension(ext):
return
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
@@ -58,6 +60,34 @@ async def run_migration(
await update_migration_version(conn, db_name, version)
def _is_wasm_extension(ext: InstallableExtension) -> bool:
if ext.meta and ext.meta.extension_type == "wasm":
return True
try:
import json
from pathlib import Path
candidate_dirs = [
Path(ext.ext_dir),
Path(settings.lnbits_extensions_path, "extensions", ext.id),
Path(settings.lnbits_path, "extensions", ext.id),
Path.cwd() / "extensions" / ext.id,
]
for base in candidate_dirs:
conf_path = Path(base, "config.json")
if not conf_path.is_file():
continue
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
if config_json.get("extension_type") == "wasm":
return True
except Exception:
pass
return False
def to_valid_user_id(user_id: str) -> UUID:
if len(user_id) < 32:
raise ValueError("User ID must have at least 128 bits")
+1 -1
View File
@@ -83,7 +83,7 @@ class ExtensionConfig(BaseModel):
warning: str | None = ""
min_lnbits_version: str | None
max_lnbits_version: str | None
permissions: list["ExtensionPermission"] = []
permissions: list[ExtensionPermission] = []
extension_type: str | None = "python"
public_kv_keys: list[str] = []
+80 -6
View File
@@ -1,6 +1,8 @@
import json
import sys
import traceback
from http import HTTPStatus
from pathlib import Path
import httpx
from bolt11 import decode as bolt11_decode
@@ -24,6 +26,7 @@ from lnbits.core.models.extensions import (
ExtensionReview,
ExtensionReviewPaymentRequest,
ExtensionReviewsStatus,
ExtensionPermission,
ExtensionPermissionsGrant,
InstallableExtension,
PayToEnableInfo,
@@ -196,9 +199,12 @@ async def api_enable_extension(
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
required_permissions = (
[p.id for p in ext.meta.permissions] if ext.meta and ext.meta.permissions else []
permissions_source = (
ext.meta.permissions
if ext.meta and ext.meta.permissions
else _load_permissions_from_config(ext_id)
)
required_permissions = [p.id for p in permissions_source] if permissions_source else []
granted_permissions = []
if grant and grant.permissions:
granted_permissions = grant.permissions
@@ -212,10 +218,6 @@ async def api_enable_extension(
HTTPStatus.BAD_REQUEST,
"Missing required permissions to enable this extension.",
)
user_ext_info = user_ext.extra or UserExtensionInfo()
user_ext_info.granted_permissions = granted_permissions
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
if account_id.is_admin_id or not ext.requires_payment:
user_ext.active = True
@@ -267,10 +269,58 @@ async def api_disable_extension(
)
logger.info(f"Disabling extension: {ext_id}.")
user_ext.active = False
if user_ext.extra and user_ext.extra.granted_permissions:
user_ext.extra.granted_permissions = []
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
@extension_router.put("/{ext_id}/permissions")
async def api_update_extension_permissions(
ext_id: str,
account_id: AccountId = Depends(check_account_id_exists),
grant: ExtensionPermissionsGrant | None = Body(default=None),
) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
)
ext = await get_installed_extension(ext_id)
if not ext:
raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.")
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
permissions_source = (
ext.meta.permissions
if ext.meta and ext.meta.permissions
else _load_permissions_from_config(ext_id)
)
required_permissions = [p.id for p in permissions_source] if permissions_source else []
granted_permissions = grant.permissions if grant and grant.permissions else []
if required_permissions:
missing = [p for p in required_permissions if p not in granted_permissions]
if missing:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Missing required permissions to save for this extension.",
)
user_ext_info = user_ext.extra or UserExtensionInfo()
user_ext_info.granted_permissions = granted_permissions
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Permissions saved for '{ext_id}'.")
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
async def api_activate_extension(ext_id: str) -> SimpleStatus:
try:
@@ -543,6 +593,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
installed_exts: list[InstallableExtension] = await get_installed_extensions(
conn=conn
)
user_exts = await get_user_extensions(account_id.id, conn=conn)
all_ext_ids = [ext.code for ext in await get_valid_extensions(conn=conn)]
inactive_extensions = [
e.id for e in await get_installed_extensions(active=False, conn=conn)
@@ -550,6 +601,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
db_versions = await get_db_versions(conn=conn)
installed_exts_ids = [e.id for e in installed_exts]
user_exts_map = {e.extension: e for e in user_exts}
installable_exts = await InstallableExtension.get_installable_extensions(
post_refresh_cache=account_id.is_admin_id
@@ -578,6 +630,8 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
if e.meta and not e.meta.permissions:
e.meta.permissions = _load_permissions_from_config(e.id)
extension_data = [
{
@@ -598,6 +652,11 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
"permissions": (
[dict(p) for p in ext.meta.permissions]
if ext.meta and ext.meta.permissions
else [dict(p) for p in _load_permissions_from_config(ext.id)]
),
"grantedPermissions": (
user_exts_map.get(ext.id).extra.granted_permissions
if user_exts_map.get(ext.id) and user_exts_map.get(ext.id).extra
else []
),
"latestRelease": (
@@ -627,6 +686,21 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
return extension_data
def _load_permissions_from_config(ext_id: str) -> list[ExtensionPermission]:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return []
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
permissions = config_json.get("permissions", [])
return [ExtensionPermission(**p) for p in permissions]
except Exception:
return []
@extension_router.get(
"/reviews/tags",
dependencies=[Depends(check_account_exists)],
+115 -14
View File
@@ -1,21 +1,24 @@
from __future__ import annotations
import asyncio
import time
from pathlib import Path
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, Response
from fastapi.staticfiles import StaticFiles
from lnbits.core.crud.extensions import get_user_extension
from lnbits.core.crud.wallets import get_wallet_for_key, get_wallets_ids
from lnbits.core.models import User
from lnbits.core.services import create_invoice, pay_invoice, websocket_updater
from lnbits.decorators import check_user_exists
from lnbits.db import Database
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
from lnbits.settings import settings
from lnbits.tasks import register_invoice_listener, unregister_invoice_listener
from .service import WasmExecutionError, wasm_call
@@ -26,7 +29,9 @@ def _renderer(ext_id: str):
def _ext_static_dir(ext_id: str, upgrade_hash: str | None = None) -> Path:
if upgrade_hash:
return Path(
settings.lnbits_extensions_upgrade_path, f"{ext_id}-{upgrade_hash}", "static"
settings.lnbits_extensions_upgrade_path,
f"{ext_id}-{upgrade_hash}",
"static",
)
return Path(settings.lnbits_extensions_path, "extensions", ext_id, "static")
@@ -88,10 +93,38 @@ async def _require_wallet_access(user_id: str, wallet_id: str) -> None:
raise HTTPException(403, "Wallet does not belong to user.")
async def _wait_for_increment_payment(
ext_id: str, payment_hash: str, db: Database, upgrade_hash: str | None
) -> None:
queue_name = f"wasm:{ext_id}:{payment_hash}:{time.time()}"
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, queue_name)
try:
while True:
payment = await asyncio.wait_for(invoice_queue.get(), timeout=3600)
if payment.payment_hash != payment_hash:
continue
if payment.pending is False:
try:
new_value = await wasm_call(
ext_id, "increment_counter", [], upgrade_hash=upgrade_hash
)
except WasmExecutionError:
return
await _kv_set(db, ext_id, "counter", str(new_value))
await websocket_updater(f"{ext_id}:counter", str(new_value))
return
except asyncio.TimeoutError:
return
finally:
unregister_invoice_listener(queue_name)
def register_wasm_ext_routes(app, ext) -> None:
ext_id = ext.code
db = Database(f"ext_{ext_id}")
router = APIRouter(prefix=f"/{ext_id}", tags=[f"{ext_id} (wasm)"])
proxy_block = f"/{ext_id}/api/v1/proxy"
@router.get("/", response_class=HTMLResponse)
async def index(req: Request, user: User = Depends(check_user_exists)):
@@ -116,9 +149,7 @@ def register_wasm_ext_routes(app, ext) -> None:
@router.get("/api/v1/kv/{key}")
async def api_kv_get(key: str, user: User = Depends(check_user_exists)):
await _require_permission(user.id, ext_id, "ext.db.read_write")
_check_quota(
user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min
)
_check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
value = await _kv_get(db, ext_id, key)
return {"key": key, "value": value}
@@ -132,11 +163,11 @@ def register_wasm_ext_routes(app, ext) -> None:
return {"key": key, "value": value}
@router.post("/api/v1/kv/{key}")
async def api_kv_set(key: str, payload: dict, user: User = Depends(check_user_exists)):
async def api_kv_set(
key: str, payload: dict, user: User = Depends(check_user_exists)
):
await _require_permission(user.id, ext_id, "ext.db.read_write")
_check_quota(
user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min
)
_check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
value = payload.get("value")
if value is None:
raise HTTPException(400, "Missing value")
@@ -147,9 +178,7 @@ def register_wasm_ext_routes(app, ext) -> None:
@router.post("/api/v1/kv/{key}/increment")
async def api_kv_increment(key: str, user: User = Depends(check_user_exists)):
await _require_permission(user.id, ext_id, "ext.db.read_write")
_check_quota(
user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min
)
_check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
if key != "counter":
raise HTTPException(400, "Only 'counter' is supported in this example.")
try:
@@ -163,7 +192,9 @@ def register_wasm_ext_routes(app, ext) -> None:
return {"key": key, "value": new_value}
@router.post("/api/v1/invoices")
async def api_create_invoice(payload: dict, user: User = Depends(check_user_exists)):
async def api_create_invoice(
payload: dict, user: User = Depends(check_user_exists)
):
await _require_permission(user.id, ext_id, "lnbits.invoice.create")
_check_quota(
user.id, ext_id, "invoice", settings.lnbits_wasm_max_invoice_ops_per_min
@@ -187,6 +218,43 @@ def register_wasm_ext_routes(app, ext) -> None:
"amount": payment.amount,
}
@router.post("/api/v1/invoices/increment")
async def api_create_increment_invoice(
payload: dict, user: User = Depends(check_user_exists)
):
await _require_permission(user.id, ext_id, "ext.db.read_write")
await _require_permission(user.id, ext_id, "lnbits.invoice.create")
await _require_permission(user.id, ext_id, "lnbits.payments.subscribe")
_check_quota(
user.id, ext_id, "invoice", settings.lnbits_wasm_max_invoice_ops_per_min
)
wallet_id = payload.get("wallet_id")
memo = payload.get("memo") or f"{ext_id} increment"
if not wallet_id:
raise HTTPException(400, "Missing wallet_id")
await _require_wallet_access(user.id, wallet_id)
try:
amount = await wasm_call(
ext_id, "get_increment_amount", [], upgrade_hash=ext.upgrade_hash
)
except WasmExecutionError as exc:
raise HTTPException(500, str(exc)) from exc
if not amount or amount < 0:
raise HTTPException(400, "Invalid amount")
payment = await create_invoice(wallet_id=wallet_id, amount=amount, memo=memo)
asyncio.create_task(
_wait_for_increment_payment(
ext_id, payment.payment_hash, db, ext.upgrade_hash
)
)
return {
"payment_hash": payment.payment_hash,
"payment_request": payment.bolt11,
"amount": payment.amount,
}
@router.post("/api/v1/invoices/pay")
async def api_pay_invoice(payload: dict, user: User = Depends(check_user_exists)):
await _require_permission(user.id, ext_id, "lnbits.invoice.pay")
@@ -211,6 +279,37 @@ def register_wasm_ext_routes(app, ext) -> None:
"status": payment.status,
}
@router.post("/api/v1/proxy")
async def api_proxy(payload: dict, req: Request, user: User = Depends(check_user_exists)):
method = str(payload.get("method", "GET")).upper()
path = str(payload.get("path", "")).strip()
if not path.startswith("/") or "://" in path:
raise HTTPException(400, "Invalid path")
if path.startswith(proxy_block):
raise HTTPException(400, "Proxy loop blocked")
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
raise HTTPException(400, "Unsupported method")
await _require_permission(user.id, ext_id, f"api.{method}:{path}")
headers = {}
for key in ("x-api-key", "authorization", "content-type", "accept"):
if key in req.headers:
headers[key] = req.headers[key]
query = payload.get("query") or {}
body = payload.get("body")
async with httpx.AsyncClient(app=app, base_url="http://lnbits") as client:
resp = await client.request(
method, path, params=query, json=body, headers=headers
)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type"),
)
@router.websocket("/api/v1/events/ws")
async def events_ws(websocket: WebSocket, api_key: str = Query(default="")):
await websocket.accept()
@@ -249,6 +348,8 @@ def register_wasm_ext_routes(app, ext) -> None:
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash else ""
app.include_router(router, prefix=prefix)
_quota_events: dict[tuple[str, str, str], list[float]] = {}
+14 -6
View File
@@ -1,9 +1,8 @@
import asyncio
import json
import sys
from pathlib import Path
import asyncio
from wasmtime import (
Caller,
Config,
@@ -108,11 +107,14 @@ def _load_module(module_path: Path, ext_id: str):
config.consume_fuel = True
engine = Engine(config)
store = Store(engine)
store.add_fuel(settings.lnbits_wasm_fuel)
if hasattr(store, "add_fuel"):
store.add_fuel(settings.lnbits_wasm_fuel)
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:
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(
@@ -126,7 +128,10 @@ def _load_module(module_path: Path, ext_id: str):
"db_get",
Func(
store,
FuncType([ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], [ValType.i32()]),
FuncType(
[ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()],
[ValType.i32()],
),
db_get,
),
)
@@ -135,7 +140,10 @@ def _load_module(module_path: Path, ext_id: str):
"db_set",
Func(
store,
FuncType([ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], [ValType.i32()]),
FuncType(
[ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()],
[ValType.i32()],
),
db_set,
),
)
+14 -5
View File
@@ -67,14 +67,23 @@ async def wasm_call(
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 = stderr.decode().strip() if stderr else "WASM runner error"
detail = payload.get("error")
if not detail and stderr:
detail = stderr.decode().strip()
if not detail:
detail = "WASM runner error"
raise WasmExecutionError(detail)
try:
payload = json.loads(stdout.decode()) if stdout else {}
except json.JSONDecodeError as exc:
raise WasmExecutionError("Invalid WASM runner output") from exc
if not payload:
raise WasmExecutionError("Invalid WASM runner output")
if not payload.get("ok"):
raise WasmExecutionError(payload.get("error", "WASM execution failed"))
+1 -1
View File
@@ -75,7 +75,7 @@ class ExtensionsSettings(LNbitsSettings):
lnbits_extensions_builder_manifest_url: str = Field(
default="https://raw.githubusercontent.com/lnbits/extension_builder_stub/refs/heads/main/manifest.json"
)
lnbits_wasm_timeout_seconds: float = Field(default=1.0, ge=0.1)
lnbits_wasm_timeout_seconds: float = Field(default=3.0, ge=0.1)
lnbits_wasm_fuel: int = Field(default=50_000, ge=1_000)
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)
File diff suppressed because one or more lines are too long
+38 -7
View File
@@ -255,8 +255,13 @@ window.PageExtensions = {
},
async enableExtensionForUser(extension) {
if (extension.permissions && extension.permissions.length) {
this.openPermissionsDialog(extension)
return
if (!extension._grantedPermissions) {
Quasar.Notify.create({
type: 'warning',
message: 'Save permissions before enabling this extension.'
})
return
}
}
if (extension.isPaymentRequired) {
this.showPayToEnable(extension)
@@ -329,7 +334,19 @@ window.PageExtensions = {
this.permissionsDialog.extension = null
this.permissionsDialog.checked = []
},
confirmPermissionsDialog() {
openPermissionsForExtension(extension) {
if (!extension.permissions || !extension.permissions.length) {
Quasar.Notify.create({
type: 'warning',
message: 'This extension does not declare permissions.'
})
return
}
this.permissionsDialog.extension = extension
this.permissionsDialog.checked = []
this.permissionsDialog.show = true
},
async confirmPermissionsDialog() {
const ext = this.permissionsDialog.extension
const granted = this.permissionsDialog.checked.slice()
this.permissionsDialog.show = false
@@ -337,10 +354,19 @@ window.PageExtensions = {
this.permissionsDialog.checked = []
if (!ext) return
ext._grantedPermissions = granted
if (ext.isPaymentRequired) {
this.showPayToEnable(ext, granted)
} else {
this.enableExtension(ext, granted)
try {
await LNbits.api.request(
'PUT',
`/api/v1/extension/${ext.id}/permissions`,
this.g.user.wallets[0].adminkey,
{permissions: granted}
)
Quasar.Notify.create({
type: 'positive',
message: 'Permissions saved.'
})
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
updatePayToInstallData(extension) {
@@ -881,6 +907,11 @@ window.PageExtensions = {
async fetchAllExtensions() {
try {
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
data.forEach(ext => {
if (ext.grantedPermissions && ext.grantedPermissions.length) {
ext._grantedPermissions = ext.grantedPermissions
}
})
return data
} catch (error) {
console.warn(error)
+15 -4
View File
@@ -329,6 +329,19 @@
<span v-text="$t('enable_extension_details')">
</span> </q-tooltip
></q-btn>
<q-btn
v-if="
extension.isInstalled &&
extension.isActive &&
!g.user.extensions.includes(extension.id) &&
extension.permissions &&
extension.permissions.length
"
flat
color="grey-5"
@click="openPermissionsForExtension(extension)"
label="Permissions"
></q-btn>
<q-btn
@click="showManageExtension(extension)"
@@ -925,9 +938,7 @@
<q-card class="q-pa-md" style="min-width: 360px; max-width: 90vw">
<q-card-section>
<div class="text-h6">Permissions required</div>
<div class="text-caption text-grey">
This extension can:
</div>
<div class="text-caption text-grey">This extension can:</div>
<q-list v-if="permissionsDialog.extension">
<q-item
v-for="perm in permissionsDialog.extension.permissions"
@@ -954,7 +965,7 @@
<q-btn
color="primary"
:disable="!permissionsAllChecked"
v-text="$t('enable')"
label="Save"
@click="confirmPermissionsDialog"
></q-btn>
</q-card-actions>