diff --git a/.env.example b/.env.example
index 1dd640354..7b6e852e3 100644
--- a/.env.example
+++ b/.env.example
@@ -8,6 +8,12 @@ PORT=5000
DEBUG=false
+# Server security, rate limiting ips, blocked ips, allowed ips
+LNBITS_RATE_LIMIT_NO="200"
+LNBITS_RATE_LIMIT_UNIT="minute"
+LNBITS_ALLOWED_IPS=""
+LNBITS_BLOCKED_IPS=""
+
# Allow users and admins by user IDs (comma separated list)
LNBITS_ALLOWED_USERS=""
LNBITS_ADMIN_USERS=""
diff --git a/lnbits/app.py b/lnbits/app.py
index d3dfa4407..30431229d 100644
--- a/lnbits/app.py
+++ b/lnbits/app.py
@@ -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()]
diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py
index b82fde8b3..fd608004a 100644
--- a/lnbits/core/crud.py
+++ b/lnbits/core/crud.py
@@ -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
# ---------------
diff --git a/lnbits/core/models.py b/lnbits/core/models.py
index dae4a1e0c..569656f36 100644
--- a/lnbits/core/models.py
+++ b/lnbits/core/models.py
@@ -244,6 +244,7 @@ class BalanceCheck(BaseModel):
class CoreAppExtra:
register_new_ext_routes: Callable
+ register_new_ratelimiter: Callable
class TinyURL(BaseModel):
diff --git a/lnbits/core/services.py b/lnbits/core/services.py
index e1ccd152a..d444d254a 100644
--- a/lnbits/core/services.py
+++ b/lnbits/core/services.py
@@ -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
diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py
index 7ff407d92..4b397b100 100644
--- a/lnbits/core/tasks.py
+++ b/lnbits/core/tasks.py
@@ -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():
"""
diff --git a/lnbits/core/templates/admin/_tab_funding.html b/lnbits/core/templates/admin/_tab_funding.html
index 9fe3e8315..6ceefc82d 100644
--- a/lnbits/core/templates/admin/_tab_funding.html
+++ b/lnbits/core/templates/admin/_tab_funding.html
@@ -9,7 +9,18 @@
{%raw%}
- Funding Source: {{settings.lnbits_backend_wallet_class}}
- - Balance: {{balance / 1000}} sats
+ -
+ Node Balance: {{(auditData.node_balance_msats /
+ 1000).toLocaleString()}} sats
+
+ -
+ LNbits Balance: {{(auditData.lnbits_balance_msats /
+ 1000).toLocaleString()}} sats
+
+ -
+ Reserve Percent: {{(auditData.node_balance_msats /
+ auditData.lnbits_balance_msats * 100).toFixed(2)}} %
+
{%endraw%}
diff --git a/lnbits/core/templates/admin/_tab_security.html b/lnbits/core/templates/admin/_tab_security.html
new file mode 100644
index 000000000..0b62f1ae0
--- /dev/null
+++ b/lnbits/core/templates/admin/_tab_security.html
@@ -0,0 +1,264 @@
+
+
+
+
+
+
+
+
+
+
+ {% raw %}{{ log }}{% endraw %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {%raw%}
+
+ {{ blocked_ip }}
+
+ {%endraw%}
+
+
+
+
+
+
+
+
+ {%raw%}
+
+ {{ allowed_ip }}
+
+ {%endraw%}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% include "admin/_tab_security_notifications.html" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lnbits/core/templates/admin/_tab_security_notifications.html b/lnbits/core/templates/admin/_tab_security_notifications.html
new file mode 100644
index 000000000..8f59133cf
--- /dev/null
+++ b/lnbits/core/templates/admin/_tab_security_notifications.html
@@ -0,0 +1,70 @@
+{% raw %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+ {{ formatDate(props.row.date) }}
+
+ {{ props.row.message }}
+
+
+
+
+
+
+{% endraw %}
diff --git a/lnbits/core/templates/admin/index.html b/lnbits/core/templates/admin/index.html
index 1d0e34398..ab6000e0a 100644
--- a/lnbits/core/templates/admin/index.html
+++ b/lnbits/core/templates/admin/index.html
@@ -88,6 +88,12 @@
@update="val => tab = val.name"
>
+ tab = val.name"
+ >
+
{% 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" %}
@@ -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)
diff --git a/lnbits/core/views/admin_api.py b/lnbits/core/views/admin_api.py
index b73c549cc..0f63800ab 100644
--- a/lnbits/core/views/admin_api.py
+++ b/lnbits/core/views/admin_api.py
@@ -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"}
diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py
index 5b1989b4d..c6bea8241 100644
--- a/lnbits/core/views/api.py
+++ b/lnbits/core/views/api.py
@@ -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
diff --git a/lnbits/middleware.py b/lnbits/middleware.py
index 2815ddde9..5911adb78 100644
--- a/lnbits/middleware.py
+++ b/lnbits/middleware.py
@@ -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)
diff --git a/lnbits/settings.py b/lnbits/settings.py
index adec59510..be09b6f99 100644
--- a/lnbits/settings.py
+++ b/lnbits/settings.py
@@ -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,
diff --git a/lnbits/static/i18n/en.js b/lnbits/static/i18n/en.js
index ed53831f3..93b639dd4 100644
--- a/lnbits/static/i18n/en.js
+++ b/lnbits/static/i18n/en.js
@@ -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'
}
diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js
index f5ff33507..e8b500aca 100644
--- a/lnbits/static/js/base.js
+++ b/lnbits/static/js/base.js
@@ -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',
diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html
index 4874dc3f1..e5dc93d81 100644
--- a/lnbits/templates/base.html
+++ b/lnbits/templates/base.html
@@ -295,6 +295,7 @@