From 4d8612c3de82a4f0380e9650748490aca4983b24 Mon Sep 17 00:00:00 2001 From: Vignesh Rao Date: Sun, 11 Aug 2024 11:39:37 -0500 Subject: [PATCH] Add rate limit and brute force protection Restructure code --- .gitignore | 1 + README.md | 2 +- doc_gen/index.rst | 45 ++++- docs/README.html | 2 +- docs/README.md | 2 +- docs/_sources/README.md.txt | 2 +- docs/_sources/index.rst.txt | 45 ++++- docs/genindex.html | 124 ++++++++++--- docs/index.html | 360 ++++++++++++++++++++++++++++-------- docs/objects.inv | Bin 656 -> 843 bytes docs/py-modindex.html | 15 ++ docs/searchindex.js | 2 +- pyninja/auth.py | 71 ++++++- pyninja/database.py | 48 +++++ pyninja/main.py | 34 ++-- pyninja/models.py | 195 +++++++++++++++++++ pyninja/rate_limit.py | 66 +++++++ pyninja/routers.py | 107 +++++++---- pyninja/service.py | 14 +- pyninja/squire.py | 120 +----------- pyproject.toml | 2 +- release_notes.rst | 2 +- 22 files changed, 957 insertions(+), 302 deletions(-) create mode 100644 pyninja/database.py create mode 100644 pyninja/models.py create mode 100644 pyninja/rate_limit.py diff --git a/.gitignore b/.gitignore index 7d1bca3..5628646 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,6 @@ temp.py logging.ini *.log +*.db !samples/* diff --git a/README.md b/README.md index d7880de..023d540 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # PyNinja -Light weight OS agnostic service monitoring API +Lightweight OS-agnostic service monitoring API ![Python][label-pyversion] diff --git a/doc_gen/index.rst b/doc_gen/index.rst index 36b231e..9c34884 100644 --- a/doc_gen/index.rst +++ b/doc_gen/index.rst @@ -21,11 +21,6 @@ Authenticator ============= .. automodule:: pyninja.auth -Exceptions -========== - -.. automodule:: pyninja.exceptions - Routers ======= @@ -44,26 +39,56 @@ Service .. automodule:: pyninja.service -Squire +Database +======== + +.. automodule:: pyninja.database + +RateLimiter +=========== + +.. automodule:: pyninja.rate_limit + +Exceptions +========== + +.. automodule:: pyninja.exceptions + +Models ====== -.. autoclass:: pyninja.squire.Payload(BaseModel) +.. autoclass:: pyninja.models.Payload(BaseModel) + :exclude-members: _abc_impl, model_config, model_fields, model_computed_fields + +==== + +.. autoclass:: pyninja.models.ServiceStatus(BaseModel) + :exclude-members: _abc_impl, model_config, model_fields, model_computed_fields + +==== + +.. autoclass:: pyninja.models.Session(BaseModel) :exclude-members: _abc_impl, model_config, model_fields, model_computed_fields ==== -.. autoclass:: pyninja.squire.ServiceStatus(BaseModel) +.. autoclass:: pyninja.models.RateLimit(BaseModel) :exclude-members: _abc_impl, model_config, model_fields, model_computed_fields ==== -.. autoclass:: pyninja.squire.EnvConfig(BaseModel) +.. autoclass:: pyninja.models.EnvConfig(BaseModel) :exclude-members: _abc_impl, model_config, model_fields, model_computed_fields ==== +.. automodule:: pyninja.models + :exclude-members: Payload, ServiceStatus, EnvConfig, Session, RateLimit, env, database + +Squire +====== + .. automodule:: pyninja.squire - :exclude-members: Payload, ServiceStatus, EnvConfig Indices and tables ================== diff --git a/docs/README.html b/docs/README.html index dfc2964..9acd69c 100644 --- a/docs/README.html +++ b/docs/README.html @@ -45,7 +45,7 @@

Navigation

PyNinja

-

Light weight OS agnostic service monitoring API

+

Lightweight OS-agnostic service monitoring API

Python

Platform Supported

Platform

diff --git a/docs/README.md b/docs/README.md index d7880de..023d540 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,5 @@ # PyNinja -Light weight OS agnostic service monitoring API +Lightweight OS-agnostic service monitoring API ![Python][label-pyversion] diff --git a/docs/_sources/README.md.txt b/docs/_sources/README.md.txt index d7880de..023d540 100644 --- a/docs/_sources/README.md.txt +++ b/docs/_sources/README.md.txt @@ -1,5 +1,5 @@ # PyNinja -Light weight OS agnostic service monitoring API +Lightweight OS-agnostic service monitoring API ![Python][label-pyversion] diff --git a/docs/_sources/index.rst.txt b/docs/_sources/index.rst.txt index 36b231e..9c34884 100644 --- a/docs/_sources/index.rst.txt +++ b/docs/_sources/index.rst.txt @@ -21,11 +21,6 @@ Authenticator ============= .. automodule:: pyninja.auth -Exceptions -========== - -.. automodule:: pyninja.exceptions - Routers ======= @@ -44,26 +39,56 @@ Service .. automodule:: pyninja.service -Squire +Database +======== + +.. automodule:: pyninja.database + +RateLimiter +=========== + +.. automodule:: pyninja.rate_limit + +Exceptions +========== + +.. automodule:: pyninja.exceptions + +Models ====== -.. autoclass:: pyninja.squire.Payload(BaseModel) +.. autoclass:: pyninja.models.Payload(BaseModel) + :exclude-members: _abc_impl, model_config, model_fields, model_computed_fields + +==== + +.. autoclass:: pyninja.models.ServiceStatus(BaseModel) + :exclude-members: _abc_impl, model_config, model_fields, model_computed_fields + +==== + +.. autoclass:: pyninja.models.Session(BaseModel) :exclude-members: _abc_impl, model_config, model_fields, model_computed_fields ==== -.. autoclass:: pyninja.squire.ServiceStatus(BaseModel) +.. autoclass:: pyninja.models.RateLimit(BaseModel) :exclude-members: _abc_impl, model_config, model_fields, model_computed_fields ==== -.. autoclass:: pyninja.squire.EnvConfig(BaseModel) +.. autoclass:: pyninja.models.EnvConfig(BaseModel) :exclude-members: _abc_impl, model_config, model_fields, model_computed_fields ==== +.. automodule:: pyninja.models + :exclude-members: Payload, ServiceStatus, EnvConfig, Session, RateLimit, env, database + +Squire +====== + .. automodule:: pyninja.squire - :exclude-members: Payload, ServiceStatus, EnvConfig Indices and tables ================== diff --git a/docs/genindex.html b/docs/genindex.html index 7b9e357..44c4b7b 100644 --- a/docs/genindex.html +++ b/docs/genindex.html @@ -49,6 +49,7 @@

Index

| F | G | H + | I | M | N | P @@ -62,13 +63,17 @@

Index

A

  • APIResponse +
  • +
  • auth_counter (pyninja.models.Session attribute)
  • authenticator() (in module pyninja.auth)
  • @@ -78,11 +83,13 @@

    A

    C

    @@ -90,10 +97,14 @@

    C

    D

    @@ -102,17 +113,17 @@

    D

    E

    @@ -120,7 +131,11 @@

    E

    F

    +
    @@ -128,11 +143,15 @@

    F

    G

    • get_process_status() (in module pyninja.process) +
    • +
    • get_record() (in module pyninja.database)
    • get_service_status() (in module pyninja.service)
    • @@ -142,7 +161,25 @@

      G

      H

      + +
      + +

      I

      + + +
      @@ -150,17 +187,25 @@

      H

      M

        +
      • max_requests (pyninja.models.RateLimit attribute) +
      • module
        • pyninja.auth +
        • +
        • pyninja.database
        • pyninja.exceptions
        • pyninja.main +
        • +
        • pyninja.models
        • pyninja.process +
        • +
        • pyninja.rate_limit
        • pyninja.routers
        • @@ -175,11 +220,11 @@

          M

          N

          @@ -187,17 +232,26 @@

          N

          P

          + + + + + + + + +
          diff --git a/docs/objects.inv b/docs/objects.inv index 962d540f31e15c5ee81aa6d937b3d5e2c377bd18..00590790a079be7dfbfe17208f534c107d76fb00 100644 GIT binary patch delta 736 zcmV<60w4X51%`IwjsMNGptg1(ZVeF-0f$-yaZ+(rv zUZ14=tnIZF$v`glF3dMGJAa@@$>nc?hj4h_#Rw$k%K(ZX+y8ZgK`-tx98KxrBG$~H$JYl!{W zszDQD0bk~#`a?|Y2Ehm3>_(h}K?fXw`qb`?dR~=zhT583kx?kZ%6n@95LLrKBx|yU z93MdW%7{IMNPjy9y3E-V5+>^*oHQ>Uth0?~QK%9nS$`$I2D9fB){Z!D%RRrq5CcmM z*Y(wLsm7*1Ov7HS8fz!prS2yH5h2toFsuyUacP^ZrkhZ3+DiwfO~Y214QFjEms{0F z8eRFpp{KNf19u~)J(gLDd_pr*s4|z~{EY3XzOVC_BeT%s zGj#6A9DkoH75O$8(#nelz6o<`nyRIqW&$xqO9L_ypcU=wkeGweWH`N7n(M>-7U2Ml z?Un8`bvT#d&>Y~{mE~Yl%}lKE63>pFa?s{USY>o#Dsn#&DJ@MFUpzNe1`~kdT`Zog z*HZEHaCiUs&{@ZBLwR<~n=Y>;iEcH1k4hIikXeaKa)N9?M9Jf7mi-zZ7iY0a-Av*a zh>$7hoH{E0_vf$ef}oF8L!yTt7U7%Tu%*Z4aU&~x%;CsZXS-Lh)fl>4UeXqb32k-( S=C#bM^ZttLVDTRy=%)_1zif^G delta 548 zcmV+<0^9w|29O1icYnc-+aM5z?|BNU_8Qc^?rqiPu!n71z_oJE4_ z>ux6AN^J^KP2t>=M4DF*hgN|vDaJtX1CVfldyB0!3X1G9oW~+&OTo|M(FOu6Np~Q4 zWmTC6zac-4!3bsBSedE}^ngdLpvZJ!3W_oi{WdIe{24SB8TJ6*nBymF_WF=e)yR18 zu6SG!ek&PoB7dPwyd*;X5({3gQR@nXHZD+n3P7~^G%k^pPRHsi1iC@t6}qML9&IbY z9K~KYc{71(TNNnu)1tP1@f&1?)DxE~IEfy_w3fv$g3ww50VhzQKl%%k`DO7HN>9XA zc8!(Q{F6NZeAHoz-8ktMV$&&ucez>Y-g?EngeMrUz<(>$uDs4uncT2qo2{TsCYH@L zaMIKXRfCEfR?_u&yfI~d>58S2U-Nn1JbZrIFi+Ke{93W~9m_L)1lx9BlIZFHrsw*& zM!rCkBG0oq_Aw1tm#{%@6!9IJSO#EZ&%%Fy|M4sahBp3E$&}%{RqW*d@p9ezypYA7 m@<$-MjvZF7VB0RPython Module Index     pyninja.auth
              + pyninja.database +
              @@ -73,11 +78,21 @@

          Python Module Index

              pyninja.main
              + pyninja.models +
              pyninja.process
              + pyninja.rate_limit +
              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) --------------------