From 78373d3549fd31ce5047fb94bed039a571970305 Mon Sep 17 00:00:00 2001 From: Bobby DeSimone Date: Mon, 8 Jun 2026 18:48:14 -0700 Subject: [PATCH 1/3] docs(guides): add servarr stack guide behind Pomerium front-door SSO Sonarr/Radarr/Prowlarr/SABnzbd behind Pomerium at the front-door authorization tier. Dual Zero/Core tabs, runnable docker-compose + config with byte-identical .yaml.md mirrors, request-flow diagram, and sealed E2E fixtures with seed config. AI-assisted (Claude Opus); human-reviewed and validated. --- .../img/servarr/servarr-gating-matrix.svg | 80 +++++ .../guides/img/servarr/servarr-sonarr.png | Bin 0 -> 35026 bytes content/docs/guides/servarr.mdx | 197 +++++++++++ content/examples/guides/servarr/config.yaml | 46 +++ .../examples/guides/servarr/config.yaml.md | 48 +++ .../guides/servarr/docker-compose.yaml | 76 ++++ .../guides/servarr/docker-compose.yaml.md | 78 +++++ .../guides/servarr/validate/assert.spec.ts | 175 +++++++++ .../servarr/validate/compose.validate.yaml | 187 ++++++++++ .../servarr/validate/routes.validate.yaml | 34 ++ .../servarr/validate/seed/prowlarr.config.xml | 13 + .../servarr/validate/seed/radarr.config.xml | 13 + .../guides/servarr/validate/seed/sabnzbd.ini | 331 ++++++++++++++++++ .../servarr/validate/seed/sonarr.config.xml | 13 + .../examples/guides/servarr/validate/url.txt | 1 + 15 files changed, 1292 insertions(+) create mode 100644 content/docs/guides/img/servarr/servarr-gating-matrix.svg create mode 100644 content/docs/guides/img/servarr/servarr-sonarr.png create mode 100644 content/docs/guides/servarr.mdx create mode 100644 content/examples/guides/servarr/config.yaml create mode 100644 content/examples/guides/servarr/config.yaml.md create mode 100644 content/examples/guides/servarr/docker-compose.yaml create mode 100644 content/examples/guides/servarr/docker-compose.yaml.md create mode 100644 content/examples/guides/servarr/validate/assert.spec.ts create mode 100644 content/examples/guides/servarr/validate/compose.validate.yaml create mode 100644 content/examples/guides/servarr/validate/routes.validate.yaml create mode 100644 content/examples/guides/servarr/validate/seed/prowlarr.config.xml create mode 100644 content/examples/guides/servarr/validate/seed/radarr.config.xml create mode 100644 content/examples/guides/servarr/validate/seed/sabnzbd.ini create mode 100644 content/examples/guides/servarr/validate/seed/sonarr.config.xml create mode 100644 content/examples/guides/servarr/validate/url.txt diff --git a/content/docs/guides/img/servarr/servarr-gating-matrix.svg b/content/docs/guides/img/servarr/servarr-gating-matrix.svg new file mode 100644 index 000000000..3926819d6 --- /dev/null +++ b/content/docs/guides/img/servarr/servarr-gating-matrix.svg @@ -0,0 +1,80 @@ + + Which access channels Pomerium gates for each Servarr app + A matrix with four rows (Sonarr, Radarr, Prowlarr, SABnzbd) and three + columns. The Web UI column is gated by Pomerium single sign-on for every app + (filled marker). The HTTP API column is reached with each app's own API key, not + Pomerium identity (open marker). The Native or mobile client column bypasses the + browser SSO flow entirely and must be tunneled separately (dash). Pomerium gates + the browser front door; each app's API key remains a separate credential. + + + + + + + Pomerium gates the web front door, not the app credential + Each Servarr app keeps its own API key; the *arr apps have no OIDC, JWT, or header identity. + + + WEB UI (browser) + HTTP API + NATIVE / MOBILE + + + + + + + Sonarr + TV . :8989 + + + + + + Radarr + Movies . :7878 + + + + + + Prowlarr + Indexers . :9696 + + + + + + SABnzbd + Usenet . :8080 + + + + + + + + gated by Pomerium SSO + + reached with the app's own API key + + bypasses browser SSO (tunnel separately) + diff --git a/content/docs/guides/img/servarr/servarr-sonarr.png b/content/docs/guides/img/servarr/servarr-sonarr.png new file mode 100644 index 0000000000000000000000000000000000000000..b240e25e9eb4ff4f279f3e8e89c80fb87d71c827 GIT binary patch literal 35026 zcmd42Wl-Ep)Hg_k5Zr>h1b2elCAf#6gFC_9H4tE6aCd@RfS`jzaCZqZgS)#kJGr?} zy|qX$f1e|x~UvVnhRQBwc^KhV+AY0`FX-)x}|(P@DzPO)>q zQx6|>k3{ySrB+VtC%ED!wrFMVdc@!_F$f6V+G$n(f*YtxI2R6sD$fQ*!VzfP8NrxGll~(icw4oh zJ9!aZs|!1+=}}4;^bZG0UAD3G4!=j;d=*NB_LnmVo%50JJI`!mejILyJsxk3u^pMM z*l+<10-ozj+F+l5Lb&lT|E?EUl_nRMe>Au22Rn?`omeUkIL)=BPkMEL-X}xuliSv& zmXS&Jv=tUFei8ZY_1`(GHg9l@*fOpt|E-o}Sywy2LZ6c|FGAecj<5!AhO>kI=*Ssz*_z536=!cWVs?d<)Cj8#>b;^8aJZ zDG!8cg45UH<|YNTiIjQ~3|k^FdE1Xc1^_hIiw=7XVGlh>^z_a4T{kk@&h z2Fm;|A(Gga|0?Q8s&>NJS`1Vp6%87Y%v&-yJC7Atqq~t+(lzpTUqSJ>w7G0h=Rt)ZXd2E5WT8w_`~`QaN|`QVCW%&P<$5b=~73aMHoD zZ$)rbhpReRwuKY!dFafhm`wFT)DWVYNJhVTXVD=!V_FTl0?fAC5s1|Aiv51A9sdbu zS z4GXV2tgp@_^JZJU@7$a8cXp*7(oc1(Ij*}ondW=?m;+wEnQbp5sMBs>hMmcrh!L=k zoVUcb{$?}~2t4%&;VSj$^mkNDw)SWdvpIa+s~K75m-FM`d!-w7#Op6-|5;N;rWORu zq&R+FmpDNnd!ILR`#QH#eZ!`9I0HGiW& zy9K^gSV|90vy)QjPQ+cs6?qfo#j#L4Jb#p{kDfn$Xq7(a5;#r!{E)Avme3lYjY|+Y zwpCZ1f0}r+6~2PY03dpPB-zqh96Yy{C0*lD8Pi% z7uoE>4~%;L{*gy*Pf0fve2W6-^i|q5&^hmZB%k?X`?(?(FH<{~7+~Y$mh-%iq3=T* zrgi%^o$ya4aW1ute|3+tHQZKQBQGB9UThFEwEL)OV>$w^w&b8uGY|5~%O_hE^A@Ll z)Jq#3kxtH+FahgC?aim|)_(+`KCFxY-?nJrZe$+XpJW7~N& zX(V_< z|MUY`h_||W%7e{MM@>iR>$&e3R^c{dih8|f@7(sZv*nBAx# zGs;~y7+PwjpFsz^N^dnh5?T}T({Mh7J5|0Io*1Rbr+9*7ay|1Fn1e>n?{MH@(XmRi zt(Fw(d(Di=QorMqe)k-b(ly@RO<(7@yr({eXi!FO$^u~gB=Q8F!=HIyP5<$YR)az zZhZ4rcn%8CGi7%l@WSthT$A`e{DiW@eoi)LTxm2)$i=T%Qw)B2&P|FLB7lt+ zYQDYgh=$n*A`Z6JS1pe9PNAp<654l1^YlWa!8es7jPk@~K^8SahUH&Iq7wAdlTNpj zdc~d!x{guQEfyBNPWoL}_79ON$7Ht>CQTTtMEp;T+p>1U^?6*K#+doGr4#bobT7`u z+W?mSja>V2v&>>!ElWbp!*0>Oai7FzIIYvzAl*5$qF$WZ7f9j2;Cg9nEK|nz* zhltWDoXe}XAwgsgZ46kaGORn#uZnN7_OhI9IoMD654XZ=3Q?^5s`g}Mav(<*zkk4* z^1et&Kh9ep9pz~9FF!kABmFC%SlN@k`>jgMR>T~omun}L?W$xs{8@-){tb+)Zzn(= zhgu{*rtjWWS)qS@*39>aVl;N!bu2V+sG}>Gdp_h0cHW!lZ_GU32wPY7-5K?7Q-aNM3$VaDhHmn2r)_KI;yMo`^T9$KQ?#V8!^wj;-m-F?b)V2`y!RkMp%dl4z@ zIqQ^vpbi2;>ihlMp8l#1Df;l^=wuQ1?gpzrXY8^=w&$DR(Y3ey{2%zI_g~QESPAi9c%u0zQ}` zb6^+NX3eAg0PFeOy@Njds%&eC`r)1Yr6}k>%z4TK|4bN>@F7#m#M(7Wl0u^`4E!8z z?$WeUW*4AudLq5T{+FVW^t8hl8b!N+Lq)>B=CxKGXk5 zKjmRY{;Bn@n^7(vZy+DPH)2D|w<(Z{8uC4V$wUa^P&%15EOccNW|J&R_IFC>JPTdt zf3w$UP7@;j>yN$q-wAX7Ux4$nOF-LoMy&KpY}yVZrN2x?2m}U`8~vs3|04$PL^mNv zK;U8uW=c;RS;kCmq1+5NPO1jAh9Hmb8`xC|%v49xC7>h`Ib&E(1p~8`wGx`O90*gh z8tIUbIV3B86RIi~QL{-4Ti>rs6&&&KHGFwMf2GIv(3OZh$!gzP}ZqE9dN^VjpDwCFEPu?Q01bYiJ> z(=&vj z_V(!|%eQX)xdnSu(J4F2IIKp$o}7c-m@X6tpOG#p6bNk{F*Cshw6*s^L*q z+Ap+~H!B|(J6{Y=)@txBez!?qOgDJzOXvD^96K)7<5z@h?&@-Svj};<)lWl*t&2vk zQpd)F_-^AJcEv<#)N&Q~Z*0&>UpUs|82M_mFDM_urTs$=t?TC8Q@H*rs}^^rfa*mk z5SIpbYl{{uu6wVn%P@LO_2(Xy2;Gb=kGqvClmqVOA?-=UzN$ zJMBtz>ubLq)Od-=Ny@9+X(hGxbyxHT;s$0tR>5A-fp zq+>p`OySvjT0W~9haV!d3lzLkQnRP*kmc8zE`)4g+c`6*@e);nMUIhx|a& zX2KrW$g}0`@BChC=-}of%$rf$Uflwy7}iZqjb|{g)&>#0N4Z^IL+!-+^zq|zn`P}v zLfQ8H^!_KAcP4jUfO%hsHpp7XqweQCJzAU*uTKaF9O<+XISGZQ&qt$MCT4kSjCyQK zLVlhRwEK@8zJqH&weZd;4ZPF6m?z$IXwyGnQSOMbPbR1V^%Rh8)YV#Uab zU*1+&!6*3@lZgG{3I$BsSdni?hgU>57!;u3Q~azL=Fc0Pp1GhDD5BSgn{Q;pQ+v;d z@Cz4(@Z_m3OQi42`T%c}zJEy4lrHQCQez`W)Hua`nV|l0rM>BJnnA*Xg-2tViaTNC zD=z_L7JOzQ3HydcK|z{k5^mXZ{)INNCF*b-Y}Kc|IWgE(&YFv%e2Jq#COSbCnj2gX zu>#ep6Xm57JtC6ArL(|q5fzn-OvWToiB3GX*8+$YgQF~K4XJV7rUtc|#q@8Gv1#cS z18SLUZfEt(eLJ9Ke4#Zi&I?mBXElcG7-9PAKaEa)sTn&rIDag}26=K$Ra*}GUy}Tw zS6@p)Q*I8uDrn?&aD$yH?-Y@UP|IP}z8Vos8MgD_huDvo_8p&XpFO0iDeUgE3i(;< zISCxjm@O=#)__n#qm$tFS*Bd)a{(@F8sz*e#)J7QMOfK=MV}ADvcutVi$)!rCJ!pw z4G-I%23Ok5!9pMO7sJROHsFT1aqX!ylO9I?l#}v&YEbeDo~t~i(V^Ck70%;3cU6mV z$R61G84m0<*iZ_M_JoH_LZE2X1$`qP~uci4_ z8pq&L*r+q+X^<^Gw)sxW=UVM8!)lutEa`KYf^e#6;2i6h=BJS5ab zFOE&dicKy8wP^U=Tu*oML}<&)9ALbjo6nky!b7Upuoe^|uxIG&)|k6D@>^@e#Qf&b z7W6@INa*}-Jm87G#;}UqE(dBvN8R>oW)=d^^eWPUdTnRF)X~+>nh^b5AoGRZ8A>1` zP>xvnj4D+Ko~+z~aPsqClj(}Q^2{k0ZlOIwE1CDvY+G(=na9g-*Vzsv?t^9)gJHwg-Jye6(%A+xw zRI7?@EORxjlP%?8z^VSk?zgdtJMOB>`>xxUNn}5>d=8w%oZ70i2+F}2u&A^8YHd3C_o-L z#@d~X$8T(ST&_Pi8NW?Ik&b16M2OGDRs9we87?!;dQ64%U9pUz@1KX2$6d@@#y)j1 zP*ZKRX;|ELAhYRV#|4gWy;;!Pk&hXBJetHmK_3wgRntrDv#15`s;kA^SUMPMzJ^?L z&;63<4+k1Iigi4NB9`vF;TbGY^;o&;mOW?d@ck1)TUn+)zVp6(N8hm7+V{%ebWo3wwezoi?*5v zg5C9E7GoVaq?MMly_K1Thcz+3AxAGP8uqn$!z~AKR6JORr6iTFvn@lNu~kETo-90c zaGLG`=!VrDmF8y=`b+FZq3$Zl@%t;={xptHuFOXPq0_sU+}v^WN1t!fO;jRdS+ahV zK@QWe9c?u^@_VSE=^B81E@-r}pIa78_aeXNk%zzx@Z;Jcrs11L&8E8#Q~CH0y<~g> z=IG?NQ);HVi{5Qdo$$aSzsDC)$5FRM`f;~A;0PDxt~`R^!fQm|<8if6taSwfVU7cy zwAjFnz2T}@H|{CAaBoKRp=$+hO)e_XY3StBIqY!LSzuhuEoWc-A_sb3EX&||9o`~A zMuUJ5OU-6I6T6)#X(hi7+^Lh1L53go+ro7dUu@^-`ebVT$hC-Av3KuT?2exDrDa+m z+0n{v;MmlYOQ^rX+m?Xvk^+7Se6sJM!{0}%VW0AaeuRqSVypU@#?Z=hlqp&Jj9x{p zJ$#ABa9LDp(^V15=%W)n zP`{sWXBP`%MsGau4mV{R6&c*G25{-~(GB5J8(aGcEXlVidF^WAE18nza}z8g!=#K8 zC(F=yW2sxit@a$Fat$ie@+XS4IC0rWm#+O~Mnzpb4Zk5GM8l^hCaqgIe9no5JAJH= z7UdA|HaE6;QLmRwA5Z;$XwLbnbBoOZk43pY%X;tdxVepTak)iibgTfb;t87&i%(#i za`rwkPvP<9E~D{#GXMpsm)dxBae-x2E0ZitByD(EKmn4WPzqysNPyS^N`;EDp5z3E z<;}{BwAekiw!N*92CYoUprhoop$Si;dKEP5-e%$1Uqs5CF#FTRi_Sg7pSwM6O(XN+WBnP)Yvyz7d-ZX0gglQO~$!YhOk)Hl=r#MpmnwYQE}WH7qF zoP$2+VvJnQ5R!M35xUlSV2HT$7?AUnbzeMA|1L}DmqL$m*X)rpw;agOaVt*u{-7V) zs9n8aa=?eysArE6*5)f$=HMZFR9YFEvrSfh#K0)O!@03-&IW$?9Ujeb_w z96h`beHw=xCo#jFKV4?z(u#zpH#gh)IR8qxeo5+#4@vOi(!c|zOOfih3jLuWx# zToQRoIcal>#*&SwcdOlPVKke>bDktG2#-DH+#P1^!&*AX6&KsT_LD|O<>+XoEJjBFQkOObQ&ydVk#MO$Jl*=rK&5_ zv3|2|+Ag3HsIRQKCGI{u|ASIlLIebEksmz-!+u>C z)z3-0rwroJm@bTN+8UjB=D1Z>rY7_lQ=fu>UYD!+#_k9oJ#7TXESnCHt04t~N0Os{P zIC5Rv4`EAbF?5ImVGoaKj)T$AD&FSw*JnQonY2CpBF})dyI-B}FZOjD{|-6ycxk!V zVtC)}qrB4u0>(3NpVOB9Pc6Wt1m?2N%EbLVSC+KdCoH--v6uFRwW;VQOxKZeySwCE{t+B)FB1G$@Brt8eIq`M-d z)$BH&8=`1r*Oid50chrZOFCwGp;2EW*sNWoyLa}g7m=wFg&(neQ&`Jx!St+v_23LYLPJPh~_!3 z#KgDh5kq=I`cC5I^Mr?&Ct-~203|&27vQUY9TPJIhu6YJ9{g~0MNM3n`-Fh-4Lf6W zaK}Y0!K_q{Yh~|GP&AuuVxGs$yZNAue8;dNVASxtU0|veZZN{`UuJON4o(WSe=fzj zX}LI~9!^j0TNgc3msPJgc=&?6JX1#U@Xz*7o)+Yv{ToDr05E#ZP>0Dpd6r(bRn?B~ zEvCnw&dm)wooJkDf0=iV+N_jhCIseK(J^zC&kTH1(vqxSWID-@nd@ACpSCd5yFlYw zwXmT(xnsr%zx0Ycnz;fHr~BNa8>`9! z=56HU3BC1N+&b-v?oI_AQrJPg>zt%5-8_!Gdt1(v03>lIL47?odLizsXx%D18w%pn zacWIt@n~6EZPnCai)vil^;SrSGW{|B3xu~()2t#2E(yiA&>~<7+PWh=2vlQU??J6y zFMQr-YSFV-0$1lYSx(?bi&?e{oca#J)4DuYgo>}=Mg)B**I)C)D;n zhj?AZSc&uU%sOHC4%Ot1yh9sg*T&t)8pB3OeH|i+(@Sug_Dei$()M7ExO@NSfJH4A zeoxIFvwiHahjC%%3`G>S9ix_ciT3aRS5sPA z|nZqf8-Y zo}I&zBy33i)};ZUA*bCtE@u^+B{{C=gePFP7mWh41TE_0m%MarP)2ci>8$>IQ~BR- zUL@1n854tRw`F6JzMTnxlFiWJasSbJ>SP*$85(KTUV2IrP4LccrRa>3asA#SohwPOEP6LH{xazBgOx^i zF76ocXpOYR?~Ax}UE$giF1~o4vvFQsIb$C9Pi4I^uhh%lS)op zRzhuL)ycHm3~w5Z_XT|A&N~ET4ulac(s1EL$cn?>f&Ko0ebhLs9LTPmr`qb1%er>( zcw6UsF08h%l~#MTmOrJN$D=wdzj`TbVD3~uiGhwzC%f^BlA1A{2D#-iT* zqS)%Tj120)p8NE7s3M}(pnkF7C~bI5Vf*o#@7TD4H#f7GKxJF*I4dAFXVgjrYT}4n z4X^TAp#GxZ4VN!I;CTJ^dHPBOqM58%x#|*58rR|n*Vwl-3d5s{vB%Q(AlgR@<&=>UfPEPU9x2IlYVujx+8_LT7{1m(cj{=|z0jKxWT)bMD+go1X zQ(@mZ`1MGjaRP(GaE!_-rr=%p=VaqDeG~VJ{ZlKE>RV0#=-lPqy-r1^q0&Y;D%rR^ z(WY1Tq~HEKJ|UZhGCgvE3OYM|iK?)HnZ=3pd=>^}%NC^jMdcf;v$1yLLxrNbH>1?% zXZsr_YjJ!+RT)*bNe^x0CR94mtLnmp^{cgPPNgbH0gNjgv< z1U6ABvJHeE+mw{@@FO}ADd4j@&&{~qi}0KYI8>L|M34EEtaZ-Ln-9n)TnM!iafs1n zJJ=q$0rW16f)%$|*j&d=2nC%)EJyIO`NqTld5N||Q(E(dYFrT~Ezs7l;>ryYOs?!| zVW3==c<>?Z(3T6g`ddVZfYlieIXdwoiE_PTt#l$d>w7#0Hu44zpsjfP)CJd^)DKqo zX3X36N&o)WGQ$h2r@0T;hP$_iEl;gO49P5@0|A4TBT6UlUpPfq@3zcbuI$5w(``;{ zL^PJHt|FQIe&oo+$WjcNAM@)^bGn;6Ijw}_yD4q)v&Rkl79HQFVXH+d^+)rL8jKkC zO?durzNIh)#}w*nKt$Hju^aw~K9yGBYlYBk9vFKvu(Cr_; zJA!;494=pBps9NGA~%18J+HQ+ z>`_*#e0J%u2~BrJ^IIskTuo5*zDu2IGIbiE6x;;^Na5_PA5IxZ#ev!i2 zDw2<$0@L%Rry^>^e$q11SyF_|#@I9o7mQ52=~AR=iJaG=lv$TP(K{ZP&XBfxnP{H6 zhm}2JDj@D&ZvES*Pg0i%-Couc-zgluWCMyIH7uGVI}vj8W%Z4~NIwh9ch~~9*Snd% z=GlS6bTx~OlA^D89}~NIGJOP?IOiZ5e6r-aDOB7_W3C=5g-A)o1G-g|)XwQb2m5sx zFrFy=XL^i_o3hIO62)lqNK%0oSY2A9?R3TFRJomvKiAb|e=` z$!)~p*J)!&_Pjo~i|;c;_Q64+eFBX#Dwe(G7E-#zOv=P|L3Me{st#vb7#_FvyWc$H zzG>-|T|Vo+>pwY8|F{g*W#q3pTvr4`1PqimMB{S87i)=ru7JPjKP|S8Y}T3awp1;A zq|M@p1Ty;x$AIud-!+gRbn2a=EgrrMk9CE{75-r0H06tGD|Qaz%*;mS#-*l~S-I0( zTbY53k3~mq3@!MLhGJLU3gu59WFc3Ff3!DLhykQh#&}3ATt-p-e*H{gfjvTw7Or}O z0HRNyAGgT4b#=ARQV7IDwxWu9Z6rU^_d;A=UeD0wgiBFl5i{!gM_!(TvU^R9GE4r_!`qo7Kt!$g};~ zw14^uVzrRBUgv47wW7Y^vA3i{hB(x7ysr%INbmDUCBr1y(TK?%js;+ySc@EksaL+| zO!Z$}7CW48;nMt6E>hW&22Wd0)P1N|e6BosyKIkuKxMqY@4jPce$7|6e-!jPlBKjz zNhA>=Mi@+G(wDqNF+*Y{r}J^Xl)Qz5QE7iyXoGUFL}itsPxCfs$t}}so19F@!ML@& z+s=!VKz2eKW#}6Ur;c)=^i$e`ve8X&wpA5r#PVz#7 zIrjtqa>ry~Jm^L*7|LT&7_CO8K=L&(HAw_bvy@rGVw6(tdYR9e z%G6!XlVBk)O#uAmzVAJfZBU|JJY3Kb7sQWJuN*}g*P)V{b_ zwm4QsMhFUJnn2jeC^^i-YYNKVJ`F%ys=H&)A=F#@Rb889sloYVd}bb@4gK!qsEFrE zy;AYvtcXxw=>v_>sqnmzmu2YdQN)OiWJ{xM4??1}Tf`4hpUn5dxGF*|8ET>zBYi@JsoT z#sN|Ai&XJH;b-XRf)PDz3pbcqc(;nBfR$E*bH~Ibs%)9C1f@CWWiBp@urNsDB^;o1 z-detuAbg3AaDk`$E+;7in`RTM+INUPol%q((DhSW(e>lU4;!#HQ_BqoXH@+DW_Uel zgFUKlr&tv`W^`GQ=zfaP=Zr3kx>#WV)#lIC-QOh_-sSLUzw-4C5!-@+rX z1uyA@*y-{qGa%ZNGXbIyF?2Y`cJMCYNw>}qKRvr{BQt$DW9;~>h>2sL;E9lb2qpdi zqv_S^FDkQNR6+#*prk;`?{D${uo_;>^%@R0{%u&;vG7Iyw0|V|S8$B>-rqbP&1RtV z3E#Q2!e3wfX;@U-0Bjbzn=p3ax{nvd#n~cX(ywc#93CL-bAS%-^=e3z1m@!=lf(TM z4Px2K<2lV0+H2Ge?-PgdFu}j|`au4Ra-`G@aKk^=mgjSovEvgsUV8)(cRLipP&kX= zZOrF_=>|>W{mM^RNLcp$XimG6 ztZA49g~L5V^s$J;iE6Q3>(Tiw_H^1M32af!0WmGwq1<9nIy&!Vj@ z5Rjq0k>RVPKyQ;CwLIHUY{RTN&BNa^uD7Na^e>cks@W=bI@>X*xzx$aEKufloWp^G z`l<|ekgIEGdV{FaQ{2r>{bD#>s%Ub)9n7$OSE0jw*doVxR`EFfN{+1Mxei=ZtD*+G zLV9RU0BO?Zl$7O1uIsVA$9p%d;WLbcqToyz!K&V8&7{%XwD8}n7moKA7H?he-xHVt zak&h<&6gG!lhG0La5XT}BsX_wqmTT^9GWSVulj{<-Yxq)U8BPCh|+r)Ow3y&?L;YL z?lBm7G-q7E_-3^91PtldVlC|Xr`i@tLHoiYkpyIN0QEah zI{%uByr2c!zSiiSD(m)6NRs;`8!mm-8O;Y_54-OMW8%TQFEjc}DW&~_up0v^iQ`O96ei{bN(btn#5>bApsC2@w8m*mCx2Dtk zPm#YZx5@^F^pYe;q?a-I_Hzpnw`-s?%vZ{~mq5Mjw6ZSq427?DxxrObO8paU?HfuI zDQZ_8LYQduH5ju(v&1o zq2tI{`dIa%Jf#0#G8r}hFP{KMNPoqG{Q?fT z|84)z&g1{UfgBXSCCK2%0jKYQgw6d@favkBJ3{Ue-nxhj4ES5Ew;t&tF}5rIOA29E zwb#hWeQg20`|V2&D|JWLF#lqwIZ?XU{D9lRxeVBYpWDF8K&<1CeD)`^gDMXDL zU_SWB!iZb~=_l@wBhO%RI#l zxN`C#x~&rMG<3vg*9I>Ki+#OK(9MBHV?t<=oLnV#I^^O&@d<#3XfvPmj=8Y*l9PLH zS3zvS`=aW(Fzv>8WHsX3Aa5Fe=vs*2x4@ueye|BAuy-Y3l)WDnH?&QEG8xGHPPS^Mjs$`k~Isdy?U3LUEtEWf)<`ITJ4xxD@39&0a3PE6HmvUW)R;>tGVfYW7Ou@ zC8X-jGTjDG()XL7w)-oe9I-E~jSn%Hi5MLn#{GUBcU_+xA5H@5m3bk&59c6M6g}I4fxmLC6P@| ze)|<(mHmTX)A@F>!*~6Y0E7HFfLyv}<8|MXJ&V~45;VX=ODcctAH|xQ%l!-ogB*YN zxPN8fb$?x4RqcQ4pC+I0!f}4trNp<=z-TFnno3zF$IDvD~zfod_e~p%<8OdKsXXN39wYaTU zaUi-VW^Agctg2jM&qR|4lq;{L@(u~x;$gkaOl7t|e|}$Dj^f6F#n=FO4E_{`wWoI! zCMJ*<_oL~cUyiQhBZT_MfRNGcm^SB~YCtw4@^^%laeAVHz zdt+G74Pcg!a8XNZPuPM%4RZ}$*H92+fF%zDl@3ejNBtPHB$DUfdNv+W>)1H&-6*JyRRZN)4m-iba?ptPsHUa0Qf}w6WVQQ;x-4!EBF~2%`{;_^ zYh@n`N4&jPjvW@s?7?P}p~K11Foi)`69N`JmML;Ak^)4n=iNn+CW}t$Ap|#JSuMF4 zOdfEJn}cwD^6UXxSi{=1{jJA<`ZJ6EEzQ97PfGjD_rGUh-urQUnC_o7Cl~Xk9?hmQ zghql^(-URiwf(o+B9PU)G+9q*opb=Z^r4kxh#y0 z&#De}1xXFIwoA_r(j-9y2=qBFl}@d);BW3^sCWd9HCyN`+;@>c#+HC*#Z; ziC2<5X??twO1&^A=MlQhpJ&7Pg&EO@Riz>{+HPlgU#JzXGT42W*T-7!w7{F7r@H3B zfH)aessV}hZ!!gxF|2F1y4;0*+m_%rk0?l@MM~$z^r!UE?>uEx8a3#B6fshd} zwivNX_l4-1Q5C~1XLlkDX|E+0Sd&w0_HZC-8vh%^*3B-r!M*5nvMoWYU(HvRRqQI> z75&EbbSu|03wTY|CmWcp6@rf6ugXZcfwFB6><@xuawOzlmvg=+{nnVm7rP`pp$Nl? zj0*G2a_V=-UwN&bo1+fGT=E9{4$=Qs10sr`%%1yoW!XRKSmG%p-NJxdOwx31*3M(^CJ9XG=`YNMNaQE#0u8`eAd$rIU&`)DaSbqJ4q%X z++tngyFZf4?jl&I>I~~aj+PY8o zD2n$)2k{%q@ryS=+x4y^aI!qvKDz>Re5YG?JPlgms6l6LbN#W5ib7nKwr_ z-2n^zz@Qe?Vv9?0frwy2VV!d?v9a9}Q%XsvrVe;9U{kRskUrtiMQe3Y6~fq*W&UFa z6)d>4LyZ*OEBa{+4{E6+9zP1iDd%7L_pG6yb0TWl4N{1z-c$JldkS7uHITN=dGw8sWzn*-C>P7w zE}bnMC5{eU%dzQr*dIDO8|P*M6G`~ctD^a<{uzvwC?|1Zt72GOZ3zJDCrL;5MG*U) zsv=JRmQD*U;u_tco$&9h|DRd_Z1#ZRii6F$^eiFKrMo$(GTl?NP$eO~U)Hm@8^iWL z|H>r+g%K^l#-gAa5pJTwMM~G-2{y$|6Or9rJ1YcBO&1w1DmDHY_>M$KITa3aSTgv0 z^KdTW;H!SW8%0Ee@qq7^5zW9T=Fr2>VqlOPKV&l#r87k2CX$c>&Q&+?S_SR+&jOk& z3raZH*(Kr(w0EBhYsxjUB%^DJ?U_i(z8Tn13i{}DW-y?mwZ#&|oRcyEJv&By);V&KW$C=Ush4MH4QYexTX;JhNaZ%ftQqgt zi-xSr>l!c1KH4l8Yc!>wyBsoUkc3B|Bz8NbUN&6Wfz>cvrexwNIjBQ{*x|loOkQXb zk{BSlmLdUpq|@ng@}Q^=M{oa*p1(>;TFG zCj<=PX@`3mgI4v22N^@LH8eK{gp26L+qorqb#@H&Z`Xdk#?}15o*GpGwq9yZ2{RsT zpma_{1Zu$bZoUA;(XyJswYbs(QjhF33a>-cRr*JwxLIo_tU*+^6^gtFG!2h{|WzC~-iRhY_b{H;LWGZdl=k8*eyhglyy=x}c}e`6@@&xQq&Oc5TX~ zSe#}RzOZ^Ow}~90OD08Q_JS(ilm?sPea9$sHv=~*l5n_lir6U{jSNoF`eDXITuyRS zm=|FkTxVq7pj|p{I&{D^lW3~J!%t|g3jMS%Xu9NHLl;1sCITI=6SNR0o8V0{>gZTs zFV`9#zS$+*3RNP))mu$H7U%gMC{rL8@-DQ4hkNGtOW%-JI@;$x|IINRa(rFMJ#roA+MItDRS zWIbugfmNf;^%gWQ#?lV86r{oDhD$nW;KsYUx6A`8yx_TtYS@_s906d%g~w-QiFPlZMCc^6Qa_v z!wIQdOqUTX%q+2${0(<8kC&&VhcYPqO{Nzx8wj7JOe-6Z@E!4rY6w2d%TIe8|6s0~ z6ogw#-g-TvNqPn^H@TC)s$i-5tR1>ylyah0WXlp-h~ z(q2J8ML;?jiVBEQRho39_YR>Ypr~|s5$Poe2+~nXfDi)GdkKUZLhqpyN`Rce_kH{9 zefBx$|IhjNIcu-=f6rnqc#>hBd1mgp=f38;W@h)fS5)oSRq9Chg)xo1{#@{&6 z&%?7)6JVLb*Pkl#FB49OIS;2D*y*LU;qnFiHR6FEVxZ`!;;s#(VDs;;z#2(;eBla? zGbK@!QD#-Eccbkf-FdoX-$eU1$*e^r&bA8pubL~cw-wDY5l_aD801yWkdOL2g7r@R zVM7B|OHr5<4?XYZPBH1&+S-9xV5zZAG4B`NyqFqur_CRuyJJfshB(FMxbMs*$kmLt zO#^-&!bf+Jv*$LfK8L^7f(ZTmVjaSfqWiX8k zNeY$_g(zO}?Z_$Q=Pz8g+(EE&&wdzIc@92ocz>InhjBh#7L{J(1k{gnf|mcte(m(9 ziiT#!{*Py4+8+pKt8~N#%_ za?3q?Pq}{>-#8I=%JCg}@TcdZ@1=FA?NwPiIe4kikQ*S4vbhB|rOV~zDNz9Kg};$- zd?gZ1^45hRfr2<1PKdVM_nhI>R^6a^7JKhapMzphXWyxW6dQ>kSbN?2nPx>XiW5<( zCwfwgs9Yb`^B%bZ{czdfMbNDe^ghLLC`LsC9n6-^TWzSGCk295k!212JuZ7OJEpg| z2H#+UZG6at&@CK}@yKnoxW!f2xRm4b#XtN_xC*M{b-90@Cct05P#V^3=8YbG^!yWx zt7)Y12kkptB4WDKBbF!#VZ(XY2l{A6f~xxJ`-2ah;lpb?w+aiMEnH_T;+Fx^y3mey zSAU7Px{xF1^Qvi{(chotrS97=y%q5|C(pagH))w2l6R{18Ex|jP@7*k4+=YnI=*a0 z#hlW^tz8N89bM-3+#5QNSnpJ*^Mr1eo-I!R9~2D^roxJ*T-Wx;BAu~@oMOkavuXE4ov*6zTf{ml<1rinC~mf&5;tKUh(q;QCS;cmhhTES1IE4+lX3IcuE0+gxpp zx^}OhevaH*EjYjyzeKLC?$=O7X2Z6pl}k@fKQ;n2eM2h#bWwCvj0{-WpC~mU&A@V7 zD1(P7T>`bgv9ff>3Z>{8>vQpDrDDh28i62WEnmOr8$j;H`V(8MZtf^)6|T5uoiYA5 z`ReQ!nJX)-54v<7s~~rxSx<*O+mRayvz_wVCo3>IT_)xSO0-e6wRNox_gq*W|D5n# z>WSB^Gu%y7CK7C4x_xdh(9zKoTgh=4QJ;l;cyf*EI%#Yu@p51JydL8%h6|X7MYX8> z%r5_CFl?7T^eAdxeL_%vYn>S?Pk3VS?se@@!Rui$d-f%!Z}NwOh|Pj)8^@?pmD9}_7`YJ>6(YK(H2_$e(ewWaBg+@52nAeygETT4?-$@iu=H}9{tv> z@g_-p7?1vjdl8?NM?0>n4CenFxYM(Hzg}d)X^nqc1DcGN_F!{@5*OdM4sv~utngY` zh@#*5Ig25Yg>*yf3j${!!Xf3b#KfX*}OEu2UF3Y8-0jv`TPrNI*01q2C&L zjMzKDJ&Xy62^T|@lb5KZ2RF)VSHqz)>S6hpn~&Zxzh%c$7@1vp^W5?kMUp5vR{oK~ zm2FDy!F@kDzrE=+kMy+wYqs~W5$Tcd2T0%Eq8hrPc-f!DYKoEI-wH7<8vfZ8Mb9K# zdIN3!e9k`GnEpf?FmM`)fzNWXl;x$k^2GTCt_fv`3qdNZK(*=*si| zcpAE?)A2zk34zrTN^9(a&ye1lq%2|awbt)FN*hfiu&Wl$Yrf9PQBA1)Zs=stJS5%0 z=YaZc^H7D|Niz@^wk{%IzOz1mC1QSzuj(e$BywL92*MI4FA)U}1TkIQZCe#C)*qb- z4sAiH%<>LnwQgN`zChTDxkLz0cM^DHeulk>;kr-L@1-8j`f?{dh_zp|f?7gB~@9T->$}*HjL#I<^K=}4J2H0b`znkSqfABfSo#g4Vmf{>0yxbJT84xK&-GNbTkzQ zVt^^pB0lSyZ+7&YyZibofWHHMGQ>f;Pj-mZ0%uL6;j2*6^DJc8nI&&!y)P@B<3pQ3 zfDyVFo1&i+g^hII7`Wi z=BGs$5xd+cqJ84tqNSUqCx&Z!cQ)$>FfE=%*)PlL*~v5K-&W3YhuYIj8Jsln+ptXJJE4Cmk$cN*Pwf50S?ZIyiEimaIdSoYq`2e< zEJzl1-Oz*%jN2T71Ob6N13--NCD%{DEz&52u}e8V=@J#fu2Sur$`Bju9S$euWn^Pl z9`t>Kj6}#2EyjMd>76)+<~6YjzMSf;Q7yleS69}J%I;>L$##Ev7=5`xv|fR`!D+dw zsghyNWhYkD89KmPvDHB*cb(a4#X>ARbjYU4zurq3g5l0KSMV@u;$GXSx|wdXGvt-{ zA{b&{h|sy=H8Eolc2$C4`yQ+o-NcyppkhRO4bu2N<*RAC>Xj|y`ECMNTZz(?TyHQE zHUintgGIERS}sand-k}i>O1oqLqdP!3774{Lrg+OsUa^`EvK<&`*qEq0m_$LYXT(6 z%dA&8@6k}Q*%VW;N%%tS)%HJEeA53e5fW**_eO=lUL6iHz{T)P$~=wvMPeP-J1akl ztG>zbyy^HQ?mmX?W|6oy8e;R@m&`~8t^jN9Iqc|RSKA+8T;(rDr|qR5 z`BF(z(+IENXG?z-Nl+yR#PA7?mk&es6Oon)H%a(L%}qY__Aq8)y0b-j;rBK>Rf82O z`%+m)pE<1*<8?2>Hp-e(49@}~%+Exxzd;b^=Y2eeH@bI3N8(1rda(C~xy}~JzEN@W zJ-2@|diH(q3&!6uf=_d>5azNHPFZnGUE^!1rsMpbNnBid%BYgNRK;^*L$->rakkRj zxexEUYd)K1_}i(5^_sCedS_k(V^u*NdG%+)STyjD_Ml52Tzs}xSY`aFhV_FwIInaLiYEy#wA1sF-l-UB$0 zJ40L-dh#SGFuHM^`u<1!1z_<0Py-vr7I z<^`g!1}HEW86z`+g5AmsSzDwwSQt>?ZCs^Ipt_b2St+;gnHpa&*vlg7`2JG?Me@!u#hm%vAFTXW^&ZfYl2+Ud7$Z*pxd;1?}5b$gYoDFxl$faTK7HQc%>H2^ExBu_Yt%Z{wK+xpaEw}33);^fCkWTVvD-VhTR^X!9_^e$KMMiy_K1ekU+}V0qJP#?96gwVFI%N zc+$7nXqf4LAVCurc3bwrS;3Rtq=BZ8X2tV+-_!m=0%go?VaFG0+C&H#epX6=X_l+ z`)mtsMubzlocgWeU}}id1g((Itu5415z?Uwv?` zToPI11nu$AAqVu6vw4V#ZWP{h5z2R(wgv&JwK>?x3y^ajP(?gWjJ!vL?9jTNkM7;F<)Az+N+A3ygThvCOD?kHM?rv_)& z;%F#e1Ei|uCMDgA_+A?NQJGrGf2n3AI>m2$SCSB#cKb5zA2FPcCBGK+#BFEdI0OC^ z>>O;fy-AVOo z_It@$nUV2FIP&IN1=YH2%(Y<-knL%350}luvYY4*9ROfix-QVue>!J;?waV{Mu1P- zbevzJ@3RIwLU?(!Y&o(9fi@Wkjxx~EY7E<5IsR}PlnF}^>l9nGSIB&7uen@v6iuVF zvFi`7om~Y0=yuly`e~xnHxbCwS1lu4I|p^-#V}bIW{e8)@Dvf?q&@ii{?D0&%>MR6 z7I_5EWR;|B-cyuVV0LII()Y>a=*kUlap4SzedClGVH&u2_jxi{@!D+>9=o%Tq0tGN zgV#KE*U58xlW#FI-MlSpzW6Z|-TN3p+PZ1^#YvPNLbwBl;H(#j*CjLsmgsNw1PU=% zr9zab-^#Q!^P#7cWwmCjbr8xQZZIenB#(|wq97@HT%husn+F&gvl7Tv0*N*s#CrxRv#Vv zih+Q#mG!oq+A~rU^zGi3g`|Ykp6%9R{e68I&rDw)9#9+}?f+tzU33-u2o};-)W8KC z*I=2?3Y!(KOo#7xC~ta#r~v)tJ;pX8)Ju{gN5vAbCxm@JvHV1nd65v-CiOS|6?R$) zq5(gMNrf~i6UJ|}kq-=Euqx?g3A&-*q_0T=;*w(_92b{?i<3dtd8+q=Mn_(#Tuk)j z4K4_RLwzSsB+)lB_jF==bCVFW<%B4yKkM`2gr7x*UvG#@9?R*>K$4T7SDK zBwyiDz@(K#ESI$V1YCEh$JYc}2i=D+E^z()n7nAb$;z@UKnrR-yJ1H_Sof@p7T|c& z#yeU={A5#xx?cZC{T&rcW)hvt*I6Nvu92A?ZJzxkvbO@r@Wtp zqT^lT^pI85%0@!v{l#lCQ~=l8BR3*yhD02)#3REnt?eBmcz*E38$+E$iT0}ir`8+KE(j)ce$#*9+TghSt2d8oKEeGs8T(fiROrs}L8FLHD#-HG< zb1rqO5YsmBL~5_!XEeX3?R4!otRuVA+mrh2)#R({B|8LjM&J_16yl)5Y%e&L2v4W{Xrvi)5rV~vdzo-yco zG|IDHvscWqTe^chg!CTZ*A}?mjB1M%!FXQHH`_T4{tC$Av-@eCFLH2uY+}C=-}>PO zSC-58@O5w^_G2MCk2qJ!;$-Qne97|OxIjF*Hj#MNGyi6T2_E`FbtB_=9q3P-#WR(| zw-+1}=k~Nbw?+0PKJIxlb9ffn5BA_aa+UQwoSnhmm7!7~4toxkStq6e%EQ)U(l*AU z2EM4=XISAT(l*p48$P*N!hWwxe+F0Fw;<1J5zoc_=o2SI3&Z6`C*Qh0Yo4mk?>`u8 ztba#H)$~VTn_Y0L_}fcx!6FDC$i@fHVnzzq7El6wIwa6T@ZVf*BOT8i_S*i&Q8>5;0cSMYMHb;y93aCimpOZniI{x=KD;oEBRb460Y zI9pZEwEI;>^$lZ-f9bQLN_`2@wPN%huBE;=PM3@e<&&|ElLPE-VnAw&2 z6g^;vK&hq~_l@*^pn%>FLzoBGDSQCiC=t?rR)0G+vv_Z)TH7HeGCDCh0d)V%uJcK0 zCwECHf66b`iu+;pu@Ekb&1ET~?|>Y8N%5%j9PKJ4z{on11Ra8Ct5b01Y6y({(A`Sq zltDR%hs^Xa2UPS8kf#ZQLl0CKKr;Syu4`7a+_L1pBz{)IdoiRxPd$vLmOLSR~P3b`bFUVIP zJbtz)Dl~9}=sEt}$Ce)kK_)E8*ioh6Z^%r1zQ21Q4cys17d*ZnH z>#)l`_Z4fbelQJQA^Ho)7YG@^_`^A{x;b#NpcgeWLXa2l&aA;KSnVUR*}D$~0rx(g ze?5-sKb$p;!Wb(umj`01?I%DuB{D!SXZe#0HRZnnX;1p*a6AG6Bn)Soy{8l_98FB$-@`RDL!Pev=`cB)}BX7abx7RW?ZH3DAqy+&!H zx)%m_APeG%(Im#`>%2Mv8rx<74sT2)(%`UM2(;4#F$LRBFZKxhaLrE*HHMX?@96;-j6AO?v97q!oXfi;zk^pc6Z%!J|5T+6R88YHx;a%4~wLc%LgU5NwJztF5Yv4PBKf2Yz@5cwUGdJ z3L(%_27~ud=iwYhzU;T9MWDpZn*H6!D1N`P*M;^Eq}*1N0DuyyjN4?<;o*m%mB8N7 zm8GQ-@AA63*%Su~ukm>4cg@$~UdpW->^x8Bc&Vi-O|P*EAN*{?zJ;65jho7gx+qqg zo=*WVPnn6d_Lv;tKXh2r3wSrKTE#@-#81waru-fb1ng9dUg1xa5s{dlA7Cv7o)!hU zMI;1FjEbBkSE@2K6TDuLdeqf04o5L~T^1$5dw5r&wI1;%gr)Y3_F-^e(MvMPqZQVw z5xKgt`rKFM$_F)p{YL_4mRre>CdKUjTvNQ%0E)}2N3v2+O_}6*WhBP~?>=7DL#A=w zyg6E%SEElomCkfs%+FELt9Qd3VQu={&g_}F+FIXB0qYHg4h43okpJxV6;;qs>s=u= z=eM%N8LMPko3rDu{<^snn)}n86UikvGQ82-V3rm? z%M;s(&`_Q`cP8KUJhWZzgi!#3GTLp^u1|eQ$Gl}+2M$h(8>2a0Xlu|!G;M}Xtq&q< z;JrXiP4xPz^#s*AF|s-7G;8+AGJ+XIG0R|EyTn_WFt zS|ve-I4&LdNXcfP>9bd5U#liUOp;FHagEzlPod5(w;n%G>GO4fD&zzQx_ZxBF7>D& z?%cTth8sa2HDo|DKoFQ38DNXun)V>dx!+XKo|<(Ycz=8gwjL)bnEC;%bwhm{jM$qi zxdey3emsVK9)akN9Vk7hXg0Yral5Y$R|8AxX0%8^llgh!~lH83|hqGd*KG>}UGYsCzB5;K!e zKfSXPKEORkxV+~KM_Cv8LOM8g>qxAqv4sR>Z!NrC@66WgpG;caGy^mQk?>wdJFm6q zlUmYe-x88$V!eEqoJl^Gp-!0=|1(}Zp*qzg!f0{JWT05ofTcoTazEPH_VCodvTxmf z@U;F2k(GUF;|iCb!ri0WQAObQC@7#gVPqE6Q4Uk``1$DP{-MOvChAXCwcPEuoNm%@ z9=ZnKs{B}}r1fKtw;K@Myz?haUQbT#?wE^a$uV zljx&@O^hz5KJL}y2X4P@B!BF$ZO!75oDsi{XkZ8CCcU0aBov|KpSo73+Qnr%%IhNa6 zEnK>-SUnzC9-+X%t8q)X*CNs6)vo@DvtO#d@c zoat7iMJW^gK#9b8puX<>@P}Q=ajl)!w?Z#`S2P}~UTUW{fn}2@Iw*F6ggfZ{EleMN zYvw{q=BD)xcz04VCoBI|07D6rFzXG!H7L7d7<-Q0Ec-+syrA0-h%of8nM+o$6tV;s zQp;(zQV7FJ~?5OzZgHyA<%GrIlIT%L)xvnd$#k);0wj^$IG_` zhNFa-kDI3~BR6p2!tiUfl?Ux5@S3vr*Kh9{xXjf}TzaFt(h@ShRu$ZmJYL#}Gb%^O z+5OlodYNlxSCt^{IO<_iN)K@Tv^9P8i7Bp&i2Ehqn+lv;VCL*P@_%1h);#TWSn5y@ zz0Nr?IJh&|Rk*uTytLlna@g}T_lpx@+}aXh8&mmiX7sQh2SnHJ^iE30ar)lZN5wGN zAj%zsb(nuzTQav`4u?u9i~O^2GHO@O2LoEv4XHQ1SV=5 zcEq#Klx?V}2of8Cr=?0j+t0kGt6OH6w37M7OGa+yeX~Sv{BRa@J>XznVn=7X6i64# z?^c2nXdLv*SA5o1{sjx=dc(!{QU*<4<3{=o;gzPlcy=#4uElASUEbq9xNe{bl%<>$ zSZ=K|R0k5<&P?TYet+fE2#vgolzaCh^lLolvFFkTo%~rHF3FYhwuq_k-QDi=k`r;w zlj{Ah9Yi5?%D?p;!gWIHS{1llr_BBAw^j(7zRP6laMcP-SyNtBv3bSzhfvi9kSHEP z1~`l!EWwjr8v!aCgRClpK9%>gJo5hQAn{Z0htEMIgj7)PJ{~##)fS5GDA_BWz+&kU zg^u(+rFRQ)WtF#i2mqKKh?z65r8pUtAv$-e4Y{;T<0sF5aLiyMJ zlDSWP)AAHiS7i)a3l@UC`A zn>oza`ILO`GQf|ikHU=JZAj8_{6tdI^gDm@hu=-+8z~~0hlq-3n_AiSUoh1Khl@2OP^hJ!R*g1kHB6U!=nBA>R?g z^uuxqn|lX|i>vla{`!K(insUDRudlCXua1`%$d(b;mIxDC9ieg9G^DYp9!-YA@HcL zGOqLQ(}Ld3Z0it=YG3$cytm-5;NnBox>oH)@5uU0HU-o-c77dKoKh1nJ<*&M_dBWB zD4FRrPu|=5m6O?7BtROu#(T461%LB#B2X9}Kv33VNx1_Bui%D8E!@gUg}FF~8c_X& zgOhB-&2b`;=;IG!zL-H?w$Cu2pgNC8gZQuh&&aKN%@e8y1_rh8t6r5A6+WU=k^oQ6 za~AAsP3JsD?F=W^Bfzk%e2F5+S%JiPJ-PF*K#>)CaB$Yy+0Ho@Sh9;FA)P12Mdasj z;J?L0LfkGwr0l$w%1p8;S>SLcz$@nSLRChRAW<+GV5s>VXppY|eV*+caQt7%;J4M) zhUeM%p*cFaqtW2!+1|b-qo2BOFx6GM>l*d(ljf~Rn?eE6%+BWk$kFI%K8ynG~u+9W{F7Bze(fQXyURL4<=f{|P!5$J67g4$1w^ zkpmgcq6%H9ZgR?0L)gjZW|~L0DQTnd^z`)5t>v>fI!5c&{q6yOhY+{=FF4NcM$v$@ zlrQshbGw^?#3d~8EG8yK(_UrJH{(a*&G?nmtgI77MKxg(<*(8eK+&dT%fMKoB~}h zP1CDjZ8Fd7i($$9nPFTle~+0S+me_MOHyo6tCSSZ=x|zU2Oc z5@BVf%MqH|&%Wxu`76;vpq!e+o?gJ+wOV^VzPkA)@Zj6Z>-dQbu+R1hvmB^d9V(xX z?I@1|hbXQ9O^nZ$=U8_ts*_Q3YJQEKM|476o>IFbd6VTL7{RPh7nGL)-r<}d)RQZX zdTH{=Dacm%KHXiqpMmx3+e=WtQ&kMx(6Gw|y*BN>>j=o$$xanW@E*)=Vc5%_;7!w{ zvqCFG|MlaZ7V(=fq(xdc1e2mS>|$&5#I+lsrDONgSWyU5xA)x!<=(YUgC~g#I(eju zh#gD*>0X2?$Bu{6Z}c!`e)c?ZosDg&VWn*>fQ& zs<8SRZe(5VFW1f1^zXf@fqy%h0Mi)^dqR2Tibm?=m7&G088?$zFME&ip_iuXopRpY zkPVH!FS9DiGI#Ir4jFALL4sc>Wb7XfRabvA2vi=cQlbBM$`bFPWb7EcVmkAQg<|KX zZcf^`j^RL_%Q?-3d1-fUU_lwp!DNRS#0)fEZpk6U2!;PK@B<_2FdiO3 zxSwjfQK!b&n?ANPjjN43Omf-p_49l|u6J=ud9B(mFAS4(>^<76m4v=~4$j%_jo11u zy^o5(cAj`k*cr#Dd0%i{Ro7N74!&@`vO6TuKSIkQV?SS3<)0u(m>aYTbc8Q`j@?!; z&w9L;&s$n8$YpOpc~8^%ffDU&oXyGVf@8S=0>u>-VG#At>L2xAoJNPtRkLr0-;v3tn_`y~mnY zrb%J&;-p>&aT8C>aBE`$+7J&adi~rPnRSP{X2_)(`9RAp1Qj-Oet}jFICBTJ8sT^jibsg<{U-w^)8`d zueDk|7{?=Q_0|!#?hqyXM!!+C;k^3$QNB?eSi8%JTS7AB%LFXZB(EntTovl)SHpL; zA$~nBW@=@F;XLKNHqzt>aj`}(i>nFfq-*qM@!Q{KO>Dcg$VOL&=-x)i4qco+17PkX zaibR#H?E)lmvs{V*ZlW?ywrn(x&fxfMwqvtqm2&Vn&dgJHh`3-F=vqf{!Ucrp^49E zkR539@m<~rD;xKpK7HS8cAnzN!48S6GZ?{3fiwo2sVsV@i17Hparglz^JF@Yck6n` zTsDEy{*43w_(5Zp7YpNY%MjyrQ3;Lmjb4~>cgL~;G+<8ylz5Ssw6Xl9052k>*p%%A zq<@U>i$M=nIe&(f6EE4$96VG>w-q#j?R~!zk%+89iHq&mU&+4o*cK;xa>nB#aQj;& zJYPvot@gC4Iy1ue<#~=l8KrCFTjP?Nv>S+8zth@%!ztog8<%xbxL55_3@qF0D7yHEBwqdzbtKfl+yk-DMq`svco}6PN;y;)613p z+SxkbrU(zswL3e@J@#u$YMxU z{D>D#CEu@sDmi{NcPEL013UxcAQcZuW6JSoI7HH=0vXy(nmuawq62g62c-5uOVvQl zf^U;ok?*#@oHX+#V&5XmN9FHGJb3Hzbpc5@{;b($%w-*weDn>>}nA<>dyp-ggfv4mF1z_an4sEM-$Y`=N;_PZ&kz2XRWmI_}(1^AkP1^a{jSn6b^n(d~tqvTYb2b;4kegQW9W&O_HBfw+2*rLt~(NlQ2Qp;f38 zA)Jv&fSuGn4U<$nC^&ZaZ=5mV<_K^!FO_vcRP4AUS52o{PcgXN^KgkTbij%Y=X9S1 zlH`czcCg@i`o|WUPL2h*c${Ee1Mx{~VVL)PjGT_A8{G z=kXfeY3z&sEQ9nf?!X-y;ivDp{MJLiEe;k7t+&I%+GC@D%F#e+U{crpl$JrEA@{>4 z=!E!v;l+Z8l#dtAy`OYs3t;o~fy<_zFW1hV7SW?;nslzwOfM00@+~xyP1tc(JzHrW z`<>{!S;}e3D(W*>JjXkgw(Jg=hHO7Z@ zlPYO}%aKr$QUQ7)*68Vpo*Y&^wVsj_u^JdM%Lo1yS1%Yfb0I)m_9Ybu`s;XqpPcgZ z*_tU&ajJZVEUuvNl9DCJx>t{|bCswgvx0V8lq2f^Y2JSzh(QvGy&H`(D>IC!RbBa$ z=eY`sim(M6rjaOG+MUwkLJ-Gj^wa0~EU#~XJ3g?ZE&PsM!Uak?v=ZAz; z^S~`ruB^3^z_VS`ct%s$M6m4Iu8gepA~_6ueXP%Xg<0Hwbw~;sKATnW0g;Sv1a-fR z6d{B%iCL-)o>0-Jgld6U1jw_WT~bSF*H2x_WnNqyqf|>56&8GWPt0Ah^Wz^DYh(iE zMsM6Zzy5etjvl9xyr5{FXwEm%Pm2~2cL~Y*HGQ(DUG&@~tirRk=yWKgick!b1o=eE zf)=j1>+LirM>tdC6OYH6D7|xwN40Y!iVSP-SoO|RLQ_|TqF296f<_WoXMg=GkZ*2P zi`(nb;B!V7ubE&TgL(z7L7@*w9lgLl$E))D&R)Dj#aFw0A>M=7<7_MG%_2%P52`nhSZd}&E7qCWI% z_&Y=+RrsrDN|*O?hIT7~-ZqQ#%x`U`cBdUk2jQJTJNuCB^=Vhqw2=8Rvh}ViYCp#M z7-Z$F90TE%nMr#W#*Af5^tya_)h3z)`XyklzpOrmS-+G8e>TIUVjz%97;cOd1-{2#7l{Lec=PtS#EAnNMgXl-!Fd*_svZnO2M zbUm0lM74eiUdZ@y5qJev$uIkG3kUn+dyM66oQrW zA`P_a@&;)?ty(@`@&_SroR=#|)zc~l0CXRzKl61`+xZobge-o`>By!?Qc{fG?7D~M zo>L~xf?X`kaIk(Fob-H>fTB0(HHzk&4Ki|=!=_`x6K*wh5Qg`ptS-?bS>;PHEY$^Q`^WDowhEc+)P9DlG@M*+WjA2a8lvq81}j5!NNPalz%gGbu{&}` zifLZHoZt*o6D|EOaBkv{@`s>ZZ--=DsMpd|*0EgUH_YDbMKO%qxGhp?m7B;MwrfV7 zFvOChXMgP|q=WKC-ilV~i&ohs93I7d?@f2=@#hKrZ#4KTT(j-Qh#{9n9JdyeI!!_? zx%A4~ppi}8lYb{4)PSdKFxd5LI)$Y<2w0rA%oDnSeKMcl>e<1UdUPFLm+>rb*v0h? z4B87Y#KQB`r}h{0lnb0}Bp+$|`LtBj4$~oHSFY0!I2|Rocx49%Uuw~*L)iyDoLQvu z`&w*f@l93n?p^NyZR2D9u95HNdurJX*Z`92G6H?Z25BI&VVW#aE7E6x|`BDQD98Tn=10QsCaKFSN0DB zMWHc7Tb0kwU4$PYVHS-xXs*tQRjB+-3VAi?>FwBCy#CdU_Ukf96l9a#gQ0N5h8cKF zoh;^-W^vFNDd;nNVVV3!Bb3woti;;sGfr0OC4#5h3^E*Kvse*GqFi>a)bRVxb}LCz z5LCgq8FC;>*YN>LN%6mm+tVD4GV0x<5<~>{Tju571Xes;O0S%V2@MtJhTq$0&4b9A zVQ%oFoX+7^PIvOjw1?Jd%WLB=gpK1j#uT0ds(*X`n!4wt^{fl&6qgSJUCi2#DjXdh zA0KWKjV5&--e7vj;s9PtREgxA8Hg?kuQs^oA^yq->{)sB!2FnmrhN zfxLLR-2raB>dHuUFz@{zA`Yluk#px9Jn71`S&k(etpXp#z(64Si?kdI0eJ-%AzY^N zx&DB3{To39>HdGLTK_-h>yOW_W&i+x7`;?{s{Q|3L;ruQH2*BYe+HZQXDj@(75;O0 zr~lNJ7(~QE*e}l7{bL8lG5?G7C(}6?2)c5qq@DnHsidJ;^33eRe*smGP8a|H literal 0 HcmV?d00001 diff --git a/content/docs/guides/servarr.mdx b/content/docs/guides/servarr.mdx new file mode 100644 index 000000000..6333b271b --- /dev/null +++ b/content/docs/guides/servarr.mdx @@ -0,0 +1,197 @@ +--- +# cSpell:ignore servarr Sonarr Radarr Prowlarr SABnzbd usenet linuxserver Servarr +title: Secure Sonarr, Radarr, Prowlarr, and SABnzbd with Pomerium +sidebar_label: Servarr +lang: en-US +keywords: + [ + pomerium, + servarr, + sonarr, + radarr, + prowlarr, + sabnzbd, + media automation, + sso, + oidc, + identity aware proxy, + self-hosted, + ] +description: Put a Servarr media-automation suite (Sonarr, Radarr, Prowlarr, SABnzbd) behind one Pomerium front door so every web request is authenticated and authorized before it reaches an app. +--- + +import TabItem from '@theme/TabItem'; +import Tabs from '@theme/Tabs'; + +import Config from '/content/examples/guides/servarr/config.yaml.md'; +import Compose from '/content/examples/guides/servarr/docker-compose.yaml.md'; + +# Secure Sonarr, Radarr, Prowlarr, and SABnzbd with Pomerium + +## What this guide does + +The "Servarr" apps are a family of self-hosted media-automation tools that are almost always run together: [Sonarr](https://sonarr.tv/) (TV), [Radarr](https://radarr.video/) (movies), [Prowlarr](https://prowlarr.com/) (indexer management), and a download client like [SABnzbd](https://sabnzbd.org/) (usenet). Each ships its own web interface and HTTP API on its own port, and none of them speak single sign-on (SSO), the [OpenID Connect (OIDC)](https://openid.net/developers/how-connect-works/) protocol, or any header-based identity. Their built-in access control is per-app web UI authentication plus a static API key for HTTP API and inter-app calls. + +You'll put the whole suite behind one Pomerium so that Pomerium becomes the single front door for every app's web interface: each request is authenticated against your identity provider (IdP) and checked against your policy before it ever reaches Sonarr, Radarr, Prowlarr, or SABnzbd. The apps keep their own API keys for programmatic and inter-app calls; Pomerium gates the browser surface in front of all of them. + +## When to use this guide + +Use it when you run a Servarr stack and want one identity-aware front door for the whole suite instead of exposing four separate web UIs, each with its own local login and separate API key for automation. In a typical home or lab deployment these apps run on a separate host or network-attached storage (NAS) box and are only meant to be reached over your private network; Pomerium lets you reach them from anywhere with your existing identity, while keeping their ports off the public internet. + +The win is not replacing each app's own access controls. The win is centralized SSO, group-based policy, an audit trail of who reached which app, and shrinking the attack surface to a single authenticated entry point. The trade-off to be honest about up front: because the apps consume no identity from Pomerium, this is a front-door gate, and each app's API key remains a separate credential you still have to protect. + +### What Pomerium gates, and what it does not + +A Servarr stack is reached through three different channels, and Pomerium only sits in front of one of them. This matrix is the mental model for the rest of the guide: + +![Matrix: Pomerium gates each app's web UI; the API uses the app's own key and native/mobile clients bypass the gate](./img/servarr/servarr-gating-matrix.svg) + +- **Web UI (browser).** This is what Pomerium gates. Every app's dashboard is reached only after SSO and a policy check. +- **HTTP API.** Reached with the app's own API key. Prowlarr talks to Sonarr and Radarr this way; once a request reaches an app with a valid API key, the app authorizes it independently of your Pomerium session. Keep these calls on the internal network (see [Security considerations](#security-considerations)). +- **Native or mobile clients.** Apps like mobile remotes expect a direct connection and do not run a browser SSO flow. Those bypass Pomerium's interactive gate and need a separate path, such as a [TCP tunnel](/docs/capabilities/non-http) or a VPN. + +```mermaid +sequenceDiagram + participant User as Browser + participant Pomerium as Pomerium + participant IdP as Identity Provider + participant App as Sonarr / Radarr / Prowlarr / SABnzbd + participant Bypass as Native client (API key) + + User->>Pomerium: GET https://sonarr.yourdomain.com + Pomerium->>IdP: Redirect for authentication + IdP->>Pomerium: Authenticated identity + Pomerium->>Pomerium: Evaluate policy (group / email) + Pomerium->>App: Forward request (no identity header) + App->>User: App web UI + Note over Bypass,App: Bypass risk: a client with the app's API key
that can reach the app port skips Pomerium entirely + Bypass--xApp: Direct API call if the port is exposed +``` + +The dashed line is the reason network isolation matters: keep the app ports off published interfaces and reachable only through Pomerium, or the API key becomes the whole security model. + +## Prerequisites + +This guide assumes you've completed the [Quickstart](/docs/get-started/quickstart), so you already have Pomerium running and signing users in through the hosted authenticate service. + +You also need: + +- [Docker](https://docs.docker.com/install/) and [Docker Compose](https://docs.docker.com/compose/install/) +- A domain you control for the routes (this guide uses `sonarr.yourdomain.com`, `radarr.yourdomain.com`, `prowlarr.yourdomain.com`, and `sabnzbd.yourdomain.com`) + +:::tip Prefer to self-host the identity provider? + +This guide uses the hosted authenticate service so you don't have to run your own IdP. To run your own instead, follow [Keycloak + Pomerium](/docs/integrations/user-identity/oidc) and swap the `authenticate_service_url` / `idp_*` settings into the config below. + +::: + +## Configure Pomerium + +You'll create one route per app. The routes are plain HTTP front-door gates: Pomerium authenticates the user and proxies the request through without injecting any identity, because the apps have no header or token identity to consume. + + + + +In the [Zero Console](https://console.pomerium.app), create one **Route** per app: + +1. **Sonarr** -- **From** `https://sonarr.`, **To** `http://sonarr:8989`. +2. **Radarr** -- **From** `https://radarr.`, **To** `http://radarr:7878`. +3. **Prowlarr** -- **From** `https://prowlarr.`, **To** `http://prowlarr:9696`. +4. **SABnzbd** -- **From** `https://sabnzbd.`, **To** `http://sabnzbd:8080`. On this route, enable **Preserve Host Header**: SABnzbd checks the incoming host against its `host_whitelist`, so it must see the public name. + +Set each route's policy to scope access to who should reach the suite (for example, **Any Authenticated User** or a specific group). Zero manages the routes' TLS certificates behind your starter domain. + + + + +Create a `config.yaml`. It defines one route per app and allows a single authorized user. SABnzbd preserves the host header so its `host_whitelist` check passes. + + + +Replace each `*.yourdomain.com` host with your domain and `you@example.com` with the email (or switch to a group or domain match) that should be allowed through. Restart Pomerium after saving. + + + + +## Configure the Servarr apps + +The [LinuxServer.io](https://www.linuxserver.io/) images for these apps generate their config on first start, so there is little to set by hand. The two things that matter for running behind Pomerium: + +- **Authentication method.** For Sonarr, Radarr, and Prowlarr, `External` is configured in `config.xml`, not from the normal settings UI. Stop each container, edit its `/config/config.xml` so the top-level auth entries are `External` and `DisabledForLocalAddresses`, then restart. Be precise about what this does: it tells the app to skip its own web-UI login for requests that arrive from a local/private network address, on the assumption that a reverse proxy already authenticated the user. The app does not validate a Pomerium identity or a signed header -- it trusts the network position. Pomerium is that reverse proxy, which is exactly why the app ports must be reachable only through Pomerium (see [Security considerations](#security-considerations)). The API key still guards API calls regardless of this setting. +- **API keys.** Each app shows its API key under **Settings -> General**. Prowlarr uses these keys to push indexer config into Sonarr and Radarr, and any external client uses them too. Treat each key like a password. + +SABnzbd is configured the same way in spirit: under **Config -> General**, note its API key, and add your public SABnzbd host to **Config -> Special -> host_whitelist** so it accepts the host Pomerium forwards. + +:::caution The API key is a separate credential from your SSO identity + +Signing in through Pomerium does not authenticate you to an app's API. The API key is the app's own credential and is not derived from your Pomerium session. Anyone who can present a valid API key to an app, and reach its port, is authorized by that app -- which is exactly why the next sections keep the app ports off the network. + +::: + +## Run the stack + +The Compose file runs Pomerium Core and all four apps together. Pomerium publishes ports 80 and 443; the app containers publish no host ports, so their web ports are reachable only through Pomerium, while the apps keep outbound access so Sonarr, Radarr, and Prowlarr can reach indexers and metadata providers and SABnzbd can reach Usenet servers. For Zero, drop the `pomerium` service and use the `compose.yaml` from the Quickstart with your `POMERIUM_ZERO_TOKEN`, keeping the four app services and the `servarr-internal` network below, and attach the Quickstart's `pomerium` service to `servarr-internal` so it can resolve the apps by name. + + + +Start it: + +```bash +docker compose up -d +``` + +The apps take a moment to migrate their databases on first start; watch `docker compose ps` until each reports `Up`, then check the app logs for the listening message if a page is not ready yet. When you're done testing, stop the stack: + +```bash +docker compose down +``` + +Add `-v` only if you mean to delete the named volumes, including each app's config and API keys. + +## Verify the setup + +1. **The route requires authentication.** In a fresh browser, open `https://sonarr.yourdomain.com`. You should be redirected to sign in through Pomerium, not straight into Sonarr. Repeat for `radarr`, `prowlarr`, and `sabnzbd` -- every app is gated. +2. **An allowed user reaches each app.** Sign in with a user your policy allows. Pomerium redirects you back and the app's own dashboard loads behind the gate. + +![The Sonarr web interface reached through Pomerium after single sign-on](./img/servarr/servarr-sonarr.png) + +3. **The API answers its own key, not your session.** Pomerium still gates the route, so reach these from your signed-in browser. For Pomerium Zero or Enterprise routes, you can also use a [Pomerium service account](/docs/capabilities/service-accounts) token that your route policy allows; for self-managed Core without service accounts, use an interactive browser session. A plain `curl` with no Pomerium session is redirected to sign in. Past the gate, each app's status API responds to its own API key. The path differs by app -- Sonarr and Radarr use `/api/v3`, Prowlarr uses `/api/v1`, and SABnzbd uses its `mode` query: + +```bash +export POMERIUM_SERVICE_ACCOUNT_JWT='raw-service-account-jwt' + +curl -H "Authorization: Bearer Pomerium-${POMERIUM_SERVICE_ACCOUNT_JWT}" \ + -H "X-Api-Key: YOUR_SONARR_KEY" \ + "https://sonarr.yourdomain.com/api/v3/system/status" +curl -H "Authorization: Bearer Pomerium-${POMERIUM_SERVICE_ACCOUNT_JWT}" \ + -H "X-Api-Key: YOUR_RADARR_KEY" \ + "https://radarr.yourdomain.com/api/v3/system/status" +curl -H "Authorization: Bearer Pomerium-${POMERIUM_SERVICE_ACCOUNT_JWT}" \ + -H "X-Api-Key: YOUR_PROWLARR_KEY" \ + "https://prowlarr.yourdomain.com/api/v1/system/status" +curl -H "Authorization: Bearer Pomerium-${POMERIUM_SERVICE_ACCOUNT_JWT}" \ + "https://sabnzbd.yourdomain.com/api?mode=queue&output=json&apikey=YOUR_SABNZBD_KEY" +``` + +Pomerium gates the browser surface; the apps keep their own API keys behind that gate. If you leave Forms or Basic auth enabled instead of `External`, that local login remains as an additional prompt. First-run setup is each app's concern, not Pomerium's. + +## Common failure modes + +- **`421 Misdirected Request` or a host error from SABnzbd.** SABnzbd's `host_whitelist` doesn't include the host Pomerium forwards. Add `sabnzbd.yourdomain.com` to the whitelist and make sure the route sets `preserve_host_header`. +- **An \*arr app shows its own login prompt after you sign in to Pomerium.** Its authentication method is still set to a local form login. Stop the container, edit `/config/config.xml` to use `External` and `DisabledForLocalAddresses`, then restart. Or leave a form login in place if you want the app's password as a second prompt. +- **`404` from a status API.** You used the wrong API version for that app. Prowlarr is `/api/v1`; Sonarr and Radarr are `/api/v3`; SABnzbd uses `/api?mode=...`. +- **Redirect loop or certificate errors.** Make sure DNS for each host points at Pomerium and that Pomerium can obtain a TLS certificate. On the Core path, `autocert` needs ports 80 and 443 reachable for Let's Encrypt; Zero manages certificates for you. + +## Security considerations + +- **Network isolation is the real boundary.** With **External** auth set to **Disabled for Local Addresses**, each app serves its web UI to anything that reaches it from a local network address, and its API authorizes any caller that presents the key. The app trusts the network position, not a verified identity, so the whole model depends on Pomerium being the only thing on that network path. Keep the apps off published host ports on a Docker network shared with Pomerium, as the Compose file does, so the only way in is through Pomerium. The inbound isolation comes from not publishing host ports, not from cutting the network off entirely; these apps still need outbound access to reach indexers and Usenet, so the network stays egress-capable. If an app is reachable on its own port, a caller from that network skips the web-UI login outright, and anyone who learns the API key bypasses Pomerium entirely. In production these apps often live on a separate host or NAS; reach them only through Pomerium, never by exposing their ports. +- **Treat each API key like a password.** The keys live in the apps' config and are passed between them (Prowlarr to Sonarr and Radarr) and to any external client. They are not tied to your Pomerium session, so rotate them if leaked. Prefer the `X-Api-Key` header for Sonarr, Radarr, and Prowlarr instead of putting keys in URLs you'd paste into a shared channel. +- **Scope the route policy.** Limit each route to the users or groups who should reach the suite rather than allowing every authenticated user. The apps have no per-user roles of their own, so Pomerium's policy is your only place to express who gets in. +- **Front-door gate, not identity injection.** Unlike apps that accept a signed header or JWT from Pomerium, the \*arr apps gain no per-user identity from this setup. If you need the upstream to know which user acted, these apps can't consume that today; Pomerium's audit log is where that record lives. + +## Next steps + +- [Secure Transmission with Pomerium](/docs/guides/transmission) -- the same front-door pattern for a BitTorrent download client, including its RPC host-whitelist mechanics. +- [Build policies](/docs/get-started/fundamentals/zero/zero-build-policies) +- [Non-HTTP (TCP) routes](/docs/capabilities/non-http) -- for native or mobile clients that can't run a browser SSO flow. +- [Custom domains](/docs/capabilities/custom-domains) diff --git a/content/examples/guides/servarr/config.yaml b/content/examples/guides/servarr/config.yaml new file mode 100644 index 000000000..971fb2b0c --- /dev/null +++ b/content/examples/guides/servarr/config.yaml @@ -0,0 +1,46 @@ +# Pomerium Core configuration for a Servarr media-automation suite. Uses the hosted +# authenticate service, so you don't run your own identity provider. To self-host the +# IdP, see the Keycloak guide: +# https://www.pomerium.com/docs/integrations/user-identity/oidc +authenticate_service_url: https://authenticate.pomerium.app + +# Obtain TLS certificates automatically from Let's Encrypt. +autocert: true + +# One route per app. Each app keeps its own API key, so these are front-door gates, +# not header-trust integrations. +routes: + - from: https://sonarr.yourdomain.com + to: http://sonarr:8989 + policy: + - allow: + or: + - email: + is: you@example.com + + - from: https://radarr.yourdomain.com + to: http://radarr:7878 + policy: + - allow: + or: + - email: + is: you@example.com + + - from: https://prowlarr.yourdomain.com + to: http://prowlarr:9696 + policy: + - allow: + or: + - email: + is: you@example.com + + - from: https://sabnzbd.yourdomain.com + to: http://sabnzbd:8080 + # SABnzbd validates the Host header against its host_whitelist, so forward the + # original host unchanged and add sabnzbd.yourdomain.com to that whitelist. + preserve_host_header: true + policy: + - allow: + or: + - email: + is: you@example.com diff --git a/content/examples/guides/servarr/config.yaml.md b/content/examples/guides/servarr/config.yaml.md new file mode 100644 index 000000000..fdff30dcd --- /dev/null +++ b/content/examples/guides/servarr/config.yaml.md @@ -0,0 +1,48 @@ +```yaml title="config.yaml" +# Pomerium Core configuration for a Servarr media-automation suite. Uses the hosted +# authenticate service, so you don't run your own identity provider. To self-host the +# IdP, see the Keycloak guide: +# https://www.pomerium.com/docs/integrations/user-identity/oidc +authenticate_service_url: https://authenticate.pomerium.app + +# Obtain TLS certificates automatically from Let's Encrypt. +autocert: true + +# One route per app. Each app keeps its own API key, so these are front-door gates, +# not header-trust integrations. +routes: + - from: https://sonarr.yourdomain.com + to: http://sonarr:8989 + policy: + - allow: + or: + - email: + is: you@example.com + + - from: https://radarr.yourdomain.com + to: http://radarr:7878 + policy: + - allow: + or: + - email: + is: you@example.com + + - from: https://prowlarr.yourdomain.com + to: http://prowlarr:9696 + policy: + - allow: + or: + - email: + is: you@example.com + + - from: https://sabnzbd.yourdomain.com + to: http://sabnzbd:8080 + # SABnzbd validates the Host header against its host_whitelist, so forward the + # original host unchanged and add sabnzbd.yourdomain.com to that whitelist. + preserve_host_header: true + policy: + - allow: + or: + - email: + is: you@example.com +``` diff --git a/content/examples/guides/servarr/docker-compose.yaml b/content/examples/guides/servarr/docker-compose.yaml new file mode 100644 index 000000000..a51c6a720 --- /dev/null +++ b/content/examples/guides/servarr/docker-compose.yaml @@ -0,0 +1,76 @@ +services: + pomerium: + image: pomerium/pomerium:v0.32.7@sha256:e10d1d267af24f581157f485d9b0bc08469e2428675b696a08e42ceb09b2279c + volumes: + - ./config.yaml:/pomerium/config.yaml:ro + - pomerium-cache:/data + ports: + - 443:443 + - 80:80 + # Pomerium bridges both networks: `default` for autocert/Let's Encrypt and the + # hosted authenticate service, and `servarr-internal` to reach the apps. + networks: + - default + - servarr-internal + restart: always + + sonarr: + image: lscr.io/linuxserver/sonarr:4.0.17@sha256:0b3f344388bd7bed4f2f770058de795e76447e4a481b83c8d5f8fed489371fde + environment: + PUID: 1000 + PGID: 1000 + TZ: Etc/UTC + volumes: + - sonarr-config:/config + networks: + - servarr-internal + restart: always + + radarr: + image: lscr.io/linuxserver/radarr:6.1.1@sha256:c0a4335d4249b46102f64cf6fa27ffc3bddfd9138fac1e4ddf238afd37f02d1f + environment: + PUID: 1000 + PGID: 1000 + TZ: Etc/UTC + volumes: + - radarr-config:/config + networks: + - servarr-internal + restart: always + + prowlarr: + image: lscr.io/linuxserver/prowlarr:2.3.5@sha256:2489c6dbaf11e3a6d71aeb2e6980d04193d4af611aa7064a974851222fd41722 + environment: + PUID: 1000 + PGID: 1000 + TZ: Etc/UTC + volumes: + - prowlarr-config:/config + networks: + - servarr-internal + restart: always + + sabnzbd: + image: lscr.io/linuxserver/sabnzbd:5.0.3@sha256:3de84922d3b4c5e7062b3cbd1e08f57d8dc113a8be4dc0447d33e2da293bab26 + environment: + PUID: 1000 + PGID: 1000 + TZ: Etc/UTC + volumes: + - sabnzbd-config:/config + networks: + - servarr-internal + restart: always + +networks: + # The *arr apps publish no host ports, so Pomerium (which shares this network) is + # the only way in. Outbound access stays enabled so the apps can reach indexers, + # metadata providers, and Usenet servers. + servarr-internal: {} + +volumes: + pomerium-cache: + sonarr-config: + radarr-config: + prowlarr-config: + sabnzbd-config: diff --git a/content/examples/guides/servarr/docker-compose.yaml.md b/content/examples/guides/servarr/docker-compose.yaml.md new file mode 100644 index 000000000..46aee6e36 --- /dev/null +++ b/content/examples/guides/servarr/docker-compose.yaml.md @@ -0,0 +1,78 @@ +```yaml title="docker-compose.yaml" +services: + pomerium: + image: pomerium/pomerium:v0.32.7@sha256:e10d1d267af24f581157f485d9b0bc08469e2428675b696a08e42ceb09b2279c + volumes: + - ./config.yaml:/pomerium/config.yaml:ro + - pomerium-cache:/data + ports: + - 443:443 + - 80:80 + # Pomerium bridges both networks: `default` for autocert/Let's Encrypt and the + # hosted authenticate service, and `servarr-internal` to reach the apps. + networks: + - default + - servarr-internal + restart: always + + sonarr: + image: lscr.io/linuxserver/sonarr:4.0.17@sha256:0b3f344388bd7bed4f2f770058de795e76447e4a481b83c8d5f8fed489371fde + environment: + PUID: 1000 + PGID: 1000 + TZ: Etc/UTC + volumes: + - sonarr-config:/config + networks: + - servarr-internal + restart: always + + radarr: + image: lscr.io/linuxserver/radarr:6.1.1@sha256:c0a4335d4249b46102f64cf6fa27ffc3bddfd9138fac1e4ddf238afd37f02d1f + environment: + PUID: 1000 + PGID: 1000 + TZ: Etc/UTC + volumes: + - radarr-config:/config + networks: + - servarr-internal + restart: always + + prowlarr: + image: lscr.io/linuxserver/prowlarr:2.3.5@sha256:2489c6dbaf11e3a6d71aeb2e6980d04193d4af611aa7064a974851222fd41722 + environment: + PUID: 1000 + PGID: 1000 + TZ: Etc/UTC + volumes: + - prowlarr-config:/config + networks: + - servarr-internal + restart: always + + sabnzbd: + image: lscr.io/linuxserver/sabnzbd:5.0.3@sha256:3de84922d3b4c5e7062b3cbd1e08f57d8dc113a8be4dc0447d33e2da293bab26 + environment: + PUID: 1000 + PGID: 1000 + TZ: Etc/UTC + volumes: + - sabnzbd-config:/config + networks: + - servarr-internal + restart: always + +networks: + # The *arr apps publish no host ports, so Pomerium (which shares this network) is + # the only way in. Outbound access stays enabled so the apps can reach indexers, + # metadata providers, and Usenet servers. + servarr-internal: {} + +volumes: + pomerium-cache: + sonarr-config: + radarr-config: + prowlarr-config: + sabnzbd-config: +``` diff --git a/content/examples/guides/servarr/validate/assert.spec.ts b/content/examples/guides/servarr/validate/assert.spec.ts new file mode 100644 index 000000000..063de1cfd --- /dev/null +++ b/content/examples/guides/servarr/validate/assert.spec.ts @@ -0,0 +1,175 @@ +import { test, expect } from "@playwright/test"; +import { login, alice } from "../lib/authn"; +import { shot } from "../lib/shot"; + +// Real end-to-end test for the Servarr suite behind a single Pomerium front door. +// Pomerium makes the access decision for every app; each *arr app keeps its own +// API key on top (it has no OIDC/JWT/header identity). This drives the whole +// chain: an unauthenticated request is bounced to the IdP; an allowed user passes +// the gate; behind the gate each app's status API answers 200 to its seeded key; +// and the apps are unreachable except through Pomerium (network isolation). +// +// SERVARRTESTKEY0123456789 is the shared, test-only API key seeded into each app's +// committed config (sonarr/radarr/prowlarr config.xml and sabnzbd.ini). It is a +// fixture credential for localhost-only containers destroyed after the run, in the +// same spirit as the harness's committed alice/password123 and pomerium-e2e-secret. +const BASE = process.env.POMERIUM_URL as string; +const API_KEY = "SERVARRTESTKEY0123456789"; + +const RADARR = BASE.replace("sonarr.", "radarr."); +const PROWLARR = BASE.replace("sonarr.", "prowlarr."); +const SABNZBD = BASE.replace("sonarr.", "sabnzbd."); +const KEYCLOAK_HOST = "keycloak.localhost.pomerium.io"; + +// After the first interactive sign-in, the authenticate + Keycloak sessions are +// warm, so reaching another app host completes single sign-on silently (no +// credential prompt). Navigate and wait to land back on the app host; if Pomerium +// briefly bounces through Keycloak, wait that out too. +async function reach(page: import("@playwright/test").Page, url: string): Promise { + const host = new URL(url).hostname; + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForURL( + (u) => u.hostname === host && u.hostname !== KEYCLOAK_HOST, + { timeout: 30000 }, + ); +} + +test("unauthenticated request is redirected to the identity provider", async ({ page }) => { + await page.goto(BASE, { waitUntil: "domcontentloaded" }); + await expect(page).toHaveURL(/keycloak\.localhost\.pomerium\.io/); +}); + +test("an allowed user passes the gate and every app's API answers its seeded key", async ({ + page, +}) => { + // Interactive sign-in once, through the Sonarr route. This proves the gate and + // warms the SSO session reused for the other three apps. + await login(page, BASE, alice); + + // Sonarr (TV). The status API only answers 200 to a request carrying the API key, + // so a 200 here proves both that Pomerium let us through and that the seeded key + // reached the app. The request rides the browser context, so it also carries the + // Pomerium session cookie set by the login above. + const sonarr = await page.request.get( + `${BASE}/api/v3/system/status?apiKey=${API_KEY}`, + { ignoreHTTPSErrors: true }, + ); + expect(sonarr.status(), "Sonarr status API should answer 200 to the seeded key").toBe(200); + expect((await sonarr.json()).appName, "Sonarr should identify itself").toBe("Sonarr"); + + // Radarr (Movies). + await reach(page, RADARR); + const radarr = await page.request.get( + `${RADARR}/api/v3/system/status?apiKey=${API_KEY}`, + { ignoreHTTPSErrors: true }, + ); + expect(radarr.status(), "Radarr status API should answer 200 to the seeded key").toBe(200); + expect((await radarr.json()).appName, "Radarr should identify itself").toBe("Radarr"); + + // Prowlarr (indexers). Prowlarr's API is versioned v1, unlike Sonarr/Radarr (v3). + await reach(page, PROWLARR); + const prowlarr = await page.request.get( + `${PROWLARR}/api/v1/system/status?apiKey=${API_KEY}`, + { ignoreHTTPSErrors: true }, + ); + expect(prowlarr.status(), "Prowlarr status API should answer 200 to the seeded key").toBe(200); + expect((await prowlarr.json()).appName, "Prowlarr should identify itself").toBe("Prowlarr"); + + // SABnzbd (usenet). Its `mode=version` is public, so assert on `mode=queue`, + // which returns "API Key Required" without the key and the queue JSON with it. + await reach(page, SABNZBD); + const sab = await page.request.get( + `${SABNZBD}/api?mode=queue&output=json&apikey=${API_KEY}`, + { ignoreHTTPSErrors: true }, + ); + expect(sab.status(), "SABnzbd queue API should answer 200 to the seeded key").toBe(200); + expect((await sab.json()).queue, "SABnzbd should return its queue object").toBeTruthy(); + + // Capture a *arr UI rendered behind the Pomerium gate for the guide. Sonarr's + // SPA polls its API continuously, so wait on the rendered chrome rather than + // network idle (which never settles). The selector wait is best-effort so a + // future UI markup change can't fail the gate assertion that already passed. + await reach(page, BASE); + await page.waitForLoadState("domcontentloaded"); + await page.waitForSelector("header, nav, [class*='Page']", { timeout: 10000 }).catch(() => {}); + await shot(page, "servarr-sonarr"); +}); + +test("each app's own API key gates its API, independent of the Pomerium session", async ({ + page, +}) => { + // The guide's load-bearing claim is that signing in through Pomerium does NOT + // authenticate you to an app's API: the API key is the app's own, separate + // credential. Prove it. Even past the Pomerium gate (authenticated session in + // this context), a request that omits the key must be rejected by the app itself. + // Sonarr/Radarr/Prowlarr answer 401; SABnzbd answers 200 with an "API Key + // Required" error body (its API never uses HTTP status for auth failures). + await login(page, BASE, alice); + + const sonarr = await page.request.get(`${BASE}/api/v3/system/status`, { + ignoreHTTPSErrors: true, + }); + expect(sonarr.status(), "Sonarr must reject an API call with no key").toBe(401); + + await reach(page, RADARR); + const radarr = await page.request.get(`${RADARR}/api/v3/system/status`, { + ignoreHTTPSErrors: true, + }); + expect(radarr.status(), "Radarr must reject an API call with no key").toBe(401); + + await reach(page, PROWLARR); + const prowlarr = await page.request.get(`${PROWLARR}/api/v1/system/status`, { + ignoreHTTPSErrors: true, + }); + expect(prowlarr.status(), "Prowlarr must reject an API call with no key").toBe(401); + + await reach(page, SABNZBD); + const sab = await page.request.get(`${SABNZBD}/api?mode=queue&output=json`, { + ignoreHTTPSErrors: true, + }); + const sabBody = await sab.text(); + expect( + sabBody, + "SABnzbd must reject an authenticated-but-keyless API call", + ).toMatch(/API Key Required/i); +}); + +test("the *arr apps are not reachable except through Pomerium", async ({ page }) => { + // Positive control first: Sonarr IS serving through Pomerium after SSO. This + // proves the service is up, so the direct-hit failure below is caused by network + // topology, not a dead/typo'd endpoint. + await login(page, BASE, alice); + const viaPomerium = await page.request.get( + `${BASE}/api/v3/system/status?apiKey=${API_KEY}`, + { ignoreHTTPSErrors: true }, + ); + expect(viaPomerium.ok(), "the route through Pomerium should work").toBeTruthy(); + + // Each app's only built-in API auth is a static key, so network isolation is + // the real trust boundary and must be real: the apps sit on an internal-only + // network shared with Pomerium alone. The test-runner is not on that network, + // so direct hits must fail at name resolution / connection, NOT with HTTP + // responses. Asserting the specific error keeps a typo or a down service from + // masquerading as isolation. + const directUrls = [ + "http://sonarr:8989/api/v3/system/status", + "http://radarr:7878/api/v3/system/status", + "http://prowlarr:9696/api/v1/system/status", + "http://sabnzbd:8080/api?mode=queue&output=json", + ]; + for (const url of directUrls) { + let directError = ""; + try { + await page.request.get(url, { + ignoreHTTPSErrors: true, + timeout: 5000, + }); + } catch (e) { + directError = String(e); + } + expect( + directError, + `${url} must be unreachable directly; the only path in is through Pomerium`, + ).toMatch(/ENOTFOUND|getaddrinfo|EAI_AGAIN|ECONNREFUSED/i); + } +}); diff --git a/content/examples/guides/servarr/validate/compose.validate.yaml b/content/examples/guides/servarr/validate/compose.validate.yaml new file mode 100644 index 000000000..f1edebe82 --- /dev/null +++ b/content/examples/guides/servarr/validate/compose.validate.yaml @@ -0,0 +1,187 @@ +# Sealed E2E validation for the Servarr suite guide. Reuses the shared harness +# (Keycloak + certs + headless runner) and adds Sonarr, Radarr, Prowlarr, and +# SABnzbd behind a single Pomerium wired to the in-network IdP. +# Run with: scripts/validate-guide-fixtures.sh servarr +# +# Network isolation mirrors the guide's trust boundary: the *arr apps sit ONLY on +# the internal-only `servarr-internal` network, shared with pomerium alone. The +# test-runner is on `default`, so it cannot reach the apps directly (the spec proves +# this); the only path in is through Pomerium, which bridges both networks. +# +# Each app's API key is seeded by a one-shot init container that copies a pristine +# config into the app's /config volume. The *arr apps rewrite their config on +# startup (schema migration), so they can't run off a read-only mount; seeding via a +# volume keeps the committed fixture pristine. The shared test-only key is +# SERVARRTESTKEY0123456789. Never reuse it anywhere real. + +include: + - ../../_harness/compose/compose.harness.yaml + +services: + sonarr-init: + image: alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d + volumes: + - sonarr-config:/config + - ./seed/sonarr.config.xml:/seed/config.xml:ro + command: ['/bin/sh', '-c', 'cp /seed/config.xml /config/config.xml'] + + radarr-init: + image: alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d + volumes: + - radarr-config:/config + - ./seed/radarr.config.xml:/seed/config.xml:ro + command: ['/bin/sh', '-c', 'cp /seed/config.xml /config/config.xml'] + + prowlarr-init: + image: alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d + volumes: + - prowlarr-config:/config + - ./seed/prowlarr.config.xml:/seed/config.xml:ro + command: ['/bin/sh', '-c', 'cp /seed/config.xml /config/config.xml'] + + sabnzbd-init: + image: alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d + volumes: + - sabnzbd-config:/config + - ./seed/sabnzbd.ini:/seed/sabnzbd.ini:ro + command: ['/bin/sh', '-c', 'cp /seed/sabnzbd.ini /config/sabnzbd.ini'] + + sonarr: + image: lscr.io/linuxserver/sonarr:4.0.17@sha256:0b3f344388bd7bed4f2f770058de795e76447e4a481b83c8d5f8fed489371fde + environment: + PUID: 1000 + PGID: 1000 + TZ: Etc/UTC + volumes: + - sonarr-config:/config + depends_on: + sonarr-init: + condition: service_completed_successfully + networks: + servarr-internal: + aliases: + - sonarr + healthcheck: + test: ['CMD', 'curl', '-fsS', 'http://localhost:8989/ping'] + interval: 5s + timeout: 5s + retries: 60 + start_period: 60s + + radarr: + image: lscr.io/linuxserver/radarr:6.1.1@sha256:c0a4335d4249b46102f64cf6fa27ffc3bddfd9138fac1e4ddf238afd37f02d1f + environment: + PUID: 1000 + PGID: 1000 + TZ: Etc/UTC + volumes: + - radarr-config:/config + depends_on: + radarr-init: + condition: service_completed_successfully + networks: + servarr-internal: + aliases: + - radarr + healthcheck: + test: ['CMD', 'curl', '-fsS', 'http://localhost:7878/ping'] + interval: 5s + timeout: 5s + retries: 60 + start_period: 60s + + prowlarr: + image: lscr.io/linuxserver/prowlarr:2.3.5@sha256:2489c6dbaf11e3a6d71aeb2e6980d04193d4af611aa7064a974851222fd41722 + environment: + PUID: 1000 + PGID: 1000 + TZ: Etc/UTC + volumes: + - prowlarr-config:/config + depends_on: + prowlarr-init: + condition: service_completed_successfully + networks: + servarr-internal: + aliases: + - prowlarr + healthcheck: + test: ['CMD', 'curl', '-fsS', 'http://localhost:9696/ping'] + interval: 5s + timeout: 5s + retries: 60 + start_period: 60s + + sabnzbd: + image: lscr.io/linuxserver/sabnzbd:5.0.3@sha256:3de84922d3b4c5e7062b3cbd1e08f57d8dc113a8be4dc0447d33e2da293bab26 + environment: + PUID: 1000 + PGID: 1000 + TZ: Etc/UTC + volumes: + - sabnzbd-config:/config + depends_on: + sabnzbd-init: + condition: service_completed_successfully + networks: + servarr-internal: + aliases: + - sabnzbd + healthcheck: + test: + ['CMD', 'curl', '-fsS', 'http://localhost:8080/api?mode=version&output=json'] + interval: 5s + timeout: 5s + retries: 60 + start_period: 60s + + pomerium: + image: pomerium/pomerium:v0.32.7@sha256:e10d1d267af24f581157f485d9b0bc08469e2428675b696a08e42ceb09b2279c + command: ['--config', '/pomerium/config.yaml'] + env_file: + - ../../_harness/pomerium/validation.env + volumes: + - certs:/certs:ro + - ./routes.validate.yaml:/pomerium/config.yaml:ro + depends_on: + certs-init: + condition: service_completed_successfully + keycloak: + condition: service_healthy + sonarr: + condition: service_healthy + radarr: + condition: service_healthy + prowlarr: + condition: service_healthy + sabnzbd: + condition: service_healthy + networks: + # On `default` for the IdP + test-runner, and on the internal-only network to + # reach the apps. This is the only service bridging the two. + default: + aliases: + - authenticate.localhost.pomerium.io + - sonarr.localhost.pomerium.io + - radarr.localhost.pomerium.io + - prowlarr.localhost.pomerium.io + - sabnzbd.localhost.pomerium.io + servarr-internal: {} + healthcheck: + test: ['CMD', 'pomerium', 'health'] + interval: 5s + timeout: 5s + retries: 30 + start_period: 10s + +networks: + # Internal-only: no route to the outside, and the test-runner is not attached, + # so the *arr apps are reachable only via Pomerium. + servarr-internal: + internal: true + +volumes: + sonarr-config: + radarr-config: + prowlarr-config: + sabnzbd-config: diff --git a/content/examples/guides/servarr/validate/routes.validate.yaml b/content/examples/guides/servarr/validate/routes.validate.yaml new file mode 100644 index 000000000..4eb6b8684 --- /dev/null +++ b/content/examples/guides/servarr/validate/routes.validate.yaml @@ -0,0 +1,34 @@ +# Routes-only validation config. Shared settings (IdP, secrets, signing key, certs) +# come from ../../_harness/pomerium/validation.env via env_file in compose.validate.yaml. +# Each *arr app runs its own auth (an API key), so the route is a front-door gate. +routes: + - from: https://sonarr.localhost.pomerium.io + to: http://sonarr:8989 + policy: + - allow: + or: + - authenticated_user: true + + - from: https://radarr.localhost.pomerium.io + to: http://radarr:7878 + policy: + - allow: + or: + - authenticated_user: true + + - from: https://prowlarr.localhost.pomerium.io + to: http://prowlarr:9696 + policy: + - allow: + or: + - authenticated_user: true + + - from: https://sabnzbd.localhost.pomerium.io + to: http://sabnzbd:8080 + # SABnzbd validates the Host header against its host_whitelist, so forward the + # original host unchanged. + preserve_host_header: true + policy: + - allow: + or: + - authenticated_user: true diff --git a/content/examples/guides/servarr/validate/seed/prowlarr.config.xml b/content/examples/guides/servarr/validate/seed/prowlarr.config.xml new file mode 100644 index 000000000..0836142a9 --- /dev/null +++ b/content/examples/guides/servarr/validate/seed/prowlarr.config.xml @@ -0,0 +1,13 @@ + + * + 9696 + False + False + SERVARRTESTKEY0123456789 + External + DisabledForLocalAddresses + master + info + + Prowlarr + diff --git a/content/examples/guides/servarr/validate/seed/radarr.config.xml b/content/examples/guides/servarr/validate/seed/radarr.config.xml new file mode 100644 index 000000000..a4ab87cd9 --- /dev/null +++ b/content/examples/guides/servarr/validate/seed/radarr.config.xml @@ -0,0 +1,13 @@ + + * + 7878 + False + False + SERVARRTESTKEY0123456789 + External + DisabledForLocalAddresses + master + info + + Radarr + diff --git a/content/examples/guides/servarr/validate/seed/sabnzbd.ini b/content/examples/guides/servarr/validate/seed/sabnzbd.ini new file mode 100644 index 000000000..1b2e1e52b --- /dev/null +++ b/content/examples/guides/servarr/validate/seed/sabnzbd.ini @@ -0,0 +1,331 @@ +__version__ = 19 +__encoding__ = utf-8 +[misc] +config_conversion_version = 5 +helpful_warnings = 1 +queue_complete = "" +queue_complete_pers = 0 +bandwidth_perc = 100 +refresh_rate = 0 +interface_settings = "" +queue_limit = 20 +config_lock = 0 +fixed_ports = 1 +notified_new_skin = 0 +direct_unpack_tested = 0 +sorters_converted = 1 +check_new_rel = 1 +auto_browser = 0 +language = en +enable_https_verification = 1 +host = :: +port = 8080 +https_port = "" +username = "" +password = "" +bandwidth_max = "" +cache_limit = 1G +web_dir = Glitter +web_color = Auto +https_cert = server.cert +https_key = server.key +https_chain = "" +enable_https = 0 +inet_exposure = 4 +api_key = SERVARRTESTKEY0123456789 +nzb_key = deea96e976a44e00bd3b2f48fa544503 +socks5_proxy_url = "" +permissions = "" +download_dir = Downloads/incomplete +download_free = 500M +complete_dir = Downloads/complete +complete_free = "" +fulldisk_autoresume = 0 +script_dir = "" +nzb_backup_dir = "" +admin_dir = admin +backup_dir = "" +dirscan_dir = "" +dirscan_speed = 5 +password_file = "" +log_dir = logs +max_art_tries = 3 +top_only = 0 +sfv_check = 1 +script_can_fail = 0 +enable_recursive = 1 +flat_unpack = 0 +par_option = "" +pre_check = 0 +nice = "" +win_process_prio = 3 +ionice = "" +fail_hopeless_jobs = 1 +fast_fail = 1 +auto_disconnect = 1 +pre_script = None +end_queue_script = None +no_dupes = 0 +no_series_dupes = 0 +no_smart_dupes = 0 +dupes_propercheck = 1 +pause_on_pwrar = 1 +ignore_samples = 0 +deobfuscate_final_filenames = 1 +auto_sort = "" +direct_unpack = 0 +propagation_delay = 0 +folder_rename = 1 +replace_spaces = 0 +replace_underscores = 0 +replace_dots = 0 +safe_postproc = 1 +pause_on_post_processing = 0 +enable_all_par = 0 +sanitize_safe = 0 +cleanup_list = , +unwanted_extensions = , +action_on_unwanted_extensions = 0 +unwanted_extensions_mode = 0 +new_nzb_on_failure = 0 +history_retention = "" +history_retention_option = all +history_retention_number = 0 +quota_size = "" +quota_day = "" +quota_resume = 0 +quota_period = m +enable_tv_sorting = 0 +tv_sort_string = "" +tv_categories = tv, +enable_movie_sorting = 0 +movie_sort_string = "" +movie_sort_extra = -cd%1 +movie_categories = movies, +enable_date_sorting = 0 +date_sort_string = "" +date_categories = tv, +schedlines = , +rss_rate = 60 +ampm = 0 +start_paused = 0 +preserve_paused_state = 0 +enable_par_cleanup = 1 +process_unpacked_par2 = 1 +enable_unrar = 1 +enable_7zip = 1 +enable_filejoin = 1 +enable_tsjoin = 1 +overwrite_files = 0 +ignore_unrar_dates = 0 +backup_for_duplicates = 0 +wait_for_dfolder = 0 +rss_filenames = 0 +api_logging = 1 +html_login = 1 +disable_archive = 0 +warn_dupl_jobs = 0 +keep_awake = 1 +tray_icon = 1 +allow_incomplete_nzb = 0 +enable_broadcast = 1 +ipv6_hosting = 0 +ipv6_staging = 0 +api_warnings = 1 +no_penalties = 0 +x_frame_options = 1 +allow_old_ssl_tls = 0 +enable_season_sorting = 1 +verify_xff_header = 1 +direct_write = 1 +rss_odd_titles = nzbindex.nl/, nzbindex.com/, nzbclub.com/ +quick_check_ext_ignore = nfo, sfv, srr +req_completion_rate = 100.2 +selftest_host = self-test.sabnzbd.org +movie_rename_limit = 100M +episode_rename_limit = 20M +size_limit = 0 +direct_unpack_threads = 3 +history_limit = 10 +wait_ext_drive = 5 +max_foldername_length = 246 +nomedia_marker = "" +ipv6_servers = 1 +url_base = "" +host_whitelist = sabnzbd.localhost.pomerium.io, sabnzbd, localhost, 127.0.0.1, +local_ranges = , +max_url_retries = 10 +downloader_sleep_time = 10 +receive_threads = 2 +assembler_max_queue_size = 12 +switchinterval = 0.005 +ssdp_broadcast_interval = 15 +ext_rename_ignore = , +unrar_parameters = "" +outgoing_nntp_ip = "" +email_server = "" +email_to = , +email_from = "" +email_account = "" +email_pwd = "" +email_endjob = 0 +email_full = 0 +email_dir = "" +email_rss = 0 +email_cats = *, +[logging] +log_level = 1 +max_log_size = 5242880 +log_backups = 5 +[ncenter] +ncenter_enable = 0 +ncenter_cats = *, +ncenter_prio_startup = 0 +ncenter_prio_download = 0 +ncenter_prio_pause_resume = 0 +ncenter_prio_pp = 0 +ncenter_prio_complete = 1 +ncenter_prio_failed = 1 +ncenter_prio_disk_full = 1 +ncenter_prio_quota = 1 +ncenter_prio_new_login = 0 +ncenter_prio_warning = 0 +ncenter_prio_error = 0 +ncenter_prio_queue_done = 0 +ncenter_prio_other = 1 +[acenter] +acenter_enable = 0 +acenter_cats = *, +acenter_prio_startup = 0 +acenter_prio_download = 0 +acenter_prio_pause_resume = 0 +acenter_prio_pp = 0 +acenter_prio_complete = 1 +acenter_prio_failed = 1 +acenter_prio_disk_full = 1 +acenter_prio_quota = 1 +acenter_prio_new_login = 0 +acenter_prio_warning = 0 +acenter_prio_error = 0 +acenter_prio_queue_done = 0 +acenter_prio_other = 1 +[ntfosd] +ntfosd_enable = 1 +ntfosd_cats = *, +ntfosd_prio_startup = 0 +ntfosd_prio_download = 0 +ntfosd_prio_pause_resume = 0 +ntfosd_prio_pp = 0 +ntfosd_prio_complete = 1 +ntfosd_prio_failed = 1 +ntfosd_prio_disk_full = 1 +ntfosd_prio_quota = 1 +ntfosd_prio_new_login = 0 +ntfosd_prio_warning = 0 +ntfosd_prio_error = 0 +ntfosd_prio_queue_done = 0 +ntfosd_prio_other = 1 +[prowl] +prowl_enable = 0 +prowl_cats = *, +prowl_apikey = "" +prowl_prio_startup = -3 +prowl_prio_download = -3 +prowl_prio_pause_resume = -3 +prowl_prio_pp = -3 +prowl_prio_complete = 0 +prowl_prio_failed = 1 +prowl_prio_disk_full = 1 +prowl_prio_quota = 0 +prowl_prio_new_login = -3 +prowl_prio_warning = -3 +prowl_prio_error = -3 +prowl_prio_queue_done = -3 +prowl_prio_other = 0 +[pushover] +pushover_token = "" +pushover_userkey = "" +pushover_device = "" +pushover_emergency_expire = 3600 +pushover_emergency_retry = 60 +pushover_enable = 0 +pushover_cats = *, +pushover_prio_startup = -3 +pushover_prio_download = -2 +pushover_prio_pause_resume = -2 +pushover_prio_pp = -3 +pushover_prio_complete = -1 +pushover_prio_failed = -1 +pushover_prio_disk_full = 1 +pushover_prio_quota = -1 +pushover_prio_new_login = -3 +pushover_prio_warning = 1 +pushover_prio_error = 1 +pushover_prio_queue_done = -3 +pushover_prio_other = -1 +[pushbullet] +pushbullet_enable = 0 +pushbullet_cats = *, +pushbullet_apikey = "" +pushbullet_device = "" +pushbullet_prio_startup = 0 +pushbullet_prio_download = 0 +pushbullet_prio_pause_resume = 0 +pushbullet_prio_pp = 0 +pushbullet_prio_complete = 1 +pushbullet_prio_failed = 1 +pushbullet_prio_disk_full = 1 +pushbullet_prio_quota = 1 +pushbullet_prio_new_login = 0 +pushbullet_prio_warning = 0 +pushbullet_prio_error = 0 +pushbullet_prio_queue_done = 0 +pushbullet_prio_other = 1 +[apprise] +apprise_enable = 0 +apprise_cats = *, +apprise_urls = "" +apprise_target_startup = "" +apprise_target_startup_enable = 0 +apprise_target_download = "" +apprise_target_download_enable = 0 +apprise_target_pause_resume = "" +apprise_target_pause_resume_enable = 0 +apprise_target_pp = "" +apprise_target_pp_enable = 0 +apprise_target_complete = "" +apprise_target_complete_enable = 1 +apprise_target_failed = "" +apprise_target_failed_enable = 1 +apprise_target_disk_full = "" +apprise_target_disk_full_enable = 0 +apprise_target_quota = "" +apprise_target_quota_enable = 1 +apprise_target_new_login = "" +apprise_target_new_login_enable = 1 +apprise_target_warning = "" +apprise_target_warning_enable = 0 +apprise_target_error = "" +apprise_target_error_enable = 0 +apprise_target_queue_done = "" +apprise_target_queue_done_enable = 0 +apprise_target_other = "" +apprise_target_other_enable = 1 +[nscript] +nscript_enable = 0 +nscript_cats = *, +nscript_script = "" +nscript_parameters = "" +nscript_prio_startup = 0 +nscript_prio_download = 0 +nscript_prio_pause_resume = 0 +nscript_prio_pp = 0 +nscript_prio_complete = 1 +nscript_prio_failed = 1 +nscript_prio_disk_full = 1 +nscript_prio_quota = 1 +nscript_prio_new_login = 0 +nscript_prio_warning = 0 +nscript_prio_error = 0 +nscript_prio_queue_done = 0 +nscript_prio_other = 1 diff --git a/content/examples/guides/servarr/validate/seed/sonarr.config.xml b/content/examples/guides/servarr/validate/seed/sonarr.config.xml new file mode 100644 index 000000000..0b328e2bc --- /dev/null +++ b/content/examples/guides/servarr/validate/seed/sonarr.config.xml @@ -0,0 +1,13 @@ + + * + 8989 + False + False + SERVARRTESTKEY0123456789 + External + DisabledForLocalAddresses + main + info + + Sonarr + diff --git a/content/examples/guides/servarr/validate/url.txt b/content/examples/guides/servarr/validate/url.txt new file mode 100644 index 000000000..caad1e0ec --- /dev/null +++ b/content/examples/guides/servarr/validate/url.txt @@ -0,0 +1 @@ +https://sonarr.localhost.pomerium.io From da050eea49dc3ebd1d30079c4e5c85c547de0094 Mon Sep 17 00:00:00 2001 From: Bobby DeSimone Date: Wed, 10 Jun 2026 09:58:15 -0700 Subject: [PATCH 2/3] docs(guides): clean up servarr guide prose and diagrams Address review feedback: condense duplicated value framing, use canonical Pomerium terms (front door, route, policy, the From URL), replace the client-gating SVG with a markdown table, simplify the mermaid diagram, rewrite double-hyphen and em-dash punctuation per the guide style rules, reorder config sections, and scope prerequisites to what each tab actually needs. --- .../img/servarr/servarr-gating-matrix.svg | 80 ------------------- content/docs/guides/servarr.mdx | 77 ++++++++---------- 2 files changed, 32 insertions(+), 125 deletions(-) delete mode 100644 content/docs/guides/img/servarr/servarr-gating-matrix.svg diff --git a/content/docs/guides/img/servarr/servarr-gating-matrix.svg b/content/docs/guides/img/servarr/servarr-gating-matrix.svg deleted file mode 100644 index 3926819d6..000000000 --- a/content/docs/guides/img/servarr/servarr-gating-matrix.svg +++ /dev/null @@ -1,80 +0,0 @@ - - Which access channels Pomerium gates for each Servarr app - A matrix with four rows (Sonarr, Radarr, Prowlarr, SABnzbd) and three - columns. The Web UI column is gated by Pomerium single sign-on for every app - (filled marker). The HTTP API column is reached with each app's own API key, not - Pomerium identity (open marker). The Native or mobile client column bypasses the - browser SSO flow entirely and must be tunneled separately (dash). Pomerium gates - the browser front door; each app's API key remains a separate credential. - - - - - - - Pomerium gates the web front door, not the app credential - Each Servarr app keeps its own API key; the *arr apps have no OIDC, JWT, or header identity. - - - WEB UI (browser) - HTTP API - NATIVE / MOBILE - - - - - - - Sonarr - TV . :8989 - - - - - - Radarr - Movies . :7878 - - - - - - Prowlarr - Indexers . :9696 - - - - - - SABnzbd - Usenet . :8080 - - - - - - - - gated by Pomerium SSO - - reached with the app's own API key - - bypasses browser SSO (tunnel separately) - diff --git a/content/docs/guides/servarr.mdx b/content/docs/guides/servarr.mdx index 6333b271b..09b284465 100644 --- a/content/docs/guides/servarr.mdx +++ b/content/docs/guides/servarr.mdx @@ -32,52 +32,39 @@ import Compose from '/content/examples/guides/servarr/docker-compose.yaml.md'; The "Servarr" apps are a family of self-hosted media-automation tools that are almost always run together: [Sonarr](https://sonarr.tv/) (TV), [Radarr](https://radarr.video/) (movies), [Prowlarr](https://prowlarr.com/) (indexer management), and a download client like [SABnzbd](https://sabnzbd.org/) (usenet). Each ships its own web interface and HTTP API on its own port, and none of them speak single sign-on (SSO), the [OpenID Connect (OIDC)](https://openid.net/developers/how-connect-works/) protocol, or any header-based identity. Their built-in access control is per-app web UI authentication plus a static API key for HTTP API and inter-app calls. -You'll put the whole suite behind one Pomerium so that Pomerium becomes the single front door for every app's web interface: each request is authenticated against your identity provider (IdP) and checked against your policy before it ever reaches Sonarr, Radarr, Prowlarr, or SABnzbd. The apps keep their own API keys for programmatic and inter-app calls; Pomerium gates the browser surface in front of all of them. +You'll put the whole suite behind one Pomerium so that Pomerium becomes the single front door for every app's web interface: each request is authenticated against your identity provider (IdP) and checked against your policy before it ever reaches Sonarr, Radarr, Prowlarr, or SABnzbd. The apps keep their own API keys for programmatic and inter-app calls; Pomerium protects browser access to all of them. ## When to use this guide Use it when you run a Servarr stack and want one identity-aware front door for the whole suite instead of exposing four separate web UIs, each with its own local login and separate API key for automation. In a typical home or lab deployment these apps run on a separate host or network-attached storage (NAS) box and are only meant to be reached over your private network; Pomerium lets you reach them from anywhere with your existing identity, while keeping their ports off the public internet. -The win is not replacing each app's own access controls. The win is centralized SSO, group-based policy, an audit trail of who reached which app, and shrinking the attack surface to a single authenticated entry point. The trade-off to be honest about up front: because the apps consume no identity from Pomerium, this is a front-door gate, and each app's API key remains a separate credential you still have to protect. +You get centralized SSO, group-based policy, an audit trail of who reached which app, and a single authenticated entry point instead of four exposed ports. The trade-off: the apps consume no identity from Pomerium, so this is a front-door gate, and each app's API key remains a separate credential you still have to protect. -### What Pomerium gates, and what it does not +### What Pomerium protects, and what it doesn't -A Servarr stack is reached through three different channels, and Pomerium only sits in front of one of them. This matrix is the mental model for the rest of the guide: +A Servarr stack is reached through three channels, and Pomerium sits in front of only one of them. The same model applies to all four apps: -![Matrix: Pomerium gates each app's web UI; the API uses the app's own key and native/mobile clients bypass the gate](./img/servarr/servarr-gating-matrix.svg) - -- **Web UI (browser).** This is what Pomerium gates. Every app's dashboard is reached only after SSO and a policy check. -- **HTTP API.** Reached with the app's own API key. Prowlarr talks to Sonarr and Radarr this way; once a request reaches an app with a valid API key, the app authorizes it independently of your Pomerium session. Keep these calls on the internal network (see [Security considerations](#security-considerations)). -- **Native or mobile clients.** Apps like mobile remotes expect a direct connection and do not run a browser SSO flow. Those bypass Pomerium's interactive gate and need a separate path, such as a [TCP tunnel](/docs/capabilities/non-http) or a VPN. +| Access channel | What gates it | Credential the client presents | +| --- | --- | --- | +| Web UI (browser) | Pomerium route policy (SSO and a policy check) before any request reaches an app | Pomerium SSO session | +| HTTP API, including Prowlarr's calls into Sonarr and Radarr | The app itself, independent of any Pomerium session; keep these calls on the internal network (see [Security considerations](#security-considerations)) | The app's API key | +| Native or mobile clients | The app itself: these clients can't run a browser SSO flow and need a separate path, such as a virtual private network (VPN) or a [TCP tunnel](/docs/capabilities/non-http) on a device that can run Pomerium CLI | The app's API key | ```mermaid -sequenceDiagram - participant User as Browser - participant Pomerium as Pomerium - participant IdP as Identity Provider - participant App as Sonarr / Radarr / Prowlarr / SABnzbd - participant Bypass as Native client (API key) - - User->>Pomerium: GET https://sonarr.yourdomain.com - Pomerium->>IdP: Redirect for authentication - IdP->>Pomerium: Authenticated identity - Pomerium->>Pomerium: Evaluate policy (group / email) - Pomerium->>App: Forward request (no identity header) - App->>User: App web UI - Note over Bypass,App: Bypass risk: a client with the app's API key
that can reach the app port skips Pomerium entirely - Bypass--xApp: Direct API call if the port is exposed +flowchart LR + Browser --> Pomerium["Pomerium
SSO + route policy"] + Pomerium -.->|"sign in"| IdP[Identity provider] + Pomerium --> Apps["Sonarr / Radarr /
Prowlarr / SABnzbd"] + Clients["API / native clients"] -->|"app API key"| Apps ``` -The dashed line is the reason network isolation matters: keep the app ports off published interfaces and reachable only through Pomerium, or the API key becomes the whole security model. +Inter-app calls (Prowlarr into Sonarr and Radarr) happen on the internal Docker network with nothing but an API key, and any client that can reach an app's port is authorized the same way. Keep the app ports off published interfaces and reachable only through Pomerium, or the API key becomes the whole security model. ## Prerequisites -This guide assumes you've completed the [Quickstart](/docs/get-started/quickstart), so you already have Pomerium running and signing users in through the hosted authenticate service. - -You also need: - - [Docker](https://docs.docker.com/install/) and [Docker Compose](https://docs.docker.com/compose/install/) -- A domain you control for the routes (this guide uses `sonarr.yourdomain.com`, `radarr.yourdomain.com`, `prowlarr.yourdomain.com`, and `sabnzbd.yourdomain.com`) +- For the Pomerium Zero path: a [Pomerium Zero](https://console.pomerium.app) account with its Pomerium instance running locally via the [Quickstart](/docs/get-started/quickstart) Compose file; the routes use the starter domain that comes with it +- For the Pomerium Core path: a domain you control for the routes (this guide uses `sonarr.yourdomain.com`, `radarr.yourdomain.com`, `prowlarr.yourdomain.com`, and `sabnzbd.yourdomain.com`), with DNS pointed at the host running Pomerium and ports 80 and 443 reachable so `autocert` can provision certificates; the Compose file below runs Pomerium itself :::tip Prefer to self-host the identity provider? @@ -87,17 +74,17 @@ This guide uses the hosted authenticate service so you don't have to run your ow ## Configure Pomerium -You'll create one route per app. The routes are plain HTTP front-door gates: Pomerium authenticates the user and proxies the request through without injecting any identity, because the apps have no header or token identity to consume. +You'll create one route per app. Each route does the same thing: Pomerium authenticates the user, checks policy, and proxies the request through without injecting any identity, because the apps have no header or token identity to consume. In the [Zero Console](https://console.pomerium.app), create one **Route** per app: -1. **Sonarr** -- **From** `https://sonarr.`, **To** `http://sonarr:8989`. -2. **Radarr** -- **From** `https://radarr.`, **To** `http://radarr:7878`. -3. **Prowlarr** -- **From** `https://prowlarr.`, **To** `http://prowlarr:9696`. -4. **SABnzbd** -- **From** `https://sabnzbd.`, **To** `http://sabnzbd:8080`. On this route, enable **Preserve Host Header**: SABnzbd checks the incoming host against its `host_whitelist`, so it must see the public name. +1. **Sonarr:** **From** `https://sonarr.`, **To** `http://sonarr:8989`. +2. **Radarr:** **From** `https://radarr.`, **To** `http://radarr:7878`. +3. **Prowlarr:** **From** `https://prowlarr.`, **To** `http://prowlarr:9696`. +4. **SABnzbd:** **From** `https://sabnzbd.`, **To** `http://sabnzbd:8080`. On this route, enable **Preserve Host Header**: SABnzbd checks the incoming host against its `host_whitelist`, so it must see the public name. Set each route's policy to scope access to who should reach the suite (for example, **Any Authenticated User** or a specific group). Zero manages the routes' TLS certificates behind your starter domain. @@ -117,14 +104,14 @@ Replace each `*.yourdomain.com` host with your domain and `you@example.com` with The [LinuxServer.io](https://www.linuxserver.io/) images for these apps generate their config on first start, so there is little to set by hand. The two things that matter for running behind Pomerium: -- **Authentication method.** For Sonarr, Radarr, and Prowlarr, `External` is configured in `config.xml`, not from the normal settings UI. Stop each container, edit its `/config/config.xml` so the top-level auth entries are `External` and `DisabledForLocalAddresses`, then restart. Be precise about what this does: it tells the app to skip its own web-UI login for requests that arrive from a local/private network address, on the assumption that a reverse proxy already authenticated the user. The app does not validate a Pomerium identity or a signed header -- it trusts the network position. Pomerium is that reverse proxy, which is exactly why the app ports must be reachable only through Pomerium (see [Security considerations](#security-considerations)). The API key still guards API calls regardless of this setting. -- **API keys.** Each app shows its API key under **Settings -> General**. Prowlarr uses these keys to push indexer config into Sonarr and Radarr, and any external client uses them too. Treat each key like a password. +- **Authentication method.** For Sonarr, Radarr, and Prowlarr, `External` is configured in `config.xml`, not from the normal settings UI. Stop each container, edit its `/config/config.xml` so the top-level auth entries are `External` and `DisabledForLocalAddresses`, then restart. This tells the app to skip its own web-UI login for requests that arrive from a local/private network address, on the assumption that a reverse proxy already authenticated the user. The app does not validate a Pomerium identity or a signed header; it trusts the network position. Pomerium is that reverse proxy, so the app ports must be reachable only through Pomerium (see [Security considerations](#security-considerations)). The API key still guards API calls regardless of this setting. +- **API keys.** Each app shows its API key under **Settings > General**. Prowlarr uses these keys to push indexer config into Sonarr and Radarr, and any external client uses them too. Treat each key like a password. -SABnzbd is configured the same way in spirit: under **Config -> General**, note its API key, and add your public SABnzbd host to **Config -> Special -> host_whitelist** so it accepts the host Pomerium forwards. +For SABnzbd, note its API key under **Config > General**, and add your public SABnzbd host to **Config > Special > host_whitelist** so it accepts the host Pomerium forwards. :::caution The API key is a separate credential from your SSO identity -Signing in through Pomerium does not authenticate you to an app's API. The API key is the app's own credential and is not derived from your Pomerium session. Anyone who can present a valid API key to an app, and reach its port, is authorized by that app -- which is exactly why the next sections keep the app ports off the network. +Signing in through Pomerium does not authenticate you to an app's API. The API key is the app's own credential and is not derived from your Pomerium session. Anyone who can present a valid API key to an app, and reach its port, is authorized by that app; that's why the next sections keep the app ports off the network. ::: @@ -150,12 +137,12 @@ Add `-v` only if you mean to delete the named volumes, including each app's conf ## Verify the setup -1. **The route requires authentication.** In a fresh browser, open `https://sonarr.yourdomain.com`. You should be redirected to sign in through Pomerium, not straight into Sonarr. Repeat for `radarr`, `prowlarr`, and `sabnzbd` -- every app is gated. -2. **An allowed user reaches each app.** Sign in with a user your policy allows. Pomerium redirects you back and the app's own dashboard loads behind the gate. +1. **The route requires authentication.** In a fresh browser, open `https://sonarr.yourdomain.com`. You should be redirected to sign in through Pomerium, not straight into Sonarr. Repeat for `radarr`, `prowlarr`, and `sabnzbd` to confirm every app is gated. +2. **An allowed user reaches each app.** Sign in with a user your policy allows. Pomerium redirects you back and the app's own dashboard loads. ![The Sonarr web interface reached through Pomerium after single sign-on](./img/servarr/servarr-sonarr.png) -3. **The API answers its own key, not your session.** Pomerium still gates the route, so reach these from your signed-in browser. For Pomerium Zero or Enterprise routes, you can also use a [Pomerium service account](/docs/capabilities/service-accounts) token that your route policy allows; for self-managed Core without service accounts, use an interactive browser session. A plain `curl` with no Pomerium session is redirected to sign in. Past the gate, each app's status API responds to its own API key. The path differs by app -- Sonarr and Radarr use `/api/v3`, Prowlarr uses `/api/v1`, and SABnzbd uses its `mode` query: +3. **The API answers its own key, not your session.** Pomerium still gates the route, so reach these from your signed-in browser. For Pomerium Zero or Enterprise routes, you can also use a [Pomerium service account](/docs/capabilities/service-accounts) token that your route policy allows; for self-managed Core without service accounts, use an interactive browser session. A plain `curl` with no Pomerium session is redirected to sign in. Once you're signed in, each app's status API responds to its own API key. The path differs by app (Sonarr and Radarr use `/api/v3`, Prowlarr uses `/api/v1`, and SABnzbd uses its `mode` query): ```bash export POMERIUM_SERVICE_ACCOUNT_JWT='raw-service-account-jwt' @@ -173,7 +160,7 @@ curl -H "Authorization: Bearer Pomerium-${POMERIUM_SERVICE_ACCOUNT_JWT}" \ "https://sabnzbd.yourdomain.com/api?mode=queue&output=json&apikey=YOUR_SABNZBD_KEY" ``` -Pomerium gates the browser surface; the apps keep their own API keys behind that gate. If you leave Forms or Basic auth enabled instead of `External`, that local login remains as an additional prompt. First-run setup is each app's concern, not Pomerium's. +Pomerium protects browser access; the apps keep their own API keys behind it. If you leave Forms or Basic auth enabled instead of `External`, that local login remains as an additional prompt. First-run setup is each app's concern, not Pomerium's. ## Common failure modes @@ -191,7 +178,7 @@ Pomerium gates the browser surface; the apps keep their own API keys behind that ## Next steps -- [Secure Transmission with Pomerium](/docs/guides/transmission) -- the same front-door pattern for a BitTorrent download client, including its RPC host-whitelist mechanics. +- [Secure Transmission with Pomerium](/docs/guides/transmission): the same front-door pattern for a BitTorrent download client, including its RPC host-whitelist mechanics. - [Build policies](/docs/get-started/fundamentals/zero/zero-build-policies) -- [Non-HTTP (TCP) routes](/docs/capabilities/non-http) -- for native or mobile clients that can't run a browser SSO flow. +- [Non-HTTP (TCP) routes](/docs/capabilities/non-http) for native or mobile clients that can't run a browser SSO flow. - [Custom domains](/docs/capabilities/custom-domains) From 10e965e98a3ea691cb69e906419e7fb9d95baded Mon Sep 17 00:00:00 2001 From: Bobby DeSimone Date: Wed, 10 Jun 2026 14:33:12 -0700 Subject: [PATCH 3/3] docs(guides): restore real em dashes per style policy change The guide-review style rule now welcomes real em dashes and only bans double-hyphen fakes, so restore the reviewer's verbatim suggestion and the em-dash section headings. --- content/docs/guides/servarr.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/guides/servarr.mdx b/content/docs/guides/servarr.mdx index 09b284465..2a6f7f8d7 100644 --- a/content/docs/guides/servarr.mdx +++ b/content/docs/guides/servarr.mdx @@ -40,7 +40,7 @@ Use it when you run a Servarr stack and want one identity-aware front door for t You get centralized SSO, group-based policy, an audit trail of who reached which app, and a single authenticated entry point instead of four exposed ports. The trade-off: the apps consume no identity from Pomerium, so this is a front-door gate, and each app's API key remains a separate credential you still have to protect. -### What Pomerium protects, and what it doesn't +### What Pomerium protects — and what it doesn't A Servarr stack is reached through three channels, and Pomerium sits in front of only one of them. The same model applies to all four apps: