diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index a81f6ec93..712fb0878 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -1,6 +1,7 @@ click == 8.1.* -Flask == 3.0.* -Flask_Limiter == 3.8.* +# FastAPI +starlette-context == 0.3.6 +fastapi == 0.115.0 imutils == 0.5.* joserfc == 1.0.* markupsafe == 2.1.* diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index 47d7e85a3..5699491c8 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -26,6 +26,8 @@ In the event that you are locked out of your instance, you can tell Frigate to r ## Login failure rate limiting +# TODO: Rui Update to use FastAPI + In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with Flask-Limiter, and the string notation for valid values is available in [the documentation](https://flask-limiter.readthedocs.io/en/stable/configuration.html#rate-limit-string-notation). For example, `1/second;5/minute;20/hour` will rate limit the login endpoint when failures occur more than: diff --git a/frigate/__main__.py b/frigate/__main__.py index 844206908..693de2e48 100644 --- a/frigate/__main__.py +++ b/frigate/__main__.py @@ -1,16 +1,12 @@ import faulthandler import threading -from flask import cli - from frigate.app import FrigateApp faulthandler.enable() threading.current_thread().name = "frigate" -cli.show_server_banner = lambda *x: None - if __name__ == "__main__": frigate_app = FrigateApp() diff --git a/frigate/api/app.py b/frigate/api/app.py index e1792af2a..9e338597a 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -14,25 +14,15 @@ from fastapi import APIRouter, Path, Request, Response from fastapi.encoders import jsonable_encoder from fastapi.params import Depends from fastapi.responses import JSONResponse -from flask import Flask, jsonify, request from markupsafe import escape from peewee import operator -from playhouse.sqliteq import SqliteQueueDatabase -from werkzeug.middleware.proxy_fix import ProxyFix -from frigate.api.auth import get_jwt_secret, limiter from frigate.api.defs.app_body import AppConfigSetBody from frigate.api.defs.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.tags import Tags from frigate.config import FrigateConfig from frigate.const import CONFIG_DIR -from frigate.embeddings import EmbeddingsContext -from frigate.events.external import ExternalEventProcessor from frigate.models import Event, Timeline -from frigate.plus import PlusApi -from frigate.ptz.onvif import OnvifController -from frigate.stats.emitter import StatsEmitter -from frigate.storage import StorageMaintainer from frigate.util.builtin import ( clean_camera_user_pass, get_tz_modifiers, @@ -47,56 +37,6 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.app]) -def create_app( - frigate_config, - database: SqliteQueueDatabase, - embeddings: Optional[EmbeddingsContext], - detected_frames_processor, - storage_maintainer: StorageMaintainer, - onvif: OnvifController, - external_processor: ExternalEventProcessor, - plus_api: PlusApi, - stats_emitter: StatsEmitter, -): - app = Flask(__name__) - - @app.before_request - def check_csrf(): - if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]: - pass - if "origin" in request.headers and "x-csrf-token" not in request.headers: - return jsonify({"success": False, "message": "Missing CSRF header"}), 401 - - @app.before_request - def _db_connect(): - if database.is_closed(): - database.connect() - - @app.teardown_request - def _db_close(exc): - if not database.is_closed(): - database.close() - - app.frigate_config = frigate_config - app.embeddings = embeddings - app.detected_frames_processor = detected_frames_processor - app.storage_maintainer = storage_maintainer - app.onvif = onvif - app.external_processor = external_processor - app.plus_api = plus_api - app.camera_error_image = None - app.stats_emitter = stats_emitter - app.jwt_token = get_jwt_secret() if frigate_config.auth.enabled else None - # update the request_address with the x-forwarded-for header from nginx - app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1) - # initialize the rate limiter for the login endpoint - limiter.init_app(app) - if frigate_config.auth.failed_login_rate_limit is None: - limiter.enabled = False - - return app - - @router.get("/") def is_healthy(): return "Frigate is running. Alive and healthy!" @@ -306,7 +246,7 @@ def config_save(save_option: str, body: dict): @router.put("/config/set") -def config_set(body: AppConfigSetBody): +def config_set(request: Request, body: AppConfigSetBody): config_file = os.environ.get("CONFIG_FILE", f"{CONFIG_DIR}/config.yml") # Check if we can use .yaml instead of .yml diff --git a/frigate/api/auth.py b/frigate/api/auth.py index e7c9f1db4..60713051c 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -182,20 +182,19 @@ def auth(request: Request): auth_config: AuthConfig = request.app.frigate_config.auth proxy_config: ProxyConfig = request.app.frigate_config.proxy - success_response = Response(content={}, status_code=202) + success_response = Response("", status_code=202) # dont require auth if the request is on the internal port # this header is set by Frigate's nginx proxy, so it cant be spoofed - if request.headers.get("x-server-port", 0, type=int) == 5000: + if int(request.headers.get("x-server-port", default=0)) == 5000: return success_response - fail_response = Response(content={}, status_code=401) + fail_response = Response("", status_code=401) # ensure the proxy secret matches if configured if ( proxy_config.auth_secret is not None - and request.headers.get("x-proxy-secret", "", type=str) - != proxy_config.auth_secret + and request.headers.get("x-proxy-secret", "") != proxy_config.auth_secret ): logger.debug("X-Proxy-Secret header does not match configured secret value") return fail_response @@ -207,7 +206,6 @@ def auth(request: Request): if proxy_config.header_map.user is not None: upstream_user_header_value = request.headers.get( proxy_config.header_map.user, - type=str, default="anonymous", ) success_response.headers["remote-user"] = upstream_user_header_value diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index f904ad7e6..41e5ef9ee 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -1,7 +1,10 @@ import logging from typing import Optional -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from playhouse.sqliteq import SqliteQueueDatabase +from starlette_context import middleware, plugins from frigate.api import app as main_app from frigate.api import auth, event, export, media, notification, preview, review @@ -16,21 +19,62 @@ from frigate.storage import StorageMaintainer logger = logging.getLogger(__name__) +def check_csrf(request: Request): + if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]: + pass + if "origin" in request.headers and "x-csrf-token" not in request.headers: + return JSONResponse( + content={"success": False, "message": "Missing CSRF header"}, + status_code=401, + ) + + def create_fastapi_app( frigate_config, + database: SqliteQueueDatabase, embeddings: Optional[EmbeddingsContext], detected_frames_processor, storage_maintainer: StorageMaintainer, onvif: OnvifController, + external_processor: ExternalEventProcessor, plus_api: PlusApi, stats_emitter: StatsEmitter, - external_processor: ExternalEventProcessor, ): logger.info("Starting FastAPI app") app = FastAPI( debug=False, swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"}, ) + + # update the request_address with the x-forwarded-for header from nginx + app.add_middleware( + middleware.ContextMiddleware, + plugins=(plugins.ForwardedForPlugin(),), + ) + + # Middleware to connect to DB before and close connection after request + # https://github.com/fastapi/full-stack-fastapi-template/issues/224#issuecomment-737423886 + # https://fastapi.tiangolo.com/tutorial/middleware/#before-and-after-the-response + @app.middleware("http") + async def frigate_middleware(request: Request, call_next): + # Before request + check_csrf(request) + if database.is_closed(): + database.connect() + + response = await call_next(request) + + # After request https://stackoverflow.com/a/75487519 + if not database.is_closed(): + database.close() + return response + + # TODO: Rui + # initialize the rate limiter for the login endpoint + # limiter.init_app(app) + # if frigate_config.auth.failed_login_rate_limit is None: + # limiter.enabled = False + # Routes app.include_router(main_app.router) app.include_router(media.router) diff --git a/frigate/app.py b/frigate/app.py index 081f97ab4..da3c409e1 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -15,13 +15,11 @@ from typing import Optional import psutil import uvicorn -from fastapi.middleware.wsgi import WSGIMiddleware from peewee_migrate import Router from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqliteq import SqliteQueueDatabase from pydantic import ValidationError -from frigate.api.app import create_app from frigate.api.auth import hash_password from frigate.api.fastapi_app import create_fastapi_app from frigate.comms.config_updater import ConfigPublisher @@ -388,7 +386,7 @@ class FrigateApp: self.inter_zmq_proxy = ZmqProxy() def init_web_server(self) -> None: - self.flask_app = create_app( + self.fastapi_app = create_fastapi_app( self.config, self.db, self.embeddings, @@ -400,17 +398,6 @@ class FrigateApp: self.stats_emitter, ) - self.fastapi_app = create_fastapi_app( - self.config, - self.embeddings, - self.detected_frames_processor, - self.storage_maintainer, - self.onvif_controller, - self.plus_api, - self.stats_emitter, - self.external_event_processor, - ) - def init_onvif(self) -> None: self.onvif_controller = OnvifController(self.config, self.ptz_metrics) @@ -761,6 +748,7 @@ class FrigateApp: self.start_watchdog() self.init_auth() + # TODO: Rui. What to do in this case? Maybe https://github.com/encode/uvicorn/issues/1579#issuecomment-1419635974 # Flask only listens for SIGINT, so we need to catch SIGTERM and send SIGINT def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None: os.kill(os.getpid(), signal.SIGINT) @@ -768,8 +756,6 @@ class FrigateApp: signal.signal(signal.SIGTERM, receiveSignal) try: - # Run the flask app inside fastapi: https://fastapi.tiangolo.com/advanced/sub-applications/ - self.fastapi_app.mount("", WSGIMiddleware(self.flask_app)) uvicorn.run( self.fastapi_app, host="127.0.0.1", @@ -778,7 +764,7 @@ class FrigateApp: except KeyboardInterrupt: pass - logger.info("FastAPI/Flask has exited...") + logger.info("FastAPI has exited...") self.stop() diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 3ae739ce0..0ce063897 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -11,7 +11,6 @@ from playhouse.shortcuts import model_to_dict from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqliteq import SqliteQueueDatabase -from frigate.api.app import create_app from frigate.api.fastapi_app import create_fastapi_app from frigate.config import FrigateConfig from frigate.models import Event, Recordings @@ -115,7 +114,7 @@ class TestHttp(unittest.TestCase): pass def test_get_event_list(self): - app = create_app( + app = create_fastapi_app( FrigateConfig(**self.minimal_config), self.db, None, @@ -152,7 +151,7 @@ class TestHttp(unittest.TestCase): assert not events def test_get_good_event(self): - app = create_app( + app = create_fastapi_app( FrigateConfig(**self.minimal_config), self.db, None, @@ -174,7 +173,7 @@ class TestHttp(unittest.TestCase): assert event == model_to_dict(Event.get(Event.id == id)) def test_get_bad_event(self): - app = create_app( + app = create_fastapi_app( FrigateConfig(**self.minimal_config), self.db, None, @@ -195,7 +194,7 @@ class TestHttp(unittest.TestCase): assert not event def test_delete_event(self): - app = create_app( + app = create_fastapi_app( FrigateConfig(**self.minimal_config), self.db, None, @@ -218,7 +217,7 @@ class TestHttp(unittest.TestCase): assert not event def test_event_retention(self): - app = create_app( + app = create_fastapi_app( FrigateConfig(**self.minimal_config), self.db, None, @@ -245,7 +244,7 @@ class TestHttp(unittest.TestCase): assert event["retain_indefinitely"] is False def test_event_time_filtering(self): - app = create_app( + app = create_fastapi_app( FrigateConfig(**self.minimal_config), self.db, None, @@ -284,7 +283,7 @@ class TestHttp(unittest.TestCase): assert len(events) == 1 def test_set_delete_sub_label(self): - app = create_app( + app = create_fastapi_app( FrigateConfig(**self.minimal_config), self.db, None, @@ -320,7 +319,7 @@ class TestHttp(unittest.TestCase): assert event["sub_label"] == "" def test_sub_label_list(self): - app = create_app( + app = create_fastapi_app( FrigateConfig(**self.minimal_config), self.db, None, @@ -346,7 +345,7 @@ class TestHttp(unittest.TestCase): assert sub_labels == [sub_label] def test_config(self): - app = create_app( + app = create_fastapi_app( FrigateConfig(**self.minimal_config).runtime_config(), self.db, None, @@ -386,7 +385,7 @@ class TestHttp(unittest.TestCase): def test_stats(self): stats = Mock(spec=StatsEmitter) stats.get_latest_stats.return_value = self.test_stats - app = create_app( + app = create_fastapi_app( FrigateConfig(**self.minimal_config).runtime_config(), self.db, None,