Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mint: Add slowapi #481

Merged
merged 6 commits into from
Mar 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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)