Merge pull request #40 from michael-lazar/rate_limiting

Rate limiting proof of concept
This commit is contained in:
Michael Lazar 2020-07-27 00:09:49 -04:00 committed by GitHub
commit 7701acd995
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 218 additions and 39 deletions

2
.isort.cfg Normal file
View File

@ -0,0 +1,2 @@
[isort]
profile=black

View File

@ -7,6 +7,6 @@ repos:
hooks: hooks:
- id: black - id: black
- repo: https://github.com/pre-commit/mirrors-isort - repo: https://github.com/pre-commit/mirrors-isort
rev: v4.3.21 rev: v5.1.4
hooks: hooks:
- id: isort - id: isort

View File

@ -1,16 +1,29 @@
# Jetforce Changelog # Jetforce Changelog
### Unreleased ### v0.6.0 (Unreleased)
#### Bugfixes
- File chunking has been optimized for streaming large static files.
- Server access logs are now redirected to ``stdout`` instead of ``stderr``.
This is intended to make it easier to use a log manager tool to split them
out from other server messages like startup information and error tracebacks.
- The default mimetype for unknown file extensions will now be sent as - The default mimetype for unknown file extensions will now be sent as
"application/octet-stream" instead of "text/plain". The expectation is that "application/octet-stream" instead of "text/plain". The expectation is that
it would be safer for a client to download an unknown file rather than it would be safer for a client to download an unknown file rather than
attempting to display it inline as text. attempting to display it inline as text.
#### Features
- The static file server now has a ``--rate-limit`` flag that can be used
to define per-IP address rate limiting for requests. Requests that exceed
the specified rate will receive a 44 SLOW DOWN error response.
- Server access logs are now redirected to ``stdout`` instead of ``stderr``.
This is intended to make it easier to use a log manager tool to split them
out from other server messages like startup information and error tracebacks.
- File chunking has been optimized for streaming large static files.
#### Examples
- Added an example that demonstrates how to use the new ``RateLimiter`` class
(examples/rate_limit.py).
### v0.5.0 (2020-07-14) ### v0.5.0 (2020-07-14)
#### Spec Changes #### Spec Changes

View File

@ -59,11 +59,10 @@ $ /opt/jetforce/venv/bin/jetforce
Use the ``--help`` flag to view command-line options: Use the ``--help`` flag to view command-line options:
```bash ```bash
$ jetforce --help
usage: jetforce [-h] [-V] [--host HOST] [--port PORT] [--hostname HOSTNAME] usage: jetforce [-h] [-V] [--host HOST] [--port PORT] [--hostname HOSTNAME]
[--tls-certfile FILE] [--tls-keyfile FILE] [--tls-cafile FILE] [--tls-certfile FILE] [--tls-keyfile FILE] [--tls-cafile FILE]
[--tls-capath DIR] [--dir DIR] [--cgi-dir DIR] [--tls-capath DIR] [--dir DIR] [--cgi-dir DIR] [--index-file FILE]
[--index-file FILE] [--default-lang DEFAULT_LANG] [--rate-limit RATE_LIMIT]
An Experimental Gemini Protocol Server An Experimental Gemini Protocol Server
@ -78,17 +77,21 @@ server configuration:
--tls-certfile FILE Server TLS certificate file (default: None) --tls-certfile FILE Server TLS certificate file (default: None)
--tls-keyfile FILE Server TLS private key file (default: None) --tls-keyfile FILE Server TLS private key file (default: None)
--tls-cafile FILE A CA file to use for validating clients (default: None) --tls-cafile FILE A CA file to use for validating clients (default: None)
--tls-capath DIR A directory containing CA files for validating clients --tls-capath DIR A directory containing CA files for validating clients (default:
(default: None) None)
fileserver configuration: fileserver configuration:
--dir DIR Root directory on the filesystem to serve (default: --dir DIR Root directory on the filesystem to serve (default: /var/gemini)
/var/gemini) --cgi-dir DIR CGI script directory, relative to the server's root directory
--cgi-dir DIR CGI script directory, relative to the server's root (default: cgi-bin)
directory (default: cgi-bin) --index-file FILE If a directory contains a file with this name, that file will be
--index-file FILE If a directory contains a file with this name, that served instead of auto-generating an index page (default: index.gmi)
file will be served instead of auto-generating an index --default-lang DEFAULT_LANG
page (default: index.gmi) A lang parameter that will be used for all text/gemini responses
(default: None)
--rate-limit RATE_LIMIT
Enable IP rate limiting, e.g. '60/5m' (60 requests per 5 minutes)
(default: None)
``` ```
### Setting the ``hostname`` ### Setting the ``hostname``

View File

@ -17,9 +17,10 @@ streaming.
from collections import deque from collections import deque
from datetime import datetime from datetime import datetime
from jetforce import GeminiServer, JetforceApplication, Response, Status
from twisted.internet.defer import AlreadyCalledError, Deferred from twisted.internet.defer import AlreadyCalledError, Deferred
from jetforce import GeminiServer, JetforceApplication, Response, Status
class MessageQueue: class MessageQueue:
def __init__(self, filename): def __init__(self, filename):

View File

@ -9,11 +9,12 @@ loading the entire response into memory at once.
""" """
import time import time
from jetforce import GeminiServer, JetforceApplication, Response, Status
from twisted.internet import reactor from twisted.internet import reactor
from twisted.internet.task import deferLater from twisted.internet.task import deferLater
from twisted.internet.threads import deferToThread from twisted.internet.threads import deferToThread
from jetforce import GeminiServer, JetforceApplication, Response, Status
def blocking_counter(): def blocking_counter():
""" """

43
examples/rate_limit.py Normal file
View File

@ -0,0 +1,43 @@
#!/usr/local/env python3
"""
This example shows how you can implement rate limiting on a per-endpoint basis.
"""
from jetforce import GeminiServer, JetforceApplication, RateLimiter, Response, Status
# Setup a global rate limiter that will be applied to all requests
global_rate_limiter = RateLimiter("100/m")
app = JetforceApplication(rate_limiter=global_rate_limiter)
# Setup some custom rate limiting for specific endpoints
short_rate_limiter = RateLimiter("5/30s")
long_rate_limiter = RateLimiter("60/5m")
INDEX_PAGE = """\
# Rate Limiting Demo
=>/short short rate limiter (5/30s)
=>/long long rate limiter (60/5m)
"""
@app.route("", strict_trailing_slash=False)
def index(request):
return Response(Status.SUCCESS, "text/gemini", INDEX_PAGE)
@app.route("/short")
@short_rate_limiter.apply
def short(request):
return Response(Status.SUCCESS, "text/gemini", "Request was successful")
@app.route("/long")
@long_rate_limiter.apply
def long(request):
return Response(Status.SUCCESS, "text/gemini", "Request was successful")
if __name__ == "__main__":
server = GeminiServer(app, host="127.0.0.1", hostname="localhost")
server.run()

View File

@ -2,7 +2,14 @@
isort:skip_file isort:skip_file
""" """
from .__version__ import __version__ from .__version__ import __version__
from .app.base import JetforceApplication, Request, Response, RoutePattern, Status from .app.base import (
JetforceApplication,
Request,
Response,
RoutePattern,
Status,
RateLimiter,
)
from .app.static import StaticDirectoryApplication from .app.static import StaticDirectoryApplication
from .app.composite import CompositeApplication from .app.composite import CompositeApplication
from .protocol import GeminiProtocol from .protocol import GeminiProtocol

View File

@ -9,6 +9,7 @@ import argparse
import sys import sys
from .__version__ import __version__ from .__version__ import __version__
from .app.base import RateLimiter
from .app.static import StaticDirectoryApplication from .app.static import StaticDirectoryApplication
from .server import GeminiServer from .server import GeminiServer
@ -91,22 +92,29 @@ group.add_argument(
metavar="FILE", metavar="FILE",
dest="index_file", dest="index_file",
) )
group.add_argument( group.add_argument(
"--default-lang", "--default-lang",
help="A lang parameter that will be indicated in the response meta", help="A lang parameter that will be used for all text/gemini responses",
default=None, default=None,
dest="default_lang", dest="default_lang",
) )
group.add_argument(
"--rate-limit",
help="Enable IP rate limiting, e.g. '60/5m' (60 requests per 5 minutes)",
default=None,
dest="rate_limit",
)
def main(): def main():
args = parser.parse_args() args = parser.parse_args()
rate_limiter = RateLimiter(args.rate_limit) if args.rate_limit else None
app = StaticDirectoryApplication( app = StaticDirectoryApplication(
root_directory=args.root_directory, root_directory=args.root_directory,
index_file=args.index_file, index_file=args.index_file,
cgi_directory=args.cgi_directory, cgi_directory=args.cgi_directory,
default_lang=args.default_lang, default_lang=args.default_lang,
rate_limiter=rate_limiter,
) )
server = GeminiServer( server = GeminiServer(
app=app, app=app,

View File

@ -1,6 +1,8 @@
import dataclasses import dataclasses
import re import re
import time
import typing import typing
from collections import defaultdict
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
@ -121,6 +123,91 @@ class RoutePattern:
return re.fullmatch(self.path, request_path) return re.fullmatch(self.path, request_path)
RouteHandler = typing.Callable[..., Response]
class RateLimiter:
"""
A class that can be used to apply rate-limiting to endpoints.
Rates are defined as human-readable strings, e.g.
"5/s (5 requests per-second)
"10/5m" (10 requests per-5 minutes)
"100/2h" (100 requests per-2 hours)
"1000/d" (1k requests per-day)
"""
RE = re.compile("(?P<number>[0-9]+)/(?P<period>[0-9]+)?(?P<unit>[smhd])")
def __init__(self, rate: str) -> None:
match = self.RE.fullmatch(rate)
if not match:
raise ValueError(f"Invalid rate format: {rate}")
rate_data = match.groupdict()
self.number = int(rate_data["number"])
self.period = int(rate_data["period"] or 1)
if rate_data["unit"] == "m":
self.period *= 60
elif rate_data["unit"] == "h":
self.period += 60 * 60
elif rate_data["unit"] == "d":
self.period *= 60 * 60 * 24
self.reset()
def reset(self) -> None:
self.next_timestamp = time.time() + self.period
self.rate_counter = defaultdict(int)
def get_key(self, request: Request) -> typing.Optional[str]:
"""
Rate limit based on the client's IP-address.
"""
return request.environ["REMOTE_ADDR"]
def check(self, request: Request) -> typing.Optional[Response]:
"""
Check if the given request should be rate limited.
This method will return a failure response if the request should be
rate limited.
"""
time_left = self.next_timestamp - time.time()
if time_left < 0:
self.reset()
key = self.get_key(request)
if key is not None:
self.rate_counter[key] += 1
if self.rate_counter[key] > self.number:
msg = f"Rate limit exceeded, wait {time_left:.0f} seconds."
return Response(Status.SLOW_DOWN, msg)
def apply(self, wrapped_func: RouteHandler) -> RouteHandler:
"""
Decorator to apply rate limiting to an individual application route.
Usage:
rate_limiter = RateLimiter("10/m")
@app.route("/endpoint")
@rate_limiter.apply
def my_endpoint(request):
return Response(Status.SUCCESS, "text/gemini", "hello world!")
"""
def wrapper(request: Request, **kwargs) -> Response:
response = self.check(request)
if response:
return response
return wrapped_func(request, **kwargs)
return wrapper
class JetforceApplication: class JetforceApplication:
""" """
Base Jetforce application class with primitive URL routing. Base Jetforce application class with primitive URL routing.
@ -133,10 +220,9 @@ class JetforceApplication:
how to accomplish this. how to accomplish this.
""" """
def __init__(self): def __init__(self, rate_limiter: typing.Optional[RateLimiter] = None):
self.routes: typing.List[ self.rate_limiter = rate_limiter
typing.Tuple[RoutePattern, typing.Callable[[Request, ...], Response]] self.routes: typing.List[typing.Tuple[RoutePattern, RouteHandler]] = []
] = []
def __call__( def __call__(
self, environ: dict, send_status: typing.Callable self, environ: dict, send_status: typing.Callable
@ -147,6 +233,12 @@ class JetforceApplication:
send_status(Status.BAD_REQUEST, "Invalid URL") send_status(Status.BAD_REQUEST, "Invalid URL")
return return
if self.rate_limiter:
response = self.rate_limiter.check(request)
if response:
send_status(response.status, response.meta)
return
for route_pattern, callback in self.routes[::-1]: for route_pattern, callback in self.routes[::-1]:
match = route_pattern.match(request) match = route_pattern.match(request)
if route_pattern.match(request): if route_pattern.match(request):
@ -185,7 +277,7 @@ class JetforceApplication:
path, scheme, hostname, strict_hostname, strict_trailing_slash path, scheme, hostname, strict_hostname, strict_trailing_slash
) )
def wrap(func: typing.Callable) -> typing.Callable: def wrap(func: RouteHandler) -> RouteHandler:
self.routes.append((route_pattern, func)) self.routes.append((route_pattern, func))
return func return func

View File

@ -6,7 +6,14 @@ import subprocess
import typing import typing
import urllib.parse import urllib.parse
from .base import JetforceApplication, Request, Response, RoutePattern, Status from .base import (
JetforceApplication,
RateLimiter,
Request,
Response,
RoutePattern,
Status,
)
class StaticDirectoryApplication(JetforceApplication): class StaticDirectoryApplication(JetforceApplication):
@ -32,8 +39,10 @@ class StaticDirectoryApplication(JetforceApplication):
index_file: str = "index.gmi", index_file: str = "index.gmi",
cgi_directory: str = "cgi-bin", cgi_directory: str = "cgi-bin",
default_lang: typing.Optional[str] = None, default_lang: typing.Optional[str] = None,
rate_limiter: typing.Optional[RateLimiter] = None,
): ):
super().__init__() super().__init__(rate_limiter=rate_limiter)
self.routes.append((RoutePattern(), self.serve_static_file)) self.routes.append((RoutePattern(), self.serve_static_file))
self.root = pathlib.Path(root_directory).resolve(strict=True) self.root = pathlib.Path(root_directory).resolve(strict=True)