|
diff --git a/docs/searchindex.js b/docs/searchindex.js
index da8dcf9..c296e96 100644
--- a/docs/searchindex.js
+++ b/docs/searchindex.js
@@ -1 +1 @@
-Search.setIndex({"docnames": ["README", "index"], "filenames": ["README.md", "index.rst"], "titles": ["PyNinja", "Welcome to PyNinja\u2019s documentation!"], "terms": {"light": 0, "weight": 0, "o": [0, 1], "agnost": 0, "servic": 0, "monitor": 0, "api": [0, 1], "platform": 0, "support": 0, "deploy": 0, "recommend": 0, "instal": 0, "python": 0, "3": 0, "10": 0, "11": 0, "us": [0, 1], "dedic": 0, "virtual": 0, "m": 0, "pip": 0, "initi": 0, "id": [0, 1], "import": 0, "__name__": 0, "__main__": 0, "start": [0, 1], "cli": 0, "help": 0, "usag": 0, "instruct": 0, "sourc": 0, "from": [0, 1], "an": [0, 1], "env": [0, 1], "file": [0, 1], "By": 0, "default": 0, "look": 0, "current": 0, "work": 0, "directori": 0, "ninja": 0, "_": 0, "host": [0, 1], "hostnam": 0, "server": [0, 1], "port": 0, "number": [0, 1], "worker": [0, 1], "uvicorn": [0, 1], "remot": 0, "execut": 0, "boolean": 0, "flag": 0, "enabl": 0, "secret": [0, 1], "access": 0, "kei": [0, 1], "run": [0, 1], "command": [0, 1], "apikei": [0, 1], "authent": 0, "can": 0, "extrem": 0, "riski": 0, "major": 0, "secur": 0, "threat": 0, "so": 0, "caution": 0, "set": [0, 1], "strong": [0, 1], "valu": [0, 1], "log": 0, "ini": 0, "configur": [0, 1], "custom": [0, 1], "just": 0, "place": 0, "refer": [0, 1], "sampl": 0, "exampl": 0, "docstr": 0, "format": 0, "googl": 0, "style": 0, "convent": 0, "pep": 0, "8": 0, "isort": 0, "requir": 0, "gitvers": 0, "revers": 0, "f": 0, "release_not": 0, "rst": 0, "t": 0, "pre": 0, "commit": 0, "ensur": 0, "pytest": 0, "gener": [0, 1], "valid": [0, 1], "hyperlink": 0, "all": 0, "markdown": 0, "includ": 0, "wiki": 0, "page": [0, 1], "sphinx": 0, "5": 0, "1": [0, 1], "recommonmark": 0, "http": 0, "org": 0, "project": 0, "thevickypedia": 0, "github": 0, "io": 0, "vignesh": 0, "rao": 0, "under": 0, "mit": 0, "kick": 1, "off": 1, "environ": 1, "variabl": 1, "code": 1, "standard": 1, "releas": 1, "note": 1, "lint": 1, "pypi": 1, "packag": 1, "runbook": 1, "licens": 1, "copyright": 1, "env_fil": 1, "str": 1, "none": 1, "starter": 1, "function": 1, "which": 1, "trigger": 1, "paramet": 1, "filepath": 1, "async": 1, "auth": 1, "token": 1, "httpbasiccredenti": 1, "depend": 1, "httpbearer": 1, "mention": 1, "take": 1, "author": 1, "header": 1, "argument": 1, "rais": 1, "apirespons": 1, "401": 1, "If": 1, "i": 1, "invalid": 1, "status_cod": 1, "int": 1, "detail": 1, "ani": 1, "option": 1, "dict": 1, "httpexcept": 1, "fastapi": 1, "wrap": 1, "respons": 1, "unsupportedo": 1, "class": 1, "unsupport": 1, "run_command": 1, "request": 1, "payload": 1, "machin": 1, "arg": 1, "receiv": 1, "bodi": 1, "httpstatu": 1, "object": 1, "statu": 1, "process_statu": 1, "process_nam": 1, "name": 1, "check": 1, "service_statu": 1, "service_nam": 1, "doc": 1, "redirectrespons": 1, "redirect": 1, "return": 1, "user": 1, "type": 1, "get_process_statu": 1, "get": 1, "particular": 1, "yield": 1, "metric": 1, "dictionari": 1, "pair": 1, "get_perform": 1, "perform": 1, "cpu": 1, "util": 1, "thread": 1, "open": 1, "get_service_statu": 1, "servicestatu": 1, "instanc": 1, "basemodel": 1, "handl": 1, "input": 1, "data": 1, "timeout": 1, "union": 1, "float": 1, "load": 1, "descript": 1, "envconfig": 1, "ninja_host": 1, "ninja_port": 1, "remote_execut": 1, "bool": 1, "api_secret": 1, "classmethod": 1, "parse_api_secret": 1, "pars": 1, "complex": 1, "from_env_fil": 1, "creat": 1, "model": 1, "config": 1, "extra": 1, "ignor": 1, "hide_input_in_error": 1, "true": 1, "complexity_check": 1, "verifi": 1, "strength": 1, "A": 1, "consid": 1, "least": 1, "ha": 1, "32": 1, "charact": 1, "digit": 1, "symbol": 1, "uppercas": 1, "letter": 1, "lowercas": 1, "assertionerror": 1, "when": 1, "abov": 1, "condit": 1, "fail": 1, "match": 1, "env_load": 1, "filenam": 1, "pathlik": 1, "base": 1, "filetyp": 1, "where": 1, "var": 1, "have": 1, "alia": 1, "index": 1, "modul": 1, "search": 1}, "objects": {"pyninja": [[1, 0, 0, "-", "auth"], [1, 0, 0, "-", "exceptions"], [1, 0, 0, "-", "main"], [1, 0, 0, "-", "process"], [1, 0, 0, "-", "routers"], [1, 0, 0, "-", "service"], [1, 0, 0, "-", "squire"]], "pyninja.auth": [[1, 1, 1, "", "authenticator"]], "pyninja.exceptions": [[1, 2, 1, "", "APIResponse"], [1, 2, 1, "", "UnSupportedOS"]], "pyninja.main": [[1, 1, 1, "", "start"]], "pyninja.process": [[1, 1, 1, "", "get_performance"], [1, 1, 1, "", "get_process_status"]], "pyninja.routers": [[1, 1, 1, "", "docs"], [1, 1, 1, "", "process_status"], [1, 1, 1, "", "run_command"], [1, 1, 1, "", "service_status"]], "pyninja.service": [[1, 1, 1, "", "get_service_status"]], "pyninja.squire": [[1, 3, 1, "", "EnvConfig"], [1, 3, 1, "", "Payload"], [1, 3, 1, "", "ServiceStatus"], [1, 1, 1, "", "complexity_checker"], [1, 4, 1, "", "env"], [1, 1, 1, "", "env_loader"]], "pyninja.squire.EnvConfig": [[1, 3, 1, "", "Config"], [1, 4, 1, "", "api_secret"], [1, 4, 1, "", "apikey"], [1, 5, 1, "", "from_env_file"], [1, 4, 1, "", "ninja_host"], [1, 4, 1, "", "ninja_port"], [1, 5, 1, "", "parse_api_secret"], [1, 4, 1, "", "remote_execution"], [1, 4, 1, "", "workers"]], "pyninja.squire.EnvConfig.Config": [[1, 4, 1, "", "extra"], [1, 4, 1, "", "hide_input_in_errors"]], "pyninja.squire.Payload": [[1, 4, 1, "", "command"], [1, 4, 1, "", "timeout"]], "pyninja.squire.ServiceStatus": [[1, 4, 1, "", "description"], [1, 4, 1, "", "status_code"]]}, "objtypes": {"0": "py:module", "1": "py:function", "2": "py:exception", "3": "py:class", "4": "py:attribute", "5": "py:method"}, "objnames": {"0": ["py", "module", "Python module"], "1": ["py", "function", "Python function"], "2": ["py", "exception", "Python exception"], "3": ["py", "class", "Python class"], "4": ["py", "attribute", "Python attribute"], "5": ["py", "method", "Python method"]}, "titleterms": {"pyninja": [0, 1], "kick": 0, "off": 0, "environ": 0, "variabl": 0, "code": 0, "standard": 0, "releas": 0, "note": 0, "lint": 0, "pypi": 0, "packag": 0, "runbook": 0, "licens": 0, "copyright": 0, "welcom": 1, "": 1, "document": 1, "content": 1, "main": 1, "authent": 1, "except": 1, "router": 1, "monitor": 1, "process": 1, "servic": 1, "squir": 1, "indic": 1, "tabl": 1}, "envversion": {"sphinx.domains.c": 2, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 6, "sphinx.domains.index": 1, "sphinx.domains.javascript": 2, "sphinx.domains.math": 2, "sphinx.domains.python": 3, "sphinx.domains.rst": 2, "sphinx.domains.std": 2, "sphinx": 56}})
\ No newline at end of file
+Search.setIndex({"docnames": ["README", "index"], "filenames": ["README.md", "index.rst"], "titles": ["PyNinja", "Welcome to PyNinja\u2019s documentation!"], "terms": {"lightweight": 0, "o": [0, 1], "agnost": 0, "servic": 0, "monitor": 0, "api": [0, 1], "platform": 0, "support": 0, "deploy": 0, "recommend": 0, "instal": 0, "python": 0, "3": 0, "10": [0, 1], "11": 0, "us": [0, 1], "dedic": 0, "virtual": 0, "m": 0, "pip": 0, "initi": 0, "id": [0, 1], "import": 0, "__name__": 0, "__main__": 0, "start": [0, 1], "cli": 0, "help": 0, "usag": 0, "instruct": 0, "sourc": 0, "from": [0, 1], "an": [0, 1], "env": [0, 1], "file": [0, 1], "By": 0, "default": 0, "look": 0, "current": 0, "work": 0, "directori": 0, "ninja": 0, "_": 0, "host": [0, 1], "hostnam": 0, "server": [0, 1], "port": 0, "number": [0, 1], "worker": [0, 1], "uvicorn": [0, 1], "remot": 0, "execut": 0, "boolean": 0, "flag": 0, "enabl": 0, "secret": [0, 1], "access": 0, "kei": [0, 1], "run": [0, 1], "command": [0, 1], "apikei": [0, 1], "authent": 0, "can": 0, "extrem": 0, "riski": 0, "major": 0, "secur": 0, "threat": 0, "so": 0, "caution": 0, "set": [0, 1], "strong": [0, 1], "valu": [0, 1], "log": 0, "ini": 0, "configur": [0, 1], "custom": [0, 1], "just": 0, "place": 0, "refer": [0, 1], "sampl": 0, "exampl": 0, "docstr": 0, "format": 0, "googl": 0, "style": 0, "convent": 0, "pep": 0, "8": 0, "isort": 0, "requir": [0, 1], "gitvers": 0, "revers": 0, "f": 0, "release_not": 0, "rst": 0, "t": 0, "pre": 0, "commit": 0, "ensur": 0, "pytest": 0, "gener": [0, 1], "valid": [0, 1], "hyperlink": 0, "all": [0, 1], "markdown": 0, "includ": 0, "wiki": 0, "page": [0, 1], "sphinx": 0, "5": 0, "1": [0, 1], "recommonmark": 0, "http": 0, "org": 0, "project": 0, "thevickypedia": 0, "github": 0, "io": 0, "vignesh": 0, "rao": 0, "under": 0, "mit": 0, "kick": 1, "off": 1, "environ": 1, "variabl": 1, "code": 1, "standard": 1, "releas": 1, "note": 1, "lint": 1, "pypi": 1, "packag": 1, "runbook": 1, "licens": 1, "copyright": 1, "kwarg": 1, "none": 1, "starter": 1, "function": 1, "which": 1, "trigger": 1, "keyword": 1, "argument": 1, "env_fil": 1, "filepath": 1, "async": 1, "auth": 1, "token": 1, "httpbasiccredenti": 1, "depend": 1, "httpbearer": 1, "mention": 1, "paramet": 1, "take": 1, "author": 1, "header": 1, "rais": 1, "apirespons": 1, "401": 1, "If": 1, "i": 1, "invalid": 1, "epoch": 1, "increment": 1, "attempt": 1, "int": 1, "block": 1, "time": 1, "address": 1, "base": 1, "fail": 1, "return": 1, "appropri": 1, "minut": 1, "type": 1, "handle_auth_error": 1, "request": 1, "handl": 1, "error": 1, "filebrows": 1, "The": 1, "incom": 1, "object": 1, "run_command": 1, "payload": 1, "option": 1, "str": 1, "machin": 1, "arg": 1, "receiv": 1, "bodi": 1, "httpstatu": 1, "statu": 1, "detail": 1, "respons": 1, "process_statu": 1, "process_nam": 1, "name": 1, "check": 1, "service_statu": 1, "service_nam": 1, "doc": 1, "redirectrespons": 1, "redirect": 1, "user": 1, "get_all_rout": 1, "list": 1, "apirout": 1, "get": 1, "rout": 1, "ad": 1, "get_process_statu": 1, "dict": 1, "particular": 1, "yield": 1, "metric": 1, "dictionari": 1, "pair": 1, "get_perform": 1, "perform": 1, "cpu": 1, "util": 1, "thread": 1, "open": 1, "get_service_statu": 1, "servicestatu": 1, "instanc": 1, "get_record": 1, "until": 1, "when": 1, "should": 1, "put_record": 1, "block_until": 1, "insert": 1, "remove_record": 1, "delet": 1, "record": 1, "relat": 1, "class": 1, "rate_limit": 1, "rp": 1, "implement": 1, "init": 1, "call": 1, "exce": 1, "rate": 1, "limit": 1, "given": 1, "identifi": 1, "429": 1, "too": 1, "mani": 1, "status_cod": 1, "ani": 1, "httpexcept": 1, "fastapi": 1, "wrap": 1, "unsupportedo": 1, "unsupport": 1, "basemodel": 1, "input": 1, "data": 1, "timeout": 1, "union": 1, "float": 1, "load": 1, "descript": 1, "session": 1, "store": 1, "inform": 1, "auth_count": 1, "forbid": 1, "info": 1, "allowed_origin": 1, "max_request": 1, "second": 1, "envconfig": 1, "ninja_host": 1, "ninja_port": 1, "remote_execut": 1, "bool": 1, "api_secret": 1, "classmethod": 1, "parse_api_secret": 1, "pars": 1, "complex": 1, "from_env_fil": 1, "path": 1, "creat": 1, "config": 1, "extra": 1, "ignor": 1, "hide_input_in_error": 1, "true": 1, "complexity_check": 1, "verifi": 1, "strength": 1, "A": 1, "consid": 1, "least": 1, "ha": 1, "32": 1, "charact": 1, "digit": 1, "symbol": 1, "uppercas": 1, "letter": 1, "lowercas": 1, "assertionerror": 1, "abov": 1, "condit": 1, "match": 1, "datastor": 1, "connect": 1, "sqlite3": 1, "create_t": 1, "table_nam": 1, "column": 1, "tupl": 1, "env_load": 1, "filenam": 1, "pathlik": 1, "filetyp": 1, "where": 1, "var": 1, "have": 1, "index": 1, "modul": 1, "search": 1}, "objects": {"pyninja": [[1, 0, 0, "-", "auth"], [1, 0, 0, "-", "database"], [1, 0, 0, "-", "exceptions"], [1, 0, 0, "-", "main"], [1, 0, 0, "-", "models"], [1, 0, 0, "-", "process"], [1, 0, 0, "-", "rate_limit"], [1, 0, 0, "-", "routers"], [1, 0, 0, "-", "service"], [1, 0, 0, "-", "squire"]], "pyninja.auth": [[1, 1, 1, "", "authenticator"]], "pyninja.database": [[1, 1, 1, "", "get_record"], [1, 1, 1, "", "put_record"], [1, 1, 1, "", "remove_record"]], "pyninja.exceptions": [[1, 2, 1, "", "APIResponse"], [1, 2, 1, "", "UnSupportedOS"]], "pyninja.main": [[1, 1, 1, "", "start"]], "pyninja.models": [[1, 3, 1, "", "Database"], [1, 3, 1, "", "EnvConfig"], [1, 3, 1, "", "Payload"], [1, 3, 1, "", "RateLimit"], [1, 3, 1, "", "ServiceStatus"], [1, 3, 1, "", "Session"], [1, 1, 1, "", "complexity_checker"]], "pyninja.models.Database": [[1, 4, 1, "", "create_table"]], "pyninja.models.EnvConfig": [[1, 3, 1, "", "Config"], [1, 5, 1, "", "api_secret"], [1, 5, 1, "", "apikey"], [1, 5, 1, "", "database"], [1, 4, 1, "", "from_env_file"], [1, 5, 1, "", "ninja_host"], [1, 5, 1, "", "ninja_port"], [1, 4, 1, "", "parse_api_secret"], [1, 5, 1, "", "rate_limit"], [1, 5, 1, "", "remote_execution"], [1, 5, 1, "", "workers"]], "pyninja.models.EnvConfig.Config": [[1, 5, 1, "", "extra"], [1, 5, 1, "", "hide_input_in_errors"]], "pyninja.models.Payload": [[1, 5, 1, "", "command"], [1, 5, 1, "", "timeout"]], "pyninja.models.RateLimit": [[1, 5, 1, "", "max_requests"], [1, 5, 1, "", "seconds"]], "pyninja.models.ServiceStatus": [[1, 5, 1, "", "description"], [1, 5, 1, "", "status_code"]], "pyninja.models.Session": [[1, 5, 1, "", "allowed_origins"], [1, 5, 1, "", "auth_counter"], [1, 5, 1, "", "forbid"], [1, 5, 1, "", "info"], [1, 5, 1, "", "rps"]], "pyninja.process": [[1, 1, 1, "", "get_performance"], [1, 1, 1, "", "get_process_status"]], "pyninja.rate_limit": [[1, 3, 1, "", "RateLimiter"]], "pyninja.rate_limit.RateLimiter": [[1, 4, 1, "", "init"]], "pyninja.routers": [[1, 1, 1, "", "docs"], [1, 1, 1, "", "epoch"], [1, 1, 1, "", "get_all_routes"], [1, 1, 1, "", "handle_auth_error"], [1, 1, 1, "", "incrementer"], [1, 1, 1, "", "process_status"], [1, 1, 1, "", "run_command"], [1, 1, 1, "", "service_status"]], "pyninja.service": [[1, 1, 1, "", "get_service_status"]], "pyninja.squire": [[1, 1, 1, "", "env_loader"]]}, "objtypes": {"0": "py:module", "1": "py:function", "2": "py:exception", "3": "py:class", "4": "py:method", "5": "py:attribute"}, "objnames": {"0": ["py", "module", "Python module"], "1": ["py", "function", "Python function"], "2": ["py", "exception", "Python exception"], "3": ["py", "class", "Python class"], "4": ["py", "method", "Python method"], "5": ["py", "attribute", "Python attribute"]}, "titleterms": {"pyninja": [0, 1], "kick": 0, "off": 0, "environ": 0, "variabl": 0, "code": 0, "standard": 0, "releas": 0, "note": 0, "lint": 0, "pypi": 0, "packag": 0, "runbook": 0, "licens": 0, "copyright": 0, "welcom": 1, "": 1, "document": 1, "content": 1, "main": 1, "authent": 1, "router": 1, "monitor": 1, "process": 1, "servic": 1, "databas": 1, "ratelimit": 1, "except": 1, "model": 1, "squir": 1, "indic": 1, "tabl": 1}, "envversion": {"sphinx.domains.c": 2, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 6, "sphinx.domains.index": 1, "sphinx.domains.javascript": 2, "sphinx.domains.math": 2, "sphinx.domains.python": 3, "sphinx.domains.rst": 2, "sphinx.domains.std": 2, "sphinx": 56}})
\ No newline at end of file
diff --git a/pyninja/auth.py b/pyninja/auth.py
index 18613f1..b0eb884 100644
--- a/pyninja/auth.py
+++ b/pyninja/auth.py
@@ -1,11 +1,16 @@
+import logging
import secrets
+import time
+from datetime import datetime
from http import HTTPStatus
-from fastapi import Depends
+from fastapi import Depends, Request
from fastapi.security import HTTPBasicCredentials, HTTPBearer
-from pyninja import exceptions, squire
+from pyninja import database, exceptions, models
+LOGGER = logging.getLogger("uvicorn.error")
+EPOCH = lambda: int(time.time()) # noqa: E731
SECURITY = HTTPBearer()
@@ -22,8 +27,68 @@ async def authenticator(token: HTTPBasicCredentials = Depends(SECURITY)) -> None
auth = token.model_dump().get("credentials", "")
if auth.startswith("\\"):
auth = bytes(auth, "utf-8").decode(encoding="unicode_escape")
- if secrets.compare_digest(auth, squire.env.apikey):
+ if secrets.compare_digest(auth, models.env.apikey):
return
raise exceptions.APIResponse(
status_code=HTTPStatus.UNAUTHORIZED.real, detail=HTTPStatus.UNAUTHORIZED.phrase
)
+
+
+async def incrementer(attempt: int) -> int:
+ """Increments block time for a host address based on the number of failed attempts.
+
+ Args:
+ attempt: Number of failed attempts.
+
+ Returns:
+ int:
+ Returns the appropriate block time in minutes.
+ """
+ try:
+ return {4: 5, 5: 10, 6: 20, 7: 40, 8: 80, 9: 160, 10: 220}[attempt]
+ except KeyError:
+ LOGGER.critical("Something went horribly wrong for %dth attempt", attempt)
+ return 60 # defaults to 1 hour
+
+
+async def handle_auth_error(request: Request) -> None:
+ """Handle authentication errors from the filebrowser API.
+
+ Args:
+ request: The incoming request object.
+ """
+ if models.session.auth_counter.get(request.client.host):
+ models.session.auth_counter[request.client.host] += 1
+ LOGGER.warning(
+ "Failed auth, attempt #%d for %s",
+ models.session.auth_counter[request.client.host],
+ request.client.host,
+ )
+ if models.session.auth_counter[request.client.host] >= 10:
+ # Block the host address for 1 month or until the server restarts
+ until = EPOCH() + 2_592_000
+ LOGGER.warning(
+ "%s is blocked until %s",
+ request.client.host,
+ datetime.fromtimestamp(until).strftime("%c"),
+ )
+ database.remove_record(request.client.host)
+ database.put_record(request.client.host, until)
+ elif models.session.auth_counter[request.client.host] > 3:
+ # Allows up to 3 failed login attempts
+ models.session.forbid.add(request.client.host)
+ minutes = await incrementer(
+ models.session.auth_counter[request.client.host]
+ )
+ until = EPOCH() + minutes * 60
+ LOGGER.warning(
+ "%s is blocked (for %d minutes) until %s",
+ request.client.host,
+ minutes,
+ datetime.fromtimestamp(until).strftime("%c"),
+ )
+ database.remove_record(request.client.host)
+ database.put_record(request.client.host, until)
+ else:
+ LOGGER.warning("Failed auth, attempt #1 for %s", request.client.host)
+ models.session.auth_counter[request.client.host] = 1
diff --git a/pyninja/database.py b/pyninja/database.py
new file mode 100644
index 0000000..05116bd
--- /dev/null
+++ b/pyninja/database.py
@@ -0,0 +1,48 @@
+from pyninja import models
+
+
+def get_record(host: str) -> int | None:
+ """Gets blocked epoch time for a particular host.
+
+ Args:
+ host: Host address.
+
+ Returns:
+ int:
+ Returns the epoch time until when the host address should be blocked.
+ """
+ with models.database.connection:
+ cursor = models.database.connection.cursor()
+ state = cursor.execute(
+ "SELECT block_until FROM auth_errors WHERE host=(?)", (host,)
+ ).fetchone()
+ if state and state[0]:
+ return state[0]
+
+
+def put_record(host: str, block_until: int) -> None:
+ """Inserts blocked epoch time for a particular host.
+
+ Args:
+ host: Host address.
+ block_until: Epoch time until when the host address should be blocked.
+ """
+ with models.database.connection:
+ cursor = models.database.connection.cursor()
+ cursor.execute(
+ "INSERT INTO auth_errors (host, block_until) VALUES (?,?)",
+ (host, block_until),
+ )
+ models.database.connection.commit()
+
+
+def remove_record(host: str) -> None:
+ """Deletes all records related to the host address.
+
+ Args:
+ host: Host address.
+ """
+ with models.database.connection:
+ cursor = models.database.connection.cursor()
+ cursor.execute("DELETE FROM auth_errors WHERE host=(?)", (host,))
+ models.database.connection.commit()
diff --git a/pyninja/main.py b/pyninja/main.py
index fada705..845b6a9 100644
--- a/pyninja/main.py
+++ b/pyninja/main.py
@@ -1,31 +1,43 @@
+import logging
import os
import uvicorn
from fastapi import FastAPI
import pyninja
-from pyninja import routers, squire
+from pyninja import models, routers, squire
+LOGGER = logging.getLogger(__name__)
-def start(env_file: str = None) -> None:
+
+def start(**kwargs) -> None:
"""Starter function for the API, which uses uvicorn server as trigger.
- Args:
+ Keyword Args:
env_file: Filepath for the ``.env`` file.
"""
- squire.env = squire.env_loader(
- env_file or os.environ.get("env_file") or os.environ.get("ENV_FILE") or ".env"
- )
+ if env_file := kwargs.get("env_file"):
+ models.env = squire.env_loader(env_file)
+ elif os.path.isfile(".env"):
+ models.env = squire.env_loader(".env")
+ else:
+ models.env = models.EnvConfig(**kwargs)
+ if all((models.env.remote_execution, models.env.api_secret)):
+ LOGGER.info(
+ "Creating '%s' to handle authentication errors", models.env.database
+ )
+ models.database = models.Database(models.env.database)
+ models.database.create_table("auth_errors", ["host", "block_until"])
app = FastAPI(
- routes=routers.routes,
+ routes=routers.get_all_routes(),
title="PyNinja",
- description="Light weight OS agnostic service monitoring API",
+ description="Lightweight OS-agnostic service monitoring API",
version=pyninja.version,
)
kwargs = dict(
- host=squire.env.ninja_host,
- port=squire.env.ninja_port,
- workers=squire.env.workers,
+ host=models.env.ninja_host,
+ port=models.env.ninja_port,
+ workers=models.env.workers,
app=app,
)
if os.path.isfile("logging.ini"):
diff --git a/pyninja/models.py b/pyninja/models.py
new file mode 100644
index 0000000..71947e7
--- /dev/null
+++ b/pyninja/models.py
@@ -0,0 +1,195 @@
+import pathlib
+import re
+import socket
+import sqlite3
+from typing import Dict, List, Set, Tuple
+
+from pydantic import (
+ BaseModel,
+ Field,
+ FilePath,
+ PositiveFloat,
+ PositiveInt,
+ field_validator,
+)
+from pydantic_settings import BaseSettings
+
+
+def complexity_checker(secret: str) -> None:
+ """Verifies the strength of a secret.
+
+ See Also:
+ A secret is considered strong if it at least has:
+
+ - 32 characters
+ - 1 digit
+ - 1 symbol
+ - 1 uppercase letter
+ - 1 lowercase letter
+
+ Raises:
+ AssertionError: When at least 1 of the above conditions fail to match.
+ """
+ # calculates the length
+ assert len(secret) >= 32, f"Minimum secret length is 32, received {len(secret)}"
+
+ # searches for digits
+ assert re.search(r"\d", secret), "secret must include an integer"
+
+ # searches for uppercase
+ assert re.search(
+ r"[A-Z]", secret
+ ), "secret must include at least one uppercase letter"
+
+ # searches for lowercase
+ assert re.search(
+ r"[a-z]", secret
+ ), "secret must include at least one lowercase letter"
+
+ # searches for symbols
+ assert re.search(
+ r"[ !#$%&'()*+,-./[\\\]^_`{|}~" + r'"]', secret
+ ), "secret must contain at least one special character"
+
+
+class Payload(BaseModel):
+ """BaseModel that handles input data for ``Payload``.
+
+ >>> Payload
+
+ """
+
+ command: str
+ timeout: PositiveInt | PositiveFloat = 3
+
+
+class ServiceStatus(BaseModel):
+ """Object to load service status with a status code and description.
+
+ >>> ServiceStatus
+
+ """
+
+ status_code: int
+ description: str
+
+
+class Session(BaseModel):
+ """Object to store session information.
+
+ >>> Session
+
+ """
+
+ auth_counter: Dict[str, int] = {}
+ forbid: Set[str] = set()
+
+ info: Dict[str, str] = {}
+ rps: Dict[str, int] = {}
+ allowed_origins: Set[str] = set()
+
+
+class RateLimit(BaseModel):
+ """Object to store the rate limit settings.
+
+ >>> RateLimit
+
+ """
+
+ max_requests: PositiveInt
+ seconds: PositiveInt
+
+
+class EnvConfig(BaseSettings):
+ """Object to load environment variables.
+
+ >>> EnvConfig
+
+ """
+
+ ninja_host: str = socket.gethostbyname("localhost") or "0.0.0.0"
+ ninja_port: PositiveInt = 8000
+ workers: PositiveInt = 1
+ remote_execution: bool = False
+ api_secret: str | None = None
+ database: str = Field("auth.db", pattern=".*.db$")
+ rate_limit: RateLimit | List[RateLimit] = []
+ apikey: str
+
+ # noinspection PyMethodParameters
+ @field_validator("api_secret", mode="after")
+ def parse_api_secret(cls, value: str | None) -> str | None:
+ """Parse API secret to validate complexity.
+
+ Args:
+ value: Takes the user input as an argument.
+
+ Returns:
+ str:
+ Returns the parsed value.
+ """
+ if value:
+ try:
+ complexity_checker(value)
+ except AssertionError as error:
+ raise ValueError(error.__str__())
+ return value
+
+ @classmethod
+ def from_env_file(cls, env_file: pathlib.Path) -> "EnvConfig":
+ """Create Settings instance from environment file.
+
+ Args:
+ env_file: Name of the env file.
+
+ Returns:
+ EnvConfig:
+ Loads the ``EnvConfig`` model.
+ """
+ return cls(_env_file=env_file)
+
+ class Config:
+ """Extra configuration for EnvConfig object."""
+
+ extra = "ignore"
+ hide_input_in_errors = True
+
+
+class Database:
+ """Creates a connection to the Database using sqlite3.
+
+ >>> Database
+
+ """
+
+ def __init__(self, datastore: FilePath | str, timeout: int = 10):
+ """Instantiates the class ``Database`` to create a connection and a cursor.
+
+ Args:
+ datastore: Name of the database file.
+ timeout: Timeout for the connection to database.
+ """
+ self.connection = sqlite3.connect(
+ database=datastore, check_same_thread=False, timeout=timeout
+ )
+
+ def create_table(self, table_name: str, columns: List[str] | Tuple[str]) -> None:
+ """Creates the table with the required columns.
+
+ Args:
+ table_name: Name of the table that has to be created.
+ columns: List of columns that has to be created.
+ """
+ with self.connection:
+ cursor = self.connection.cursor()
+ # Use f-string or %s as table names cannot be parametrized
+ cursor.execute(
+ f"CREATE TABLE IF NOT EXISTS {table_name} ({', '.join(columns)})"
+ )
+
+
+session = Session()
+
+# Loaded in main:start()
+env = EnvConfig
+database = Database
diff --git a/pyninja/rate_limit.py b/pyninja/rate_limit.py
new file mode 100644
index 0000000..01d6c34
--- /dev/null
+++ b/pyninja/rate_limit.py
@@ -0,0 +1,66 @@
+import math
+import time
+from http import HTTPStatus
+
+from fastapi import HTTPException, Request
+
+from pyninja import models
+
+
+class RateLimiter:
+ """Object that implements the ``RateLimiter`` functionality.
+
+ >>> RateLimiter
+
+ """
+
+ def __init__(self, rps: models.RateLimit):
+ # noinspection PyUnresolvedReferences
+ """Instantiates the object with the necessary args.
+
+ Args:
+ rps: RateLimit object with ``max_requests`` and ``seconds``.
+
+ Attributes:
+ max_requests: Maximum requests to allow in a given time frame.
+ seconds: Number of seconds after which the cache is set to expire.
+ """
+ self.max_requests = rps.max_requests
+ self.seconds = rps.seconds
+ self.start_time = time.time()
+ self.exception = HTTPException(
+ status_code=HTTPStatus.TOO_MANY_REQUESTS.value,
+ detail=HTTPStatus.TOO_MANY_REQUESTS.phrase,
+ # reset headers, which will invalidate auth token
+ headers={"Retry-After": str(math.ceil(self.seconds))},
+ )
+
+ def init(self, request: Request) -> None:
+ """Checks if the number of calls exceeds the rate limit for the given identifier.
+
+ Args:
+ request: The incoming request object.
+
+ Raises:
+ 429: Too many requests.
+ """
+ if forwarded := request.headers.get("x-forwarded-for"):
+ identifier = forwarded.split(",")[0]
+ else:
+ identifier = request.client.host
+ identifier += ":" + request.url.path
+
+ current_time = time.time()
+
+ # Reset if the time window has passed
+ if current_time - self.start_time > self.seconds:
+ models.session.rps[identifier] = 1
+ self.start_time = current_time
+
+ if models.session.rps.get(identifier):
+ if models.session.rps[identifier] >= self.max_requests:
+ raise self.exception
+ else:
+ models.session.rps[identifier] += 1
+ else:
+ models.session.rps[identifier] = 1
diff --git a/pyninja/routers.py b/pyninja/routers.py
index 30ed2f8..dfb20e7 100644
--- a/pyninja/routers.py
+++ b/pyninja/routers.py
@@ -1,21 +1,21 @@
import logging
import secrets
import subprocess
+from datetime import datetime
from http import HTTPStatus
-from typing import Optional
+from typing import List, Optional
from fastapi import Depends, Header, Request
from fastapi.responses import RedirectResponse
from fastapi.routing import APIRoute
-from pyninja import auth, exceptions, process, service, squire
+from pyninja import auth, database, exceptions, models, process, rate_limit, service
LOGGER = logging.getLogger("uvicorn.error")
-# todo: Enable rate-limit and brute-force protection for running commands
async def run_command(
- request: Request, payload: squire.Payload, token: Optional[str] = Header(None)
+ request: Request, payload: models.Payload, token: Optional[str] = Header(None)
):
"""**API function to run a command on host machine.**
@@ -28,12 +28,29 @@ async def run_command(
APIResponse:
Raises the HTTPStatus object with a status code and detail as response.
"""
- if not all((squire.env.remote_execution, squire.env.api_secret)):
+ # todo: remove dependency authentication and convert it to condition match
+ # add failed auth to handle_auth_errors
+ # placeholder list, to avoid a DB search for every request
+ if request.client.host in models.session.forbid:
+ # Get timestamp until which the host has to be forbidden
+ if (
+ timestamp := database.get_record(request.client.host)
+ ) and timestamp > auth.EPOCH():
+ LOGGER.warning(
+ "%s is forbidden until %s due to repeated login failures",
+ request.client.host,
+ datetime.fromtimestamp(timestamp).strftime("%c"),
+ )
+ raise exceptions.APIResponse(
+ status_code=HTTPStatus.FORBIDDEN.value,
+ detail=f"{request.client.host!r} is not allowed",
+ )
+ if not all((models.env.remote_execution, models.env.api_secret)):
raise exceptions.APIResponse(
status_code=HTTPStatus.NOT_IMPLEMENTED.real,
detail="Remote execution has been disabled on the server.",
)
- if token and secrets.compare_digest(token, squire.env.api_secret):
+ if token and secrets.compare_digest(token, models.env.api_secret):
LOGGER.info(
"Command request '%s' received from client-host: %s, host-header: %s, x-fwd-host: %s",
payload.command,
@@ -44,11 +61,12 @@ async def run_command(
if user_agent := request.headers.get("user-agent"):
LOGGER.info("User agent: %s", user_agent)
else:
+ await auth.handle_auth_error(request)
raise exceptions.APIResponse(
status_code=HTTPStatus.UNAUTHORIZED.real,
detail=HTTPStatus.UNAUTHORIZED.phrase,
)
- process = subprocess.Popen(
+ process_cmd = subprocess.Popen(
payload.command,
shell=True,
universal_newlines=True,
@@ -58,7 +76,7 @@ async def run_command(
)
output = {"stdout": [], "stderr": []}
try:
- stdout, stderr = process.communicate(timeout=payload.timeout)
+ stdout, stderr = process_cmd.communicate(timeout=payload.timeout)
except subprocess.TimeoutExpired as warn:
LOGGER.warning(warn)
raise exceptions.APIResponse(
@@ -85,10 +103,8 @@ async def process_status(process_name: str):
APIResponse:
Raises the HTTPStatus object with a status code and detail as response.
"""
- if service_status := list(process.get_process_status(process_name)):
- raise exceptions.APIResponse(
- status_code=HTTPStatus.OK.real, detail=service_status
- )
+ if response := list(process.get_process_status(process_name)):
+ raise exceptions.APIResponse(status_code=HTTPStatus.OK.real, detail=response)
LOGGER.error("%s: 404 - No such process", process_name)
raise exceptions.APIResponse(
status_code=404, detail=f"Process {process_name} not found."
@@ -107,15 +123,15 @@ async def service_status(service_name: str):
APIResponse:
Raises the HTTPStatus object with a status code and detail as response.
"""
- service_status = service.get_service_status(service_name)
+ response = service.get_service_status(service_name)
LOGGER.info(
"%s: %d - %s",
service_name,
- service_status.status_code,
- service_status.description,
+ response.status_code,
+ response.description,
)
raise exceptions.APIResponse(
- status_code=service_status.status_code, detail=service_status.description
+ status_code=response.status_code, detail=response.description
)
@@ -129,24 +145,41 @@ async def docs() -> RedirectResponse:
return RedirectResponse("/docs")
-routes = [
- APIRoute(
- path="/service-status",
- endpoint=service_status,
- methods=["GET"],
- dependencies=[Depends(auth.authenticator)],
- ),
- APIRoute(
- path="/process-status",
- endpoint=process_status,
- methods=["GET"],
- dependencies=[Depends(auth.authenticator)],
- ),
- APIRoute(
- path="/run-command",
- endpoint=run_command,
- methods=["POST"],
- dependencies=[Depends(auth.authenticator)],
- ),
- APIRoute(path="/", endpoint=docs, methods=["GET"], include_in_schema=False),
-]
+def get_all_routes() -> List[APIRoute]:
+ """Get all the routes to be added for the API server.
+
+ Returns:
+ List[APIRoute]:
+ Returns the routes as a list of APIRoute objects.
+ """
+ APIRoute(path="/", endpoint=docs, methods=["GET"], include_in_schema=False)
+ dependencies = [Depends(auth.authenticator)]
+ for each_rate_limit in models.env.rate_limit:
+ LOGGER.info("Adding rate limit: %s", each_rate_limit)
+ dependencies.append(
+ Depends(dependency=rate_limit.RateLimiter(each_rate_limit).init)
+ )
+ routes = [
+ APIRoute(
+ path="/service-status",
+ endpoint=service_status,
+ methods=["GET"],
+ dependencies=dependencies,
+ ),
+ APIRoute(
+ path="/process-status",
+ endpoint=process_status,
+ methods=["GET"],
+ dependencies=dependencies,
+ ),
+ ]
+ if all((models.env.remote_execution, models.env.api_secret)):
+ routes.append(
+ APIRoute(
+ path="/run-command",
+ endpoint=run_command,
+ methods=["POST"],
+ dependencies=dependencies,
+ )
+ )
+ return routes
diff --git a/pyninja/service.py b/pyninja/service.py
index eb88ed8..a955bc4 100644
--- a/pyninja/service.py
+++ b/pyninja/service.py
@@ -2,7 +2,7 @@
import subprocess
from http import HTTPStatus
-from pyninja import exceptions, squire
+from pyninja import exceptions, models
current_os = platform.system()
@@ -13,7 +13,7 @@
)
-def get_service_status(service_name: str) -> squire.ServiceStatus:
+def get_service_status(service_name: str) -> models.ServiceStatus:
"""Get service status.
Args:
@@ -23,22 +23,22 @@ def get_service_status(service_name: str) -> squire.ServiceStatus:
ServiceStatus:
Returns an instance of the ServiceStatus.
"""
- running = squire.ServiceStatus(
+ running = models.ServiceStatus(
status_code=HTTPStatus.OK.real,
description=f"{service_name} is running",
)
- stopped = squire.ServiceStatus(
+ stopped = models.ServiceStatus(
status_code=HTTPStatus.NOT_IMPLEMENTED.real,
description=f"{service_name} has been stopped",
)
- unknown = squire.ServiceStatus(
+ unknown = models.ServiceStatus(
status_code=HTTPStatus.SERVICE_UNAVAILABLE.real,
description=f"{service_name} - status unknwon",
)
- unavailable = squire.ServiceStatus(
+ unavailable = models.ServiceStatus(
status_code=HTTPStatus.NOT_FOUND.real,
description=f"{service_name} - not found",
)
@@ -67,7 +67,7 @@ def get_service_status(service_name: str) -> squire.ServiceStatus:
elif output == "inactive":
return stopped
else:
- return squire.ServiceStatus(
+ return models.ServiceStatus(
status_code=HTTPStatus.NOT_IMPLEMENTED.real,
description=f"{service_name} - {output}",
)
diff --git a/pyninja/squire.py b/pyninja/squire.py
index 7be7429..2ef9734 100644
--- a/pyninja/squire.py
+++ b/pyninja/squire.py
@@ -1,125 +1,10 @@
import json
import os
import pathlib
-import re
-import socket
-from typing import Optional
import yaml
-from pydantic import BaseModel, PositiveFloat, PositiveInt, field_validator
-from pydantic_settings import BaseSettings
-
-def complexity_checker(secret: str) -> None:
- """Verifies the strength of a secret.
-
- See Also:
- A secret is considered strong if it at least has:
-
- - 32 characters
- - 1 digit
- - 1 symbol
- - 1 uppercase letter
- - 1 lowercase letter
-
- Raises:
- AssertionError: When at least 1 of the above conditions fail to match.
- """
- # calculates the length
- assert len(secret) >= 32, f"Minimum secret length is 32, received {len(secret)}"
-
- # searches for digits
- assert re.search(r"\d", secret), "secret must include an integer"
-
- # searches for uppercase
- assert re.search(
- r"[A-Z]", secret
- ), "secret must include at least one uppercase letter"
-
- # searches for lowercase
- assert re.search(
- r"[a-z]", secret
- ), "secret must include at least one lowercase letter"
-
- # searches for symbols
- assert re.search(
- r"[ !#$%&'()*+,-./[\\\]^_`{|}~" + r'"]', secret
- ), "secret must contain at least one special character"
-
-
-class Payload(BaseModel):
- """BaseModel that handles input data for ``Payload``.
-
- >>> Payload
-
- """
-
- command: str
- timeout: PositiveInt | PositiveFloat = 3
-
-
-class ServiceStatus(BaseModel):
- """Object to load service status with a status code and description.
-
- >>> ServiceStatus
-
- """
-
- status_code: int
- description: str
-
-
-class EnvConfig(BaseSettings):
- """Object to load environment variables.
-
- >>> Settings
-
- """
-
- ninja_host: str = socket.gethostbyname("localhost") or "0.0.0.0"
- ninja_port: PositiveInt = 8000
- workers: PositiveInt = 1
- remote_execution: bool = False
- api_secret: str | None = None
- apikey: str
-
- # noinspection PyMethodParameters
- @field_validator("api_secret", mode="after")
- def parse_api_secret(cls, value: str | None) -> str | None:
- """Parse API secret to validate complexity.
-
- Args:
- value: Takes the user input as an argument.
-
- Returns:
- str:
- Returns the parsed value.
- """
- if value:
- try:
- complexity_checker(value)
- except AssertionError as error:
- raise ValueError(error.__str__())
- return value
-
- @classmethod
- def from_env_file(cls, env_file: Optional[str]) -> "EnvConfig":
- """Create Settings instance from environment file.
-
- Args:
- env_file: Name of the env file.
-
- Returns:
- EnvConfig:
- Loads the ``EnvConfig`` model.
- """
- return cls(_env_file=env_file)
-
- class Config:
- """Extra configuration for EnvConfig object."""
-
- extra = "ignore"
- hide_input_in_errors = True
+from pyninja.models import EnvConfig
def env_loader(filename: str | os.PathLike) -> EnvConfig:
@@ -151,6 +36,3 @@ def env_loader(filename: str | os.PathLike) -> EnvConfig:
raise ValueError(
"\n\tUnsupported format for 'env_file', can be one of (.json, .yaml, .yml, .txt, .text, or null)"
)
-
-
-env = EnvConfig
diff --git a/pyproject.toml b/pyproject.toml
index 2b5ff42..72b0a94 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "PyNinja"
dynamic = ["version", "dependencies"]
-description = "Light weight OS agnostic service monitoring API"
+description = "Lightweight OS-agnostic service monitoring API"
readme = "README.md"
authors = [{ name = "Vignesh Rao", email = "svignesh1793@gmail.com" }]
license = { file = "LICENSE" }
diff --git a/release_notes.rst b/release_notes.rst
index d180fa3..2e300ff 100644
--- a/release_notes.rst
+++ b/release_notes.rst
@@ -3,7 +3,7 @@ Release Notes
0.0.0 (08/10/2024)
------------------
-- Release first stable version
+- Release stable version
0.0.0-a (08/10/2024)
--------------------
|