Skip to content

Commit

Permalink
Mint: Add slowapi (#481)
Browse files Browse the repository at this point in the history
* Add slowapi

* fix startup

* adjust settings

* add rate limits to tx routes

* elastic
  • Loading branch information
callebtc committed Mar 23, 2024
1 parent b288a6d commit adeec00
Show file tree
Hide file tree
Showing 9 changed files with 370 additions and 97 deletions.
17 changes: 13 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,19 @@ LIGHTNING_FEE_PERCENT=1.0
# minimum fee to reserve
LIGHTNING_RESERVE_FEE_MIN=2000

# Management
# max peg-in amount in satoshis
# Limits

# Max peg-in amount in satoshis
# MINT_MAX_PEG_IN=100000
# max peg-out amount in satoshis
# Max peg-out amount in satoshis
# MINT_MAX_PEG_OUT=100000
# use to allow only peg-out to LN
# Use to allow only peg-out to LN
# MINT_PEG_OUT_ONLY=FALSE

# Rate limit requests to mint. Make sure that you can see request IPs in the logs.
# You may need to adjust your reverse proxy if you only see requests originating from 127.0.0.1
# MINT_RATE_LIMIT=TRUE
# Determines the number of all requests allowed per minute per IP
# MINT_GLOBAL_RATE_LIMIT_PER_MINUTE=60
# Determines the number of transactions (mint, melt, swap) allowed per minute per IP
# MINT_TRANSACTION_RATE_LIMIT_PER_MINUTE=20
67 changes: 45 additions & 22 deletions cashu/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,51 @@ class MintSettings(CashuSettings):
mint_listen_host: str = Field(default="127.0.0.1")
mint_listen_port: int = Field(default=3338)

mint_database: str = Field(default="data/mint")
mint_test_database: str = Field(default="test_data/test_mint")
mint_duplicate_keysets: bool = Field(
default=True,
title="Duplicate keysets",
description=(
"Whether to duplicate keysets for backwards compatibility before v1 API"
" (Nutshell 0.15.0)."
),
)


class MintBackends(MintSettings):
mint_lightning_backend: str = Field(default="") # deprecated
mint_backend_bolt11_sat: str = Field(default="")
mint_backend_bolt11_usd: str = Field(default="")

mint_database: str = Field(default="data/mint")
mint_test_database: str = Field(default="test_data/test_mint")
mint_lnbits_endpoint: str = Field(default=None)
mint_lnbits_key: str = Field(default=None)
mint_strike_key: str = Field(default=None)
mint_blink_key: str = Field(default=None)


class MintLimits(MintSettings):
mint_rate_limit: bool = Field(
default=False, title="Rate limit", description="IP-based rate limiter."
)
mint_global_rate_limit_per_minute: int = Field(
default=60,
gt=0,
title="Global rate limit per minute",
description="Number of requests an IP can make per minute to all endpoints.",
)
mint_transaction_rate_limit_per_minute: int = Field(
default=20,
gt=0,
title="Transaction rate limit per minute",
description="Number of requests an IP can make per minute to transaction endpoints.",
)
mint_max_request_length: int = Field(
default=1000,
title="Maximum request length",
description="Maximum length of REST API request arrays.",
)

mint_peg_out_only: bool = Field(
default=False,
title="Peg-out only",
Expand All @@ -77,27 +116,9 @@ class MintSettings(CashuSettings):
title="Maximum peg-out",
description="Maximum amount for a melt operation.",
)
mint_max_request_length: int = Field(
default=1000,
title="Maximum request length",
description="Maximum length of REST API request arrays.",
)
mint_max_balance: int = Field(
default=None, title="Maximum mint balance", description="Maximum mint balance."
)
mint_duplicate_keysets: bool = Field(
default=True,
title="Duplicate keysets",
description=(
"Whether to duplicate keysets for backwards compatibility before v1 API"
" (Nutshell 0.15.0)."
),
)

mint_lnbits_endpoint: str = Field(default=None)
mint_lnbits_key: str = Field(default=None)
mint_strike_key: str = Field(default=None)
mint_blink_key: str = Field(default=None)


class FakeWalletSettings(MintSettings):
Expand Down Expand Up @@ -138,13 +159,13 @@ class WalletSettings(CashuSettings):
"wss://relay.damus.io",
"wss://nostr.mom",
"wss://relay.snort.social",
"wss://nostr.fmt.wiz.biz",
"wss://nostr.mutinywallet.com",
"wss://relay.minibits.cash",
"wss://nos.lol",
"wss://relay.nostr.band",
"wss://relay.bitcoiner.social",
"wss://140.f7z.io",
"wss://relayable.org",
"wss://relay.primal.net",
]
)

Expand All @@ -171,6 +192,8 @@ class Settings(
LndRestFundingSource,
CoreLightningRestFundingSource,
FakeWalletSettings,
MintLimits,
MintBackends,
MintSettings,
MintInformation,
WalletSettings,
Expand Down
84 changes: 22 additions & 62 deletions cashu/mint/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,9 @@
from traceback import print_exception

from fastapi import FastAPI, status
from fastapi.exception_handlers import (
request_validation_exception_handler as _request_validation_exception_handler,
)
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from loguru import logger
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request

from ..core.errors import CashuError
Expand All @@ -20,43 +15,26 @@
from .startup import start_mint_init

if settings.debug_profiling:
from fastapi_profiler import PyInstrumentProfilerMiddleware
pass

# from starlette_context import context
# from starlette_context.middleware import RawContextMiddleware
if settings.mint_rate_limit:
pass

from .middleware import add_middlewares, request_validation_exception_handler

# class CustomHeaderMiddleware(BaseHTTPMiddleware):
# """
# Middleware for starlette that can set the context from request headers
# """

# async def dispatch(self, request, call_next):
# context["client-version"] = request.headers.get("Client-version")
# response = await call_next(request)
# return response
# this errors with the tests but is the appropriate way to handle startup and shutdown
# until then, we use @app.on_event("startup")
# @asynccontextmanager
# async def lifespan(app: FastAPI):
# # startup routines here
# await start_mint_init()
# yield
# # shutdown routines here


def create_app(config_object="core.settings") -> FastAPI:
configure_logger()

# middleware = [
# Middleware(
# RawContextMiddleware,
# ),
# Middleware(CustomHeaderMiddleware),
# ]

middleware = [
Middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)
]

app = FastAPI(
title="Nutshell Cashu Mint",
description="Ecash wallet and mint based on the Cashu protocol.",
Expand All @@ -65,18 +43,16 @@ def create_app(config_object="core.settings") -> FastAPI:
"name": "MIT License",
"url": "https://raw.githubusercontent.com/cashubtc/cashu/main/LICENSE",
},
middleware=middleware,
)

if settings.debug_profiling:
assert PyInstrumentProfilerMiddleware is not None
app.add_middleware(PyInstrumentProfilerMiddleware)

return app


app = create_app()

# Add middlewares
add_middlewares(app)


@app.middleware("http")
async def catch_exceptions(request: Request, call_next):
Expand Down Expand Up @@ -113,33 +89,17 @@ async def catch_exceptions(request: Request, call_next):
)


async def request_validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""
This is a wrapper to the default RequestValidationException handler of FastAPI.
This function will be called when client input is not valid.
"""
query_params = request.query_params._dict
detail = {
"errors": exc.errors(),
"query_params": query_params,
}
# log the error
logger.error(detail)
# pass on
return await _request_validation_exception_handler(request, exc)


@app.on_event("startup")
async def startup_mint():
await start_mint_init()

# Add exception handlers
app.add_exception_handler(RequestValidationError, request_validation_exception_handler)

# Add routers
if settings.debug_mint_only_deprecated:
app.include_router(router=router_deprecated, tags=["Deprecated"], deprecated=True)
else:
app.include_router(router=router, tags=["Mint"])
app.include_router(router=router_deprecated, tags=["Deprecated"], deprecated=True)

app.add_exception_handler(RequestValidationError, request_validation_exception_handler)

@app.on_event("startup")
async def startup_mint():
await start_mint_init()
41 changes: 41 additions & 0 deletions cashu/mint/limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from fastapi import status
from fastapi.responses import JSONResponse
from loguru import logger
from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.requests import Request

from ..core.settings import settings


def _rate_limit_exceeded_handler(request: Request, exc: Exception) -> JSONResponse:
remote_address = get_remote_address(request)
logger.warning(
f"Rate limit {settings.mint_global_rate_limit_per_minute}/minute exceeded: {remote_address}"
)
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={"detail": "Rate limit exceeded."},
)


def get_remote_address_excluding_local(request: Request) -> str:
remote_address = get_remote_address(request)
if remote_address == "127.0.0.1":
return ""
return remote_address


limiter_global = Limiter(
key_func=get_remote_address_excluding_local,
strategy="fixed-window-elastic-expiry",
default_limits=[f"{settings.mint_global_rate_limit_per_minute}/minute"],
enabled=settings.mint_rate_limit,
)

limiter = Limiter(
key_func=get_remote_address_excluding_local,
strategy="fixed-window-elastic-expiry",
default_limits=[f"{settings.mint_transaction_rate_limit_per_minute}/minute"],
enabled=settings.mint_rate_limit,
)
55 changes: 55 additions & 0 deletions cashu/mint/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from fastapi import FastAPI
from fastapi.exception_handlers import (
request_validation_exception_handler as _request_validation_exception_handler,
)
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from loguru import logger
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request

from ..core.settings import settings
from .limit import _rate_limit_exceeded_handler, limiter_global

if settings.debug_profiling:
from fastapi_profiler import PyInstrumentProfilerMiddleware

from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware


def add_middlewares(app: FastAPI):
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)

if settings.debug_profiling:
assert PyInstrumentProfilerMiddleware is not None
app.add_middleware(PyInstrumentProfilerMiddleware)

if settings.mint_rate_limit:
app.state.limiter = limiter_global
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)


async def request_validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""
This is a wrapper to the default RequestValidationException handler of FastAPI.
This function will be called when client input is not valid.
"""
query_params = request.query_params._dict
detail = {
"errors": exc.errors(),
"query_params": query_params,
}
# log the error
logger.error(detail)
# pass on
return await _request_validation_exception_handler(request, exc)

0 comments on commit adeec00

Please sign in to comment.