pax_global_header00006660000000000000000000000064144755563770014541gustar00rootroot0000000000000052 comment=622cf86b4cf815cb716576f117ab5f56b7c77441 pycrowdsec-0.0.5/000077500000000000000000000000001447555637700137255ustar00rootroot00000000000000pycrowdsec-0.0.5/.github/000077500000000000000000000000001447555637700152655ustar00rootroot00000000000000pycrowdsec-0.0.5/.github/workflows/000077500000000000000000000000001447555637700173225ustar00rootroot00000000000000pycrowdsec-0.0.5/.github/workflows/pypi_publish.yml000066400000000000000000000012051447555637700225520ustar00rootroot00000000000000name: Upload Python Package on: release: types: [published, prereleased, released] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} pycrowdsec-0.0.5/.github/workflows/unittests.yml000066400000000000000000000014241447555637700221100ustar00rootroot00000000000000name: Python Unittests on: push: branches: [ main ] pull_request: branches: [ main ] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.7", "3.8", "3.9", "3.10"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | docker network create net-test python -m pip install --upgrade pip python setup.py install python -m pip install -r requirements-dev.txt - name: Lint check run: | black --check -l 100 ./ - name: Tests run: | python -m pytest pycrowdsec-0.0.5/.gitignore000066400000000000000000000037621447555637700157250ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/pycrowdsec-0.0.5/LICENSE000066400000000000000000000020551447555637700147340ustar00rootroot00000000000000MIT License Copyright (c) 2020-2021 Crowdsec Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.pycrowdsec-0.0.5/README.md000066400000000000000000000117311447555637700152070ustar00rootroot00000000000000

# PyCrowdSec [CrowdSec](https://github.com/crowdsecurity/crowdsec) is a FOSS tool which parses logs and detects attacks. PyCrowdSec enables integration of CrowdSec with python projects. It is easy to setup and boosts the security by leveraging CrowdSec's attack detection capabilities. PyCrowdSec contains a python client library for CrowdSec, as well as middlewares for django and flask integrations. ## Installation: ```bash pip install pycrowdsec ``` You'll also need an instance of CrowdSec running, see installation instructions [here](https://docs.crowdsec.net/Crowdsec/v1/getting_started/installation/) ## Client library: ### StreamClient This client polls CrowdSec LAPI and keeps track of active decisions. In the below example assume that there's a ban decisions for IP "77.88.99.66" and captcha decision for country "CN". **Basic Usage:** ```python from pycrowdsec.client import StreamClient client = StreamClient( api_key=, ) client.run() # This starts polling the API assert client.get_current_decisions() == { "77.88.99.66": "ban" "CN": "captcha" } assert client.get_action_for("77.88.99.66") == "ban" assert client.get_action_for("CN") == "captcha" ``` The `CROWDSEC_API_KEY` can be obtained by running ```bash sudo cscli bouncers add python_bouncer ``` The `StreamClient`'s constructor also accepts the following optional parameters for more advanced configurations. **lapi_url** : str Base URL of CrowdSec API. Default is http://localhost:8080/ . **interval** : int Query the CrowdSec API every "interval" second **user_agent** : str User agent to use while calling the API. **scopes** : List[str] List of decision scopes which shall be fetched. Default is ["ip", "range"] ### QueryClient This client will query CrowdSec LAPI to check whether the requested item has any decisions against it. In the below example assume that there's a ban decisions for IP "77.88.99.66" and captcha decision for country "CN". **Basic Usage:** ```python from pycrowdsec.client import StreamClient client = StreamClient( api_key=, ) client.run() # This starts polling the API assert client.get_action_for("77.88.99.66") == "ban" assert client.get_action_for("CN") == "captcha" ``` The `QueryClient`'s constructor also accepts the following optional parameters for more advanced configurations. **lapi_url** : str Base URL of CrowdSec API. Default is http://localhost:8080/ . **user_agent** : str User agent to use while calling the API. ## Flask Integration: See `./examples/flask` for more detailed example (includes captcha remediation too). A minimal flask app with PyCrowdSec protection would look like: ```python from flask import Flask from pycrowdsec.client import StreamClient from pycrowdsec.flask import get_crowdsec_middleware client = StreamClient(api_key=) app = Flask(__name__) app.before_request( get_crowdsec_middleware(actions, c.cache, exclude_views=["ban_page"] ) actions = { "ban": lambda: redirect(url_for("ban_page")), } @app.route("/ban") def ban_page(): return abort(403) @app.route("/") def index(): return "Hello" if __name__ = "__main__": app.run(host="0.0.0.0") ``` ## Django Integration: See `./examples/django` for more detailed example (includes captcha remediation too). After installing `pycrowdsec`, in your `settings.py` add the following line in the `MIDDLEWARE` list ```python MIDDLEWARE = [ ......... "pycrowdsec.django.crowdsec_middleware", ......... ] ``` Next add define the following variables required for `pycrowdsec` to function. ```python PYCROWDSEC_LAPI_KEY = PYCROWDSEC_ACTIONS = { "ban": lambda request: redirect(reverse("ban_view")), } # IMPORTANT: If any action is doing a redirect to some view, always exclude it for pycrowdsec. Otherwise the middleware will trigger the redirect on the action view too. PYCROWDSEC_EXCLUDE_VIEWS = {"ban_view"} ``` You'll also need to register a view with name `ban_view`. In this example all the banned IPs would be redirected to the `ban_view` For more advanced configurations, you can specify the following variables in your `settings.py` **PYCROWDSEC_POLL_INTERVAL** int : Query the CrowdSec API every `PYCROWDSEC_POLL_INTERVAL` seconds. **PYCROWDSEC_LAPI_URL** str: Base URL of CrowdSec API. **PYCROWDSEC_ACTIONS** Dict[str, Callable]: Action to be taken when some request matches CrowdSec's decision. **PYCROWDSEC_REQUEST_TRANSFORMERS** List[Callable]: Obtains value from Django Request object, this value is used to match the request with CrowdSec's decisions. By default it contains only one transformer which obtains IP from the request. pycrowdsec-0.0.5/assets/000077500000000000000000000000001447555637700152275ustar00rootroot00000000000000pycrowdsec-0.0.5/assets/pycrowdsec.jpg000066400000000000000000002406071447555637700201240ustar00rootroot00000000000000JFIFCCqq"   d!1 A"Qa 2q#BR$3b%4CSr(5VtUcu&78DHWsw  L!1A"Qaq2 #BR$3b4CSrDs%TV ?┥)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR+ D.ET5 DO1)|)~K(_2ր)G J{)*wjW7JǨQk@r)JR)JR)JR)JR)JVMBp3sÚV &=݂nL}6܅LlҶAOvo*N7%Ind|piZҔ)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)Cӭt}G _K0\Qs[VlS -mDW%H!KRR^t!,Eh1O2aKXI܄-šҥhHʖ%)H)D$9=F׊]!]7.&eF#5 {.<nqyu+Lԝa-a:ځfٝE{ɖķ^/kOX̯aq!ێRcqEq&}ykXk$1 yoX[IߵeϘOr*vtϧtKɬ딇Bh+)eqKZ @ʐ;T' 8̟|\}DUc &IdO\ni؆=tJuٷ'lt*q[g7ǔgluVG"`՗o}=ɾRX~}e;k4T\-^qj,ԴRҹ,OlJ@JR TI$)R[JR)JR)JR)JRی,+?j>eS0+NW7ql0,r/9MG*0lJHRrHafS~ݺΘڣ^͆ Q"!Yq;pE%vR zO N5ǔ)9ze@cf,9ah͘.l&57IZ셇TO IGgۜ S:舰?Ҿ;K  He/ߙ45T@O-69gp kRTC/8[RѸKd.qݧJy+`Dk.њ.ZG)an$*+J$!$)8Si5G!l V䨀{GνB߯ZН8Lŋbn.Umjԛ5$ CSM2pdGz$HB# mPr>}W;JRiJR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR({z:?HwtA0N*jwjumOG8'EIʔC6i,:5,j߶??Mo^q=xIi6:qJf7썾;Y"A$!R2nsQ9H ~_u'_@\[3k8 O4ۈ\,608+!p{|j-?_p)ST_ l !)|&ѭH@Z$U'BOz:!}¼XCWgr &:;iF; ؏A&O鷧oö zo[{ 8=$Jo8 86@?#g #[JUu)JZR`mu'aj=JAm2G9#88n)[JRmW?WL)!t:5#v眾j)t w9a%􈯢eIac(6(fYۢovwPK0n3dp#3T Rqӏ^jYE' p\S7ѫZg32mjʃmȉ%6ߞղqNYӋոU-PV́Cb)2I!*,klmFM:6ǿELm MDkp].3[;r}A|:iJUm)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JRqpK(g_dR٬W7ˆj"Nzu"sfke9%MrKM*CJqoYwNunI䔄'FwF/<^DK2%}uVPusC^n@m)m#d)B@;oza$EԷ0amC q5gHRsʀFԥ*L.7ï'%ߜu,g]pH2Q&l?[W t ޽"Q |:}?嵧yZM@3-m/5f r uO )$d P\N(1') AyGȠHR9%$%\lyz"r)\z#ͼsT~^}9@ة]w#q>,,xš%]r+ŶmB >*$TBI~%zpl HLD!l21Iܥ@uCo lV9V6N;9 W(߿v5&XpF\qpq+c7ӗ9qg d4:SƄaɵ>+Qj [XNjVky)Qn"uF5w ? \ڕ=3ʵ,srr.W.K]S}TJVI e-a4iMOKӬ𧚱E{KL-b2v$-hֵml!j ^Ns?)ƞ_ptҝ&yH2+Z RQ헜Z`MQQnBt\Y/:STe V<k+2r8$uTv546h$a;7)n2ʟmӻg4[[ ʮqr(Fe1YNPC"pYۜQ0A[mOgdtK|,̃: śdgYQ%0?LgˈuPB<s}GUs;lH1%#Aun>kvVԶN^ *Zzj9c iFEOgSDnN-oZ+ikJJJN$3xWӁ*c]g$qL2n~Mbְ,[ݲoۛnFC_.+6uٸFcQRPS#dqR/?f57oV(6o [p!e:˓]}};7O?U&VU?̕)g\>J@=Z})JFAX;tuiUۈP+ma+BIҽ,x&ԅj <8;.?h >j*Y1bnTOKZNOE#|2ˮ$ꔠVY,nyͲl&n]B*Ű=R7;W^N[( bO^lj\tq'MM P>~ZU"#)JTuhR ZTI);Vh6?;kCMA57x'=7 M$ 5l]$H7;D)ovm]W]pOwƬjY;]2~##{'-PCة(a)o XtL-h 'c[.%-2Vwq.RcEW)2`ύ&fC$ӪVڭ- 0C~H̪8h<)HRJpbk'qR'9~5.1Lj' -׉v.1”^q7[Md?r9KklG] ƺжrRKl^yl6Zdoa = }h@;RG HSm*A?~?\c?:R\z~.}3mcw};-Ũ;@ްg;w+<\a{ßK*l)ʥu{8(PGCX+HёqwțrpNHO\$ҔiUR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JRQӿ^}~2d3Hya:Zi'kqBS-IHr@VfO"DkMBژݡMy:qZy͋d$]-eF&|6&L$D+o*f]|{ !dJ"<[qyƙ:[Mo;S渔FOmJR)JR)JR+8:2.f[qmlʔ)N!;-e1`ҬCC;<&ӒMtŮP$OՒ p!gp{P G4-^#zoo =%w\- [9+Q-Pm3[DYlcytH :`M&^NkN",[/~Bh[4JㅧuhJڢ8s.)?}8#pϧ4s%ǮȸYg?ohyAvy"sLL9z#S B˙d}>mQ--\T+;!m6|/hJԑN/h KB[O0> <>XR)JR)J*Ruۿ7#ƺ&ck[Zd]'FʕBqu!1[v-{]#S(7HNʏ6+t!/q(s\t%\[h>xU\\Mzj@IK)XxFRT}ĺ[*ݟ\W1JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR{{zgaVJp!- wa*۩pmE"]OD8W֚աVK6?krGh 7rla:R&;2%J< >0d4?<0S_7R_^m\Z`+~Xܙajs 5hiݰl,RWd͸8O0Sa=LuAV6RA)DkSQ*Z֢j;kQu?Nr,Յ~).mu߽y֝c:E5ۑڙfK" $ +xJNR^^Vm?l:gF)wck:PRiMɓ%Gma H1DyGO'bq__]u\.Abtc9 ;[bO_UzETmZ* ItP_V*7H}7l|S)JUFT)JR;lwZ҆nUqڏfl72}6VTXfLv6mWw<^kڃpu.^PRaJy8VnuhSv|fvM!puϹIiSeV,O&c=6Iz' S%AyrjJB|Bk }6aiR' \B\H(gҩԥ)JR)JR)JR)JR)JR)=|~#|iUCuVm-ZٽmRxȳHSP.ѐya|l*bS\ gi3Sdo:ON5#V~ Gj]}A^}b/"eN冄eCP|̈́VR:ab3ou%!HuM: ɒZ(8sղ!'Ffd9,%ۈZO%i;oBܰ*=xG/M.ҹ%Ƨ\%S{̎9;,+YA&BaYQXޚVІx>*a07(JJB 2ZXPn]-/E{JܷI)eDI :H%aH<Ҕ\ԥ)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JRòޡێWO 8 R[r|&>̏RTPTTCdvu;: ;ɑUu KkW {#X1 1}s$k"-.USRyČ3s?TIvߗTv^ւӡEFoӕVil77ۥ\H>;/q;"\!-\Z09nG7s=Nv ߽~~\#4eX67qK,4'y4֐9G2њ:۠-2aę)䙲aBmF98zշ5ur\O7(BR9$) D^qiWx~%K  .$51)kMz} $]7nVcn^[6GU#n -#;ٯH~"묚vqT]D+ř:*c6>uFoCW#܋6)$"csҒ{f*o<6 ~Y\u6+R\Pwfgc܄Ӣ@1lAr*!E5wHMG ˆlu":6aWmSo1A!M jGR-3 7'hfөV8j6wzRљФ1Fą!yԤp{VL<Өq*(([kJqRxPyԪΩꄔF\# Sb[@?v6$ ,_ᕪ7vٕfxӁ+]RK{ˎ1m8[r4GMڬ7T|Uzm%dJwKE\t #w]9Mu6ŦnsQK,8򣑟p[=Gz~:l{+G`L:|f<ʔ:4gGJ;"T_rIݏ4̺I;QӯT7v_%Z[zCjڭGȊmwKH3d Te4nwHIHPFgcwd45:TxƦeVIC7{e3RNA۸{P\VmncY6[?))J]f| 'rO0WH~ڽM4V#ڐΫ^l)@a&C^P0IV9t՛^.$6; Q+)eK~Qzd$)E^kcq[9%bnneu%/d[\+iī}u#G鬗ܺلiXHdB7@mς[l/l*qեJI {P?J< ;-ax+!/7n;"H%-e %Jۚ,SPaKoǯP*d:=׈@i/!(ݓ`m mRQi$\L2vW>pC ( QpJN Z *\m3_\H:+jDYM)Zq,`R+j$\>ApMyjO*qO^]7|$.(%}CM)(RIW)Zm<-A#_PPd˲G½]_Q;}}{d9tٛzA߿rܟSז{q^2qO<|NrIs'f-vc٠ŏO԰ޕ[uWŅT/܅耠.|OΩ¢c;H#lSG?c$Ғ]iIy7^eAƏC褍;G}#(,ccVyQNēw[rzSԞNZڞŲ6Qݱ]F]JSȬlJJ IHU=X&o;}'`+>Ϫ]<ڳ۰#kwxBϯqn'?LiJRYS&7韣m|.}/&F1ؐTd}jj?ĺs/q6WW[Ш\ * TK^4-Cnu͖K}TLIӺT?Z=6?h֋I6rHKi)]6-vW'j1Ȳ1l"Ss 3.hCbsu 9#fiA9^O?aVP/iB)ZZHhy2[$8w}uj -*Q's62XziBޠLrWRbG[Xd*,!0DdJ(_ 0Jƈ.I Tv(m`MuЖpZks;p ʾW6\Xmݗ&cqZJiE G*IENG w9jD[~aR~.ai:}H=RLiA^{%JQ*RQ%J;Dʔ2TI5^+r|8ufCGrM5njlb^isaK;=ޝw n?Cڽxc[`h(m@FS vl4@TR9"{yy)NU:ڔHҲ=  ҩj=V,s?J*9Z"9>4$%yq j xBնK=}f\mK|rbM)m̆2Bw$k4ֹn6zL];gGv7;?ЗZcm.%+IRAzm[aD4c@KBЬFA:uv֎lQ5wI7z>/Wp)o- n;qIi[m`Y}lvlV9hZ-Gdʺ!l8Fld' ;N%E)~ "C䄡Kb1d+/7Tu#Vܥ"JJr03%*IJӽu/e(Bl% HxOhdAGUMpX5&te%l?^&;+]ac;U+Z,HZWC(yy)H@cM=b~ND_ 8yZ6M:/;L?pN%Bۏ!![ Uʢk߿}2Jz\:$8VZKK'HC,fN)'B (SyQm8ůI2nca.+4R ۋm*R B"N[eHYBևYIۙ%Wm(h)RNa\)-)*JO`0@#YJ N H+=┥+o3?2OkR)Jm0v#|jBA p{;pG$~><*e؏\&43]5JsR{dbrd,i eȗX%H$n%|ec;^#}e%3δۡqZoii\$9M2QnTvdJl lBV^m *C8?+RRpG FG5'.՜dI>Y)Hɬq_liDr ҥ)9 rLbѢyÚmZ{!6Շ*8[S ۀxdlByJ²m_m.62Bq+G' m^`L7HchziΩi*zl!u.p[._~ޫcΩ^չxRN^tD?%9<7%ED[$pR HBS夶FwҔEԥ)JRp1H}T[fqBv%FT:=R #oR;ugOQijL\uh`sķ4IHRR뉕( дVUϧ`۵5 aؖ#3܋hWʝRAڬY%hr~3USg(ݖ uՈ<xp#b:c-2M4iJmB$%#~TyFo%Cn #Ďu_+иe-!-; ۄV1$I$IrO>rj& Bg(CLls:K,+~Ssa)BBD'4\@٥Ov5GhLFV6?i,j t͵;s!EH* T@#`}n3=3 Ԗoc1" 3lА`"CY4+1f^iSj.۝Q$b0,+*R,D RҔ)JED5'0CD~32-IV}ow-mP$MK;RT`xj FD| ِxZIJ)R%봿1w %<αx 3ulc8[3VcJtT^w<ծDx +&CR7QT{I95izbZi)&i߭7gre)H"<8i{<-H%ƙmNB Ҽ_GNL:’Ŕ[ĻdǗԤ67/A5ġjݯ&kffxYX7N;CfS4nM뷷_y3ʇ]qQڻoL<#[n-]T-vѝR\yQ>-BfCsNu搜8L4y^h-„`)^tv1bkorN`gNp#i SAŨLrfYrL2%pbO9!S,JI}(qV-VVRIr{VUm M4B3 R|})JS{sw3ۜfׯ)J};ҷ%*Q lsT`\qGJGR N2ьI+Je"}K<u )u6ʷ? S2YNUp~l-c ;\ucjn$X C@s)թkQUO&b]%qz']٭C `"S-n 'rR X:qmښu)ʶA[kBJ9ؑI}CXN7"SBU}Ysly׶eRˆ:ku'<&hQݍCN_cˋ Zb۝h8ŠJR{iN@VRҔY`q㏀'O~jcJR)JR)JUBlaS0L^Ry^{TIK5;ڪĆCl|]rv7[c™mZ“_=1&`MްjWٖ[R CBXqjߢ¹=y^u;m)b5\\k2U@Rv):6H؀Y0tɿ51U9wl;lڐ #Ww\?4_YpBEK +ڗ5iS2{3@`6t|Fؑ4Gp 8 '?\w);FzU3S{$ xy)ja [溒.lDwp\R] >bVU{E:Ќ^5eo 8ԒLh\G<{QwoGFH^K9^T)RWQVi)m1v!incZ6!J]LOLY1cnGi\bkmCYR`8(•XWn]G9aNT"ZaDk%RTW9`d,A&*\ .ڸj^߻Zop ۡ NǙ` ]3Vx,QI{oPHTUM Ol8p7MdZ^mZ<ɘ7Բ\[U$s39e4Oϧ~by˟o H.z:&( |l\!XjC̿:: ݷ|ie#(#;.Xz~m)ߊ23.-xCAR?\BI_o!%,%hФT RTA=~UmyڧO91l!u m-;m/ B$ܧcū؃fjL)6jL$9S[HeÈN9Ur*~>#FTXF/!1veL^If#-_|MVhNFmWkBsK2s$ilv*;4tGaŦd]p#nm\1IiU"P>椭v6LvyJq $P:'e+10FG~{}j(V`G[,8[*λF)YI J}%i< #h*BI"sV]jnE_}BO#k"B̭nFBVl Ootwи: } ele:JKi['";u՛"CV~2 B&""9 [16QʽLyb50oLr-MVR%)jGM}[DufvAq@zRiJR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR_z;.ȝZd-˽cxwv+qk,WvH~ C9ƨBOyWuo>R`#M+$G^`j?MWmGny8;KϺ (~hio>(zs[|/b>ُ{@'\F~_v~facv9@dCv%E*;6@TIRꢵ(QRRwz^F8R\mJm;qR[kF%yZmh)R$) AJ9y][Vff]P[H=eSC,ЖJ@JZ2!6KL8Qm{٢2:w(<qZ?Mڏe&:\թ7GB-p%((mERcD:8b݆lO]ܳ>ONaH82Jej%Kq\qd-kShRZϼ-eJ$I$W} zhz[YJ&ۦ-Rxe YQ P c& ^#mI AZH $ T)P$(lGzՌ> Ħi u;&[XSf.CJRubMJy[ j/C:ATWBI-<Յ[JJ@)P O1gNu;K.65ns`pP=*@y*iyITx)}vHrL76) VC{ūx\mqvC;`DaxȐM#RP;֥0kc }fNeIV| {}12-\Tva_#/ȕ"1LJcx`/jqsβ[pmMDAVd\(<{t <,y qc7J-hX[/Tԓ<|->;7;ΦUhj~#~&D7k&mRc`zV:8 ix#[ RVi_CL洠Rma[ d)/|S)e\!\mz &[#ưسN.&xv5*Z9fڮHaQ)gU@<,dvLO_tIh~[w w+DYWȜ /!Q()! xIxzVZ#GզF#n3 Cy!jF[N䲭i2 tj@[~#"tj9@nF|\a>&lip_殲o{ 8Fh-_HaMw$+qVXaƖO%8'=>bEJR5SMƔ)߿_nJz:@?|{V?e/l4/x-{c:[fyƷeȮ 1k$n huO1.ŪYWKi3jnMjX$ za*Uh[KQt&$kL?4_STJN9c[chKhҭTۊ-'XsKt\X/.#G}vI\)\iK=BaI6_h ٦ˎI;wuhh[ʗbmZBVqO^vH`̷׽c#4Qm" Kquv,vYmN#qgcQ2~"ui(\yZÍۭCECP8T:UxxV'-GY&5AbõrB8{>Fjm.ŭU:J֮>rӦ7 [9u0'~6LnVۏ)3_Ez;!LWj9SwmZ?aQ jBK[̄>f;f@s%{s0?5W+WJb3>emѬTW -VCpL}jqR.)[î&\nLd4Z$:[jMhf'@W1Z6ӏBi ߡH5( p< ifv>+P"E]a}O6be"aeV˓5פ.9:Z{myw3"4?6_s'uU)CpTp=7ޣ{-pf,4,lRb|[74 Ӝ-Jq|Ρx*ZTN9S 8PN߮Ǩ R{ +KC;!%$-;Cʡ>n$I9T`O~F~|5JGdZҕ!$S4)[ϵU}V'?/Mۤ ;ok&EKJLt$r\q+6 dsFE;f|4^yeڃw\vHۍ얹ns:"8yAAG?Agd\DMV=/‘Y&) LfU;J+Jv=ga甔EYmHQyŖF c,ȑCǮpjG^xMFo>ݭxΖb(jutKTվ4ȍ*ZdDtreD+Zg=JsoWbҋʜS.8EGq)C `8Jkksxԝ]ͯo}p*HK8CmE1-象FFi&6㌺/4 q)u[kJ) L8#6TMRs=sM.SBYaRɥ mN)&tbO[m\Ȗ?rc0䵖[l(•FPR˛I *I@ Zo#cEqmv v?Fݳ$`{*!EGd]HNU1Z{bvV=+.,Lcb7eĒSJƌЫ׆==2dHEaP%*؋DCز\l(mc :NjrtD̓19 ڂw}bqlY e'[Wx[+ko F0=e L\ /6_(\iii㕻xpE+̬qr%!i!h*!eW9tݥ.7ϖ㏸]uAֿ%.,K-suMpRӦcvQuvmJӈ,^Km[ڬ=pOzޫӧ,6L~ϖU4t ʩi,G-:}{A`($[V9㐯$lwWm7.!+m@e #qWY')e#(P샾¡Wm_HfD /CjI kNYeWD>&VT/PR65bG| Ԭq@(J[:luš*B[o9&=% ZQVV2k-fe8.yEնuUˮPN8>'~~z8]ۨAnڨf8 -]-F2jm_Ԙtlc!NEq\c} {Ѳ6 +3=#%dZP+ە8VqHHRHսDyNIJ۟DȶIqqVdV7 \% ԽW! Jr%$ڤT@%]I;k+~):C7KNNN^5*lwL]anZuZRRSl3zyw@ý_Vf~DE}Ч J +RJ>[N :`y/ͫ0}wihq 5vsu@}ĭR|  Uˣm=_:0=b.ѝ-ŝwZ 6y}>gr6uHѿ/_}t:VSu־#Q.'96KԤ6M0% ~Dp$%N@@p!0>5pbؕ"xn6JeênL4)DmK (KZT)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)[ӯn=oP ]]'C.MbMꇸv 3+Җ́pḠ%mĆ'f-3)[.%Ɵe>Hq 8P z-դ׽|O/a~h7])RLХ~:JAU)=ɰDiELXEimj?W\{;?,\[rs:\m ^мu}ԉ`[ݴGm:B 硿D1!}4&悽ībV WJʛU)JTsJRhIil:Rs+&.Ì| o-K:']徦xm83ZPF!嫄F\V;Z@Or@ڦocn߭:] ,g|sM4V.1[B&. LaFq%[s:Nb͎v㨲lns"MKVԅJO.i-kiVOOq{6#a۬چrH[TJOڒ KH)#Ctj j'o0.DfJo$DmYnDo.!!R(A*@ "l<\d,}0{ck&nE~IJ\I^nxph6y~si[=dg_eI%$+!dm.׌KN 5NKd:[Bz*gDΪӶ) 崢Ԗ3y UʒRN1FA??Ͻ}ֻs^ݭ7m|es6$g ^,ݱ]Eo^8Ht5>5!ĺk||e5 Kj[>,uWΎ r,v1c,6 ۭp;KES9f[eа ameW=)'a*Aote#r>K!x#p?X *!\|GUkf[Rg㙝ikӽZ`ʭ>af.GmqqSrMy.6ʆX.?n )<+Luha.c9FY;%K E&`Sא"J Al3ƠDzޚe㈃gHUߕ[XPXLfK-Et *=,^]6+L fiIyJ78yǙYlp}ÑB#@nՃ+#X,LbL\pS 3vŎ\ECq2U1Dhq!!J@m%`(`GzҾe]nJpυhemS6(?%w;ri98dV^'Tր\`Jg6P(_cTemIZ+xeqY`“(ǐ#}kr|T?^z 2Dv[ /-qJR9=ݗ12e잝jV䃺zV?j:yN]}pQR$Q'+Ϥinv>*#KVw$=c<ӞG/)+EBJgP˩@RBТӵ_9bֽ0Ins)Pm\6)@p6&ͣJe<}kX+9\⓿~IzoW WVDqpη҇\u}Z?]q(np[20dA-Ɣt;ն~J> ڤ=H۹X\Q RRG 9v?^bFynddd1y|1!mcܮbaJp)9,'h6\uWRc> `&cOŏ,1ڭ3B_#\mSFO$_៧^v"qJrHC,8׶]rˇn03d!Vc--cݯǡ;mES(R %#r A@֔aGNH5<q. LqrfuO?p[n0:3В7DS~WryJ=%\Z@H^q}.F6xiBvGAQ'JA*$w!)Gn7؀ATx[.8\ R{/ &qŦ] VKxk=6g7ý]cY-W Grږ>%Kڐ22HqЅ8#%J<ʦ[/ʹ׎2-W˼h7v+udo9j^e/jt{iPٴTWeǜ'3ӼsM2=kx^0tQܦy;r˺;&omqqr*lȷ!k5hǎv[DviZeM4HH6 H;V qt=؍v 6=X֟MG)pZ3w(XOo MW},6R8Y;zcZ:GP{t;7=AR[“<&xv&ڝ)ڲ'< exL˞L7muSSw~[cb5)NJCh'M/-* R'o)JRղ! AQ'$O5w}4äaXDD>re_4uΐ r}pU*wkGfͺM1`G\ֳkVI <ɇa%oKVR%Nq-4yRԔ3I;וu#X1t2rm JP&b#er-$[|Zb4x0i<5(וm mBSG1a2_q-3+.H !Xi*qH JI'oZ׾ꞿXu'Q<=KxqtfB=v;dxDk{l$kpjRaFm)iB 4*CpN$=:Kp%*u6;pET=dY!Ž#,CT"+oz.xo`AZCL QVw/Вy7) cFݷa7nܐwvj˯^_za TC~z7&/el"$c%6NHCI[k Ru)X"msn:<{CJin[1nL^[+P(qe-$G})J`JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JRu[Ω(iVCM!N8O@z Eϴa6&A ?¹Q^a* _Jqayy4î22Vh*mq=oi([õ- ZRQM@Zt }V+cmB|sNkzTА⋅ C74*KՖ~;wX.l9e&6;(uẦVKWT+BJT qu^ߵZ}A>8})uH}m.. @{@BJR51lYgKHGe:%畂K+5Bc8c IWn }ѐyvHD*ݯlZ cV6!)6Nu8ҭiR%*$eЏp~uHx}[/![}yKx5r`,;-em4ooA!QNMIzNjY HzgPP.8uq!ܐy-N Ъ$:?zK-3vqRaeLȏ.8֠[R)QA^Ӊ<32-%Ja퍲9򬮅u-oںl疽+8}Um?"75${O ׶R].} NZXX}MiwϊR:m(5"88MpdTUCGc@xԍz01 5?k9.9bHYEC+C"gQwf.ܯX`ΐy. `W{so]5 jM^!ɥem2EV3`cٛXb\dn2ݛ=;E(6߫>)zV4U2+FSV{;P-NΉDHL D\JRAQh;y/i[-!HC-F:Ck$``Fot~Zz6 b5?,o.3"r= B?uJN㯾@s2n0ᶷH2ޒL6$n(Hu%@$lj:1rɌ,OR!laA-ۂcָ.F˥[ .2] 6ԐAH j1bx\1.ޙSyZr-Bcn6;qv=J89xRGgK)%Lȇ,^KEt{b!.M_',VdS}NBRO 7vlwĭSq[|";yKk6wr\-0E] jlxLf{]cᄅ\鰂Wn{q]yzZ!%zOQv-9gjyJVԔuI}gC)pFƽ9G/[D`<۬.cSgێhY,Y XN )HC~JN&<=jkm:wnbcxeV}Fp3xH{ɦ,ۣ\ZU6yZ+&8OD}=%T92Ci*i,QsP[fSOXjŧPn#Nf =MvlbO)__\ ;1a ih\~#STg]e -ˁ+=Х$w#5 ʄ'(J}HA?y[RJQz'iS p]J4~bc LflC;tPM`Iw;\\c\[2$kPR9<3 IsbW6זƁ+Fw*\w | VOq gi@wk@w@?kӞIg_ƻ DǍ5Eͮn˧VU.M e@Cs #$U{bG&m+If/RJU{JZ;p-ņ-%ǧhny59Z{ lBZ/ R^3DcQrU>=T3MCcW.-'+r V3v;%# \UqzAfURF?JzRmXw$Iæhtr=)l3^H ɕeZ w J6c~ TdKXӦ ;lT7ߥbSˣ-XJ&T"-%ۊ~ BOJ"!sT)qPA*V|^{W_FݞSF(,:)X#$(S漤3uU\ik(!`)eI?$eI17өꏇ5g:J՚sVUl2QI`4DmN鶲!y24눲HL:Ȃ:;-ԑkmiBERQŖ?=y_RV-z1 CcC(o3jY.]mR/.WhskZis9c*c#یB0N'rP*.Oۥц)e=heԽ;c됱ϝ, }嵹$OEzSE)2)뻓2&G"#&3Ĵ % ;徆NHZr7~8#Ѽj.8~M\Aۛ~~8Xl%0,n6 I=" Д>{!NMq׆ESQoط w1jr}Zj4j]NmJVU~mPҜO*l3.v:%IN|5un*FGlHrI FeQթN\䲎RP0[na#]g뮺p̺e!Rs [cI!!]nk:[kv}M9V]9dvAiseDS J㏸!оjUTj[{5,pAjTlͷ qoWl;y yIy d .) JU̕uorOݷ]oО͝:ruIr%-p0bTwP.7qǺN+ 3bE L9R\iLZu0X8J# vq224Ӝi% 4”XyɊ(sFZQ ?`A={v 67q4;Nf\=+!ZΗumR~Í55R^Um {LY&R jn&hR()}BAO "u hpרv"]:̷4Ã)JRQJR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JRPִ~>Hnq=a1\m8Ĩ7D4 [^c r7)[|7˴^k_4*d^/o&ˉ.y1@LӨ5A9T[ot:+I'j>7x\+ wZ %[)f2JZls:jx^Ii6DhbeIa돝niL砷$X !JRLX5 Pi;y$3%aSQ %8=[!H/"V֤ЂA' OPApv kJ I[j BХh)%;>"@'#x$R}qOR PHp{Zz\9Tz6e6t@QcS\53i!Z8ԇP E˺#*Sm!!ԥGl)EcuGbzlj+l8sf X-Pc)KYJ|qiBwq:R~qc~Ēy9ڧccG~:l vSO%`Fq=F:Vlx^ҖOz1#U6|Zm,TD f5bx[dd%96tHJ}6N gIȸp\REVE(rJBG'm]s(Tv ;"cE¼)F)dct{tVDBֱe~En$=5|Q(}TTp$Ry|*@<08ƮfJw ? DLs;)UluڮTB!DyGF=#bw@5LZn6 .0߈ݻy )%^T+rPwJ8sjSeBڒ)!Hq) RBHR I  k? `0.ZCT u *WT@~Ft2-"=DiDXEŰF^(fZ&9aR"=Y֡1S$Mj5 ]JˋJ].7#d}Im N%ő\Wכi%JP QOĬ zNc{46#Ӳoǽ!iRtbH**~CzT"C?"DVye^}yՒqJqki#rkVds)狎u]qN$9 R]-ȸ!mpzHuL4 Wg.IpBf٣*  E*ҖӟW5ˀzSYpV' jߪ=ctB-hCאV#p6$k  \nESEmwc>#&ds ڣ(%^^ahc( ;(ċu-$Ғ7AhΛ2ϽMoh+Ł2bƘQߧXK ul~8o([^jK[R5om=$"yv> "r.6 ? [?!So+X$W?9+&߀mO>Y9'Fas(̱\uVkg*H -Jo9u_ I~Ew qtzcFߕͷYtu哶pNI7:?k1Fғ^ޛFIޞ!Ί2%kTқ9U kꎏdu&ViEAsqH" --V&PY]ڔ<Rۈ$-B\mI!ΒRwI)7G2&ܧKB="CQ$tO)*=&#BՋ2՛2Y- v|{^jl&ZiSSիia.jI}-yjr!sVȶեKJ:i%Ufᣨ 0udaER^t6]=^ݷ0|1!>%U~-zhz.r% ϐ}E)t'֤UR#is]ܨYF}mg[/->-H5IDaRA8u+8fxJIuznJ{wgs?bm:vUk受Y7[&n[#b䎠Sm q}$uTHl(M%XveU' 9y^Ze=I_(ԝyx][Qʹ%<RǣwP Vr|Vt*!='xzB: ՈHc"L/a'Ԥ,n?V6n kF/tޚW *K*վRBPܴ܄kQv} F!k] ?eү¯GXz}C8]GGi N"!RxI ի75~!E>}:m%ϼZAǛo~`қ=>M ^dԒ(|>Dk- *Bj̀e4Jzd"s=7(@۱8~xg+ϼE8 Mp0vn J^.ОR7 OZl B^nېV oE @?%3\Csіߘt/;oPDgBU zXY5]qNjvKdŝPI8 V)zqKZЭMpM;WgҔn*rJpB1[I JyTgI&GʫhJV)<|3 ܀U'cVun;ɒdV-mB>XZGeC5 38~ N˟<}Q?{*ha;O5J3cǸadqsnF^6iq[ HB>lAo!Hy!.}6{-= Zzre'pRc^_Ekm*qBBA:x }zpXuÏ-U9:q}DjX^8̲T**Q pE|@ߘ?V29z=70^'Ү/L&ծҽcPZJ1NKm'7戀[rm;F</Tzl})J{ Z櫷Sut(3sT=!]5b^"0/ hYf'1ù!5y#o]};{&y\6"A=<nSHZEN@;z>GiP۪yZ[v\ol%KAmSd9C9 oԥZlZT{ѥB~5ہ5ۑro`dM(ǡʼn8zۡ= mF׈V4,}.HXOtOѹ_u.AU9ܝJj8ey!;|\ҭm w4Oǯ}jNu6 .6~#$?d[W6Dz9Kvi IZ1  yapxNjz*Ҽ~~pG`@Ҝqڲ<ǐ1:uM͸dǓ)sD16iзaNBBkXPUHf2-ToPÈ{jʇZHRz.K՘iȧT'-^ZAp 34f1d c'`NI>7}Palnu=Q9اΒl1m;jmM%AEG ZPT@snkٯVd>$Fa\z< kdn' [Q#-Nj3a#iT %#ws\?zRj3he-! 4hJhCm)ChBRG J[Rֵk%JZʔJQҔ}m)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR~PEŵ{h~ ٴmА~2 T;YeVyPjqGk A/N,W<(,rOvPp+P{'N4>C.`=r͜r9G+p{W_IȘ_8K@1q^JRS¥[tUzkJnyvGa_4|wjF6JF* ]FtQ[wyh2VKH*7R B't[D [H`fd9m7",%Ri mhP(R IO^/nY5OC:b4S~&]m*BLwf?24IqqG|ii泹e%v N(E1؏)mԲA 8҆AEDR9De_em+轾Yܦu?6q-im:KuꈮRn(TPRM'ZF JA%D%!;eTI$$zԥi(7=_(Mv#`%HqYW% m0py/(+M.J}22J!iUK(HRn^}ZofΉm)Ӕ.3zzA;lMbw l}:Νw׮އn6udei]{r2,ѐ;Ya,sR<2IPZպ Me5S1b:,\XK b <ϸ[KBM7q՚f2ztw$$6^Ce:0h((66i%I*{n@co__˭jN~޾7O]T$I@IQYJ%#oU^iR&HJS<)#Q O|}+,L %ǸܓIPixgʔ^FƲ {8R'bm^pEH>Х)ߩJJ/4J:#H|1_ Hhq2-8ߐvܹ\0H$|v Gd$r+٧m9(eѦ,ۀk'>;ߚ^.Z8+].SBN&c'@ԩJTJR)JR)JR)JR=~]:8xqwb)A'MȌ&x1Z y$6;"?2*a]7UM@Aہ弻ܪuǎ hSj\HuwZqW]9ă 60xD=hSNt>nAJjɓƓLNnyqnY!P-Hmn kfd bRQQ_%KG̻?ŚĄ-Z}Tˈq'> (PR B II#h;>ml]1.6oQCjۙ(n#۴[P^4/Pn`)% r HM(^TSm02˩PrR3F%e!q.\.am*[ˋ̌7*Z@TU(*rN'ZWdȳu%.{fsq =դ( %iRw}zܔ 6$`>Cj'\#]\Q`O7qAĺw YQ5X*SJ"WH~ğ{rS֭KX?ɒSlW吝s~G?}\ë=N$uvץDzon0tV?82IF{i+f5PJ]nڇvB[~[B*d]%0Cei->>u`n+N) A"GSk<&[یK*ā!86-rJ` ;~=ko;|u#ּ|F|c>_:@HuMGyĻeR9 8,G qAm?qfbݘW37KUZ&Ce;&W_HR#ǎ6q(`ztyNi]R^V%]{8 H^}ewt}•&ft"2>gUў¢0 xJpcɸ]V; 1)q9@”++uiJR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)ZJWL,x6cxZENtFIu'xocTj*RRA}~|V * h.۲m[V"JaolîTn:l;}vڰJoN ܋fsq^<+Ol{_r8>uEI],+B$p}A31t)Xөk@v˧_1<-\ܘjmdJhBT&2ЂUUv 7.7wҷۦ6uk_ٜ)l)%-yI>K- Gq6;OrCV(dߎ8v}?g?o˧ׯorsՍk*v8?7gqq׫`{48Sv))Pj_+?i{`jeL>K'I~dN-22YN[8Hj'=+t=]]w\aeoړw{2.dӎ)!G-I2N- FY[`S,gJ z v5mM#\u*mW`7fMUOyĞ]2AsSӯ[kgh!IWںOCP9K8eQ! Q'3G$V HU fejRwf-;3NJ \%hkV(R]zׯR}zkm[e8 {@+J̛f [`+eJQ~_Rdl1;)aY<̾5>$0_oc8d[}KN㔠n.xNҢ"rZ +?.+NRݧ}yl4-#`nߢsϷ`MPz.8lۮ=e+ٙU^A#~}Hg㻯vzXNıomS'5;$"BB@'qYpﴺ}W`6߭b+L;iI<枻CFa\Rkf ojiU:aoLv}ؓ(HB9R: WKҺWy Kyӓ\NdZ. d/m}>RPI$],Z:b߭Pn![$ˈRNђ wW~8ב~]_8Jğ^s}j}y>~1uC>.8=g'\VgVf׭L@D>NˍZX贶JNDu$3ԒI=I$I?IҵuչzXkme?y~]qIo9H?>ev 7ql6{}W:1`80  QQj RBT$-G}ZIV^1w * j#JA)q͙o$ͽ{둑aoq >U 2J|*N{f̐HW*Cl:U$oH#N>7]t[ԧq#s+gSӒO6mCp6mӷoJ +a8r[rKUٔtJ.(+ ~Jʺ嬀}[|27{^=v *q*x&WI|'*^,Rz8*TJVl+)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JRWn{t7Uh{ҕcy]D-ovJ;32{Pv}Z뺓M, P~Uw܅Ej,y5DnH v;F `OǕ]C_"EHX !fދO*8z p\I`ljFK*w" 7;yoa#5j;rm.9bysH!#&_3oƴvh2)RH觠X㹸'tV82qr%IOm;}ܺט)]9ke0!h.0WlDger y(rC )폸HJRkJR)JR)JR)J-FiBW!F'pf!6y]E%A1A IPBA> Rpm! qNy[i+pTBM\wL(v;\VGDRmV G^JJYL`Q+_Wفc\])JʥN?7Sٔ[i.G7 RzKjA\%"^IzjJJ带G%CxIGqm6ڽҢzzv k+KJqH=+Bmk1ӻ)v5w_l8w!3vj/]jEnJ#g\ R>-54l6P{ZqE)%lz) %C~F9p}ḩA/]:k/rRu$[rʕ.[~GN~*=s}q7dzrףu @?R=)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JRֵ}Pzo[ҡ˩հjXR HuN[@;X\wawe[v7\+{Ku;nG|@vlUĽJ~ WmZmH jʏHoMttbT4iJ9D3̒>Xǥ)JUJR)JR)O+^]}ĞH*78܂KU2u#foY&-Шt {(JYPEvA;-2<+Nt'm(g\çIU3^vYxg\(m7L1!TAPI$ 6ۤ#7\=|SJbAj-@`K1:S!>[beO\WR@ R3.-owebXW72R)׋iꠞGl$f՘/PU4Ԍ([n]ZyyS;mz3]d۷-pc1B#_w8wʔ;eG'ҔUe)JR)JR)JR'~uД-Q-4T]QKhJ{(#j)_u)*Q02y?u<-s)2*4fKTd>xڪ~v׮_t5>bcIfN6# _^y`ySս ʒ㤜n)lwp=0~~ m@H?:RnW&;؞.!M0rZ%% mmĭA*mJ _Tn@{J(v ZNK5)*qGm_ -Hdg<} O?.yGzqk\'V/1>WvߨJ̍~[ 5zoo:s|%/R$*?_jF쫷u< {1m N9)i1d ȷsJBRI= {:Oaw&)X3JR)J|ǯo_~RDX$8BP.:w n[IRR9I*P $so$Uqվ㏸wq՞mzzUG&0[).s cbO+$kq,Ko)HPNx v C;Z[d%#p>g&p'd/Hl-ovnPCW^{Cw=8AX56m9v9<FE[Q% %-ӡ:lH]B}lTx$dzP1k5nbeO}RqYZ~m-+m /ҽ!бHJ@ D`┥+})JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)[WOku|W':JɏK'KLO_/&:tbԻW02 O;t/DZJN]x;ە=rRVIq׹.:T]1W-AxW3.$>{OeO}odzŽJJRJR)JRo>o_r-U))o>d׹TF"SA%dd#*9nBJԔ$sɄ~IIi)J 8ڧ7֖p"6= rK_̒Ony2Bu-@+uy$u[(,j|B/i_ֹԞȽ` $ "{ߔeki7J[ވ?eJӂC[J;I*L%q bKL=+ Kی8M2+O 9ύCm_ېr1%d7P$$J@JGQ ??.+ԥ)_JR)JR)JR]0@=9]B[,EX*sdLjbE@iT!dRe;:3)Yj:QI*H4l o͖"Kym+W@ m (@Hm]jpځan Gl +38bz =>]?- ҔҔkJVգCq΅'~zoN ZAcNצOy_ m @o2-;f=6J?3;mtA FݺmӦz܎iivv_ƣ>pG1Y|$y hwd:v1o]l ]n0mko(toJT&.pЙӻ jo8o959<$M*cXA*J6-m(x.bVe)JRuܟ}vn]?]fmXI"Tx̎` PRS!ѸBO'"+Lڢ 9 o#q=2;VЧVʖsCWűw){WLF||l} vw 2 қR¬AR qUt^]}bLLya V|B`cy;!JRتJR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)Jڳsm1\܆.!)KIm V}tv?ߵ[oPR-m((ϘCmT7OϘ ޺GR\]԰ض-A0,\']Ժz /v5+$T'rUkrOĚֵ >_>B/EJ%_x2*'~Jq|”)ZҔ)JR:~^>&[tJRԒvrYIKHe 6nAVߐk_벁n0 $OC KT'rOS's=Z6XAn'+X e\ Ufr#k;8 PH#ر s|4yw 67bE3kHIloRKN1Yw^K&&-eo ̕B<'Ne\3Z "-2^IȃLu\ `,usk{Ep>`3h7Ck ƧuBW4b7 jE]q\7&^E $ɳ~zp>qp A?W)mJ:aF8IKm5XkҔDJR)JR)JP )n-68+Z@[+JGêN) V䧘HJTjJm! AR֢R7'j9nPZ-]הി~#J uĐIeSn7NRyۻ=*P_.Eq=ަJmDjXXo W&CLER#-թVO5^jR)JRtT(9ⳬ3[!йxN2Kd"$HZeFÔE}]ԫX@@ߐ(vjBvPwt5jE.T6&UzӔDelG1/@A>ʑWŵu>O'*'Jܐvۛ-PX$&OYyhvý{fsk:xcW^`D&fSx!NCD~JV }Ҕ~_S|a޵Q $nJR9w$ItT~t7[$ٛLTK QW1ԧ=þr`ژW \ i}e(t)l'r*o,4d8}{'}jjR)kYG^p1ҔVjJiJR#N/2._}mvV!CaUwxl [L) S><`{$oH"+Ї@.lt_sgo:q J N+=ZoSu@F:~TJo#+┥*U)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JRneuJf;nI2'YGqnR^AӳI<>xԥ+}e7)JRzm|O)ZW:c諗%\7$m-G]!wd"[Si .:F,#V@ک5"'W3Q'٣n<FGZŲکK#<r9d(kݽR| ܥ-y;!bwA=Ѐz*J@HO;s](BBP % 3xvgw4,MY%Bl&GrZZ^#3 e}U*7rQB`y _dž5'JllCkFA܆n20;5"m׎-;b\A}:LR~J寑N򲂐) 8QG; ,!mRЁ)+@ 8󭕱瘌yZ͒O6 7@SPw޺1[ҴDq7\$ERom} J)<Eicmyʪ_uo/gH+Bvf$pX"6sE:֨3lF>jxp~=F1&gw|=;|}~Uc@ y\kQPR*ǵ0D4$%O)J:Q;s_?_u+ZqEKQ$ =rR@)Jִ)JR)JR9sJ8O\t|XvA'JTE;%@e%Iӭ}P$ЎT]C6[ϊ]RAv'n ;Hq+Fc{f_{vͷu* )6]6C+Žk:%!'^V]vFtPLKaJ3G^3u] '= <`H_ 3D~Z*;fP?- n46}Md^Z"r^1:)Yo~ 8c#]cZX!o+pPxPWt *N&%2T @JSm$lO^T6=9W6&JHhPQGnQ*!*,H'1)>3W><8.:*Zw$IGO ~tvݾ~@ ~c+`a?RkJRLnYukRnw%wa0jAuR5SlXJn8{m8 ,Z}P?g7.hUP۔3 'e> ֱEFU|&}8q} '<")Re)JR)JR)JR)JR)JR)JR)JR)JR)JR)J巨?oZgJZYÞ7Q-b87R.WI ZZ,vxKSP`ざޒj3^~f:ƚf}EfS.d íhӂW01w!KӐon!ߐe]j7!ZARʸX*҉\SLVmJSoH-8]-7j6*DHA..3ϽzZߎUlM7@tsf^-7k5tL;u.7E<䭄涔 )IQ}MPmkUvңw˓JTRYL}cb֢@:3wy).٫Vnǁ')ħr WޤEñ ԉm8]=8ld-oD>Ő)܇o}`&eFs8FKxų+%ǮR7/nk'̻u &Fq$eV*JҦ֤j@|21 -_L%0\H2ϺJݜ ztv-i0ŭZ;RN03ǥ ;Z8Fan+ka6ZB;uAھ+~;?ؐqJO>؊PRRR~cpgg> C&_fs`%qSIK{]# u*R"r9\tUL@ۏRՏbÓIr:}m!L^QQ)_r:'H|*wm7IR4f[) !1 'e6SWX&{ugVkd7Ng*II~S^,nm&d)Mv$ejSrH, ,,2vIk~n@5[;|k-)*}%;Q8<_\'8q\-"3? sWqL=KsrKm r0Nˆ2w*R> Ra! xz>^ޫiJP8w'ޔ+Q[X<.=\7n1eJݹ\ waHߨb7㹖 Cb)J~|jT@&0yEfkf9o07Mn ڰ% ̑qPӫ~V_`?pSPyO@P)N`"FzT3C9DZ[!FP+8}O5e6㌬:ۉ ֤,-'$Ҥ$Pd<>X1c0KID3,y\BR}v߭r6?mͮJ&CK0>w]fD8 J [b0Ju}ѲJBT ԩg%K)G"=C ,(BV)D%! Ҷ)#^{ZnG~\/)JV?y?֠b+nX4)J}G@x)JV)JR)JVAµ)JUv+&.gn`JrIRH<՝GU#)n:jW lN2r%uMl! NRZz8-:@vɚPy}ɖʅRci|讴`*HQIUf⒬~JGϓڮDe*/Sj]Fp!O$$(Ũ H%JQQ$w'uKY[Vxb~T:n٢Pz ۜIqIPk-2+{%G}:W^ccRd-8?wcy=g?ŝj8TF[I8ug9&T*Du;6?k*8zU()$pq~ZRkJR)JR}g qm"߼\X̎8;m;V6|Mm*r7":HY.Qgar|~]+1~.IvUlW-CS`_@8ipOsƯF?S ۄN1*sKR-'kJR-ER)JR)JR)JR)JR)JR)JR)JR)JRJƔ d9[v`qk=N_jFrTُYmJm uՀH[JN2ө}9wI{Eln/m7 Tu"9ӻ /PjiS`qkK /(./{/hٸ =aw`T̝)R  %)qw\]Zչ:ÿ Z&̖,U;&vvS0r.0ӏʂ]q)CJp$YiG]~#ߴrr9Qi8d1C |wːyasd|]M</XzoM:>~}nW^mޯ7 u^ívLK i!uS.6ɖ[mعn9aλͅ[l$?"[ղYemZn!@~ueV6 jf&oMs00 \e*LxI}?Շ6-$dEI7܍oPOtXqr]ωSQW2 ܭ6!֐Rv|uQڲ:JHu={T~g>6gVy}34ʯRm#ZO%I\wdl4#m Tmqս`z"$IRjvl&t)[])* 8_i~jۭT;)n-FCӡ>(r A(ܦlDXH$u!`v~n#h!Uԧozޝ$ƙ}] B֙}V^1`_»,OtPԨ-y=2?YpdB60?4##5o_1̎W/cCtK6-DX;\m: 37@!(p{~ F3Ҳnf6KhwS-:mRTvUƀӱۿOy? I;kio߯墓i|m>{8? [25j}#=]&#=ʬۂ =Bٲ ~')orSv;Q,s? j@ AI ڰzԏH-G~-e"6=Irå26Tu 帆RyP>GV66߿n÷VDz>3mSO!-ܐCf^Tacŗ0KU^dQCL˼d_j8Pe%9 oR夝 Z2Ib JCn'+[Hs.FZGٚOrn>u}_)) sG0hEDI:ѧ%m{EIA~;W*@۔mS?WDUvj=Wz}>믈_Oy1x3ӵl?G{J57oUcFa 7)sT*)=wJ-)I\_F5%Jp҈^ ZJgNAW oY=@;vZս\q[vV9_Ξ?V5~ #$/n:=cP~MߨGѣamïaYG]nG^E+aƯ]v~=Mv[j?# ߗly>'줖7>V7F}X $xx2IG'Н;ף(MIö3r[X5`X">2)wV╈d+2\U.z^#9 :ӋCֻy^d$B=7HQ츁rDV늳g75bRܳ2Z2 jm3BG)bCjAUB}v6O7C { W7 1}fu966Q֞^OǺ?q<:Tqǝ g#KF|dk>Ӯ޹ğ_zYh9X?]}|5w{)30VRWP~ls}yu׿ZӒu.3HoV} ̟Nt|ؾ!_WnG'cG_ڵ|_t2䥹;Ip,jڇR9Am(l;l]Vw<7\+G'}F$z#cֺĿ믑aVO<{DkC;c\=kqdiec*#\=2GzƯc~wdM;Ӕ/=~iH2|ۓq?ʺ5ËEJڣ>N[NEt~ ,|}][`ȵU~/-ڢ?{́ =dlAPEugM.py.r®Z}qh' e<uxgygf$]% w%i$H=) l7߾uLG:Ӯ"C:֨W'CGRqj3>ULU> ˄5!*6RJl>Ѹ{ɭԥ*dTp)JR)JR)JR)JR)JR)JR)JR)JR)JVncإAet CBW O^^^=|AUNZϧ!ڡخҭ3!O-N縥@T +Z z^Mܗlp_6_myG %J rnރڷ?[=>d5ݳ\nj>p~U j;{0▙(JBSmyGz㾮l=e{I^q#-J$(RTB>Y>ČW-'|S#=7v*!I۷.ݭWکZL)$5 Z}[p(}- g ۠࿹1i ylˈ)J\B #yǽܟo*Z]{S42]?:eku%(Jugm RBRk~_iV--_YIRԔ%*[FPkRP;hJM]%wn?lc/3e8ܑ AfLH[lSemFe)m)Pm5?5/Kh[/{28pS- %r,inJ6Ӷ-xs*TaCSyL)lJCH *SJR9;sx)##~oװ[BRaF_^|=)JV)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JV$J֔YWü5ql(0F<ǧ1 ~Qvg~~Iwl;JaIqpCS.mnu!B*BbBC̭^q6iPnRnZfs -MfIm,ye)}UIG);b7χ_{zWM+S&l@ ` x+giyxܕ$.]5sպ\Em<_q`1m)Zr3 M8WKcGaSc0!ԶZa*uT5uʹ~6)57ġyQ$L%׶RXT|K)m-m$bi>cJUƖJqkWwQe.9J&c:ek몙 ij%!YظK)\Pc蕺 *C<0WJWHw],i}Kq7]7snJcvq،7ڶص%D߷\G\/SZZX{ qn**5xq7,T(VRnI$ l;>z}Vh#60i _JJ{''$3韗8qJq-kZ%jR J)J*%)(IPӌG!{q;\E]&-jrTO~T! raTEy:P(cy\&<(}W N0]) ~Cqӹ|#auFp'ä녏,{nYۋ+bʕk"I;1qEV^VzG,ŘV=K016܀ մNaO#aK3=ҜP]Z]53dq' oteɎLRK O2cG{嵹Al)vU]6/嫠QlUs"b]JCrSਈqR '$] $cx"Lp=Fsz". 9f8i萴w+1gmRv rSuTLJN'O^4#V Fb+hz8~%glr;B@m!E#O6zmWnje)tqlשd+J+ByAl T43Ptn^s"L_+qĚf Í[sDo(BZSmLi9ڠ1Zk׳|91QDuC7ƾdlk>ZːZvxN4of.ihi3imS 0GK՞fLeswq.}ԩ*B褩*‚ViJI HRs221/iJU.R)JR)JR)JR)JR)JR)JV7>\_x\;R0Xd{ޟڬ2ˉDzd6[z~H2iJ+d-a$m=`1,ZO&O\^i^&wCˈmr䄲_ڙKD %>ǔhRG?yx+3N8қi.fguķBs7fpG5xdKt-rr={ 7;T }ќx{t$S{_s%tW!M}_ŘڔŲ޾E^$nրθ#'Z3peY7-(qns\QL+tB,d!Bkh}%yח6+;h2${oiz[ˆB$ 8R~WO<@6<. rvߺ|ؐl@4bGR- rcֹ.'suKj#ʷ*)G)V60|Jx"޺twpa/)1&?|TH2^AB.e6='=О\=w̼_r TGڟ| N*,z1vK2+ IY% [z'VQ.a}eEbPfϺ!'v۠12uFˏk(Х(T,0sߔwHcyƚۇ}_1U#)P ,A HW堄m] }lK3,u ?q VFA>v3SsF3r۩r=(.7Cɔ)MŹ%mK vYgY|m*>/{ҙT }[Xn! ZR^B $H:u!Fص e 3cEIͶ7Al$wKrSo6vT[ ^qʕ kwd[/Z,r776RkZRJR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JRH9G~U+LZrõiʞVS?*Vۧj{O+j|}w;m>z;_*v /qtjnwz,meV[7! qI$4 Q&dK/ݩ2)ipE=u&b\yPJf f%kqoG ߲Ǘ$ Y2җݧ^#z3Xxk6!rYwU/աKRO᥌R(KK[wRN\^z-eN0|-3k%|넖!ƒ Kƍ![e$%=jxEsq6q ɑ ' c\il+SMtwBKEp|>.!ʇ_ܓZ%WG {q\W5S7;" c+Ǥ÷ ݮK5.0 iqeFj:dz*=G c4;&7ĩ2Y1'r=mHii6N4’܃iaJi)̇$;>J1fqn1$pVy |$qWc{sG8nObyW̍7,*iIYpH-B)]%Wq @o 8u%j &epN7ΰgZbtdz&g3&~U]wdq^-ALtt\ #~pֹّdZXj8uNʯvy֨#*.Bx \)yvze5ga-IbBX t!T )LQG% eG MfCexcaeՔc-lw%xI"#Dp+@@F}!}5|jye2ȋ=ui˚TOGYNZH!CxJ)x;[wnՐئI.ӻK1!j4%4y6Ũ7+jݜ &_;㵠i?3g#t Q YI-HrS=*ʏwQkwC\m$=ćXRPGw-ރd Z; NADg 'B࿋l+m gv InW;nȊ3''W\J.w#p7?wV!Ì֯fflug"7"-\|eB$nDHrOaО*k>/nF8)f$&CZ\ dk .4dq#ǥC29#Dr}2#oW9RUnJEdi Ho3HSA[o+Fxz/i921KWi.·}f?Z,S%<ܸq^DҖ2Pۏf4R`)ی;\qM y {q?n IIYcZ-^t ԠUp8iSA"qrX^̠X1o93SRm\%eQˑOtQe!%PpTmwWF@{3+Ќ]=$Y&7?nzr+v ;uz<4{ERYCBvJI$}~gֱlI:XõJG+ii[%;&d B<O,WÊ.o!F#/66V}815P}K_8;Uu~jk` 3"%b/~w6֤#fLO*I ?ro&=H:L F; e9.[DfX9C(JJ԰On9qށwP}LK–] Vuso._ī#ԝBx=ѼEK0)vhw6!8(!Hef+n|2ωj^[f?H0DǷ&}|.DT:jڰ$5O $qZ^;򭭤$$}yv!o$<O#?}MNW*- Ğp7 YKly KM2GVGcwݽ;uc٥ϟsf,˕j}tȌJJ"\WK.#gT>ғz)\ԁ\"J eRRWHOh]'59j*9PcΉsm¥>sˉy\{bU~gQ^ ;s˸b,9q%Z<-)ܖ)RA37&l:@}*D!AH\YW6C G2φ궟񛓉eK_.Li+۞[sp쭐H:ljsw==OZ-lKkŝ%%1BTќIn9Q(cO!C>5[-p-"[-oGB[bx ӝ5.:vdž} PC/\-IJjǙXR i]dBu}Ta@/{;Iceb9⤫β!Ԟo`ИȷGZKJGVOv6Bn:^'jSa7nÍ(H~F p讞ߵFePuvzB MKRVOq)ʅO:_Livsuْ'${Tw%*AH!AI(W@I؎aP|DyҸᎤ%j{ЫӘ6 iR9O4I3YB]Ӊ, ȸ8m{x8DA#Lw7yrdP!h)i H쑵Cm)Vy+xļCqNLCɹĻ%Ǿay,,!1U%xҽTt1ZfxI\(b"D¶aJV-E*t/J6^>Rb>򈌇˓r9*;JX)Id}wԎ߭~Q}*6_iJR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR7:{I}_mP'm[ևl?O1'1%5pKl]E,:}tدiV;l@v"0~)ʂ}?i8l#KiЕ|^q #IP|nyjDݹئƷ74JMߚiDdߕ($M \;J9pqݚ~].: [mg~9ǢNܭ%|ʜIJG^Fǜս6\-S{3oڼTbj>$g9 ǦsJ+}?1UVm*~?u79|IJ/zrEw}֯7۸n1g`vkԶw܏?W") ^i;zv={UsVlWeOVSni\uKqj^+ʺڑdm+-fɚJ! #qzfxF=8f%V|g mŊ!vmBRL:SZ,!v \w!iLhV81eyK!fyfRվ +d!AIZBA) iX?%@ޯWd&anY@[2mÁmUW۷cv a+t_ ;)p.QٛlY0C.WTY ,).";eպVԕO잝H}G1FAF03G^kv荧n/ UCű D5W7%zikn 4}aS?v;p$ۮ{lOvZ|ْ1deqה<Im!enuܾۛx;p۰ڛlI>>5O N0;xg?β5c[W G3<&nGâ:Z<+RgJŸGZ׭ ZyBEKw(P,%7|Qmaܪ}iulIaJ%ILG#*1S;y* bW^m#GSY}`wveI+Z1neZ=mێoy(CEN伟P2D!).lOwp:-yP{ZEדd'ˉgRCqaIHfpNvKH๴sO?޲e_w^-63kF5LaO\řJr #yn \ /5oQ`B/3(ᖗ%Օɓ|2^W!s.tV P;^ڡ,iφpJVaZڊ+,mpiPmo7Vd>zL,$ןv10h Xf9 ! 1y%dB3m0RJI jҫ^lϗ8{8#*LMRحC%'Z<%)<%98z[hƔ#zl&`c{q{LhPnD \{EbQrTVU5TE|pFD:H6zbaIJSJ(RH!HP=ʠzq^v)iێwL1kbȭ[, 7[@ یx7ʘYBD坁'b?=wX֌WTڳHfKXf-5ƊTS|gyTy˛,vRJ@ j})|vʽ;ʖvfLCgJs(9 ?^T5&qL]yB5ŖRc<~;!_eRNRmi%*B’GB6; ہ 0@OQ}\ܼ}~ߠ5nzSonSnmRJF%YJ“f12eI';AP#AsY~pAًܱ%WWԧKHeyyݵ>J!= 5͵YqVb~|4+vkmd2Ժe%[ U #ɹDnAkێO}䭗($OtVicr1JO(TaqqJ} SA|/u/Br1ŤDmvb)Î!H.6ۑ!St/ZFZb,;$<6Z Cs !n6 8LFc/o˱yϳSX_@Lgr#%sB H&5_&1Ԝ$}dgX*Q!0x,Gd#,yy 5y|7q[*Mk2պZRϚXBjl.O$$6 ^d[=GTwlOzϬ=nSnWJ֢:$42&;kܤ6J:3h~u+7 F1Z6a"Rtmҽ*1陬wrDz2J\[q#a(r3oeB^{'js:WaWB;MF{^&{M±زm`M1[1%BR[P}jH.8$:ҝ~K)OBМ)RdoA ,)$( "Uş+P |v'po:wuF׽9ʵ)9n^bݢ_-=~XGK7ȯ.:RiS"-R:o_9b{+n>=Su$|?\4zvE,O"\O) ʉ_m!uAl4c (W4wGQv$Ɣc>l,;ڐl^Z)Rv)WsQ$4W yjojNOmJneS!BfAqt&ߕh+d QUqJr=YpF$JpÒ&U  V%9)bSW`Rcuh_ l 8#`2*z9G~?t(q^ΞYZkR, ĝ'q%d iQ w_ "hIu3#rM^~rDxqn4.mq#uĿxݤ^oS#.RJXkɊl׃׉ S/dbM!ip24'߯]tP(o!-S !ktNA }hAX;-8|R# ֒BHNS5SWxYmb7>{\,h/&.ށ>#=܈ei= V{VҫMZwc\í7(N-psb u<]eKeQASkZ T{??%xЯ$\%*I zEDgLכ\&eq%i%ֱtvBPi-x,Ү[U|sd#-◧J!Z#<,tELjƫZmQkȱ2K3'a0y 7%iۜFE⛐N3"N8td1p*gvB|Q%`*$r"cmwFm/ٍ)Ax8C$*ƽ-FKsX**aw::%Ф6ꬲ.--TH14U4+&݇S@.RyݻVn"LEڋ{kRN-I.4<ǑwaԐ 6=<{oM*M{Ec[KKFYdXX<כ.0ka,VFǤXa>΋#?ESK-4L9 O+m),);rƈx0xkBԌNi !2/ƴYCŽ c˹ey`̈\Z?8,Ξ >ʂԗi 2fy*%iC+aVo7Ka,Hz}L~DAxc8Nݖ,!U\ imHpa~zw-q *n V-RPB)MݤdnB7G$r ^ 9>[ J㶸i/,,)n!ф; |BԇTH qˏeIgI"%Xo>܊SRUrvˌHauo1 r))O}_郀-KA&]1P%%P+exTtIOt閜`zsfa֌ pk-&&;fih B/2)gL:ߨxg]C֜+N 2%*n= Ekw3]7֌K]bıi=\4;k]>Kgɘ[*mIarAle%BIFq݆_np _CMZf{^Kܑ72"[%(XW|"pŘ\@2 K ,\"ÒhKz\K3YiKKmԇP"^:{RL-T궧f" Mu߃bV۳ٮHny'8^l6yRuZz>kt");)K} \ÊXaCI *3y!A($I!# sN32go.N!+C8!֖UԤ)*)*!@EyxsiXOuQz 8-l!$Â܎D8✳]6;LŐwncJ>#ՠ^񵥏鞬ZCЗ"斏):h]lS V̄! ,C`.nkzB#\hnq*%m# Ou4eE5Q@i9Ix+|TV ]+v'%+ݲy7oA`L ڭǶ\R]ij4P]~;-!HZH JҒ)':FՁ~ mht+  +ҶLbB) Om.rw\K5! mVbx@-84b+Y"r,C<Ɛf=Ғ:UȼtHjn4B9~9ǰ|ތQ,Hl) $$nC 28 H<+Vk{{{{l.ζHNیi<.Wl]d-W;gT1ɒٗmrȻq;t";</KFmeV 1 vriĞmK?mڡƇN6ݏ!y )ewۮ |.:˶JqJM+DM^閨ZyeڠXd+)BLZ yoŌmY zaPe;,t6o..,!k}CMdu_kNCl8~.eXRӥԋQL6bq>g̷R1(.ͧ8fYwZCpq *)Ruz}jHoj4BeƝN7ؒW" ^fnE-aA |uP)ccڞh4ȎL[qa۸nQa K !na .j_CtRZºޡ4T[ q*%;d!ֶP(h-J'  M4-JqRԫ͘}*xw 2@\ji3Pdgv$HH%nW>#nmq4xUǭ1ongcw$Hx>\S;5.?q2[7[E͕5&,7+̺)1,)/2 mCJy~`lvPZF\-M+Sf:ڵ)D ,=o2q6n0L5&6Yѻ-[ߨ&zg\]b%JJTLNCχ%@GCqRdIەy)q\D]m`RA5qa*XlZj.CP[_v<׼W'~T(ԝ~jޛq#olŵ{7u(.&IAꮠA-ӎuå3:X[, ð_[6֯g.-4F[6#nsVi-{gJx[.iq1~$8R\e ҆Mj'yE/>+huqKC-NB}R8 W["wpm8~B˒0 Nܹۗ>mRHVTp6?#e9+emgx4Se%ϫw)@KYk}HuWGZ:kD=Y5\W|Gd Yy)(lm=]\hޏh='uvcXOIh6Xؖ4!M>#eyR !@ۨR}P ym`Y.f.BJe[%,%E҆ZuIR*ttR[vM!^c4rԶ$-*! -@PRg#3c4ί r+3m[1lhC.ݡʛyHTƒ͒82q!hPZ Oإi$}ơQ$ՌRcbl1YC.;[yewlLKuu QZ]X*KI;"Z0~&-YcGGDT;#4glSw'h|JuףH^uPZt8,CCp?e >l 7rwJs.jMK 5JkϽZrTY1TC;P-,)p $brǘpYqvsr&[hpV8;*K\;U!᳋8ڟ2.nۜ>EUmObe%KiBoEM Q׌rW3afC2/9l)ܐaNȺHq7G/J8ڕ&\2)l%Q-Y [T]B{Lz⓬XjtiUtӤ܍>Ѩ~Ԅ%Jܶ"Mxs Ga )N%n!tGtNhlk k-Hz4K!RYScە<î)RR w,+um@HkudvAQv)ZҔ)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JRh{tiJVzoBwSMvGkVVА8#i_/7Esuߦ>~oִ@/ҕOnkJR)[JAqn;ҶxV s niێ”)ZҴ koJЌ䏐<)JV=JEn OVǩ?SRkJSߧw[J~nR9??yJڔ~g߷P[Z>?hOa~C_]k41?ZW椅zlwo}+ii> m(׹~WJ\m y?S ?_?w`Hm|OsۧN$Aj)Z $vJJ!C =ڔ+J˹W?[VS{ÿz+l9y`}}Oƞq~a;@~[u?{~uo߿| ֕?lwҔ)ZҶozۯ$۷@A;c|m)OאGŸc~)@nϧ_W;^ZVcJR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR)JR+pycrowdsec-0.0.5/default.env000066400000000000000000000001321447555637700160570ustar00rootroot00000000000000CROWDSEC_TEST_VERSION="dev" CROWDSEC_TEST_FLAVORS="full" CROWDSEC_TEST_NETWORK="net-test" pycrowdsec-0.0.5/examples/000077500000000000000000000000001447555637700155435ustar00rootroot00000000000000pycrowdsec-0.0.5/examples/django_example/000077500000000000000000000000001447555637700205205ustar00rootroot00000000000000pycrowdsec-0.0.5/examples/django_example/README.md000066400000000000000000000034021447555637700217760ustar00rootroot00000000000000In this example, a django app would be configured to use pycrowdsec. Remediations are as following. - ban: These IP's would be redirected to a 403 page. - captcha: These IP's would be required to solve a google recaptcha. **Requirements:** - CrowdSec installed and listening on http://localhost:8080 . Find installation instructions [here](https://docs.crowdsec.net/Crowdsec/v1/getting_started/installation/) - Google reCaptcha API tokens. Find instructions [here](http://www.google.com/recaptcha/admin) ## Steps - Set the following environment variables ```bash export CROWDSEC_LAPI_KEY= export GOOGLE_RECAPTCHA_PRIVATE_KEY= export GOOGLE_RECAPTCHA_SITE_KEY= ``` **CROWDSEC_LAPI_KEY**: This can be generated by running the follwing command : ```bash ❯ sudo cscli bouncers add django_eg Api key for 'django_eg': ab9512a45e258b36d38ba8e274c5f1e4 ``` **GOOGLE_RECAPTCHA_PRIVATE_KEY** and **GOOGLE_RECAPTCHA_SITE_KEY**: Google would give these keys after setup completion - Install dependencies, make sure you are in a virtual environment. ```bash pip install -r requirements.txt ``` - Finally start django web app via : ```bash python manage.py runserver ``` The web app would be served at http://localhost:8000 . It would display a simple "Hello" message. - Now add some decisions: ```bash sudo cscli decisions add --value 127.0.0.1 --type captcha ``` Now if you navigate to the web app, you would be required to solve the captcha. You can also add a decision to ban some IP. ```bash sudo cscli decisions add --value --type ban ``` You can also remove remediations for some IP by running: ```bash sudo cscli decisions delete --ip ``` By default each "decision" would be deleted after 4 hours. pycrowdsec-0.0.5/examples/django_example/crowdsec_views/000077500000000000000000000000001447555637700235465ustar00rootroot00000000000000pycrowdsec-0.0.5/examples/django_example/crowdsec_views/__init__.py000066400000000000000000000000001447555637700256450ustar00rootroot00000000000000pycrowdsec-0.0.5/examples/django_example/crowdsec_views/apps.py000066400000000000000000000002371447555637700250650ustar00rootroot00000000000000from django.apps import AppConfig class CrowdsecViewsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "crowdsec_views" pycrowdsec-0.0.5/examples/django_example/crowdsec_views/templates/000077500000000000000000000000001447555637700255445ustar00rootroot00000000000000pycrowdsec-0.0.5/examples/django_example/crowdsec_views/templates/captcha_page.html000066400000000000000000000006201447555637700310270ustar00rootroot00000000000000 reCAPTCHA demo: Simple page
{% csrf_token %}

pycrowdsec-0.0.5/examples/django_example/crowdsec_views/views.py000066400000000000000000000031331447555637700252550ustar00rootroot00000000000000import os import requests from django.core.cache import cache from django.core.exceptions import PermissionDenied from django.http.response import HttpResponse from django.shortcuts import redirect, render from django.urls import reverse # Create your views here. def validate_captcha_resp(g_recaptcha_response): resp = requests.post( url="https://www.google.com/recaptcha/api/siteverify", data={ "secret": os.environ.get("GOOGLE_RECAPTCHA_PRIVATE_KEY"), "response": g_recaptcha_response, }, ).json() return resp["success"] def captcha_view(request): if not cache.get("valid_captcha_keys"): cache.set("valid_captcha_keys", set(), 30) if request.method == "GET": return render( request, "captcha_page.html", context={"public_key": os.environ.get("GOOGLE_RECAPTCHA_SITE_KEY")}, ) elif request.method == "POST": captcha_resp = request.POST.get("g-recaptcha-response") if not captcha_resp: return redirect(reverse("captcha_view")) is_valid = validate_captcha_resp(captcha_resp) if is_valid: valid_keys = cache.get("valid_captcha_keys") valid_keys.add(captcha_resp) cache.set("valid_captcha_keys", valid_keys, 30) request.session["captcha_key"] = captcha_resp return redirect(reverse("index")) else: return redirect(reverse("captcha_view")) def ban_view(request): raise PermissionDenied def index_view(request): return HttpResponse("

Hello !

") pycrowdsec-0.0.5/examples/django_example/django_example/000077500000000000000000000000001447555637700234755ustar00rootroot00000000000000pycrowdsec-0.0.5/examples/django_example/django_example/__init__.py000066400000000000000000000000001447555637700255740ustar00rootroot00000000000000pycrowdsec-0.0.5/examples/django_example/django_example/asgi.py000066400000000000000000000006251447555637700247750ustar00rootroot00000000000000""" ASGI config for django_example project. It exposes the ASGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ """ import os from django.core.asgi import get_asgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_example.settings") application = get_asgi_application() pycrowdsec-0.0.5/examples/django_example/django_example/settings.py000066400000000000000000000075031447555637700257140ustar00rootroot00000000000000""" Django settings for django_example project. Generated by 'django-admin startproject' using Django 3.2.6. For more information on this file, see https://docs.djangoproject.com/en/3.2/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/3.2/ref/settings/ """ import os from pathlib import Path from django.core.cache import cache from django.shortcuts import redirect from django.urls import reverse # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "django-insecure-z*(=%mshgl@3(!fqe0w33v@uf)n(i$aiqmsejy2+mnwitj!4=+" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "crowdsec_views", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "pycrowdsec.django.crowdsec_middleware", ] ROOT_URLCONF = "django_example.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ], }, }, ] WSGI_APPLICATION = "django_example.wsgi.application" # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3", } } SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" PYCROWDSEC_LAPI_KEY = os.environ.get("CROWDSEC_LAPI_KEY") PYCROWDSEC_ACTIONS = { "ban": lambda req: redirect(reverse("ban_view")), "captcha": lambda req: redirect(reverse("captcha_view")) if not (req.session.get("captcha_key") in cache.get("valid_captcha_keys", default=set())) else None, } PYCROWDSEC_POLL_INTERVAL = 5 PYCROWDSEC_EXCLUDE_VIEWS = {"ban_view", "captcha_view"} # Password validation # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] # Internationalization # https://docs.djangoproject.com/en/3.2/topics/i18n/ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True USE_L10N = True USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.2/howto/static-files/ STATIC_URL = "/static/" # Default primary key field type # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" pycrowdsec-0.0.5/examples/django_example/django_example/urls.py000066400000000000000000000017451447555637700250430ustar00rootroot00000000000000"""django_example URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/3.2/topics/http/urls/ Examples: Function views 1. Add an import: from my_app import views 2. Add a URL to urlpatterns: path('', views.home, name='home') Class-based views 1. Add an import: from other_app.views import Home 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from os import name from crowdsec_views.views import ban_view, captcha_view, index_view from django.contrib import admin from django.urls import path urlpatterns = [ path("", index_view, name="index"), path("admin/", admin.site.urls), path("ban/", view=ban_view, name="ban_view"), path("captcha/", view=captcha_view, name="captcha_view"), ] pycrowdsec-0.0.5/examples/django_example/django_example/wsgi.py000066400000000000000000000006251447555637700250230ustar00rootroot00000000000000""" WSGI config for django_example project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_example.settings") application = get_wsgi_application() pycrowdsec-0.0.5/examples/django_example/manage.py000077500000000000000000000012361447555637700223270ustar00rootroot00000000000000#!/usr/bin/env python """Django's command-line utility for administrative tasks.""" import os import sys def main(): """Run administrative tasks.""" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_example.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc execute_from_command_line(sys.argv) if __name__ == "__main__": main() pycrowdsec-0.0.5/examples/django_example/requirements.txt000066400000000000000000000000211447555637700237750ustar00rootroot00000000000000django pycrowdsecpycrowdsec-0.0.5/examples/flask_example/000077500000000000000000000000001447555637700203565ustar00rootroot00000000000000pycrowdsec-0.0.5/examples/flask_example/README.md000066400000000000000000000033611447555637700216400ustar00rootroot00000000000000In this example, a flask app would be configured to use pycrowdsec. Remediations are as following. - ban: These IP's would be redirected to a 403 page. - captcha: These IP's would be required to solve a google recaptcha. **Requirements:** - CrowdSec installed and listening on http://localhost:8080 . Find installation instructions [here](https://docs.crowdsec.net/Crowdsec/v1/getting_started/installation/) - Google reCaptcha API tokens. Find instructions [here](http://www.google.com/recaptcha/admin) ## Steps - Set the following environment variables ```bash export CROWDSEC_LAPI_KEY= export GOOGLE_RECAPTCHA_PRIVATE_KEY= export GOOGLE_RECAPTCHA_SITE_KEY= ``` **CROWDSEC_LAPI_KEY**: This can be generated by running the follwing command : ```bash ❯ sudo cscli bouncers add flask_eg Api key for 'flask_eg': ab9512a45e258b36d38ba8e274c5f1e4 ``` **GOOGLE_RECAPTCHA_PRIVATE_KEY** and **GOOGLE_RECAPTCHA_SITE_KEY**: Google would give these keys after setup completion - Install dependencies, make sure you are in a virtual environment. ```bash pip install -r requirements.txt ``` - Finally start flask web app via : ```bash python main.py ``` The web app would be served at http://localhost:5000 . It would display a simple "Hello" message. - Now add some decisions: ```bash sudo cscli decisions add --value 127.0.0.1 --type captcha ``` Now if you navigate to the web app, you would be required to solve the captcha. You can also add a decision to ban some IP. ```bash sudo cscli decisions add --value --type ban ``` You can also remove remediations for some IP by running: ```bash sudo cscli decisions delete --ip ``` By default each "decision" would be deleted after 4 hours.pycrowdsec-0.0.5/examples/flask_example/main.py000066400000000000000000000042041447555637700216540ustar00rootroot00000000000000import os import requests from expiringdict import ExpiringDict from flask import Flask, abort, redirect, render_template, request, session, url_for from pycrowdsec.client import StreamClient from pycrowdsec.flask import get_crowdsec_middleware c = StreamClient( lapi_url="http://localhost:8080/", api_key=os.environ.get("CROWDSEC_LAPI_KEY"), # your crowdsec LAPI bouncer key goes here interval=5, ) c.run() app = Flask(__name__) app.secret_key = "SET_SECRET_KEY" # In Production env use a "real" cache backend for this. Otherwise # different app instances would end up having different "valid_captcha_keys". valid_captcha_keys = ExpiringDict(max_len=1000, max_age_seconds=30) def validate_captcha_resp(g_recaptcha_response): resp = requests.post( url="https://www.google.com/recaptcha/api/siteverify", data={ "secret": os.environ.get("GOOGLE_RECAPTCHA_PRIVATE_KEY"), "response": g_recaptcha_response, }, ).json() return resp["success"] actions = { "ban": lambda: redirect(url_for("ban_page")), "captcha": lambda: redirect(url_for("captcha_page")) if session.get("captcha_resp") not in valid_captcha_keys else None, } app.before_request( get_crowdsec_middleware(actions, c.cache, exclude_views=("captcha_page", "ban_page")) ) @app.route("/captcha", methods=["GET", "POST"]) def captcha_page(): if request.method == "GET": return render_template( "captcha_page.html", public_key=os.environ.get("GOOGLE_RECAPTCHA_SITE_KEY") ) elif request.method == "POST": captcha_resp = request.form.get("g-recaptcha-response") if not captcha_resp: return redirect(url_for("captcha_page")) is_valid = validate_captcha_resp(captcha_resp) if not is_valid: return redirect(url_for("captcha_page")) session["captcha_resp"] = captcha_resp valid_captcha_keys[captcha_resp] = None return redirect(url_for("index")) @app.route("/") def index(): return "Hello" @app.route("/ban") def ban_page(): return abort(403) if __name__ == "__main__": app.run(host="0.0.0.0") pycrowdsec-0.0.5/examples/flask_example/requirements.txt000066400000000000000000000000461447555637700236420ustar00rootroot00000000000000flask pycrowdsec requests expiringdictpycrowdsec-0.0.5/examples/flask_example/templates/000077500000000000000000000000001447555637700223545ustar00rootroot00000000000000pycrowdsec-0.0.5/examples/flask_example/templates/captcha_page.html000066400000000000000000000005351447555637700256440ustar00rootroot00000000000000 reCAPTCHA demo: Simple page

pycrowdsec-0.0.5/pyproject.toml000066400000000000000000000001471447555637700166430ustar00rootroot00000000000000[build-system] requires = [ "setuptools>=42", "wheel" ] build-backend = "setuptools.build_meta"pycrowdsec-0.0.5/pytest.ini000066400000000000000000000002551447555637700157600ustar00rootroot00000000000000[pytest] addopts = --pdbcls=IPython.terminal.debugger:Pdb --ignore=test/install --ignore=test/backends --strict-markers env_files = .env default.env pycrowdsec-0.0.5/requirements-dev.txt000066400000000000000000000001431447555637700177630ustar00rootroot00000000000000https://github.com/crowdsecurity/pytest-cs/archive/main.zip black redislite pytest pytest-dotenv pycrowdsec-0.0.5/setup.cfg000066400000000000000000000014331447555637700155470ustar00rootroot00000000000000[metadata] name = pycrowdsec author = CrowdSec author_email = core.tech@crowdsec.net description = Python bouncer and clients for crowdsec long_description = file: README.md long_description_content_type = text/markdown url = https://github.com/crowdsecurity/pycrowdsec project_urls = Bug Tracker = https://github.com/crowdsecurity/pycrowdsec/issues classifiers = Programming Language :: Python :: 3 Operating System :: OS Independent License :: OSI Approved :: MIT License [build-system] requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"] [tool.setuptools_scm] [options] package_dir = = src packages = find: python_requires = >=3.7 install_requires = requests importlib-metadata [options.extras_require] geo = geoip2 [options.packages.find] where = src pycrowdsec-0.0.5/setup.py000066400000000000000000000002521447555637700154360ustar00rootroot00000000000000#!/usr/bin/env python import setuptools if __name__ == "__main__": setuptools.setup( use_scm_version=True, setup_requires=["setuptools_scm"], ) pycrowdsec-0.0.5/src/000077500000000000000000000000001447555637700145145ustar00rootroot00000000000000pycrowdsec-0.0.5/src/__init__.py000066400000000000000000000000001447555637700166130ustar00rootroot00000000000000pycrowdsec-0.0.5/src/pycrowdsec/000077500000000000000000000000001447555637700166765ustar00rootroot00000000000000pycrowdsec-0.0.5/src/pycrowdsec/__init__.py000066400000000000000000000000001447555637700207750ustar00rootroot00000000000000pycrowdsec-0.0.5/src/pycrowdsec/cache.py000066400000000000000000000074401447555637700203200ustar00rootroot00000000000000import ipaddress import threading IPV4_NETMASKS = [int(ipaddress.ip_network(f"0.0.0.0/{i}").netmask) for i in range(32, -1, -1)] IPV6_NETMASKS = [int(ipaddress.ip_network(f"::/{i}").netmask) for i in range(128, -1, -1)] NETMASKS_BY_KEY_TYPE = {"ipv4": IPV4_NETMASKS, "ipv6": IPV6_NETMASKS} def item_to_string(item): try: ip = ipaddress.ip_network(item) return f"ipv{ip.version}_{int(ip.netmask)}_{int(ip.network_address)}" except ValueError: return f"normal_{item}" class Cache: def __init__(self): self.lock = threading.Lock() self.cache = {} def get(self, item): with self.lock: key = item_to_string(item) key_parts = key.split("_") key_type = key_parts[0] if key_type == "normal": return self.cache.get(key) item_network_address = int(key_parts[-1]) netmasks = NETMASKS_BY_KEY_TYPE[key_type] for netmask in netmasks: resp = self.cache.get(f"{key_type}_{netmask}_{item_network_address & netmask}") if resp: return resp def get_all(self): with self.lock: resp = {} for item, action in self.cache.items(): if item.startswith("normal_"): resp[item.split("_", maxsplit=1)[1]] = action elif item.startswith("ipv"): _, netmask, address = item.split("_") ip_network = ipaddress.ip_network((int(address), bin(int(netmask)).count("1"))) resp[ip_network.__str__()] = action return resp def insert(self, item, action): key = item_to_string(item) with self.lock: self.cache[key] = action def delete(self, item): key = item_to_string(item) with self.lock: self.cache.pop(key, None) def __len__(self): with self.lock: return len(self.cache) class RedisCache: def __init__(self, redis_connection): self.lock = threading.Lock() self.redis = redis_connection def get(self, item): with self.lock: key = item_to_string(item) key_parts = key.split("_") key_type = key_parts[0] if key_type == "normal": return self.redis.hget("pycrowdsec_cache", key) item_network_address = int(key_parts[-1]) netmasks = NETMASKS_BY_KEY_TYPE[key_type] check_for = [] for netmask in netmasks: check_for.append(f"{key_type}_{netmask}_{item_network_address & netmask}") responses = self.redis.hmget("pycrowdsec_cache", check_for) for response in responses: if response: return response.decode() def insert(self, item, action): with self.lock: key = item_to_string(item) self.redis.hset("pycrowdsec_cache", key, action) def get_all(self): with self.lock: resp = {} for item, action in self.redis.hgetall("pycrowdsec_cache").items(): item, action = item.decode(), action.decode() if item.startswith("normal_"): resp[item.split("_", maxsplit=1)[1]] = action elif item.startswith("ipv"): _, netmask, address = item.split("_") ip_network = ipaddress.ip_network((int(address), bin(int(netmask)).count("1"))) resp[ip_network.__str__()] = action return resp def delete(self, item): with self.lock: key = item_to_string(item) self.redis.hdel("pycrowdsec_cache", key) def __len__(self): with self.lock: return self.redis.hlen("pycrowdsec_cache") pycrowdsec-0.0.5/src/pycrowdsec/client.py000066400000000000000000000172771447555637700205440ustar00rootroot00000000000000import logging import queue import threading from abc import ABC, abstractmethod try: from importlib import metadata except ImportError: # for Python<3.8 import importlib_metadata as metadata __version__ = metadata.version("pycrowdsec") from time import sleep import requests from pycrowdsec.cache import Cache, RedisCache logger = logging.getLogger(__name__) def create_session(api_key, insecure_skip_verify, key_path, cert_path, ca_cert_path, user_agent): session = requests.Session() session.verify = not insecure_skip_verify session.headers.update( { "User-Agent": user_agent, }, ) if api_key: session.headers.update( {"X-Api-Key": api_key}, ) else: if ca_cert_path: session.verify = ca_cert_path session.cert = (cert_path, key_path) return session class QueryClient: def __init__( self, api_key="", lapi_url="http://localhost:8080/", user_agent=f"python-bouncer/{metadata.version('pycrowdsec')}", insecure_skip_verify=False, key_path="", cert_path="", ca_cert_path="", ): """ Initializes a new instance of the CrowdSec API client. Args: api_key (str): The API key to use for authentication. lapi_url (str): The URL of the local API server. user_agent (str): The user agent string to use for requests. insecure_skip_verify (bool): Whether to skip SSL verification. key_path (str): The path to the client's private key file. cert_path (str): The path to the client's certificate file. ca_cert_path (str): The path to the CA certificate file. """ if api_key == "" and key_path == "" and cert_path == "": raise ValueError("You must provide an api_key or a key_path and cert_path") self.lapi_url = lapi_url self.session = create_session( api_key, insecure_skip_verify, key_path, cert_path, ca_cert_path, user_agent ) def get_decisions_for(self, item): resp = self.session.get(f"{self.lapi_url}v1/decisions?ip={item}") resp.raise_for_status() return resp.json() def get_action_for(self, item): decisions = self.get_decisions_for(item) return max(decisions, key=lambda d: d["id"])["type"] class BaseStreamClient(ABC): def __init__( self, api_key, lapi_url="http://localhost:8080/", interval=15, user_agent=f"python-bouncer/{__version__}", scopes=("ip", "range"), include_scenarios_containing=(), exclude_scenarios_containing=(), only_include_decisions_from=(), insecure_skip_verify=False, key_path="", cert_path="", ca_cert_path="", **kwargs, ): """ Initializes a new instance of the CrowdSec API client. Args: api_key (str): The API key to use for authentication. lapi_url (str): The URL of the local API server. interval (int): The interval in seconds between each decision request. user_agent (str): The user agent string to use for requests. scopes (Tuple[str]): The list of scopes to request from the API. include_scenarios_containing (Tuple[str]): The list of scenario names to include in decisions. exclude_scenarios_containing (Tuple[str]): The list of scenario names to exclude from decisions. only_include_decisions_from (Tuple[str]): The list of decision sources to include in decisions. insecure_skip_verify (bool): Whether to skip SSL verification. key_path (str): The path to the client's private key file. cert_path (str): The path to the client's certificate file. ca_cert_path (str): The path to the CA certificate file. **kwargs: Additional keyword arguments to pass to the requests library. """ if api_key == "" and (key_path == "" and cert_path == ""): raise ValueError("You must provide an api_key or a key_path and cert_path") self.api_key = api_key self.scopes = scopes self.interval = int(interval) self.lapi_url = lapi_url self.user_agent = user_agent self.death_reason = None self.include_scenarios_containing = include_scenarios_containing self.exclude_scenarios_containing = exclude_scenarios_containing self.only_include_decisions_from = only_include_decisions_from self.session = create_session( api_key, insecure_skip_verify, key_path, cert_path, ca_cert_path, user_agent ) self.__post_init__(**kwargs) def cycle(self, first_time): try: url = f"{self.lapi_url}v1/decisions/stream" params = { "startup": first_time, "scopes": ",".join(self.scopes), "scenarios_containing": ",".join(self.include_scenarios_containing), "scenarios_not_containing": ",".join(self.exclude_scenarios_containing), "origins": ",".join(self.only_include_decisions_from), } for k, v in params.copy().items(): if not v: del params[k] resp = self.session.get( url=url, params=params, ) resp.raise_for_status() self.process_response(resp.json()) except Exception as e: logger.error(f"pycrowdsec got error {e}") if first_time == "true": self.death_reason = e raise e def run(self): self.cycle("true") # So we catch errors on startup def _thread_cycle(): while True: sleep(self.interval) self.cycle("false") self.t = threading.Thread(target=_thread_cycle, daemon=True) self.t.start() def is_running(self): return self.t.is_alive() @abstractmethod def process_response(self, response): pass @abstractmethod def __post_init__(self, **kwargs): pass class StreamClient(BaseStreamClient): def __post_init__(self, **kwargs): if "redis_connection" in kwargs: self.cache = RedisCache(redis_connection=kwargs["redis_connection"]) else: self.cache = Cache() def get_action_for(self, item): return self.cache.get(item) def get_current_decisions(self): return self.cache.get_all() def process_response(self, response): if response["new"] is None: response["new"] = [] if response["deleted"] is None: response["deleted"] = [] for decision in response["deleted"]: self.cache.delete(decision["value"]) for decision in response["new"]: self.cache.insert(decision["value"], decision["type"]) class StreamDecisionClient(BaseStreamClient): def __post_init__(self, **kwargs): self.deleted_decisions = queue.SimpleQueue() self.new_decisions = queue.SimpleQueue() def get_new_decision(self): while not self.new_decisions.empty(): yield self.new_decisions.get() def get_deleted_decision(self): while not self.deleted_decisions.empty(): yield self.deleted_decisions.get() def process_response(self, response): if response["new"] is None: response["new"] = [] if response["deleted"] is None: response["deleted"] = [] for decision in response["deleted"]: self.deleted_decisions.put(decision) for decision in response["new"]: self.new_decisions.put(decision) pycrowdsec-0.0.5/src/pycrowdsec/django/000077500000000000000000000000001447555637700201405ustar00rootroot00000000000000pycrowdsec-0.0.5/src/pycrowdsec/django/__init__.py000066400000000000000000000043771447555637700222640ustar00rootroot00000000000000from django.conf import settings from django.core.exceptions import PermissionDenied from django.urls import resolve from pycrowdsec.client import StreamClient def crowdsec_middleware(get_response): def set_settings(): if not getattr(settings, "PYCROWDSEC_LAPI_KEY"): raise Exception("PYCROWDSEC_LAPI_KEY is required") settings.pycrowdsec_lapi_url = getattr( settings, "PYCROWDSEC_LAPI_URL", "http://localhost:8080/" ) settings.pycrowdsec_user_agent = getattr( settings, "PYCROWDSEC_USER_AGENT", "pycrowdsec-django-v1" ) # TBD settings.pycrowdsec_poll_interval = getattr(settings, "PYCROWDSEC_POLL_INTERVAL", 15) settings.pycrowdsec_scopes = getattr(settings, "PYCROWDSEC_SCOPES", ["ip", "range"]) settings.pycrowdsec_request_transformers = getattr( settings, "PYCROWDSEC_REQUEST_TRANSFORMERS", [lambda request: request.META.get("REMOTE_ADDR")], ) settings.pycrowdsec_actions = getattr( settings, "PYCROWDSEC_ACTIONS", {"ban": default_ban_action} ) settings.pycrowdsec_exclude_views = getattr(settings, "PYCROWDSEC_EXCLUDE_VIEWS", set()) set_settings() client = StreamClient( api_key=settings.PYCROWDSEC_LAPI_KEY, interval=settings.pycrowdsec_poll_interval, lapi_url=settings.pycrowdsec_lapi_url, scopes=settings.pycrowdsec_scopes, user_agent=settings.pycrowdsec_user_agent, ) client.run() def middleware(request): try: if resolve(request.path).view_name in settings.pycrowdsec_exclude_views: return get_response(request) except: return get_response(request) for request_transformer in settings.pycrowdsec_request_transformers: val = request_transformer(request) action = client.get_action_for(val) response = get_response(request) if action not in settings.pycrowdsec_actions: return response res = settings.pycrowdsec_actions[action](request) if not res: return response return res return middleware def default_ban_action(request): raise PermissionDenied pycrowdsec-0.0.5/src/pycrowdsec/flask/000077500000000000000000000000001447555637700177765ustar00rootroot00000000000000pycrowdsec-0.0.5/src/pycrowdsec/flask/__init__.py000066400000000000000000000034041447555637700221100ustar00rootroot00000000000000from flask import request def get_crowdsec_middleware( actions_by_name, crowdsec_cache, ip_transformers=[lambda request: request.remote_addr], exclude_views=(), ): """ Returns a middleware function for flask, which can be registered by passsing it to app.before_request Parameters ---------- actions_by_name(Required): Dictionary where key is an action string, eg "ban". The value is a function which carries out the action. Eg {"ban": lambda : redirect(url_for("ban_page")) } crowdsec_cache(Required): An instance of pycrowdsec.cache.Cache. It is a KV store, keys being entities like IP, Country strings etc. Values are action string for these entities. Eg {"1.2.3.4": "ban"} ip_transformers(Optional): List of functions which take in the request and produce some other string. This produced string is looked up in the cache, if found then then the remediation is applied. Eg: [lambda ip: ip, lambda ip: get_country_code_for(ip)] exclude_views(Optional): List of view function names, to exclude crowdsec actions. Example: ["ban_view", "captcha_page", "contact_page"] """ def middleware(): for ip_transformer in ip_transformers: action_name = crowdsec_cache.get(ip_transformer(request)) if not action_name: return destination_view = request.url_rule.endpoint if destination_view in exclude_views: return if action_name in actions_by_name: return actions_by_name[action_name]() return middleware pycrowdsec-0.0.5/src/pycrowdsec/utils.py000066400000000000000000000007611447555637700204140ustar00rootroot00000000000000def get_geoip_looker(db_path, scope="city"): import geoip2.database from geoip2.errors import AddressNotFoundError reader = geoip2.database.Reader(db_path) def geoip_looker(ip): try: scope_looker = getattr(reader, scope) return scope_looker(ip) except AttributeError: raise AttributeError(f"The mmdb at {db_path} doesn't support {scope} ") except AddressNotFoundError: return None return geoip_looker pycrowdsec-0.0.5/tests/000077500000000000000000000000001447555637700150675ustar00rootroot00000000000000pycrowdsec-0.0.5/tests/__init__.py000066400000000000000000000000001447555637700171660ustar00rootroot00000000000000pycrowdsec-0.0.5/tests/test_cache.py000066400000000000000000000136751447555637700175570ustar00rootroot00000000000000import random from unittest import TestCase from pycrowdsec.cache import Cache class TestIPv4Cache(TestCase): def setUp(self): self.cache = Cache() def test_insert_delete(self): self.cache.insert("1.2.3.4", "ban") assert self.cache.get("1.2.3.4") == "ban" assert self.cache.get("1.2.3.5") is None self.cache.delete("1.2.3.4") assert self.cache.get("1.2.3.4") is None assert self.cache.get("1.2.3.5") is None def test_range_simple_range(self): self.cache.insert("0.0.0.0/24", "ban") for i in range(256): ip = f"0.0.0.{i}" with self.subTest(ip=ip): assert self.cache.get(ip) == "ban" assert self.cache.get(f"0.0.1.0") is None assert self.cache.get("::") is None self.cache.delete("0.0.0.0/24") for i in range(256): assert self.cache.get(f"0.0.0.{i}") is None def test_range_all_match(self): self.cache.insert("0.0.0.0/0", "ban") ip_parts = [str(i) for i in range(256)] for _ in range(20): ip = ".".join(random.choices(ip_parts, k=4)) with self.subTest(ip=ip): assert self.cache.get(ip) == "ban" self.cache.delete("0.0.0.0/0") for _ in range(20): ip = ".".join(random.choices(ip_parts, k=4)) with self.subTest(ip=ip): assert self.cache.get(ip) is None def test_range_overlap(self): def assert_state(a, b, c): assert self.cache.get("0.0.1.2") == a assert self.cache.get("0.0.255.255") == b self.cache.get("0.1.255.255") == c assert_state(None, None, None) self.cache.insert("0.0.0.0/16", "ban") assert_state("ban", "ban", None) self.cache.insert("0.0.0.0/8", "captcha") assert_state("ban", "ban", "captcha") self.cache.insert("0.0.1.0/24", "throttle") assert_state("throttle", "ban", "captcha") self.cache.delete("0.0.1.0/24") assert_state("ban", "ban", "captcha") self.cache.delete("0.0.0.0/8") assert_state("ban", "ban", None) self.cache.delete("0.0.0.0/16") assert_state(None, None, None) class TestIPv6Cache(TestCase): def setUp(self): self.cache = Cache() def test_insert_delete(self): assert self.cache.get("::") is None self.cache.insert("::", "ban") assert self.cache.get("::") == "ban" assert self.cache.get("1::") is None self.cache.delete("::") assert self.cache.get("1.2.3.4") is None assert self.cache.get("1.2.3.5") is None def test_range_simple_range(self): self.cache.insert("::/112", "ban") ips_to_check = random.choices(range(65535), k=100) for i in ips_to_check: ip = f"::{format(i, 'x')}" with self.subTest(ip=ip): assert self.cache.get(ip) == "ban" assert self.cache.get(f"1::") is None assert self.cache.get("0.0.0.0") is None self.cache.delete("::/112") for i in ips_to_check: ip = f"::{format(i, 'x')}" with self.subTest(ip=ip): assert self.cache.get(ip) is None def test_range_all_match(self): self.cache.insert("::/0", "ban") ip_parts = [format(i, "x") for i in range(65535)] for _ in range(20): ip = ":".join(random.choices(ip_parts, k=8)) with self.subTest(ip=ip): assert self.cache.get(ip) == "ban" self.cache.delete("::/0") for _ in range(20): ip = ":".join(random.choices(ip_parts, k=8)) with self.subTest(ip=ip): assert self.cache.get(ip) is None def test_range_overlap(self): def assert_state(a, b, c): assert self.cache.get("::ffff") == a assert self.cache.get("::1:ffff") == b self.cache.get("::1:ffff:ffff") == c assert_state(None, None, None) self.cache.insert("::/112", "ban") assert_state("ban", None, None) self.cache.insert("::/98", "captcha") assert_state("ban", "captcha", None) self.cache.insert("::/82", "throttle") assert_state("ban", "captcha", "throttle") self.cache.insert("::ffff/128", "block") assert_state("block", "captcha", "throttle") self.cache.delete("::ffff/128") assert_state("ban", "captcha", "throttle") self.cache.delete("::/82") assert_state("ban", "captcha", None) self.cache.delete("::/98") assert_state("ban", None, None) self.cache.delete("::/112") assert_state(None, None, None) class TestCache(TestCase): def setUp(self): self.cache = Cache() def test_non_ip_items(self): assert self.cache.get("TM") is None self.cache.insert("TM", "ban") assert self.cache.get("TM") == "ban" self.cache.insert("TM", "captcha") assert self.cache.get("TM") == "captcha" self.cache.delete("TM") assert self.cache.get("TM") is None def test_ip_items(self): assert self.cache.get("::ffff") is None self.cache.insert("::/112", "ban") assert self.cache.get("::ffff") == "ban" assert self.cache.get("::abcd") == "ban" assert self.cache.get("::abcd:abcd") is None assert self.cache.get("1.2.3.4") is None self.cache.insert("1.2.0.0/16", "ban") assert self.cache.get("1.2.3.4") == "ban" self.cache.delete("1.2.0.0/16") assert self.cache.get("1.2.3.4") is None def test_get_all(self): self.cache.insert("CN", "captcha") self.cache.insert("1.2.3.4", "ban") self.cache.insert("1.2.3.0/24", "ban") self.cache.insert("ffff::/24", "ban") resp = self.cache.get_all() assert resp["ffff::/24"] == "ban" assert resp["1.2.3.0/24"] == "ban" assert resp["1.2.3.4/32"] == "ban" assert resp["CN"] == "captcha" pycrowdsec-0.0.5/tests/test_mtls.py000066400000000000000000000040131447555637700174550ustar00rootroot00000000000000import pytest from requests.exceptions import HTTPError import json from pycrowdsec.client import StreamDecisionClient def test_tls_mutual(crowdsec, certs_dir): """TLS with two-way bouncer/lapi authentication""" lapi_env = { "CACERT_FILE": "/etc/ssl/crowdsec/ca.crt", "LAPI_CERT_FILE": "/etc/ssl/crowdsec/lapi.crt", "LAPI_KEY_FILE": "/etc/ssl/crowdsec/lapi.key", "USE_TLS": "true", "LOCAL_API_URL": "https://localhost:8080", } certs = certs_dir(lapi_hostname="lapi") volumes = { certs: {"bind": "/etc/ssl/crowdsec", "mode": "ro"}, } with crowdsec(environment=lapi_env, volumes=volumes) as cs: cs.wait_for_log("*CrowdSec Local API listening*") # TODO: wait_for_https cs.wait_for_http(8080, "/health", want_status=None) port = cs.probe.get_bound_port("8080") lapi_url = f"https://localhost:{port}/" bouncer = StreamDecisionClient( "", lapi_url, key_path=(certs / "bouncer.key").as_posix(), cert_path=(certs / "bouncer.crt").as_posix(), ca_cert_path=(certs / "ca.crt").as_posix(), user_agent="bouncer_under_test", ) bouncer.cycle("true") res = cs.cont.exec_run("cscli bouncers list -o json") assert res.exit_code == 0 bouncers = json.loads(res.output) assert len(bouncers) == 1 assert bouncers[0]["name"].startswith("@") assert bouncers[0]["auth_type"] == "tls" assert bouncers[0]["type"] == "bouncer_under_test" bouncer = StreamDecisionClient( "", lapi_url, key_path=(certs / "agent.key").as_posix(), cert_path=(certs / "agent.crt").as_posix(), ca_cert_path=(certs / "ca.crt").as_posix(), ) with pytest.raises(HTTPError, match="403"): bouncer.cycle("true") cs.wait_for_log( "*client certificate OU (?agent-ou?) doesn't match expected OU (?bouncer-ou?)*" ) pycrowdsec-0.0.5/tests/test_redis_integration.py000066400000000000000000000032121447555637700222070ustar00rootroot00000000000000import unittest from redislite import Redis from pycrowdsec.cache import RedisCache class TestRedisIntegration(unittest.TestCase): def setUp(self): self.redis = Redis() self.cache = RedisCache(redis_connection=self.redis) def test_store_normal_to_redis(self): assert not self.redis.exists("pycrowdsec_cache") self.cache.insert("124122", "ban") assert self.redis.exists("pycrowdsec_cache") assert self.redis.hget("pycrowdsec_cache", b"normal_124122") == b"ban" def test_ip_to_redis(self): self.cache.insert("1.2.3.4", "captcha") self.cache.insert("::ffff", "captcha") assert self.redis.hgetall("pycrowdsec_cache") == { b"ipv4_4294967295_16909060": b"captcha", b"ipv6_340282366920938463463374607431768211455_65535": b"captcha", } def test_delete(self): self.cache.insert("1.2.3.4", "captcha") self.cache.insert("::ffff", "captcha") self.cache.insert("TH", "ban") assert self.redis.hlen("pycrowdsec_cache") == 3 self.cache.delete("TH") assert self.redis.hlen("pycrowdsec_cache") == 2 self.cache.delete("1.2.3.4") assert self.redis.hlen("pycrowdsec_cache") == 1 self.cache.delete("::ffff") assert self.redis.hlen("pycrowdsec_cache") == 0 def test_get_all(self): self.cache.insert("1.2.3.4", "captcha") self.cache.insert("::ffff", "captcha") self.cache.insert("TH", "ban") resp = self.cache.get_all() assert resp["1.2.3.4/32"] == "captcha" assert resp["TH"] == "ban" assert resp["::ffff/128"] == "captcha" pycrowdsec-0.0.5/tests/test_stream_client.py000066400000000000000000000054631447555637700213410ustar00rootroot00000000000000import ipaddress import threading import unittest from pycrowdsec.client import StreamClient class TestStreamClient(unittest.TestCase): def setUp(self): self.client = StreamClient("abcd") def test_process_response(self): response = { "deleted": [ { "duration": "-40h37m10.022674981s", "id": 1, "origin": "cscli", "scenario": "manual 'ban' from 'b436842423d302bb11cb6f1160d6cb30q9EGL7irEAzdUu1z'", "scope": "Ip", "type": "ban", "value": "18.22.10.20", }, { "duration": "-37m7.335622172s", "id": 97, "origin": "CAPI", "scenario": "crowdsecurity/http-crawl-non_statics", "scope": "Ip", "type": "ban", "value": "185.220.101.204", }, ], "new": [ { "duration": "-37m7.335622172s", "id": 97, "origin": "CAPI", "scenario": "crowdsecurity/http-crawl-non_statics", "scope": "Ip", "type": "ban", "value": "18.22.10.20", }, ], } self.client.process_response(response) assert len(self.client.cache) == 1 response["new"] = None self.client.process_response(response) assert len(self.client.cache) == 0 def test_read_write_race(self): response = { "deleted": [ { "duration": "-40h37m10.022674981s", "id": 1, "origin": "cscli", "scenario": "manual 'ban' from 'b436842423d302bb11cb6f1160d6cb30q9EGL7irEAzdUu1z'", "scope": "Ip", "type": "ban", "value": str(ipaddress.IPv4Address(v)), } for v in range(100) ], "new": [ { "duration": "-37m7.335622172s", "id": 97, "origin": "CAPI", "scenario": "crowdsecurity/http-crawl-non_statics", "scope": "Ip", "type": "ban", "value": str(ipaddress.IPv4Address(v)), } for v in range(100) ], } def response_filler(): for _ in range(100): self.client.process_response(response) t = threading.Thread(target=response_filler) t.start() for _ in range(1000): self.client.get_current_decisions() pycrowdsec-0.0.5/tests/test_stream_decision_client.py000066400000000000000000000063701447555637700232140ustar00rootroot00000000000000import ipaddress import threading import unittest from pycrowdsec.client import StreamDecisionClient class TestStreamDecisionClient(unittest.TestCase): def setUp(self): self.client = StreamDecisionClient("abcd") def test_process_response(self): response = { "deleted": [ { "duration": "-40h37m10.022674981s", "id": 1, "origin": "cscli", "scenario": "manual 'ban' from 'b436842423d302bb11cb6f1160d6cb30q9EGL7irEAzdUu1z'", "scope": "Ip", "type": "ban", "value": "18.22.10.20", }, { "duration": "-37m7.335622172s", "id": 97, "origin": "CAPI", "scenario": "crowdsecurity/http-crawl-non_statics", "scope": "Ip", "type": "ban", "value": "185.220.101.204", }, ], "new": [ { "duration": "-37m7.335622172s", "id": 97, "origin": "CAPI", "scenario": "crowdsecurity/http-crawl-non_statics", "scope": "Ip", "type": "ban", "value": "18.22.10.20", }, ], } self.client.process_response(response) assert len(list(self.client.get_new_decision())) == 1 assert len(list(self.client.get_deleted_decision())) == 2 assert len(list(self.client.get_new_decision())) == 0 assert len(list(self.client.get_deleted_decision())) == 0 def test_empty(self): assert self.client.new_decisions.empty() == True assert self.client.deleted_decisions.empty() == True for _ in self.client.get_deleted_decision(): pass for _ in self.client.get_new_decision(): pass def test_read_write_race(self): response = { "deleted": [ { "duration": "-40h37m10.022674981s", "id": 1, "origin": "cscli", "scenario": "manual 'ban' from 'b436842423d302bb11cb6f1160d6cb30q9EGL7irEAzdUu1z'", "scope": "Ip", "type": "ban", "value": str(ipaddress.IPv4Address(v)), } for v in range(100) ], "new": [ { "duration": "-37m7.335622172s", "id": 97, "origin": "CAPI", "scenario": "crowdsecurity/http-crawl-non_statics", "scope": "Ip", "type": "ban", "value": str(ipaddress.IPv4Address(v)), } for v in range(100) ], } def response_filler(): for _ in range(100): self.client.process_response(response) t = threading.Thread(target=response_filler) t.start() for _ in range(1000): list(self.client.get_deleted_decision()) list(self.client.get_new_decision())