From ec3357be5d402205d562500ee378dc36a330384c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alo=C3=AFs=20Micard?= Date: Tue, 5 Jan 2021 20:08:08 +0100 Subject: [PATCH] Big improvements - Reduce debug noise - Create scripts to blacklist 'famous' legit hostnames - Make blacklister more resilient - Merge archiver & indexer together - Better prefix for cache key - Rework scheduling process - Update architecture.png - Remove trandoshanctl - Improve testing --- README.md | 50 +-- build/docker/Dockerfile.tdsh-archiver | 24 -- build/docker/Dockerfile.trandoshanctl | 24 -- cmd/tdsh-archiver/tdsh-archiver.go | 14 - cmd/trandoshanctl/trandoshanctl.go | 13 - deployments/docker/docker-compose.yml | 28 +- docs/architecture.png | Bin 55113 -> 60349 bytes internal/archiver/archiver.go | 99 ----- internal/archiver/archiver_test.go | 55 --- internal/archiver/storage/storage.go | 11 - internal/blacklister/blacklister.go | 63 ++- internal/blacklister/blacklister_test.go | 104 ++++- internal/cache/cache.go | 4 +- internal/cache/redis.go | 23 +- internal/cache/redis_test.go | 15 + internal/configapi/client/client.go | 4 +- internal/configapi/configapi.go | 12 +- internal/configapi/configapi_test.go | 37 +- internal/constraint/hostname.go | 2 +- internal/crawler/crawler.go | 39 +- internal/crawler/crawler_test.go | 41 +- internal/event/event.go | 34 +- internal/{crawler => }/http/client.go | 0 internal/{crawler => }/http/response.go | 0 internal/indexer/auth/auth.go | 124 ------ internal/indexer/auth/auth_test.go | 85 ---- internal/indexer/client/client.go | 141 ------ internal/indexer/index/elastic.go | 151 +++---- internal/indexer/index/elastic_test.go | 56 +++ internal/indexer/index/index.go | 38 +- .../storage => indexer/index}/local.go | 36 +- .../storage => indexer/index}/local_test.go | 21 +- internal/indexer/indexer.go | 408 +----------------- internal/indexer/indexer_test.go | 371 ++-------------- internal/logging/log.go | 31 -- internal/logging/log_test.go | 18 - internal/process/process.go | 74 +++- internal/scheduler/scheduler.go | 101 ++++- internal/scheduler/scheduler_test.go | 186 ++++---- internal/test/process.go | 68 +++ internal/trandoshanctl/trandoshanctl.go | 119 ----- scripts/blacklist-hostnames.py | 56 +++ 42 files changed, 889 insertions(+), 1891 deletions(-) delete mode 100644 build/docker/Dockerfile.tdsh-archiver delete mode 100644 build/docker/Dockerfile.trandoshanctl delete mode 100644 cmd/tdsh-archiver/tdsh-archiver.go delete mode 100644 cmd/trandoshanctl/trandoshanctl.go delete mode 100644 internal/archiver/archiver.go delete mode 100644 internal/archiver/archiver_test.go delete mode 100644 internal/archiver/storage/storage.go create mode 100644 internal/cache/redis_test.go rename internal/{crawler => }/http/client.go (100%) rename internal/{crawler => }/http/response.go (100%) delete mode 100644 internal/indexer/auth/auth.go delete mode 100644 internal/indexer/auth/auth_test.go delete mode 100644 internal/indexer/client/client.go create mode 100644 internal/indexer/index/elastic_test.go rename internal/{archiver/storage => indexer/index}/local.go (57%) rename internal/{archiver/storage => indexer/index}/local_test.go (63%) delete mode 100644 internal/logging/log.go delete mode 100644 internal/logging/log_test.go create mode 100644 internal/test/process.go delete mode 100644 internal/trandoshanctl/trandoshanctl.go create mode 100755 scripts/blacklist-hostnames.py diff --git a/README.md b/README.md index 135adbb..a8e9104 100644 --- a/README.md +++ b/README.md @@ -30,50 +30,16 @@ and wait for all containers to start. # How to initiate crawling -Since the API is exposed on localhost:15005, one can use it to start crawling: +One can use the RabbitMQ dashhboard available at localhost:15003, and publish a new JSON object in the **crawlingQueue**. -using trandoshanctl executable: +The object should look like this: -```sh -$ trandoshanctl --api-token schedule https://www.facebookcorewwwi.onion -``` - -or using the docker image: - -```sh -$ docker run creekorful/trandoshanctl --api-token --api-uri schedule https://www.facebookcorewwwi.onion -``` - -(you'll need to specify the api uri if you use the docker container) - -this will schedule given URL for crawling. - -## Example token - -Here's a working API token that you can use with trandoshanctl if you haven't changed the API signing key: - -``` -eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRyYW5kb3NoYW5jdGwiLCJyaWdodHMiOnsiUE9TVCI6WyIvdjEvdXJscyJdLCJHRVQiOlsiL3YxL3Jlc291cmNlcyJdfX0.jGA8WODYKtKy7ZijngoV8C3iWi1eTvMitA8Z1Is2GUg -``` - -This token is the representation of the following payload: - -``` +```json { - "username": "trandoshanctl", - "rights": { - "POST": [ - "/v1/urls" - ], - "GET": [ - "/v1/resources" - ] - } + "url": "https://facebookcorewwwi.onion" } ``` -you may create your own tokens with the rights needed. In the future a CLI tool will allow token generation easily. - ## How to speed up crawling If one want to speed up the crawling, he can scale the instance of crawling component in order to increase performances. @@ -87,14 +53,6 @@ this will set the number of crawler instance to 5. # How to view results -## Using trandoshanctl - -```sh -$ trandoshanctl search -``` - -## Using kibana - You can use the Kibana dashboard available at http://localhost:15004. You will need to create an index pattern named ' resources', and when it asks for the time field, choose 'time'. diff --git a/build/docker/Dockerfile.tdsh-archiver b/build/docker/Dockerfile.tdsh-archiver deleted file mode 100644 index 8041833..0000000 --- a/build/docker/Dockerfile.tdsh-archiver +++ /dev/null @@ -1,24 +0,0 @@ -# build image -FROM golang:1.15.0-alpine as builder - -RUN apk update && apk upgrade && \ - apk add --no-cache bash git openssh - -WORKDIR /app - -# Copy and download dependencies to cache them and faster build time -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . - -# Test then build app -RUN go build -v github.com/creekorful/trandoshan/cmd/tdsh-archiver - -# runtime image -FROM alpine:latest -COPY --from=builder /app/tdsh-archiver /app/ - -WORKDIR /app/ - -ENTRYPOINT ["./tdsh-archiver"] \ No newline at end of file diff --git a/build/docker/Dockerfile.trandoshanctl b/build/docker/Dockerfile.trandoshanctl deleted file mode 100644 index 11a4b90..0000000 --- a/build/docker/Dockerfile.trandoshanctl +++ /dev/null @@ -1,24 +0,0 @@ -# build image -FROM golang:1.15.0-alpine as builder - -RUN apk update && apk upgrade && \ - apk add --no-cache bash git openssh - -WORKDIR /app - -# Copy and download dependencies to cache them and faster build time -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . - -# Test then build app -RUN go build -v github.com/creekorful/trandoshan/cmd/trandoshanctl - -# runtime image -FROM alpine:latest -COPY --from=builder /app/trandoshanctl /app/ - -WORKDIR /app/ - -ENTRYPOINT ["./trandoshanctl"] \ No newline at end of file diff --git a/cmd/tdsh-archiver/tdsh-archiver.go b/cmd/tdsh-archiver/tdsh-archiver.go deleted file mode 100644 index 6b38e09..0000000 --- a/cmd/tdsh-archiver/tdsh-archiver.go +++ /dev/null @@ -1,14 +0,0 @@ -package main - -import ( - "github.com/creekorful/trandoshan/internal/archiver" - "github.com/creekorful/trandoshan/internal/process" - "os" -) - -func main() { - app := process.MakeApp(&archiver.State{}) - if err := app.Run(os.Args); err != nil { - os.Exit(1) - } -} diff --git a/cmd/trandoshanctl/trandoshanctl.go b/cmd/trandoshanctl/trandoshanctl.go deleted file mode 100644 index afe6c14..0000000 --- a/cmd/trandoshanctl/trandoshanctl.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -import ( - "github.com/creekorful/trandoshan/internal/trandoshanctl" - "os" -) - -func main() { - app := trandoshanctl.GetApp() - if err := app.Run(os.Args); err != nil { - os.Exit(1) - } -} diff --git a/deployments/docker/docker-compose.yml b/deployments/docker/docker-compose.yml index 0e5bb9f..d9ebbde 100644 --- a/deployments/docker/docker-compose.yml +++ b/deployments/docker/docker-compose.yml @@ -45,47 +45,47 @@ services: --log-level debug --hub-uri amqp://guest:guest@rabbitmq:5672 --config-api-uri http://configapi:8080 + --redis-uri redis:6379 restart: always depends_on: - rabbitmq - archiver: - image: creekorful/tdsh-archiver:latest + indexer-local: + image: creekorful/tdsh-indexer:latest command: > --log-level debug --hub-uri amqp://guest:guest@rabbitmq:5672 - --storage-dir /archive + --config-api-uri http://configapi:8080 + --index-driver local + --index-dest /archive restart: always volumes: - archiverdata:/archive depends_on: - rabbitmq - indexer: + - configapi + indexer-es: image: creekorful/tdsh-indexer:latest command: > --log-level debug --hub-uri amqp://guest:guest@rabbitmq:5672 - --elasticsearch-uri http://elasticsearch:9200 - --signing-key K==M5RsU_DQa4_XSbkX?L27s^xWmde25 --config-api-uri http://configapi:8080 - --redis-uri redis:6379 + --index-driver elastic + --index-dest http://elasticsearch:9200 restart: always depends_on: - rabbitmq - elasticsearch - configapi - - redis - ports: - - 15005:8080 configapi: image: creekorful/tdsh-configapi:latest command: > --log-level debug --hub-uri amqp://guest:guest@rabbitmq:5672 --redis-uri redis:6379 - --default-value forbidden-hostnames="[{\"hostname\": \"facebookcorewwwi.onion\"}, {\"hostname\": \"nytimes3xbfgragh.onion\"}]" + --default-value forbidden-hostnames="[]" --default-value allowed-mime-types="[{\"content-type\":\"text/\",\"extensions\":[\"html\",\"php\",\"aspx\", \"htm\"]}]" --default-value refresh-delay="{\"delay\": -1}" - --default-value blacklist-threshold="{\"threshold\": 10}" + --default-value blacklist-threshold="{\"threshold\": 5}" restart: always depends_on: - rabbitmq @@ -99,11 +99,13 @@ services: --hub-uri amqp://guest:guest@rabbitmq:5672 --config-api-uri http://configapi:8080 --redis-uri redis:6379 + --tor-uri torproxy:9050 restart: always depends_on: - rabbitmq - configapi - redis + - torproxy volumes: esdata: @@ -113,4 +115,4 @@ volumes: archiverdata: driver: local redisdata: - driver: local \ No newline at end of file + driver: local diff --git a/docs/architecture.png b/docs/architecture.png index 7a4090fb6b5cce520924e0012c1091520ea680da..af552ee57b2cba4602677c365a6d01ce2bee735f 100644 GIT binary patch literal 60349 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

CV| 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 RVG+*JPEE} z__9P-r##)c1;6LzWl<{txKuFo@F4$_ZNZbm(XmV}Wm$Of;A zXnfkoPa@@kE3pa#l7y{6)@v5|!&oWg=~6Y~rUR~RE%rVKL|kj%!U8^7r*AixIfwc;55Rc&(dEH$<6*zZu^MS>X|^dAdSIVb{C!($>N(Y|PY zmK35hQ3Hm~wllP(0^s@Y-@eFSSz3am^V!8?qUeBIjtHSSZdo9^WO5|#J zLjD8yZ+FTA1Cfco(+$9YONVBYD5FBF3!u}OJ3^_b3Q(3|W-iQETP7NY`Z90Sp?UHD z9aD1P&Ooey{ZW+g7MhF>ohB8$0}g>tgkE*YNBn-JDS*fey-ELo2VDaw0$c5#gUV+P zlw9iSigMs45=79z9}B>UZfI4Q_y+WH1m+{aJu%))2skn@gHWfB5#fJG@&7g{lBnh* z35Q(HPBA>aErUa?31}6U!&{nH|*SrpXW@#{=N&gP{dqWU#`;_o*S0LP-a;kmYE1<1bK3cOM&6_Cfi^Vs#h0zFbGRzGp7zf6fA zh%%;6j8}n{Z~SYIf(r28W1r+edaw7{pXfk1B!(w!U0`dEP1FqT^tg7b=!c2yP@}cLM@9jnP?bna0HEp)tP4hmEW?WA`$mv zGroPq)tr^3+}=apAst z58*zvQxs>j(X_gZZd;Gsf)Wq0o6T-%kqh-|9MNf*=ZIkroFwrxl+(rURt8U z=!d|-nPEh7<9?93@>o9J*r!jQGydUB&`&VbdKM&0Q>q{|kR_yb8{m1)%Ih$L*ocUp z;_&KN&B6Dw`KJz7KbEN}=b_RW<-vtXSno}Ez1aPb#&St?`_&Q#1aw<}Y z!mCh-OSZ@A>z9 z_3Fo-DiT3QZwYH{bapoW%c8j0YOb26shcV4CwWVe$ZU*ar^V6v!=XEBG(@(O$-Xk} zKkHYCT3MM#2S-QH2QMt%KXuR(BnDL~goqdjjoEVCQNJ~49P97Gq56%`%Mk4OwIQ$TK%M1~vWG|OLzy@=>I&-@H=0nzPtnN?h^~T6p zULE>nzHBdkaj;t3Tx&K}6Adn4D+e?xoL#=llt3Gu8fLe8HoIc4v8p~N$&`8hNs0_8uZB9Ozn&zWoBevXJpFaQgqJ1q?ibBaSXikZ z2qkuj-u8Up7xz7~N=(47{GgaAh%HOcwZt)5a!vj^xg^PQFk291i~%#2k42*kqh0V0 z_vm-YG{@u@H9mWBU*hjaYmxJp__et*3W>>k-p&5Ff-hDlIWvf=>@9HMqxe|v%JjoQ zSF6sIq>Oc9uF8qNF;+B!p$v}b+tG=UW)HLesd-`k_dME0Hk!&teo=pkpUA%+%|Et& zf*8Df2uhybCv5bHu-@rFzK?Z-21|Aat@WKQ2lf!(7Amc0>7e>)y6Ed&;ndx1Jyr!< z@l(vDfDkH;WOQr;0cV8>pA1%w*9FN|wolnS(u7%GkAJfIY_p-PP@VbNa$^B*j#dJJ zkcMW-S11kvFDfGdiU{zm2iM^CM;eKL>+dBO*4_? zUGC-K!GVOmeaW2PPga(dw4_RLGETSLqpQmaqT4?^0^Z9{Bfi&JVqN*0z4d+EG;4RW z2Dg2eQH{<%>z){pb)tIJ+mBME+Uo^TYn=rxgA&<|xfdlZ=eO67BsTL=G}fD9$QMEb z%bVG-;z!4Ty%mz(&_x}8)zp{g-#Zly9k9Q{7LWA;gf1LW_(sug=a!Vn#47TIqej516=c`r`rr%sumKUMl z>ixzxLZNnaxX{$n0JAyK&ZlyyGJCKE>-AlVpQO}Z;w`%jr`#&@Sy{!4wRQt-O}%eB zbdjGMnpHjx$y~_4th&P-?o0y(i6iFvsHzZHJXLv#Z^03svN9qGG?+LYftMAg?Q2pe zAOJb4l>zqw%~~bbyq{{out949HU32Zt{>HFLK+VN^d)dG{u42Eg%KanU_0X*7W7^) za;i^qeV_KOhm~nXTQ!#kHNS3JIJNP>`g>ztsQ*Aktwk=)AH)5zkMsn04KE3(S89LP zW@WVN)%y zK%AzHqe_*#Y% zb=xC|tik%@TFn!30qKRfWW?5trX|;9$6qRv@DN4%|AM_! zx8$lg>#xJXW^Ql<`}=Xjkr}!D0&#>e_sCH)J^_ntng>~84D7Fs z@*WkJtI2^2*5x%#^v3$H67?zdxmtUdTVvnw2}&;wjh1((^4TxZ&mS#_(P!%w`HJ4) zUX;wVC1qGV<6ds}SXba|x^_o-3)jfyx@+OKH-YuqS~0{EVvNVKQV{N$+u3&_yw3Bg z+{2ODxa9pYanfOeb+7eU$HWLfB4*d^e8BSF0{P+9AC0hPj}-78sa@#c1BKk+p%mIL zLMId8xCpuukfP(G|M-sV9~%qW0wP&I|?N(1-W{qf#q} zi>UC%AoC_5if}0cTwhkKv;Dr&?+_>D{5mCZb8v;rrvR;*KzYFfD$9&3TLSD z>)tS*57~;*Y2h==;hU4&BE#DEZ1GuIMF+wxg$V^@zY}18tU0AjzFBvO&#QaPNJ_wF zjKye8=%tYA8JTh(?t~f6nzn-4H&zDN8iYYHOpbIW>aX=|?Xos zoa4jp4hNH*4(rn~Kpj0oNW@ch$W?!wSA!&^d(}ut7@0ad!t~pi#NGY$`wv`g*D@I+ zS!`0$^TdM$Yqf6|;Z9$}^wn*V_~F^uDopockE9aBORmeGk6JZfzG{af_hWLU{Dry| z-^|S18}@@F(5%y7dHMGTJ7k)2@Gdg%Wyt$iOv;#HdB(fdZZoLgT}A)=A*W;L=qI#T zyAhVi9A?_s4xy2;&yuHV^25XTLNF4eU(-3cFu&c_NVv*-guo;LBZX3-%AHt3ampI; zfuMDL-A>(bevUiflsD>jO~Gw@K_AO!%7IX1cw{iP^bPLtN7a#1>o;GmZokmRWFkN( z_=W~1I6{uJHgVsd4k?hsQY%+`&1-v_rxulpBqWQCSWOO(hCeSkbUgv4xECP_Vi*FJ zK7rygLW->-jsgZgY66xY!I2lm8I<}OKwafPp1=aN?xJPLDKe~P_*}_Wl11ox^Ef}B;?*u z^L<9CR2^YA7%-l{o=$&Y6}CxkUR3qA_i|v&wxq2(E>KGPb*ariE-Lw;>q2FsZIP5A zww^d?gCm~^9xq_%kd&!)<_>ssY;O0URb?T;5l?=8*$GAx`hxcPg;H2>gyXW=?e<3Z zBvcrfa-trg;ag_DoI=BL(gR!TYEsCE33C$_;q ziBC_C32vs4-Xw`-1XW@h_=7PT+z2om<)><9x3*5*$q%AX0+}IT$>tP&t98QK+TQt0 z*9HHt`{n}Nb<+-=QAuK}qzu9#ZznTXz_(bEV{ISdnt}&8M;NG4)ot2?&CJYHkGSSL>%ZvhrNbv6vu?O-u_vJoqOn6>tQ%`2rIjq$-OOE8) z7TR0d*+qJvY~!0@%_YKKVw>C&EWE$a_vER83cN(%CZow@aNlYEdw~Exiaio-e}8PQ z%BpQ5c3&m{^9iX!!3?T?CVQD2BSj%yE0Ex;U6?9eTzx^#h(`X-lV`V*V@ z+ZNX*UoDv+KBhko;$F8;(ogKBD%gPGqVfGrp zgIQ$KFN)J}Yag`YDh}6`q|7_>p^-=(j{}1AhQ}?etQRFJE)4ENhg{>FaGrv9+6CuV zS5nvQln7a$9vz>}Tn;R5eixeIzgHexG%b?r#*GfbSj(!OBnFX!)OFs(VxXE<#M5VO z+_IySF)!K4`!osfnU%G5=+csj;UvYZx`Y$#z#=1aTC=yl(H!>uL?VnpSKhTPHV05L zDrdF?>!+PFcEK7s#xQN}BRF9(;=N|GB`~faG%ks0&VHxwfrCv!9KBV4rq<+XbsBW6 zYp2-|FTliv40;R<+00bwqplH{+`tfXCvnP7At~k|*UIt>*-YvnUkOoLTbobA*^z;%<=XN# zeW_|a?q0ty5d-yov=qiuz4`Nll)OCZ=on@5{I=IM2#$sr*1L$E7VsECe(i@W}kz-73sW8aH_B~JiX zTLrq68a!P7HKx{_T)tO^{Q2T&$GT0k!9yAdMA0zVGVq<`423muC@?Gp+ zMj^020EChGVJe@cz?f$`-!)K~Xj-mczDxT6wY75~eF!>5L^cqw9P@&}pz(>d0 zaCDB6_j?nMNdlZ2?>Vq3wuo#@1lUktQMFu8JPOaaANvRlP2C^?^V)Pp=NCdAo7}pR z|MM3&!XI}*~-bDt`c@d#!E(sB4BrFM77oWrm!#yn} z0%5^{N%R{B9Cq&!L)!Boh+r&6T;0Q1RWQ`142EJW)oKp(Tt$Z2;GBZFpH)@D1I9@0 ze-`^coc(_+#$6<-+~$Z~v6QhkV}+($!?`nObwgRAr1^5OU7%wVm+!grSSq^Q3m6_; zliPIN7D2-Yt4bQ8t%+yKQI`PvZG(5lz*}l0r>Cc3(b2*6^}-ZlzF~j=x`C>DBxPk~ zg#9pmz?ld%t^j;S>(B?afe6$_6jRSiUvG)7V*%#If8+vG$Hm9Ti?WcCl1346!)km| z=F5BU%7m9AVFVR-_U>GsusykGOza z8F@(&bxJWwSzFX6JWotaZBY7Yo-&y^A(3vHF>Noy_&8AtoL{tlb|c1PB|ONK9dcwg zSpbVYjuE$3d9W<*t!iQvZ0tH*Z`evkDVP0GnQ0&Iw%#$cQ%A>&*KdMLUvp0j2(PibV$;QlyhNd!83i`$!Kdoq=-Koa%EN;$jB|vkVC{)7-qXeY z?$~}C=rplpAwlJwu-=WBpVU8|RGa?^hGhY!*B83DOvDG}uM9MJ3^j)SF;AJjPUsV* z^gIh^L-I4Lx(&5Ztkt6|L#DCsXs||mHgz#|)~Km*kB+C_z3t3ZzsFz(_<$o%)=*Mt zo&8%qDk`g`zFQ5N?(CMVXC>MtXU0Je429pv|3waTYVNLm#|&D6M=3%s>?~lGo&Pxz zFMwQ(nCTkKR+3w94oL;q?5VcFXW!JhtO#&8%to;6H^s)Y>)TMbFyL_+g4O@-ilKdA z#4(OZu6mEBv0f!Qw;X1^S^cT=lV$Bkj0+8cX!^A+JarT-e}C&gM{rbz8N&m5;{Jew z?g%awu-~)PHV)Bx`Q2$ha|G2<@gyM^`p9gK z@(+hM7Y+bQ0#9S{i?9MwHCIU=qvq+LQip>HadI-7mLiWY*$EFYCJiVaPF*O43pox1G6oPJSl?PzR>Rp{^NUmliZ`I>2M?`TuwuY366P1 z{xQghcIkPoZ(l)ZkeMkv%(SPmd7#U0c;Gi1;pjcD3mt-hcwQCYabMx12dY zr~0I>4Cj<#L~eY!y^g{QU+I$~fB2zm+y*c|{hpixZ8r!y=s@i_5@0f2lXj+pW{Wnx zG~<87B`{njcE~W<4Iag&2UJ%}xH^$Ycu!9pH9ji+Hh+k DH<0CAK*v(N-YCCVhqG1$LMFiu)ZHVX zms&s4i!TN9Ulr4rd1rSoQ#Q$sB$r~(eJox$8zfhkAcWa)YV{sid>dW&V966@gJ8p> zl?8WgC#UoA5=&p~C-K ze2&HC7-v7zVK(_Msp}>pFMrXWNx^)8dQ->T|L&88?Q_t{8OccWYavEIKkQN(E{9(K z)=`PFV~B${#}>$imR4cX=N+|pi=M_SleKpUzUKZCATIE0mej4`V(&V8FMy`2kA&|M z0|J_SuG~~hp|0Tr&$o#IfYWzTcsPwQVX8SbPcew!ML7Iw`~I%{87&QJl2ox)US@Xo z?&fBxKDYIR%5<%nU>glB_p2j9I^V3m8%v0$y#o_kq7arQ=H4R#l za!{zrZ^8uZH#-DfzM`a`IBtTP$c?+!sDm(s{40;&?M%7Yj>&qWlSGm5*%CE~yGsPV z1?xvZxjEY$Fdi(eqrcbBM4=E}LKbjr3S2b*My5EgY0albO&StAe`NIMPhCnJWM8ZN zS+ll~x#|$b*2xIdc&U-$!Y{t9<6h5lIV=HTA+x?Sw8QxhlyjSYf`MC?k8GOPdf#Mw zIE%U6e!02XFOXr_O-K)6iMK`AXsC5fRdgmx2nhsGu>JZf=UjhUQC-bbM=G4tG6rx9 zHYOcdY|E&85E~J3m}(A98h`!Bf-vVqo&yJCnua`88?8^62H=UlyH>c8*+YJ

xzy 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 H-SGbhhi!+8 diff --git a/internal/archiver/archiver.go b/internal/archiver/archiver.go deleted file mode 100644 index 4c2d795..0000000 --- a/internal/archiver/archiver.go +++ /dev/null @@ -1,99 +0,0 @@ -package archiver - -import ( - "fmt" - "github.com/creekorful/trandoshan/internal/archiver/storage" - "github.com/creekorful/trandoshan/internal/event" - "github.com/creekorful/trandoshan/internal/process" - "github.com/rs/zerolog/log" - "github.com/urfave/cli/v2" - "net/http" - "strings" -) - -// State represent the application state -type State struct { - storage storage.Storage -} - -// Name return the process name -func (state *State) Name() string { - return "archiver" -} - -// CommonFlags return process common flags -func (state *State) CommonFlags() []string { - return []string{process.HubURIFlag} -} - -// CustomFlags return process custom flags -func (state *State) CustomFlags() []cli.Flag { - return []cli.Flag{ - &cli.StringFlag{ - Name: "storage-dir", - Usage: "Path to the storage directory", - Required: true, - }, - } -} - -// Initialize the process -func (state *State) Initialize(provider process.Provider) error { - st, err := storage.NewLocalStorage(provider.GetValue("storage-dir")) - if err != nil { - return err - } - state.storage = st - - return nil -} - -// Subscribers return the process subscribers -func (state *State) Subscribers() []process.SubscriberDef { - return []process.SubscriberDef{ - {Exchange: event.NewIndexExchange, Queue: "archivingQueue", Handler: state.handleNewIndexEvent}, - } -} - -// HTTPHandler returns the HTTP API the process expose -func (state *State) HTTPHandler(provider process.Provider) http.Handler { - return nil -} - -func (state *State) handleNewIndexEvent(subscriber event.Subscriber, msg event.RawMessage) error { - var evt event.NewIndexEvent - if err := subscriber.Read(&msg, &evt); err != nil { - return err - } - - res, err := formatResource(&evt) - if err != nil { - return fmt.Errorf("error while formatting resource: %s", err) - } - - if err := state.storage.Store(evt.URL, evt.Time, res); err != nil { - return fmt.Errorf("error while storing resource: %s", err) - } - - log.Debug().Str("url", evt.URL).Msg("Successfully archived resource") - - return nil -} - -func formatResource(evt *event.NewIndexEvent) ([]byte, error) { - builder := strings.Builder{} - - // First URL - builder.WriteString(fmt.Sprintf("%s\n\n", evt.URL)) - - // Then headers - for key, value := range evt.Headers { - builder.WriteString(fmt.Sprintf("%s: %s\n", key, value)) - } - builder.WriteString("\n") - - // Then body - builder.WriteString(evt.Body) - - return []byte(builder.String()), nil -} diff --git a/internal/archiver/archiver_test.go b/internal/archiver/archiver_test.go deleted file mode 100644 index 19b01e5..0000000 --- a/internal/archiver/archiver_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package archiver - -import ( - "github.com/creekorful/trandoshan/internal/archiver/storage_mock" - "github.com/creekorful/trandoshan/internal/event" - "github.com/creekorful/trandoshan/internal/event_mock" - "github.com/golang/mock/gomock" - "testing" - "time" -) - -func TestHandleNewResourceEvent(t *testing.T) { - mockCtrl := gomock.NewController(t) - defer mockCtrl.Finish() - - subscriberMock := event_mock.NewMockSubscriber(mockCtrl) - storageMock := storage_mock.NewMockStorage(mockCtrl) - - tn := time.Now() - - msg := event.RawMessage{} - subscriberMock.EXPECT(). - Read(&msg, &event.NewIndexEvent{}). - SetArg(1, event.NewIndexEvent{ - URL: "https://example.onion", - Body: "Hello, world", - Headers: map[string]string{"Server": "Traefik", "Content-Type": "application/html"}, - Time: tn, - }).Return(nil) - - storageMock.EXPECT().Store("https://example.onion", tn, []byte("https://example.onion\n\nServer: Traefik\nContent-Type: application/html\n\nHello, world")).Return(nil) - - s := State{storage: storageMock} - if err := s.handleNewIndexEvent(subscriberMock, msg); err != nil { - t.Fail() - } -} - -func TestFormatResource(t *testing.T) { - evt := &event.NewIndexEvent{ - URL: "https://google.com", - Body: "Hello, world", - Headers: map[string]string{"Server": "Traefik", "Content-Type": "text/html"}, - Time: time.Now(), - } - - res, err := formatResource(evt) - if err != nil { - t.FailNow() - } - - if string(res) != "https://google.com\n\nServer: Traefik\nContent-Type: text/html\n\nHello, world" { - t.Fail() - } -} diff --git a/internal/archiver/storage/storage.go b/internal/archiver/storage/storage.go deleted file mode 100644 index d1525a3..0000000 --- a/internal/archiver/storage/storage.go +++ /dev/null @@ -1,11 +0,0 @@ -package storage - -import "time" - -//go:generate mockgen -destination=../storage_mock/storage_mock.go -package=storage_mock . Storage - -// Storage is a abstraction layer where we store resource -type Storage interface { - // Store the resource - Store(url string, time time.Time, body []byte) error -} diff --git a/internal/blacklister/blacklister.go b/internal/blacklister/blacklister.go index d31fdf6..bde6651 100644 --- a/internal/blacklister/blacklister.go +++ b/internal/blacklister/blacklister.go @@ -5,6 +5,7 @@ import ( "github.com/creekorful/trandoshan/internal/cache" configapi "github.com/creekorful/trandoshan/internal/configapi/client" "github.com/creekorful/trandoshan/internal/event" + chttp "github.com/creekorful/trandoshan/internal/http" "github.com/creekorful/trandoshan/internal/process" "github.com/rs/zerolog/log" "github.com/urfave/cli/v2" @@ -12,10 +13,13 @@ import ( "net/url" ) +var errAlreadyBlacklisted = fmt.Errorf("hostname is already blacklisted") + // State represent the application state type State struct { configClient configapi.Client hostnameCache cache.Cache + httpClient chttp.Client } // Name return the process name @@ -25,7 +29,7 @@ func (state *State) Name() string { // CommonFlags return process common flags func (state *State) CommonFlags() []string { - return []string{process.HubURIFlag, process.ConfigAPIURIFlag, process.RedisURIFlag} + return []string{process.HubURIFlag, process.ConfigAPIURIFlag, process.RedisURIFlag, process.UserAgentFlag, process.TorURIFlag} } // CustomFlags return process custom flags @@ -35,17 +39,23 @@ func (state *State) CustomFlags() []cli.Flag { // Initialize the process func (state *State) Initialize(provider process.Provider) error { - hostnameCache, err := provider.Cache() + hostnameCache, err := provider.Cache("down-hostname") if err != nil { return err } state.hostnameCache = hostnameCache - client, err := provider.ConfigClient([]string{configapi.ForbiddenHostnamesKey, configapi.BlackListThresholdKey}) + configClient, err := provider.ConfigClient([]string{configapi.ForbiddenHostnamesKey, configapi.BlackListThresholdKey}) + if err != nil { + return err + } + state.configClient = configClient + + httpClient, err := provider.HTTPClient() if err != nil { return err } - state.configClient = client + state.httpClient = httpClient return nil } @@ -58,7 +68,7 @@ func (state *State) Subscribers() []process.SubscriberDef { } // HTTPHandler returns the HTTP API the process expose -func (state *State) HTTPHandler(provider process.Provider) http.Handler { +func (state *State) HTTPHandler() http.Handler { return nil } @@ -73,12 +83,41 @@ func (state *State) handleTimeoutURLEvent(subscriber event.Subscriber, msg event return err } + // Make sure hostname is not already 'blacklisted' + forbiddenHostnames, err := state.configClient.GetForbiddenHostnames() + if err != nil { + return err + } + + // prevent duplicates + found := false + for _, hostname := range forbiddenHostnames { + if hostname.Hostname == u.Hostname() { + found = true + break + } + } + + if found { + return fmt.Errorf("%s %w", u.Hostname(), errAlreadyBlacklisted) + } + + // Check by ourselves if the hostname doesn't respond + _, err = state.httpClient.Get(fmt.Sprintf("%s://%s", u.Scheme, u.Host)) + if err == nil || err != chttp.ErrTimeout { + return nil + } + + log.Debug(). + Str("hostname", u.Hostname()). + Msg("Timeout confirmed") + threshold, err := state.configClient.GetBlackListThreshold() if err != nil { return err } - cacheKey := fmt.Sprintf("hostnames:%s", u.Hostname()) + cacheKey := u.Hostname() count, err := state.hostnameCache.GetInt64(cacheKey) if err != nil && err != cache.ErrNIL { return err @@ -86,11 +125,6 @@ func (state *State) handleTimeoutURLEvent(subscriber event.Subscriber, msg event count++ if count >= threshold.Threshold { - log.Info(). - Str("hostname", u.Hostname()). - Int64("count", count). - Msg("Blacklisting hostname") - forbiddenHostnames, err := state.configClient.GetForbiddenHostnames() if err != nil { return err @@ -106,8 +140,13 @@ func (state *State) handleTimeoutURLEvent(subscriber event.Subscriber, msg event } if found { - log.Trace().Str("hostname", u.Hostname()).Msg("skipping duplicate hostname") + log.Trace().Str("hostname", u.Hostname()).Msg("Skipping duplicate hostname") } else { + log.Info(). + Str("hostname", u.Hostname()). + Int64("count", count). + Msg("Blacklisting hostname") + forbiddenHostnames = append(forbiddenHostnames, configapi.ForbiddenHostname{Hostname: u.Hostname()}) if err := state.configClient.Set(configapi.ForbiddenHostnamesKey, forbiddenHostnames); err != nil { return err diff --git a/internal/blacklister/blacklister_test.go b/internal/blacklister/blacklister_test.go index 8132d63..fe3a78f 100644 --- a/internal/blacklister/blacklister_test.go +++ b/internal/blacklister/blacklister_test.go @@ -1,16 +1,80 @@ package blacklister import ( + "errors" "github.com/creekorful/trandoshan/internal/cache" "github.com/creekorful/trandoshan/internal/cache_mock" configapi "github.com/creekorful/trandoshan/internal/configapi/client" "github.com/creekorful/trandoshan/internal/configapi/client_mock" "github.com/creekorful/trandoshan/internal/event" "github.com/creekorful/trandoshan/internal/event_mock" + "github.com/creekorful/trandoshan/internal/http" + "github.com/creekorful/trandoshan/internal/http_mock" + "github.com/creekorful/trandoshan/internal/process" + "github.com/creekorful/trandoshan/internal/process_mock" + "github.com/creekorful/trandoshan/internal/test" "github.com/golang/mock/gomock" "testing" ) +func TestState_Name(t *testing.T) { + s := State{} + if s.Name() != "blacklister" { + t.Fail() + } +} + +func TestState_CommonFlags(t *testing.T) { + s := State{} + test.CheckProcessCommonFlags(t, &s, []string{process.HubURIFlag, process.ConfigAPIURIFlag, process.RedisURIFlag, process.UserAgentFlag, process.TorURIFlag}) +} + +func TestState_CustomFlags(t *testing.T) { + s := State{} + test.CheckProcessCustomFlags(t, &s, nil) +} + +func TestState_Initialize(t *testing.T) { + test.CheckInitialize(t, &State{}, func(p *process_mock.MockProviderMockRecorder) { + p.Cache("down-hostname") + p.ConfigClient([]string{configapi.ForbiddenHostnamesKey, configapi.BlackListThresholdKey}) + p.HTTPClient() + }) +} + +func TestState_Subscribers(t *testing.T) { + s := State{} + test.CheckProcessSubscribers(t, &s, []test.SubscriberDef{ + {Queue: "blacklistingQueue", Exchange: "url.timeout"}, + }) +} + +func TestHandleTimeoutURLEventNoTimeout(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + subscriberMock := event_mock.NewMockSubscriber(mockCtrl) + configClientMock := client_mock.NewMockClient(mockCtrl) + hostnameCacheMock := cache_mock.NewMockCache(mockCtrl) + httpClientMock := http_mock.NewMockClient(mockCtrl) + httpResponseMock := http_mock.NewMockResponse(mockCtrl) + + msg := event.RawMessage{} + subscriberMock.EXPECT(). + Read(&msg, &event.TimeoutURLEvent{}). + SetArg(1, event.TimeoutURLEvent{ + URL: "https://down-example.onion:8080/reset-password?username=test", + }).Return(nil) + + httpClientMock.EXPECT().Get("https://down-example.onion:8080").Return(httpResponseMock, nil) + configClientMock.EXPECT().GetForbiddenHostnames().Return([]configapi.ForbiddenHostname{}, nil) + + s := State{configClient: configClientMock, hostnameCache: hostnameCacheMock, httpClient: httpClientMock} + if err := s.handleTimeoutURLEvent(subscriberMock, msg); err != nil { + t.Fail() + } +} + func TestHandleTimeoutURLEventNoDispatch(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() @@ -18,20 +82,24 @@ func TestHandleTimeoutURLEventNoDispatch(t *testing.T) { subscriberMock := event_mock.NewMockSubscriber(mockCtrl) configClientMock := client_mock.NewMockClient(mockCtrl) hostnameCacheMock := cache_mock.NewMockCache(mockCtrl) + httpClientMock := http_mock.NewMockClient(mockCtrl) + httpResponseMock := http_mock.NewMockResponse(mockCtrl) msg := event.RawMessage{} subscriberMock.EXPECT(). Read(&msg, &event.TimeoutURLEvent{}). SetArg(1, event.TimeoutURLEvent{ - URL: "https://down-example.onion", + URL: "https://down-example.onion/login.php", }).Return(nil) + httpClientMock.EXPECT().Get("https://down-example.onion").Return(httpResponseMock, http.ErrTimeout) + configClientMock.EXPECT().GetForbiddenHostnames().Return([]configapi.ForbiddenHostname{}, nil) configClientMock.EXPECT().GetBlackListThreshold().Return(configapi.BlackListThreshold{Threshold: 10}, nil) - hostnameCacheMock.EXPECT().GetInt64("hostnames:down-example.onion").Return(int64(0), cache.ErrNIL) - hostnameCacheMock.EXPECT().SetInt64("hostnames:down-example.onion", int64(1), cache.NoTTL).Return(nil) + hostnameCacheMock.EXPECT().GetInt64("down-example.onion").Return(int64(0), cache.ErrNIL) + hostnameCacheMock.EXPECT().SetInt64("down-example.onion", int64(1), cache.NoTTL).Return(nil) - s := State{configClient: configClientMock, hostnameCache: hostnameCacheMock} + s := State{configClient: configClientMock, hostnameCache: hostnameCacheMock, httpClient: httpClientMock} if err := s.handleTimeoutURLEvent(subscriberMock, msg); err != nil { t.Fail() } @@ -44,17 +112,21 @@ func TestHandleTimeoutURLEvent(t *testing.T) { subscriberMock := event_mock.NewMockSubscriber(mockCtrl) configClientMock := client_mock.NewMockClient(mockCtrl) hostnameCacheMock := cache_mock.NewMockCache(mockCtrl) + httpClientMock := http_mock.NewMockClient(mockCtrl) + httpResponseMock := http_mock.NewMockResponse(mockCtrl) msg := event.RawMessage{} subscriberMock.EXPECT(). Read(&msg, &event.TimeoutURLEvent{}). SetArg(1, event.TimeoutURLEvent{ - URL: "https://down-example.onion", + URL: "https://down-example.onion/test.html", }).Return(nil) + httpClientMock.EXPECT().Get("https://down-example.onion").Return(httpResponseMock, http.ErrTimeout) + configClientMock.EXPECT().GetForbiddenHostnames().Return([]configapi.ForbiddenHostname{}, nil) configClientMock.EXPECT().GetBlackListThreshold().Return(configapi.BlackListThreshold{Threshold: 10}, nil) - hostnameCacheMock.EXPECT().GetInt64("hostnames:down-example.onion").Return(int64(9), nil) + hostnameCacheMock.EXPECT().GetInt64("down-example.onion").Return(int64(9), nil) configClientMock.EXPECT(). GetForbiddenHostnames(). @@ -66,9 +138,9 @@ func TestHandleTimeoutURLEvent(t *testing.T) { }). Return(nil) - hostnameCacheMock.EXPECT().SetInt64("hostnames:down-example.onion", int64(10), cache.NoTTL).Return(nil) + hostnameCacheMock.EXPECT().SetInt64("down-example.onion", int64(10), cache.NoTTL).Return(nil) - s := State{configClient: configClientMock, hostnameCache: hostnameCacheMock} + s := State{configClient: configClientMock, hostnameCache: hostnameCacheMock, httpClient: httpClientMock} if err := s.handleTimeoutURLEvent(subscriberMock, msg); err != nil { t.Fail() } @@ -86,23 +158,13 @@ func TestHandleTimeoutURLEventNoDuplicates(t *testing.T) { subscriberMock.EXPECT(). Read(&msg, &event.TimeoutURLEvent{}). SetArg(1, event.TimeoutURLEvent{ - URL: "https://facebookcorewwwi.onion", + URL: "https://facebookcorewwwi.onion/morning-routine.php?id=12", }).Return(nil) - configClientMock.EXPECT().GetBlackListThreshold().Return(configapi.BlackListThreshold{Threshold: 3}, nil) - - hostnameCacheMock.EXPECT().GetInt64("hostnames:facebookcorewwwi.onion").Return(int64(2), nil) - - configClientMock.EXPECT(). - GetForbiddenHostnames(). - Return([]configapi.ForbiddenHostname{{Hostname: "facebookcorewwwi.onion"}}, nil) - // Config not updated since hostname is already 'blacklisted' - // this may due because of change in threshold - - hostnameCacheMock.EXPECT().SetInt64("hostnames:facebookcorewwwi.onion", int64(3), cache.NoTTL).Return(nil) + configClientMock.EXPECT().GetForbiddenHostnames().Return([]configapi.ForbiddenHostname{{Hostname: "facebookcorewwwi.onion"}}, nil) s := State{configClient: configClientMock, hostnameCache: hostnameCacheMock} - if err := s.handleTimeoutURLEvent(subscriberMock, msg); err != nil { + if err := s.handleTimeoutURLEvent(subscriberMock, msg); !errors.Is(err, errAlreadyBlacklisted) { t.Fail() } } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 3a5b676..d2a975d 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -1,5 +1,7 @@ package cache +//go:generate mockgen -destination=../cache_mock/cache_mock.go -package=cache_mock . Cache + import ( "errors" "time" @@ -12,8 +14,6 @@ var ( ErrNIL = errors.New("value is nil") ) -//go:generate mockgen -destination=../cache_mock/cache_mock.go -package=cache_mock . Cache - // Cache represent a KV database type Cache interface { GetBytes(key string) ([]byte, error) diff --git a/internal/cache/redis.go b/internal/cache/redis.go index 07bc9fe..a1252e5 100644 --- a/internal/cache/redis.go +++ b/internal/cache/redis.go @@ -2,26 +2,29 @@ package cache import ( "context" + "fmt" "github.com/go-redis/redis/v8" "time" ) type redisCache struct { - client *redis.Client + client *redis.Client + keyPrefix string } // NewRedisCache return a new Cache using redis as backend -func NewRedisCache(URI string) (Cache, error) { +func NewRedisCache(URI string, keyPrefix string) (Cache, error) { return &redisCache{ client: redis.NewClient(&redis.Options{ Addr: URI, DB: 0, }), + keyPrefix: keyPrefix, }, nil } func (rc *redisCache) GetBytes(key string) ([]byte, error) { - val, err := rc.client.Get(context.Background(), key).Bytes() + val, err := rc.client.Get(context.Background(), rc.getKey(key)).Bytes() if err == redis.Nil { err = ErrNIL } @@ -30,11 +33,11 @@ func (rc *redisCache) GetBytes(key string) ([]byte, error) { } func (rc *redisCache) SetBytes(key string, value []byte, TTL time.Duration) error { - return rc.client.Set(context.Background(), key, value, TTL).Err() + return rc.client.Set(context.Background(), rc.getKey(key), value, TTL).Err() } func (rc *redisCache) GetInt64(key string) (int64, error) { - val, err := rc.client.Get(context.Background(), key).Int64() + val, err := rc.client.Get(context.Background(), rc.getKey(key)).Int64() if err == redis.Nil { err = ErrNIL } @@ -43,5 +46,13 @@ func (rc *redisCache) GetInt64(key string) (int64, error) { } func (rc *redisCache) SetInt64(key string, value int64, TTL time.Duration) error { - return rc.client.Set(context.Background(), key, value, TTL).Err() + return rc.client.Set(context.Background(), rc.getKey(key), value, TTL).Err() +} + +func (rc *redisCache) getKey(key string) string { + if rc.keyPrefix == "" { + return key + } + + return fmt.Sprintf("%s:%s", rc.keyPrefix, key) } diff --git a/internal/cache/redis_test.go b/internal/cache/redis_test.go new file mode 100644 index 0000000..bdd8a52 --- /dev/null +++ b/internal/cache/redis_test.go @@ -0,0 +1,15 @@ +package cache + +import "testing" + +func TestRedisCache_GetKey(t *testing.T) { + rc := redisCache{} + if got := rc.getKey("user"); got != "user" { + t.Errorf("got %s want %s", got, "user") + } + + rc.keyPrefix = "config" + if got := rc.getKey("user"); got != "config:user" { + t.Errorf("got %s want %s", got, "config:user") + } +} diff --git a/internal/configapi/client/client.go b/internal/configapi/client/client.go index 8889824..268dc9a 100644 --- a/internal/configapi/client/client.go +++ b/internal/configapi/client/client.go @@ -1,5 +1,7 @@ package client +//go:generate mockgen -destination=../client_mock/client_mock.go -package=client_mock . Client + import ( "bytes" "encoding/json" @@ -12,8 +14,6 @@ import ( "time" ) -//go:generate mockgen -destination=../client_mock/client_mock.go -package=client_mock . Client - const ( // AllowedMimeTypesKey is the key to access the allowed mime types config AllowedMimeTypesKey = "allowed-mime-types" diff --git a/internal/configapi/configapi.go b/internal/configapi/configapi.go index d382f4c..7bdbee8 100644 --- a/internal/configapi/configapi.go +++ b/internal/configapi/configapi.go @@ -41,7 +41,7 @@ func (state *State) CustomFlags() []cli.Flag { // Initialize the process func (state *State) Initialize(provider process.Provider) error { - configCache, err := provider.Cache() + configCache, err := provider.Cache("configuration") if err != nil { return err } @@ -76,7 +76,7 @@ func (state *State) Subscribers() []process.SubscriberDef { } // HTTPHandler returns the HTTP API the process expose -func (state *State) HTTPHandler(provider process.Provider) http.Handler { +func (state *State) HTTPHandler() http.Handler { r := mux.NewRouter() r.HandleFunc("/config/{key}", state.getConfiguration).Methods(http.MethodGet) r.HandleFunc("/config/{key}", state.setConfiguration).Methods(http.MethodPut) @@ -90,7 +90,7 @@ func (state *State) getConfiguration(w http.ResponseWriter, r *http.Request) { log.Debug().Str("key", key).Msg("Getting key") - b, err := state.configCache.GetBytes("conf:" + key) + b, err := state.configCache.GetBytes(key) if err != nil { log.Err(err).Msg("error while retrieving configuration") w.WriteHeader(http.StatusInternalServerError) @@ -114,7 +114,7 @@ func (state *State) setConfiguration(w http.ResponseWriter, r *http.Request) { log.Debug().Str("key", key).Bytes("value", b).Msg("Setting key") - if err := state.configCache.SetBytes("conf:"+key, b, cache.NoTTL); err != nil { + if err := state.configCache.SetBytes(key, b, cache.NoTTL); err != nil { log.Err(err).Msg("error while setting configuration") w.WriteHeader(http.StatusInternalServerError) return @@ -135,8 +135,8 @@ func (state *State) setConfiguration(w http.ResponseWriter, r *http.Request) { func setDefaultValues(configCache cache.Cache, values map[string]string) error { for key, value := range values { - if _, err := configCache.GetBytes("conf:" + key); err == cache.ErrNIL { - if err := configCache.SetBytes("conf:"+key, []byte(value), cache.NoTTL); err != nil { + if _, err := configCache.GetBytes(key); err == cache.ErrNIL { + if err := configCache.SetBytes(key, []byte(value), cache.NoTTL); err != nil { return fmt.Errorf("error while setting default value of %s: %s", key, err) } } diff --git a/internal/configapi/configapi_test.go b/internal/configapi/configapi_test.go index 7986741..71d0405 100644 --- a/internal/configapi/configapi_test.go +++ b/internal/configapi/configapi_test.go @@ -5,6 +5,9 @@ import ( "github.com/creekorful/trandoshan/internal/cache_mock" "github.com/creekorful/trandoshan/internal/event" "github.com/creekorful/trandoshan/internal/event_mock" + "github.com/creekorful/trandoshan/internal/process" + "github.com/creekorful/trandoshan/internal/process_mock" + "github.com/creekorful/trandoshan/internal/test" "github.com/golang/mock/gomock" "github.com/gorilla/mux" "io/ioutil" @@ -14,12 +17,42 @@ import ( "testing" ) +func TestState_Name(t *testing.T) { + s := State{} + if s.Name() != "configapi" { + t.Fail() + } +} + +func TestState_CommonFlags(t *testing.T) { + s := State{} + test.CheckProcessCommonFlags(t, &s, []string{process.HubURIFlag, process.RedisURIFlag}) +} + +func TestState_CustomFlags(t *testing.T) { + s := State{} + test.CheckProcessCustomFlags(t, &s, []string{"default-value"}) +} + +func TestState_Initialize(t *testing.T) { + test.CheckInitialize(t, &State{}, func(p *process_mock.MockProviderMockRecorder) { + p.Cache("configuration") + p.Publisher() + p.GetValues("default-value") + }) +} + +func TestState_Subscribers(t *testing.T) { + s := State{} + test.CheckProcessSubscribers(t, &s, nil) +} + func TestGetConfiguration(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() configCacheMock := cache_mock.NewMockCache(mockCtrl) - configCacheMock.EXPECT().GetBytes("conf:hello").Return([]byte("{\"ttl\": \"10s\"}"), nil) + configCacheMock.EXPECT().GetBytes("hello").Return([]byte("{\"ttl\": \"10s\"}"), nil) req := httptest.NewRequest(http.MethodGet, "/config/hello", nil) req = mux.SetURLVars(req, map[string]string{"key": "hello"}) @@ -53,7 +86,7 @@ func TestSetConfiguration(t *testing.T) { configCacheMock := cache_mock.NewMockCache(mockCtrl) pubMock := event_mock.NewMockPublisher(mockCtrl) - configCacheMock.EXPECT().SetBytes("conf:hello", []byte("{\"ttl\": \"10s\"}"), cache.NoTTL).Return(nil) + configCacheMock.EXPECT().SetBytes("hello", []byte("{\"ttl\": \"10s\"}"), cache.NoTTL).Return(nil) pubMock.EXPECT().PublishJSON("config", event.RawMessage{ Body: []byte("{\"ttl\": \"10s\"}"), Headers: map[string]interface{}{"Config-Key": "hello"}, diff --git a/internal/constraint/hostname.go b/internal/constraint/hostname.go index 0e3ee8d..dacf4af 100644 --- a/internal/constraint/hostname.go +++ b/internal/constraint/hostname.go @@ -6,7 +6,7 @@ import ( "strings" ) -// CheckHostnameAllowed check if given URL hostname is allowed for crawling +// CheckHostnameAllowed check if given URL hostname is allowed func CheckHostnameAllowed(configClient configapi.Client, rawurl string) (bool, error) { u, err := url.Parse(rawurl) if err != nil { diff --git a/internal/crawler/crawler.go b/internal/crawler/crawler.go index 2514dc9..161a52f 100644 --- a/internal/crawler/crawler.go +++ b/internal/crawler/crawler.go @@ -1,22 +1,18 @@ package crawler import ( - "crypto/tls" "fmt" "github.com/creekorful/trandoshan/internal/clock" configapi "github.com/creekorful/trandoshan/internal/configapi/client" "github.com/creekorful/trandoshan/internal/constraint" - chttp "github.com/creekorful/trandoshan/internal/crawler/http" "github.com/creekorful/trandoshan/internal/event" + chttp "github.com/creekorful/trandoshan/internal/http" "github.com/creekorful/trandoshan/internal/process" "github.com/rs/zerolog/log" "github.com/urfave/cli/v2" - "github.com/valyala/fasthttp" - "github.com/valyala/fasthttp/fasthttpproxy" "io/ioutil" "net/http" "strings" - "time" ) var ( @@ -38,36 +34,21 @@ func (state *State) Name() string { // CommonFlags return process common flags func (state *State) CommonFlags() []string { - return []string{process.HubURIFlag, process.ConfigAPIURIFlag} + return []string{process.HubURIFlag, process.ConfigAPIURIFlag, process.UserAgentFlag, process.TorURIFlag} } // CustomFlags return process custom flags func (state *State) CustomFlags() []cli.Flag { - return []cli.Flag{ - &cli.StringFlag{ - Name: "tor-uri", - Usage: "URI to the TOR SOCKS proxy", - Required: true, - }, - &cli.StringFlag{ - Name: "user-agent", - Usage: "User agent to use", - Value: "Mozilla/5.0 (Windows NT 10.0; rv:68.0) Gecko/20100101 Firefox/68.0", - }, - } + return []cli.Flag{} } // Initialize the process func (state *State) Initialize(provider process.Provider) error { - state.httpClient = chttp.NewFastHTTPClient(&fasthttp.Client{ - // Use given TOR proxy to reach the hidden services - Dial: fasthttpproxy.FasthttpSocksDialer(provider.GetValue("tor-uri")), - // Disable SSL verification since we do not really care about this - TLSConfig: &tls.Config{InsecureSkipVerify: true}, - ReadTimeout: time.Second * 5, - WriteTimeout: time.Second * 5, - Name: provider.GetValue("user-agent"), - }) + httpClient, err := provider.HTTPClient() + if err != nil { + return err + } + state.httpClient = httpClient cl, err := provider.Clock() if err != nil { @@ -92,7 +73,7 @@ func (state *State) Subscribers() []process.SubscriberDef { } // HTTPHandler returns the HTTP API the process expose -func (state *State) HTTPHandler(provider process.Provider) http.Handler { +func (state *State) HTTPHandler() http.Handler { return nil } @@ -108,7 +89,7 @@ func (state *State) handleNewURLEvent(subscriber event.Subscriber, msg event.Raw return err } else if !allowed { log.Debug().Str("url", evt.URL).Msg("Skipping forbidden hostname") - return errHostnameNotAllowed + return fmt.Errorf("%s %w", evt.URL, errHostnameNotAllowed) } r, err := state.httpClient.Get(evt.URL) diff --git a/internal/crawler/crawler_test.go b/internal/crawler/crawler_test.go index 485f140..eeb895f 100644 --- a/internal/crawler/crawler_test.go +++ b/internal/crawler/crawler_test.go @@ -5,16 +5,51 @@ import ( "github.com/creekorful/trandoshan/internal/clock_mock" "github.com/creekorful/trandoshan/internal/configapi/client" "github.com/creekorful/trandoshan/internal/configapi/client_mock" - "github.com/creekorful/trandoshan/internal/crawler/http" - "github.com/creekorful/trandoshan/internal/crawler/http_mock" "github.com/creekorful/trandoshan/internal/event" "github.com/creekorful/trandoshan/internal/event_mock" + "github.com/creekorful/trandoshan/internal/http" + "github.com/creekorful/trandoshan/internal/http_mock" + "github.com/creekorful/trandoshan/internal/process" + "github.com/creekorful/trandoshan/internal/process_mock" + "github.com/creekorful/trandoshan/internal/test" "github.com/golang/mock/gomock" "strings" "testing" "time" ) +func TestState_Name(t *testing.T) { + s := State{} + if s.Name() != "crawler" { + t.Fail() + } +} + +func TestState_CommonFlags(t *testing.T) { + s := State{} + test.CheckProcessCommonFlags(t, &s, []string{process.HubURIFlag, process.ConfigAPIURIFlag, process.UserAgentFlag, process.TorURIFlag}) +} + +func TestState_CustomFlags(t *testing.T) { + s := State{} + test.CheckProcessCustomFlags(t, &s, nil) +} + +func TestState_Initialize(t *testing.T) { + test.CheckInitialize(t, &State{}, func(p *process_mock.MockProviderMockRecorder) { + p.HTTPClient() + p.Clock() + p.ConfigClient([]string{client.AllowedMimeTypesKey, client.ForbiddenHostnamesKey}) + }) +} + +func TestState_Subscribers(t *testing.T) { + s := State{} + test.CheckProcessSubscribers(t, &s, []test.SubscriberDef{ + {Queue: "crawlingQueue", Exchange: "url.new"}, + }) +} + func TestHandleNewURLEvent(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() @@ -167,7 +202,7 @@ func TestHandleNewURLEventHostnameForbidden(t *testing.T) { configClientMock.EXPECT().GetForbiddenHostnames(). Return([]client.ForbiddenHostname{{Hostname: "facebookcorewwwi.onion"}}, nil) - if err := s.handleNewURLEvent(subscriberMock, msg); err != errHostnameNotAllowed { + if err := s.handleNewURLEvent(subscriberMock, msg); !errors.Is(err, errHostnameNotAllowed) { t.Fail() } } diff --git a/internal/event/event.go b/internal/event/event.go index 0f40fec..5309860 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -1,20 +1,16 @@ package event -import "time" - //go:generate mockgen -destination=../event_mock/event_mock.go -package=event_mock . Publisher,Subscriber +import "time" + const ( // NewURLExchange is the exchange used when an URL is schedule for crawling NewURLExchange = "url.new" - // FoundURLExchange is the exchange used when an URL is extracted from resource - FoundURLExchange = "url.found" // TimeoutURLExchange is the exchange used when a crawling fail because of timeout TimeoutURLExchange = "url.timeout" // NewResourceExchange is the exchange used when a new resource has been crawled NewResourceExchange = "resource.new" - // NewIndexExchange is the exchange used when a resource has been indexed - NewIndexExchange = "index.new" // ConfigExchange is the exchange used to dispatch new configuration ConfigExchange = "config" ) @@ -35,16 +31,6 @@ func (msg *NewURLEvent) Exchange() string { return NewURLExchange } -// FoundURLEvent represent a found URL -type FoundURLEvent struct { - URL string `json:"url"` -} - -// Exchange returns the exchange where event should be push -func (msg *FoundURLEvent) Exchange() string { - return FoundURLExchange -} - // TimeoutURLEvent represent a failed crawling because of timeout type TimeoutURLEvent struct { URL string `json:"url"` @@ -67,19 +53,3 @@ type NewResourceEvent struct { func (msg *NewResourceEvent) Exchange() string { return NewResourceExchange } - -// NewIndexEvent represent a indexed resource -type NewIndexEvent struct { - URL string `json:"url"` - Body string `json:"body"` - Time time.Time `json:"time"` - Title string `json:"title"` - Meta map[string]string `json:"meta"` - Description string `json:"description"` - Headers map[string]string `json:"headers"` -} - -// Exchange returns the exchange where event should be push -func (msg *NewIndexEvent) Exchange() string { - return NewIndexExchange -} diff --git a/internal/crawler/http/client.go b/internal/http/client.go similarity index 100% rename from internal/crawler/http/client.go rename to internal/http/client.go diff --git a/internal/crawler/http/response.go b/internal/http/response.go similarity index 100% rename from internal/crawler/http/response.go rename to internal/http/response.go diff --git a/internal/indexer/auth/auth.go b/internal/indexer/auth/auth.go deleted file mode 100644 index 4d3ff0f..0000000 --- a/internal/indexer/auth/auth.go +++ /dev/null @@ -1,124 +0,0 @@ -package auth - -import ( - "context" - "fmt" - "github.com/dgrijalva/jwt-go" - "github.com/gorilla/mux" - "github.com/rs/zerolog/log" - "net/http" - "strings" -) - -type key int - -const ( - usernameKey key = iota -) - -// Token is the authentication token used by processes when dialing with the API -type Token struct { - // Username used for logging purposes - Username string `json:"username"` - - // Rights that the token provides - // Format is: METHOD - list of paths - Rights map[string][]string `json:"rights"` -} - -// Middleware is the authentication middleware -type Middleware struct { - signingKey []byte -} - -// NewMiddleware create a new Middleware instance with given secret token signing key -func NewMiddleware(signingKey []byte) *Middleware { - return &Middleware{signingKey: signingKey} -} - -// Middleware return an net/http compatible middleware func to use -func (m *Middleware) Middleware() mux.MiddlewareFunc { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Extract authorization header - tokenStr := r.Header.Get("Authorization") - if tokenStr == "" { - log.Warn().Msg("missing token") - w.WriteHeader(http.StatusUnauthorized) - return - } - - tokenStr = strings.TrimPrefix(tokenStr, "Bearer ") - - // Decode the JWT token - token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { - // Validate expected alg - if v, ok := t.Method.(*jwt.SigningMethodHMAC); !ok || v.Name != "HS256" { - return nil, fmt.Errorf("unexpected signing method: %s", t.Header["alg"]) - } - - // Return signing secret - return m.signingKey, nil - }) - if err != nil { - log.Err(err).Msg("error while decoding JWT token") - w.WriteHeader(http.StatusUnauthorized) - return - } - - // From here we have a valid JWT token, extract claims - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - log.Err(err).Msg("error while decoding token claims") - w.WriteHeader(http.StatusInternalServerError) - return - } - - rights := map[string][]string{} - for method, paths := range claims["rights"].(map[string]interface{}) { - for _, path := range paths.([]interface{}) { - rights[method] = append(rights[method], path.(string)) - } - } - - t := Token{ - Username: claims["username"].(string), - Rights: rights, - } - - // Validate rights - paths, contains := t.Rights[r.Method] - if !contains { - log.Warn(). - Str("username", t.Username). - Str("method", r.Method). - Str("resource", r.URL.Path). - Msg("Access to resources is unauthorized") - w.WriteHeader(http.StatusUnauthorized) - return - } - - authorized := false - for _, path := range paths { - if path == r.URL.Path { - authorized = true - break - } - } - - if !authorized { - log.Warn(). - Str("username", t.Username). - Str("method", r.Method). - Str("resource", r.URL.Path). - Msg("Access to resources is unauthorized") - w.WriteHeader(http.StatusUnauthorized) - return - } - - // Everything's fine, call next handler ;D - ctx := context.WithValue(r.Context(), usernameKey, t.Username) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} diff --git a/internal/indexer/auth/auth_test.go b/internal/indexer/auth/auth_test.go deleted file mode 100644 index cd87694..0000000 --- a/internal/indexer/auth/auth_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package auth - -import ( - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" -) - -func TestMiddleware_NoTokenShouldReturnUnauthorized(t *testing.T) { - m := (&Middleware{signingKey: []byte("test")}).Middleware()(okHandler()) - - // no token shouldn't be able to access - req := httptest.NewRequest(http.MethodGet, "/users", nil) - rec := httptest.NewRecorder() - - m.ServeHTTP(rec, req) - - if rec.Code != http.StatusUnauthorized { - t.Errorf("StatusUnauthorized was expected") - } -} - -func TestMiddleware_InvalidTokenShouldReturnUnauthorized(t *testing.T) { - m := (&Middleware{signingKey: []byte("test")}).Middleware()(okHandler()) - - req := httptest.NewRequest(http.MethodGet, "/users", nil) - req.Header.Add("Authorization", "zarBR") - rec := httptest.NewRecorder() - - m.ServeHTTP(rec, req) - - if rec.Code != http.StatusUnauthorized { - t.Errorf("StatusUnauthorized was expected") - } -} - -func TestMiddleware_BadRightsShouldReturnUnauthorized(t *testing.T) { - m := (&Middleware{signingKey: []byte("test")}).Middleware()(okHandler()) - - req := httptest.NewRequest(http.MethodPost, "/users", nil) - req.Header.Add("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkpvaG4gRG9lIiwicmlnaHRzIjp7IkdFVCI6WyIvdXNlcnMiXSwiUE9TVCI6WyIvc2VhcmNoIl19fQ.fRx0Q66ZgnY_rKCf-9Vaz6gzGKH_tKSgkVHhoQMtKfM") - rec := httptest.NewRecorder() - - m.ServeHTTP(rec, req) - - if rec.Code != http.StatusUnauthorized { - t.Errorf("StatusUnauthorized was expected") - } -} - -func TestMiddleware(t *testing.T) { - m := (&Middleware{signingKey: []byte("test")}).Middleware()(okHandler()) - - req := httptest.NewRequest(http.MethodGet, "/users?id=10", nil) - req.Header.Add("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkpvaG4gRG9lIiwicmlnaHRzIjp7IkdFVCI6WyIvdXNlcnMiXSwiUE9TVCI6WyIvc2VhcmNoIl19fQ.fRx0Q66ZgnY_rKCf-9Vaz6gzGKH_tKSgkVHhoQMtKfM") - rec := httptest.NewRecorder() - - m.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("StatusUnauthorized was expected") - } - - b, err := ioutil.ReadAll(rec.Body) - if err != nil { - t.Fail() - } - if string(b) != "Hello, John Doe" { - t.Fail() - } -} - -func okHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if username := r.Context().Value(usernameKey).(string); username != "" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(fmt.Sprintf("Hello, %s", username))) - return - } - - w.WriteHeader(http.StatusNoContent) - } -} diff --git a/internal/indexer/client/client.go b/internal/indexer/client/client.go deleted file mode 100644 index f5e2b49..0000000 --- a/internal/indexer/client/client.go +++ /dev/null @@ -1,141 +0,0 @@ -package client - -import ( - "encoding/base64" - "fmt" - "github.com/go-resty/resty/v2" - "strconv" - "time" -) - -//go:generate mockgen -destination=../client_mock/client_mock.go -package=client_mock . Client - -const ( - // PaginationPageHeader is the header to determinate current page in paginated endpoint - PaginationPageHeader = "X-Pagination-Page" - // PaginationSizeHeader is the header to determinate page size in paginated endpoint - PaginationSizeHeader = "X-Pagination-Size" - // PaginationCountHeader is the header to determinate total count of element in paginated endpoint - PaginationCountHeader = "X-Pagination-Count" - // PaginationPageQueryParam is the query parameter used to set current page in paginated endpoint - PaginationPageQueryParam = "pagination-page" - // PaginationSizeQueryParam is the query parameter used to set page size in paginated endpoint - PaginationSizeQueryParam = "pagination-size" -) - -// ResourceDto represent a resource as given by the API -type ResourceDto struct { - URL string `json:"url"` - Body string `json:"body"` - Time time.Time `json:"time"` - Title string `json:"title"` - Meta map[string]string `json:"meta"` - Description string `json:"description"` - Headers map[string]string `json:"headers"` -} - -// CredentialsDto represent the credential when logging in the API -type CredentialsDto struct { - Username string `json:"username"` - Password string `json:"password"` -} - -// ResSearchParams is the search params used -type ResSearchParams struct { - URL string - Keyword string - StartDate time.Time - EndDate time.Time - WithBody bool - PageSize int - PageNumber int - // TODO allow searching by meta - // TODO allow searching by headers -} - -// Client is the interface to interact with the indexer API -type Client interface { - SearchResources(params *ResSearchParams) ([]ResourceDto, int64, error) - ScheduleURL(url string) error -} - -type client struct { - httpClient *resty.Client - baseURL string -} - -func (c *client) SearchResources(params *ResSearchParams) ([]ResourceDto, int64, error) { - targetEndpoint := fmt.Sprintf("%s/v1/resources?", c.baseURL) - - req := c.httpClient.R() - - if params.URL != "" { - b64URL := base64.URLEncoding.EncodeToString([]byte(params.URL)) - req.SetQueryParam("url", b64URL) - } - - if params.Keyword != "" { - req.SetQueryParam("keyword", params.Keyword) - } - - if !params.StartDate.IsZero() { - req.SetQueryParam("start-date", params.StartDate.Format(time.RFC3339)) - } - - if !params.EndDate.IsZero() { - req.SetQueryParam("end-date", params.EndDate.Format(time.RFC3339)) - } - - if params.PageNumber != 0 { - req.Header.Set(PaginationPageHeader, strconv.Itoa(params.PageNumber)) - } - if params.PageSize != 0 { - req.Header.Set(PaginationSizeHeader, strconv.Itoa(params.PageSize)) - } - - var resources []ResourceDto - req.SetResult(&resources) - - res, err := req.Get(targetEndpoint) - if err != nil { - return nil, 0, err - } - - count, err := strconv.ParseInt(res.Header().Get(PaginationCountHeader), 10, 64) - if err != nil { - return nil, 0, err - } - - return resources, count, nil -} - -func (c *client) ScheduleURL(url string) error { - targetEndpoint := fmt.Sprintf("%s/v1/urls", c.baseURL) - - req := c.httpClient.R() - req.SetHeader("Content-Type", "application/json") - req.SetBody(fmt.Sprintf("\"%s\"", url)) - - _, err := req.Post(targetEndpoint) - return err -} - -// NewClient create a new API client using given details -func NewClient(baseURL, token string) Client { - httpClient := resty.New() - httpClient.SetAuthScheme("Bearer") - httpClient.SetAuthToken(token) - httpClient.OnAfterResponse(func(c *resty.Client, r *resty.Response) error { - if r.StatusCode() > 302 { - return fmt.Errorf("error when making HTTP request: %s", r.Status()) - } - return nil - }) - - client := &client{ - httpClient: httpClient, - baseURL: baseURL, - } - - return client -} diff --git a/internal/indexer/index/elastic.go b/internal/indexer/index/elastic.go index 25c035d..a920921 100644 --- a/internal/indexer/index/elastic.go +++ b/internal/indexer/index/elastic.go @@ -2,10 +2,10 @@ package index import ( "context" - "encoding/json" - "github.com/creekorful/trandoshan/internal/indexer/client" + "github.com/PuerkitoBio/goquery" "github.com/olivere/elastic/v7" "github.com/rs/zerolog/log" + "strings" "time" ) @@ -56,12 +56,21 @@ const mapping = ` } }` -type elasticSearchDB struct { +type resourceIdx struct { + URL string `json:"url"` + Body string `json:"body"` + Time time.Time `json:"time"` + Title string `json:"title"` + Meta map[string]string `json:"meta"` + Description string `json:"description"` + Headers map[string]string `json:"headers"` +} + +type elasticSearchIndex struct { client *elastic.Client } -// NewElasticIndex create a new index based on ES instance -func NewElasticIndex(uri string) (Index, error) { +func newElasticIndex(uri string) (Index, error) { // Create Elasticsearch client ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -79,103 +88,24 @@ func NewElasticIndex(uri string) (Index, error) { return nil, err } - return &elasticSearchDB{ + return &elasticSearchIndex{ client: ec, }, nil } -func (e *elasticSearchDB) SearchResources(params *client.ResSearchParams) ([]ResourceIdx, error) { - q := buildSearchQuery(params) - from := (params.PageNumber - 1) * params.PageSize - - res, err := e.client.Search(). - Index(resourcesIndex). - Query(q). - From(from). - Size(params.PageSize). - Do(context.Background()) - if err != nil { - return nil, err - } - - var resources []ResourceIdx - for _, hit := range res.Hits.Hits { - var resource ResourceIdx - if err := json.Unmarshal(hit.Source, &resource); err != nil { - log.Warn().Str("err", err.Error()).Msg("Error while un-marshaling resource") - continue - } - - // Remove body if not wanted - if !params.WithBody { - resource.Body = "" - } - - resources = append(resources, resource) - } - - return resources, nil -} - -func (e *elasticSearchDB) CountResources(params *client.ResSearchParams) (int64, error) { - q := buildSearchQuery(params) - - count, err := e.client.Count(resourcesIndex).Query(q).Do(context.Background()) +func (e *elasticSearchIndex) IndexResource(url string, time time.Time, body string, headers map[string]string) error { + res, err := extractResource(url, time, body, headers) if err != nil { - return 0, err + return err } - return count, nil -} - -func (e *elasticSearchDB) AddResource(res ResourceIdx) error { - _, err := e.client.Index(). + _, err = e.client.Index(). Index(resourcesIndex). BodyJson(res). Do(context.Background()) return err } -func buildSearchQuery(params *client.ResSearchParams) elastic.Query { - var queries []elastic.Query - if params.URL != "" { - log.Trace().Str("url", params.URL).Msg("SearchQuery: Setting url") - queries = append(queries, elastic.NewTermQuery("url.keyword", params.URL)) - } - if params.Keyword != "" { - log.Trace().Str("body", params.Keyword).Msg("SearchQuery: Setting body") - queries = append(queries, elastic.NewMatchQuery("body", params.Keyword)) - } - if !params.StartDate.IsZero() || !params.EndDate.IsZero() { - timeQuery := elastic.NewRangeQuery("time") - - if !params.StartDate.IsZero() { - log.Trace(). - Str("startDate", params.StartDate.Format(time.RFC3339)). - Msg("SearchQuery: Setting startDate") - timeQuery.Gte(params.StartDate.Format(time.RFC3339)) - } - if !params.EndDate.IsZero() { - log.Trace(). - Str("endDate", params.EndDate.Format(time.RFC3339)). - Msg("SearchQuery: Setting endDate") - timeQuery.Lte(params.EndDate.Format(time.RFC3339)) - } - queries = append(queries, timeQuery) - } - - // Handle specific case - if len(queries) == 0 { - return elastic.NewMatchAllQuery() - } - if len(queries) == 1 { - return queries[0] - } - - // otherwise AND combine them - return elastic.NewBoolQuery().Must(queries...) -} - func setupElasticSearch(ctx context.Context, es *elastic.Client) error { // Setup index if doesn't exist exist, err := es.IndexExists(resourcesIndex).Do(ctx) @@ -193,3 +123,46 @@ func setupElasticSearch(ctx context.Context, es *elastic.Client) error { return nil } + +func extractResource(url string, time time.Time, body string, headers map[string]string) (*resourceIdx, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(body)) + if err != nil { + return nil, err + } + + // Get resource title + title := doc.Find("title").First().Text() + + // Get meta values + meta := map[string]string{} + doc.Find("meta").Each(func(i int, s *goquery.Selection) { + name, _ := s.Attr("name") + value, _ := s.Attr("content") + + // if name is empty then try to lookup using property + if name == "" { + name, _ = s.Attr("property") + if name == "" { + return + } + } + + meta[strings.ToLower(name)] = value + }) + + // Lowercase headers + lowerCasedHeaders := map[string]string{} + for key, value := range headers { + lowerCasedHeaders[strings.ToLower(key)] = value + } + + return &resourceIdx{ + URL: url, + Body: body, + Time: time, + Title: title, + Meta: meta, + Description: meta["description"], + Headers: lowerCasedHeaders, + }, nil +} diff --git a/internal/indexer/index/elastic_test.go b/internal/indexer/index/elastic_test.go new file mode 100644 index 0000000..a4e145b --- /dev/null +++ b/internal/indexer/index/elastic_test.go @@ -0,0 +1,56 @@ +package index + +import ( + "github.com/creekorful/trandoshan/internal/event" + "testing" + "time" +) + +func TestExtractResource(t *testing.T) { + body := ` +Creekorful Inc + +This is sparta + + + + + +` + + msg := event.NewResourceEvent{ + URL: "https://example.org/300", + Body: body, + } + + resDto, err := extractResource("https://example.org/300", time.Time{}, body, map[string]string{"Content-Type": "application/json"}) + if err != nil { + t.FailNow() + } + + if resDto.URL != "https://example.org/300" { + t.Fail() + } + if resDto.Title != "Creekorful Inc" { + t.Fail() + } + if resDto.Body != msg.Body { + t.Fail() + } + + if resDto.Description != "Zhello world" { + t.Fail() + } + + if resDto.Meta["description"] != "Zhello world" { + t.Fail() + } + + if resDto.Meta["og:url"] != "https://example.org" { + t.Fail() + } + + if resDto.Headers["content-type"] != "application/json" { + t.Fail() + } +} diff --git a/internal/indexer/index/index.go b/internal/indexer/index/index.go index 3446612..a3da953 100644 --- a/internal/indexer/index/index.go +++ b/internal/indexer/index/index.go @@ -1,27 +1,33 @@ package index +//go:generate mockgen -destination=../index_mock/index_mock.go -package=index_mock . Index + import ( - "github.com/creekorful/trandoshan/internal/indexer/client" + "fmt" "time" ) -//go:generate mockgen -destination=../index_mock/index_mock.go -package=index_mock . Index - -// ResourceIdx represent a resource as stored in elasticsearch -type ResourceIdx struct { - URL string `json:"url"` - Body string `json:"body"` - Time time.Time `json:"time"` - Title string `json:"title"` - Meta map[string]string `json:"meta"` - Description string `json:"description"` - Headers map[string]string `json:"headers"` -} +const ( + // Elastic is an Index backed by ES instance + Elastic = "elastic" + // Local is an Index backed by local FS instance + Local = "local" +) // Index is the interface used to abstract communication // with the persistence unit type Index interface { - SearchResources(params *client.ResSearchParams) ([]ResourceIdx, error) - CountResources(params *client.ResSearchParams) (int64, error) - AddResource(res ResourceIdx) error + IndexResource(url string, time time.Time, body string, headers map[string]string) error +} + +// NewIndex create a new index using given driver, destination +func NewIndex(driver string, dest string) (Index, error) { + switch driver { + case Elastic: + return newElasticIndex(dest) + case Local: + return newLocalIndex(dest) + default: + return nil, fmt.Errorf("no driver named %s found", driver) + } } diff --git a/internal/archiver/storage/local.go b/internal/indexer/index/local.go similarity index 57% rename from internal/archiver/storage/local.go rename to internal/indexer/index/local.go index c498c0b..44f14f9 100644 --- a/internal/archiver/storage/local.go +++ b/internal/indexer/index/local.go @@ -1,4 +1,4 @@ -package storage +package index import ( "fmt" @@ -12,21 +12,25 @@ import ( "time" ) -type localStorage struct { +type localIndex struct { baseDir string } -// NewLocalStorage returns a new Storage that use local file system -func NewLocalStorage(root string) (Storage, error) { - return &localStorage{baseDir: root}, nil +func newLocalIndex(root string) (Index, error) { + return &localIndex{baseDir: root}, nil } -func (s *localStorage) Store(url string, time time.Time, body []byte) error { +func (s *localIndex) IndexResource(url string, time time.Time, body string, headers map[string]string) error { path, err := formatPath(url, time) if err != nil { return err } + content, err := formatResource(url, body, headers) + if err != nil { + return err + } + fullPath := filepath.Join(s.baseDir, path) dir := filepath.Dir(fullPath) @@ -34,13 +38,31 @@ func (s *localStorage) Store(url string, time time.Time, body []byte) error { return err } - if err := ioutil.WriteFile(fullPath, body, 0640); err != nil { + if err := ioutil.WriteFile(fullPath, content, 0640); err != nil { return err } return nil } +func formatResource(url string, body string, headers map[string]string) ([]byte, error) { + builder := strings.Builder{} + + // First URL + builder.WriteString(fmt.Sprintf("%s\n\n", url)) + + // Then headers + for key, value := range headers { + builder.WriteString(fmt.Sprintf("%s: %s\n", key, value)) + } + builder.WriteString("\n") + + // Then body + builder.WriteString(body) + + return []byte(builder.String()), nil +} + func formatPath(rawURL string, time time.Time) (string, error) { b := strings.Builder{} diff --git a/internal/archiver/storage/local_test.go b/internal/indexer/index/local_test.go similarity index 63% rename from internal/archiver/storage/local_test.go rename to internal/indexer/index/local_test.go index a16912a..e3763b6 100644 --- a/internal/archiver/storage/local_test.go +++ b/internal/indexer/index/local_test.go @@ -1,4 +1,4 @@ -package storage +package index import ( "io/ioutil" @@ -47,18 +47,18 @@ func TestFormatPath(t *testing.T) { } } -func TestLocalStorage_Store(t *testing.T) { +func TestLocalIndex_IndexResource(t *testing.T) { d, err := ioutil.TempDir("", "") if err != nil { t.FailNow() } defer os.RemoveAll(d) - s := localStorage{baseDir: d} + s := localIndex{baseDir: d} ti := time.Date(2020, time.October, 29, 12, 4, 9, 0, time.UTC) - if err := s.Store("https://google.com", ti, []byte("Hello, world")); err != nil { + if err := s.IndexResource("https://google.com", ti, "Hello, world", map[string]string{"Server": "Traefik"}); err != nil { t.Fail() } @@ -76,7 +76,18 @@ func TestLocalStorage_Store(t *testing.T) { if err != nil { t.Fail() } - if string(b) != "Hello, world" { + if string(b) != "https://google.com\n\nServer: Traefik\n\nHello, world" { t.Fail() } } + +func TestFormatResource(t *testing.T) { + res, err := formatResource("https://google.com", "Hello, world", map[string]string{"Server": "Traefik", "Content-Type": "text/html"}) + if err != nil { + t.FailNow() + } + + if string(res) != "https://google.com\n\nServer: Traefik\nContent-Type: text/html\n\nHello, world" { + t.Errorf("got %s want %s", string(res), "https://google.com\n\nServer: Traefik\nContent-Type: text/html\n\nHello, world") + } +} diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index bc5966a..79abfc9 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -1,43 +1,24 @@ package indexer import ( - "encoding/base64" - "encoding/json" "fmt" - "github.com/PuerkitoBio/goquery" - "github.com/PuerkitoBio/purell" - "github.com/creekorful/trandoshan/internal/cache" configapi "github.com/creekorful/trandoshan/internal/configapi/client" + "github.com/creekorful/trandoshan/internal/constraint" "github.com/creekorful/trandoshan/internal/event" - "github.com/creekorful/trandoshan/internal/indexer/auth" - "github.com/creekorful/trandoshan/internal/indexer/client" "github.com/creekorful/trandoshan/internal/indexer/index" "github.com/creekorful/trandoshan/internal/process" - "github.com/gorilla/mux" "github.com/rs/zerolog/log" "github.com/urfave/cli/v2" - "mvdan.cc/xurls/v2" "net/http" - "net/url" - "strconv" - "strings" - "time" ) -var ( - defaultPaginationSize = 50 - maxPaginationSize = 100 - - errHostnameNotAllowed = fmt.Errorf("hostname is not allowed") - errAlreadyIndexed = fmt.Errorf("resource is already indexed") -) +var errHostnameNotAllowed = fmt.Errorf("hostname is not allowed") // State represent the application state type State struct { index index.Index - pub event.Publisher + indexDriver string configClient configapi.Client - urlCache cache.Cache } // Name return the process name @@ -47,20 +28,20 @@ func (state *State) Name() string { // CommonFlags return process common flags func (state *State) CommonFlags() []string { - return []string{process.HubURIFlag, process.ConfigAPIURIFlag, process.RedisURIFlag} + return []string{process.HubURIFlag, process.ConfigAPIURIFlag} } // CustomFlags return process custom flags func (state *State) CustomFlags() []cli.Flag { return []cli.Flag{ &cli.StringFlag{ - Name: "elasticsearch-uri", - Usage: "URI to the Elasticsearch server", + Name: "index-driver", + Usage: "Name of the storage driver", Required: true, }, &cli.StringFlag{ - Name: "signing-key", - Usage: "Signing key for the JWT token", + Name: "index-dest", + Usage: "Destination (config) passed to the driver", Required: true, }, } @@ -68,108 +49,33 @@ func (state *State) CustomFlags() []cli.Flag { // Initialize the process func (state *State) Initialize(provider process.Provider) error { - db, err := index.NewElasticIndex(provider.GetValue("elasticsearch-uri")) - if err != nil { - return err - } - state.index = db - - pub, err := provider.Subscriber() + indexDriver := provider.GetValue("index-driver") + idx, err := index.NewIndex(indexDriver, provider.GetValue("index-dest")) if err != nil { return err } - state.pub = pub + state.index = idx + state.indexDriver = indexDriver - configClient, err := provider.ConfigClient([]string{configapi.RefreshDelayKey, configapi.ForbiddenHostnamesKey}) + configClient, err := provider.ConfigClient([]string{configapi.ForbiddenHostnamesKey}) if err != nil { return err } state.configClient = configClient - urlCache, err := provider.Cache() - if err != nil { - return err - } - state.urlCache = urlCache - return nil } // Subscribers return the process subscribers func (state *State) Subscribers() []process.SubscriberDef { return []process.SubscriberDef{ - {Exchange: event.NewResourceExchange, Queue: "indexingQueue", Handler: state.handleNewResourceEvent}, + {Exchange: event.NewResourceExchange, Queue: fmt.Sprintf("%sIndexingQueue", state.indexDriver), Handler: state.handleNewResourceEvent}, } } // HTTPHandler returns the HTTP API the process expose -func (state *State) HTTPHandler(provider process.Provider) http.Handler { - r := mux.NewRouter() - - signingKey := []byte(provider.GetValue("signing-key")) - authMiddleware := auth.NewMiddleware(signingKey) - r.Use(authMiddleware.Middleware()) - - r.HandleFunc("/v1/resources", state.searchResources).Methods(http.MethodGet) - r.HandleFunc("/v1/urls", state.scheduleURL).Methods(http.MethodPost) - - return r -} - -func (state *State) searchResources(w http.ResponseWriter, r *http.Request) { - searchParams, err := getSearchParams(r) - if err != nil { - log.Err(err).Msg("error while getting search params") - w.WriteHeader(http.StatusBadRequest) - return - } - - totalCount, err := state.index.CountResources(searchParams) - if err != nil { - log.Err(err).Msg("error while counting on ES") - w.WriteHeader(http.StatusInternalServerError) - return - } - - res, err := state.index.SearchResources(searchParams) - if err != nil { - log.Err(err).Msg("error while searching on ES") - w.WriteHeader(http.StatusInternalServerError) - return - } - - var resources []client.ResourceDto - for _, r := range res { - resources = append(resources, client.ResourceDto{ - URL: r.URL, - Body: r.Body, - Title: r.Title, - Time: r.Time, - }) - } - - // Write pagination headers - writePagination(w, searchParams, totalCount) - - // Write body - writeJSON(w, resources) -} - -func (state *State) scheduleURL(w http.ResponseWriter, r *http.Request) { - var url string - if err := json.NewDecoder(r.Body).Decode(&url); err != nil { - log.Warn().Str("err", err.Error()).Msg("error while decoding request body") - w.WriteHeader(http.StatusUnprocessableEntity) - return - } - - if err := state.pub.PublishEvent(&event.FoundURLEvent{URL: url}); err != nil { - log.Err(err).Msg("unable to schedule URL") - w.WriteHeader(http.StatusInternalServerError) - return - } - - log.Info().Str("url", url).Msg("successfully scheduled URL") +func (state *State) HTTPHandler() http.Handler { + return nil } func (state *State) handleNewResourceEvent(subscriber event.Subscriber, msg event.RawMessage) error { @@ -178,288 +84,16 @@ func (state *State) handleNewResourceEvent(subscriber event.Subscriber, msg even return err } - // Extract & process resource - resDto, urls, err := extractResource(evt) - if err != nil { - return fmt.Errorf("error while extracting resource: %s", err) - } - - // Lowercase headers - resDto.Headers = map[string]string{} - for key, value := range evt.Headers { - resDto.Headers[strings.ToLower(key)] = value - } - - // Get current refresh delay - refreshDelay := time.Duration(-1) - if val, err := state.configClient.GetRefreshDelay(); err == nil { - refreshDelay = val.Delay - } - - // Save resource - if err := state.tryAddResource(resDto); err != nil { - return err - } - - log.Info().Str("url", evt.URL).Msg("Successfully indexed resource") - - // Finally push found URLs - for _, u := range urls { - // make sure url has not been published (yet) - count, err := state.urlCache.GetInt64(fmt.Sprintf("urls:%s", u)) - if err != nil && err != cache.ErrNIL { - log.Err(err). - Str("url", u). - Msg("error while checking URL cache") - continue - } - if count > 0 { - log.Trace(). - Str("url", u). - Msg("skipping already published URL") - continue - } - - // Update cache - ttl := refreshDelay - if refreshDelay == -1 { - ttl = cache.NoTTL - } - - if err := state.urlCache.SetInt64(fmt.Sprintf("urls:%s", u), count+1, ttl); err != nil { - log.Err(err).Msg("error while updating URL cache") - } - - if err := subscriber.PublishEvent(&event.FoundURLEvent{URL: u}); err != nil { - log.Warn(). - Str("url", u). - Str("err", err.Error()). - Msg("Error while publishing URL") - } - - log.Trace(). - Str("url", u). - Msg("Published found URL") - } - - return nil -} - -func (state *State) tryAddResource(res *client.ResourceDto) error { - forbiddenHostnames, err := state.configClient.GetForbiddenHostnames() - if err != nil { - return err - } - - u, err := url.Parse(res.URL) - if err != nil { - return err - } - // make sure hostname hasn't been flagged as forbidden - for _, hostname := range forbiddenHostnames { - if strings.Contains(u.Hostname(), hostname.Hostname) { - return errHostnameNotAllowed - } + if allowed, err := constraint.CheckHostnameAllowed(state.configClient, evt.URL); !allowed || err != nil { + return fmt.Errorf("%s %w", evt.URL, errHostnameNotAllowed) } - // Create Elasticsearch document - doc := index.ResourceIdx{ - URL: res.URL, - Body: res.Body, - Time: res.Time, - Title: res.Title, - Meta: res.Meta, - Description: res.Description, - Headers: res.Headers, + if err := state.index.IndexResource(evt.URL, evt.Time, evt.Body, evt.Headers); err != nil { + return fmt.Errorf("error while indexing resource: %s", err) } - if err := state.index.AddResource(doc); err != nil { - return err - } - - // Publish linked event - if err := state.pub.PublishEvent(&event.NewIndexEvent{ - URL: res.URL, - Body: res.Body, - Time: res.Time, - Title: res.Title, - Meta: res.Meta, - Description: res.Description, - Headers: res.Headers, - }); err != nil { - return err - } + log.Info().Str("url", evt.URL).Msg("Successfully indexed resource") return nil } - -func getSearchParams(r *http.Request) (*client.ResSearchParams, error) { - params := &client.ResSearchParams{} - - if param := r.URL.Query()["keyword"]; len(param) == 1 { - params.Keyword = param[0] - } - - if param := r.URL.Query()["with-body"]; len(param) == 1 { - params.WithBody = param[0] == "true" - } - - // extract raw query params (unescaped to keep + sign when parsing date) - rawQueryParams := getRawQueryParam(r.URL.RawQuery) - - if val, exist := rawQueryParams["start-date"]; exist { - d, err := time.Parse(time.RFC3339, val) - if err == nil { - params.StartDate = d - } else { - return nil, err - } - } - - if val, exist := rawQueryParams["end-date"]; exist { - d, err := time.Parse(time.RFC3339, val) - if err == nil { - params.EndDate = d - } else { - return nil, err - } - } - - // base64decode the URL - if param := r.URL.Query()["url"]; len(param) == 1 { - b, err := base64.URLEncoding.DecodeString(param[0]) - if err != nil { - return nil, err - } - params.URL = string(b) - } - - // Acquire pagination - page, size := getPagination(r) - params.PageNumber = page - params.PageSize = size - - return params, nil -} - -func writePagination(w http.ResponseWriter, searchParams *client.ResSearchParams, total int64) { - w.Header().Set(client.PaginationPageHeader, strconv.Itoa(searchParams.PageNumber)) - w.Header().Set(client.PaginationSizeHeader, strconv.Itoa(searchParams.PageSize)) - w.Header().Set(client.PaginationCountHeader, strconv.FormatInt(total, 10)) -} - -func getPagination(r *http.Request) (page int, size int) { - page = 1 - size = defaultPaginationSize - - // Get pagination page - if param := r.URL.Query()[client.PaginationPageQueryParam]; len(param) == 1 { - if val, err := strconv.Atoi(param[0]); err == nil { - page = val - } - } - - // Get pagination size - if param := r.URL.Query()[client.PaginationSizeQueryParam]; len(param) == 1 { - if val, err := strconv.Atoi(param[0]); err == nil { - size = val - } - } - - // Prevent too much results from being returned - if size > maxPaginationSize { - size = maxPaginationSize - } - - return page, size -} - -func getRawQueryParam(url string) map[string]string { - if url == "" { - return map[string]string{} - } - - val := map[string]string{} - parts := strings.Split(url, "&") - - for _, part := range parts { - p := strings.Split(part, "=") - val[p[0]] = p[1] - } - - return val -} - -func writeJSON(w http.ResponseWriter, body interface{}) { - b, err := json.Marshal(body) - if err != nil { - log.Err(err).Msg("error while serializing body") - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(b) -} - -func extractResource(msg event.NewResourceEvent) (*client.ResourceDto, []string, error) { - doc, err := goquery.NewDocumentFromReader(strings.NewReader(msg.Body)) - if err != nil { - return nil, nil, err - } - - // Get resource title - title := doc.Find("title").First().Text() - - // Get meta values - meta := map[string]string{} - doc.Find("meta").Each(func(i int, s *goquery.Selection) { - name, _ := s.Attr("name") - value, _ := s.Attr("content") - - // if name is empty then try to lookup using property - if name == "" { - name, _ = s.Attr("property") - if name == "" { - return - } - } - - meta[strings.ToLower(name)] = value - }) - - // Extract & normalize URLs - xu := xurls.Strict() - urls := xu.FindAllString(msg.Body, -1) - - var normalizedURLS []string - - for _, u := range urls { - normalizedURL, err := normalizeURL(u) - if err != nil { - continue - } - - normalizedURLS = append(normalizedURLS, normalizedURL) - } - - return &client.ResourceDto{ - URL: msg.URL, - Body: msg.Body, - Time: msg.Time, - Title: title, - Meta: meta, - Description: meta["description"], - }, normalizedURLS, nil -} - -func normalizeURL(u string) (string, error) { - normalizedURL, err := purell.NormalizeURLString(u, purell.FlagsUsuallySafeGreedy| - purell.FlagRemoveDirectoryIndex|purell.FlagRemoveFragment|purell.FlagRemoveDuplicateSlashes) - if err != nil { - return "", fmt.Errorf("error while normalizing URL %s: %s", u, err) - } - - return normalizedURL, nil -} diff --git a/internal/indexer/indexer_test.go b/internal/indexer/indexer_test.go index 3a27e93..023f89d 100644 --- a/internal/indexer/indexer_test.go +++ b/internal/indexer/indexer_test.go @@ -1,325 +1,55 @@ package indexer import ( - "encoding/json" - "fmt" - "github.com/creekorful/trandoshan/internal/cache" - "github.com/creekorful/trandoshan/internal/cache_mock" + "errors" "github.com/creekorful/trandoshan/internal/configapi/client" "github.com/creekorful/trandoshan/internal/configapi/client_mock" "github.com/creekorful/trandoshan/internal/event" "github.com/creekorful/trandoshan/internal/event_mock" - client2 "github.com/creekorful/trandoshan/internal/indexer/client" - "github.com/creekorful/trandoshan/internal/indexer/index" "github.com/creekorful/trandoshan/internal/indexer/index_mock" + "github.com/creekorful/trandoshan/internal/process" + "github.com/creekorful/trandoshan/internal/process_mock" + "github.com/creekorful/trandoshan/internal/test" "github.com/golang/mock/gomock" - "net/http" - "net/http/httptest" - "strings" "testing" "time" ) -func TestWritePagination(t *testing.T) { - rec := httptest.NewRecorder() - searchParams := &client2.ResSearchParams{ - PageSize: 15, - PageNumber: 7, - } - total := int64(1200) - - writePagination(rec, searchParams, total) - - if rec.Header().Get(client2.PaginationPageHeader) != "7" { - t.Fail() - } - if rec.Header().Get(client2.PaginationSizeHeader) != "15" { - t.Fail() - } - if rec.Header().Get(client2.PaginationCountHeader) != "1200" { +func TestState_Name(t *testing.T) { + s := State{} + if s.Name() != "indexer" { t.Fail() } } -func TestReadPagination(t *testing.T) { - // valid params - req := httptest.NewRequest(http.MethodGet, "/index.php?pagination-page=1&pagination-size=10", nil) - if page, size := getPagination(req); page != 1 || size != 10 { - t.Errorf("wanted page: 1, size: 10 (got %d, %d)", page, size) - } - - // make sure invalid parameter are set as wanted - req = httptest.NewRequest(http.MethodGet, "/index.php?pagination-page=abcd&pagination-size=lol", nil) - if page, size := getPagination(req); page != 1 || size != defaultPaginationSize { - t.Errorf("wanted page: 1, size: %d (got %d, %d)", defaultPaginationSize, page, size) - } - - // make sure we prevent too much results from being returned - target := fmt.Sprintf("/index.php?pagination-page=10&pagination-size=%d", maxPaginationSize+1) - req = httptest.NewRequest(http.MethodGet, target, nil) - if page, size := getPagination(req); page != 10 || size != maxPaginationSize { - t.Errorf("wanted page: 10, size: %d (got %d, %d)", maxPaginationSize, page, size) - } - - // make sure no parameter we set to default - req = httptest.NewRequest(http.MethodGet, "/index.php", nil) - if page, size := getPagination(req); page != 1 || size != defaultPaginationSize { - t.Errorf("wanted page: 1, size: %d (got %d, %d)", defaultPaginationSize, page, size) - } +func TestState_CommonFlags(t *testing.T) { + s := State{} + test.CheckProcessCommonFlags(t, &s, []string{process.HubURIFlag, process.ConfigAPIURIFlag}) } -func TestGetSearchParams(t *testing.T) { - startDate := time.Now() - target := fmt.Sprintf("/resources?with-body=true&pagination-page=1&keyword=keyword&url=dXJs&start-date=%s", startDate.Format(time.RFC3339)) - - req := httptest.NewRequest(http.MethodPost, target, nil) - - params, err := getSearchParams(req) - if err != nil { - t.Errorf("error while parsing search params: %s", err) - t.FailNow() - } - - if !params.WithBody { - t.Errorf("wrong withBody: %v", params.WithBody) - } - if params.PageSize != 50 { - t.Errorf("wrong pagination-size: %d", params.PageSize) - } - if params.PageNumber != 1 { - t.Errorf("wrong pagination-page: %d", params.PageNumber) - } - if params.Keyword != "keyword" { - t.Errorf("wrong keyword: %s", params.Keyword) - } - if params.StartDate.Year() != startDate.Year() { - t.Errorf("wrong start-date (year)") - } - if params.StartDate.Month() != startDate.Month() { - t.Errorf("wrong start-date (month)") - } - if params.StartDate.Day() != startDate.Day() { - t.Errorf("wrong start-date (day)") - } - if params.StartDate.Hour() != startDate.Hour() { - t.Errorf("wrong start-date (hour)") - } - if params.StartDate.Minute() != startDate.Minute() { - t.Errorf("wrong start-date (minute)") - } - if params.StartDate.Second() != startDate.Second() { - t.Errorf("wrong start-date (second)") - } - if params.URL != "url" { - t.Errorf("wrong url: %s", params.URL) - } -} - -func TestScheduleURL(t *testing.T) { - mockCtrl := gomock.NewController(t) - defer mockCtrl.Finish() - - // The requests - req := httptest.NewRequest(http.MethodPost, "/v1/urls", strings.NewReader("\"https://google.onion\"")) - rec := httptest.NewRecorder() - - // Mocking status - pubMock := event_mock.NewMockPublisher(mockCtrl) - - s := State{pub: pubMock} - - pubMock.EXPECT().PublishEvent(&event.FoundURLEvent{URL: "https://google.onion"}).Return(nil) - - s.scheduleURL(rec, req) - - if rec.Code != http.StatusOK { - t.Fail() - } +func TestState_CustomFlags(t *testing.T) { + s := State{} + test.CheckProcessCustomFlags(t, &s, []string{"index-driver", "index-dest"}) } -func TestAddResource(t *testing.T) { - mockCtrl := gomock.NewController(t) - defer mockCtrl.Finish() - - body := client2.ResourceDto{ - URL: "https://example.onion", - Body: "TheBody", - Title: "Example", - Time: time.Time{}, - Meta: map[string]string{"content": "content-meta"}, - Description: "the description", - Headers: map[string]string{"Content-Type": "application/html", "Server": "Traefik"}, - } - - indexMock := index_mock.NewMockIndex(mockCtrl) - configClientMock := client_mock.NewMockClient(mockCtrl) - pubMock := event_mock.NewMockPublisher(mockCtrl) - - indexMock.EXPECT().AddResource(index.ResourceIdx{ - URL: "https://example.onion", - Body: "TheBody", - Title: "Example", - Time: time.Time{}, - Meta: map[string]string{"content": "content-meta"}, - Description: "the description", - Headers: map[string]string{"Content-Type": "application/html", "Server": "Traefik"}, - }) - - pubMock.EXPECT().PublishEvent(&event.NewIndexEvent{ - URL: "https://example.onion", - Body: "TheBody", - Title: "Example", - Time: time.Time{}, - Meta: map[string]string{"content": "content-meta"}, - Description: "the description", - Headers: map[string]string{"Content-Type": "application/html", "Server": "Traefik"}, +func TestState_Initialize(t *testing.T) { + s := State{} + test.CheckInitialize(t, &s, func(p *process_mock.MockProviderMockRecorder) { + p.GetValue("index-driver").Return("local") + p.GetValue("index-dest") + p.ConfigClient([]string{client.ForbiddenHostnamesKey}) }) - configClientMock.EXPECT().GetForbiddenHostnames().Return([]client.ForbiddenHostname{}, nil) - - s := State{index: indexMock, configClient: configClientMock, pub: pubMock} - if err := s.tryAddResource(&body); err != nil { - t.FailNow() - } -} - -func TestAddResourceForbiddenHostname(t *testing.T) { - mockCtrl := gomock.NewController(t) - defer mockCtrl.Finish() - - body := client2.ResourceDto{ - URL: "https://example.onion", - Body: "TheBody", - Title: "Example", - Time: time.Time{}, - Meta: map[string]string{"content": "content-meta"}, - Description: "the description", - Headers: map[string]string{"Content-Type": "application/html", "Server": "Traefik"}, - } - - configClientMock := client_mock.NewMockClient(mockCtrl) - - configClientMock.EXPECT().GetForbiddenHostnames().Return([]client.ForbiddenHostname{{Hostname: "example.onion"}}, nil) - - s := State{configClient: configClientMock} - - if err := s.tryAddResource(&body); err != errHostnameNotAllowed { - t.FailNow() - } -} - -func TestSearchResources(t *testing.T) { - mockCtrl := gomock.NewController(t) - defer mockCtrl.Finish() - - // The requests - req := httptest.NewRequest(http.MethodPost, "/v1/resources?keyword=example", nil) - rec := httptest.NewRecorder() - - indexMock := index_mock.NewMockIndex(mockCtrl) - - indexMock.EXPECT().CountResources(gomock.Any()).Return(int64(150), nil) - indexMock.EXPECT().SearchResources(gomock.Any()).Return([]index.ResourceIdx{ - { - URL: "example-1.onion", - Body: "Example 1", - Title: "Example 1", - Time: time.Time{}, - }, - { - URL: "example-2.onion", - Body: "Example 2", - Title: "Example 2", - Time: time.Time{}, - }, - }, nil) - - s := State{index: indexMock} - s.searchResources(rec, req) - - if rec.Code != http.StatusOK { - t.Fail() - } - if rec.Header().Get("Content-Type") != "application/json" { - t.Fail() - } - if rec.Header().Get(client2.PaginationCountHeader) != "150" { - t.Fail() - } - - var resources []client2.ResourceDto - if err := json.NewDecoder(rec.Body).Decode(&resources); err != nil { - t.Fatalf("error while decoding body: %s", err) - } - if len(resources) != 2 { - t.Errorf("got %d resources want 2", len(resources)) - } -} - -func TestExtractResource(t *testing.T) { - body := ` -Creekorful Inc - -This is sparta - - - - - -` - - msg := event.NewResourceEvent{ - URL: "https://example.org/300", - Body: body, - } - - resDto, urls, err := extractResource(msg) - if err != nil { - t.FailNow() - } - - if resDto.URL != "https://example.org/300" { - t.Fail() - } - if resDto.Title != "Creekorful Inc" { - t.Fail() - } - if resDto.Body != msg.Body { - t.Fail() - } - - if len(urls) != 2 { - t.FailNow() - } - if urls[0] != "https://google.com/test?test=test" { - t.Fail() - } - if urls[1] != "https://example.org" { - t.Fail() - } - - if resDto.Description != "Zhello world" { - t.Fail() - } - - if resDto.Meta["description"] != "Zhello world" { - t.Fail() - } - - if resDto.Meta["og:url"] != "https://example.org" { - t.Fail() + if s.indexDriver != "local" { + t.Errorf("wrong driver: got: %s want: %s", s.indexDriver, "local") } } -func TestNormalizeURL(t *testing.T) { - url, err := normalizeURL("https://this-is-sparta.de?url=url-query-param#fragment-23") - if err != nil { - t.FailNow() - } - - if url != "https://this-is-sparta.de?url=url-query-param" { - t.Fail() - } +func TestState_Subscribers(t *testing.T) { + s := State{indexDriver: "elastic"} + test.CheckProcessSubscribers(t, &s, []test.SubscriberDef{ + {Queue: "elasticIndexingQueue", Exchange: "resource.new"}, + }) } func TestHandleNewResourceEvent(t *testing.T) { @@ -341,8 +71,6 @@ Thanks to https://help.facebook.onion/ for the hosting :D subscriberMock := event_mock.NewMockSubscriber(mockCtrl) configClientMock := client_mock.NewMockClient(mockCtrl) indexMock := index_mock.NewMockIndex(mockCtrl) - pubMock := event_mock.NewMockPublisher(mockCtrl) - urlCacheMock := cache_mock.NewMockCache(mockCtrl) tn := time.Now() @@ -357,49 +85,9 @@ Thanks to https://help.facebook.onion/ for the hosting :D }).Return(nil) configClientMock.EXPECT().GetForbiddenHostnames().Return([]client.ForbiddenHostname{{Hostname: "example2.onion"}}, nil) - configClientMock.EXPECT().GetRefreshDelay().Times(1).Return(client.RefreshDelay{Delay: -1}, nil) - - // make sure we are creating the resource - indexMock.EXPECT().AddResource(index.ResourceIdx{ - URL: "https://example.onion", - Body: body, - Title: "Creekorful Inc", - Meta: map[string]string{"description": "Zhello world", "og:url": "https://example.org"}, - Description: "Zhello world", - Headers: map[string]string{"server": "Traefik", "content-type": "application/html"}, - Time: tn, - }).Return(nil) - - pubMock.EXPECT().PublishEvent(&event.NewIndexEvent{ - URL: "https://example.onion", - Body: body, - Title: "Creekorful Inc", - Meta: map[string]string{"description": "Zhello world", "og:url": "https://example.org"}, - Description: "Zhello world", - Headers: map[string]string{"server": "Traefik", "content-type": "application/html"}, - Time: tn, - }).Return(nil) - - // make sure we are pushing found URLs (but only if refresh delay elapsed) - urlCacheMock.EXPECT().GetInt64("urls:https://example.org").Return(int64(0), cache.ErrNIL) - - urlCacheMock.EXPECT().GetInt64("urls:https://example.org").Return(int64(1), nil) - - urlCacheMock.EXPECT().GetInt64("urls:https://help.facebook.onion").Return(int64(1), nil) - - urlCacheMock.EXPECT().GetInt64("urls:https://google.com/test?test=test").Return(int64(0), cache.ErrNIL) - - subscriberMock.EXPECT(). - PublishEvent(&event.FoundURLEvent{URL: "https://example.org"}). - Return(nil) - urlCacheMock.EXPECT().SetInt64("urls:https://example.org", int64(1), cache.NoTTL).Return(nil) - - subscriberMock.EXPECT(). - PublishEvent(&event.FoundURLEvent{URL: "https://google.com/test?test=test"}). - Return(nil) - urlCacheMock.EXPECT().SetInt64("urls:https://google.com/test?test=test", int64(1), cache.NoTTL).Return(nil) + indexMock.EXPECT().IndexResource("https://example.onion", tn, body, map[string]string{"Server": "Traefik", "Content-Type": "application/html"}) - s := State{index: indexMock, configClient: configClientMock, pub: pubMock, urlCache: urlCacheMock} + s := State{index: indexMock, configClient: configClientMock} if err := s.handleNewResourceEvent(subscriberMock, msg); err != nil { t.FailNow() } @@ -434,11 +122,10 @@ This is sparta (hosted on https://example.org) Time: tn, }).Return(nil) - configClientMock.EXPECT().GetRefreshDelay().Return(client.RefreshDelay{Delay: -1}, nil) configClientMock.EXPECT().GetForbiddenHostnames().Return([]client.ForbiddenHostname{{Hostname: "example.onion"}}, nil) s := State{configClient: configClientMock} - if err := s.handleNewResourceEvent(subscriberMock, msg); err != errHostnameNotAllowed { + if err := s.handleNewResourceEvent(subscriberMock, msg); !errors.Is(err, errHostnameNotAllowed) { t.FailNow() } } diff --git a/internal/logging/log.go b/internal/logging/log.go deleted file mode 100644 index 76a0e20..0000000 --- a/internal/logging/log.go +++ /dev/null @@ -1,31 +0,0 @@ -package logging - -import ( - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/urfave/cli/v2" - "os" -) - -// GetLogFlag return the CLI flag parameter used to setup application log level -func GetLogFlag() *cli.StringFlag { - return &cli.StringFlag{ - Name: "log-level", - Usage: "Set the application log level", - Value: "info", - } -} - -// ConfigureLogger configure the logger using given log level (read from cli context) -func ConfigureLogger(ctx *cli.Context) { - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) - - // Set application log level - if lvl, err := zerolog.ParseLevel(ctx.String("log-level")); err == nil { - zerolog.SetGlobalLevel(lvl) - } else { - zerolog.SetGlobalLevel(zerolog.InfoLevel) - } - - log.Debug().Stringer("lvl", zerolog.GlobalLevel()).Msg("Setting log level") -} diff --git a/internal/logging/log_test.go b/internal/logging/log_test.go deleted file mode 100644 index 8e09e9e..0000000 --- a/internal/logging/log_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package logging - -import ( - "testing" -) - -func TestGetLogFlag(t *testing.T) { - flag := GetLogFlag() - if flag.Name != "log-level" { - t.Fail() - } - if flag.Usage != "Set the application log level" { - t.Fail() - } - if flag.Value != "info" { - t.Fail() - } -} diff --git a/internal/process/process.go b/internal/process/process.go index efc3eb2..0344e53 100644 --- a/internal/process/process.go +++ b/internal/process/process.go @@ -1,16 +1,21 @@ package process +//go:generate mockgen -destination=../process_mock/process_mock.go -package=process_mock . Provider + import ( "context" + "crypto/tls" "fmt" "github.com/creekorful/trandoshan/internal/cache" "github.com/creekorful/trandoshan/internal/clock" configapi "github.com/creekorful/trandoshan/internal/configapi/client" "github.com/creekorful/trandoshan/internal/event" - "github.com/creekorful/trandoshan/internal/indexer/client" - "github.com/creekorful/trandoshan/internal/logging" + chttp "github.com/creekorful/trandoshan/internal/http" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/urfave/cli/v2" + "github.com/valyala/fasthttp" + "github.com/valyala/fasthttp/fasthttpproxy" "net/http" "os" "os/signal" @@ -30,6 +35,10 @@ const ( ConfigAPIURIFlag = "config-api-uri" // RedisURIFlag is the redis-uri flag RedisURIFlag = "redis-uri" + // TorURIFlag is the tor-uri flag + TorURIFlag = "tor-uri" + // UserAgentFlag is the user-agent flag + UserAgentFlag = "user-agent" ) // Provider is the implementation provider @@ -38,14 +47,14 @@ type Provider interface { Clock() (clock.Clock, error) // ConfigClient return a new configured configapi.Client ConfigClient(keys []string) (configapi.Client, error) - // IndexerClient return a new configured indexer client - IndexerClient() (client.Client, error) // Subscriber return a new configured subscriber Subscriber() (event.Subscriber, error) // Publisher return a new configured publisher Publisher() (event.Publisher, error) // Cache return a new configured cache - Cache() (cache.Cache, error) + Cache(keyPrefix string) (cache.Cache, error) + // HTTPClient return a new configured http client + HTTPClient() (chttp.Client, error) // GetValue return value for given key GetValue(key string) string // GetValue return values for given key @@ -74,10 +83,6 @@ func (p *defaultProvider) ConfigClient(keys []string) (configapi.Client, error) return configapi.NewConfigClient(p.ctx.String(ConfigAPIURIFlag), sub, keys) } -func (p *defaultProvider) IndexerClient() (client.Client, error) { - return client.NewClient(p.ctx.String(APIURIFlag), p.ctx.String(APITokenFlag)), nil -} - func (p *defaultProvider) Subscriber() (event.Subscriber, error) { return event.NewSubscriber(p.ctx.String(HubURIFlag)) } @@ -86,8 +91,20 @@ func (p *defaultProvider) Publisher() (event.Publisher, error) { return event.NewPublisher(p.ctx.String(HubURIFlag)) } -func (p *defaultProvider) Cache() (cache.Cache, error) { - return cache.NewRedisCache(p.ctx.String(RedisURIFlag)) +func (p *defaultProvider) Cache(keyPrefix string) (cache.Cache, error) { + return cache.NewRedisCache(p.ctx.String(RedisURIFlag), keyPrefix) +} + +func (p *defaultProvider) HTTPClient() (chttp.Client, error) { + return chttp.NewFastHTTPClient(&fasthttp.Client{ + // Use given TOR proxy to reach the hidden services + Dial: fasthttpproxy.FasthttpSocksDialer(p.ctx.String(TorURIFlag)), + // Disable SSL verification since we do not really care about this + TLSConfig: &tls.Config{InsecureSkipVerify: true}, + ReadTimeout: time.Second * 5, + WriteTimeout: time.Second * 5, + Name: p.ctx.String(UserAgentFlag), + }), nil } func (p *defaultProvider) GetValue(key string) string { @@ -112,7 +129,7 @@ type Process interface { CustomFlags() []cli.Flag Initialize(provider Provider) error Subscribers() []SubscriberDef - HTTPHandler(provider Provider) http.Handler + HTTPHandler() http.Handler } // MakeApp return cli.App corresponding for given Process @@ -122,7 +139,11 @@ func MakeApp(process Process) *cli.App { Version: version, Usage: fmt.Sprintf("Trandoshan %s component", process.Name()), Flags: []cli.Flag{ - logging.GetLogFlag(), + &cli.StringFlag{ + Name: "log-level", + Usage: "Set the application log level", + Value: "info", + }, }, Action: execute(process), } @@ -148,7 +169,7 @@ func execute(process Process) cli.ActionFunc { provider := NewDefaultProvider(c) // Common setup - logging.ConfigureLogger(c) + configureLogger(c) // Custom setup if err := process.Initialize(provider); err != nil { @@ -178,7 +199,7 @@ func execute(process Process) cli.ActionFunc { var srv *http.Server // Expose HTTP API if any - if h := process.HTTPHandler(provider); h != nil { + if h := process.HTTPHandler(); h != nil { srv = &http.Server{ Addr: "0.0.0.0:8080", // Good practice to set timeouts to avoid Slowloris attacks. @@ -243,6 +264,29 @@ func getCustomFlags() map[string]cli.Flag { Usage: "URI to the Redis server", Required: true, } + flags[TorURIFlag] = &cli.StringFlag{ + Name: TorURIFlag, + Usage: "URI to the TOR SOCKS proxy", + Required: true, + } + flags[UserAgentFlag] = &cli.StringFlag{ + Name: UserAgentFlag, + Usage: "User agent to use", + Value: "Mozilla/5.0 (Windows NT 10.0; rv:68.0) Gecko/20100101 Firefox/68.0", + } return flags } + +func configureLogger(ctx *cli.Context) { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + + // Set application log level + if lvl, err := zerolog.ParseLevel(ctx.String("log-level")); err == nil { + zerolog.SetGlobalLevel(lvl) + } else { + zerolog.SetGlobalLevel(zerolog.InfoLevel) + } + + log.Debug().Stringer("lvl", zerolog.GlobalLevel()).Msg("Setting log level") +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 691a815..9372711 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -3,12 +3,15 @@ package scheduler import ( "errors" "fmt" + "github.com/PuerkitoBio/purell" + "github.com/creekorful/trandoshan/internal/cache" configapi "github.com/creekorful/trandoshan/internal/configapi/client" "github.com/creekorful/trandoshan/internal/constraint" "github.com/creekorful/trandoshan/internal/event" "github.com/creekorful/trandoshan/internal/process" "github.com/rs/zerolog/log" "github.com/urfave/cli/v2" + "mvdan.cc/xurls/v2" "net/http" "net/url" "regexp" @@ -20,6 +23,7 @@ var ( errProtocolNotAllowed = errors.New("protocol is not allowed") errExtensionNotAllowed = errors.New("extension is not allowed") errHostnameNotAllowed = errors.New("hostname is not allowed") + errAlreadyScheduled = errors.New("URL is already scheduled") extensionRegex = regexp.MustCompile("\\.[\\w]+") ) @@ -27,6 +31,7 @@ var ( // State represent the application state type State struct { configClient configapi.Client + urlCache cache.Cache } // Name return the process name @@ -36,7 +41,7 @@ func (state *State) Name() string { // CommonFlags return process common flags func (state *State) CommonFlags() []string { - return []string{process.HubURIFlag, process.ConfigAPIURIFlag} + return []string{process.HubURIFlag, process.ConfigAPIURIFlag, process.RedisURIFlag} } // CustomFlags return process custom flags @@ -46,37 +51,58 @@ func (state *State) CustomFlags() []cli.Flag { // Initialize the process func (state *State) Initialize(provider process.Provider) error { - keys := []string{configapi.AllowedMimeTypesKey, configapi.ForbiddenHostnamesKey} + keys := []string{configapi.AllowedMimeTypesKey, configapi.ForbiddenHostnamesKey, configapi.RefreshDelayKey} configClient, err := provider.ConfigClient(keys) if err != nil { return err } state.configClient = configClient + urlCache, err := provider.Cache("url") + if err != nil { + return err + } + state.urlCache = urlCache + return nil } // Subscribers return the process subscribers func (state *State) Subscribers() []process.SubscriberDef { return []process.SubscriberDef{ - {Exchange: event.FoundURLExchange, Queue: "schedulingQueue", Handler: state.handleURLFoundEvent}, + {Exchange: event.NewResourceExchange, Queue: "schedulingQueue", Handler: state.handleNewResourceEvent}, } } // HTTPHandler returns the HTTP API the process expose -func (state *State) HTTPHandler(provider process.Provider) http.Handler { +func (state *State) HTTPHandler() http.Handler { return nil } -func (state *State) handleURLFoundEvent(subscriber event.Subscriber, msg event.RawMessage) error { - var evt event.FoundURLEvent +func (state *State) handleNewResourceEvent(subscriber event.Subscriber, msg event.RawMessage) error { + var evt event.NewResourceEvent if err := subscriber.Read(&msg, &evt); err != nil { return err } - log.Trace().Str("url", evt.URL).Msg("Processing URL") + log.Trace().Str("url", evt.URL).Msg("Processing new resource") + + urls, err := extractURLS(&evt) + if err != nil { + return fmt.Errorf("error while extracting URLs") + } + + for _, u := range urls { + if err := state.processURL(u, subscriber); err != nil { + log.Err(err).Msg("error while processing URL") + } + } + + return nil +} - u, err := url.Parse(evt.URL) +func (state *State) processURL(rawURL string, pub event.Publisher) error { + u, err := url.Parse(rawURL) if err != nil { return fmt.Errorf("error while parsing URL: %s", err) } @@ -123,18 +149,71 @@ func (state *State) handleURLFoundEvent(subscriber event.Subscriber, msg event.R } // Make sure hostname is not forbidden - if allowed, err := constraint.CheckHostnameAllowed(state.configClient, evt.URL); err != nil { + if allowed, err := constraint.CheckHostnameAllowed(state.configClient, rawURL); err != nil { return err } else if !allowed { - log.Debug().Str("url", evt.URL).Msg("Skipping forbidden hostname") + log.Debug().Str("url", rawURL).Msg("Skipping forbidden hostname") return fmt.Errorf("%s %w", u, errHostnameNotAllowed) } + // Check if URL should be scheduled + count, err := state.urlCache.GetInt64(rawURL) + if err != nil && err != cache.ErrNIL { + return err + } + if count > 0 { + return fmt.Errorf("%s %w", u, errAlreadyScheduled) + } + log.Debug().Stringer("url", u).Msg("URL should be scheduled") - if err := subscriber.PublishEvent(&event.NewURLEvent{URL: evt.URL}); err != nil { + // Update URL cache + delay, err := state.configClient.GetRefreshDelay() + if err != nil { + return err + } + + ttl := delay.Delay + if ttl == -1 { + ttl = cache.NoTTL + } + + if err := state.urlCache.SetInt64(rawURL, count+1, ttl); err != nil { + return err + } + + if err := pub.PublishEvent(&event.NewURLEvent{URL: rawURL}); err != nil { return fmt.Errorf("error while publishing URL: %s", err) } return nil } + +func extractURLS(msg *event.NewResourceEvent) ([]string, error) { + // Extract & normalize URLs + xu := xurls.Strict() + urls := xu.FindAllString(msg.Body, -1) + + var normalizedURLS []string + + for _, u := range urls { + normalizedURL, err := normalizeURL(u) + if err != nil { + continue + } + + normalizedURLS = append(normalizedURLS, normalizedURL) + } + + return normalizedURLS, nil +} + +func normalizeURL(u string) (string, error) { + normalizedURL, err := purell.NormalizeURLString(u, purell.FlagsUsuallySafeGreedy| + purell.FlagRemoveDirectoryIndex|purell.FlagRemoveFragment|purell.FlagRemoveDuplicateSlashes) + if err != nil { + return "", fmt.Errorf("error while normalizing URL %s: %s", u, err) + } + + return normalizedURL, nil +} diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index f5a731c..aca19bd 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -2,109 +2,122 @@ package scheduler import ( "errors" - "fmt" + "github.com/creekorful/trandoshan/internal/cache" + "github.com/creekorful/trandoshan/internal/cache_mock" "github.com/creekorful/trandoshan/internal/configapi/client" "github.com/creekorful/trandoshan/internal/configapi/client_mock" "github.com/creekorful/trandoshan/internal/event" "github.com/creekorful/trandoshan/internal/event_mock" + "github.com/creekorful/trandoshan/internal/process" + "github.com/creekorful/trandoshan/internal/process_mock" + "github.com/creekorful/trandoshan/internal/test" "github.com/golang/mock/gomock" "testing" + "time" ) -func TestHandleMessageNotOnion(t *testing.T) { - mockCtrl := gomock.NewController(t) - defer mockCtrl.Finish() +func TestState_Name(t *testing.T) { + s := State{} + if s.Name() != "scheduler" { + t.Fail() + } +} - subscriberMock := event_mock.NewMockSubscriber(mockCtrl) - configClientMock := client_mock.NewMockClient(mockCtrl) +func TestState_CommonFlags(t *testing.T) { + s := State{} + test.CheckProcessCommonFlags(t, &s, []string{process.HubURIFlag, process.ConfigAPIURIFlag, process.RedisURIFlag}) +} - urls := []string{"https://example.org", "https://pastebin.onionsearchengine.com"} +func TestState_CustomFlags(t *testing.T) { + s := State{} + test.CheckProcessCustomFlags(t, &s, nil) +} - for _, url := range urls { - msg := event.RawMessage{} - subscriberMock.EXPECT(). - Read(&msg, &event.FoundURLEvent{}). - SetArg(1, event.FoundURLEvent{URL: url}). - Return(nil) - - s := State{ - configClient: configClientMock, - } +func TestState_Initialize(t *testing.T) { + test.CheckInitialize(t, &State{}, func(p *process_mock.MockProviderMockRecorder) { + p.Cache("url") + p.ConfigClient([]string{client.AllowedMimeTypesKey, client.ForbiddenHostnamesKey, client.RefreshDelayKey}) + }) +} - if err := s.handleURLFoundEvent(subscriberMock, msg); !errors.Is(err, errNotOnionHostname) { - t.FailNow() - } +func TestState_Subscribers(t *testing.T) { + s := State{} + test.CheckProcessSubscribers(t, &s, []test.SubscriberDef{ + {Queue: "schedulingQueue", Exchange: "resource.new"}, + }) +} + +func TestNormalizeURL(t *testing.T) { + url, err := normalizeURL("https://this-is-sparta.de?url=url-query-param#fragment-23") + if err != nil { + t.FailNow() + } + + if url != "https://this-is-sparta.de?url=url-query-param" { + t.Fail() } } -func TestHandleMessageWrongProtocol(t *testing.T) { +func TestProcessURL_NotDotOnion(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() - subscriberMock := event_mock.NewMockSubscriber(mockCtrl) - configClientMock := client_mock.NewMockClient(mockCtrl) - - msg := event.RawMessage{} + urls := []string{"https://example.org", "https://pastebin.onionsearchengine.com"} - s := State{ - configClient: configClientMock, + for _, url := range urls { + state := State{} + if err := state.processURL(url, nil); !errors.Is(err, errNotOnionHostname) { + t.Fail() + } } +} - for _, protocol := range []string{"irc", "ftp"} { - subscriberMock.EXPECT(). - Read(&msg, &event.FoundURLEvent{}). - SetArg(1, event.FoundURLEvent{URL: fmt.Sprintf("%s://example.onion", protocol)}). - Return(nil) +func TestProcessURL_ProtocolForbidden(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + urls := []string{"ftp://example.onion", "irc://example.onion"} - if err := s.handleURLFoundEvent(subscriberMock, msg); !errors.Is(err, errProtocolNotAllowed) { - t.FailNow() + for _, url := range urls { + state := State{} + if err := state.processURL(url, nil); !errors.Is(err, errProtocolNotAllowed) { + t.Fail() } } } -func TestHandleMessageForbiddenExtensions(t *testing.T) { +func TestProcessURL_ExtensionForbidden(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() - subscriberMock := event_mock.NewMockSubscriber(mockCtrl) configClientMock := client_mock.NewMockClient(mockCtrl) urls := []string{"https://example.onion/image.PNG?id=12&test=2", "https://example.onion/favicon.ico"} for _, url := range urls { - msg := event.RawMessage{} - subscriberMock.EXPECT(). - Read(&msg, &event.FoundURLEvent{}). - SetArg(1, event.FoundURLEvent{URL: url}). - Return(nil) - - configClientMock.EXPECT().GetAllowedMimeTypes().Return([]client.MimeType{{Extensions: []string{"php", "html"}}}, nil) - - s := State{ - configClient: configClientMock, - } + configClientMock.EXPECT().GetAllowedMimeTypes().Return([]client.MimeType{{Extensions: []string{"html", "php"}}}, nil) - if err := s.handleURLFoundEvent(subscriberMock, msg); !errors.Is(err, errExtensionNotAllowed) { - t.FailNow() + state := State{configClient: configClientMock} + if err := state.processURL(url, nil); !errors.Is(err, errExtensionNotAllowed) { + t.Fail() } } } -func TestHandleMessageHostnameForbidden(t *testing.T) { +func TestProcessURL_HostnameForbidden(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() - subscriberMock := event_mock.NewMockSubscriber(mockCtrl) configClientMock := client_mock.NewMockClient(mockCtrl) - type test struct { + type testDef struct { url string forbiddenHostnames []client.ForbiddenHostname } - tests := []test{ + tests := []testDef{ { - url: "https://facebookcorewwwi.onion/image.png?id=12&test=2", + url: "https://facebookcorewwwi.onion/login.html?id=12&test=2", forbiddenHostnames: []client.ForbiddenHostname{{Hostname: "facebookcorewwwi.onion"}}, }, { @@ -121,56 +134,57 @@ func TestHandleMessageHostnameForbidden(t *testing.T) { }, } - for _, test := range tests { - msg := event.RawMessage{} - subscriberMock.EXPECT(). - Read(&msg, &event.FoundURLEvent{}). - SetArg(1, event.FoundURLEvent{URL: test.url}). - Return(nil) - - configClientMock.EXPECT().GetAllowedMimeTypes().Return([]client.MimeType{{Extensions: []string{"png", "php"}}}, nil) - configClientMock.EXPECT().GetForbiddenHostnames().Return(test.forbiddenHostnames, nil) + for _, tst := range tests { + configClientMock.EXPECT().GetAllowedMimeTypes().Return([]client.MimeType{{Extensions: []string{"html", "php"}}}, nil) + configClientMock.EXPECT().GetForbiddenHostnames().Return(tst.forbiddenHostnames, nil) - s := State{ - configClient: configClientMock, + state := State{configClient: configClientMock} + if err := state.processURL(tst.url, nil); !errors.Is(err, errHostnameNotAllowed) { + t.Fail() } + } +} - if err := s.handleURLFoundEvent(subscriberMock, msg); !errors.Is(err, errHostnameNotAllowed) { - t.Errorf("%s has not returned errHostnameNotAllowed", test.url) - } +func TestProcessURL_AlreadyScheduled(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + urlCacheMock := cache_mock.NewMockCache(mockCtrl) + configClientMock := client_mock.NewMockClient(mockCtrl) + + urlCacheMock.EXPECT().GetInt64("https://facebookcorewwi.onion/test.php?id=12").Return(int64(1), nil) + configClientMock.EXPECT().GetAllowedMimeTypes().Return([]client.MimeType{{Extensions: []string{"html", "php"}}}, nil) + configClientMock.EXPECT().GetForbiddenHostnames().Return([]client.ForbiddenHostname{}, nil) + + state := State{urlCache: urlCacheMock, configClient: configClientMock} + if err := state.processURL("https://facebookcorewwi.onion/test.php?id=12", nil); !errors.Is(err, errAlreadyScheduled) { + t.Fail() } } -func TestHandleMessage(t *testing.T) { +func TestHandleNewResourceEvent(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() - subscriberMock := event_mock.NewMockSubscriber(mockCtrl) + urlCacheMock := cache_mock.NewMockCache(mockCtrl) configClientMock := client_mock.NewMockClient(mockCtrl) + pubMock := event_mock.NewMockPublisher(mockCtrl) urls := []string{"https://example.onion/index.php", "http://google.onion/admin.secret/login.html", "https://example.onion", "https://www.facebookcorewwwi.onion/recover.now/initiate?ars=facebook_login"} - for _, u := range urls { - msg := event.RawMessage{} - subscriberMock.EXPECT(). - Read(&msg, &event.FoundURLEvent{}). - SetArg(1, event.FoundURLEvent{URL: u}). - Return(nil) - - subscriberMock.EXPECT(). - PublishEvent(&event.NewURLEvent{URL: u}). - Return(nil) - + for _, url := range urls { + urlCacheMock.EXPECT().GetInt64(url).Return(int64(0), cache.ErrNIL) configClientMock.EXPECT().GetAllowedMimeTypes().Return([]client.MimeType{{Extensions: []string{"html", "php"}}}, nil) configClientMock.EXPECT().GetForbiddenHostnames().Return([]client.ForbiddenHostname{}, nil) + configClientMock.EXPECT().GetRefreshDelay().Return(client.RefreshDelay{Delay: 10 * time.Hour}, nil) - s := State{ - configClient: configClientMock, - } + urlCacheMock.EXPECT().SetInt64(url, int64(1), time.Duration(10*time.Hour)).Return(nil) + pubMock.EXPECT().PublishEvent(&event.NewURLEvent{URL: url}).Return(nil) - if err := s.handleURLFoundEvent(subscriberMock, msg); err != nil { - t.FailNow() + state := State{urlCache: urlCacheMock, configClient: configClientMock} + if err := state.processURL(url, pubMock); err != nil { + t.Fail() } } } diff --git a/internal/test/process.go b/internal/test/process.go new file mode 100644 index 0000000..93ff18f --- /dev/null +++ b/internal/test/process.go @@ -0,0 +1,68 @@ +package test + +import ( + "github.com/creekorful/trandoshan/internal/process" + "github.com/creekorful/trandoshan/internal/process_mock" + "github.com/golang/mock/gomock" + "reflect" + "testing" +) + +// SubscriberDef is use to test subscriber definition +type SubscriberDef struct { + Queue string + Exchange string +} + +// CheckProcessCommonFlags check process defined common flags +func CheckProcessCommonFlags(t *testing.T, p process.Process, wantFlags []string) { + if !checkListEquals(p.CommonFlags(), wantFlags) { + t.Errorf("Differents flags: %v %v", p.CommonFlags(), wantFlags) + } +} + +// CheckProcessCustomFlags check process defined custom flags +func CheckProcessCustomFlags(t *testing.T, p process.Process, wantFlags []string) { + var names []string + for _, customFlag := range p.CustomFlags() { + names = append(names, customFlag.Names()[0]) + } + + if !checkListEquals(names, wantFlags) { + t.Errorf("Differents flags: %v %v", names, wantFlags) + } +} + +// CheckInitialize check process initialization phase +func CheckInitialize(t *testing.T, p process.Process, callback func(provider *process_mock.MockProviderMockRecorder)) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + providerMock := process_mock.NewMockProvider(mockCtrl) + callback(providerMock.EXPECT()) + + if err := p.Initialize(providerMock); err != nil { + t.Errorf("Error while Initializing process: %s", err) + } +} + +// CheckProcessSubscribers check process defined subscribers +func CheckProcessSubscribers(t *testing.T, p process.Process, subscribers []SubscriberDef) { + var defs []SubscriberDef + for _, sub := range p.Subscribers() { + defs = append(defs, SubscriberDef{ + Queue: sub.Queue, + Exchange: sub.Exchange, + }) + } + + if !reflect.DeepEqual(defs, subscribers) { + t.Errorf("Differents subscribers: %v %v", defs, subscribers) + } +} + +// TODO HTTPHandler + +func checkListEquals(a []string, b []string) bool { + return reflect.DeepEqual(a, b) +} diff --git a/internal/trandoshanctl/trandoshanctl.go b/internal/trandoshanctl/trandoshanctl.go deleted file mode 100644 index f14478a..0000000 --- a/internal/trandoshanctl/trandoshanctl.go +++ /dev/null @@ -1,119 +0,0 @@ -package trandoshanctl - -import ( - "fmt" - "github.com/creekorful/trandoshan/internal/indexer/client" - "github.com/creekorful/trandoshan/internal/logging" - "github.com/olekukonko/tablewriter" - "github.com/rs/zerolog/log" - "github.com/urfave/cli/v2" - "os" - "time" -) - -// GetApp returns the Trandoshan CLI app -func GetApp() *cli.App { - return &cli.App{ - Name: "trandoshanctl", - Version: "0.9.0", - Usage: "Trandoshan CLI", - Flags: []cli.Flag{ - logging.GetLogFlag(), - &cli.StringFlag{ - Name: "api-uri", - Usage: "URI to the API server", - Value: "http://localhost:15005", - Required: false, - }, - &cli.StringFlag{ - Name: "api-token", - Usage: "Token to use to authenticate against the API", - Required: true, - }, - }, - Commands: []*cli.Command{ - { - Name: "schedule", - Usage: "Schedule crawling for given URL", - Action: schedule, - ArgsUsage: "URL", - }, - { - Name: "search", - Usage: "Search for specific resources", - ArgsUsage: "keyword", - Action: search, - }, - }, - Before: before, - } -} - -func before(ctx *cli.Context) error { - logging.ConfigureLogger(ctx) - return nil -} - -func schedule(c *cli.Context) error { - if c.NArg() == 0 { - return fmt.Errorf("missing argument URL") - } - - url := c.Args().First() - - // Create the API client - apiClient := client.NewClient(c.String("api-uri"), c.String("api-token")) - - if err := apiClient.ScheduleURL(url); err != nil { - log.Err(err).Str("url", url).Msg("Unable to schedule crawling for URL") - return err - } - - log.Info().Str("url", url).Msg("Successfully schedule crawling") - - return nil -} - -func search(c *cli.Context) error { - keyword := c.Args().First() - - // Create the API client - apiClient := client.NewClient(c.String("api-uri"), c.String("api-token")) - - params := client.ResSearchParams{ - Keyword: keyword, - WithBody: false, - PageSize: 1, - PageNumber: 10, - } - res, count, err := apiClient.SearchResources(¶ms) - if err != nil { - log.Err(err).Str("keyword", keyword).Msg("Unable to search resources") - return err - } - - if len(res) == 0 { - fmt.Println("No resources crawled (yet).") - } - - table := tablewriter.NewWriter(os.Stdout) - table.SetHeader([]string{"Time", "URL", "Title"}) - - for _, v := range res { - table.Append([]string{v.Time.Format(time.RFC822), shortenURL(v.URL), v.Title}) - } - table.Render() - - fmt.Printf("Total: %d\n", count) - - return nil -} - -func shortenURL(url string) string { - if len(url) > 125 { - url := url[0:125] - return url + "..." - } - - return url -} diff --git a/scripts/blacklist-hostnames.py b/scripts/blacklist-hostnames.py new file mode 100755 index 0000000..f6ddc3a --- /dev/null +++ b/scripts/blacklist-hostnames.py @@ -0,0 +1,56 @@ +import json +import sys +from typing import List + +import requests + +# This script is used to import list of hostnames to 'blacklist' +# it will pull hostnames from the CT log source (see url variable) & custom define ones +# and blacklist them to prevent useless crawling + +url = "https://raw.githubusercontent.com/alecmuffett/real-world-onion-sites/master/ct-log.txt" +custom_hostnames = [ + 'gamebombfak3pwnh.onion', # gaming forum, lot of noise + 'metagerv65pwclop2rsfzg4jwowpavpwd6grhhlvdgsswvo6ii4akgyd.onion' # search engine, lot of noise +] +config_api_uri = sys.argv[1] + + +def add_if_not_exist(a: List[dict], b: str): + found = False + for i in a: + if i['hostname'] == b: + found = True + + if not found: + a.append({'hostname': b}) + + +# Get up-to-date list of real-world / legit .onion +r = requests.get(url) +new_hostnames = [] +for hostname in r.text.splitlines(): + new_hostnames.append({'hostname': hostname}) +print("pulled {} real world hostnames from ct-log.txt".format(len(new_hostnames))) + +# Append custom hostnames ignore list +for custom_hostname in custom_hostnames: + add_if_not_exist(new_hostnames, custom_hostname) +print("added {} custom hostnames".format(len(custom_hostnames))) + +# Query existing blacklisted hostnames from ConfigAPI +r = requests.get(config_api_uri + "/config/forbidden-hostnames") +forbidden_hostnames = r.json() +print("there is {} forbidden hostnames defined in ConfigAPI".format(len(forbidden_hostnames))) + +# Merge the lists while preventing duplicates +for forbidden_hostname in forbidden_hostnames: + add_if_not_exist(new_hostnames, forbidden_hostname['hostname']) +print("there is {} forbidden hostnames now".format(len(forbidden_hostnames))) + +# Update ConfigAPI +headers = {'Content-Type': 'application/json', 'Accept': 'application/json'} +r = requests.put(config_api_uri + "/config/forbidden-hostnames", json.dumps(forbidden_hostnames), headers=headers) + +if r.ok: + print("successfully updated forbidden hostnames")