Adds security tools, such as a rate limiter, IP block/allow, server logs (#1606)
* added ratelimiter * Adds server logs to admin ui * Added IP allow/ban list * fixed remove ips * Split rate limit number and unit * security tab and background tasks for killswitch * fix test for auditor api --------- Co-authored-by: dni ⚡ <office@dnilabs.com>
This commit is contained in:
+62
-7
@@ -7,20 +7,28 @@ import shutil
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
from typing import Callable, List
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import HTTPException, RequestValidationError
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from lnbits.core.crud import get_installed_extensions
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.tasks import register_task_listeners
|
||||
from lnbits.core.services import websocketUpdater
|
||||
from lnbits.core.tasks import ( # register_watchdog,; unregister_watchdog,
|
||||
register_killswitch,
|
||||
register_task_listeners,
|
||||
unregister_killswitch,
|
||||
)
|
||||
from lnbits.settings import get_wallet_class, set_wallet_class, settings
|
||||
|
||||
from .commands import db_versions, load_disabled_extension_list, migrate_databases
|
||||
@@ -34,7 +42,12 @@ from .core.services import check_admin_settings
|
||||
from .core.views.generic import core_html_routes
|
||||
from .extension_manager import Extension, InstallableExtension, get_valid_extensions
|
||||
from .helpers import template_renderer
|
||||
from .middleware import ExtensionsRedirectMiddleware, InstalledExtensionMiddleware
|
||||
from .middleware import (
|
||||
ExtensionsRedirectMiddleware,
|
||||
InstalledExtensionMiddleware,
|
||||
add_ip_block_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
from .requestvars import g
|
||||
from .tasks import (
|
||||
catch_everything_and_restart,
|
||||
@@ -47,7 +60,6 @@ from .tasks import (
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
configure_logger()
|
||||
|
||||
app = FastAPI(
|
||||
title="LNbits API",
|
||||
description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.",
|
||||
@@ -85,6 +97,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
# Allow registering new extensions routes without direct access to the `app` object
|
||||
setattr(core_app_extra, "register_new_ext_routes", register_new_ext_routes(app))
|
||||
setattr(core_app_extra, "register_new_ratelimiter", register_new_ratelimiter(app))
|
||||
|
||||
return app
|
||||
|
||||
@@ -245,6 +258,19 @@ def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||
return register_new_ext_routes_fn
|
||||
|
||||
|
||||
def register_new_ratelimiter(app: FastAPI) -> Callable:
|
||||
def register_new_ratelimiter_fn():
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=[
|
||||
f"{settings.lnbits_rate_limit_no}/{settings.lnbits_rate_limit_unit}"
|
||||
],
|
||||
)
|
||||
app.state.limiter = limiter
|
||||
|
||||
return register_new_ratelimiter_fn
|
||||
|
||||
|
||||
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
"""Register FastAPI routes for extension."""
|
||||
ext_module = importlib.import_module(ext.module_name)
|
||||
@@ -287,6 +313,10 @@ def register_startup(app: FastAPI):
|
||||
|
||||
log_server_info()
|
||||
|
||||
# adds security middleware
|
||||
add_ratelimit_middleware(app)
|
||||
add_ip_block_middleware(app)
|
||||
|
||||
# initialize WALLET
|
||||
set_wallet_class()
|
||||
|
||||
@@ -296,6 +326,9 @@ def register_startup(app: FastAPI):
|
||||
# check extensions after restart
|
||||
await check_installed_extensions(app)
|
||||
|
||||
if settings.lnbits_admin_ui:
|
||||
initialize_server_logger()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
raise ImportError("Failed to run 'startup' event.")
|
||||
@@ -308,6 +341,25 @@ def register_shutdown(app: FastAPI):
|
||||
await WALLET.cleanup()
|
||||
|
||||
|
||||
def initialize_server_logger():
|
||||
|
||||
super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest()
|
||||
|
||||
serverlog_queue = asyncio.Queue()
|
||||
|
||||
async def update_websocket_serverlog():
|
||||
while True:
|
||||
msg = await serverlog_queue.get()
|
||||
await websocketUpdater(super_user_hash, msg)
|
||||
|
||||
asyncio.create_task(update_websocket_serverlog())
|
||||
|
||||
logger.add(
|
||||
lambda msg: serverlog_queue.put_nowait(msg),
|
||||
format=Formatter().format,
|
||||
)
|
||||
|
||||
|
||||
def log_server_info():
|
||||
logger.info("Starting LNbits")
|
||||
logger.info(f"Version: {settings.version}")
|
||||
@@ -346,10 +398,14 @@ def register_async_tasks(app):
|
||||
loop.create_task(catch_everything_and_restart(invoice_listener))
|
||||
loop.create_task(catch_everything_and_restart(internal_invoice_listener))
|
||||
await register_task_listeners()
|
||||
# await register_watchdog()
|
||||
await register_killswitch()
|
||||
# await run_deferred_async() # calle: doesn't do anyting?
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def stop_listeners():
|
||||
# await unregister_watchdog()
|
||||
await unregister_killswitch()
|
||||
pass
|
||||
|
||||
|
||||
@@ -428,7 +484,6 @@ def configure_logger() -> None:
|
||||
log_level: str = "DEBUG" if settings.debug else "INFO"
|
||||
formatter = Formatter()
|
||||
logger.add(sys.stderr, level=log_level, format=formatter.format)
|
||||
|
||||
logging.getLogger("uvicorn").handlers = [InterceptHandler()]
|
||||
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
|
||||
|
||||
|
||||
@@ -305,6 +305,11 @@ async def get_total_balance(conn: Optional[Connection] = None):
|
||||
return 0 if row[0] is None else row[0]
|
||||
|
||||
|
||||
async def get_active_wallet_total_balance(conn: Optional[Connection] = None):
|
||||
row = await (conn or db).fetchone("SELECT SUM(balance) FROM balances")
|
||||
return 0 if row[0] is None else row[0]
|
||||
|
||||
|
||||
# wallet payments
|
||||
# ---------------
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ class BalanceCheck(BaseModel):
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable
|
||||
register_new_ratelimiter: Callable
|
||||
|
||||
|
||||
class TinyURL(BaseModel):
|
||||
|
||||
+19
-1
@@ -21,6 +21,7 @@ from lnbits.settings import (
|
||||
get_wallet_class,
|
||||
readonly_variables,
|
||||
send_admin_user_to_saas,
|
||||
set_wallet_class,
|
||||
settings,
|
||||
)
|
||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||
@@ -37,6 +38,7 @@ from .crud import (
|
||||
get_account,
|
||||
get_standalone_payment,
|
||||
get_super_settings,
|
||||
get_total_balance,
|
||||
get_wallet,
|
||||
get_wallet_payment,
|
||||
update_payment_details,
|
||||
@@ -511,7 +513,6 @@ class WebsocketConnectionManager:
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
logger.debug(websocket)
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
@@ -528,3 +529,20 @@ websocketManager = WebsocketConnectionManager()
|
||||
|
||||
async def websocketUpdater(item_id, data):
|
||||
return await websocketManager.send_data(f"{data}", item_id)
|
||||
|
||||
|
||||
async def switch_to_voidwallet() -> None:
|
||||
WALLET = get_wallet_class()
|
||||
if WALLET.__class__.__name__ == "VoidWallet":
|
||||
return
|
||||
set_wallet_class("VoidWallet")
|
||||
settings.lnbits_backend_wallet_class = "VoidWallet"
|
||||
|
||||
|
||||
async def get_balance_delta() -> Tuple[int, int, int]:
|
||||
WALLET = get_wallet_class()
|
||||
total_balance = await get_total_balance()
|
||||
error_message, node_balance = await WALLET.status()
|
||||
if error_message:
|
||||
raise Exception(error_message)
|
||||
return node_balance - total_balance, node_balance, total_balance
|
||||
|
||||
+84
-2
@@ -1,20 +1,102 @@
|
||||
import asyncio
|
||||
from typing import Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.tasks import SseListenersDict, register_invoice_listener
|
||||
|
||||
from . import db
|
||||
from .crud import get_balance_notify, get_wallet
|
||||
from .models import Payment
|
||||
from .services import websocketUpdater
|
||||
from .services import get_balance_delta, switch_to_voidwallet, websocketUpdater
|
||||
|
||||
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
|
||||
"api_invoice_listeners"
|
||||
)
|
||||
|
||||
killswitch: Optional[asyncio.Task] = None
|
||||
watchdog: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
async def register_killswitch():
|
||||
"""
|
||||
Registers a killswitch which will check lnbits-status repository
|
||||
for a signal from LNbits and will switch to VoidWallet if the killswitch is triggered.
|
||||
"""
|
||||
logger.debug("Starting killswitch task")
|
||||
global killswitch
|
||||
killswitch = asyncio.create_task(killswitch_task())
|
||||
|
||||
|
||||
async def unregister_killswitch():
|
||||
"""
|
||||
Unregisters a killswitch taskl
|
||||
"""
|
||||
global killswitch
|
||||
if killswitch:
|
||||
logger.debug("Stopping killswitch task")
|
||||
killswitch.cancel()
|
||||
|
||||
|
||||
async def killswitch_task():
|
||||
while True:
|
||||
WALLET = get_wallet_class()
|
||||
if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet":
|
||||
with httpx.Client() as client:
|
||||
try:
|
||||
r = client.get(settings.lnbits_status_manifest, timeout=4)
|
||||
r.raise_for_status()
|
||||
if r.status_code == 200:
|
||||
ks = r.json().get("killswitch")
|
||||
if ks and ks == 1:
|
||||
logger.error(
|
||||
"Switching to VoidWallet. Killswitch triggered."
|
||||
)
|
||||
await switch_to_voidwallet()
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
logger.error(
|
||||
f"Cannot fetch lnbits status manifest. {settings.lnbits_status_manifest}"
|
||||
)
|
||||
await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
|
||||
|
||||
|
||||
async def register_watchdog():
|
||||
"""
|
||||
Registers a watchdog which will check lnbits balance and nodebalance
|
||||
and will switch to VoidWallet if the watchdog delta is reached.
|
||||
"""
|
||||
# TODO: implement watchdog porperly
|
||||
# logger.debug("Starting watchdog task")
|
||||
# global watchdog
|
||||
# watchdog = asyncio.create_task(watchdog_task())
|
||||
|
||||
|
||||
async def unregister_watchdog():
|
||||
"""
|
||||
Unregisters a watchdog task
|
||||
"""
|
||||
global watchdog
|
||||
if watchdog:
|
||||
logger.debug("Stopping watchdog task")
|
||||
watchdog.cancel()
|
||||
|
||||
|
||||
async def watchdog_task():
|
||||
while True:
|
||||
WALLET = get_wallet_class()
|
||||
if settings.lnbits_watchdog and WALLET.__class__.__name__ != "VoidWallet":
|
||||
try:
|
||||
delta, *_ = await get_balance_delta()
|
||||
logger.debug(f"Running watchdog task. current delta: {delta}")
|
||||
if delta + settings.lnbits_watchdog_delta <= 0:
|
||||
logger.error(f"Switching to VoidWallet. current delta: {delta}")
|
||||
await switch_to_voidwallet()
|
||||
except Exception as e:
|
||||
logger.error("Error in watchdog task", e)
|
||||
await asyncio.sleep(settings.lnbits_watchdog_interval * 60)
|
||||
|
||||
|
||||
async def register_task_listeners():
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,18 @@
|
||||
<ul>
|
||||
{%raw%}
|
||||
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li>
|
||||
<li>Balance: {{balance / 1000}} sats</li>
|
||||
<li>
|
||||
Node Balance: {{(auditData.node_balance_msats /
|
||||
1000).toLocaleString()}} sats
|
||||
</li>
|
||||
<li>
|
||||
LNbits Balance: {{(auditData.lnbits_balance_msats /
|
||||
1000).toLocaleString()}} sats
|
||||
</li>
|
||||
<li>
|
||||
Reserve Percent: {{(auditData.node_balance_msats /
|
||||
auditData.lnbits_balance_msats * 100).toFixed(2)}} %
|
||||
</li>
|
||||
{%endraw%}
|
||||
</ul>
|
||||
<br />
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<q-tab-panel name="security">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none" v-text="$t('security_tools')"></h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row">
|
||||
<div v-if="serverlogEnabled" class="column" style="width: 100%">
|
||||
<div
|
||||
class="col bg-primary"
|
||||
style="padding-left: 5px; max-height: 20px; color: #fafafa"
|
||||
v-text="$t('server_logs')"
|
||||
></div>
|
||||
<div class="col" style="background-color: #292929">
|
||||
<q-scroll-area
|
||||
ref="logScroll"
|
||||
style="padding: 10px; color: #fafafa; height: 320px"
|
||||
>
|
||||
<small v-for="log in logs"
|
||||
>{% raw %}{{ log }}{% endraw %}<br
|
||||
/></small>
|
||||
</q-scroll-area>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
@click="toggleServerLog"
|
||||
dense
|
||||
flat
|
||||
color="primary"
|
||||
:label="(serverlogEnabled) ? $t('disable_server_log') : $t('enable_server_log')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-12">
|
||||
<p v-text="$t('ip_blocker')"></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formBlockedIPs"
|
||||
@keydown.enter="addBlockedIPs"
|
||||
type="text"
|
||||
:label="$t('enter_ip')"
|
||||
:hint="$t('block_access_hint')"
|
||||
>
|
||||
<q-btn
|
||||
@click="addExtensionsManifest"
|
||||
dense
|
||||
flat
|
||||
icon="add"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="blocked_ip in formData.lnbits_blocked_ips"
|
||||
:key="blocked_ip"
|
||||
removable
|
||||
@remove="removeBlockedIPs(blocked_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ blocked_ip }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAllowedIPs"
|
||||
@keydown.enter="addAllowedIPs"
|
||||
type="text"
|
||||
:label="$t('enter_ip')"
|
||||
:hint="$t('allow_access_hint')"
|
||||
>
|
||||
<q-btn
|
||||
@click="addExtensionsManifest"
|
||||
dense
|
||||
flat
|
||||
icon="add"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="allowed_ip in formData.lnbits_allowed_ips"
|
||||
:key="allowed_ip"
|
||||
removable
|
||||
@remove="removeAllowedIPs(allowed_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ allowed_ip }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-12">
|
||||
<p v-text="$t('rate_limiter')"></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_rate_limit_no"
|
||||
:label="$t('number_of_requests')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-select
|
||||
filled
|
||||
:options="[$t('second'),$t('minute'),$t('hour')]"
|
||||
v-model="formData.lnbits_rate_limit_unit"
|
||||
:label="$t('time_unit')"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_notifications')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_notifications_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_notifications"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
<p
|
||||
v-if="!formData.lnbits_notifications"
|
||||
v-text="$t('notifications_disabled')"
|
||||
></p>
|
||||
<div v-if="formData.lnbits_notifications">
|
||||
{% include "admin/_tab_security_notifications.html" %}
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<p v-text="$t('notification_source')"></p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_status_manifest"
|
||||
type="text"
|
||||
:label="$t('notification_source_label')"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p v-text="$t('killswitch')"></p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_killswitch_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_killswitch"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('killswitch_interval_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_killswitch_interval"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
<p v-text="$t('watchdog')"></p>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_watchdog_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_watchdog"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('watchdog_interval_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_watchdog_interval"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('watchdog_delta_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_watchdog_delta"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,70 @@
|
||||
{% raw %}
|
||||
<q-banner v-if="updateAvailable" class="bg-primary text-white">
|
||||
<q-icon size="28px" name="update"></q-icon>
|
||||
|
||||
<span v-text="$t('update_available', {version: statusData.version})"></span>
|
||||
<template v-slot:action>
|
||||
<a
|
||||
class="q-btn"
|
||||
color="white"
|
||||
target="_blank"
|
||||
href="https://github.com/lnbits/lnbits/releases"
|
||||
v-text="$t('releases')"
|
||||
></a>
|
||||
</template>
|
||||
</q-banner>
|
||||
<q-banner v-if="!updateAvailable" class="bg-green text-white">
|
||||
<q-icon size="28px" name="checknark"></q-icon>
|
||||
<span v-text="$t('latest_update', {version: statusData.version})"></span>
|
||||
</q-banner>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="statusData.notifications"
|
||||
:columns="statusDataTable.columns"
|
||||
:no-data-label="$t('no_notifications')"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width> </q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props"
|
||||
>{{ col.label }}</q-th
|
||||
>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
v-if="props.row.type === 'update'"
|
||||
size="18px"
|
||||
name="update"
|
||||
color="green"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-if="props.row.type === 'warning'"
|
||||
size="18px"
|
||||
name="warning"
|
||||
color="red"
|
||||
></q-icon>
|
||||
</q-td>
|
||||
<q-td auto-width key="date" :props="props">
|
||||
{{ formatDate(props.row.date) }}
|
||||
</q-td>
|
||||
<q-td key="message" :props="props"
|
||||
>{{ props.row.message }}
|
||||
<a
|
||||
v-if="props.row.link"
|
||||
target="_blank"
|
||||
:href="props.row.link"
|
||||
v-text="$t('more')"
|
||||
></a
|
||||
></q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endraw %}
|
||||
@@ -88,6 +88,12 @@
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="security"
|
||||
:label="$t('security')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="theme"
|
||||
:label="$t('theme')"
|
||||
@@ -101,7 +107,8 @@
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
|
||||
include "admin/_tab_theme.html" %}
|
||||
include "admin/_tab_security.html" %} {% include
|
||||
"admin/_tab_theme.html" %}
|
||||
</q-tab-panels>
|
||||
</q-form>
|
||||
</q-card>
|
||||
@@ -164,6 +171,8 @@
|
||||
data: function () {
|
||||
return {
|
||||
settings: {},
|
||||
logs: [],
|
||||
serverlogEnabled: false,
|
||||
lnbits_theme_options: [
|
||||
'classic',
|
||||
'bitcoin',
|
||||
@@ -175,10 +184,30 @@
|
||||
'monochrome',
|
||||
'salvador'
|
||||
],
|
||||
auditData: {},
|
||||
statusData: {},
|
||||
statusDataTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: this.$t('date'),
|
||||
field: 'date'
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
align: 'left',
|
||||
label: this.$t('memo'),
|
||||
field: 'message'
|
||||
}
|
||||
]
|
||||
},
|
||||
formData: {},
|
||||
formAddAdmin: '',
|
||||
formAddUser: '',
|
||||
formAddExtensionsManifest: '',
|
||||
formAllowedIPs: '',
|
||||
formBlockedIPs: '',
|
||||
isSuperUser: false,
|
||||
wallet: {},
|
||||
cancel: {},
|
||||
@@ -349,11 +378,18 @@
|
||||
},
|
||||
created: function () {
|
||||
this.getSettings()
|
||||
this.getAudit()
|
||||
this.balance = +'{{ balance|safe }}'
|
||||
},
|
||||
computed: {
|
||||
lnbitsVersion() {
|
||||
return LNBITS_VERSION
|
||||
},
|
||||
checkChanges() {
|
||||
return !_.isEqual(this.settings, this.formData)
|
||||
},
|
||||
updateAvailable() {
|
||||
return LNBITS_VERSION !== this.statusData.version
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -364,7 +400,6 @@
|
||||
//admin_users = [...admin_users, addUser]
|
||||
this.formData.lnbits_admin_users = [...admin_users, addUser]
|
||||
this.formAddAdmin = ''
|
||||
//console.log(this.checkChanges)
|
||||
}
|
||||
},
|
||||
removeAdminUser(user) {
|
||||
@@ -406,6 +441,68 @@
|
||||
m => m !== manifest
|
||||
)
|
||||
},
|
||||
async toggleServerLog() {
|
||||
this.serverlogEnabled = !this.serverlogEnabled
|
||||
if (this.serverlogEnabled) {
|
||||
const wsProto = location.protocol !== 'http:' ? 'wss://' : 'ws://'
|
||||
const digestHex = await LNbits.utils.digestMessage(this.g.user.id)
|
||||
const localUrl =
|
||||
wsProto +
|
||||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/api/v1/ws/' +
|
||||
digestHex
|
||||
this.ws = new WebSocket(localUrl)
|
||||
this.ws.addEventListener('message', async ({data}) => {
|
||||
this.logs.push(data.toString())
|
||||
const scrollArea = this.$refs.logScroll
|
||||
if (scrollArea) {
|
||||
const scrollTarget = scrollArea.getScrollTarget()
|
||||
const duration = 0
|
||||
scrollArea.setScrollPosition(scrollTarget.scrollHeight, duration)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.ws.close()
|
||||
}
|
||||
},
|
||||
addAllowedIPs() {
|
||||
const allowedIPs = this.formAllowedIPs.trim()
|
||||
const allowed_ips = this.formData.lnbits_allowed_ips
|
||||
if (
|
||||
allowedIPs &&
|
||||
allowedIPs.length &&
|
||||
!allowed_ips.includes(allowedIPs)
|
||||
) {
|
||||
this.formData.lnbits_allowed_ips = [...allowed_ips, allowedIPs]
|
||||
this.formAllowedIPs = ''
|
||||
}
|
||||
},
|
||||
removeAllowedIPs(allowed_ip) {
|
||||
const allowed_ips = this.formData.lnbits_allowed_ips
|
||||
this.formData.lnbits_allowed_ips = allowed_ips.filter(
|
||||
a => a !== allowed_ip
|
||||
)
|
||||
},
|
||||
addBlockedIPs() {
|
||||
const blockedIPs = this.formBlockedIPs.trim()
|
||||
const blocked_ips = this.formData.lnbits_blocked_ips
|
||||
if (
|
||||
blockedIPs &&
|
||||
blockedIPs.length &&
|
||||
!blocked_ips.includes(blockedIPs)
|
||||
) {
|
||||
this.formData.lnbits_blocked_ips = [...blocked_ips, blockedIPs]
|
||||
this.formBlockedIPs = ''
|
||||
}
|
||||
},
|
||||
removeBlockedIPs(blocked_ip) {
|
||||
const blocked_ips = this.formData.lnbits_blocked_ips
|
||||
this.formData.lnbits_blocked_ips = blocked_ips.filter(
|
||||
b => b !== blocked_ip
|
||||
)
|
||||
},
|
||||
restartServer() {
|
||||
LNbits.api
|
||||
.request('GET', '/admin/api/v1/restart/?usr=' + this.g.user.id)
|
||||
@@ -449,12 +546,43 @@
|
||||
this.settings.lnbits_allowed_funding_sources.map(f => {
|
||||
let opts = this.funding_sources.get(f)
|
||||
if (!opts) return
|
||||
|
||||
Object.keys(opts).forEach(e => {
|
||||
opts[e].value = this.settings[e]
|
||||
})
|
||||
})
|
||||
},
|
||||
formatDate(date) {
|
||||
return moment(date * 1000).fromNow()
|
||||
},
|
||||
getNotifications() {
|
||||
if (this.settings.lnbits_notifications) {
|
||||
axios
|
||||
.get(this.settings.lnbits_status_manifest)
|
||||
.then(response => {
|
||||
this.statusData = response.data
|
||||
})
|
||||
.catch(error => {
|
||||
this.formData.lnbits_notifications = false
|
||||
error.response.data = {}
|
||||
error.response.data.message = 'Could not fetch status manifest.'
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
getAudit() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/admin/api/v1/audit/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.auditData = response.data
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getSettings() {
|
||||
LNbits.api
|
||||
.request(
|
||||
@@ -467,6 +595,7 @@
|
||||
this.settings = response.data
|
||||
this.formData = _.clone(this.settings)
|
||||
this.updateFundingData()
|
||||
this.getNotifications()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
|
||||
@@ -12,15 +12,35 @@ from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.services import update_cached_settings, update_wallet_balance
|
||||
from lnbits.core.services import (
|
||||
get_balance_delta,
|
||||
update_cached_settings,
|
||||
update_wallet_balance,
|
||||
)
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, EditableSettings, settings
|
||||
|
||||
from .. import core_app
|
||||
from .. import core_app, core_app_extra
|
||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||
|
||||
|
||||
@core_app.get("/admin/api/v1/audit", dependencies=[Depends(check_admin)])
|
||||
async def api_auditor():
|
||||
try:
|
||||
delta, node_balance, total_balance = await get_balance_delta()
|
||||
return {
|
||||
"delta_msats": int(delta),
|
||||
"node_balance_msats": int(node_balance),
|
||||
"lnbits_balance_msats": int(total_balance),
|
||||
}
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Could not audit balance.",
|
||||
)
|
||||
|
||||
|
||||
@core_app.get("/admin/api/v1/settings/")
|
||||
async def api_get_settings(
|
||||
user: User = Depends(check_admin),
|
||||
@@ -40,6 +60,7 @@ async def api_update_settings(
|
||||
admin_settings = await get_admin_settings(user.super_user)
|
||||
assert admin_settings, "Updated admin settings not found."
|
||||
update_cached_settings(admin_settings.dict())
|
||||
core_app_extra.register_new_ratelimiter()
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@@ -78,6 +99,11 @@ async def api_topup_balance(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="wallet does not exist."
|
||||
)
|
||||
|
||||
if settings.lnbits_backend_wallet_class == "VoidWallet":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="VoidWallet active"
|
||||
)
|
||||
|
||||
await update_wallet_balance(wallet_id=id, amount=int(amount))
|
||||
|
||||
return {"status": "Success"}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
@@ -52,7 +51,7 @@ from lnbits.extension_manager import (
|
||||
get_valid_extensions,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi, url_for
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
@@ -73,7 +72,6 @@ from ..crud import (
|
||||
get_standalone_payment,
|
||||
get_tinyurl,
|
||||
get_tinyurl_by_url,
|
||||
get_total_balance,
|
||||
get_wallet_for_key,
|
||||
save_balance_check,
|
||||
update_wallet,
|
||||
@@ -726,25 +724,6 @@ async def img(request: Request, data):
|
||||
)
|
||||
|
||||
|
||||
@core_app.get("/api/v1/audit", dependencies=[Depends(check_admin)])
|
||||
async def api_auditor():
|
||||
WALLET = get_wallet_class()
|
||||
total_balance = await get_total_balance()
|
||||
error_message, node_balance = await WALLET.status()
|
||||
|
||||
if not error_message:
|
||||
delta = node_balance - total_balance
|
||||
else:
|
||||
node_balance, delta = 0, 0
|
||||
|
||||
return {
|
||||
"node_balance_msats": int(node_balance),
|
||||
"lnbits_balance_msats": int(total_balance),
|
||||
"delta_msats": int(delta),
|
||||
"timestamp": int(time.time()),
|
||||
}
|
||||
|
||||
|
||||
# UNIVERSAL WEBSOCKET MANAGER
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,14 @@ from http import HTTPStatus
|
||||
from typing import Any, List, Tuple, Union
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from lnbits.core import core_app_extra
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -189,3 +194,30 @@ class ExtensionsRedirectMiddleware:
|
||||
]
|
||||
|
||||
return "/" + "/".join(elements)
|
||||
|
||||
|
||||
def add_ratelimit_middleware(app: FastAPI):
|
||||
core_app_extra.register_new_ratelimiter()
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
|
||||
def add_ip_block_middleware(app: FastAPI):
|
||||
@app.middleware("http")
|
||||
async def block_allow_ip_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
if not request.client:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "No request client"},
|
||||
)
|
||||
if request.client.host in settings.lnbits_allowed_ips:
|
||||
return response
|
||||
if request.client.host in settings.lnbits_blocked_ips:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "IP is blocked"},
|
||||
)
|
||||
return response
|
||||
|
||||
app.middleware("http")(block_allow_ip_middleware)
|
||||
|
||||
@@ -101,6 +101,22 @@ class OpsSettings(LNbitsSettings):
|
||||
lnbits_denomination: str = Field(default="sats")
|
||||
|
||||
|
||||
class SecuritySettings(LNbitsSettings):
|
||||
lnbits_rate_limit_no: str = Field(default="200")
|
||||
lnbits_rate_limit_unit: str = Field(default="minute")
|
||||
lnbits_allowed_ips: List[str] = Field(default=[])
|
||||
lnbits_blocked_ips: List[str] = Field(default=[])
|
||||
lnbits_notifications: bool = Field(default=False)
|
||||
lnbits_killswitch: bool = Field(default=False)
|
||||
lnbits_killswitch_interval: int = Field(default=60)
|
||||
lnbits_watchdog: bool = Field(default=False)
|
||||
lnbits_watchdog_interval: int = Field(default=60)
|
||||
lnbits_watchdog_delta: int = Field(default=1_000_000)
|
||||
lnbits_status_manifest: str = Field(
|
||||
default="https://raw.githubusercontent.com/lnbits/lnbits-status/main/manifest.json"
|
||||
)
|
||||
|
||||
|
||||
class FakeWalletFundingSource(LNbitsSettings):
|
||||
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
||||
|
||||
@@ -207,6 +223,7 @@ class EditableSettings(
|
||||
ExtensionsSettings,
|
||||
ThemesSettings,
|
||||
OpsSettings,
|
||||
SecuritySettings,
|
||||
FundingSourcesSettings,
|
||||
BoltzExtensionSettings,
|
||||
LightningSettings,
|
||||
|
||||
@@ -122,5 +122,52 @@ window.localisation.en = {
|
||||
description: 'Description',
|
||||
expiry: 'Expiry',
|
||||
webhook: 'Webhook',
|
||||
payment_proof: 'Payment Proof'
|
||||
payment_proof: 'Payment Proof',
|
||||
update_available: 'Update %{version} available!',
|
||||
latest_update: 'You are on the latest version %{version}.',
|
||||
notifications: 'Notifications',
|
||||
no_notifications: 'No notifications',
|
||||
notifications_disabled: 'LNbits status notifications are disabled.',
|
||||
enable_notifications: 'Enable Notifications',
|
||||
enable_notifications_desc:
|
||||
'If enabled it will fetch the latest LNbits Status updates, like security incidents and updates.',
|
||||
enable_killswitch: 'Enable Killswitch',
|
||||
enable_killswitch_desc:
|
||||
'If enabled it will change your funding source to VoidWallet automatically if LNbits sends out a killswitch signal. You will need to enable manually after an update.',
|
||||
killswitch_interval: 'Killswitch Interval',
|
||||
killswitch_interval_desc:
|
||||
'How often the background task should check for the LNBits killswitch signal from the status source (in minutes).',
|
||||
enable_watchdog: 'Enable Watchdog',
|
||||
enable_watchdog_desc:
|
||||
'If enabled it will change your funding source to VoidWallet automatically if your balance is lower than the LNbits balance. You will need to enable manually after an update.',
|
||||
watchdog_interval: 'Watchdog Interval',
|
||||
watchdog_interval_desc:
|
||||
'How often the background task should check for a killswitch signal in the watchdog delta [node_balance - lnbits_balance] (in minutes).',
|
||||
watchdog_delta: 'Watchdog Delta',
|
||||
watchdog_delta_desc:
|
||||
'Limit before killswitch changes funding source to VoidWallet [lnbits_balance - node_balance > delta]',
|
||||
status: 'Status',
|
||||
notification_source: 'Notification Source',
|
||||
notification_source_label:
|
||||
'Source URL (only use the official LNbits status source, and sources you can trust)',
|
||||
more: 'more',
|
||||
releases: 'Releases',
|
||||
killswitch: 'Killswitch',
|
||||
watchdog: 'Watchdog',
|
||||
server_logs: 'Server Logs',
|
||||
ip_blocker: 'IP Blocker',
|
||||
security: 'Security',
|
||||
security_tools: 'Security tools',
|
||||
block_access_hint: 'Block access by IP',
|
||||
allow_access_hint: 'Allow access by IP (will override blocked IPs)',
|
||||
enter_ip: 'Enter IP and hit enter',
|
||||
rate_limiter: 'Rate Limiter',
|
||||
number_of_requests: 'Number of requests',
|
||||
time_unit: 'Time unit',
|
||||
minute: 'minute',
|
||||
second: 'second',
|
||||
hour: 'hour',
|
||||
disable_server_log: 'Disable Server Log',
|
||||
enable_server_log: 'Enable Server Log',
|
||||
coming_soon: 'Feature coming soon'
|
||||
}
|
||||
|
||||
@@ -241,6 +241,15 @@ window.LNbits = {
|
||||
}
|
||||
})
|
||||
},
|
||||
digestMessage: async function (message) {
|
||||
const msgUint8 = new TextEncoder().encode(message)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
return hashHex
|
||||
},
|
||||
formatCurrency: function (value, currency) {
|
||||
return new Intl.NumberFormat(window.LOCALE, {
|
||||
style: 'currency',
|
||||
|
||||
@@ -295,6 +295,7 @@
|
||||
<script type="text/javascript">
|
||||
const themes = {{ LNBITS_THEME_OPTIONS | tojson }}
|
||||
const LNBITS_DENOMINATION = {{ LNBITS_DENOMINATION | tojson }}
|
||||
const LNBITS_VERSION = {{ LNBITS_VERSION | tojson }}
|
||||
if (themes && themes.length) {
|
||||
window.allowedThemes = themes.map(str => str.trim())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user