From c6042de9c99a6ed2f79bef4151ddcf5ac4e1acaf Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Thu, 13 Jun 2024 14:13:43 -0500 Subject: [PATCH 01/15] Fix bug where MDM migration fails when attempting to renew enrollment profiles on macOS Sonoma devices (#19726) --- changes/19512-mdm-migration-sonoma | 1 + .../AutoEnrollMdmModal/AutoEnrollMdmModal.tsx | 29 ++++- .../details/DeviceUserPage/DeviceUserPage.tsx | 2 +- orbit/pkg/profiles/profiles_darwin.go | 2 +- orbit/pkg/update/execcmd_darwin.go | 3 +- orbit/pkg/update/notifications.go | 13 ++- orbit/pkg/useraction/mdm_migration_darwin.go | 106 ++++++++++++++---- .../mdm-migration-sonoma-1500x938.png | Bin 0 -> 120203 bytes 8 files changed, 118 insertions(+), 38 deletions(-) create mode 100644 changes/19512-mdm-migration-sonoma create mode 100644 website/assets/images/permanent/mdm-migration-sonoma-1500x938.png diff --git a/changes/19512-mdm-migration-sonoma b/changes/19512-mdm-migration-sonoma new file mode 100644 index 0000000000..d82dff2208 --- /dev/null +++ b/changes/19512-mdm-migration-sonoma @@ -0,0 +1 @@ +- Fixed bug where MDM migration failed when attempting to renew enrollment profiles on macOS Sonoma devices. diff --git a/frontend/pages/hosts/details/DeviceUserPage/AutoEnrollMdmModal/AutoEnrollMdmModal.tsx b/frontend/pages/hosts/details/DeviceUserPage/AutoEnrollMdmModal/AutoEnrollMdmModal.tsx index 3114d73530..2f40fa82b6 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/AutoEnrollMdmModal/AutoEnrollMdmModal.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/AutoEnrollMdmModal/AutoEnrollMdmModal.tsx @@ -2,16 +2,27 @@ import React from "react"; import Button from "components/buttons/Button"; import Modal from "components/Modal"; +import { IDeviceUserResponse } from "interfaces/host"; interface IAutoEnrollMdmModalProps { + host: IDeviceUserResponse["host"]; onCancel: () => void; } const baseClass = "auto-enroll-mdm-modal"; const AutoEnrollMdmModal = ({ + host: { platform, os_version }, onCancel, }: IAutoEnrollMdmModalProps): JSX.Element => { + let isMacOsSonomaOrLater = false; + if (platform === "darwin" && os_version.startsWith("macOS ")) { + const [major] = os_version + .replace("macOS ", "") + .split(".") + .map((s) => parseInt(s, 10)); + isMacOsSonomaOrLater = major >= 14; + } return (
  1. - Open your Mac’s notification center by selecting the date and time - in the top right corner of your screen. + From the Apple menu in the top left corner of your screen, select{" "} + System Settings or System Preferences.
  2. - Select the Device Enrollment notification. This will open{" "} - System Settings or System Preferences. Select{" "} - Allow. + {isMacOsSonomaOrLater ? ( + <> + In the sidebar menu, select Enroll in Remote Management, + and select Enroll. + + ) : ( + <> + In the search bar, type “Profiles.” Select Profiles, find + and select Enrollment Profile, and select Install. + + )}
  3. Enter your password, and select Enroll. diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index d950153f48..16b38e11a8 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -311,7 +311,7 @@ const DeviceUserPage = ({ const renderEnrollMdmModal = () => { return host?.dep_assigned_to_fleet ? ( - + ) : ( H1QigFhrj>>3@~$Nn0xOz=bp!ZSH8V>KdP#$x_f`$_rJs3 z(=~I?|J&VF)z!Vbd)KeJcW?Z{FaEO6+yTG@$J1{4_bgp+(YaGHmQ6cHc5?W8$~;BA zf928{980{-r%t|R@fV$ZC!nKMa|H+3eWdsibOD17ryV?x$*SDc&wIK3cF=nZ#eQ&M zr1j867Y8e)w~rqAQ1|TWeVp~vbMJlVdv>z1ybHRbGq#1!Om)qahKhsAEs~95cUu*9 zk}cieyWTtJ6LaCBEns|M8LG$!l`)+_jBWqjRL)7t{U+LYrnD zGSVQVQ~D+Ippp`k305n(K3x8z^e+Ka?ZT$@l6vdirqgISL=SaIP~Hc4W6a)lE54kK4eP zMQef6)*{ip5KVwacArjLUzJa1*Yt(X*<+eFrmE%lO;o>f>DaME$0(p?$LZHj=Fd&G zgc2=>V)EEIp_5tg?pWfkcmPqzL*2GJ#&JL$Q>jXJsT-E&@4ztK?C)fhRKE@8eWe~| zDw5_DzQ7XwszL=a-^g*2#GDe8EOX{gF$H<+`H129te^j=oxq|@TxC)EpWJ>`l&wdU zhowxRxUMIUQ-3_x9us!*4mw0)>0Nw%)IV^#%5n$D<-%+89-Ulgi#Fowq>k;~NK6Mq?uhVSDQ{T(e zc||@QZSyJmf9{)RcAt_H5t&K-&xkfBb!JlGsU6rFJz|^cX#C!I4>oZd(+)KSj1>Kx zBVQ6p@(8@l@(R-!fakX>T{C2#f1)g%O3+KnUY#bhaRL9N`lI!XPeBS+CTXybnNN>Y zR5FaI7|&!hxT?8HhU$|MZuc%X5~=b|LtTE|#&XHHaR)te$)C^tvg?FhTs7T&ve==i zr?e^upNCG>qifFw>M3cf9kgxwPjluaJSU3DlHi6sMs$=XwvW<}Qz*H$KpE_6Ua$`>h#2TCR|S0!3c2jTc|_RcPU z3AYBihJPz6a+zfxEOvnIV;Y#spL#ktFd`sQi*ZC1v5G_Q+C?qqPO=6QdE>^sFikm# zP8B!pmEY7}#~R>uhRZ3fWdSdV^3Scpr!mwsU|W+lbVr>XiaLGUqc(g*C7mRSn!Jn- z?aZ}_YP=?ibC|-;CD0WBJ~PEft(c;afqV_v*|Ql9xs)3U*7gY-FasqKe2_(poh8`@ z)~uxY<$Xuic)A;#-M0cLd)$o1qoYBG!=9FVddL7h<&3&T>m~$2zFf8Z-e>wIq62ju z*3#6GV&&_(pl~tNKsmM_6qAg~Vt;&wt>wX%?r#`D?Gms{bp;WHr1NQN4DBs#K*f)Z za<&Pg#wNot1!y+he#5#!|XP_`dj_HSC8j_<3_uv1>9{Jk7Qi96OElHIP;v) zrt(VWQ##*AD7HnrriXr;h)y$~`R>CZ^I5F`C+VPp?kGIEziw#%S_byt8@YN7Rab~2 zKbl3dAL3`pmrG|#D>i}#IeKlH44&TBw5*g$JY8r6LoA=be5S;}LItH)h|x&$cGTFnx4$|18+-=-PY7zkooKP-^3N;XFIsm9k< zWe3<2Vd&<&$DEbTz}{p06%AO9DZqZ%_GvtI#V);}4kl8g)Flg9cRW{)38HNVT_>Cu z3MaTXnsF(&`lRmZaYa2Bw6Qw!QF=sMG@iegacg_x0G*i!7TA@wQRj(T3R_v(yCjDT zm5^mB`UMouJZe3Z(}~IHKm&$|0U2ddP-+DyFiMeKev(1uQv?yz!3(}6TeSy<3P_u| zEYr{1hbNKpQPhLi%mYW4LDqf7F0MLl_OK5*c%!q-X4wa7>}g+1fNC<8dl*xC>p^wZ z9TH`7p%Wd(%|spI$M=z9qN%ZwDOeHklD67izYirU+~AEL%EBhA^XcgOaDp4Ti4Gky zJ$m^K=+Nh~A7bO-Qy!h*9^i)5wagaBp_CoU)9bcMT4`%phsl=H$m#0Hy`XI}r*H`l zu17gLA4ZqbSyW&2Zu)`dAKS;b_Mz=g#A(XCJSsvF6?$%;Q-_px`R#cb0f1)a>5A+s z=5p%zD=q-AyPfEaI+8b&7b@Dq z4F_}t|IXDV4x?=$W9pJ^-Lt;3f=ow4^muM&vUekOif4Fn4%)5aFY)Q^L;w8&2~gnV4=+CtQ8{; z^KmsCn5U!c1QG*dT_;uYG7}q2%@vnULMKViT$e;`{fDj?&;dCq8;{6ecrO2d#ZA*t zk0mJn^r>+=@0Jw{E<@eIjqu=Qq21=azwGgHSaI0T*rmQ}&T{Bz0B}GPpXP~qye-v7 zVbf1KzHjwRUHzKcNdV9_4`NWz@}%M;wR|p`mrH`R87Li=XS6x)ALLYXx%k|k=1eT! z-6zHGmW%xHj=4DHParFxlw)O^vgpc!p+o3SI-npWQY8_vfa8cdb1%0XII)Y0b$AW$%^Vs=2$sh1gL7m{U%519Z+-Qo{Tb9?R z<_-Wgm7Rc!sSv8QCr_@*ypdW6q?PTVx&w7K6?Mo{Lq{Wq&^KXp44;OVQJYY!qEs&u zou_3@XYJwZf-&f6FEW3gj<)LGYQ(Wd_f5tJ=K~!ZIPIfTK1JCB>=cgERvl^DqQlFt zjoohH=OIzA!*v3`sCDnR-=ot2-_r;mXA7ynhe>bC(rm}n;1J)d@|Y@sYW$6c;AXqS zTw?V{Gk|3#mYyKLj&Pk6=xp1H4u1$PC?$o?_}M|Io1fD%f{ShI0w%2IYu(xMe*Rtn z-=e;t!y&XSP;nVvzOC1{i<|(uG^v8@(|q! z_sL15N_Jh?VWDPhz)I_DqPns23;?nxU&2%RzCGv)jGAwEW%^0i`ni^G={_DJ;6}A{ zDeb&cikf%g*6s<|Dx-(lldok^uPin4w|{f~5XZ1)kDYsZ-N(@uH}8^4?;VPjsp^ov z%o)9hw71&^I||<992g}~{#m8xSD@hN$A34S!ttgKiAx_pPy!P?RZDd~OS+XDs^KKn z$!>J_Jwf}@kEhII8gnxG>PY<%x{V-WOiz4K&QO}pSwUtC<>xo=H~e#b9zTF8Lq#{@ zJeh+DrF`jnQsDy&9*VYN;j$+Cof|3bk@{=2Wlq2%epovb~dGW5FYyoYS`j; zyMm~bujsqfDJP}WF9o7{$F^@;S&G#cH|p=@LWg(Tn5}d7J}g&ef1>Il=8am8>^3zA z-rNnm2aRNBPA;;IIfs9atD(9|#01;`7j^15B{e;*ODXkm2W%5v{;MsrwCy=FZD|X# zt~XWCm@m-es3aUmMbXvCyMKIGG>L-vPcj+#t8q2AVosls+_@uxS=v? z(O#xNo~Vym7qg`}u+QQpWe4W_6(NuOgkaQAmX~Fejqy4tAePU``rKg7taN8?A4}l( zwiQIH(KeFD#s+?*|1 zT)j>1j`G_j_WF(+eC>^6$9mSpdBRh5K21hy>p^zO)Sftu8MKxStRcu2u@sn&Zfo`+ zosi|LMRqlBQAm5>AL2pU^Cnc>fHsw;SJ`U$=EJg4Pp^0n5PQPC6Liat>NkJ3=qczD zCeqEk8)PRqhzcjTvwv+b<8o$5$&P1)88UDljx zw}X2dS>JGqIwvCkfl`cM-AymY9ibC&NQzx#n+4RNQ;1-x<1lJHIdqDDAMnUom?~HG zk`+T|bGUuc$)`?mP6w8!A0AZW?I?lRkNRn^v_b1O&gVXO;HfxV2e}Ey*;P2nHHH(A zH9Wez1^PNf4ms0!M-iFbnG-vFh%IFwMbx}|hf}BB$Ci4`YM!t2P&#oN9#!mC2kcme zQ_0;te|1zNIJ$0H@;>;OZy#GbO|9SbUL)d6Yj^SWf-;3XlHhX-cm zvU)+-Di3mTWgOoMWU%tMF5MRK2i2BcD$hCFSL*Yox6Tp0{Inr;i~dcciVpJ)n_f6} z@{z@7_=UKk?5ggjRGF$29P^l_%9W-xUWtdaPE-QPTx#k=*y59Jsb}KZ&~de^M>^5v z_LF$ltW&)L5L`Vnlwhe5JDSv^)B1rYoh~}dpfda5Vxn;Mk$wMV~@@c)Lr?vgKd2zcfN6*7Y1^~J^-=p2*_`V9`A{WvH+ zS~qhlwrmP*ppm9vaqp;l3B9@PKH3096_Mx!uN_8k&MlfwPoUq=J2?t+K8*ku6+-Bvu{J`^gt z%RZzmEPiZ|>7p<(ZxM1FDNlEWp_NtYIh2<=c(&*}xlATKZr{x1Y?_yChA1zI??22Dd zf`6*&)ABVQi`U{l_DO!qIZEp7vkW`+X~teT4=`Kz+7mMM)yAoz!{e>6nu4`f#luel ztOHQ!eCG6`-4^S@&>JA27DKI&evuPw`=?%io!V*VRO|rYpqT$|n$_{ULxuI=*+On{ z-qWe`=3yh#c?empN!T-h`!~dxq-VX&jd^(KVsh&&w>lxUWJo@syB`KjzbzNSGAh$_ z4k`th4gpI&)Lx&%CagVe+8Iak9wXyPA3vE!I@p2P;04+J;COHpSH`f@S+LlI(|w>G z0)Lk6qV}YC6rD&pf{JYe$ZD?k?2oEi?cKf%k!@~ zh1R64^PZ|wD*(`A67?{FEO!(Cdx9LByKniC0RWqh8!QQ1^*po9`jcWgcJOvmSAXfS zvOX1;j>ZE^Z~6@T1#CzI^V?dwd#BVip5<;Q>s=nB&^; zO^KIe2%%xVDR1ZofGtlUlb*{*>mmAk`)9ZA!;vi$$FB0NgD^F->!Kp(DV<$1?J6%C zZlz76`fZOaR@J#zohoeES`dPIHIbF*O0^(fXJUhcdc->PcIs(VY7GEvz}8$Zf{_R| zxUI^?0(yX-Cp*)`FAgE~kf}DFHv>32<4mKx?-IK^ zmJeL0^fgBZISiQo=~7cZ1k6Uk&|gUgOhCyNB{Uk#;kRuhh5D$WtA{r7^JtS}Lf?o! z&BPis8KezWKS}*<-qXyEMM?)z>9CMlP%gonua}>wL>k_w>)f=+7zS0|gS?#Mo&u&F zhVt&+d^J7?@I}D`tKX#5UHONly=R2()8y-`(Pk&`5SHG1(lqA4N*+sLTu(X?)KopE zdS6hHkNiRwr&T|Hu5lL%xdF&#kl07URErD+rEaAB8)W?|=~;frPh6s|r2yl(Vzbe@(MyKFL(5Ga9=!R5Oe)S+flA>(xb);YI~x5#SgKb7l6!e@Tp_k z>6UEiDr~Nf6)bhKhmToj=%}mZzO#Omc(I~GmG715em!0Ff0Ks{p2~B8)J65)A?GKZ z)g-DUR{a#*s+YVB(K|MIzMBs4BQK*WwLOnlxzI zq7WTL2T_>8o0X^Q(na?%JLM!&T za&C#dAu8U5NTd#3$Iz)JnXKoKY3A56<@Mnf6waWR*(9>J{)0;6C*vlz0VL0~#_~22EmY=O zlU%3Xe$g!;pTOMS)2qS$xt*wSpDN1t0e~_wR>`WG^DM9n+=AN;@mh*dLgnSABkaTj zf{a8ko`Yy-b`{jR-B`J@^mSxmA6sVM_7LbbqeX}K{HC+g!*6Kso+MqO?J|YKMo|8$ z2==6RMMtSKZL5w$csx1n?i@9BCXx<7=io6B5jLgPGCDKS{3#zEl~XE_FXfqvI|OAj{60pD!MEr%Jq8pSfC5NuR<}45ECCM%(j}EnzRVGT#kkR1&xPt8L2hTS zYBRD=^SHVMvTb)yw2y61l|kwbM8!iv6)4~-^Z@87ViiR(ltdFpzKNT;1|n%vdZHbm z>>yPRaT!V=E$NsqiLLhI%rY%$x<0vW%K@Z!2Z(L|lx5ay5~@W}u}<9gM4pD+B#23I zoE5Vaia*lEpwtGG{bS8r^|bu$Lxz(DvNlcO)K2E@)v}Q+1z-;!QViaFscqr9Q0b`N z7wRe3(BTbxv%L1CZQh@nm{ke$szbaBzw~Ie#E$br?c%rl$I(YkQ(RYd8ag*u%@;VI zrfm(}MaArP8HC~?R~xjOh$`g?Hj-IK)wLYf>Etp$VF& z_DirQTwt4@J=ojTqoO?!`9Up8lp#5+*n6V4WtZ5zc^w$Fp^m&SBP?(w$h^Dewu?kKS^rxAPmCs?W zem*9h!muou`eIUq*-mY@GI%X3?*J(E<~oQxmDF;=p?%5dxV!`Uk_Q&LSmV?We&yMWm?ZxYs|-t+OwxpbRj{tMkP;AUtgmG za!S^%{hEwlW;Sen=uoFv)dFhCG_>}}J3L|sz{v(v?@V=UA-o*wg)K*vcM$bX*{u}* zsNGuZhK{m9xtP?f8Sy|WOgAe{f?sq6s_P_IQx#JP=g9?B038sL3TMuG*jrb9jIwfdh`c^uXo;uY790xolw-PxenMK?7Xh)EoGAb&3Subys-G+QG(e|w7{JHg3 zD>>?PaeC)*vP?PPF{T~WLWNk$^kp5E?()0jPVlLu(-BoKduY?65&h)WiPYkGdDEkx&$}F|-GPBZEc5FW*&SX$5r+Yu;NLEyj@6* zU&pJ8XMlrZsi&+$O6j%|Evb%YgwW~fX%@Am2Uc*@(Y`N~uMYJ|nV0%`80XpEuMX&G z_fRJB8Nfpa`*T5VCnD&WJk*om)Ff;$r-~yfKS8^-HD`k6-4i?v^{Y4WApL>g97m z)u;cFW7a6d$V1l=ueo0}l~qO!{x^#Ei(57)-H(-YQ&5g z4v3qT`CDx;__IY7F~L;7yFLDIjjCl2uuH z9wt+~)T2HFcu903=dGul<<^$U!kUchH z<$lMx`{t%f%$$2_&BN;b90pViJCuJ^eEGxR zNtxn-#+D{gX2mm(^~zS1a^tzjHgutUi_&FpfZ#^7+mmz~EQ#cs!ptx&YM9wmi$j}F z$6V~3!-!11M^;I#xR_obC%@=~uvA5h;a)0e#Epu&Ih>3eV$aGZ%fUrLy~$4%it(B|)tc4SZ(B0EZO^?B6D`}5#UDJqr`Fb5<+y;> z=>h;)l{&B-+Q@%M8P64U6yNH{D^qPEEp?Il4W`m5e30@xg*gPS8qI3_R$8at zBDjDX%-zVp@EzM0o5?S*h?V?G>sn2EuQT|$q367h*?nnCmlC{<@!Y4^h)hIza=EK1 zeo-g%1F81p>C{I@1Olo_opXH$%Nojr?&_UisLudm_?MW<)C)-#RS<3jG6?Q~A|{m& zfr0H*?L^*F@5qtf-EVCN&d#aP#>h)&)x_-y%|ehvBM9WKG#u4_3T^hj7h~SJ4l}9; zm?&FxlAYjXb(X^hTnW%9oP6w0#8QuUNe+MQF`x$`Ir-q|QS!CmW7XDEdsPQLPU~#* zAHpua(v&*FMX-3#_{A=a@4cJ+$`rS+SGqzAd>=70fcwqE&N_i>3mUmCTfml~W9n#G z)UI6hxnTu`P|H|vW4G?))*Nm?$&59PsJKY0Ieb2-&F4MdgHO`M>;AG%$D+(Kj5kh$ zGdmAT7vjp@tBvu62Z7BisEm`m%PmBvvNjQzkB+&+PE03C9R<=^Jr&;Wq^g3^ZYsZ& z5@pKsiv!nT4qQS()s%wh8YYHb zn&r8!nviwXuo+_O5I97t_mfUy@-^D1-qC7XlDi9zrTdvG$8>6JMI9PdTj_g5%wuxu z+c$KfaA@PB(~=_Q2-)A}&LJ8{LnSW^bT@FOT@5nw!Rchi6L|t?_g||F?2tQz=)k;4 zd8|<9Jr7c*s;qA+#Sm>}T%U7$z%8$l)QF)&5GC3UP{x~q&3`4c4d;~fb>%EDO}M*YqwQS@OR#XogpG0 zMqIf9X4KZ-N3}_|rH3kfULGkY+m=I9mvKGS_&Vg9M4M%L9f;%T1oLUt4d5Kc)8FCj zsvGvO99X|D-Iq_*N0peUdz>8YJ|1QWcU;YRrrHNK7Kw&FQaoKm9lg{jzoJhw?ku*+ zmfX}Yqa%GlS8ls%Ju;Pk$Q~jBlxgQ*Iic~k^1dwkQsk!+7xBE99_JUb9)QF_j67LC z8XNP4>xA3}Id`op924H8s$kKugS+wPZjnwSXwh{wc)(RF}$%J*&d?0Zeu(Yzj_EeQmS-OiW~!& zwmf8N%CIHJQmf8=hwhkg(a?f+vd`yI-Lbc)Bkm%3`fB-D(HVY|pXX0!p!9m_`dW9o z*7)6em;327TIz(&M16nkI{S0Jb{~2kYEnC>P7NrX-);}s!N~COHNAnMtJr}@|~L6Y$%Agsl#l#F^xIC zULSHBv3IVCyFwN}&FP8-aKOwvsr#MUiV2AsW?mhi7*AxK!43mOQV-@8?B#k|K8=^! zGk~R7kdWnrp43(Y(sm>Joe>k?@|{=GgH1H4ib$T^4w)~#ZBVoshIXCQl)Lr;vSa|{ zy$Xrea+JHBd%|>|G-;7Z%n{qjlIcIksLr=VPrNN$m@3bi$}Azrec-CKBgKvs(;2hA zpYtnz<{ZqsuzIF0X~^`E@{&-`L&oj`HAIT?&F7d)uDT43P-FgQ9P`9&q9;g(c$EwaosWf z)D>+cJ>1Fs-P@jIns%g#W*OGJ(kjQBwH!aE^K>WTY7tIbhL{lli`?q zjEzhINGO8@r#VWOgTSx21|O!v9$i#_3@{lXo*E9bG29>J%=%M=JdbpmHj>J6S;|^0 zm8waDf3Am2Q?_)-KGvX}+#FN$mT?hvSs;C@9*?%_>3z;P+=32#D*7tvNS)+f9-Q9F zOF%;>$vfNXYLEg}+>Y_49@LBCDWjbtAg&fq^97B1^`E;Rr z;}LYE_;zJ@9E0v$zUaZ>E@%{dH1Pv&y2@@VJc$@}@OTWTxbX2!dRR_*0 zPa~1^eWm#w;P8M)3iqFwN|O9;Svrk9{`G-z>18*NWob_djQpt2{gyN-WvGQ68GgC8 zi_K^0v)~Y_Y+es6SuNGG<3%XmrEPUoOb|?7&%d(j^1*`QS8U&K4@G|gzQeVsd2SV2 zR_&ZZloaUF-PSGI%G1pSrwYF)5cAGWe$OJK=y%BaF{*y83*&YX=zW~!n73-Ljh7uJ zzr%}4Z)r<1mzxyF4Q8n+-*PbS1g})Gp}1N6U2dqg3n-_MQmy!3UfxPm&>XGIpsKhH z+%-XUD-zGV<<%IUAuj>9up&+2cO0v+BFOm39^7Ug?0{Y9u)XTZJ?NfmwUq7 zfe9kBUzAO3=0w>}#I^~<`>*R4`#yyPK6ME2&)X~1FD9lsgKopzZ}^^W4EHVmEoT8L z=hLD9mWE(YOX8Y}0Y$v;tNucvky3w0K8_%@D_~81_@PXk7T04*w$Pu~_H05TpBI7~Q|ja8ROUf) z9)@J`Q*t27cCqFX<~R~xi>0E7)endc8%QUFzpSgO%KHtfas?~zHzL9EGqJEQRarB$ zzrqIvMYUVbuCr6mDplOR-0zSTxF+Q>Mlnz6j%|Z!%#eLcqoQO38I4uiLKuTZB<)Tw zPRH>I>mgI%IBV0YGllQCeQ4E7){i5=bWeHtz<*1Cn=x4g&7rSg>~Cr=Vz$Jd$1$t5 zz{~rK)tN3L-dC6oZfLl7L8~eklEFnEVf~aCvK9B$=v<`Gz8p@J z#%N-hCg1>-SU`s;-y=$a5nzMYuvypw{?=d001#9As=m!ZBTJ7nNj@a79MJ3g)_IP^ z3OG#nT|5>jefff)bs!A{(YaG#L1pnNHwWiP>Pb4FurBp805%4t5oL#)FxX6V2OWu-4or zJxZx%o@8$&v`P1+exZiS9-Y)t1#%==USClrV_USNt{trB)HupkPfeRCc_j$;D&2-D zVDoIzwpy-s9Hzrooc_5~I^^)9?fh-QHb$;{>s3>l*K(@{DR!HMS4 zezf&P$4(ArzG|SkGuQ*Jk^quZD;$)NnSc$YRUqm6YbPPfMZn;_79gDoDGwl)pR~L{ zipXp=(1PB)h{{gm7j`@?3Dmta9N3cIs}oexR@W%6z@br(Kx~OXbVYt44A1Fvo%7*C znNI*dF-=zvPNn?Siv$cN#S`a!ZAz|jcK*S*6__3QHIdyj;S~$RKoB{<8E=MA_GdR! zW2@Ly1n;6NVmZvxEunlOLtMq5$Z{N!jn}YpNEs-p8!u`k(v%l~#mmx^4U9a%Rj<0z zI^eSV2usyKa!^Y+od!gLgCRrZ;D z>OQY1U!mXW0ERL?JtK%USePj1^9VBdjnB?0^(}LJM?K*jfYgK4vxwK5+ro9BsOMD9 z9LYYC_aK$Pre2l?ps(|Y<6kRCIXRpE3_tQ%0Fpdl%+4-xv&B8al8ui8KEKDG#xqw0 zT3AthM^-Ujct7Fkq|()6EzLWuKq0S`>Q=GlMSdU3=a1d8h8lT4p}6oBPzQgNs56=R zwG@H4whgp}U6c_uw9D9@1KsUgJpD6G25+oyj9xC=Yz9aMg2c#o?cW zY~5H4BQ$=Q%sqB(7^E!@gbp*X+C@qYxUEWlTY>ggd>u5EZ0Vss)1;GGx%%YJb(>`V z6elVBxMX}-QMPqTsq2F_Jh2eCLqO8$OI}!3EFF6d#$r$a3;UuI!TGbbvB*l$Bsz7; zHn5$+I7d*q?JzH4+47uqae~k}7EcL^+7eF)7bx3OG!d4%1wcwAFz2{1#K%sq%k1{yn*$a8wub|>OK%F0zn5XivA*y zo89KcnB(OkD=PwYP~&1bWGs)0TtnS1l(r>nOX6!jXp78z)C0k*g z>$13*7t{PeaqN`sLd+m;qj22gd`7Z1G3X=bnyid?7X==?C~e`7thlwDLIwwi{7X+_ zTQ%{KmvFb$spP9r#Qzgym`w%Z#$FSs)U) zZIFQz>yJDLC${^id7ZT@#aHbDc%Ns(R5lC1lYAva?%IgW80!=Z&|{YOB^mhV&+mM9 z@*;pZzktob>l9_p3b8%2EvY@lK%4WYSag2IWpl(pEp~1IiJ3^T^UlXk)F-j!oo|Wv z7Q6C&XNfTg$;NZEg10MiJ0Ld&XgdL9FfUse`bbC<<0s24X~oKQ>t9)3~F)7+%8*G`CaDIhE$b98?~xLCjfvpx6;$T z`3*8UW>$Si5Lubo9TW2~eu`>;ECXLGW1bCrvqlbfdY z&LFrcQ|X)(3s!gw7BXaF7N2Z*WeG~X40;J?f~{POG6@P+x2+~AD#i!@J~AXnCwDHA zPY)|-LfSS2aKD)COl0igQrv&98BN{!b5!e-T|L2rY_xfX_6ojL*hF*q+R8x zijK3M1LAKHKShwMe&zJfDnzVzPNp5AuW9|F^MtmJK!O*u+(PGGv-yFm(^K}US^{hU zWef4P;jh4iy0Eg=vBAzQ2#qYtwaP=;>+`M#)~^x7qb~et#!7-b(m1NGT@;RjJb(AMAsyCVQ89+5E9jC-!>$JKML8U2zH1?0Y@+X+~lF1kz ztl;qU+Tqj%8!YaefpvA2J6UEKBit&+y43|c^%iP;LeI8KJ|Li+nFUk4EM`XV%t9}R zq{c#*c5J3>422O@S|JCe8MUU&83!(kG!PHTW%U6et#+(`NKUvE9>~HL{!5Jm){)Xw zChk9f3i8P%I}K^%^FpiN63zEp+Llubn2~If4{uNtv8b3iwXCg`92e$Hq#F>Pmib;q z{#@*24p<6T;iFKg`;=?u`u^}5h;cBX!3CncqMpp-N-I=63mj(&Dn_bg*}L=aso=`L z5oI4DF{L8NKvWzIvU0JXFYn67dxP3>EgD2vX*;^QhHNJb>Uw75<(*eNxYombgh!=u z@J@W3^Kk_rx8`b7^G+RW2ZMZvzpQ65kH>R4DAs+o_X2c31aN^h%bnuNrY<9>D&7L1 z)mVN&Th<88**)LkEWug<AZyu-cEe6>k%nC~ZzT zvrU4Z#J%JC!B$Cp;PTgf|0IP$RmTf?`STfizTUJu( zA*-*V%%I%ofIY|UUQUH3F0HvEO~jv0j5FOx-99KvBT#*cTSQiS0+WP7ira90QE}eQ z0>SF|vWH{Oil12?n<2K)E^CnGRLY}TF?w5~w)N+5E=0B0R;2P6Xf3-HHiUwrQ}8^gC=mc*wI zFQ5NLBfTcml9N-HXhi*RNM@pVimi3q|Fh$~fhYCXFlmy1zcm0M?L5V2LvScz;{3AB zqcbTUyrBYC%SDyRmgqo)ht0GL)mzHX@2n=9RIO%P(tEUF#VHk(nLr6u2M?+mSPu*@ zB`24!2BgOsBDjq+l}V&8l0z=_3l^cpt8lF$NZ62m@xH3Md+M^Fx-~C?zh)DADisp8SuT~I%S~Sl6FV$BDcPhh3@#LK$Wn_>wGLhQ3p-ad&8iQ6$ zrFeao()7)GcwQDpo_HUGhN!=_n{=DGb zTj&KjPQy*Gbnq?XZhHc#fC`qYwL`mM*1ut7DOu%m2J+5N!lS`OwTaLB`ms}R8!qP9v^ zE>%@cPP$C!Do(u;GTG+zB>Lr1HMij9oagENu-d>ab@L! zwyW&hSo#QC_X1nuHDM>OzL}Sr<&i5}=;5miF`e4|FHUsw?nn+|>E&ZI(}lK|q%IUQ zWbrsjrBU+m%lsw08-Ngx#3wMjn;jatCK&#oR-#drTh@h~-%=uhW!~T>uSeYduw)SQ zD-0l_mq-q_OG1 zZfh|Q=)!n=rg@ERK+jPs5EbA*QJ0oq7b(U>VBu>MQIFhu4Q0Inny*5|z9Y6U>(_r` zmC$Zx#{^XdlvAMAf&dYm2p|-0NS@2Ib_o4IhBY&A%1g1K2+-a_21#p0by;bMrX5Bs zW0C<2l4h+{VAt7R*%GK^GT-_`$Bs}%QaCs)%gKVv*5`@64!{Rc7}zMv&i3Wo5rb(m zP%6g`m~*VGG%GgkfXFSttOoXiLsFhcWI4#o4aiR6V1-dNk4&@nH-Tx3Y}a+_!!&Ov z!O%C?c8K>S*w29f`kGK~%+x+=Zp*gJWcaQG-auS3(wKffv1xH6}6 zLnbIddRng#V+oT>$gN!#2-tFd@hqP5A_C}|Mtk_=3jBUSA!yxkA;&UXb6?^@BTehI zvuu5va#{-g3Q^_C&!P=gAWOVgxMhYfWAy*?;?!Gg$~-r8NWt0xIl1BFmv`Aj_P_kSAt-KATfv`AX?&;I$PcHdc!uMkOzm&O^)+GS-+SX((wY!?mtW zD$J4yS)Sw9ndb8AsnBN#06U!;K&$`(OG`V(MU~fpfd$NXsZqbMg+MIE$C`@s?O0yc zpN-GEPQX0;A5d1;y0yGNqfh|FxeAP(vl&$OkHw)fb69zckqj1^g_YJ=y%4o2;4w(H z)DG#Upj-ijraGn9zQFr+=GSF#5(5R<3$miG zrJu;!4S?8t379z4lOXM~fy}Eqat=)JamqU{WV;B=T|e1K*~#plTT<$(1ajw2FR^aJ zx@N~5TiOv{f>`B=^-BnCk8EtmIX29<$oeW9AKXR}uv2+{V^KRP$%pZ0<^@4=@`z6x znMvGMX1aVHQ<0Vzh(Pph5 z6-Ty;BW@=3To~f#)6^(+%Y<^g*k(lsm4jZwk;C9U+E!H6pC*q@gkAbQdyxIEs%A+9 zbu+P1X35(*1Gend>0!o3FP~o%r7hyi|E5qLQJylEh%rS|!atZ*X+9_!E$+v>hMe(iP?D6{0*hb7giv z(0)PTJ$ZFlgf+8PRHy7>WpzZXHf)(*wu`J&aZp{*oq7fu_N&N1bo{K=61xS0p~dZD8267L-PhPAk(1pRD0aRCQ`}YX zT-$V=^0_|9Wpg|E9bP!t3TA$8h zt5dQ&F14k;pEYNr!ZfS3i`Ad_?iy80U3jCIuPR@8W0I zNYD3CEKhx+lq3&~FDHvfM&8Aehr?p$48|s6C&NP;ap`Lfe5D*@pWt9e%sC0st$)Rd z^^8RhlU!*GqEk{4`g%arr21Mq{XSKh_#-2fiK%vRl<@AIKE=2WnovJ}L)f46f-kcE z8*LBeuxSFY?Axg;C!5s>P=iv>+-tXCW$_(|#UND_jRM})Vl9k8pKemj;yk3rDWqFZ zx%E>iyIsC9>2oGDP02_BP%PLQ9eTP+HV7OIVB8B|N1l6ngm^L3X5m*|>dCz%kH;17 z4Fco~YVvhua@=@Bq#+ zO#Z|U01TED4_5xDX4}wE4}MhFQHkgj!Kq56)!Tp%$&$BYqEss$B2xexU3zT4=y1zi zJzYybfH{>AokU8X1*5q7C5mLNPKGx@jUHAsP|&5t^c;;KPU~LpBi2>UwqtOfcHpnh zlA(k@l$qYOirzM4tyJZN_952hX1x)r1KVq1UJbPBL#z@!kqeM6g!d7qqH7|tMg_8M zzN`$)4iFYjba@?j`3Uvwo>>}FUg)}_t(X87wukF5F7k48R>D8)nL;y^SM{`!rH$(^ zG)8Hbq4aM$KLPVt~7*H4S~ zK`TGB3wpe&UoU_7vjZFY^A!)&0G51tdp^kLodChwNOgvQE+6f*R0={7`^EG*jJ5As zy&8)-rZOgF7nbEi<(bER^8j$!8s2!a=9f~I2Y~gX&L1w)kW7VXT? z-7VeS-6`GOBHhx>0MbgAbcb|<)PR75gmei*w{#D1{m#ATJNF;F`+4`Xp0(BvWGyoj zm)ri3yqz5@y{e5L4PPM50iP;pDsqq67L!@BKa0`HqHX7FLCq6SOkVn;_Nl`Y1BtLK zzxh&1K>0Z%7DK12H~H&>gvETWa2m@rPO9ZLJ$LbbIpH~QO`6iT4<~MuWx5>fIAB%e zrldb@aM1CSHeFWM80?`59lq+SrES)3d$EEK1{Z}m5GI!zG8D0|9Z zk5`@~Kg{E7b>JsyW;&kqdmxk?fmu=c_k7PLKfbHXh+<*QjG|6mDW_8L0-+WI@@$Q# ze0}7Sw&wF?KT%w+md`u7`Ysu+=yzJF$_DmM`>V;9vVFdrU$a4&*5Ot0=IPIYqW<V;)Uo;vllpBtl3E9>*v-hNoV zk&jhDoi0tr*YHmtTpMG0*TscX=BPu!ej!T9?9H6~o=R5&^QnPK{1UwJN4bum!|iGY z<%Ikk1r-2fgp2V}z$LIFnxL zTW*6J{bdo?QRVfjG&Y$;g&zg5+m?x3mQzht;Mep|2|q+6jf}SirkL-dJt~_ql_2^r zi&GR6!B2lE?q`S0?o$h1V$X9VA7wK&NRne`(9jSuNcrri3#BvVM5wq@Lp&9^Og?^w zMW-N}Kd&qfxGljYQa)@_hPyqKy8*gGDeKJoGR$oZ`$(HW=N%}rAeW!@DvqZxu5n~& zi5PVfL;&BcC6aEv++BzgF&|qD8QCQF>dn~a#yI&m%mr@J=vh|)5Eva7yPk1Ouv-d| zbMOzcZBX(gG6t6ggtIL|p*wB|TL5?TRcmpyy*8|iV&`z+F}I7yR-g4sXoJxH7(Fc#Ru)U&hS zJnK4=>qakJ#D9+n3$ADLdnkMN*;6m5cWb;CKfmXS&9o-~!@XT3iuXL$NcM$98B z1k0bs>_rl2@m#>c0qu_nJpUninhKq-_Y+?0^(6c6@kCG%2HZ}P`z?=KU{bz{h6)DV z7=)0VOipb&3qx!XddFE_yJ^I0dlxvuj^_&oDEcIT_EW-%LU3hYJ4`40e!cBKR^gz2 zTh=Sv1Lpdp%#)My%G_Wfv6B`&k@_%|6^Hi&TiB3mrMju`klhL?5_JA44SvI2e?2htbF{aB$~(A24uRM$=^mT^*yfC9(p8~{Ry1H7eqOL&WNGmKbhRsvS$jY*mRF!@)GHE`W{c@PA z?uA^~eWuEo5%kxKDocE#(R};K$KmT-|)L{TV-`&V&%lN2s&3V;&}_UO0UH`+=U%smCmh| zr;zryNGk6)FcDK|%;qSG4>MVb8#U~#&svK(4zsps-6IMF)b@6FH1fJn?KMA4b7Vw( zVuA-QExeN4PZQ zM<`4&@U%__IKLu`RUmv|dngF!IOGKz**U!pO2lMV5GgXbwI5I$bx)%A)6}Q@MSrL_ z769?(m9idlcA5yW+z|cZ|13gb()2=)3EQ`XT&}!Dge8jmTr~w8{NLmM`^kYAzq=m} zCEp~2$=*iEs<$r#L_H^k;7s}(n3fbXo6j7F$%a^O^2S#>JUuURXYLT zIQ_~kIAB&H8g7YaTL5o}pgFlK%RUZmH9}8Laf;QEu$JAafEF)d(`^-8l3i;E z65u)lb>rMPqJP~$!A`iMGDoL2B7O8qmnE4wF)mhMtTI+F*3brDKq%e$jE71`FceU> zskq7F{G539%30MlPVDwCGN0w~scn)?BOix_2444>*I9sK&Yn4Yi9ksM*!Y)DqQIEG zRVxfcVTWei}}>--Qs%NxJU~1d=^cr zlm(HdI~`O)oxalgb`i77vO8Q_0nqYXksu+Kr%VIvRkJ6S$iYBjwh*w1*?v%uEqCnN*OL5W!6kkn8`V3g!!xpWWiDW!=4!8Ewe{RJbEU;r#^x@RE*3?3{VN_>+0Y zWpDqWHMPP9?8>~BdZ){Y+PPNnwGH8|m>CJ%ma@|i;hvJLHE9tUGRU!t>IVJ8U3Eo} zl{b9SuY&Jz2aC^LuDsP{&x=wZKukQ46Z$Y68_l2h?b4+DmuMl;pKH>lN8m}&(oXl7 zi<=PV>{L^>ajLS6EiqLR#8L^PQprI=vj~Dw82I6rgpVU?IPw|!zU9AusQ_BN-&@t1 zG)GqJ7Iaf3CRTBreQ?nX|79=Hx$AqmDz9Ck8`dt5-$npWMaw=aQWE!uOs+eIPVx+R z(W0ljV$7cwA>=)2N*MSm`3IC0`eVvPofg3LUv$Bb$-uj2{L}~ZX$XJ)?TU14knXeT z>G#IEzKN)^~Gf0E`~sZgLEk}V1{w-V8zs4ZaF@nidLU7iWNp=Y-mLEcNB zUsg}b9y$hONnMmef7XPe=$9@c%=O3CtW}!%^}(~bOMwQbmg0$FV;0nMWUPa77|WV= zYRbAHPHF?4N}1Y1H>cQPV~T>2cFBM>0qtLu=!`0PY3m!GV!6*lQr+TEaPdMWC2h0i zrYrD^TZhurf4SJdpj)y7rcor+ZZU+A;wepIj}G<36|TsrG(PCone2wGD(s-Tk6jGq zk!I&cHWUvq*Jh~*Xp+M}jV*}kCFJIB^-xJ4Cs_Uiw=GyU;opB zQ_oQL<|;Zrksh5d!1A1i86M<*GdmmLy%yX6!_*W(L|{S!5R&XITvmLFlNG8eQ{jjeK3NKRN11&Ph`B-Bu{Df(0tQhaf%l@D=N4b9=|{@ zwJ2h)VahGH{6f5jQW@kp(@udk!PAy4vWl8ZNkocUBCLuryjePua8h>sNx60v_dJL~ zQsOUCW>l*mzh5(tMs}0EmA8tl-1&}LUB`Fk*EC5HXVJ+4LDL<-#g`#1r(_A5+>$AiPxaI6SB|&p6t;=A zv9ZxYjmPmCkgpHtK$|y|*6)?0Vm3@JAryG=!oF+zl|yTr)FqGVM0Xw5A!kwfcKr4(s8GUB53^c3g#tEX~=a$V7_k%rNaYA?(O5h7a3;0O`2o|$II7W!$v3+SPG-=`v4q%Selu3Wj3FY$pP4r5@^c&xG+);D%Z| z(gLP+mzfr;e4eo(pFe(9t>LL6o)4p{LR)pySCbK(N2Q-C7r{Q|L1{<5ZlrcCc;?$9bY2=oTZDRC>*%MoAq?pVZ!Nr{jfes{{CNML%kg^_H#@13y86I8TuItInC-awgy{d6CJ z>BYZiehA|*%>pw92|q3mJ`0YV*oUu@=F&B?r3)zYEPWIo0FsO=gpX@MhTpEhiA^WD zk+CHRODy9+Od3RODB6l1{{^ic(ZaHD1$ZovB!6DUcw_%{0z-d9nDHer_9CRjyAA-r zR&xHBAQ8e<YV~T z*x-Fz0Mn;A%IxM9%McMTmDd!L0jM6jyxmDIL0`m_2wALkd*|VL=!R6LlkyC4Sx8v; zFHo#o$yIt*h!>J^Fy=MCo=?ps9(f?$7SZajN|;+w>tVk4y3SE9;whwxCLT?@sG%im z$idZBj2Inf+}4~Lt;guQt!VNqh{eEBe%12J8mjbpA%$T6i;0M}a(Iz=uXmch{&%73 zzn|2vo?ge}9$d{D3uf(l&CHL~0r|qtD4&XNUEV{eB0%U=m|u!vU%L4}pvZCQcE3;; z;VdtLxPY>%FLsZq;Uv-t_l!5%gYRyNxqDX&8WCy-P)_jFx`!Qx%vqYWmfiO!VhlHHE^4^3E5m(wj3Cprbg5t z27N8bn%KRwpJjSg0qLg42L%`!%gV@nU)7`lkTVpyq(x;@3Yx264zsz^t!$fxK>RKYc6q_y zRH%-Py2X%cOY{mS!u5aY#g+UVzhyb0S1eKFP3DY~@BNh{x{C~B`Ec%dbWQrpfdwVM zA5)VU*R)PS_b^Hk_O-Tpp4t&?yfL2Q$pXR~y!0}@i2c$K>5Oqd=z^dFc|ExbeK)nZ z1pJrdw#=N6PwXexO1&Wpbu@<-%=%nWgYo{fvRiN)_y&05K{+Apvx#r*L%^cuKPP_oNp{*N`OtmDX0rXVT ziCG!pax;oh`}nKajs_6SD&kwaky+Ufc5ZS}v^Za*fSA70_ZUI>f38)(sHJ3&?1wC)~h#EY$-$s}-h5 z5WQsQt-Gy%PNn7Uqns$>dEeGES8;IyJZzB{P>x-fA0yy%|1VJOGY`$bIv{j4ckyvf zoVd5wU7sOeGWZ(9uK6(=@`BcdRXn zHVaMx4J_Z$b0ZMN zH)6~0Q2}h=(AU@9$kE-NSBLQ5n0S`;wIWUy$($eQC9Hs;F)0ev<&#Fwz7%azQ84zs zKoDwwM39FB4Oi75{dE}AXCqwV?4aBl1)BZ#;iJEnMpC1$Y*0IEFT^oN3f!I^?5JGhj&+d5&~ z4c#PmQ5I0KOCMy4g@UfK=TH>=u#FO1xT41zbQ@G0s!Til9ZW;o8}f1q7;Q%QNM6Lz zIH-#}8+(!^oYu?EnF&f#r;h5_nmWoDvqo9jRk%U?I!x_Q0U{1Um0G%&0y2Z1OW2Rg z?utAqoejLD3F_LFE4h%eq(ef&?2&5A`;CElUsQKVpiZE;IvE)acqIqrB=@A zU~>B-`N!~TQd6*cu{wqGwfzO5}T7RRCE?ydXCZnN5$LHt466L$z$nb zL8twVEo`a3?W;)tWik{J*%qm}wtdXbz8jI>U#(U!sol~O0YZg4*8x4C>v*gKXOx&a z!wY}0>mc1Ir&!>1QdmC#-G`1$GbE04)Kh|D8q%~jpzeYvePIl4^Y6^h4RS&P&jSd4 zGo;E2u$6>RM~4O;vZM!i>9nJ15CjVoYH2Ot017cFlE<1RX*HGRFw9S7DhpawLSJC! zUCBunBW&Bn*!ZP=sYdkV4xZs?0nm$T<7T3YaAUC(3o}a`1YK>{PNW@0j`zdh-iBmx zX~Rd_{WmMQ_;?z$e?vwT4|Oo&ExJj2 zI6vCK2fUuFOqlD4N%|Y{+Z~V%eG4^xuzexPsJ{Cmc~xWU-jg8~rFTsLKYZQs9`GeK zKB=ESiq+qp%DNz{{(yIbca%0}h?a$rN$a7;`svjk3fxWdt-U?Vy;k&w>P<6uTj4#Z z1uK3RT$IzdWF+wFaBon&M!ol~0wcnTxcQtTp+VX7FXn-HSp znXnGJ=owH4**SjF8jYJIKE$h)7onmWN79SbSE)atdyHT78hnTp!DRXY7^+7Y`#(TDI&Bo;|;@z43exb7UA zprI!i8mF=(Uf6L0(qr-xyVOacq@qu8BN}iyv$;VCPWq{9rH^0{es?nzo`&n)!w>nD z(wWusQsqX>#$J&M4!!q>+;KgD8aq1#qjem~YU!6&6_*fk2ZT!w%`G68Rz4)uM|l6j z>oPyb4%n;I&xLu>q5136Ba>g^2*`;IPn*ke$do1#i(0nv!n0GgB(teD#`P@~YeN_d ze6mDgl`EC|uYmp0S`+J?f*lR`sKY<}6ho5yD$eBPis`IhWC%<~(ia>Q2`jqmwaI3a z*UzT909M$R2(a-I_J%(JQ7iv)4q55udcMfWl;)nk9@t9#@$~XZf!gum6YTes*G`Bz zmvX`4`+(iAfrv6AJnwmaa5_^)^%2-4_qwZHWQD=;a1iOz~5FHsTwUih3(=BCVCdq`LWEv7IEjjm5_gygR*lqAGnb!j^igr0jLPv~5&#q^gUT>T`8GjpNB#M}=x7$P#fO zw{L>UXYZ+A6k5m!p^n@p+ij8_m-G%}L6$C4GbK&mTk?-T(04Q#!;y*iVn`(r%*Q&X zQ_kRbqS$M+-Gdvi*WExIZa@Jq`lDFa(m8OsTBHjjPczL&q+<2rb_*z+NeHq(Z1<*Cj!nOS->O<^R@;Af~w+;@)8+PKfwSX8}mCfS`!@KQF z{cO6pql;ag>;wG9pxx%KZ%je!IkUP=(g>f0x=>)`+Zj9&o-E$sc5@zmstJ)t(SQZ5 zENNH?q4=6f{1${&N_57*cMVnNa}2IDPNjAanV6Fr*dd#Tz$kwxNTx*X!TK_z(>mev z&q%iV{q2vF(CC%IR`06&+(L7%saca#+8NLJ@ozeNtjG9|Z=s_Fpr2u2uPBJzbUXY1 zuH=J8wUgYYV6DjucRm{6I1J1eCWOIx|7X-~@C|%nhZRNL_|$nVzQNu5Bw#9K%xG=A zDEz&C=#Dwx6?utS5H(lt_r%A@Cu?{kS9?_BgGBIX4(7ae4UUhEO$C>l|O(|DHPTjgDSK>^dMl@{dAA@RL_q9OdQHot6k- zosDKgi3sN4Ee?+l0;@pCwetBP#efkn_p7GAn}5JzUTr1s)M@uh%A1sqX4+C6iDC)T z-w+e=uSeQ7M}Hy?Zt`^n(EF?RgtFK{e(r0EyWi6|3w{y1UX^YOdwozkT{{tlm!~_<*LsW zL~2rq)k!vIZj#2bACOcU?4*X^ipW!l>VP|AWn4#PQHrk|C74aZY9~t|nCrl3_-`i= zavna(xjLY#CU_{3l%t42C$pyGkOC1ud_X_rop z*jz0eKYF#@Tm>aIq*!2~&RN5Py1G4_@x#h@u_T{lo{4;cgY_;D__ydS|EBjo#$OTg z@cR`(_iXZ(^>s51_S^NMek(4>JlKMBhee#QdJ@O_cr?8{{=<0hx!1Ot0f z3ntMlcT1b$!Sj*xDA#W97^}#Eh9_1d-dvLpGD)H|R>4V}Nz~N_hx}JpaE|6|j?&Ud z-7J4tkGE;b1ednRbLWN?Xc%9lO$y-;O@QM3scel`N}-uzw{(%3)rISz?jr+4!=<9) zU%Bz5>kUzj2tM0w#J>GCa8rjN9CxK*P{ynFSKPbW#(W4bGN9$5D?JrU-8OkU-sa= zvn$zVML7lc7kN48=q$1q%Dk;B@d*r*LwtAa9s-JzI5`kMeiF@~^#88(K|2(dcbceA zox(!98h0c@okoX;7GuGV=Wdo!fB(gZq}TmZ<_hNh5#Fr@7 z2c#NSm9_~Srn*yZfZmU*)b5(Mq;UmiwO=O(EPFKUg*f|kExiCIt&Ips^jxyC2!z68 z#9N_NF}h(Ns_<-_RT0t0QmW?OwW;ZXH>by2>zF1c!Vt>mE7=dorjC_*$cNb@NP)B- zJ023k5!W5$z(NL0ubPx>Kh3aqUoE@u6ucGp$Sh$7R9HH>1zk_jQ!7_puCi0g*dc%@olIIoYZp2jH>$eR*x{aWmR%$c?Yu^|ghWv(%C$@`BLx&CBey@{Twd5hc_R(|QwZ(oY;4$*AGCs+*XZ=V{J+^4v&AdCW?YdprY{8B(9y+c^fGP3ew zALsdehc(%rtDLY5;%v4>Xn|7ed3I2YPJ~jU5fAdQf2lg*w!Z;B&~ovNe34N57B#I5 z!PpYbPoppSdK1@Bnck4MCZ8#<(wW`FAZ-gkVjeC6c4E7LPED9Db)XrpPe8 zQ6)_(KUd7GB52Bzq%iGBcKl-!7M$tpcs;^(V*CFfNVN8tLC2$-=>(8<2d$XD?H~_* zeF70PF(g&&>I}}L89dkofuHFw{?3d1NsjJG$@Zm^(?r~pd{ z=)DAd8?kZ0Z2TmXa=L49|4Uy5CR1Dla&9*D4vooFGdH4gj+3U_5a6O((>*V6du1cJ zq4%6a?Jk)EnDne!9ZR%~?aaiF zDJL{#M~Rll;mRU)pjd|JslZOg>>^&ry0dpo60SIWF z!hkdxr{i88x}mbAJ>IXc#aq&P1XA}6P*EY{M$>vw&q9%Or8XMm4Ya9>y|gUsYwQ4q z4POf?BO(gHpLa$oLP~KmtFoS{M@ikX2+yGmC{7rNwDm~l$wMsl0tf)UIowiLwv_i6 zT}N0Td-q`tvn27%kK**xHPQ>#nRRGFtlvxHwY}#fqQ?O6XS^9|%ze`~5utr*RoV4@ zZdJ4FC|)ziJKjGo&HZ`A*xM<+$6LG{B&tkSDMiRHg2@|chfiPe;9lTQ|G&E>WPh8y zw+OIBau=+Boc%@yvpSQEJOg~Z+vM&e!t#&jM_Ock2bk`^Q>(sOmP|Yu`8c~71OdUx zfCr7>A>LO8DA-GmlI7r!n(4a5J!D~7FJQ_gnvj{I#>jgQ;Q15^?$gBaBp2c%@iH1| z*3T$YN|%d7@%rD;FDoJoE=&pu5+_BP(rx+`)T7DV-h&o-6UB|?@0Ty@Q32;061vyO zYIW>VKNqWVrp&*zQE`09Z9?DP(~VV@%J=#^pGx-y^I#eW_qq+IcD{+EpJjfE^=cJN zqUKOJHvcgG&GIc})1%31+xL?&e74v-qwrU;j_>=gF+HG$&A{@1i&#@mG=>sDMId-0 zCw-s&e73tu{4~R9tu{%lcdx7N<37JpVN(S@^PoC&Ta^vA3dhzRh-wzEb<~rko-n;8*UxnKuU1%UESoqBvjqp ze3yq|g&W~5t=^^5Yt5C=8%?*!e2+|W0EuaU<@8h~K10(=`?z`gT}OnfRG03yw5-#- zDGNhncy!*S5%{JwdRY_0yXMun;N)CN#Epz|D^^{za1OdtRF{Jh?up91&a>FNhKqh~ zp4s4kY<$IOVZiDxeR;;yImjsmKP|bD`?tek5EHD7^lyDPK+*mp!TzQ;^VNqnYsld@ z3U>Q!JrZ2gqS1G(XVC%7#^T9-@B6n;Qwyjemo7RK7KatOlPR)BXT@+=8$W*>f#?j5 zNi;sFhv^Yn`!r5Cb@m}KI_O3r17$E>u+(;Y~Mgs*>z zrP3a)+=GQX_f$YCG?eZV;oBy^zC}r41(AM5g>C!)FC8NEvpE<#vKEFK{E;B&Y`z^D zIlk~e(f=lf&O5*+317XrooOX*^*yNB5_F0}Z<+QEVq5Pp%pIx@&2)7ZK*L_t2H$gz0Vg0DJW9cD#GeH~wr8ofQ*jup z!EuCGSwXD-WG@7|`hlyKTMktLe39^Cx{yNk5f>HlcZ> z7#zWmzw}snK&L=mM48QAGP&c_N>L@i*shB0LTWSlFlJM>`z)~#R9=yYzoIG3qZ`a^9g|X zzbA$&H%xB`7=G-hTl@nK`;aE#C3Y(L823p2e1pZa z9%EK4+3rCLx3GrviS?Q5V*c5XkX00s*vbJ#rEn*)yO^U|yVo%DLa#UB77Z(7F?KYD z7lG?aI&zA3^NP}IZ~lU3ZY0y2wh{tW#UpAl7o8RwW}ab4OP#c}dYw)VGDZO{W|D?1!oXgo@DkhLO}BMl1@EXh6L@6S^~TrzE5KH%7V#usf7Bzev0yJ11&}BJmqyRWt zOzs$$B3jP(;bZb9??prKOr39%gT*qmNB1N75?X|+R@#=K6T-jz1C#3Y)kRy&>K%X$mgqL0C4)p_VuhZ7=R+j{yIE= ze-O<$h#!Hdig_&%9Dx+pQ-S4-7PR!0a*}l00xbq2au{dQ${j8~va7QYnYOV%)uwqn zfiruIz7Q6C$%;hBGfNVZb!xRo=siCO#|SyGD^_3ucUhkF|6PCfz7kcLpzfWno@}e? zww>}=<+?57+a)2i$$|bANVkybmTs+q<&t~w`coXDG59A2s3ko>I^400tUL0v;h4#{ zWSAJXWk}B-xZ%#hCQd)Nx9RCDl_GCRM|>)P6?_=IN(9@rdx4h^7Wo!%yD!0-6=$i#nyE7%~#eAtP6ml1}xe=+zB0CwFve9>;7z+-jY!}L{q-%e8rKY)olFM zTgv+b|4Y7iq`0+1t_&Mi7N?r9TEq_1WCVg%#u;IZOwhg*s`^JBv95di3lVHAIwpTx zyv3?_vhZgk$MxKfb67JRu6T@YnX=N>+ff2H7xUcW=|Zbkf51p{lh`Py-rnWEsvN@< zS^T2B)FXY%V`L_s_F2C5=KEa*~@TXYj0EiV32H=WW-66&v1ZOFbMyer`P*_H;1-Fk<$lLGtrf^_7{=-t?nor z9I(2q=C9jY!0!!O@cy@+i8#SW`PXR#$XPfpV_+vdZ?H=Vl(?eD5PkWc3lZ`CGAkW= zk==7g$y3~(h%O#)37Ez6t_giFQ7$jcYS}OV2hZe;V)Frsh*vS%l)nK}6h4wN$tKRr zwd=c1d9o3rkd36^V9+IpU3O51CKR1_8T$(8mn@U6J(@ZgLatt}(;PnO#EEha+{%B+ zLeyxU?}$m&%l>Tdz|xmgS-(wq0?JJ_YMASvZl7~mhYzZIBkA6N zhg~4xV!4mMLka-<1hgC~o4S$@q#_G%jI=_1A{J4~l~c{vqlZ_&Y(*p%t3_rSu{9ds z6h(v|ZR7CNe`AEwSy*Q2+Y_Lr)JB8@~AZ^7u_EzM&*TR5tg*gQfY*;c>m4Ry{2? zxKFUBtH@7+=QY>3ErA}nKc;3MR%fP9mlA@y#>4I0_$#GMc638Oox$pfK1C1c(7*&F z*@gZ^PWPEd5-DX%F{_2$J`xVqcfJsiQF8lH*c!uaRT;nLo7r{xFQ2~Lv zo)$x?CbLHrCKI` z&foCN@%{oms7At@-sE@jnpIMdV$m2gy8KHiDq61z4ohdT+tL_AbUqib_)|!>(@$=F zXsdt)VnTl|;=R&A|H&6|U#~V_;`&_bwLqyq28ok*4+fKf{2q89VI2+t&3XEe|o7h3J1|H5$})S| z3{O+MLB=Ig`8hyFrjw0g zN9f5HC0|z0CMbAUdQGFNe^Q2P-wm=&Dp_F;opzf#ddsJzTYKU*k2 z;A2V1Mu@Ldp=hc+7*}|6fT?X`;ILz!G5x!9kfDxV#`PsP7n(VO=hSxZnu^IgkTO7d zZ4wWu$Hm1Hznp%gP0a+X^i@?CY-qq$Qq~%cJC(KXIJxvQ%I24c;_*fO`X|Hk()J$i zs#ft}L>4eQ*uj)ISR;5_?S8i48iX9Lj%KzT@mdhPj{xiJyWzNREa<{2c;pt`?1SwP zL52|i76xv({M&&DIN%fjQ# zd;vikV2+e%Tp!I)m(gv<2Z)XbB+M#L2$0|}7m*Y%<9#f>;Y-%LDQF5>!xR}M`-hy7 z9==hyQqi^}QTpY&$f*UB^x{%)eIzaaIU+1{OOt1mmhmIvW8vootLdFJI6eJNi7eT7 z*i(rS(NY-MgYhym91%nJs8pY7eviha@Jq3d)yPbu;uz=6i^OoAk3pk--#SV8`TWHA1Q4 z^f$>wfD<~S-DW=R7P|!%*=YQ&xdpVar*<)Lf!`|J(rMU>KAlQrH4kF#Ml& zi#6vCAGpoxbr?QudTK4=*Z^l8_TG1MGC17HF01@&S?=%00=>z9{tmhFIO5pDUkgLh zSnC{SN2ISj)cKB?3p-JNyplv%2(dw01n~4o_!`6u*q|wFRPfl4ou)c`*AW)emyyJK z-y3PoxF@fI!zMLKdn842kGBtG^MV6IWC7@G+Anz#gqr!iSk_~{jPYWAn+}eTK?fKKQVmEkj@0>le0K;!_>jb!a#@kf0G|b#%PwrF-eC{?+A~zZ0?bej zB=#xBvtMetbV~}}K(Y2Vj?k>|o~zCNb0P32@h4!T++*N;wZMOzhP@51zb)XOR7=vh zXsGhveTjYRd!9m+8=L-A{lo~H>VEFO^van6bljZ4TfDfmLvQMPFp01jgjOq_msUBT z1wnT%emkTb>bH4acx&I}-Jd_GHjY0KggxB7QS^i1e9V;S<+SUb_(#d%;Y7*;X!9)^ zBzJ{5M9fl4A^cG%d1a&kNBW81RA)~rrpp=!hDH$|BP~}aW6eb zl8TMcv1b8*3Na(`;8QAZP$1O(5=o7T!7LnO3l64LJ4LiSin9=Kxj8&m()rx3_;-U! zeP;3n=P29X*~@_TjR7j)HDhnp3`6M{0>V4*a);E?&wuA$99!TCiRddcyLG#LtTTf*{N@PL>w0b5ba0xy*^5$w^jXm6ehzifTJUh24Ir%9q4-Pq7DcKpSj$3_g!%K z?W#;dr$R2#HrsnP5!Zf4+~)}TxbKP#B>stj!@568uX86A8OI4G!Oz;G zFfiNN&BfrRdE)eX^mTVlfpFf~<5uU0Jp%^&Z@M3cd_rA)W)QZ-LqRCQip)%4Td0a( zLPd_>1kDPhX;=9Dn~wc+a&3IAzTAh9J|J(UY-U-{hmpR#pSXi0fM65BZV=6Rqvwqt zg)97|IxZ2ENGzP$P?rCkbVieTE9jd$-%eE#9$5bN4b=6}CbZC=@@qtBNnHqkdD3un z`-0tJsj`3VG2t?ll=S)QP7&(g=jqp6MdrQxr@JH>#Ml2w#UHaU)2v&_%LZ()$Hw(x z$f6fK^&Gkm4E>{Hj$9u)!GFIOYzgfOJ>h`eum1V|x%xO|Nz87e58$`Y>tB~`S4IMz z;&Z#G=xM+2d_7A&*Xny@>AsMy`|h-HM+)n43nqQ}<+~9@bkhGiCGh}Qe@uuEhHSq1 zhjbs%wFDthh1SNGKcvBs6TUOvZ?(Nb?>#B;XC+DDQ{hL0aza&Cc0YDEBwS_-&m4}! zt1?OkPJ|Rl9>`f10NBP65brx{hCx-mEA2mU_3?ozW34DZ8d4Zzl%E7f3^QAQim-Y+ zqA=%i6(PzCWVwDsw9k>KT!zp$LSwyLzx*GLuEMYBw~LPM5CueOM5Mc8AS$5JARsYN zkPhh{%})_fxyyJLia(mi@~!$yw5;+^mJANYQryyxC?&w02A*l&1Q-Z+H8|I%-M zv$pj70j~Z2iW*QkvPe>}di%+?Q*?rwUCLI#H@kq5%8vW~+Jb%)QbBU-@5tBa>lc)a zH@q#PcE`7N`t}M7+oq6XOWHRa<;dAHKI3b2lV(#1^GH}Y7Gd7O^na8zIH{C60>6eF z*zF>$A>LPeC^rHG=sGb!qTGZ&6gAh-LF;iWPz9e7SkO$9^hc_6H>1}quhW5nbXFc- zWV$L|1c|VD;c|{d3s}!E$Y17X-M`5B-;>}8Ay)n_V!|uO47KZn`s8H^Htb_oRw5#< zuk>`NSD+W_ntZ1`$+?e18FTAr1 zHMck-v=01lDT>U-qiijejBIS{bekU} z{83w&Xa*vU9pigSZSJF?MH6plB@_P=MYCoJDM_@b7SlfvIZ$P-c$K|Xg5}o5nm75R zNK$HD%{*BR|C>6lWd(U}XKep1Bjc&9;cO==N zcViwi0P$zmhcuUn0&n#pMn-CNnI!2y`wFQRTZ{RpI_E3!Cttg_8lJGUd;a&P{BBy) z6wf0d1aX3Deg8*x3_fQ<24JYsRs+1QjpTgc_&i@u7eQ2{ct7N|Z50xI(|d3wEGlR# z|BmX%e}pfEFN=d)*1k)+i&|F;j5KK%4~~>2QhqPKVbEml8MeRSb`MAk+r}i%&~b{> zy{&89B}LZiA2=wvwdK9|0se1uEBy9$tLuAZt>udcp|#?wwycDjmZZqp@sxM29P1i; z=CkU6R0q-lOWq)WF2C{ip@bJbfYn++ZQ;T5VOjnqmy7QkVL;k1s6Vj=G8W%w)h18m z@S|s2Bza?!t(9Io$aTe&i?@_E1#P7$(yDSfa-Lr3#<$x_?WXpwKB zqQ~j<{Fc6`fp|j@kR5Y|Ujp_IpLGzU8gt3lc`K2(WX$VESYkn?^ld(b4tBoj zw;l`LQR#ROIRKwOIPrmJtcK8Ab3F0iJ*HUQJ+0sF2$T0yiCa6!L%yqaR(UoNRO$Y2 zLkPXaU+xn3`9``vqC3$)c-JzSvgx;$rmgivfMoTM%My*U7q2Io|yl z_*^w0H-mp&wvXZd!4T1%JI(Z);h#u|>g0ZyaLDbM)WmGMoh%NlebOXie8$NHA`l)q zB8Ma;I^WTPm@ZlRM86L>c<#5_Rwo3HfPL!ip1E&CEN;u<%Sa)l^lqJ6!t-_VXzt#N z5Q62a<8dXWW}4ux;*-% zlx~A#JF5@W6Rx?!P|0DM!E1NDjlBO&4Z~>pO*`fp+8A~^;`spg5V%F!fv8f?`SL0mO(-^0~3Zmn=?CWio5A2!A2x6qzr^#(%FHk)d1S8|fOuKT*>`QwI{%COq z|E}O%hd9mIaXv*e-mkg)zfwXHS|B<;Bn+PxmF9Glph24u6&b)8ZUjY;tOSH zt0k<`FHv-CV$aJj;Sq*TnU__h7FAIQT-^_FgBTDbMf4qlURVV>e6yib3q^ggKL7f! zgsrYCxraM|chKbH`gMEXL;Vn+)0JrQ5qD(QN-+nwCc(GDEgu=vb29@2r?2&NraHsM$qHW&RsR$~RrzqN-O9`S^G z<|&7%qj(#8f=c2!(qCg{)~=P!F7fc%b2U4Sv5OLlzjA!tUq8vP9PiV-rdzJR=1oBw z96Y5u3dF<|lnE}`?eXM3-mZ6Q*ZG;zv#I;eg3OFpy~E@;9X9Uiy{hbX%73us*N*_V zPx<>n-W-4o-CNug7(0^W=LJ@FABN12i$be8JFaW-kBRvy&6CmiA}WE`Lcth6SOg4t z$PpgbjyWG!G9Im_U1RTt>`k%-&%ynvL;J`qVCv5w0{kZi-lYH z-pk1)P`V-)@$}=+TY^8t?+(uA`+@*MtvkPq5#)P!yNizkoN7k&qY&_U{)m935QGZF z=V)bv?zb&%%cQnS^moWfx)XXKI|Rh*@`i}YfaouOQ}ShLDl80|%w_t$sV+asxF|`y z3sG2her&xKwNs=eOPIP4!*Et*q10<8hy8Uf()ycMkPF~RIxDAzkR)GKvcK&_j3hfWn=V@#w z1i$+p%=~1r#`Pq&Qh*Q5^I}Te?^?8T+fUvy6N@erT?X`H8Fh1d>@lgJw`1h~JG94Y zx6_Dr@CwhcNJL!xZsM5XeDDMejijmWq6DMrLDaYT;A?)WmKp`j_F72atf@D>Sl|nKgCMP{-=&( z01UW{5zc*ze7FB=%7kh9y#kp6?s2CIA<)pCfgF z>Abqy0nFWT+!bdo2oOMzAm~K~X#~ukY#y zrMjQ$$HxjljdqLiLXu}6vfT1-#C{GqNL3l97o`j=Wswm5J_*|-JNXx^X|r@s!KZj% z9V}lb09#TWh^!k0hR(T$Afo0h!A7?GfOt1I?MP3x^m+U6a0!5?Uoum#z(FK(D&$R9ND{9Z_lJG- zb_BxDZao(JiQ`NXtPh-KCL$%4$SA~|^wO0ig}3K(|3~km=+HI8Rzo@0v7pXDXp5~l zYn(6bU3&l6Skz7cr8Hw>LZ>eox!6&I*S$!8))%L$IGs^V-7q%aTR_5 z$N!mEL;FKW-fisTY#4!Vks8hv0xkCUMCm_AUTrv&#?eN~w)qLJY4~CR&HQ^**bv`F zudt^seY*5vQ=UriL}c4rOc(DM#jz1NDWu!|qD~t-BoMk@V<@{h{lXyTtw+&vQ9hTH zeKX@~voB%1^gDV&Q_xXCPp3~rVzGiLbB`i1Cq2J+ny?KJF$JhnH!)&XuoJH$ihEY` zrmF9WsKOfy6hkr(}PfrTmY=8J@Ph&A(I}Ul|VBB3e~p z_4o&PZuW0zqN)SVB@vSF7D#ZKd!EUOGUxR)2Tl$>CtI-wF2vlECaiD~Px2n6Eh=mgP?eV{+yAxfXR0oNotR(soEk_AAD{{}xD*jFONI&Z3V=$IS zxAkedqMS*lWyTEiKCjd0an+IpzQ`4uzO!J{jB{5(z_AUadE-|Q@Kz<=+Hlvg%#rWm z=gCS8C7KWSQ)68+^tNdDDp&*zUnPbd7IB2TU(5Qf-L882$v5HzA)74MEp+V!99LQ8 zh@;NUodVSBh=(SA2o!FTYP~)5=;YmSut-m`t~MgQYi9+)n~$v9R_(ntq_!};QT|8y zJihY@xwQ##Pp7Pq0R@-BA=SMxHAnnma@{u*_(3XvSVgOn0|Loj|P>-nOjg zE)W-W-^}6fd1?sz{RwvAL7@+pnP;BEI2q8b824BBV}HB>fV#4=R8FodGc(;c;yv(a zck@rIPaSFFku<{?W}qmLqKe>0cj!@ctx5LNygCEjep2df%?WKnkLoYJmXm-gQ!3R* z^7PQr%e^afNv=d;fVgc8_oxOlxuiLLLvZKfIQMc>(O8WST33~VL z^==<}bvN1`!SCKdF-;=81V^E9*7>+=9>wb~IE4Q7q-ZB(FZcu96l5zqKL)oB41Fh0 z@3H95S@09pfHUdXqQ?H_QzFG3LX{dveEvCnX@p*>phZC_5q}pDwo#h~5 z?tI(2_aC0DbC5^41I6`C{|<(sH=mZT8bE%Og8nEq5o_q377)KJf4eXI6H3AqkTMZN z@+hLTj`f`&hLeb*z%GmcNc)Zc^D!=^T#r=ZE7v2ERR4fF-mU0pkY(p!lIGSL^8?M(iq^EBfmYK*Gna_l>gP4)MI)Bo&Wvhbg1WkFXW;@W%%ex~Uda zkrlaj-a4I=yk$xCRG9JoihtBYJ$H#piPUM2OANVLP&_(*I_7iHX{Lf(#O! z(e4$t5Mh0Q_fXl~0^1tqC`EGC)#wxtSI!s0!(G!mcA`Eg{0-VW+f_*Iz=kxKRRkHh zF-t~&qN{uX;dYf&U5MeX=&t*3Hgf4z67EmxzwU0l($y*xj3(_R>w+0yLxCTe+1s1$ zqRtO+ac(DTe&7op58oJkDdx5EEw1W_<1={UE=O!NCQs1=*VFa@_Z)U1)v@ja#e2aj zEB^hAj_)KpEyW)tckDO&^|u+0oA#9U-C(;|#EpsN!p`)+;^#WgTL_`pT^-7d@jNIi z=YQnsni2bZp(M5s( zPR@iLm7t8d+}j&0gXm<8(%<{I9P4+?T}qc?X%^;1!$#u;k3r7^%K)f&`6W|_+&)a1U2CYjuvQf{*+f$cM&^5UP0(01euQl=EhQd^i**QA@4h#sr`)QwavVGhvikYjPHtex-NP$!%SdT&V$L&egC!Kksp8bj*&qbKz?$@C8Li8uK}Hw_ox$ z^#+xKt0Y5?R&xaZcCJ1-mXGDjZ!@(5WjM+UAr=BkdW#VGBl)BqFr~qOf5Df(GOyf1 z$P80)**(4YraDZP=p%W~FoXhOfKIkpZ{f@a)@l@lOXuf*FWVrpruNaut@t&2Q1Re5 zrGogwZ^JdPDJgxR4ap$aBUe&mx~hCimA=HE172Ynm4Ntw&i8YE;vn_!`$1e?7 zW>WMKqiKr;?t<#ZMMFx7>WPLFF|Lh`o(Cl->{eh8)aZ zj{7bJrnL z2WEvCF>~xViEKl*66Pbm$*vJuePr|vDyEZRotGsOvO2qF2|kLo038J%k%|c7>Q^B* z@}ia(t6h@uX8)>i+C$DR>p#E*0@f=Vbd;v*^O-EcD}?4XrUXGjs(|BnamvYj@hlFP zyKh=Mmclqx*;c%Tv%9(e7WAKKRL}Eeo4$H7n4aM&_2zd+0IOYn8ne;6l!)U3tq{q; zjW&QuNl!R?ZV{A#dp8VQ0x9O>R$osh))Ba1yjwMr$j_lE0j4n(?VA_>9yE-CTrA z21e~!>H7SQNKEI`P*NK|0aQf_>ByvJFMMTJDn*}(vdEZ}Dg1*tIXkU~Saws$-TcUt=9MVOHFA~aoDfrSn;!ztO~LJO zv{K=&K`RlusEon2dLZ_34ty?>G{~`Os|Xuej@X8EDXiBYp&xX+-xQP&3sSys!xsqj zQs=)@@Jg|gqc)grk(5*<{GzCXmrwzb5TyX?b4uWuOelRm^_T4(OOljsg$U{=6~><2 zfFa3|aFQqGKWZ{|cbP>LhnfJz4=Ndh1>RA=wA*KF{`fAgNr~&v^{n8e;4@mQUs`)n z47%oRzXA%0WmR66e)H&xxUa?FE{TYUQF|?!Wf^N7!WVmwb1n=z_0YR+;P<$}lNJ zy^e3GOyTp3t5i?KWU7F4C#m?M-zd3YdoxxwW=lwMqqR zO^;ClH_O&pddKrcwflpU*=J8wV+|&0?=$dw>)orK3P8~Y_P?a@@DF%!LBO={a76jU zj=-}*?dR|lGj{&R4%z@^=XoBR$i5g3W z>2)UNdFF_il*Xsdaso;e``7C28K290uj}lt8zmpqTG#ueh7+ymSX z(UaZ5dTD<#$OjKQ4lTJQZG^QGiuqr7@pruJgVrZ8wa}Eq8++ja<(S{Jj&miOUIH~N zFQ4^V%69(zWD(OE?sY?gjHCE15?&!mOd&SDZ76qN429)?+U+8=f(*VwjsW>@Ra*zk zXz!L;6NX)1aF)zi5qru;DPnfrVI13YOsTcQPLqqYyiHLblmV=JU#lpj^E_;{()6`j z!QSLrRb?e7QW^-G8w|XJ+3!}0e?wHv|@EizHg;`kq{<&7qJ+$_vc7j zO=F$PRP};lV*5@@W)nR(Q)@^^Pz@Jxs7_J`}|9GIcVlt z-nVjSmitB(W0x_AS?Dl5P(cgxM`(mj9K-am_WzcGno}rtC0Hxx^=b2JtUJzcTqG7S zV0gI;hQWF5vp*yrLg`m8i2T-K!B^}S3uO0pB8fnlGq3A$TsPHvBn(ce)J}#1ow7Br z!Etf+R|FA0e%;XEUE%8*sAc4#;(Gya)|0r-wMIetgB@_U(jl7=gxx;SlqZx z6Ph4kJpCWa|JLrfW6Lcz~zv8}-UcG?JG^esc6bK1=Vl-GoYQd`l*(UHh-UrlycG9!O9Ds=bBSRET7osdJgg zo|u|nnz3!H@E@#%LLsDEhXl!-qA%L2iiCGps2tmOYKNOY9Y~rYe{F8tVH<9&{EInV zAMqRSk*SnwKOIY$sx8sYa`-C^b5cC?#D}v;Zp#H=JlLP^8iK%cBpm4dTSS$;$W=jL zGcn1Btdd0L<#}Pjmz~~Spi^8PM<*3hbZ#6lR(=V_M}+^wdB42eG*Rl{6F8Uv{abW7 z7K|mruNDpVD47{ykG)Z}wh!oOs;{Y-7%jnqD0)60-g@FLb(bT-^*DHll{YB|Ze4b* zUgbo*C4lCZ_4_|GYx|5BFPfu@5E4`6y{}___&jhf6CkNKW>!N2bBMP-j$l^Jh&*|2 zOv{g`_W#Ht;mr$T9xj#sTSraO7}Z`1+lLnTab0Eh_Kv?0+ufO>1M<4g&i$?a&g%Fn zv35(wHN#yZEmj;5SU>eotd>@a(riCu%%41yR*bm#+ zdR7lvVpfbM>GV2)pkKz-jDG^ErgW&UnI}hoG=Epj(lH=ojBK9$r#A~U-9Kn#3qD3J z$AE9D-TUG=6wP&xcFiqz^G&KRi1zwRbI9|8o!-jKjKOh`i&M7Xz4B@izVq>=i``08 z;)xn6w(CHkWB(Sen$x*@ITG`v@3j<}n4VmrY}JOrfM~}dVb`_xxR71&hK34ZGu&)S)7kt$+q#?io`5Sz)S}<-xRt$g=gHRzPyfP(PrF3|^@tY~ zpDhW@WNV)P4r5?Ny!`1ZK6(|!1KAp7G2>%=a_YZ$a5ov1%r{icZ$w*|TbIP%u!&>_ zy+9xEKf6nJP(z#0=;_qv4d_mA-qSY%DJQf|!@sA0C3=E?$}a_R_LygHEzDLFb{}C@ zg}1%f1td{ot_s7Co;Et7tRUybQN6TPUdDxdBBk#M%Q2o^8pK_$sd?H>!CbRvPOloq zA4^G`^DHD+v8G7q+@I1cNqK9F5yFf#)m}*B35Qq_cKYB6!Kz+7I;ZtJnW}RFV?Gnl z-d)n4d-RsAU(n>uLcnv}m2y~#yRHXz>U^6Gw;d3EE>Cb)v$*mCK33<5CG5sfq!Ez- z5h2w=)db66v2Gmx>^!PmwNWAp3vhYN`9e(B@I{xyuTE|9Sc(DIS1StDscWLCWtO=2 z&&e&HO#KyMzVG{9{EHm&CpQ~9Mk#TD_1wU@HjZ-9Kt=SG!Bf*8--d)PUHA?*3)%V0 z>;ji_pAq&LtvkeuU`t{fB^O3CeIK%&FQ>knQhM+M#k(i%&AWC;LhE0hu9`(gzyQ=L zVgT3^I?R73X!=7ZbKuyQMiEeOpc;Qx>DiNAX50RVeM*g(-toEh)4kfEHKQvB?zr1$r{aA$-#=R=y&v|- z0-PUshQ`0oxw^L**Uff40PiFMp5nzd#usvfcq=95*P@Og2%g}HTg!0w#p;vsSPB)N zTf(j_^Sp4;>k7WL)4@45QsBijay=hgzBK$9BP0p&M3kuPlOXmQE&WE$hODmQy0DJ^ z!}#XXu?HEuU4!fkJrAwC7*$UCs{0-SuSu6X5i%5~ zzt23#y_ll)O>QLDf+LpG0{JzLI}+|PmRAyPteF;@Ff4oi+Tul|;}C5pLU4hEKcrO2 zV1Hv!!0e`h3A7->t=)dpG(hR<6QzhUzMY}NTx~bsVe4vwMi@zdOOWXIFu+)2aRJ(| zK4#<`_%N-_eEB8(0Wfz)wBZ{d<@)XUJYU!W5xJSHPApkk;IQk>R+{K`>v0!0Ctpx6 zrRa^{1kuPZKo@z~SAIe7M*=|R`E84zR?mGuz#TYs@oDNYgZxP4Tt8sVFH zm}{0OnrmMAoxDc0UYp>Rl&r1Tvj0^R={* zo%o$-BR)b-?p$xd&`*}--~#UgKCE;lB6wjJ7w>TB)*d`@h0fFsWW~-80!fi4Iv)0* zv3_DAG+w&&hl{Xt)AO>hw;7P;NT=rSdN0_rHf1bSLPMtm?{^9m7K42?LVfM_d%H{p zy_a+PC-N_}X41-qF?%ygeyboDb-%5qL zmx^^q?H5j`B^^l2^h|$S&@g(Hp1C6k*mJVgZ;>a3MRVO=@IBgk_Y^_0)aJc|8Z&s`unfR~Qtjv9f?F;x6Qg!minvdxxnYTysc zp-J1_=M5+B9UeUj0d{FXAMOWdTc^*-PpCi7yV*MYwX2lYPh{860o)R+en*k#Q2zg1 zd=D6EX$jj_?JN&+hZiU2tF1~WBAWO(x}z`zVe(?A(A%(H4z#xHx_j4t=Jw4Z%5A|6 zv3ZO8AbtKBgNC7>hYfPbM*`0VG;EslyWNzRjDLI9thvAy^Yhk(l|3TEY55-Sygtj&7mlD;M zq*MYDy5GvJxYZm?MMfVAb8n%*Bfs(PdgR>TC4OeFwSL8QesA}4 z4A8(qa2L!yu^l%Do-41s5NpB^f4bGv3zDO;HzA2uFkv``FVW{8Dk}T2OPNw->OCdS zPWeRcmG-`J2wi&^;E0=3H8Z*9f^{=OcO7ErsDLE#k?-~%@xs!&bP`KM3T!)FG*Pt~ zn(=njuNW}e)r2_9MI;2vxG>6kL=sRdY!wM@-x8`+z13SLH5aU`#J5wPGemh#IEH%) z8a*xCM6FfQc*iR>zOCwc^k6?}FwOjZpcPwV=gkC==Fjk1g^Vz)zj>%;sFskaY4lZ~ zQ+??>UH){ic@NbdD`w_2Tl>uilj?9=CyT+;isQ#=&1#|xf%?B_>g)bfHQe|-F2vyD zEBOC@l*4*Ex(XCm*!jy_(axZV{Gh>*>uw~ZjdcV13WSc=g`4a<%9nx{i7uRjI?uTx z+e5T;m0}R$9KmcV$IU8~)wjkbGU2$)v8%Q&xG-{UEVL1K`mp^u94+5dA0h^j?Wp>f zTE=Fd+4vojQ2rjuL~lQ-ATWvPuKiZ}9am+kYnHD1@fHV3XYL9}(|0@*%~O#&WS z7H|{*DqYzrK6gL?-lB7y=2cP2JX8r5ucrmi*b$2D~M*uHjDq!fSVQ&m`!(@y_O9|m|d@71eBNE z53|N;uje@0h(Nc>&1ti!r63P{e-h#WIvRM!?SC*AnAbg=FBgfAab%?IN0Efc=23%l zors`u9fWI8c2!=rV>(j(l*D7 z3d6h~yae5s&(|DQk!ihNznHPgxmeOPN_&Z|DoG%DFEEmR`>5VSCv(OtDA1QJr+I^k zT}+5tPZ>hobqJ#9HQ#>yLF{SRD&bLDfdMIX9Xlj@9)4}EMtGQ&PUY4r-Kp@W8=Ovt zbgWydBSj0|{QfT^BQ(-vzJQ28#l8*5I}r31EyW@5c$xTd@_Dnc!rJHbx~+VPgE z<#xv_cd%};fthpgHx1hCXY)iYL8?kD6$1p}I*BjRpp1HY$;CcX;5!!9bpc(%+f&M% zybo{o>s;draJ>9K{>h5<;&)uJK-}a=0pKZmapl{}47p8SWMiGc>lQ9szCn>Tr{L`x z3zrS@7Y1i^{EUWMd{nb9fiRn}@2XEIpYg1m{domSBO`sOB6cjy*j#c@nT+Cpj5AbL z{nK$YWwl*=U>CXcw?F}Y$NMDQ)Xd-YGAUena`+MAdHEqzXVP+yZAtz2uLYRZU`HZK zbDzMpa<;B?pLz2$XICKzlOEY^rGi&0LsVpsJplJX*6ZYb>Nx2kw}4j2cGlYRT7CVm zs(icZ?Y(azK(app^p|LDdC%dkf9^1UidZxup^|t-b zZOKsk9!JcSdnl>O0lw*>Xv`+eXVCo3F62L?c6g!A=(HO?l{kzW>rhEn!5B!X~kpb(w;b32x%X`M!+P4 zx{ip0Qw-1KH!j0j!uuUrpBfmzsAuH+8X?yxoBj) zh-p~7TiA0S615#^#?N-q&c(`zFbMPZPgtim11=yVi(FfZZzX;839Cd)eoat>W^5aM z3z$*e5YLum7=cb#;oV8@E@jo4PMrf$H|{1CPo={$KBozbh*d^qThz6s1d@i0GCf>X z>T8(6`sT9I!h&GPGNj$k3zA&#T_ijxGqaY{emzTrzC|r-rYMzaX)=9!u;=lCG~*@h zp_H0s)+vv%@6V-FyFf_XT`B1oFR#H-kN(mPQM<3rOuM_Ov)kV9|11b$*aF@(_f}x~ z?6^0z#0;m?EAZnhjO2s6x#F+{=Fq7(kSs+DFOKts;bT|qZieJ&Xpk9)6|PA)|6NrO zs&j-r*u5hrrx;l&7@%p=6XLRG1a9`>V?MeCf?;ck(C_CR& z4^TnO%|6nZ2p}m*21brI8u`m8$-RJRR z+?#LOvA5Y)nJ7PKlwyg1f}uEY&A z!rMiceA{Cr!X-Pyyy#_gChVG<3N7`UFiCE?Q~!G<1CjbfBsGsU+uOj{3O`77f~Dof zuA4!&z=D8{!^HC?0`gkmx|BWk(vmM@R|%oC!@X;DYJ+9HKxPAT-zS(#(;n6DE(#JO zLDJ+(M^|JiW4D!riS;2S$~Q;1;Qmw$DY6C{U5deQXpQ970U%OASyUnZ&O zW+uSL6iauh6f&h*@p=Hmy0H{ngK_LOCVMSVNt7>r+#~w!z;1?;W!ZxW-gv{}tW>U# zvxPxReenGb)C{$I#WB%BgUO1IfRwxcIKuk9Zp(*nanpS;k(-BURKu}!Y{DCAhjDT2 z#QY00relZkgL>0;fydHBW*D7`X0tslly6Dz$!1_|(~?d5Rt0go+iL`!Mrkg^`|vj+ zAI+m;2+E!Hxv9?RD)57hx$jK1nOQ6A|NW?Ts1t%4t{kccA7GtL-T^@sSSsx0`!N~a(7um53v_acsOi7E8OZ9u zUDR&Q#4S(}_Bo(ic-*e3{o`;Wz1v(&s&aFNXfV$Eyn-h|);?z&{g8rKuw47*$ZuQ> zj+-jo32l~3m&bhJje000C3W=1;~>48@nq-lfKvl%1I5bnE%i0p`OD=w-Jg))&J68j zc@?S8s`r-chBDUf9#14o#V2iDKbIPEyS}CrY&3c6;Ekx4rFiM>U!ZI$N$}exi7Y*H z7x9a$c%c%8&vfGea(MHv?ynbvj}+_4P_K~B(6_fH20J=B1M+;*d~6HMlExkUJH;SSbbk2AvWR9r1DV|ci*S??cP;hcUz z?tc2?y?06nt>TB*CVSpdq$6S^6%2UTDDIZL&!Z>(%uV>fAe!MNp(RF?=WA~TtR1GW`f+3TdVQiD9%hzf`272lsJmUfA9ScVs*Dxj(tc2yo7uEPqy7*5B; z#G#Na$NTEIhNfsAlFQ34PejbzOl7_%exZ@)iB8bd{n6*R`6K1HEA7MEx3NwEBS~KA z{z;eX9R8m&9z3@k+-%ox5+s5h3qr56 z*2BvPtYOAUxJ_8jasH>pSR?XHz0cudCUHj{;(!e97yt< zK048x^kC;5JKJr?K5m;(QxVVgVFl?>tG2GstIbx1M>T)HDXXU^-hSVEP#^ULdSh&t z+LT_aPh}pjc&}KNSY9Ey1lIWSE+LiQRONHP#cDarle9?G&YRlMcyWFj9Bt=3|Ay$J zhH4PsaVr!_LjhjYn7$reYrE_lKpVmAS?rpJreSoN!O8vW6nx<8YFY&9blE%@Yn=H6EF^9e&UWjn1~#K zCoFxyOQ8%yQhH}a8cSNw`mZi_iSR2c^+gT2xJ4^-4X(=BPu}};4iqCDVOTl(gCSU_ zn~PLN6%6FWf0ukZAK=}l`;h`Qkw<@@#GCydW7EK+8Kxn=MF#9IHT{omBHM!lPm5`* ze*PgOls+>Yv|XO9--Z0PBdhbGGCJVwkRo9H`u<_i$=$6*0mlzINtZIb)C@8W%3?Z3+qcLn9v|@qWyfid#l3A|opQJ4L7O?1SO?GM7QAF}hwpscumrap{{IE^nJ0=dw_o>^vo|#9HsP@!9d5DH z_@onAW)E&RTXOO{5UAyo!d-ZM2{`1_m8nMm`MV0Rjs&;ssD!lR@uhPWIJ$H6QWTQj zI@NzAv8(JcT*eHJgVl@1;Eu}~b6Q7HL`2~8LD!vZp!{^O-Rv*K$ll^HGlKYbzXTE4S%T~ zEUtecF!sxex#IU$jmA{2{+}9Jj4(j_bmsh4;$x7-bLBoyFT$KT`@4bf6N9?)T7%uG zBcbP|mm>qdnnRc3uOJd)0`QylS6Mb{5W^|x)7V${{MufB1zsGrD-7gmPEC>d%y<_Y z_-Xx+y>}Q)gue@p7S{qo$9y-na9L2N<3i6dnfjMIm94YVSJI-PIIVJ0favl0*R@OV z(IgJ;p6{lD!$^)0+`5VHDnj3QbrCgXoL6C2f^-l2u65%}FnVvsNuMbg$5ayx@c8f95Is>1^bJ+o=tC+T2I(Br+SOkpA_@cC*9n z3bnRcvQ-mpJ;quU^9x*IrCRT(DjYP8;r_1tyT7nL?ugRBgtv3%-*ux3S zSq!d*ZrgqKsO|mb{^70?6+S5V2L6g0m<4wwVrR`($P#mRkYa&5Oc^?L zI+7naAitCGK3Cp3`!FxWy!mbYu0T*eBj|F^oP#T@8TX|=9<1p(zcy#gLi-5a&oM3z zx!IKQ3MXp5X>0r0KxT;#bI&R&e^`F#X=uu-lYi-4-_cgiXO-vce4Y{IJo-_cr?;b9 zXpwU(oS!ZfRNQ4dH$+WMf6g#u(5_>``{J{%DwgBhMfnBgb_3|)1$?%}Y5BjIesM63 zwOIMh9AA3V>rAnbW0|464dCqxlo6*Bw9|K13$-jS7kwWISAYL(XhIx%$3PyVO|ZX5 znEa%?vGduUbWxAe%0RNYByXEd&~5a^Rq;iJ6=q%3q^ZuwHB;^OEGIY!F-X=0jz%jGBk z>sO!ak(0TkNQ%Swhn|<28C6_)Yq`bc8m4{9qvDP-FL;|@@SwTO5{HYs29J*)QpRin zu~q5$VBKaet*c!9u?G{GMaGblshDG(Bou;T-Khi3=vXza z?#N_$Oqep#H0Z`!3Q@h6tz6S|1HCzQpUwJaeS2_l`K=iSTRz4o9aL3G!C%OrEf-ch z6xFz7)THS6RUX!L$u}Wv5|7ez3B1@P3iN!(WIW6HOTOZy&sLzt)jW3v-E^XN5%<~=mSPN&8y!yrBTtE5#~*;Z1lfOI{Tlv82Wn~kA{EbooB zPWJ#xTNiz(n5e5}#_(=DDF()02NYTwKRg`1>*Z_^6LsxpssRg@Rids@!tQ&~Uj%OW zrO>WRY#$elq89Go2YM#Gf81bbZZP|W;T_L2SMxxHFXD}ZDA%pcMm$V_M)Mt(yP&bM zOZlK317-_x@W;v!jO5PNbgtR<@y=rU!qW1o!fnPb?m!5&V&bd3Ssoza^$8f{nc@+f&Al2eT*UA_*6x~O0X zv6i7)jzCe(HHCizbRKTR>>FN=-d$c>G)Wb-_9YF|nU$&&X-+wj)|D09?rEaH@UN&p zT@_W7a|KP>yng*Xc1yu;zF_o+BP1^9Dvp#Gs(kCY34`X#KheV+*R{oPXO_;%wv^E| z8->m}=oP(xwj#e$G~7m+`7it8jUc+Forh)(uAZva_bY%kVZO47&o(Z>%<|)(VNmec zRpayD%I+Qct;dZ>N&B3x6^KZjb(km)L4Do|&s~Q})xcKGN4kPC*?WLNmx^TMDi?FN z*}~>>_TB#=g%Fv@wKqjQqf5*ES6{F0-?;?cZX0;^{k({FnS4?+_cq-Ew{QafOegH{ z^Bqv{-et6f*1%S7$6>v#C0(UY4o_yMmGe3EdcKuio9*@6zikF0C@G!FK8+sE-A^+-^Rbk1Fn`?uJu@Uyd33J6(${tQKc3zyEY7Cs8pS0z1b2rJ zJh;1i2<{f#-GhYS?k)jBU~spfgS)%C`yjK2=iBdJ2ON;=3Vm01S9h(ox+?Ht#)tnk zsfgu^5vSnpZoQ8Z2Y^DuR!Kq7+`v8{?8W@^#W{&6-s<~hj2Jl@Y6YAA(ph$c5`U?; zi|VpjqcQ8m$br>0s^Z*MS=d|e;c>-Q#g@z~$~D9BWmnO4yL@F#lcnb8QrZGY>eIv$ zx59TvR(r@Iz-W%Ie`q&U>ja{95#a#naZotJ1RrLBM=QgWbKjhIN>c;ZeCE++%m(si zuvnsJ2B3MoY{k?AdSDha?|$e}G^v*j$X2s)%m;xIQ);6c7txGh2~3sMb2zrRwkxr= zyo20^Bl58xc{{)zMiT@VZPKyN*GE2}+6;;3Q8sL`q@lT)iM%~2L=GDcIyFa@N&-gJ zkylQaB&6Dk1&vuFg}1@q_}8`I|7Phgbv26qp`hL<4u;UA-}8Z0mhZZgi0k=lrP>=-vw347owV3` zcX50EUuSQqDTU(5g`{e5y3k5T8a@wVVvUF3)$o9@fiD__Y zPbtd8D}QifUUcrBiR=m(`TY6S79zS7-eQM*NJx#D+-}{`M#gVG=0vJ-*t#8(ee4B> z_r+!YcQJz}{$ zJKh5JtFyFS=QCFP9|JBnD(1*nw9sHYJJ&n+CyCDvEXExu-`>|ltiwNjycy#iKOUfK zq?de#hK9r(%lE`}hOBbQ8-=~*HYF+kJ|oM(M1<&3fC0R1npF{~ktFX0^j$%#7Tfn= zp)(CwE4MqrwW4TfPs%9H(lvtxV=eT0Uvlgca#79@T<}uO@>bUW8_dZkk;{^XDeM#%W~AAu@v8H_OtWw+(f|~e1S^HsY(s}qNpl;W(!ko)(Cn7b z5sNCy9X66EXPfpwGtz!E!3>XF^*KHrXa zKoVjm_+iqww9Hl%s*&GKt0-(nRtL*txs;X!Iror=aA+~h6x5-H$SYiLjCD~Y9E8AU z#~)&bb)DcO__=lFwzZsTrRIZ~eO_`hQ|X(D_IW&hWiRAh1ITajM?&qnLws(0=G#He zKDrb6PAYW9o!p=ChxgrYr%i9PZ@Za<7HhSC(hnAzw|!>=u5FH-#&N&U<;+8t#D4}w zwe2V|ZS;G0PSXuEQ+1QB7`v8XEMwm4zQYkxpU*oL(_D75UQ35%It0Zt%OOVC3 zcAO~KBKll@J!Fl3uiW$Puaqz29fd6rlE}HapqnF>; zI&#Wb^fpHjDm&M4S~_Ys*~!^}n%(ku%I69*j)Sj9|&_}@ZBsd)r^e1olj^;x*55YE?1vTQ80(Uc8pMjTK@&yFN1z#`=*WS za)1yb#?S*1y0cY3)vbOHhnBklpOv1#aj7K#3=PnhIg0Pmc9gf0Eo%_YI^5bRy#7sG zd;7>B9rN#I&x8ld;Z-{$ICoQ;{MHQ zR^y>#PQeN&Hx(-5r>k>w8fN)E9nAvzIlQ|LTFp4s-88S@`$0c*t1 zZ*qN-UWB6YCRg$a$)@!_eBlsw$P#-Pl(vr4&$|(rb7K1*c!6AGl0%j~u9I8II+ zWNf3G-}ao7$m^+}sT7`&D`^$Am-;WDaPKKXofuXthxgl6W+HCVbpZYJFyd26M4&;R zDCqW8+X^IfuY>JiY9=0|Xk^64Y-cpv6|qYHjKg6kaMoG%iPJ=V?lg~FC^#+UV3=cW zASA(za}kEkK=U0YhFEVE20q~1niI5uwxrITZQ&0r??!da%-7rcn0nS?bx3>FDU`80 z?F^PeSD&S0FGMZcluzGSn*r9{NtW zyqgKCC5mlVMD5K+3o~>Fd|E9Gnbbh7+xo{7NL$@V|AOTmz(Oix!xGY~PknyurWRcs zCc&>y{sNYETH8*Z!FU1W2i8FE8;!l6h90HE=r{T`Z`s0N0iV6RjG-0D=Px-uw||vR zRNdXfL+Y`98(FDV1*uK6=tkG4rwE?T%?~H+27Vkk%+2SCh0@MLnfd=WU^OT8y$fS> zYigPme$H=YIhT%a1>b9BM|qC%V#d)&&IM%&ou$7ox*2TPp%FD&(H>x7n4o;HMTO^s zapc?snJ?3>U=O?bWFxoV+_XPk2>!cpyGMo3-1^7pesp)YoX%l`zN`^f;yPOX@Vg?- z+3co1I=Q`qftX&U#S>RgS5xmDqWjMj9og4jV@>W%HR3r)%H~+C!wiG@4dt(cP6g+>(*7)UQ0PMPNGfI;yUdlgD z{_EG2Fc=Ww<}+Sqx?{Cw+5L6eNay$W%8JWTt%SR~@9jUvXD+Te{%?;%ezNx`PmEx9W(xXpq&vN~{E8EHZhJb*Z?lkZ1GMIapjQ1|VM~8?ypW05 z_Qgnx^ApPxqzjjI>--*GG)NG#LRHiIMf|p0 zIbV$8;ZbhZplCtut{Dl~#TZ5TLMt(sqfSS)t+r&IW5`nCDBGQs3-0TDi@!Ztf!8l? z19Uy-v>!`#MZxvKyUTI;gKUGNwh=1*#bvE&9ii?L_p#IOG#t)-d zEnah+b>tYa8D|fMoO}dq=U478e^Gt^G|T`aJYVYQbGA@x<(H7|GYjjm>xF2DCOG! zL!hB*15={=9G!>JR_G70*491^BGV&_?Y^xKhHsuC{a!2P^CW|6q_C{lHexF_BGf&F z@1p^KYksK!fQb}~{iDHc@MQU9u9AS0p(Y`oesEjd7xd=r2SaATk1k;^jK&U{IpWK2 z&j0(whps$V~rVyqEU z$@I*W?oN#K72JG!;%RYu&973{x|J8P@=1VZgbg8MX8T_2LMEEC+OxRZx3L%D|ErH4 zyLPv$2QvXz&q>hW@&yTeL@21veN*>eJa`WY8YZ6;06LiSEW=EV@S!MOEal>02WuLJ z1Vm1?wZf>Aa&MOMLw~!Z(pC#Q<3sO3TjQtZlFs5a-Dbc)4Ig+|2937xSkvH^XDM>on8v4 z0#h+w>#_7}{_{|jNjdXCt=s8~^!vZxp_i?vJ5xmziHG}X0vRs;M8h7rS^a5584b{_ zI2vu3FgKdOC#L!ek7Le5Y;)1{cQEj>$j~xlH?YW|#uD*=UM2{UuN9ITBrvE_r*l{C z_VEE5+#k3cm(6x5DqiQGp;nv`U2|e9v{o@mi04sl{655Ol0OX0Ea&`3+_S$N#b?SHyv-7`+w)rES0_qvqYTEkIvu~ zBRK%WA5&!M(6RP?5~W8^jam^+qyF=B+^ngu*YSf#PZF;%*JKGs4QQWgR51PVtJ!7% zu9&1|ToNITXeQd@1P0tTreTv_OYU{}$w1%2XwEPf+6jO80nJNLp+!D6PBbZ>CT!2f z|L@gF)!-$0`SHhVls}UtR2=MdRk5}bcXvOqJi3n^3ICx-9U2jN%nrBIu3KVNcb@w-qS_9(^qx|_+uDa%m z)72)@K8cg3>6t%ICm%q1HR%$vdNbE};C{fPdo!bQoIlB!7l-;^_#P;_!Hh9bjB$ok z-dHT-@+^5PDP-mlI0Ys(@%Xw)D!~PfwV2_C_UW(b0S;xu&*(A6P*(pPBl~!NeAu>q zYC!1FZNajr9YY(&tEeK#JucsswoF6vO1aB#tc8!PoKZRfx47&DHqJliK|V7F%l8zL z`?b1PWFCBT{OdZ-H)yxgICdJF57uk6WA{C4&`KNd%J#ossz9(7h`0Ex^=<#3zg4dH z@1;)vXHf_mU0`w`HZH&&{tq8X{Flf_cK3v(+zE^3^}LuW53LHf62<$Sjh%>2NHu#o z?u3P*(Lz+b+xAlNTTKawZ1uU^v;!hq!QIELAl+il3sqkXkRRRKQ|Rkj#Mg>voHI&b zw-%JSm57EJKg~aomFT5I*uDQL@=^fqlZFDGK8lpa$0t{*l(p%le zv&{3qzmg6LkAdCkf&jN!LJzAm;C&(+=`4t!2<_T8DH|Fvmf}>&w)V-MzQE6^<)0t2JwPH7^Ix7FBtj5m&sH|xJZ_d3I_Gh;BmQ9O$$pUz zVlp_A-=B&LsBby5cWf)ChCW0I1@Dl9o9X=ygdbYQlpI`e@($?2nWn*cFT3>nSaDvV zaWKYyWY_UO(*qxW1`+_pd5s=>@H0I-Z*l@oBR)P3Yjv8kxR)zD$-aN`!ZGBpHa=a& z1)d)CYXW>UU9X~y+6hgGh+L|!8LiFo02+9Y4ST7-#>DVE2~yWy4aT!X3?3r; zm=Av6X1_2wsH~)ASef5%A#xPt{#mj4OPMb%+~1D7Z&=Srg9_8)GNpie-1BOCVkGXY zMMxU2NFw(#2*geNKsXZO_0)UZQ?FVDSx)E??CY-~=={JWI^lT3X%*frIF4OwYoql-q%Tar)bcD zZwG-64n8wk9v}(h)1YDvt@5#Y6tkYr=kt@noe`tA+NU$eKwB1N|}Kp+VU%G1DFk0$68&>GRA_hM=CssnjCNi!CV^w^K)J|-k*1YY479MglSho$>cwS!+ zybQ*;-JqOd4SX6Id*}lDyB?R9W>UuqPRl@x);@|xL)x=#JY$%nQTK$gxD~LxkQsmG zecDYDf)16Gu#6K1eb0+Kg+?UVyvuf*mRp~M;(MtRdFFK83`X?H@tc%k;IV&vng~1} z653nLb*ggB85zkZCk=e;CHLz%EGDrJfim62H0vh^KgzHo5*91rH+RFgZ-sEDL_;6a zjDj`tK*@1%`AR|8;5#Gm-bskxfKO;yuGjXjbyZQm<$&Cq?7;`k2rm1cOBgoZ$@{#w z+fu{aMnlMQK9?1~=HTvn8gm?l{FdG*x28blRwv?1Es)(q@{20D3x3C;)R^-isns~| z4yp+EETzv=yi8s*OEk}!g`E^VC{E<J@o4+;&Oywmqi==LPU)y>rTM3b!mnX7_?v*TVIYviH6q3y?9^2 z9#QW{G!pTV;b`Q@0U>y5(XB7AgGoF5&6@w4o6uevl-Q$m5Q|b0C%jN$&o^M0({E2{ z(G<_lc}!#a)Ul(#lLIVh)*>eTA@50R`3Mq;meOFdS)nrKL5*(12pLf6@0C<@gwi{9S68h$k zR>yEs)AQOzz`nr7Q|!~rfyjj%3E6Gn>q6k&MjrU(AdCEE6EQb^tNeAP>s8M+##X-< zM;d805d0AMJPEq+zwqtM!R%Jh-^SiId}vK;xyiXW4V=m4={QpAWC0$6UpK%Rh(d>A z0pxF-xmT!;(Z+4P?|Tr`DI4hMQJ3Y-2g({A<=dTxmfjou}EffCjr-Q=x?5r4s15h_S zfj2yzf9v;B0DdK`Ket4I>?_;^7sTtA;hh`6Ku8e#0`OfsdEteX<<%1qzAeDWaP&XX zGXz8aPdrDsO;9Jq8}~l!EUNem`2Kc3H&Nl1AGa_97m0sO2FCfW_7dX@KMkq)c%$;! zr5K;LA4;)ayR&iMW<+3DvC`z&-a&%$Z2J!B#UDd;rV z1@(mCPN-oYG&#(y^v&0Uw_n-hL=*8or9GcfB(j!3A=OrM|91Wp8J2}AHE+sG9O=(& z91$+=4#wH&e`sh@NIGG%;v|%+1%o<*Lzr$ra_@M4B#Lz%Bqp*={Ps=wTw_x<3baBc zA{yLnpd+w7^hAPWN(lsMi~;t7c^Kg7oJw*568E7MGSFYb3a%SHFE6zJ1l7d({{_EB z8NKxQHcm-BTaWCqc=D-yD$lD!2POewqVX{&R02UW`;d@ZM_gRJV(%6Q0;t1@w9Z>vM@$o@KFE~l+yG^SYgbZ5b=uEVyn1l_ilKIb z(Aleu<4slxsed6E`%!MiyICWiS&Ux&kr-%MuZJ~&L#)<3^VZlVd1^u@PB{#r zSYOu8J$P@vc#!>1<%AdrpkNKCU-e8>ftldL&A*`%;JtM`yXCVtmr{~OPNi0Cb(rX=8bqxQfsnn-^G za#8%ZfAMyH(OOAL!1try29a6HKPM@4gD))KyAhSY$@6((WIRxNyn9TEIp@Mf^Hyz_> z9-P&5aY#9%BE%_SRUc#tV(b-^U7Xrs-&>Zv=Bwpyuub9D+9syoTO!}x8`dmd*dMfq z6yc#BjwvrnV*i~9?eWRpqM-$)gKS`ksCk{7tE&~1SQYy6Jps)N&xx-Qabpjl(fPgr zV6T-31mJL{L$RP-J{3P!_oCmxp#nZrTtEifMz4!$2qe$7L}J}VcVRZqthPtuUw43l zrYR=hjv(2^tPLka5}K5SL;6KL~5p*TuuOt(!FOJXRRTxT;qSbthcLn>Uj z!@>;$ZZ=HO`Kd4xGqjBUOL1Q~zpkqo$5V+zYYg)=`wztunUpuTaRFQZtOVfK-G2E6 zT}IK9Uhwp#g9wbGY2v++1P3q+nP`s18b9R28W6ZUK%)CIxA-{wa6U!`_yPQ8v|zY9 z^~)g|ZkA~Z55sq{*RK!hZYqEZg#c&y$5uFoi=;+1>5op6O%p|SZYKoz1VoOs^eh4s z-bIXFq=j@p!!B7;0w@R1wv~(t|8v*%^ncl5k2Cnkc8)OLzspi1HFI^O)Eeox*b$nY zV$U0y;k}exSH*Wlyb;;;Ai<=`S4#vdHKiMdrj!PL!_}Mbywu59Cxl|0csq1@$?lBw zv(F1m6aGzU76ZivG7xVV8!0va^Tz-{+^uQZq9%p-qbSpe@iY>ddRUpEx_>mrny*I$ zxDqzji$NsG<7kuzhvtze@bQdY;g%OUWn`w&X686YL>9cKKvu ztG$5MiyH1_J&Q;~%8FCME;dcBq87ES)ODw^tdO)(vGl{o1)5aUV|Z;}mg zue5!#$Eg2xg`h8hTwz7rqhNTQxUQ`h>k7Tg4-IneXELmr`F95obV>~+nT>#!9GC5; z_(2)rwvRmk9}Ts|MH5%k<0Ww$s;`AAI!cTrv@_Cff@)3-i?mTKDv+LbcG(pk{t?7v z8;1TV>J!hTNl{qpi8b*`E{ToM&#MRB&u0IcV<)moDhwUByP(Nt4o`eEYog`9*#*(|3@Z%~sEZ_#TksN8h75 z%z)LTCZWDPNdPcPjZPUKSb_FrTpzSM((nPmI`9M2WlUT`43ejO{M(`t4ryrt{}|`# zckg~kkMNhih3v#TJI#6MrE?X%AK~gysj%#?XSrs|YUD_-9T(>_?idqs(;&^Y1h0ns z;?g3cAlsKRLc(Z<9sO5If2rp@ybe`DTud_^H5iQr#wu}Rq4w7hDvgm!iQrs+D~}0> z(aM#{>Jw;}4>h!3$yKS520>Fi7Ylc zDQK*@LY3}EM+*E!KvQw866a-EoF8_xp&^;`kZMzVuOSuscjAH*ztGbe8NugzLTm#Y z;?{GwQh_IXM89GYJ48&@hM-n=wqkFSK59Is8n_>Q*>-VcJy~MP_PS`HCwQgYZ3wTqDuO&E*>nR0qM|xh ziL)>zP;mZ2-pMk7kiiIJGA^&d2 zc`Z1r_a>CrS(Y>$O0b5fWk{)YX4@N{geHB`?f%?#k;u1l39tIl&=!g6C#twHGS=XX z(pMN?FrNri9RHa)h9Dp)Bwd_tPn2)>JLlHULdUs#2l}$40P-5Wd2WE%>XOWOz;ot8 zuUIr8Z2KdO_!VXK6|`$Th6}OuCyJ1%;Dy&PZ!=0nw8&DD4kO5Q@3rDBR1$Vs?z_+m zECodR!wTNU5Zrb1Uytr?{zaeDNr^J~YYQ>QE^aelCR##&<2|i74_j0TCYhCeZZ>Qe zj_;-AGYhK2@`|B!HwoR;jTP!JiWbp#9mk@;Hk;z+`ErV`t;gDOK4bLKvh>52Ya;M> zRg-?c!iQqnNe(vUrgi#KzvJD}Lh?2^(j^t^(E~vUj%^g$`3}CVdov(##I`T6Ow)zL?LF1rO;%gMX@cM1!pPR0WtV<<@+#Knn>|*GMMzky^(lqusqKMw26pNLzdMFzp%8W=P=}(WHl_Do5y$YXXoA7MYRjW8W1U;aL`r=ztt7emm5qdJp1YE?)N6}=tBIPB=@u5pQu6U=bOVKtN z!ELPlaut6YdWG*o1()@ZVx*K+UK!q8BnZ&=nKPF6lM?%^o-HF!y{JPWn>J=N_9bK> zsXU4~OtT0!EV!*;uVOhQ;c$56!L-*WOegb2bun`D&ppmtIC(OnnRb<;7}9v+;1*>} z-9k3r?^kMZKFJsOwSK@IKNwVA46kn37j6vn{kDE)2NT2l+O($)>=p{dYplq)t+0EO zFWs0>8PX7}{-bZwBn{hgb+Wp`#J)y*rz5Uo?WljB3S~F5|Na_p zhY{n=r;_G*NAQQ;XA)#Kyb;2UJyXs`Ym!q7)>MZUW}4mWC~*5y`)IOYR#Lg0p3Ma~ zIj{=r*YRMEtGP+qvf^<$i;sK#%_EHe!>O7}u9{ zE|-z=3oktjfS{pUiwK2!HYO_^s!AJ4Dxw@UKh%T>95x1T>6*lgup#X_!NTn0z-jv1BpDh6ckv*;xjeUV1tv-Qr zS;0LgVZsli$c0#s%0?}QLAE3A=Gl{j z%_D-vs5_%~B^&xi`$>}Qm)uZzM#wt#SZq)iHk20^M|tVTGOEV~1hc%B^ZtjP?Z}s_O2K)6C6_ zGa88%R0K9Y2UMR6cM=b~zx=bh#qagb5sq8Hh;nd3lD7~!g2qZc5~PP{#L5OLMBI9Z zsq%TUr{bzAykL$}4$qOx93>G+3~i*;Elw#-@G1a>5#30N9y5;3Ty>l)=u~0RJ6$G= zYnxm`@YXr24~O*p72}zrVE+)`v4w!DFNEbxTxJz~nc`Jrw}`;bAv#(TCt8RbF>L${ z*V_L);fW+dlY8}VXCea%^GKWLZhF63NxZY+KHSkhKIzSMfrljT(mnAi_tnWLFrEH@ zqR$ML1_s`E6nU_|z{FE}8Opd7{&qR1qU{k+kHc~;T%lVVk8|)E)R4`;dKjIs7~cT? zkzywgD;ncDU8X?=QJ~0LEVb));O?!>9y2ZTOfo}yO`WEq{L$Zhs-5+2QrzxBrCvsF zEtfWm9bKo-!;J^e8dhXZHbO!_+SXejTFdLSc>drqSr&_Tt(HMP79Zs+CDuniW&lFD zu#Hc>F!1D(BERL@d8so%p4yf3RH{qj zU;)nkIEjO726{ zy~QsxcJ(=VwIvQJgH%#N%wz!C9=ZMJvc>RtW&!q3`Jom|gfqWz?k@IEc;?Ra?a|(K z28$h&vXHl{AsClf5PmZT_p=iL{Ez#i1=!b>?h?W79&*H{9uHoUcs^^gj18uSl{=w zlImnz9oBB~!ukIsFHBU144AQJyhN=T~rOG)%3kW(S!eU$KY*f@jFLT(veU)h4Fh|;L#yr z+6N3atL5hZ$+S?_YJTKeR95z`o%&0_$9qtyXk$tH8Et#J= zxoac}4c+XPpu}$9o9%9@uD4o%(+_^6*`sZd6Osh{XC^uM&2jJ!qbtz*w+qsNgQ7*P zBl$;}v1e+6L6x5}BzK{5ZYa~Rxq8o#s}{}h_jaS9DIFE>%_yp)Q{41oMhO1SM#N-J z=-;?QG(rYFTrdiR@QsmOSYc+|vNl1l#8_p~3|7 zpN3%%c{_7Nkb5P~&BwwHw?y6kRO*`1P@p|AgZKK*Z&ZiGJ2p7D9yegSfu=E#=c1F6EuGnt-%rC}sCPht$9~AkDGD ze8K20dw5WyzS5K5_I@8nCjW3c2lU8v+o1^G|Kvn%`1@ogX$D6pOvhg==G!+K6s2eA zbrGw?Imu{02*%lIkB-J$Qg-MTRel87!dmTd^tBIQQn)l6X@&=p28)^DTN_*VrN*^# zLiKnjTou|%NqY}5eb78YTh3Y*-n0Z0AJ!bnm2>xv4oarXM18dIq_dk9;@hh+=qWS- zH@FBSj6vQ>nhZHrfEnzUYD=Cwr>ZNM7@CH@spw{Ln+ZTp6>bC)m@+J&w?o90gNYuE zc9hzgb?Y_APD-B7%HgH#xCLzlwFuDWCh&P}WuJ7{=8Ci=B1kPfNN&{|>l>C^y|$Ho z5%>K@=haWBSQ3D)gy<(X5lyFQi;7=1vR`;tit|T5+OCb;Af#Nlm}{2W|7aHA?YY4!D1%eOA45w~n2 zc8bjEC@Zv>qhh|lnG5YN7ieuFPA=vMLX|KuK4b^(f0MKKnK2^Z7oxru6lyQ)0^UpOkXzopE-HdA4OB zo~Ki=GOJnkZ^6V$#^j`*g$BN&LxYQ%J&=J3piu0-!{cN0!dJ);y3TLl9VOv@XK5`u zAt-J7d(OIFME+VvUoYPHF1xt-&C@Y7qCgXb5CMiL4M<(Lf6@C;!-&4s&nR<(pS!>( zXh>$rg4Y0_xeC9P`(<0!IzlJVQEAq@EMb2)N#%UtXjVgo>%wbL!0*kHO&xNgpF*L| zD~@xHHyj4NSw(?IsS!bWs*Q9)TAeg)_e)gvwb$(bJf936YC_^rge?bpb|#uB*Eyb( za~rqPfp=_TdfS8U3;SjM%i1>XCCB8&r9=*;F(c+~XNxWAtDouEDQ>7&E)+Xn%&CLS zKb=+ByL_XgIF~b%NH((YOP6)v@j8y}Ipm(xwdTgu7z#{@uk_7_Tr^JGyP@8Z(aed! zx0`)tNAD=_mmTtpu0V(!gDO!M`=J?nz zqgKK*?Vvfp`E}VzKWjkEJLU5IouoF(#R!K^*b?C1V;u;YtP6lRW`e}28(KK8))=Bt zISN}#ddm9h^}MoiPEFJYbqP_){EI6&Olo~p zHkihvc(sFE6yVt?R2|YwhcB0e^?d9n;_uW!+95$KGgg0=mX&@mT%b9|4`Cr-oxQ0Hq8sE<5y08XEDzk8)E6{`Myf-bV zdcSHuL84s_dT6=PiS7aiPxC*tQT)zJ_zRQ3cL@jb`8#WOQSj22|%Y-mWx%&`# zMeD?h??uPh)gZa!`t$lj<~wt}50T9@@4 z3L`G9j$_TScMjSJj}VEc^$0}`^(T2ibISl%)z;lQXuj!QIvOM3GGrg3$BBD-mqSqF zPF~#s0@sKoF84(a#V|Pj6N2m|YmDCHJ_v4T?E)Zzq-TDL*LtjuTjuES?(a_Lu6Zjm z2$*WlkZD|By$t91B17UFN?wFwa45b%llNB7lc@hRti>G&S!-h{Rc^F^rgs6B6!6Nv zsnO%6;W2u<1Ycu2BWX_CMogz4Dmk@ZE`>=@Z`wdDFIWt}c}~GAi1Vzk~bpAJd|fRjOS3vFEcSjwhnX z`*lq7hf}D9~Y}*QANyy1ds>{Y@BrHm$bhxlzb>xw)=74`#&q?NaAzcbynqT(2Nd zY;3&j^NhVCs^bddWUnX?GB~%dE(U5^nNRkvW!#;`h#W5C_8k!3hj_d52{QHjGZe@1 zXXLMkGW)myuQ11-UXw!tkhiDpTfq}%WH$s|B~XP*o!L5~$&*Z0F%{R-%BF&`m%gPL{V<-6D*w;Cd;dElP#8sHp# z*V1f+^R#cF{ilZD13^gMDT?TTZrQ80jo0y${_o=C1oVx%?GQBi=Jj7$&&Ip>VBa0! zYv3Uu1%cVeVUb-Q3=KGW5M-O5@hphDRfjVFzOnzKPfJU3?L96Qs&jfYWat|5&%Rzd z2k#f6{fJCKHTxRAxzf2NyBMHdI#!LWM(ThoTUpd_za-u|TW)_bc^#d&eqokv`2E($ zxafpwKEpD$7b$2HH2BQa>ZiBSwV25X+^kRnrrQYUH3kri(AyA72?NZ@i@k87l_rw5=`Vlh#C}8M6jOi^({L7p|K3Ep< zn&MefP?Z%S2C__i)@M|fD;?aKx;lbAj44D@fp9>9JMiP6$bBoD>MCRgdESZM4mFC~ zbxs6HW@E{2FHvFXNaBsh0paV-rmojY5n|{aBnX8F>w@^{J*+D_4SN#5bWO<3&rdoW ze)#6TUkD@4mz02PZbjL{U14<`$t8SR4VcO7^Wo@U2rn0sChK^eI9z^aj;v=)tP>RY zI}I=L1+nAf6sSB-Hkq4=mm!a&+5N}-qI>^iWbOXVWYW68=SfEIs?{$~#T*KV*p}{J zQ(fi7l>wP#xRV1G>TTGdG-$0GonQ4eCkH@&SvadA5(7+!(y5_RN_X2dxVc=zB z)N;}tnt2(UfJvY`-q<233^!~RtT1ue2p$5KzRrrVj3J01oSc)^AYBl#hiS*qn9RNE zF>$g4uuvQ=fB804RP|f`=b?O5Olyi_!8mjVpmGB3>)+ShNnWi#UW_?PhX%jTY0YHf z!9*4fK0F|ANG4nD90Vt^95#7PTsYw<>_?Brfi@KgQ7!B|EJ>J8xIzSXhGB?uGGyxL zI`r*tS(ROyO!K8tgcD)yxyCK6X9 z`2je}Ax3m@DdDPbXjQDF%rE7^tmcAG`++L9!Qi%{d2PSsbMBg9g+82Fo^p)OK&wZK zKfGvqS7fqKB%GCzj6>AoaN|bbJI;vHzhFsZ3H5lSTp)h-+A+{5W-o_T06DOc_72!-z5h;z_WUZ zsZ1k-5iyO>JE-}C{I6f=ZXvGdYLCMVN=`!8vCM)pbjfA+nxi~OZX2~=+35Si zr;9wNo~!+`qS!C=dAIZ_j}A4EzPf)DiyIy)!8JG$5ACC=L;QoY0-Z5*Nx~CKz_Cv4 z4&!+R@&(MM!S5W6My1Pf%4};3Upqp+lvkvktHU+~T)MZ!RI;Y|UxvZB29X*F8t9=jNzZeYG^F=CSFt<5naENJTD*ho@e&f~AhI**Qm6E`lURE7pBN zpX#E>SCs({-xr4B3|SN-HQ81_dV}LFfxSnkb!f+rn|47f5Y0}2-L7V&pQ9QCAr@fu`u1{aHJILu z(FJ(A0|W@_8(BR4W0+?vXeXtF?BZ`82?s0fb{h|R^GibqR5b=h!WHytO;G{O;zG_X z`W-{{`fd2N^sLmUzB!@>jo=PPs0jA(zJo|TT4u)fZu>@2->A6YUkE=Tx?@7<+UovL zWPUtGvzgsIw}p3z8VyYWb~mUJoj{&fBd9Cp62b@aBcVZc>2N{)W&RBVtydRIEU`P7 zI=5R03x%)ymu0Xzp8n)p8c$61$*970!ubPy8L z(&{0dV#7>oWLxLfzqA_0ttBnJasj)L>GkE6$+4)TCzi7qk1!VltQA%>4*t@5XRRO0 zgY5Wj8F!YPAh2OS*yF6*x9wd^Uy2~DK28z;{g?C`WQqBsI=Bkx>sbV(Z~b6S13dL> zRFP@y`OjM4F>og(J0la0l^=q@#KT#XJTteRwRNn=!Dn^0=A7YBd~OcDU17Z*+MI!H zy4q5zf%*<*&WlZky2*U;a~}YR2spkw)#^&0IDc^Qn`ZojSJdynG>x7TY4p?n>z1Wk z(o5^GO#Q-LQ0HQ=;Xmdf6-`h&gvInLnvvVQrO93lqSJ;97O4^XJ-&mG9@1=2R@)bZ zLYa_KZ|pf7Q#WqbV@$+;Q)H#p!4mdIo&Rtz9avh;m&Bi;0Oe3k`Jw89|dBeCd4d6t}9rP#<|5A~UFz`#jrYJE^vfLAxg|1@a zqW-m1*O96QVNv)u{BqMM0M3did+KFz8!*s%hw;TKw*MI*la#_FOljj1+kh~*tmmA$n_ zsOc?IW|8;I^WM(4W=SD2fxnlvKuG-=Tit7Op=9TOX~C;F(d32biAIrh;=1$~n#OHH=^uIL{>g9;Z=Z+n5{ z(03CU!@{KNX4zvR-V)N{l^{>P1wb8B+T}7}&ua8bO^D_)fpcYU(ZcP4`uLTd;*Xzv zQrrSqIB;I5F2$~lPB)xTF7ERcOebYm-3NBxxoN+TvK>>TGcuwd%rFrda=NN zZ+;93{nKWN{Snl$q_%cGHY>K5+Z49(zKQ=c+v=%}$F}gj$I#TR*9ngN!={|CO>(mQ zIo$L%9}40AqH)f5e#nz|S~6La&&9^YoG;Bec0_bWbqY~QtpZwezhsSecZt@GC`y4~(ajb)sp zJapT>bptIK=T-6xtT?eQ>{JwgWb7pz*2t3o}q z?TaQQi~-?B9vqDN@rFn{FT5jZ8LB^AJSr$ z8B6oeQG>o({Wv93xBawdM=6mcmvZv|5p|YfQGH>%rzE6C5Ez=Fk#6ZuX;iwqrIhZJ z0fvz76jZufKo~luySrQBZ2sqc&)L_-2R;nU%wB7+weIJBem9HQ$B_YwP9l1A*(*KG zBTFxPLzXg?Me2%tj?&{=B7fq`19+uQIcdxPezp7o(ee&9t-`ub@QXGm_ii#ujEP_o z)sHF!%^00e)u$w0Zw*+8b|_(U72h-Qog?6B6hhmoY*IIxyArb%Z6@zKD(n;gG9Ocm z*n`?PdoPf)adRS~%Am~Xd zcWpF&0()chMjLeA?u!3ia=VJdi-Mu-w}!ev*?yi73GBphxnp4be_I7LJ5aMZhEjJk z`Uw40|5hh$z0_c`{Z1!W<%klD>1NjxVc3_t?YCu(3%`YueJR`$>~iAVuHpuQw#I!t zi|b3R(a}RByJ9WT;X_fsn}o(WRrTzAW}FI{kTabPdd#5=ivJQqr!Y%Yh&QnU%zwX5 zJacaFQyv@Z2@ua=zz5T6bT8QUU{1GNi+Cz4P^Hs&3Jj-2seJ#N`x4Dz9ts9?CM^!D zBlAhrbEbc`f7Vwi|3}`eyBqxSD74ayi7S`(AO(6FlIRa_T3F##a`jC+(l+$#-K@DP z*n{R?vi@C8^8*IbfmZ}JLZ;Ttx9Ps9N!~)^cAufn3UeOV#`v0cACS5r^ufz;IJ74~ zC+XcJ_wg)ja6aJ13HN3&N_1g82GL}hf~hi)&`L>wgi78GSxUTJl*vjt zP37Aj>BdlECBLjj zk;u58^y?Qs$)%l}x*Mpr4)Q^Ru=)l>-{W4KnE>N!xO+$6R>&fg!&f)dp(Q++RWb)E z$_F~Pw6aX+W-l*;t2Y<@SZ)j(bQ7qlY%K3LZ1=^t>_Lby+fUb47(ED2-0tj*GKyS( zeE7>JlH9DNML~>pOk}-jce2!Mdfc+4U*oVzEV^fSGO=JbT1SCEt4PH^L{pY-BPe3! zD=OM;PEM6wtiCVS2rKyB^DF8kS;{i>vi| z+(G^MYxAtsb&2%y%qS5zTInsC0+ zx>pF01Ic@>j~{&~3+%Ov!Q5+)5fml(B0h3Nlwk z-M=}kaHh%Gu!S+yCtTrs?vHlAG~X5}vzs02|G?Zpd!teEnUU73pb&CA#9G|kVvXKO zKbtcL_u11qJ z+zMs+Rl0CM>d0Aitu?Q?V?|bY(Zx4;`4&pJWE>K_oZ<&8(kPHV*kkpu-~cJY z4QayXA#{-Bd^7tdFT0PTm93b+Jp$;gy_drK=4!wx5&=D*#FR!{yFT%il;n)1UqbKo zD96}yo%*+|e{G8*fo5rRd$Ku^tg6mnqFI5~ZDqON&dGtdVWu_?bYK2^lw0_{uS$}E zD&OYA5$iO~+OZ_HrP7q0lhWKi8+iH1(KMGClY7KHe3!!F5ARnM~s_`zr&K z4q7Sk_r@oWJ#k+YB^}tMesb02(8$Dl%Oc2dY^9buccqs8!ZO>#m_S=oH;ut$==~&@ zlgDYcwWuwn$*(d;UJ9qLx@&UT-S??}7G^Rc`dr9fP@y3g4I_dIu8zXJPwa*XM=#(; zeV8Io&WVAR2svV_CgCQFrb_62{$L1b?^*JGFjfwW&?>8UZE|gYcc!_YS_HqGtxlqB zh|i37(zI|;d!t4ZjuaM|M%cbYKyAN}v7FL|2;rkZ|9#Yl_Vri#e#U+p|18Pr9`jWXWrc*?3rlVSVb|+H#6&Ka=@vkgh zn1%NOgSM33_SXJ;f#yFptSQqbR(WmuatE0T&WfZv6rkXl?II|9>LJ*Zrg|lT37&iV3BIjn% zsf(BNi+V|sE=}p{LG#z}i5d9-`!-W_4g#;DoHH)g7kze_74UA{%8aE%h{6vJp>3s} zx35Q>`T0L~H$AIfV%EhsQ6^L&A>DTH%(Zgg0xWcQTR~H)z=K;{+`ELkuDb9n|6A6a zmK}Q4$FI(i%c~$@)4hnvcRp{qQM9MZKv-SnGN8@ROg|@~a?|a$nB^-3Cp!v{bsL-- zn;I(I-p;}P(x5jXCe%TSltC=0oxd!iT>9vzSL2U}PuA!}RpE;$Bm_3L7O!Y^N*ku= zeK~zLj8)@?8i%5(S}QWh-BVDHpv~=Juu2W5K-zgTV^NhEN)eVzCD5oMn#nuz4&?bu zKPBr;dAvw5DVYX}SlPrI#mrP=6c{vzl_1%UXG+S-A+t1&ei;3zT0U61AQ+dnHQk&`wzB~mvUf;Gz~H$L}#0VE=oY|8Y7+)E!}LS|Ib{~1q2W*!CS zY&|bai{6U~`p3CDm)bu@f+wbLpA1r*q?#P5N4$QTB~5!i1$TFRC}-_jzx?uLCr4r6 zy01xduAUy~Gr-2A*Zh(K5Cx{~j8BSx43v0%GgUxqjNpF@_}>3z3d%+7}n^1Muik)t4&^EXsNxGZfqV^9mZoDQ{Tr zE@|3NhnBwu4Ja6vwmkBpyG)Uu=QAAz1}h(VC&$evcAuTpa=K2=S)9192;X^8fbNw! z2Ks-iq==m`f7U@UpUveIesW;LdH@8-JprCdx<#|gaw~7ii@p}}L_L~WZl>m|_Sd-v z3E2a4JGFs!HX3|9RMuk%=UT`;ul2aG+;DgbO-fF#AjKLLBz^*k#a*Y8R4n~dYTcNSjXeVP)n@S<|6FoK(YCd~|R{P_hjIVF)2 zk7?e1ZA@Vocdr33e>$O0vc+0P-@u?F`hi@?(3Eg8X6K7r}q>x9VY zc_1-l^O)PFo0;o)OX2m@rG*fNiZI@(h0qg@YODSNjIe<(hx&Xl=>}I@*3eiaDc>9h zA+;V1B2^!BxVJieW~?Z(>QQpIehP%MAW@B4DW9R-mzB-Nj;e7a);@Rz5jLxt-0+%T zs%O68#5pH`C|vt{d#X6-{p24UgA%u1VJ5pZ4OgV38L9iJx#uBY>LFa5-3fmT8As+H z1A!(JFuxotdrdp3s`!oaC9@U38Y=UN@v*$qB1@NiWbFR3(Qt5CT37m$mGQtJTq3Yh zsnU-~WD+kTn|1hkbRL1mnL~tWJJS%Uqse}Ehi<*g`RyklEUb|{e|iycNue@ zjr6JE=|8K)w!RbH_t%7R*mK~pnE~M$iVuPt7EKT-zi#2>H$i3pmes;Z69{X??1Qmi zCP5MHxE^q>aopTO&(ynQNf8EfI746JOR{m6e9)jUT3L+<@mD&n^?T7J5f4J$tOBSr zHi0HvRhTl#s>6#72d0_dqSNMLY0G0@Ct)Efh@|OS#n1DbB5RnueSStGN1%~M^a>N) ziCL10--`f8y5b_{i=||0+?t(WZLOA8aUrwkP1<#c^1Z=(RfV#|w+gB+T=pN;E&i;2 zqxw-fyxRCE`STDuiDtJcuz3KtT#h>2GM~bpK5MPw)0QIjT@>3EX1iP5j@Dbq&~I*R zh*TAjcTLJk5<3J{(?kYLI3rKHwI63 z$I^eKQxPNIq1!T?{qVvJSt=-XenBH%&}`?r<%rwcv2kGuBW=D6N@wJl`);ydS{XXH zlFFAtL#@j@wb*5OHN970MB$R%zkbB>YEs% z#?`ciA@uUhzM;&bj|SYm{>m4vKbD5vKV$Pb`+xJFfB$lk-8xu_|Gn+q18J%JDSQUe z(V)z@4>rb)IM^{}drgzB|l1My?NbZ+`fZ zYyN!Gnphv<$j>D*xtR#nk7z5hau5)r8UKMXpZTGG=zLoK=03zb?5`i|BhP6srAa@u!&!R6$jE`LzSd`6QAM|IN z^ygsvn@@^#m{ntZs@CAoJ`^E$4Q%_Jqnfhq zSyx>1kHr0FH&bWbE|2c2=gVaI&=albYVwye#vqqzW~12seN2u$EQINY5thGT7&nT1 zTb+@ZjwpPm?M)Bjub&<+4n;$C)Xv5z9F`R$G#?_Nu6-bwdTu{kkcG&MEzHjKeuZ-^ zxR4=b?3*{~SIbl82dT*fSH#;d^qxyV<@kRt3cfp`S|10)k4sa1(IxDmBrcR?LQ&?f zbrCVQTgDE*3VyxPnEt!&C3@HBN6L7W`&31t)v|e->klEWdv#lD*;ZGVd6nbP>a`BN zZxXXl9@_YF7kaQf=(a7FoXvct3SGh=A}==j>rfJ zl4&R9B82V3FxDomLTlgaPSH`3cWV3ZVdFwD&irs69u59ZS(-0WSL2u;>0#XU`9{nzf~CO{5KjB4K%kg@J6gg%qW2b ztmEmuaV&yh(Y}Hji+J0$I`fS;A6T(T70g{9N^HZ=P9nCW;}_ChgWZIbVx2Nhid`%J zpMeoojrxgqL;oEM3DTRnJd3Ha+T=?5FHB}Kk?Y-FgRrLpEHsSA)RUcSj1p<2nN)pi zaTq-CBm*IHrrF%rb#^r=qymb56^-Hu?^``9GWxk$D@G>gnle^N zV?`d($Cs$x?xXSvw$fDGLAhJf>wP+#W3dGDaG`XXWi|(Ec@o6et;jl7ll&MPIhXuQ zR-efe9S1orOrYOO1x7USZ1Olw37{{Hk~nkD2x9z zvF$@oW2SM55>5IkE;m9q@I-N_17y_vvkZZmE^=f4We{osy^gBtg;Z@8ZT}`*dt~kd zm2>5|iV8PY_gdVz1qmWFPi*?)_f6r3#+z^ZNs=X5)Jc7Q+=mq06uI}tID=evlo|0U zzBj4e%Jcs#t~Gb1J>J1l6{27+dRoJTXT4j0H&p)^lasJ;b=4N}cDXr-%&)7mRr^K5 zn2D03`@H>?54>nUK8nG<`87h&_Uosy=Vy6Mhx2RvPD@ym9!9IoD-_uM z{&#&gn&~!jh;v8~i&yCLa^b24)(i{DP0~tk&NvF3!joi*?1~4J=$kplk)i&^?^%KN z<}5pXp=5mm-_|dt*XoKHrk6Y`J-=#(bhf$asO-u~`fI4xal76ovYCa+f;1%CxH#em z@eCv>cN`GEQ>zny6alm7ZKVY}Gr@!pQ8o7?BAPgDqyiRbSG(vhrw~b*28j_-EnE&U z_IMOU1m1uBMqsaDUL@UiNN~>n5nqhVEceEQAc@i*5j`5wasB^;;buxeLerM}F01&O zef6wx*60IO{f$eHo{&qAa-R+`+Ahf{-&aUS8bz{e*T&NEdl3bl`7iM}8>XT~Q^#It zC$lS0X{W!X-7n@yCku&5HEx+6oH;d3&?8Fw_FNTZi`h!0HjlaAx1w?=RG>wwr#6^P znfKX#mDWnQ)fUG4+~`u~RH^WxU^m4SMa{iXP-|k>tI`1J#nLiS)vim)KApd255l4B z$9Y=Z&Qb9H!pBBOrRul7MkuTBB^%Lj3;1rBHp>d%3O2{NTU#L)s662g3{{~m0r!_m z>q}L1HD&3mj=|^`HZ%c|!$hAr%W?X(Yq3RUt$ika_(8m(M^|qvoL>fhX=)kRdABty z`ZUaBSYqc+DH9yiVe7h6eR>1Dnkd(##2ei*Y1!uv`}8J00>qQ19e_(R4;fpBV!BPgA0f+xdn_Gn3Pp8Qc+(msTHO|% zJUaEZ-NHZX2V8vxfYgWj-n8GT2t5}H)scIjnY!c#`%E(xv!!L#$L!vf4;5w4PT;d$ z1PAePp<-R2w40XA8xZ~YnlAaV?%^Zk8ygsU1_?;B)MJ>!SB4oXykyWyQMW@^W5`xe zvQLHaEq=SX^lzL-EOH~9-{iW>LrQyP$|GxVpeQnsNS;q4bFuDS-Dz!6E1eHEqOQC_ zd@bgXGIK^l=UuZiz#x zZaJ(&W}Xahi?hl4i@dCzd-sNq`Y5WXRkm-Pty%;&fe?vik5{b7)y~#FwQufVfB)B~ zflE7UZFclAF8Wqs<;_^SGc$m1{A;@%J-F7o27Tz2%xPY@XQxe#*6knjS=7r=l(r3X z%w@MEZlu@KecH$wCe6wIrW*}wso@}dpwtJ`$agm zvn|W-Zg*?C{js+E`q?$j?sppXAWUSiAoUC`0c^wP#MOYlO}pJPwKTqfS#GmOfi+<> z@EFfZ2-n9?S!m|^76t$v#%CLDoXGK{~hoI6GRpx(6GnL(&4TFzNrGp&^fMWX zRTY?+dp!FSX&B%zhAfFd5Gn){gtLcSKr<5l`855my9=GEaHv2*?{rkdaE{t#QZ)_1 zU?;9Ed_!R_$Z)qDev-*!6C7|{povNx!QGv3g|WN(4}eQqxSsq*`?n0w0tjfCM*zrw z=4E7(mqPMsQ8_j9I&p9ffT6kDLLzDia6-ORk8#`jO>C91+8ceGM=7xP55PTHKRf1c ze8%V=iZb*m6?)e(BNMMql6l+!4}JUDI`u2Y+_sbm*CD`ScAlEPbW!AoCHil6#a4W7QZZ#08( zhW<#3Ta~+K%u-r@tmvG7`zp4RHJjI1)EyTWu1)YH)1k#^bsz@V>{v*~AhY<>2gj+{5K1W-N**e_0@{rLSnD&9dzA+Ff0A{c3%- zOqCjYDj^aCYG>75mdfKfg?%4sH{DYz^$?WOW@a*A%KMci7Xd(JZ#>j}#%I0ew?W>KV8_b+hCg#OeYyD){ zt>=6iTn(xjW%TdGI1W-Q$*i?0sAj?`yuJgB8b`DLurB{We;CAWBL$vrM~++ldda_J zGy?8Y+asV0;qJvfFat5q0q6I!{*g;EH{eYV9g-8fQ+F+SXV5RailNGZM)oPd6L65* zw%z<;_o}+ukrSI^zls&yV^qB=bB?z25RoPmUtzok_=SZurZu60z&t(nJYIjcxVdKT z>-Ty)+Kh++)?+WOkn8Jb5M$$LDBH$$G4&2XG*~t?TG}g7wozX z0A)UwQ%;179GGl%Amd(e?GPd(G%-rGQ{KYm?l==+*UtoqEDk`xNpHU;6;lC!q&ijg z8c!IMVRLzP{>)VK_PSM>efHjF!ML*YG4Eu_aXsNu_q+v5Ut0GK+OT0e&!{QRXLi^i zKyX7s7fnkwOX~%gJAxB|QlfLsB3sb-Nd7a>|Z@c{U;6pPWXeZMi=lx8{ zy5x@0SKV59MqF)U7Ti5Ig76nDK3>&dyGDN=m6PXm-DNhq{YrT*C$gu$1@PUH8769? zyrH+~Zx|E2xDLrlHZWI$rf21f)0e+H{yh)15qvCp9z#tyx(n#^d42XnRF&s5Czq6d%NOM%oUhf`|6wp8``%m? z3Rwodf@6nZ#AA#FlZj7u>!A||@c^wP+2@x4Em7?H)?*mr3Y&j!Imlv^45N|(lTjr+ z-DW@SMuImmTo4&&Qp4C__#j~3e8cAGpwWCnzxJvb7JISBZgK~IZ*LuL-rGYf4_Wg zaPrPdEp~n@*1o&{jFeq=+~S{35Z>!DzKr?O(v@uCbAx@mrE5%P?9-s~%yqo4wLIVe znPjZ`<(A`RcKC?%caxC=)9z_n#?v_G_h=Ae(7xwJoZMaGpva$$j*if=6#!VksDdZr`peE>U|~m=p9^UC zco@nC?ytAm%iD!{Fd`?y!SHr44+~{w`1}jv&JwS%KRGPtjz3*F>Z z{ks9MhQ@N&a&C<7t>Gfy7+Zd(Cu-x~M*bqf^>m%_p<5b!#KDB_3wE}pqLSLO(3m>50tXc*ni zQPUt(1mcjym9bV}`8sNi1rft3yQ6qEsN6up^0-+FfOp#w{t^`d{;HdaHn0Y*tBa1^ zQ$e+YO4fr8G6NECX_s6%{j?Srk|<$LMGNCVCvlfLI*G3y3OunCuJ@r@_o#yKdjvy% zz=SVW-Fxh?nXJgDl$`ZOqp&toD|4}WP~|BdLO51sig4s#qhRF?hF7BZeGieE_>6@N zMh7$8LC}g$lPh~0ej8!%_X=T_m>ob$cZ?5i7Ov{*59>%na#zm`IuA-HV|mxZ$3l3v z5U1n4POV)tPeLZX$XkP}l=T`P!H85bFzH!IaUO)Q96Ml%$s&`?Yn-|W764dKi#DJ) zD(?YShxV~ca#qr$q|kUU4DTX zLl<}$-8q)s-Bd&3 z_agRLg;3)^Pe~zy`lEyj^56D%KiMo!Va+q`>Y`;t-bs<#(>f`gu_7B#+WWV-u3IGv zuKQ??ew%1j*RC0!OaBuM-zoFo1By(4yj_c`09ZpP)y8cgu1^kw9l&t8JH?TFOGN{c zia?KYZyA)CKjawb3DI2;m6gG@|opW$q!l!6aN?$%>ALvZdE?`rw& zN=|`=W?n8(Rf9dkEgjcxGQ;m{CVXmKErT+`Vd%2~#*eVvQ$j9@ZNoeLJ!32lNt5VN z+c3x4<$i!9k_RKpzJzlddi1=?N7KXQKs5$qVq@&!Nri)7(2Cg8IG8%Vx<$~T-wSYo zIhA3Ub^5G$ab8Uh)FyIDiMbNy6Q9TJOGsmcG0oABk%EOfy#rOPIpX-7N1TVz^vpRz zoLNt2@FOMNFi^z=mJy{81TF5Z@y-5RQpN5=%$P32+(XS|bnb>)^87CnnmFrn-VLV% zkzptTXCGu*WVilKHp_eQV*DC?p)XlPzi8W@FKyRv_jg>EUq>s?XF5hF4{qzP{Bs($ zg-wm3XtSqBHd4eOvTAsvH)jB!ZT0o=LD(7O>onC$D<503O~yy9Tkv#;!|ZvQAUDcBifl~ zg_g}>>Kk1b9?i1O1By~z@)hV-tE}C)$LvwA!g&X{R(c4z3oJ`gRU<)$5gdXyDh(pi zq*bzv99gQu5g@tFs?&-!3Hol5Old@84s_K|L_y?JkmVAfm<5`BZu(p@WABDwLBcx3 zbs@Kt1Ad<9uN^{vuB(R~h>LEFa_14Fx6&VEfE@3bL&W|Pc6LB|oczwyMV#|QcJMVA z4<*sMvCYs7T?A-3$$&gDb!?ar7J)u)Ac)ZhH_N#vT4Xz;IN+MHin#~5PJE>#)F)nL z5?PN3>;%!#DBlSUSuL@GHt=OMi)#xX7PVOkuM;ojbWVkzjEeTVC{qIN>w_E_@+LYIbwXp@97K;q>Zn3+jI-_qBIG8{= z^G0#%-d6lY9=R+0*m@163B!YuNL#3h2}(&J$Epu<7H&xqM$q z5ru($@f^@ zSsZ>5V%Hd`+(%5qVIX7zUxud8TN*D;G7ff*4rkm~mk7C5E#^K2eu!q`*|RYLrz-#} zx;|SD`~mEpXVI_)1py}TO{jnM2av zK1T-6dw(OPN0vk&4$G;Iu*$^|2)~eM(cY=P>19?SF;tXC{TBuCg4v^EWr_Enk!xV5 z2GxM7SPG)OV86Qt{2V2J#Pz--_d0^u!!Y2KE-(zq!PCk3VZwmt{pIN)^+DRzib-gI zz#z&4(S*kh~iOEKzJy`ePDlvgDPS>^Rv{(j#^G5JXsUg#xR|dDJ8j(O)yAUM)p@kH z8_GRsNDA53G^#2e+eLQ8f%>2|&MW_qZu z@t@Hpig5>tuNJw(>6(H)B|Ps2`Ci`zI)CMqZV%Z-?s?Y}DXJC|0rS{Ll_lLUrxZDl zt2DMRNf(gvC&oYHMBu33DYy5I zu2&nO4D-@8ma7-cFbq0>eid-?9)U>l)W`2lzvSHF8deaXxm6R`7#JRN-wU**!}#Vo-3=@ybMpL zy`n=2>DI0G%u|2z(Tyw`b;sKoEn^lHKCK!hClg1!ZH;inB{k)`iaN7B`Glf5%aZMjXgGv#_3CNgUC!EMQ&SdEqzM}e8w3lg$ zfR1PP(=50}vR5;yR(CQ{=h(l>j((--x269S;k%;ZGKXCWk1|UHYi%T2mh?XY1(BhG z6pDoF_w=!$1nm|^6(LpU28O4EMepRf+c*EZ1YDea`vju%JT#o4@C*fazibt6>8(-S zgMq9>YY22^EyYDGHhj-l4ZVhiI_z+mx+28i@|eRoVUK4bN68mjJrZ}4-gGY|?mEHo z3}$d6&tF*?QB)MhXOQ;QHoLQpzu`z5om%yMsYn{-Eh^E1-*J!Gq@(0<`l$wt?!p}} z`aYwK|Ir&c{Ig(Mu+oaY==wt0V-dA|4e)s z#T`_?W!a!7gb&^l56BbJ*s|QiupAuWZE)~L8*E=%$1U_hs@VP?C^$HvE{@VrY^=*8 zYAk>cE*!H>0Tb2CI+alw7C@L}c8G)dyIH%o=9;kFlNx`-M!?nPMabadMhFN*>?oKX z8ZcbmH1BF*3Jb3MZXj8V-k1jDke&7G@?G-N*3qq{@lexlK$!pD0gahyE!v=r3x(YkL{f*zfH7s2}0Z{qLNVRTz&q4V44e(RSP=N7-OcIJ*vwWy)(TSg+@~?A)M3!ci$R}DUbcH`}Ff#YPj0rR|Z(rnDmmv za)oQ~zWfC;3Tb&GSQn_pY@r5lAR&Z=&&|GMFu?hXvOLunYry@{q%*%Q483^c9ZKWI zAjv%|y!g!4+;t3n&*_gfXpgh8N|qnVt)1EJJuy-cyIC}1nWCquA4(*%r9?M@Hr<#z z@p((ij9`8N!ye}TCjm)Hc(;v-Oxe=}u3Ep^tAUR@|HRY$9Xq82h>fBY+zYuN#w156k@67X#)ioOt$l z3%ifHPEn06yF^{myn9P0tlM&v9(TVUm;{fu$hc=4!O z^cvuW3*b>V|Bx`$)bW}C3>MfwulRNA+1E}-1U5dSpW=Rl`fS+A6!;HgPybv!Hq7FE zK;DNElksim39dK2JJU_Cy|Ss|`<-k$+aN|iC5Hd?$wqmXBr*Fz^H<$tl)fK9 z2Zv+Bxb=h}fGNUo4+EDH>du8b%H+MU&d&-BwZ}B3eB#1jYHG-SD;B`#+Jlh&ep#~H z2|V+m<9@3Jdp2);w1QFpB2;YQ&qBYR68UVMtaF1-ew){@?HOQ+-q;?O^FQGD6=F_m z-~P~eW?7(LaujdatWwol(|viR(~mH(Wbl#6=&L2A>w{os(XwCdVWg^}vt!vU!xflI zE2_XlJm!64gvZByt^V)k?BDe?3*v-+u@;{8zq*z1jJCIQn}n?#0_cRT#O2}FY@U)8 zcIQ{3C4BoQ&Z(+Z_|?M<12T*IJ#VIdeaHDn8k^2d&z*vey-sDQIcvsh!_ooyceeDY zt=cK83(-Qg1qSjA7ir&YJzL`?SmYGI+DG5tY)+Ia_enlS3D`;GMtNKL^dI>~o<(2wLNZgAvgE<<(F5%hz;pQ;` za*9rtb&@I8X>qrZ!{~qC3l-W6A54t8_Nm=_%@927Q1spuIGB)=oOnGWc&@|o`aNGc zH=AcS>H9ynJqJ!q>-4aZ@n4*TAunnF7T&xJ#9Pgc)||QaxTni(95(TK7LuCJR`0*n z^<9pZS@^IcU=g=e#cGMVd|CqN6psEKl)f;&e(ikj4E{5{IY!Du!LaM}&xeKf9_8U? zc+1%)^BNK5nLAk9ZrWLH0t2gYu|5Uaa;vvM<{W0%)uYEGORjxrR)Iatk=eYbKvjk{ zj?8%Max3C2>cUe=$BrS9v&afd{l&{oO;Z7y4zN@}blc=rxpfN@qi!U7)jP(DN^uKJ z*R?Yza!=cvd#Fvj|M{jhVIEdG*JYrcRmnLr}Epsv1!48~yz z50<4=K)WXaIDAV4fJ|I6_`aJFVVH9*6>Pd0?z_$4!+ASKAvw(47C`0| z&LWM21ZP_1ZyJkkVN-cg>}U9{2Y?g5$iPz(Wf+s9XOz-8D~Xyuk)T2+$B7J{48Bfk z`wx&`?iQ@(YAX2eX_u&+2OqD$@48?B5`-lG(jAW&!9?mBKzXmnFiI>@u|0!md^1}V z51;AY(-N&287ez`>s=T}XO;*{EeHw^0LbTHfA!Miz6bwJmX*0e0xQPFX47=5qns(^ z{ff!1w5h)iEhb8|K}X&#|I;$csCx(Ju#)%XkGF{4%sh`mXeGm#oG0cJl$a zYgWQ)N%AzM=|RIp=F!G}F3yI>Hv{{huv6jtldcEeKbj*A<633c09NA%+v^Nx3Cih| zQvlawxT)H={R=f?UcC7<<9zGI5&~7X9#j-^6a6X_|B#qw3uz3AYwEdL_rSZUCUSAo zgQPNITJb_lfR!;3{+)KJ%trA}nXZ(q-K;5ba`_kgOfz)@ISZW+Mu{{3 zh_M`1veSs1!{2`^6#cARp)U0DMU=Za{qU6UHO~l-nK8E*vz7l4wvDEHv?qeJg?)*V zzEQ3iA3xy>rQHGRTcjj+O|9hy$AWvHsWCpBi%)Zk@D6mvJzhQCw#^$(@i|4{B**nQ z9+Hc_k4d-(OK1m>S=S5|lz`}&bs{C0b(UD{ffqoO9xBis_a*Z@c{-HA z@lytpYIAq+0NA+|Y`0|J@%HRZ)AI?pEfb1ZK*aSpp4>L+y${K62__fOXC7~qZOJF^ z>}gpcKYp(AF}OZQvOMgc#JpWYUpATx<}Lp=6qm0QkmkxX~La5cL}q8S^mwMCyW*YD{i!cws~68u4r}8~|X=wmD*v z4`Sa9T(0;5;~5!j(NG5*(<$BGp&+6{0Z%~cqaY_`Lpz+IDr6tNVB%;}k%JAsZ9Dr` ze)Abr;@m~LG)hVm@l_&;?-Mph6Py$|pv5oF>g6<&8Tt8FPZc+u^Psi~6)3{any7Ib zKrc33SoOoa`lL>a4!(}S0i>QnHarW-t5&bMSdC!1$JSBKjN4QqhD(mIwnlXPqyu6P0Et;Bmm9BR7!A3es7b&+Jo`bxSx4lQk;s42WGZ0+lj#0nT}-=2mkU9;9P)DEHx9$*3ykI&QipG%0-1a8YNNJpL-^~D**RYkZb9)?X1Gc-BF zO`bi=tmoxfgjKqzFi@8~@6I^w#vf@|fo!Hx1s8)&RRkb^^|{fBEWmmZG$FW1Sj3^` zQW*e91r8)-Bzqh)cW3XTOKj?Lv*~XG!91Q@5L{3BZHZ(x4-Clmw;)jWGoL1_MuLU2 zQyhobX^+kn@WkHO{`NRJjnjH#s%DzSxwR8GBdLL_$NNdr3-S$$_TV|0PqDd8XuwX+0~IJFJqTl1K~bQHuPDHTDctuK zgOKt;%sFaQ@D`c7$*9NUdG;|j*We3u4ph3H3}Gw;>1pxNRR2L5EJ^URb-8qa{5;+G z3#ne$?0@=VVX+?FG%az^%HKOO&5ceQ?nz)*FdF(>Gi5PEb<#oaxnM-7@D{z93=iu0 zDl+9wfD=O#hy173l;hKMZgazNoGt8YmL8!a&Mk(+#^<0Se>1n=9pc`N%N)iW0GJn3 z%8ae9eXH&>oU$kW-WF}6yGn?RU}=ZsmJGECFYD4g)9g|;acskt;$N0=X^X;Sy3Obe z=k87E4|dHDs)-F;-(cEwvU%@ZEUn|BVBErQWg519S$Eh;kZ6r)j>>Wd-ax3-t!nNx z(fcg*RaJW!xiC$+h!^EmAFEbNEucr%uX|!}ufULkO#+ zh|zJ}r2?o3VRrI(>ZmZ1J>^iWd@11+m>AY8yw6zb1ey2Wu2ZVN1@g?{#9{xF66l|x z$RodbCm_#e9(@JEkg%?6+0Vl5j<50vaRQ=lZk+=n)^9B`;(FKnv{%(du4c?0v?mCA zLaN|nes1dgX7t-=#@&Gp`Dl1wM8Y!Tp4!@}+40gWx4SYsvEUWU@ zm%NRmBkv0G@F7Mn18OL8 zn9CxJ8@&Iu*Ptx@pwCJ_wcLI`&YLW=Cio1BZ$XR(^#H$2@z=M)vZ*4F-$HMS0X!XI zVJ@i1w@~DBy&gnks5tv(18V%zXvKT&hk8o}I00#TTNnHZ9-3Q+L$3UK(PgfRWn+%g zwch?8{+*X>fR1OQ9~+zX;K>lWWm_BnSH}b!Ep6Ff+z_WAzJJFAzQa@av{x$soW~f# z1MT0B7KU&)X?V((6l>AvcBD$`z8|(A$@yCtqh*WrW^8u=nyNQmT*n!Vd~Q2XPLnj= z{#9|U>j~PYe=g2<-+b|z_n^*rO!+NGlt)WHObr+hZt^$Jgu~;*P&G+H^LDYQ&yZjc zg*@hxuH^UAH4L5K0pci#wAmHj5vVz26dDmkkhyE1h~+~LdjSsy<=+UIUq0LLV(DQV z{=hlL=#(ClivI%b(+r8pbzVYy0M}0FiMZ&=44wu~U*t9aI{}EWPk4bkjw(~wYwFkL zV*KWqT(Y9Rp@VBO7WNpq^eCCfA)mNaq`5iT4$6xt&jfke%+Ngm6UtU^@^=J*Z1K)D zh_^=%#2LgM!4Elxis-$XNkdfsV=VC+WjfPtxHh=L^!;xdC3ImuChKD$=y7|~n)Crw z#ZKYdHo$!FJwYA^3j{K084Gb)x3Fr<(Ss5qT|}shmytfuW!_4n3z)2XB+Qyh==GUF zXMzG8Bls!oIqM=gB`?rZm^}_}2i|gS6Jy3DR5MwlEkh2#_Bco(Yj{I?+ctt5N( zjv0ve5wXz`T3s?nd5IjBRqiY0y>FG~KIQ`mzWF0AODNOGH2AIw`;hfN3v`rH2)@*c z;hyl%=urKgZPAVuiAn5jHqh@baNlKu1JR)87Mp8y;{f4{Ju@fF1u;2JT zuh(<`0Xyg3u3h(iopV0#4~NDzDD045sYObmmh`)fcmv1{b`o;y#)WYO(0oG8_w3m$ zfgo88)V<95cD9gapyN{Tzvz_DUxsZl8{N37`!h|~ z>S0S2jp|`_1yquR&0m~0ACCuQAG>et!BsOvB^nT~SbN@0id zICXf2VV+ZM%6RiAU|^1!F+6`iz_*_gDq{LBSV6`gKRLoc1_#e2{GIH33$T!f3`2RE z7}J{s0-G%FE{^EhkO{xQMSTexvv|R$u{zY=L*Q;W+zP0pIfF zpWsjTMKZB!z;F1Q2GE?I9csGrJU9j0xFBnGj*!{suIZt9zI#&c7}1zF+X*Lb)mS)q z+h3(kR}HieHs~;_ndj1-{rfPt@V69E6``KYJxgnRx31Thbq~438`^O4t z@2Fc7^h8ZaBsUk7j0)-YDDt@t*x?)e+RSkYx`c8uSOKMydltDoPVWd?HRb-qyo>6v+OdvDI8;M zM_!w#V9e2ll3f-=-$ZtYH@R?OUAm~*2rB~m!bE>y1Vg#dhp@wk@hM@~8pCg{!BTu* zvSUNk2|qTxEp#j=p^1`P&-i0d<`lz!wVXbuEF|!iT0XmPJ8h~=fr2w>F0}Ifum1Eu z6t5o7wYvD0PqBgK4perCE);vY%D89E6=uJr#PUVoMdPv-RnJ?AL&~ew1Aw_Y)z;J^ zU%@xj7J{Giho13oR&(fT+s8U)rIJinC29Jv&%3sj3THH9KT$ps>(OFYcN%9kb0WB5 znfm0))w1+ac(0YqInrl%DUOE<+}oXnpx+?MqHxU=B?ywc`y0y9%kUgSnUn;0ghgfc ziYZ56fkmAL{#|~6=>;jeZ~8SXpZG$~nc2)$hxU@-^sfkMpc0A4Aal@O3aK?d|I=~c zg@*gI^!E3!KZHLH4ZI$n;GV=QCO9o(kSZR`z?~94WssNptIxS5*KRTHr}L#xb9-BS z#}e!{uLv6UA((RVAqc+6J0suey8dBQ9c3WDkfy=ddVHoQa@K0K`KZkQ`k6e97rt>? z(*&0f0oIyjwk1r=$grc24)Ky1>Zu0li0BsJtsT-Ytnq zz6cGRQbaIEe8kMKcI`~Shee;PG8^s!4JZ~_Cz+GDfw1JOB^6XLY6@qup9YWA9=<>U z4S_Hey+?Px*G?r;eMrYHo%O>EoQ6K3hY(C718%ax%(ysaHCxMsF}~DX7Ic;j@4rg# z@Y3<7QmeB53iteot4ZgYp0{jFUO@pW{g5lYEKUxcp5S>j(rJ8uOMRZ-1G7`V-->T< zY_2M5=dq*}#efXHvMuk!6RS(v+TjDKV;J?t*BowiZRU57J_$nth%a zTJrZD?5bnv3$`>L!B%^LM(uw%`ug=t-+h`wV2L9tezq#96D|#G)%PQi=}{OkvS3an zmm&(oR+g&U$lW1L;$HhsxaDC1ZlQZz{*%I$m6dIoy*hx3{DtLaz|LuI06q~Fi%gX; zCyVL29NU)PD=qcD7%{o6$YK@INyfl5mxOvkxvjSbX(1zHVsLD(bR&hMDG|& z*5rmPRC^9tSOx8y6AoIq(6hBFVhW_LN>3EL9sUH`Y`xlgF4hBAd3MvFwn!DPtz$e7 zcg}X*;#AD?=8BBFW=Mn&8bn@3fJ>@RR=bl)XxMBYB?aUmm;03}Bbk*6Uo5r^QinOj zH5Lge%k5BU}}!k&@m64c|T7h?n6 zMuop+a>DQ)_lpy-&bcpZ6fke|YW6zZTQGZNfQ)~EGR+uOy?;CR#OTAONaH0{ZD=a+ zZJ@5G^MOPSP5-e>i$tF9`CQyrB@EY~uwP1P5Vr|X#;{Jhvbkeep}lOrKm`=9vt;hw zXIjdSmiC~k(Wkkd)7-SxeD%9DAIu*Or|RPzm2Y3%z8MLs9q}Lo)N|MEim!i*NJkG} z;<=rwG~oq@k76-1=Z_BK_t6l}UGA>mhTB zw10i_S|A!u2_hm4xpBXqKAC|U`+Xf)+a~Px#j?1>8)H&MZ%zX5QcJ%dvqJdt@v<}v zb&20d^?m;30&-?v^Bq2l8hJ@}@|H|7yBCi|wlu}*>wIEOed9-nM4~&t#>+Q3wy9sl zX>Wj2Ueor<*r#qHZ z(~>=vp2VfSH^B6oq3EROU)r8q>5wKKa^uy(W9g+WfwDsma^z)r7QBK_HEfIYLee7u zXUkz^ld2Xi?7JVyc{&m06rqvxiA~yR#IlWg1l4#NM$-Ip)S(hfwqx3C?vTisDMn!5c@n~iTc8= zh-ri|ICwFofi~b3#vGW9obgUYpg~(Tpc>6F3-w#2YOQCl2-wW{y0o_7%PO@DA7!D|7$;S>5eZpy1kzSg;)1TkXA#)V15#&PCFN zhZ5B;T1C<+-I_DkBx_U>+rS+|n9#Fp2CE(jx9lYtGrl=t!&KY;V&fi(1b@*HzuXsA3Lc` z^-nlYbd;;v4RiE_ulcG)3yxCyw-hBb=Pt#*_(JDcSQqp!-q0#T(W!c&O%D)rU7B+bItowR zwo@aY#jeUD@5ZJ72DpX(Gdn!Q0HSp&2u~w)P4;Zf2g(F%f&7g1w;;g36F+X*@^5Fp z>gbD~+dlmvc~L}CgauqHGH!tWhky?|SrqFFtyPNCe#X~F{zlgC;Z%w`knj7}1MfY- zV9nfo;NG^PYkx<`?^O!K9~jHd&%?2*2Xl_y*^R^JwoMZ@1Vi>x=2p@2!6Ol8{VR#A z`n8Qkkf%?he443>b9f2g^1GXEZDGzWHo?+Y?Rr)=uKCoR_EIi&jghOF^QToLv|eLy z7%X|pq4?;@0c@}=;V@a**joE^!RVURvOh+?G}hFr+8(GjDKSK3i2ehe0a|+iClBRa zin#|;{;nyBUphN1!C7u92#v?jyy}2R?mqY#!&D(Fd)^5*1QmbHV z6EEZ*JzBpRwe>K~UM&IC2`%1o>8~d_jW97|B{jnz{H}mjOwg|EDdvtm{JiI|gnoVv z9jUh5i(>X0id`TYW1WuvF84u>K5rOUshqCpkDYPdN?scdK3KlU#dmZLnhXmwpS*mUTB4{*QKcx}mds z)NAP_k=$XmI0Z=zmtI_t^UW${)4%Y;p6;taJpp&dZNVeOaFG_(@0kgEA0rqW za>lP#AgxT}W7GD9M%t5B)|Y#j9A*1>{k?1#eKetb742+fnmqViNVmG`XOd?C4XJV= zfS#mbmcfO7ph8_OU)o*@>!AMw#Y;qH7hOX?^x+oFHD0v|f{3pzfFBdVZXcv2u##}X z&49G48u>BiOYgY{I=?SWun+K#U4cw=fM=VEq}lsg`Xq$J`z#t4i9YzWTy@AFoT|#( z?gYRdxZ0BVAoNtY$%Bx_d-DhJ!QX3ui>1>&+E@ZMEE5wY!9*ScC##NQ#vl<^40{j@ z<<`i@L~-U5Dt^mDivV`fTSzHPToI%2F%KXioVWuKL*~#nsOGpy^qBhX9_^(8TF(OC z3YNkXuxZG7hZn;b?>kfEFRdJ5V6M}B@~z;})+0r7gwhH%;AD9#Ds65cG0y1TRgEY7 zB+jpplIbiaB<#3|WQ#B$yoTR%L-zX~t68w;Azy#gt^vaZeI9 z`M`L$`;4?v#&{{?fx1kb+n|uxQWr-O3ury-<5ax&5_rE2_U$xaLTUM?h@~|e8IOAe z{jiHkq<6;9)~*%;H>qi90%Y}G4N0jkCAl|e%IKY%^%ahNcIShqhbQw49nxoh!B6|@ z^ggfB#_nZR!<&DwZEuUd#T}_?*8FB(strxQ(K>{h58-ybB!U+1@SgSCES2Ez6Xiw^ zKeMAAvCT2tL`qY7GHB2W>dvIg2&ut7> zL#$VS^^L}W1Q~#ulBqZBJlZrk5+H$ACTztk8LCG@fO~kd(mM_QG_T!F@tB@r^k8D= z)OO*WqQy~vD119k84(i^ZMuLkVAPSkJK`H1s?yoi7LEa7!TZyWi*hUqOrpRH>f?Q+3xB>Gs$AV zziE*sSWsbsi%%h8iq?C&V_J{n{%HbT`%TS?*o?0Rlnv~PpA~rO^ktffyS}~Mmpl-$ z8R6=N3nY?@iGGj0jMRNs`s(89jO7m-LhjLDxZ;kVuIy40ADgH>%lCr;d+7Dt=OmWt z?}e{8=J?vhvN;^pKG59((J`pD=Hvm{K&>jqF?Bg5C zNbi8q@ei6v#A&X;Ws5%7jovB{&lIpeI;%ualZ1GLk7Qw;W^fWdvZ# z^Eow7V9nc2v^=94wuLT5>I1XH{$OFzuoKeQ1DOhg*9HctPjiH(j20F0{!^-)7UrFX z1Pn|YHwlw!$;{w7m`c3(2uRXkeME-YaCOo{as{ERQNvz|p&@&Gkx?G)9R1*bjW8p1 zr4MXq*fS^CE@K0-fg>M1^{GzSQ2p+!D{r@dmv?aW@ojZNQ_ozBies?a{W~_Tow>zy z==R91P}pz3>U8TbR1KE0`ntt>jipPw!`tq}7g|z0pF1-qPEu5veJrpP3(rcODb5YK zrqd_M=vBJYYcrkAuPj(CMi2CL-Yr?9&NJKTJ?9S!Z5-{&M4D75ESNOa>Oi&_Zst_0x1(quK$4r@=tu=1&dn& zf#Cq-qS9Ashvy@Vk}0y`Bmu?a-u({_ABf0D@ILwVDC&I9fL}_Pp z{07A;mR+?W(PzURnEtYj)-M=n#E8d`wC&?==hbc@3bLA9B%?NE*q3CtW#kuW!QE)C zl4VE5;Ar#R4OyfX}? zqU)Z>T>uLfvp@!S@X8z%shzG~oM?q_GqV8Sr0{vSmb{q>%}yyIM#K>+(D=j<`9pec z9zCeQB$bt(77cvLL(Eh|gFyxSM_IEtQ`ha&8wJl$}mcTxFl-pEu0q0Z63@Y3^ zRhi$YeTkP-+VXGEN-@F89ZskOl?_gCc(~ys^0@IRl=s5vOL=#%pMF`9xCk6xYkjQf z%j?_VCt;r-FxJHhe1NdHwr#x?Rys#aH0s^7F2aMp=iZHvmy9l<_c33l9weUm(%eFg zZIl*ZU}R-4sb#KRfCi@9TJMO2VJ&fz2vgrTDS(qVmBP8AE%I(*~!1)zUm) zYmalGm$md@<;z9eYA)Gadd79S!d zwZz*XQl5HZMqhek{nk4fFG`y>RR}Z8vLgnnowD~)%MbTv!=l#LH(UCVqrK6XUPD#H z(!096Dj^Rt{yOuv{Abu^=CQ*wn$r>J$CM!^={-+xkTxc3pf*$=o!hSL8`(6E4tmYN z`oth%x%~G5joMDdaUQ>HQ_eiX`Vv(XI)0RE@DRo%>Nmus=tm72s(A+u4SaYEK2^q0 z)<~^nJr){M5#*f6_*HMMgBPgt_t%%_yYCy@3`|qie7tUpk^OJ3Kq@o>wmrV3dt+2Q zY*cM%3R0xjfyOhRZDY1F)o!A||2GA-_lz{J2_^rYr6>1h{k1+J%bdIUfWNX3#Q9nM z?5y{@-&f$a*cYGSK1IA43GF+FhKdW+uX$U)4B91CkVu~4*UE-e8#(9)n) zf2a4{pa5-R*gLQIjjQgc{KxZvN3|BRmvR~bi=vl@7A@QHx*s>XKmPWknP7-CE!Rz_ z8BKH!{DmTIZPk1!VE;E@-ce``>pyFRhpnkzW_B%8wyQYVi#ipG0c2aD+?Lk%v~L&e z30B#L48>Kf#j39H*f;&{czye&rf~3uh}-O~&?2b`LLuNo>DQm>);n^?xX6~GwTiRQ zdlAWx1+VjHXw{r#IZ2wIBq}?vpc-OOYpYqox=99?o$SAUrS0z@@9)#ZS_u>-a!!2v zUh^hKU_Nq(ITQn{i4ljhs}8^tnwK^%aSj+Hsns`V+|9+ zUQ{G_7Aue$(B|h0`}Nq`Vx@zUhGtllEYSz{8-W7BJlA4GAPU=_ug9V&GE5=DOduU< zjKrHxQarlRL;1N3J7pj zs~0I+j;*!$h@7rFF4I5J+e;)=_tb9vJH4oU(`2P@?%82k-DNLcmUoW%9PR63nDH4a zks3E^k%|gZ>JUv6C(xs+qvI^oJLmZ4-JUPuqtQh%ho2re@B`qphXiCWItQh#Rc$m{ zVkiOSe4d;(9UrtdZ_DhJ7h^@wIq&2$O3c5Dbs|Y+j1{7){4}ACev=q0DY+$EqY@|H zeaDO5?L>KC`bNlzK|7h^T<)Ca9GUc5*5#ZdfVS1_752~C8#H6%c>*Es-KOP&)AR!()07N#rg6%2Ytv$gLA#gt|4nP z@`Xr~<8{5R#dwICUT(S=BwU?kQe7n`rc25JkC_vtaL<0xmWecsI5cnTotVTIjU$S) zC+euyQ#Z+PaLd$vYEmSeyihO&C31X6KQ~wKE0UoKrQrS92-#cSyi+`jW*j zux8m5dqN0TLWNlSc;tJlbkd$S<#s!Tt9rZ?r8g}GUBogRr3?>Q03wrlWt&vDxE$x? z&5yEm15ydM+QBlPJB4-NIZxnDJ^yG$+Ry(ui@(nw&(!tUru<9KVZ7w~C4U*3!0?@j z#C@ea3qpgv;u%v>UGfp4cW?p0A%E)WBnnyJD$>mW&Oe1cif#C$gxu~YdkB{=l*-SO zYQ84eP5xwloAN9>h@NzYEGPJJr9-=V**1koffOZ@C8o{n>FkNOgJT%ojidPd2i6z4 z{Pio6qQ)enL}J|_4oN6H+sk1gX>7_iK6{H2-u8PFQU)iXz6z&UBBS1owDs(vtcJye z+FRFMBUTK>hqRAhF3c@H4`h7E0q_Nra%GugcmO*+Gy7P}?LKNP(0L^Ht31KMM+feI z7Wpc&N?+Fbpwd`f2(dM`AH|YOFP${g$7o0z9EL|xpInx@?-$3~7dxZsFfme>m&27f z;DjN$u|qL@^{@(+s0Au8@%+mJm6&b!0BYcm&NcO!S2Lf-bNyugi{8b>pd|AMq%r>} zRbAPy;77_i%p*4=*=F&|5Simws{esCS%ovXbG{fvk3t+^l7xAnS+1yBZkHi50{Ak|x z8lGE~~sU z^k&{#L=<8|j*KZ%y)wMn}plG9$#VzStk=;7zzJw0v%THMbIRtmAQa;GXi&(TE{Ed?iJWC7;l5{&apo@1HHYBsk6_sc^%Z{+=cV<` zfeIkw=57)B9dM8Qkt>eE!`DPWaZjeMNrFLmN*{;`FfX6ZyR_4HDoi|^5#27VahNVx z^tYX1(N@}acO+v0hCA*!9dW!;{2Q7BB}fy@0T=EAkC*JN=9~8uRYoCy;zs_K`mfMX z=h|d1@~hqDkT^CJeyqAUk8&dA)fRA%7m8J~+8l~*fdBmRIfBYvu+SyhY0nW+C!VsSThy-fFPJNno)h)5wUegl?7l3>g zE)UEQD{&5p&=V@ETl}P0$JZ>;7^Ig!Yy*V2Yg3O{=Y&3r2X{*CVlLp#&gZz#a$Pz0 z>6&udS|5&rkukbwa9`Vb6>ncIv4XudT-MF8v<0FJu+@O0h-v!2OhhxpR#>iIf zY4G)*t&28kNu@Q^k7@U#S8p|1P62miz}C2X2xCsk9BAL*a3)~ft-551HitiU>q=&; z%kbYw6KbZ}KtqY75>A7)Yjl^I=b!H|EK-^0KdqRRimL6axJ}U~nTSUXAMXEVY*FRD zWh4K3sDN zF`8)b@^B+`Yz+(ztlIC%l6FoEgg_ztNYuih(E2G0*!4#)(x-oaT*|wZ$p5xae{9N4 z3i49$-szAt=qe~Eef6sLwfkX_(B30spKqY8G={=ywS@Hn*+N**st1(!?s$pOHn8kT zz)`_ylZKaMjk1?;_s<~jB{D=QcU$=VH8=M}kYgOQyA&mi)*?w?#Li6v!8>$0V1M0OOZ1)G(Tz);tezLT#tCTn+ff;wvbmXb*( zBWQ8&DMqg#f%lpS>(TqbrEzHm*mdRbZ+XOV4M68-=>g>qk!mZi#3wjI*{*#9<<9$7Pc@+Q#lNxrnfyi$BKx3CE)UM2=!t z7!l!zi!#FmwCzf?etm<&0XGWuYm7GEXpL{GD)(E*r4*gK=f2O@N>5}jmjzA9B7(r1 zdWi39lJZWzYk%41^D|}oggUQjHJa`^>+!idJ6fw5yj(-2&N(6$gRua?@n?i7a^slP zPh6dZqpBimvxEE!%X=rO#8F}&sW?<-Z8C`255>2+U57;23824gFLK_+ z=A21TP0EYrWt2mMtB42eR=5kp0&+-eo8^C=tL0qtOl0Jdn+Y8WH9^nq=?}X-Wa(szZ9PQICJR*K!$NE_+EJ{taCFKvWJVh@woT{6jPSI%!;27 zm-f$K@GuJvEOt2wJTsR3aV*`b7PLH&p^F0F4Myx~8=ZV@+nJ=jNJKX6`4~HA2i~{V5}{Rta2lpX!mkr-LACq0t7p+^%>s6?C9qGXss`CjXZv!!B_M} zYXOsL7or;C3eu-`Ws3`&Zd6TsxOrYlpbZ#f8Dro-8{a8&;;A4zy6G(7QhwSkoaH>? z)(|9j(W1ndPZczMc|j4&k1D+~ws&xHsAS8}-`iPp!Mc;#+P-gAgh0Iv3K>z2{1-_m zh~|a5J}bsZ`US7j`Z7Kvt6|3BD$)&}6G5R+ z(vr&Dw+9p#D&U~FW4r9*OWp0=k6TajoDioys7+tQ6>1N=^Bzh?Yke<T9fBDMd--24_AT60$N2wO>mC_O*ro*Mq#0B?NL>aP>MzMIVK)pft+wNz! z=R*}ZzH=?BcgJM(1`R)tbpgTOmAk=^ZZ%uB>3z!}?A2ydV~!*Lp(C9k{}R|Ed~d+C5FB`pAvQ0P+$>bzN0pUf`A-yWWyk*-mEKpe^7S5E z;?KfSDFztRYYmHnk~5w+)vKv4s_UZH}Z2&UEo3XoC~8CWyO}UMRKP^8m&_AxM3Y~ z5t)G!(AiK6^2xl*nI3mog<9M3LFjo2wk5QhEbOW8W-P9RuzP$nZO)tQgB@V9jX#IU=1nnGxRDEw7`)BudcfCQ) z_)HS;$wT)57PrlVMj>Q3wt$o`itTP!e|&uE*5k03(I)OLIrH2jUsOg|uz&lbn~|Cy z@IJZT!Fi)dZGD4ALu_W`#A#|PE07jBidNtX_fI$*Q|Ryv69?Wlk_baIreoP>v)o!% zj`D23QQ0{hpo)NN`AT^_R0NF~&(Pi>QHt0HMCV2I#tSJa@`~VU6V_u@$zW zL6JVImF8N@rsgKE!0e{Q#-j#5r>v%D5LZ7th?MZ9emyK}^lD3+WASVDp4VEVGCNRG zCg>zr<@cvW5NJHz-W_O*NjOJuT&Cf7%Zz9Pd&Nk3&%J1K_krAC^oa`F9R(eDl?;`2yc;ldoQoZ4-AEqMKpObf*`X}(ghdM|E~OdPHj?i+d;m1d#kuz@IUt_jFp zEDMzIwnxomK{xZTY*n5HWO-wym`E3a8yJWO>uV(^+a^5XK${pSqn)ZViMdxaa_7z$70O1ZS6 z^>>iri@z5RS-VX}XO(69P7)iWl8|$aiDuOD9kdF`pX;6Oh+4DW*9!8@70QANgRY(j zEbzw*#NGK)Fp0^a&W6<|46h7IH6;8?7Nq}isqsKO`;X%S>ZkiEjF&z*?DnOhD;-kE zY2xXn)u0eZ$yQmXo30WctG~1MgOQGLAwFDPccU)gy0?H}133zzdKaw$l0A+XQtSa! za!4NE(rL_P5hw)c7=ooYmjSJg4YUmJG-D15R;>D@Hm_OhF6>VZH+i?%bsP9YbZfCK zibhzVzqWbliy_9Reo*j{So^zvwMz?M*IJ676gE)}gQ@>+83{QSDX7WzaY5RDSA$;f z{T1zZGu$x-D$bxxkE*#1Hw>_9jCpOAt)AZb8N*5GZ9u~JFYmg}tBb%ZBZzxs9tf-k z3;f96ephAn1@RjF3VAm-mu+ih%~+uHET@wAsN*S6-}164iEDJVgkz(Z?^jI z1GfJ>4S;B8e_i4wLHM~-(6vA-v?s4t?|CHQ;jwT*-(6V`CE%iu&I)Qyq~R)WwGiSD zpPU`M`(;gE5_CRIGUYLPbaTq`19akK^bbdfOPK$14emzLG)^soe$|KXMIc;+xWoA3{%OlZdKafCDCkr3>@09HPF+lV3i>y$A$ITj(uh&{;k0Do* zR~GGSrJxnulEtn1Njen!a*M3=T~^utE`{C2g@)7&4!R~Mmpw@7mri*;hf9$_0J<|% zH+ch*-U)Mas9!Q`7_QCHr~1k5T-TcR<2q@os*qDW=2y9ipE13j%3!top-fX zl%r|=5^!aAB$T_TAJxb%fOYPG3FzHF&hukfwaqcgbdZK4VrNeUNt*ciYw|AAiTLib zby@C@53CvJzUEFjT3N=D_%te8I$0%+XV1k_R6bh}^sRAeQ1fk8=7)U)u)SR^ z?xAJBxc`*(n?6}Cs5HX?yjyPjX&vTk5`Ap9?bzm7Op7#G&8}Rm%?qm+>8dy~1bdyOC=KwXkZ>UnQ1!>8Lg*O#f90$rPZRY`!t0s&e(QV zM((ppu!RLYiG|J>L9q8r(pzD`aXuD8E1NK%n$4oj;O>&qoaPtlO{1!9K1uhDYN!#_ zTij#N6jvvYua#){ByR{~{hH#~#lH7+GuSpRFQXe=Alk$>V4lj@aT`B2Sub_@3$FD8 zb(6+e>U4Kzw@0HP@y)hxb)RhEqK7fHXRQ0@lIw~IVutjv@(tpocm$3 z)KSo7p)Ij7iMzL#*eJwfPCPMaeh8JvA3euc#fcR4Vhk`zjuY(f&5m%mMgdrPorqfx z$4I7y{h@AqNAg|`mcNi9=C56w-+Wt0r>n#LjY&3s{RQ`lha6D*=SF0^Z|3IRGkgA} zhT#VFVVuLC=C5Cj*V4S4Bf~&j#XJ)XEJWjij)~@zuM853c&wOYpg05eG{SfjgU``T8ag+E1>hvV zd&R7k;`}X?C$6)##M4N-%!-8X-}#Q%uF+9;9lRB=Bo3BkGclQ?i3wdTInUBQ9<{Xg z73cf)PY~+p00or=PQS-6b~1iZw&;#{!!Ecykk$5Ew`UjZIMH6}8Tg^v9(q5|kUqNZ zgeTk(7oC`XlyIKH^juj;G-+djwIr^8p+kSxPEZMll>guHe zq~G}y(FgwIead}G$iLI7UhG8&Jp~nQInY-Sr0j>8|6*yYmMXb$FOKu}T;%&%UM6CI zQt~w@;)~i+O{`0hQ3o&;*OM6zA89hZFK(_hsa>PiW&h%5k&|4_>0vB=QT69FBoWrg zShIhee3mu%7|&VVC@yw6%ap0VV^S0|i7}Y)TysNbVt^fpRR^+D_M+0~%rrMn0078# zXYqgk2GuqCnR}?4q;X8}n@ozHDy~vR9A-<;MK$I9a-v&T8y<`65x2K~y-wulhgcv= zZ<3IH3JuhQFM*Kof5+hIG?O)nJDB~~{?)h`hQ-XE`!ad<`p2hrODE7zdvpda2(u$} zbpu$+jTBeXW53to?{V1~atz*?`Z;j#i`Z;3JzAPmN5({|18)K?DWuN;7Ur=6gO~C8qt!sV{ zS0E$_DdYFJWzG+>jeB>CF1n?c>QZr`*Res{TqDE6{7vm&F@L}pg0+@5?>&J@>=>&i z=`Y%@efLQqPQ;u$10Jansh&Y%|Et@^TI9SRUjA$yQ?TscS5Z?Mw_ZzL?*2rco@Vf6 zT->^E0Nw?e;x|v~@ho{$k`6DFEXgN-(UnPmeu1bpeN578#t&yezIq{IS4@!fHdLYB zBxDKn{QmyG&c~5BdFUk}$|>SAsYxM|EZ*r6xch+LuVW?l6UEt!5Dfa4BtKp3cgQa? z1T?Bsmdy{e65yiDHM|MUPiEGm*QADPt>)`Ic0J{%j|lbMXlIEEspE{0d1hY!mMH0$ z*B{~9id|?f1ZVepzGvEYDP@&hnQcoS!uRg`u z41FIE6Wf!#0j;pQlg;-md>sP9IduYO1v6O1tG`$C{9`} zIMzdOlla1Pb3~!?Pd(Y-#3d^)m*QZI=IT=Q0mbS!)j%!twMM0u-6QaI!F~ZqXJ-;1 z!#=DW!Cp~RHQm;265L5BGdvB|HylmQD~sZXK5px75-iR23r$(w6SX-W?2;v-maVFu z2PIOT$0r8-UKHxMB6)}k*h{7s;DDew<<#qYGt`UMmM;1RvX3zI!zbBKsrCN$A3QJf zl8wx*{JEdbJ?v6W9(+o@z!c6hB05%);jyGRQfs|2;KoPz4cXNhX&^dABw7n$_q;D5 zEBW9z!q}Z3SZM#``CODC9Uh%KWqLeTiEMaehv$K4oTokE7=2#Ewyh}Y zsE6Xr(8ECD`N!|Spijhf3+&@#)rAB@r4m@IIxV?NVAG~#$!TMKb>g}JmUSjRmKKpZ z?)Qew&ZLVBTU;p&b6q|#Lp>=PjSS-d8s`82L797#G>c@AZEEM;K{v)IiC*ve5Q2}$ zmf@T(?{^u6=Atv@$NEiOSwOsGD!qoHOjYMPUzp62>>B4rgVyDKnD+bQ?=L`+?mALS zr`C!u-6ji&{vo*?n8t zj)I7FJzpU{ipDj4R?OUB`U&CjCt`X_XQt0~uHHb8oLP~8sW4IVCb&V7+ma4$p-)qU zw7zE&yH1>13ncAaYZKD3jzs_TZL1Zi?}};!Dye@M>a2E;{cm{!YN#@N3yZ&*kDA6T zKvIlm;;g}f+G&H^^{8IA7CUr@{QH!QVw{v0e65CzpERc+4}S+6^JrnFwM~GU)OF1N zhm;SyK2tetaYLAl<4MN2oGV$cd%#k~P@1acVjQTJC$64qD1-Xp!!IuP@J-nYuK#Xkx zk#q~~x*eoJf8e^a&o-`Wbw>%m*O(ZY)^6F0rAA?;_q3#C;A8-BM^5>xUAcmI{DLI1w-Fl5NjmNcxIL!;8V#K2mEUK}V)&`EKAkFsQ zA9RV@%|D1Cot)zTK^k=|J&Rsw>&5jSh`w9awVNA!qc#nPw!weey#lkmp{?equ+a~^ zH=Iy%2%zb?0lKu9;WVflNG}5U%O7L=yLX|xtLQ6#=NY?l5wWP|GMO6+Gwuuq8Id6fQs zc9s*j!XC$|4dOFEMJ_d3()U9y>|#rwTU$vt(-%;@er3#O%St*@kH_lO^-ByD6JCNW z$#8x6JU~|{IVCr@xiu!9!;AjXRtFV6%t%Ga#7ds~*`y&dz6!%6jNo6m|6Ip(H8X;X z1|6ecAK*V5q2GG(7g{HBY`Qa3?}2qT)%3|XnZc8u3vN^tl^+tY5{8*qc_RPcWC#yU zZntK|csX`n?#F?I;*AqF6?UEe{oyCWRQ86|f$QsbkQ?11xGA=6?4g_g_vI#~VF2k{ zu^%yS6$qAUt^3oW3S|;4N`u%K#c>3!@630SKbjjig7L{6!D(Qtf{A} zr!Euy4UKu;V~x2`y2drbvd4#(hphwE-sUn+X!R`oH&S9UMUiBe?JcOMLjL^O#&IcC(0tr}dq|o=DM^l{pgxBC{t}n^0qr-@yU@Am*8#D zV#Yt_1;L7`mw5COpY?n{#7~KVJb%$Mnt~gZG^oT-M(uI|o<*s5%Gsz7jPG{@Kg~7+ z3BaunaR8}wJGUBR-aiY(Ub;(*zO>R+OUcj9;>(+k$S{I`NOd{-)D&&KN3b~d^C!>u z9+9j$l(tTCvBT>qpTIV2D6}& z|6;Hyq}S?7RQ(VsGBV~(#3!}=!@&O|`vc{^zM}CwU(9_9f@&o!6YQ#R6kR&8Q7zi~ ziy+2D%b|7B2&?6*`{~aNsr*bnCqNX1*z|P9ZOiAGH7;ifF_^6q) zGS2+PhXPi_R%X>MP$qfNY`NL}P{j+pAMa#_j!_Ibjuhe3ItookkQ% z8P__qG6U*Efy!(E&7Z<_E#os&H1+39@jGLHa+kA`idVTdciE2KBfXZd#ZV<}=d`}v zW8iISD;i*aG)>@t=}HE>*ReNn6-*I=uQSYtii*fR!;n>&&>xo~k3|`6XkRP5F&_6f zzK;MXTG=f?-gx1pedUSPPv*56h_=4}j?+69B>UliOkH(c)X($2qdTR$rAu1i014?1 z={mZ*50Fx%Te>?1r9ry8Q|VT^`JV9kym2|I~+H9yMktnGm(|6RlRc!)sl{dv4Q(_DaQ+TPlC9d?yvF-HB zqHije<>$*_cb=~pWh3ETi^xjEQGysiZ_XUm5??X|XwDGU|qJn3q~T$T19#rL;c?-b+YV7U4!n7|Gyrp6lYdrhE^Xkgy~ zAq5M+U>s^**A~ELh4&NwXmBfs%^a<^IHozeYr|X|tJWG$5rcd@I$k^_$9;CuYHw`} zTSY980B?5_r%vLV8a42c)NO7!JI&>uG!lXtqB)@qLrrq5@{{H*4^rdE5vhquSWxpxzOS`xBd3f z>7A;=l(8Va&H2xQI)bo8BFC78a=8}~;9P$Q82U{c6n))wNz^a()YF_^qagc>14-l$ zPt%yL((8^gFP~$G^YGBfc=|mR*Zhu_e(2R~?aNvN!82~F%witX!{I1qPE}F8_olP5 z`B;BH!E61UP8d_XSKI3NF4Lx~dU6a9{u2qtbFP{_qHp^^N^uUlGsE{4y|GkzcSH$L zGm7~XBJTie^cVB*Wx=(|{`fllVp&V|?QC{a64Xlmp*Z=}>4O)|~?f z$!I`GBe&tlMMSDcPfe_)<|18H%=r&9IG|MXBYj&n12SMbrB!jj+Spviq7($`-(z61 zVe*-6ce&)bFsRa|0dOWM;tz+$nauv3NMn?K){qgz1g`=?K(YFhL)fCSz$h>G5-21H zH=UO|N(?Xm2;U6m75m6~!k2DYQCOqlErfblV4A05K*fT)rgFE{8zIkXK*sy=$c=fw z5RVt%4g=#=;7{u8#dIk|p8LoSmAn&kEnGg5CZI*yWr}8lH#@4;qnQ8hV5>_vdQBZf zilk!IfU(>O2*bOmzm95Q zrC3t@My0Aj>*B6a*p{F1;Z;LX@9_x?`!4#TM9La!BBRmMY%wU~^tF|PWZH*OlSwCw z!e&&BS~Gq4hDwvK37pO3{6*~U=)%NG6hQ$^WW{v zUNuPc{bWPsNsCcwV`2uy4qEeU=-Jgz?0YUMG)VXbRt^{K)c1(RUTKGI&Tpv_vGLeR zYB$jV1pj2vu-k)P+!)riL?rh+_6#j#fzf(C%B`Q>xz*X zqf~wUZu50x_#ht-`S2DZ>4R!QHFZ6$2|KqAz=$a{FB>H z`tKBtDAf9`bpfV=vyx&E&?|Vc=b-@Tb95N%n{~JvP*^Y6{@yveA|7n0DSnfV;vXY$ zoZVx%ljyp9)#v5Dm5-?L*Y(rA%K#UmM|^WPRTw-x*=08lfRDR(1%pZDLk#1#R&JAG zHaO{vokY2s<68tBbkpV6+|2_ncJ-n$=;sQEd@tG~wIJOh`S4}S;!C2}NCSy>GM1iH z_J%jrQq3EmD1X_??;kr>E>pi#?o*K6*cG?x3r29t=nfF5<&Zf4MqCgaR?Ilx3$@O>NBnDAf@LORH1_HTL(sb%7`T^U zq0ls;Mho8J8@K6`Qkz{*3MB@AsR~&xQ&po{iRrI0rEg`biRow&uNuW53H)mQ)08k> zp6q}QH!d~jMmGWH%BTnAnVK&OGNo&cm*Puw4dNHSO-ZY%R7;U*?t;yG2Ykot@1B>A z>bdb)=Zup#5x&vo34yV{h9PA++?BL)$b>pTmGp(bfnyW{NXkGL5ur0K$%(c*Tl7Q8 z#ghmx7{ZCiEjb2omO{{nj?j8BPrz#N8e zz_U>hxQoKL|I(U|MfRx%0E`AB^m?3@^bY6e#v=`ImOb2p01OAVUOTE~HK zP93|zqVa%#CPyf*^}5azm^05xISDm6$el7;tC|2}Uwl}UNk6~h>BC#UG8@SZ%EP2ya8;F$+_8i7P2_2adMW3h3v%8fhf1c8!S$IW8{@IN5GAh)M z!;VQy#oX6Od!$XGp%sd&D~C*h_Va&e6kzzxyq34$L7<60M!=0jXauBRt1iyTAA zXk~Ge`qO#PEp)IQZzpFa>^-3MO7Nj($P|-^C1Zx7SRDhVdcWZ4{U%M*85)E$7{KFZ zy<2!5T=z4iZg}|=XNk^AWpTw(#QTyeiI3b~!dW5Ws_19cj|`2DK^92-%c`s(t*VIN{CQ)9;cxRCS}m%&(nTP-yYYjyqa`W5z(`4SJRha|r_)|7)p? z@JR@8YIU6zAOcOFOeJ@F2NC`ZoTr-x9+`XG3+X>e)$&{MT}e0WUaLQ9cx3I5*{%AF zM4l47B!BfzVbU2VCcVa=G%c_5)0HIayb}Ecs%SD&vDeE^p_mL(YujrbvLXo$`pvx9 zsF+D-PEUjmZ9c7v^$tP|o;j7H5j3@XT${X)%9Qz^SVv-v7t3dh%yWir30C`?y|9#zo6vFbL^JL3BG6sENqw zw_c&*dXY+ruO-czje}Dxe_w<#YvoR@?0(W=>v^P7;iLjf@c5^^sbg6(_!YgUeV0Nr zg^O>1rsp(JU&wt2^DKC>t2mJu970N1Sh|hmbQEf)gMMQdu8!xGyo6ypOls@E8?HeR zvQX2g4HD6&fL@3~O}{@h zQ)L#Swa!|KdOe6 zz%{o*nF>e@VKBxyX0HUAE2^Q1e@sZJGeI^@EXBQ!64 zT zKor^ESTGK|4cs6VFh0`vk0B3=9KtIlb8X_$wwL_! z#r$ zu3)m{nsh78t{^FTkNXIOQPDX~0u(U}SVl>V z`Lf&G_wF1Gl7g!x1d1u$s2*3hJ9_$2g@sl!Z7HZNaGz%JIVX8CYg6xJ$971GYmSEn?F*`-z<3^&Iywz^UMAK(=+kA5*qajp;i@Lo$Hi`k1OF73 z+eLGW&X!!(XIx-`y3{d7Qml6BN$u_L7Mp8y)yv2f>E>?6tO82QgQ%tKNfkx^jSz$Y zlpfrnWMx9Cw&?YguTs0Ug;=^p6dk12C-eXQ8F3?@V{I)#62t=Vm|dG)7#9(vUz~9H zZd$u67$J{x1=ffa%#v3mQE8NIC&%H2di5S#d~AN zi#Sk#Yh|SukuH{S_S+Dy-WQ>NuN7q6-jm6WTCmqDQvH4W=zSii@AlD$2tc>D%2Rxl z?rd9dDWF~jn>Be!D`T-luy;SV>@WwmYfk5NIe)e+h++hLyv4~~aVhTarg)#r@|)|Q zyw0;JSG;&~Z&dqj=jPnagamyme=EG-{A|YX6UxilH!u-+qPm15-9%qP0nw8VU?~bf z>uWC@S-caO{d-6>ro1OffQ!;(UGy9QTp}&D3um>`%8KA-$FmbT^z^2sg$sj8-D+ii z8*wY3&F=N3n`lxNLcJfyxTbAnu{z`5m%Cf`_mL;mMEjt{KHOjec?Y~DZ&1r*7RCEBtMsf;qn7{;sKg;qE;tRc0JypI3 zOYX@)_uiN)a}SojfJusppWy^BFENeRJjPRt(Kepl(nU9+!(MEk8D1noYD$OhVftxl z)sS4o7`1RC5cOo(KLkpMfkJF#VPLihZiUb8t|`HWE2dH#EN0w|;O&bUt0AkcIgM&x z^2|9-n0r3g=#VuVAPjScQmsj3z&3h0Ky*xKa`?2I(FvK2R@1nNkvSF)^8}OUlFp^yQD7(urfVE z-SgiqJz5IVskC=)gi@1Ml6Sj+G`QRa8@02cm*=7dxUZx4SkQxroXrRBn8UsfEZkK` zWco5yW3xpr#SE`6H4x?+SQx;Y`Cify!P+F!3zqG@CtcJ_q9asZXfL1bRTmXkBMs2s z$m6a|HUH7^?9ZAZqn&fXxhp4uP4>FdhoPnU5)GC@9}3mD^wOPEbW96%YY?kQ}9nDj8eALZM2h2BU@!fC7|7CbU_2-ILe|`b)kh1R=BGEz9&fAG}i2?3uw+dTaODMh3l1?LkwelWM{O z5Qz;p9(xoM1y^;J7}y3w~wl~p_2Bb1jV}DZ~_(e#icg$5z#i`5LFXpRS9D9 z64GggyoBDJQbE+NS)iNA@fYPT;7o)PBTo>5z|q$1oTaIb-RFMvm!J`X@8LTw=aq?` z?JC~KfJN+MCA>nIITJHdHJDDT{<48aXyf5~rW#=}>^UNPbb{OXp?#KQ1L9V3W%*%iJA_p26@MHUSTNw=9l zESEW+L~niHgv-T%sRhadscgo>qBlu$0O8oqRr>V?HHxZa~}! z=mU?Y)a`Gkb`yQzJ#-_^WFm{X68WL1a^F$7?{})X=Dk0PdnJyExydV#eD?Z>8|hcY ztKX+dpgW#yN`I(MdUcqze(yRjkxFEl3Tain)B2!Qmto!@Ol)fK0tNZJ@<1{dl5OS6 z7{@e&$#L zT}M3nxDR@^*`>aq+88u(63<9s{W^IFVJzN@ksGx6URUm3RC{70zh%(>Jn`-#J0shw z4Ep-C7M)pIQh{i#oD-zLp1D+O-=R!+tuBbH{9NJ*>D(n=%;we6w*&rA+jyQ}hdsDD zz-wq-4ZZv*8=(rU&JnoXw((J^6CyyI%&c}QY=Iz^&}YZ}#_3Pw)?!{(P`}?6_^`>N zd}hr3EY=5kZsPlc!IBe^{9zG-ruUWT1fgv=Q1{r=al*8J8qGB^&3m#pJn+BcD znVOyXB~2>)I#T25ss%s>d=$YrRC7=kEvb<&b0@@Gz;~7q+o9ohn~bn>_mUdV^TS@; zl(Tx(rFf{b?1=T)HgQif_I*A;gP*p}R*U-VXj=lkIsRu@uaCYi*k)Gv;MAvqkbAS0 z1(JewgwjwesC;Cg6yd(Y^VD`2y#6?5mf+H+)+{`ho2`HM#)jq=A21-PqLySTCq%*6 z=v|O-zEB^pmOO<4y<`9H<3LDLNg)+IaBUsSK2Wke9qJjLC0=Ug%|l1dc#>2$txdF( z5M*{hF8L-?%f%c|sa|-ElJ4%u8(#&~+opstXStjy5Fr)cbj7Ga-Q&T!neZL`bHj13 zZAzyz{S*Xl#E1+-wj&mzq^>$TI7404ZuRAaXwmu4meub{kIFt0<|%CXs;K0+Vu2Xl z34FL#%77yQ<#Pb{D+;ZolvBn9?N2fhD=AUmDsYx~E{c%Y4z-q^B^bR8GQF}p9sP!| zRe@qS_(My8GKExko1YYjtG2hbID?i-3^%n1iznu#q|fqTFj*A%DSmZ~GihJWRRA!N z&8fMR#K8;z(YE&%1VV5ugz-SHoqiK+X-DA!NvC=Z>d>F16n2p}k39t2gALL9&L=<+ z+5XT3IWpX=k?`V=qz{Q{4@xnvu9BIK??%{=;6LReOwK^dXXsMY9Qe41ThBG?8zTY) zmywXP{3CUIwC1GX_vM0srm6>_1|PpL9x0krJPUzRIU>zR#iosHmxY@iXON=t0|ax* z#aBPT94)U`Ke zCMfiUGOYtR-~1i3vuo578rtQTy`vR4W)w07yqq2Y60$R%!bqG}NLw}4Pxi+|1(g*e zoV-*iD>cQV0yyChQ^J4m$?SK#QzM8sB;Yi}f*4cLiqM@9AeehB0kTQV$<$^HuQpTT zN?@r?7f6bD*wq)(PeW?Y}1;b+*d`A!4;uVy?7C1hllBAiK zXu_j7bD^|s@o)jvO_nOaU>J3D03j{+l0XS#L6o5McXtkmO_h6d!wM&%Q1O52F*@NQ zJh3~$w;h8^z&O~08moxixtALTDE3rvYUGjFJKBixXZe@r`w62QoNsf?T9I6#B?f+$ zPnIbazwHr^m}C*SOId)9HSBF4hG0~iXuoxhtn17y*v-G3&XjUpZyQeqq7u*IR4Zb$k=jV2I?g13tPbKs;byLusV*S?Sy z+g8ksVuINCToy*mfklcOVrhGoSG~MadA1+Z5!w#VzDISia7%(Hci(-R{h z9bR$%hE#u901OXnJKa}iQzH?ku&UcJSn1?pVA0|b%1fFVmt;>UAaGX1WF?=?40EYen{%eZn3NyZ zwf2SMDRZVYl*j?Q)hBB>Bnie0S`HKPM;u*Z&*%)mG}jNFiKR$&59Zb1DOA$mj$yYw zViYX14$)-q34Z)kvG~rucvq{OWt7_+jJbvj2UN8d5QEq!i6(vlnZbAA_!(#eI@Uig zPFHu;CzkQ?4y}8dZvVGN1hONIj}$lA+I(3k%1+?hEV;A|ciQaLwqv64?(NZ3_^9)f;mG2ivK=sI9S8%uYsC>oxFCk8S%K2W+~pzvm!=FZ19tRpk(G85#t_`J#^YH z<-wt>cZG28jd78pz`P5BeSi1v3@t^OGTTB33kiAv0g|o&fl4ycIw9un%ps=*a6xAG>S5FO|Y!&8!3-KS%>iP_~sMd+z3;C{v(IzZSd`LbQ zHe5IYW4Udx5f&uY@@`_sw>3pDkT5;MEwyZmsdR zzJ66ifS&9zg>JhmZ@`T9xdKw(i~hT-`}W#uZr!ykSWzkE!p!HHrht-olbHRVkt7A- z_9q@WZa*;d1WeRDaq_1EdFl|o8{UQnM*w1ngJKLySsh5GlXXh~5Cs-<@!V#uV4!2u z%d%OD{bw#16KLy~-49vy%LG-8s}+W1;NIZ?rD<&rN0AW1Rc}&-)O;F&RjTw~x>Tr)15_L8qQgKnvxk1e6Jy9Q*B_x?+2-V* zNa89ZtOWu4Ew}(77*>=Wvo$&e*#OdjXJ4t7P2t?QXMC&+SVs1iY^iAlj~&mzhNkAAFhUth8UWuiuCf)2}}g*^IiG^q0M$Pk6EalTgt zlgO)w3WK);x1k}|M3A#d-RT4iAS$Z<7eb9|soFF_+_y3?3Du1-1%PAP@%JyYv(B0q zs8QgYbaf%J@Y4XiGF4~78L7TwMgvund9HMWC|^cAPDScydjh-w9;1cuju+spN)vkX zfEu|+C-=9l_Y&Q{1?=z^Ylt53E;GT|D3f!m@F+c)e09)v9Mx9IEFX;thUc! ze)zFF$DxvvvSP1;)sglKQ1pBTQZHj&5pUb%0efiy41$!SddkgW5I;P?ZVLP>G0FAo zm)1ywM9N#yI&cPw`obm$G(te2QVBuAFU8@MSjung=K@CtOJ>z>@pHfVe~&!q*TraQ z-4^`@7PXv3rpMx}1vb^{{Az>d(pWF~4#3E3{kEFt!HUdEy@wcc%apqgM#|+~dRye4 z3}Q0fj~*u0?~&E8uyG5%O1z+*l;%K)B^D$+10AIJ&<5Q&;R0(P`d*dDJ{tzWAAEXr zT*mIVy78cejO3Z_ITEYY`gA?&SMVC4ELy#&B4wcX5T@^uks*_rptK4PF2zCFkbjZg zP*d!KYXCGTvX;WuBJUivSQ}avdhyaj3YMbRn;)68qE6G^=i)qsqh6T%9klA)Mv>JR zV3hD&;w`cf?JNDJ=Xi*nh6N)2+$1V$$rqwLpTymQRw4LLYX z z@RWti&J43LWC%&q&oWE?qNrN}*o>ZQMBkA;hzw(S#KOAU?K*BdmgLuZV}=dT=2~8B zeXqS~`!?uBp?BmfLxEA{56Ob(LwcDpfG~H)rcB`k)anzooY4zjS3I4A+t)asZpEG8qOFFk}2WUv&`J$9*n5dw#873B-S+x>;6bg7K1q_Vcz^zFQ4jzL zP>xm5IUb6t%zLBB2Hqt7SL2 z&P27avH4u7*CQxNiEkIGt+#lyv<~Wy9rEhW#26?DLPJ~9 znMPvzXlq#-Z?|5TF<^!pSs%m$^QjJ=AAv# z7j}|!=S@T-y*(Kp5~zC!fsp?MSvMF2xIlO9fx?X(R5ms?u7B3Wnr`>YtbBaFKFheI z0n5AYj*F?<92isGQ9Y-=!gaTw zwXjYUaNzK}bi+ImT0IrA{EyQhKNI!{Ww)#PTPJ4FZFc)EOFO8SCwOAaLB#R8n~ z>bQCF31-OAe05kg`A)`H|06XIR1Mh(5;Q^^dA2<{c@bjFz-Gx zoTXtSqK0XqaA=1Wg_(e|p%&*+VP-|geqYKfUP`CUmF*t`h`F|as7#GOy8i@2gyp9D zDmihpWb=|Mizt|1$LvPS?ly^iX{1SywHsd|Wkxl5-=8sY;u>)Z1FW$H{I_8N0C%Bc z)F4G~pMJ~NSnFlX_4+15HGQ9i<_`wAVaTJ9V>1&txsme~{Vd-DCOn5=f))@w(J5b~ zewMUR(ACxb*4nz_Kj(e^ZrA#Cfu)_@7wGI+da5%Pd1H^}>PWK@=trf*uE9{Ff51-{ zggf!Z&2HfO(rMz3mrFnlkb4gT+4DjwmiB1=8%jqIhvL)LQ1ae-XRzyxhSo%HuQmFZ z!V|lG(>nrogM3&-jBkMe#wM4?`i&>nKyK~(4iRZX$$e)#O|g`cb@a4iU!NXtAg-w@ z7q{VQ38JR>*In0jj zl&2bMHiZGw03au2nwZB5+*(xh0=Qef9t6HGB7A;^M(&U02wNqe8RtF_$J921oTpAD znoL_*0nm0dvCghE9B}OpGI*R<*5<*azx+Pfl|DnUQ);K*0?x*T&o~`0IS6;|x+8EG zqOM>u-*N<`wR^q(bunj{WzKAo)#9(WU$Y04NMjx%h|Obuv+c53Fxt8~kDO{@Dr0-6 z==ufH69*4NtLP&$Hl1?x5vjZg=1TugYS~>Uz-@V(EuBzHGg{SmX0ytLNDs zo#kie;exr8;RVSUreUwgOF~b#@x*yBo?0>OCp}s=#cmhb1r?umQ?;fNN9>@CG(I0I zh1wLKZsnqIfS4)0X07*rP7ZC%??TUG2 z9glPE>)H1Kj&0n@-O0AA&8&b3}cf z3g}-~D`Y*Cgnfk;`-AGmh+4j>&1*MvfO>fQcDiUX1eM@J-;+ha zrNy4K|EBaV=Hoi%Zo`s&p2KaSgSGkaY2ew;*c7xb#W`=d(P3&$w_@+QNfz$tHZ%t z$T@erS7F3%S?_|@5&A=0w8La{+p+7$FtebW;*`hZZ;A7M^OfPnvWCOWiTa!9?m(%X z=rx~MgUhg;-NoL$rwe+^Kl^-HHe@#ht-oJo*qke7t>sd9ZtX!qjKl!vs8Ive^L^2` za-t75Meq5<0@o~Ek3~&auRibn%O;IvF~|hpIY5i2=Gh zNqC!HjgZEeC--HuUoqsHQVlG13i4dr7>iI2;hsQ=mq8LGNwy|h?lg4n8#Q*kbH+J? zZCSY2U$t&IR%)`!fEw9gs&b4k>Tc?Bk;?lHk$u8u%{9~Dl*D3Jom=8*fRD^p?R+iZ zivFR;P@i6?qkUy@QDC<>qJ4V!dhK$v>%1~rFURm^%H_Q2dwPM8h=0iAZ3W`qK(wgL z)7f|O93{KQr^6yLD;U7e?)Jg#9>?Ur7!ESf_$!oTktm?9@Cizy&jzhyz@A%H)s|-8 zv{%?TTt8>9B*M_qLO%YMWBd=4s6W2#`g40q+ih8U)6Bz9_WPfg0s!zH#48VSLnwr5 zh?DIe7E<#oC(6&2zg~o{`PGk%g#G+OWV@m$x-q1!lW1@PyFk6_ji39E1{-r4h`CAP zLyfRMPa4Bf+o3so9>d#T$T|~lXW!@FLQXuOgm8}o7Wylrt9A>vUc3NJb~UH|_(})WE*}XAc@9ERPj@w&NCl z`^Mf=&FtfMq7Cn#&d;JIvs&(`9~O1Tz9&zLwj3d?TqbncH9aXkELwRRM9PoY)}0`fMrX+h7BOOwZlH!pKqgBsDt!1{#WiZOTX*3OmO`uWA~)j)~bWoU+mv#SroHGRw_Hk zkO3?-U|W|l;j3?*NcMnr8w)6uy~@Fh1@|Yse?X(0W3*CGot;N>V;%oo44yB4F!EG& z0h`DtBt$9y7_)=DLw^PBE1 z$H4@?BmhPx!}5(z2_sw(Nl~|U{GHJZn=HPVGuh6-G2VLISxIW9rS$V(K|GgK=SHm% zyAxQT4Vhdt8JShx;$O0dcReM4vq4+Lb!gPkx2HOPB$>f8x6hD}GVm;0_$<2(TB+VL z6T7-JB@9XAPY3LgE~4|^jn8L6QqzXAu1B{c_fmoNbS_Mnq86}n5EUO1$|eE=y?~0- z+cqCOKWOOs8UE$r-zDdEq7SC%Jx7LUK-+_TW_CMMCm_DZNcC}Kxc$cUp~^HJU;0ae zcj(c3E*P*K-hvSesxmTeL&o9p_3h7CPBBPagTPUg{2n(H34sG#GRZmS&+TV1|bIye-HO51aGL;Q0 zoM&kOVDUVmjhyTdCQrZyISLFEe@Q0d_SQjpIrxR(*MV30$1|qdWgQU5KSWO&3cLKU zEwcC}O}FG@HviRRM;3;-Bn<9@TTa)e2#0bJISb(dDg)9wv7DaFz*G&b7(k+u+58-8 z{HeO=jHlM?re~OZ+8D17S?$tm82QG;dbl=uS7`<;AGT+71p{Pak>+&Kvv?baPO0%# z3r?t>7}_Ta?I6pUbU%@^b`(axe=Y=2%N|+6#F^@;F+Nm9fRpW8`HTi9v#5|kh^JN0 zr|RI3lxcBXg8~o4X_`2&>3q}}a0ax%8?{%Xi7?z72)b;et@vH7=v4Lni?9Im%qcbF z!dJdQqZvX%>}NM>lLLF>7OK{7j&~1AL_W}yEK7HC?$&3-6ex@SkmWQ*!~Gj%HSk5W z19d@(gZ+475y5qTnz3mkN@(%wk^1H7(jBhC!HD**1Vu~mXoi4-T6Mp%qvAX9!}ZyB zj@1aAkOa!U!ec~gT0yJMcVyh!R1-w;>68HIZsfbZGs=0=?>Ay&AW@{0WuKMy?2Xk~ z^In$g#C8U4@>Sk5HkN0TjXC4tB{SYSB8xZ5)FN0Fq{zlTZ4bn|a<=xs6BeJ`~hogu0jjXP7WD9o?hxV{u@5v4*6`nBTqVd@~Hh>)w^$L zO}6GH#_wX+ti<%K8)Y_VMo4IEB@CjcuFq|50@El4!TaMk%zBlDbZFRnZys`X($lhb zwQ}OmC%nxWo%63Im+RgnUCZ+=IDii^_bg{#1}q8Uu3P&pF*HTFF=+CAG+s=B2YA!u z#9eit;QbI1-4ZLE(A-@vwB=rmT97YgHufs_1`<@_s3*JyVIAW2{*ZCfpldvCI@tZU zF$zE3j1~P$03N!+cb;)aqblQG{-vd~l zGHo4tPcBb7u%~MB+ZY)p@Y0a^xNPxfh@9-`dR9cGrH$3ZHdR07l+Wq|!Fj{?*5K8v z)vIx!Tk32@NWbGF?k0I8BZi3;NgL5V$^rnEla3?jbRVA#Y5lRB8UVPRzGwcou4-5J zs(;F1aO&sEKt2Ys+59Y>g~Z%=XmP_iKYjG*jGG7rm`1ZEbq^gjaccZ|zZ{B#hbZ8h zoqGRg_?8CHk-Z6F@u4&-r38VLNXL^9fH;Enpns+X0P=E7mY5DfR|xlkL_{9zMK~q7 zRJyIsTM3xW_=ifxD=B%oFl&AxHBLEWR0;GERyzS}YK;q+J+qIGbXj?HM|FP&Hpm)H zZU5$Zs=KPKiX0t>nV@)Ay*K_!tpf#t25if{Tp9i(YJP1^!N$6wQdq?m*ha0|G1CuK z+}r#0Xlws3nFVyf%?FbV$Xm$sT6DarPIkO^1F=}rNG?vO66yyf%2&Qb$jHe*OxE=pmZ1sV0WM_M{yW9G02{WU}!lW4Os{g z;8}dg{Q6k6Avi%P$t8|Oura$sNj_x3&w1&D4DW?QuZ=?I@CsbU6k0BO>#+Jsa*f$1 zvmylHRg?KGyW$Llg?PN6&#@_#^z3=PYppzf3Weo>c*LCv!Woe0sLw8wG9RdW? z%QJS(S$q-t%<2ZxCj7w`Ex^fqajA^)!gK$PY|Ec6|2!Jf3bY6vYewrxB|{sCp(|t6 z;lp!T|E7x)e3@R)++d{B^)jQqp@~6H)no*g>CNj+mMejNPnW$5lpP2*3HlDaGUDEv-%)gq z69{nh)xfid>%o?*r#V90Ay11X>4xGQ|2}GG7=S(zA7EXVEt`5NX9wf8hlEeM&z4md zbyNh(Zs?|!cM&)N0640AQbK@MCo>_T$@{ovAZAD_rSTVdJLfFUxZ@oU2M9W4%4Kr2 zEo?K?3{*Km)p#$JtBC`rEdRG82teH__|V@<{_OYAATdFcYoeu&8iWHc9}2iiazRIk6~_Khhl#`n_%>j}$S0{Rikm zLVk%5weN&^r%$%ks!_yReCa7qU2H6w1Xeo?f>l-{9YMq-mQ8NLSxR@w+%bKb%(#C^ zc4RP(AbC<*ucB)Q7_8Eib- zzT#Ox!etpXrC8n?-uBS^D(+VU0oO0q=z4~u&qbJY3IAB$ppX`($}-oQZT{&s=3u+< zxK>gmtF-@3<)~fPaWKk7Dvhazbu+6Hg|9i)&isziQn%Rv!$E3na^X{*;ls`uXi8E} zfD+2Y@=hvY854Pgizz9)MYzgfFnkaPFShKvHOtBa$1F14FP@}04f(|MN3KeT9yYV- znV2ic%e4ZQ1mZd$7ZNoEJnzL=`8X$fRXS5CL##lY2c1`Czwb#*xcw!QQwQQ4gLSMD zb9^m%U6EThr)e$h`Ah?AT0163hojuY->2N*sc-HP=!?2zy>Wc9#^Eg$23mtD<~<)Z zS&!VFU4JWt`{sAX3d5(WhwmtUurzB+0F#F1rcBnY1fiey6smVyOVSj;L?$?kEn)7{ z^vPoi)if8-3U~a51^1IiMifU1VbcMxq78`jg<2qd6nGj2dLc(!n-H zYI9)fAzPXJ%J_poCwkz^_~fM$FP0`PVBdXGi~m5i()q#O;>-55VijYw~^x|=^5s|feusn@9X=@y>&2E%m`!!pvISLBP=QH->zQgVk8GXiMZWI28Siai4 zm%~pyZR8<~x_2vA7s&|tiSp_>m-_hCXtpFOkQy3+3#0TZmTczz2o{CfhUcsa;jE>ZkxsJC#*4) zSMgfv;;s2|XHy~CPFACm!HQ=MVJz9$=&Ymlw`}1OvcHc5W!_vL5YKTM%5DXTxD(Pl z^eSxZtGFAxC=T8ge^B$H1YLba;ng9AL%QD%_m?30SptGUD;xYFgoY5%4`OBHGaX1h z*SX_~Sz?mm2uGH>$)-;1)z|CQs1UjTYWLb*b7_?F$K*WU^t&H%0^#h+N{iqXK_W58 zhlE20X^%*kb^`6iji1PH%3^CgW&LZA{E7ax7)}@<)yi&LhzRz7gDbWKH;z{S!W|}3 zcFw@c?Kc`e6RuZUPLYvqNWzW5bu6B!9J7oKrgUIi#c%;+wahm1No4k0&b4)GMz93| ztu(ElOfxRvu4`>w(uQt~<%sbvQ=NVX&3N-!#?55rg@-{SU;rQ+=`t8l!9Edo!5B@3 z=Rco%>Pl%AP|3afQNw=7+boHA@svk{iWMnIqdoQs`Tih`DYUUp%Z5E6LYn zN`Fo5UlPaa#nhT?uheA63#UcS2K?}{wCDpUf0Ii(SFqQ)6s7QGT+FsUc znLuykv&KAo2V^Etpmrpq9^mY)v`hGgIL? zzN=B88jTl$nj3WhZTp1KqTYuAy4{3FH$h~A*k~!4Tm6-0NqtF41jbNay&O!QSgtmLxXcQo?4bQ$)L=s>vJXfGB#2u9HmZira$ zanhwpzrXoPz|!3R+;~ZXVwntmk$K14txGxvRLK29;`%-u zqC?K~bUGR!BeQi5jh)-PtD^k)2a1sejG1Cz2v~@&E?ea*Rr9BuWb3@%NUPMr{7cP{ zM2}7JIr@!{rH8U&x*-dIp5IJOsqkFSQmZ)-6fKHHmK-&CmfrHgx4Y^KFGVOaqMF%^ z`MabC7;Cth)Lqm^BNnwt`Q%Zw%SY$Cx6?1o(U~e8I#?cwa9c7Wha%WbYkSbXC2E|r z`ND$zf0l51w#4U3oTY|L2hDdXjw>$8Vc}Q#0W%JLY+wtZ=l=p72IBd2s}dMUXU+ao z-Sz+6&Q4BkTQll)hF-3$l(BB<(0;E1nwa}gb4M3-&?0SepK|Y&k?xC5*peWSX9YjIROQxPVwmsc>it}HV6O! z003aYn1P83RA@K#I4I0D+7}^PY^LK?jnzr}V3mvP`Ro*uZ9<2)C-)n28WbLM-+3Bb zKT;ysRPJ>#&{GTW^^x!Lc(% zAYf6K+GyyE&!SjFADEG9WnmmPJ-T7vw z!{^=530bajdr(<(@Gl);*4Q6P9wM=gGNJKc_#{=0jd1$2WI49RJ~dOcF$q&0DDFpU zdrFu$9OqAh?tRHbM@%ev5C8!H00000ECyYFaHECg-a5_*lB_`_1F3AzSbW(WA}tWe z3Y^rUz?Sd4);Jfn&Zjg8pc!R7RNCH{*XG#vSDM`Do%Eual28yJ;~uOM2uH?+zCJOgAdQ57* z8=A@npP>u@tv&Uwm=5StSl4eTXey~!m4c^kh2B#xG8wpGSocw^QZaO5CRDmy0!yvn zT(C?mllL=5Uovd=RC12LP^b753vLYYwV!)!Oa2%pJ5f!{tA_vp00000vQZ`Fj&q^{ z<=w`>xAAz~sQ^GvZzwXb>1kqE@9A%Jd|ELGAkJX$iMh`BgaH8UFHY)>Lcxf-cw9}##*kEn#tC&N`_5IT`3~ll^wPqOFV5A%Hj=`=h%;JOYBIq zlY5v~iK+5^3hIw=EqRwCOm)q2SwMfH*Fx@vTk#&v3I0{G zBe_=N3Yoi;vrn-;jD5ic>~9bN0002Mgmg0y!;K7EIy{JqnQrSG0k1rxe{+%9my}A$ z{*UbgFYT<*lpQO)CSzu5n09S+hbGsqw@-oSMXW9DS7G--B3}(BqE_oNTZTYF^;^a^ z$k{=ue2K}>1r)ik+~bq#VwYJa4g|#dpq;AP7f#@SVF>^L0000+VUf^;awG!wmBALQzok^}2YYl)C z_W}R_005AoGU|LYj)Tl)&+-6on*Bg=_p1ubWGV{Jx_IkU?`zN|h5G1MVHPqeaX{_h zNF-2_S^}UW)jne^`3fV|n2G2iuax$jeg2iOIaRjA#A{<#mQdlEW>uEl23btx4YBqs zqc1gsmSQR`nW+J5CSB03;?$Sy2lw})^0E3q6TB>k;8VA@00000NkvXXu0mjf*lnH~ literal 0 HcmV?d00001 From 443564b553203b1975eab7145e87a6f85d3880ab Mon Sep 17 00:00:00 2001 From: Noah Talerman <47070608+noahtalerman@users.noreply.github.com> Date: Thu, 13 Jun 2024 15:24:09 -0400 Subject: [PATCH 02/15] Update story issue template (#19735) - Add "Fleet's agent (fleetd) changes" section. Response to the following bug: - https://github.com/fleetdm/fleet/issues/19736 More info in Slack [here](https://fleetdm.slack.com/archives/C03C41L5YEL/p1718299265992889?thread_ts=1718298355.630389&cid=C03C41L5YEL) (internal). --- .github/ISSUE_TEMPLATE/story.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/story.md b/.github/ISSUE_TEMPLATE/story.md index 2e592a9f21..54fffcd59b 100644 --- a/.github/ISSUE_TEMPLATE/story.md +++ b/.github/ISSUE_TEMPLATE/story.md @@ -35,6 +35,7 @@ What else should contributors [keep in mind](https://fleetdm.com/handbook/compan - [ ] UI changes: TODO - [ ] CLI usage changes: TODO - [ ] REST API changes: TODO +- [ ] Fleet's agent (fleetd) changes: TODO - [ ] Permissions changes: TODO - [ ] Outdated documentation changes: TODO - [ ] Changes to paid features or tiers: TODO From bcf3052117cc09d1711c3fd043448cb38d0a6e34 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 13 Jun 2024 17:07:14 -0500 Subject: [PATCH 03/15] Website: Send analytics events (#19745) Related to: #19603 Changes: - Updated the contact page to send an event (`website_contact_forms`) to Google Analytics when a user submits the form - Update the signup page to send an event (`website_sign_up`) to Google Analytics when a user signs up - Updated the swag request button in the docs to send an event (`website_swag_request`) to Google Analytics when a user visits the swag request typeform. --- website/assets/.eslintrc | 2 +- website/assets/js/pages/contact.page.js | 7 ++++++- website/assets/js/pages/docs/basic-documentation.page.js | 7 +++++++ website/assets/js/pages/entrance/signup.page.js | 3 +++ website/views/pages/docs/basic-documentation.ejs | 2 +- 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/website/assets/.eslintrc b/website/assets/.eslintrc index 94cfe4e273..96d18f3427 100644 --- a/website/assets/.eslintrc +++ b/website/assets/.eslintrc @@ -48,7 +48,7 @@ "moment": true, "docsearch": true, "Chart": true, - // "google": true, + "gtag": true, // ...etc. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/website/assets/js/pages/contact.page.js b/website/assets/js/pages/contact.page.js index 3a4314bf01..5b8865b67c 100644 --- a/website/assets/js/pages/contact.page.js +++ b/website/assets/js/pages/contact.page.js @@ -80,13 +80,18 @@ parasails.registerPage('contact', { methods: { submittedContactForm: async function() { - + if(typeof gtag !== 'undefined'){ + gtag('event','website_contact_forms'); + } // Show the success message. this.cloudSuccess = true; }, submittedTalkToUsForm: async function() { this.syncing = true; + if(typeof gtag !== 'undefined'){ + gtag('event','website_contact_forms'); + } if(this.formData.numberOfHosts > 700){ this.goto(`https://calendly.com/fleetdm/talk-to-us?email=${encodeURIComponent(this.formData.emailAddress)}&name=${encodeURIComponent(this.formData.firstName+' '+this.formData.lastName)}`); } else { diff --git a/website/assets/js/pages/docs/basic-documentation.page.js b/website/assets/js/pages/docs/basic-documentation.page.js index 5b7262b8f2..a2540321f7 100644 --- a/website/assets/js/pages/docs/basic-documentation.page.js +++ b/website/assets/js/pages/docs/basic-documentation.page.js @@ -222,6 +222,13 @@ parasails.registerPage('basic-documentation', { // ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝ methods: { + clickSwagRequestCTA: function () { + if(typeof gtag !== 'undefined') { + gtag('event','website_swag_request'); + } + this.goto('https://kqphpqst851.typeform.com/to/ZfA3sOu0'); + }, + clickCTA: function (slug) { this.goto(slug); }, diff --git a/website/assets/js/pages/entrance/signup.page.js b/website/assets/js/pages/entrance/signup.page.js index 598e3b073e..4f0904a935 100644 --- a/website/assets/js/pages/entrance/signup.page.js +++ b/website/assets/js/pages/entrance/signup.page.js @@ -67,6 +67,9 @@ parasails.registerPage('signup', { // redirect to the /start page. // > (Note that we re-enable the syncing state here. This is on purpose-- // > to make sure the spinner stays there until the page navigation finishes.) + if(typeof gtag !== 'undefined'){ + gtag('event','website_sign_up'); + } this.syncing = true; this.goto(this.pageToRedirectToAfterRegistration);// « / start if the user came here from the start now button, or customers/new-license if the user came here from the "Get your license" link. } diff --git a/website/views/pages/docs/basic-documentation.ejs b/website/views/pages/docs/basic-documentation.ejs index 8e6853c7b7..eb47296580 100644 --- a/website/views/pages/docs/basic-documentation.ejs +++ b/website/views/pages/docs/basic-documentation.ejs @@ -157,7 +157,7 @@ Releases Support
    - +
    A very nice Fleet branded shirt

    Request Fleet swag

    From 60b233e5f7aa19b82a5398a128e52f922e83b8e4 Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Thu, 13 Jun 2024 19:10:27 -0300 Subject: [PATCH 04/15] Return token when creating API-only users (#19525) #16961 API changes here: https://github.com/fleetdm/fleet/pull/17609/files - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [X] Added/updated tests - [X] Manual QA for all new/changed functionality --- .../16961-return-api-token-for-api-only-users | 2 + cmd/fleetctl/user.go | 12 ++-- cmd/fleetctl/users_test.go | 36 +++++++++++- server/fleet/service.go | 3 +- server/service/client_users.go | 11 +++- server/service/integration_core_test.go | 38 +++++++++++++ server/service/users.go | 46 ++++++++++++---- server/service/users_test.go | 55 ++++++++++++++++++- 8 files changed, 179 insertions(+), 24 deletions(-) create mode 100644 changes/16961-return-api-token-for-api-only-users diff --git a/changes/16961-return-api-token-for-api-only-users b/changes/16961-return-api-token-for-api-only-users new file mode 100644 index 0000000000..97c5ce6a01 --- /dev/null +++ b/changes/16961-return-api-token-for-api-only-users @@ -0,0 +1,2 @@ +- Endpoint `/api/latest/fleet/users/admin` to return API token when creating API-only (non-SSO) users. +- Added API-token of the created API-only (non-SSO) user to the output of `fleetctl user create --api-only`. diff --git a/cmd/fleetctl/user.go b/cmd/fleetctl/user.go index 38b5a43edb..ce0a842c7d 100644 --- a/cmd/fleetctl/user.go +++ b/cmd/fleetctl/user.go @@ -160,7 +160,7 @@ func createUserCommand() *cli.Command { force_reset := !sso && !apiOnly // password requirements are validated as part of `CreateUser` - err = client.CreateUser(fleet.UserPayload{ + sessionKey, err := client.CreateUser(fleet.UserPayload{ Password: &password, Email: &email, Name: &name, @@ -174,6 +174,10 @@ func createUserCommand() *cli.Command { return fmt.Errorf("Failed to create user: %w", err) } + if apiOnly && sessionKey != nil && *sessionKey != "" { + fmt.Fprintf(c.App.Writer, "Success! The API token for your new user is: %s\n", *sessionKey) + } + return nil }, } @@ -208,7 +212,6 @@ func createBulkUsersCommand() *cli.Command { } defer csvFile.Close() csvLines, err := csv.NewReader(csvFile).ReadAll() - if err != nil { return err } @@ -278,7 +281,7 @@ func createBulkUsersCommand() *cli.Command { } for _, user := range users { - err = client.CreateUser(user) + _, err = client.CreateUser(user) if err != nil { return fmt.Errorf("Failed to create user: %w", err) } @@ -351,7 +354,6 @@ func deleteBulkUsersCommand() *cli.Command { } defer csvFile.Close() csvLines, err := csv.NewReader(csvFile).ReadAll() - if err != nil { return err } @@ -362,10 +364,10 @@ func deleteBulkUsersCommand() *cli.Command { } } return nil - }, } } + func generateRandomPassword() (string, error) { password, err := password.Generate(20, 2, 2, false, true) if err != nil { diff --git a/cmd/fleetctl/users_test.go b/cmd/fleetctl/users_test.go index e65cc88d3a..c3b43a5820 100644 --- a/cmd/fleetctl/users_test.go +++ b/cmd/fleetctl/users_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/csv" + "fmt" "math/big" "os" "strings" @@ -73,31 +74,57 @@ func TestUserCreateForcePasswordReset(t *testing.T) { ) error { return nil } + ds.UserByEmailFunc = func(ctx context.Context, email string) (*fleet.User, error) { + if email == "bar@example.com" { + apiOnlyUser := &fleet.User{ + ID: 1, + Email: email, + } + err := apiOnlyUser.SetPassword(pwd, 24, 10) + require.NoError(t, err) + return apiOnlyUser, nil + } + return nil, ¬FoundError{} + } + var apiOnlyUserSessionKey string + ds.NewSessionFunc = func(ctx context.Context, userID uint, sessionKey string) (*fleet.Session, error) { + apiOnlyUserSessionKey = sessionKey + return &fleet.Session{ + ID: 2, + UserID: userID, + Key: sessionKey, + }, nil + } for _, tc := range []struct { name string args []string expectedAdminForcePasswordReset bool + displaysToken bool }{ { name: "sso", args: []string{"--email", "foo@example.com", "--name", "foo", "--sso"}, expectedAdminForcePasswordReset: false, + displaysToken: false, }, { name: "api-only", args: []string{"--email", "bar@example.com", "--password", pwd, "--name", "bar", "--api-only"}, expectedAdminForcePasswordReset: false, + displaysToken: true, }, { name: "api-only-sso", args: []string{"--email", "baz@example.com", "--name", "baz", "--api-only", "--sso"}, expectedAdminForcePasswordReset: false, + displaysToken: false, }, { name: "non-sso-non-api-only", args: []string{"--email", "zoo@example.com", "--password", pwd, "--name", "zoo"}, expectedAdminForcePasswordReset: true, + displaysToken: false, }, } { ds.NewUserFuncInvoked = false @@ -106,10 +133,15 @@ func TestUserCreateForcePasswordReset(t *testing.T) { return user, nil } - require.Equal(t, "", runAppForTest(t, append( + stdout := runAppForTest(t, append( []string{"user", "create"}, tc.args..., - ))) + )) + if tc.displaysToken { + require.Equal(t, stdout, fmt.Sprintf("Success! The API token for your new user is: %s\n", apiOnlyUserSessionKey)) + } else { + require.Empty(t, stdout) + } require.True(t, ds.NewUserFuncInvoked) } } diff --git a/server/fleet/service.go b/server/fleet/service.go index 732202b139..bc97e3f7ea 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -106,7 +106,8 @@ type Service interface { CreateUserFromInvite(ctx context.Context, p UserPayload) (user *User, err error) // CreateUser allows an admin to create a new user without first creating and validating invite tokens. - CreateUser(ctx context.Context, p UserPayload) (user *User, err error) + // The sessionKey is only returned (not-nil) when creating API-only (non-SSO) users. + CreateUser(ctx context.Context, p UserPayload) (user *User, sessionKey *string, err error) // CreateInitialUser creates the first user, skipping authorization checks. If a user already exists this method // should fail. diff --git a/server/service/client_users.go b/server/service/client_users.go index aedf807f77..6845997515 100644 --- a/server/service/client_users.go +++ b/server/service/client_users.go @@ -8,11 +8,16 @@ import ( ) // CreateUser creates a new user, skipping the invitation process. -func (c *Client) CreateUser(p fleet.UserPayload) error { +// +// The session key (aka API token) is returned only when creating +// API only users. +func (c *Client) CreateUser(p fleet.UserPayload) (*string, error) { verb, path := "POST", "/api/latest/fleet/users/admin" var responseBody createUserResponse - - return c.authenticatedRequest(p, verb, path, &responseBody) + if err := c.authenticatedRequest(p, verb, path, &responseBody); err != nil { + return nil, err + } + return responseBody.Token, nil } // ListUsers retrieves the list of users. diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index a0c0fa916b..d445736523 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -281,6 +281,44 @@ func (s *integrationTestSuite) TestQueryCreationLogsActivity() { require.True(t, found) } +func (s *integrationTestSuite) TestCreatingAPIOnlyUserReturnsAPIToken() { + t := s.T() + + defer func() { + s.token = s.getTestAdminToken() + }() + + var createResp createUserResponse + params := fleet.UserPayload{ + Name: ptr.String("someadmin"), + Email: ptr.String("someadmin@example.com"), + Password: ptr.String(test.GoodPassword), + GlobalRole: ptr.String(fleet.RoleAdmin), + APIOnly: ptr.Bool(false), + } + s.DoJSON("POST", "/api/latest/fleet/users/admin", params, http.StatusOK, &createResp) + assert.NotZero(t, createResp.User.ID) + assert.Nil(t, createResp.Token) + + params = fleet.UserPayload{ + Name: ptr.String("apionly"), + Email: ptr.String("apionly@example.com"), + Password: ptr.String(test.GoodPassword), + GlobalRole: ptr.String(fleet.RoleObserver), + APIOnly: ptr.Bool(true), + // AdminForcedPasswordReset is set to false when creating api-only users via `fleetctl user create --api-only`. + AdminForcedPasswordReset: ptr.Bool(false), + } + s.DoJSON("POST", "/api/latest/fleet/users/admin", params, http.StatusOK, &createResp) + assert.NotZero(t, createResp.User.ID) + assert.NotNil(t, createResp.Token) + + s.token = *createResp.Token + var chr countHostsResponse + s.DoJSON("GET", "/api/latest/fleet/hosts/count", countHostsRequest{}, http.StatusOK, &chr) + assert.Equal(t, 0, chr.Count) +} + func (s *integrationTestSuite) TestActivityUserEmailPersistsAfterDeletion() { t := s.T() diff --git a/server/service/users.go b/server/service/users.go index 795fbb02d6..21a10432f7 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -34,38 +34,43 @@ type createUserRequest struct { type createUserResponse struct { User *fleet.User `json:"user,omitempty"` - Err error `json:"error,omitempty"` + // Token is only returned when creating API-only (non-SSO) users. + Token *string `json:"token,omitempty"` + Err error `json:"error,omitempty"` } func (r createUserResponse) error() error { return r.Err } func createUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*createUserRequest) - user, err := svc.CreateUser(ctx, req.UserPayload) + user, sessionKey, err := svc.CreateUser(ctx, req.UserPayload) if err != nil { return createUserResponse{Err: err}, nil } - return createUserResponse{User: user}, nil + return createUserResponse{ + User: user, + Token: sessionKey, + }, nil } -func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet.User, error) { +func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet.User, *string, error) { var teams []fleet.UserTeam if p.Teams != nil { teams = *p.Teams } if err := svc.authz.Authorize(ctx, &fleet.User{Teams: teams}, fleet.ActionWrite); err != nil { - return nil, err + return nil, nil, err } if err := p.VerifyAdminCreate(); err != nil { - return nil, ctxerr.Wrap(ctx, err, "verify user payload") + return nil, nil, ctxerr.Wrap(ctx, err, "verify user payload") } if teams != nil { // Validate that the teams exist teamsSummary, err := svc.ds.TeamsSummary(ctx) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "fetching teams in attempt to verify team exists") + return nil, nil, ctxerr.Wrap(ctx, err, "fetching teams in attempt to verify team exists") } teamIDs := map[uint]struct{}{} for _, team := range teamsSummary { @@ -74,7 +79,7 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet for _, userTeam := range teams { _, ok := teamIDs[userTeam.Team.ID] if !ok { - return nil, ctxerr.Wrap( + return nil, nil, ctxerr.Wrap( ctx, fleet.NewInvalidArgumentError("teams.id", fmt.Sprintf("team with id %d does not exist", userTeam.Team.ID)), ) } @@ -82,7 +87,7 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet } if invite, err := svc.ds.InviteByEmail(ctx, *p.Email); err == nil && invite != nil { - return nil, ctxerr.Errorf(ctx, "%s already invited", *p.Email) + return nil, nil, ctxerr.Errorf(ctx, "%s already invited", *p.Email) } if p.AdminForcedPasswordReset == nil { @@ -90,7 +95,28 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet p.AdminForcedPasswordReset = ptr.Bool(true) } - return svc.NewUser(ctx, p) + user, err := svc.NewUser(ctx, p) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "create user") + } + + // The sessionKey is returned for API-only non-SSO users only. + var sessionKey *string + if user.APIOnly && !user.SSOEnabled { + if p.Password == nil { + // Should not happen but let's log just in case. + level.Error(svc.logger).Log("err", err, "msg", "password not set during admin user creation") + } else { + // Create a session for the API-only user by logging in. + _, session, err := svc.Login(ctx, user.Email, *p.Password) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "create session for api-only user") + } + sessionKey = &session.Key + } + } + + return user, sessionKey, nil } //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/users_test.go b/server/service/users_test.go index d47e1bacd5..2f935d1c83 100644 --- a/server/service/users_test.go +++ b/server/service/users_test.go @@ -367,7 +367,7 @@ func TestUserAuth(t *testing.T) { } teams := []fleet.UserTeam{{Team: fleet.Team{ID: teamID}, Role: fleet.RoleMaintainer}} - _, err = svc.CreateUser(ctx, fleet.UserPayload{ + _, _, err = svc.CreateUser(ctx, fleet.UserPayload{ Name: ptr.String("Some Name"), Email: ptr.String("some@email.com"), Password: ptr.String(test.GoodPassword), @@ -375,7 +375,7 @@ func TestUserAuth(t *testing.T) { }) checkAuthErr(t, tt.shouldFailTeamWrite, err) - _, err = svc.CreateUser(ctx, fleet.UserPayload{ + _, _, err = svc.CreateUser(ctx, fleet.UserPayload{ Name: ptr.String("Some Name"), Email: ptr.String("some@email.com"), Password: ptr.String(test.GoodPassword), @@ -641,6 +641,7 @@ func TestUsersWithDS(t *testing.T) { {"CreateUserForcePasswdReset", testUsersCreateUserForcePasswdReset}, {"ChangePassword", testUsersChangePassword}, {"RequirePasswordReset", testUsersRequirePasswordReset}, + {"UsersCreateUserWithAPIOnly", testUsersCreateUserWithAPIOnly}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -668,13 +669,14 @@ func testUsersCreateUserForcePasswdReset(t *testing.T, ds *mysql.Datastore) { // As the admin, create a new user. ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) - user, err := svc.CreateUser(ctx, fleet.UserPayload{ + user, sessionKey, err := svc.CreateUser(ctx, fleet.UserPayload{ Name: ptr.String("Some Observer"), Email: ptr.String("some-observer@email.com"), Password: ptr.String(test.GoodPassword), GlobalRole: ptr.String(fleet.RoleObserver), }) require.NoError(t, err) + require.Nil(t, sessionKey) // only set when creating API-only users user, err = ds.UserByID(context.Background(), user.ID) require.NoError(t, err) @@ -1319,3 +1321,50 @@ func TestTeamAdminAddRoleOtherTeam(t *testing.T) { require.Equal(t, (&authz.Forbidden{}).Error(), err.Error()) require.False(t, ds.SaveUserFuncInvoked) } + +func testUsersCreateUserWithAPIOnly(t *testing.T, ds *mysql.Datastore) { + svc, ctx := newTestService(t, ds, nil, nil) + + host, err := ds.NewHost(ctx, &fleet.Host{ + UUID: "uuid-42", + OsqueryHostID: ptr.String("osquery_host_id-42"), + }) + require.NoError(t, err) + + // Create admin user. + admin := &fleet.User{ + Name: "Fleet Admin", + Email: "admin@foo.com", + GlobalRole: ptr.String(fleet.RoleAdmin), + } + err = admin.SetPassword(test.GoodPassword, 10, 10) + require.NoError(t, err) + admin, err = ds.NewUser(ctx, admin) + require.NoError(t, err) + + // As the admin, create a new API-only user. + ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) + apiOnlyUser, sessionKey, err := svc.CreateUser(ctx, fleet.UserPayload{ + Name: ptr.String("Some Observer"), + Email: ptr.String("some-observer@email.com"), + Password: ptr.String(test.GoodPassword), + GlobalRole: ptr.String(fleet.RoleObserver), + APIOnly: ptr.Bool(true), + }) + require.NoError(t, err) + require.NotNil(t, sessionKey) + require.NotEmpty(t, *sessionKey) + + sessions, err := svc.GetInfoAboutSessionsForUser(ctx, apiOnlyUser.ID) + require.NoError(t, err) + require.Len(t, sessions, 1) + session := sessions[0] + require.Equal(t, *sessionKey, session.Key) + + refreshCtx(t, ctx, apiOnlyUser, ds, session) + + hosts, err := svc.ListHosts(ctx, fleet.HostListOptions{}) + require.NoError(t, err) + require.Len(t, hosts, 1) + require.Equal(t, host.ID, hosts[0].ID) +} From db8e16bf6625538de2ed4cbeb28b75fc4b4feb08 Mon Sep 17 00:00:00 2001 From: Brock Walters <153771548+nonpunctual@users.noreply.github.com> Date: Thu, 13 Jun 2024 19:29:37 -0400 Subject: [PATCH 05/15] Create patches.yml (#19700) Create patches.yml per #16993 --------- Co-authored-by: Eric --- schema/tables/patches.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 schema/tables/patches.yml diff --git a/schema/tables/patches.yml b/schema/tables/patches.yml new file mode 100644 index 0000000000..2c8cfc4416 --- /dev/null +++ b/schema/tables/patches.yml @@ -0,0 +1,23 @@ +name: patches +description: |- # (required) string - The description for this table. Note: this field supports Markdown + The `patches` osquery table lists Windows security patch updates. +examples: |- # (optional) string - An example query for this table. Note: This field supports Markdown + Basic query: + + ``` + SELECT * FROM patches; + ``` + + This query determines if a specific hotfix patch is installed: + + ``` + SELECT * FROM patches WHERE hotfix_id='kb5037663'; + ``` +notes: |- # (optional) string - Notes about this table. Note: This field supports Markdown. + Microsoft creates a support page per hotfix patch. Support pages can be discovered by doing a web browser search for the hotfix ID string (e.g., KB5037663). + + Microsoft documentation for [KB5037663](https://support.microsoft.com/en-us/topic/may-29-2024-kb5037853-os-builds-22621-3672-and-22631-3672-preview-dcf14fd8-84d6-4234-9d5b-784c319cd7cf) + + The `patches` table does not include updates that are applied via Windows Installer / Microsoft Standard Installer packages (.msi) or updates downloaded directly from Windows Update (e.g., Service Packs). + + [Windows Installer](https://learn.microsoft.com/en-us/windows/win32/msi/windows-installer-portal) From dd73758ebc4014762b8364f2657d7c4ddf48d633 Mon Sep 17 00:00:00 2001 From: Brock Walters <153771548+nonpunctual@users.noreply.github.com> Date: Thu, 13 Jun 2024 19:46:41 -0400 Subject: [PATCH 06/15] Update software_update.yml (#19714) Updates to software_update per #16993 --- schema/tables/software_update.yml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/schema/tables/software_update.yml b/schema/tables/software_update.yml index 9ac1ab9025..b853acbd27 100644 --- a/schema/tables/software_update.yml +++ b/schema/tables/software_update.yml @@ -1,12 +1,29 @@ name: software_update +description: The `software_update` table displays the number of updates available from Apple's Software Update service on a Mac. platforms: - darwin -description: Information about available Apple software updates. +examples: |- + Basic query: + + ``` + SELECT * FROM software_update; + ``` columns: - name: software_update_required type: integer required: false description: |- - If true, means one of the Apple softwares installed on this machine has a new available upgrade. -notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). + A value of 0 means no updates are available. Any other integer represents the number of updates available. +notes: |- + This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). + + Available updates on a Mac can be displayed in the macOS Graphical User Interface (GUI) by clicking on the Apple menu and then selecting “System Settings”. In the System Settings.app, click General > Software Update. + + Apple Software Updates can also be listed in Terminal with the following command: + + ``` + softwareupdate --list --verbose + ``` + + [Update Your Apple Software](https://support.apple.com/guide/personal-safety/update-your-apple-software-ips4930e3486/web) evented: false From 9d453280b57722a8a30eccacd08d2e45b8c3089f Mon Sep 17 00:00:00 2001 From: Brock Walters <153771548+nonpunctual@users.noreply.github.com> Date: Thu, 13 Jun 2024 20:47:54 -0400 Subject: [PATCH 07/15] Update safari_extensions.yml (#19738) Update safari_extensions table per #16993 --- schema/tables/safari_extensions.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/schema/tables/safari_extensions.yml b/schema/tables/safari_extensions.yml index 68ab38ddd7..ea0d561b39 100644 --- a/schema/tables/safari_extensions.yml +++ b/schema/tables/safari_extensions.yml @@ -1,11 +1,25 @@ name: safari_extensions -description: Installed Safari browser extensions (plugins). +description: Safari extensions add functionality to Safari.app, the native web browser in macOS. The `safari_extensions` table collects all Safari extensions installed on a Mac. columns: - name: uid examples: |- + Collect Safari extensions for all Mac users: + ``` SELECT * FROM users CROSS JOIN safari_extensions USING (uid); ``` notes: |- - - Querying this table requires joining against the `users` table. [Learn more](https://fleetdm.com/guides/osquery-consider-joining-against-the-users-table) - - Includes installed extensions for all system users. + Because Safari data is intentionally isolated for each macOS user to maintain privacy, this query requires a `JOIN` operation. + + Query explanation: + + - The `safari_extensions` table has a row for each installed extension + - Each row has a column with the `uid` of the user who installed the extension + - Each `uid` from the `safari_extensions` table is matched in the `users` table to collect Safari extensions in the output data for all user accounts on the Mac by the `JOIN` + + Links: + + - Apple dcoumentaion on Safari Extensions: https://support.apple.com/en-us/102343 + - CROSS JOIN SQLite tutorial: https://www.sqlitetutorial.net/sqlite-cross-join/ + - [Fleet documentation on joining against the `users` table](https://fleetdm.com/guides/osquery-consider-joining-against-the-users-table) + - Fleet users table: https://fleetdm.com/tables/users From faa673634bd180c3e05a064798b6439a509ce8ce Mon Sep 17 00:00:00 2001 From: Brock Walters <153771548+nonpunctual@users.noreply.github.com> Date: Thu, 13 Jun 2024 20:50:07 -0400 Subject: [PATCH 08/15] Update programs.yml (#19742) Added link for the choclately_pacakages table --- schema/tables/programs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/schema/tables/programs.yml b/schema/tables/programs.yml index a3a3e2bccd..4bcd5aa70a 100644 --- a/schema/tables/programs.yml +++ b/schema/tables/programs.yml @@ -24,6 +24,7 @@ notes: |- # (optional) string - Notes about this table. Note: This field support - [Windows Installer](https://learn.microsoft.com/en-us/windows/win32/msi/windows-installer-portal) - [Chocolatey](https://chocolatey.org/) + - The Fleet `chocolatey_packages`[table](https://fleetdm.com/tables/chocolatey_packages) - [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/) - [winget.run](https://winget.run/) - Windows [cmd](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/cmd) From 93ba31ebef6ee7c21f1d9fd71dbfb851ed5526fc Mon Sep 17 00:00:00 2001 From: Brock Walters <153771548+nonpunctual@users.noreply.github.com> Date: Thu, 13 Jun 2024 23:01:05 -0400 Subject: [PATCH 09/15] Create scheduled_tasks.yml (#19739) Create scheduled_tasks table per #16993 --------- Co-authored-by: Eric --- schema/tables/scheduled_tasks.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 schema/tables/scheduled_tasks.yml diff --git a/schema/tables/scheduled_tasks.yml b/schema/tables/scheduled_tasks.yml new file mode 100644 index 0000000000..c20e612c09 --- /dev/null +++ b/schema/tables/scheduled_tasks.yml @@ -0,0 +1,13 @@ +name: scheduled_tasks +description: |- # (required) string - The description for this table. Note: this field supports Markdown + The Windows Task Scheduler tracks and performs automated tasks on a Windows device. The `scheduled_tasks` table collects the data from the Windows Task Scheduler. +examples: |- # (optional) string - An example query for this table. Note: This field supports Markdown + This query collects all tasks that are enabled but have not run: + + ``` + SELECT * FROM scheduled_tasks WHERE enabled='1' AND last_run_message='The task has not yet run.'; + ``` +notes: |- # (optional) string - Notes about this table. Note: This field supports Markdown. + Many automated tasks are added to the Task Scheduler by Windows itself, however, administrators can also customize the Task Scheduler. Scheduled tasks are analogous to Launch Daemons and Launch Agents used on Linux or macOS. Because automation is a potential vector for malicious activity, monitoring the Windows Task Scheduler may be critical in an enterprise environment. + + [Windows Task Scheduler](https://learn.microsoft.com/en-us/windows/win32/taskschd/about-the-task-scheduler) From 9cd452e8d54131ee2401abcbeadba56ebf12a77b Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 14 Jun 2024 06:40:49 -0500 Subject: [PATCH 10/15] Website: Update Vanta integration (#19349) Closes: https://github.com/fleetdm/confidential/issues/6069 Changes: - Added a new action to add support for the Vanta integration to be set up from a partners website. This action sets the required cookies provided via queryString and redirects users to the Vanta authorization page. - Updated the `create-vanta-authorization-request` action to redirect users who provide a `redirectToExternalPageAfterAuthorization` value the new endpoint instead of returning a vanta authorization URL. - Updated `view-vanta-authorization` to redirect users to the URL provided to the `create-vanta-authorization-request` endpoint (if one was provided) --- .../create-vanta-authorization-request.js | 30 ++++++--- .../redirect-vanta-authorization-request.js | 61 +++++++++++++++++++ .../controllers/view-vanta-authorization.js | 4 +- website/config/policies.js | 1 + website/config/routes.js | 3 +- 5 files changed, 88 insertions(+), 11 deletions(-) create mode 100644 website/api/controllers/redirect-vanta-authorization-request.js diff --git a/website/api/controllers/create-vanta-authorization-request.js b/website/api/controllers/create-vanta-authorization-request.js index 30fb4dbf15..8bb2c89c63 100644 --- a/website/api/controllers/create-vanta-authorization-request.js +++ b/website/api/controllers/create-vanta-authorization-request.js @@ -19,6 +19,10 @@ module.exports = { fleetApiKey: { type: 'string', required: true, + }, + redirectToExternalPageAfterAuthorization: { + type: 'string', + description: 'If provided, the user will be sent to this URL after they complete the setup of this integration' } }, @@ -59,7 +63,6 @@ module.exports = { }, fn: async function (inputs) { - let url = require('url'); // Look for any existing VantaConnection records that use this fleet instance URL. @@ -139,17 +142,26 @@ module.exports = { fleetApiKey: inputs.fleetApiKey, }); } - + let callbackUrl = `/vanta-authorization`; + if(inputs.redirectToExternalPageAfterAuthorization){ + callbackUrl += `?redirectAfterSetup=${inputs.redirectToExternalPageAfterAuthorization}`; + } // Build the authorization URL for this request. - let vantaAuthorizationRequestURL = `https://app.vanta.com/oauth/authorize?client_id=${encodeURIComponent(sails.config.custom.vantaAuthorizationClientId)}&scope=connectors.self:write-resource connectors.self:read-resource&state=${encodeURIComponent(generatedStateForThisRequest)}&source_id=${encodeURIComponent(sourceIDForThisRequest)}&redirect_uri=${encodeURIComponent(url.resolve(sails.config.custom.baseUrl, '/vanta-authorization'))}&response_type=code`; + let vantaAuthorizationRequestURL = `https://app.vanta.com/oauth/authorize?client_id=${encodeURIComponent(sails.config.custom.vantaAuthorizationClientId)}&scope=connectors.self:write-resource connectors.self:read-resource&state=${encodeURIComponent(generatedStateForThisRequest)}&source_id=${encodeURIComponent(sourceIDForThisRequest)}&redirect_uri=${encodeURIComponent(url.resolve(sails.config.custom.baseUrl, callbackUrl))}&response_type=code`; - // Set a `state` cookie on the user's browser. This value will be checked against a query parameter when the user returns to fleetdm.com. - this.res.cookie('state', generatedStateForThisRequest, {signed: true}); + if(inputs.redirectToExternalPageAfterAuthorization){ + let internalRedirectUrl = `${sails.config.custom.baseUrl}/redirect-vanta-authorization-request?vantaSourceId=${encodeURIComponent(sourceIDForThisRequest)}&state=${encodeURIComponent(generatedStateForThisRequest)}&vantaAuthorizationRequestURL=${encodeURIComponent(vantaAuthorizationRequestURL)}&redirectAfterSetup=${encodeURIComponent(inputs.redirectToExternalPageAfterAuthorization)}`; - // Set the sourceId to a cookie, we'll use this value to find the database record we created for this request when the user returns to fleetdm.com. - this.res.cookie('vantaSourceId', sourceIDForThisRequest, {signed: true}); - - return vantaAuthorizationRequestURL; + return internalRedirectUrl; + // If the useInternalRedirect input was provided, we'll return the URL of an internal endpoiint that will set the required cookies for this request. + } else { + // Otherwise, if this request came from a user on the connect-vanta page, we'll set the cookies are redirect them directly to Vanta. + // Set a `state` cookie on the user's browser. This value will be checked against a query parameter when the user returns to fleetdm.com. + this.res.cookie('state', generatedStateForThisRequest, {signed: true}); + // Set the sourceId to a cookie, we'll use this value to find the database record we created for this request when the user returns to fleetdm.com. + this.res.cookie('vantaSourceId', sourceIDForThisRequest, {signed: true}); + return vantaAuthorizationRequestURL; + } } diff --git a/website/api/controllers/redirect-vanta-authorization-request.js b/website/api/controllers/redirect-vanta-authorization-request.js new file mode 100644 index 0000000000..82e380342f --- /dev/null +++ b/website/api/controllers/redirect-vanta-authorization-request.js @@ -0,0 +1,61 @@ +module.exports = { + + + friendlyName: 'Redirect vanta authorization request', + + + description: 'Sets provided inputs in the user`s browser as cookies and redirects them to Vanta.', + + + inputs: { + vantaSourceId: { + type: 'string', + description: 'The generated vanta Source ID for this request.', + required: true, + }, + state: { + type: 'string', + description: 'The state provided to Vanta when an authorization request was created', + required: true, + }, + vantaAuthorizationRequestURL: { + type: 'string', + description: 'The Vanta authorization url that the user will be directed to after they are sent to this page.', + required: true, + }, + redirectAfterSetup: { + type: 'string', + description: 'The URL that the user will be redirected to after they complete setup.', + required: true, + } + }, + + + exits: { + noMatchingVantaConnection: { + description: 'No Vanta connection could be found using the provided vantaSourceId', + responseType: 'badRequest' + }, + }, + + + fn: async function ({vantaSourceId, state, vantaAuthorizationRequestURL, redirectAfterSetup}) { + + // Find the VantaConnection record that we created when the user created this request. + let recordOfThisAuthorization = await VantaConnection.findOne({vantaSourceId: vantaSourceId}); + + // If no record of this authorization could be found, return a noMatchingVantaConnection response. + if(!recordOfThisAuthorization){ + throw 'noMatchingVantaConnection'; + } + + // Set a 'state' and 'vantaSourceId' cookie on the users browser. + this.res.cookie('redirectAfterSetup', redirectAfterSetup, {signed: true}); + this.res.cookie('state', state, {signed: true}); + this.res.cookie('vantaSourceId', vantaSourceId, {signed: true}); + // now that the user has the required cookies to complete the vanta integration setup, redirect them to the provided VantaAuthorizationUrl. + return this.res.redirect(vantaAuthorizationRequestURL); + } + + +}; diff --git a/website/api/controllers/view-vanta-authorization.js b/website/api/controllers/view-vanta-authorization.js index 3fd465bb0b..cf6bb03afd 100644 --- a/website/api/controllers/view-vanta-authorization.js +++ b/website/api/controllers/view-vanta-authorization.js @@ -89,7 +89,9 @@ module.exports = { if(!updatedRecord){ throw new Error(`When trying to update a VantaConnection record (id: ${recordOfThisAuthorization.id}) with an authorization token from Vanta, the database record associated with this request has gone missing.`); } - + if(this.req.signedCookies.redirectAfterSetup){ + return this.res.redirect(this.req.signedCookies.redirectAfterSetup); + } return { showSuccessMessage: true }; diff --git a/website/config/policies.js b/website/config/policies.js index 2c46a7f477..5d4552459d 100644 --- a/website/config/policies.js +++ b/website/config/policies.js @@ -55,4 +55,5 @@ module.exports.policies = { 'deliver-talk-to-us-form-submission': true, 'get-human-interpretation-from-osquery-sql': true, 'customers/view-new-license': true, + 'redirect-vanta-authorization-request': true, }; diff --git a/website/config/routes.js b/website/config/routes.js index 7814ed055c..7a07552257 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -589,7 +589,8 @@ module.exports.routes = { 'POST /api/v1/create-or-update-one-newsletter-subscription': { action: 'create-or-update-one-newsletter-subscription' }, '/api/v1/unsubscribe-from-all-newsletters': { action: 'unsubscribe-from-all-newsletters' }, 'POST /api/v1/admin/build-license-key': { action: 'admin/build-license-key' }, - 'POST /api/v1/create-vanta-authorization-request': { action: 'create-vanta-authorization-request' }, + 'POST /api/v1/create-vanta-authorization-request': { action: 'create-vanta-authorization-request', csrf: false }, + 'GET /redirect-vanta-authorization-request': { action: 'redirect-vanta-authorization-request' }, 'POST /api/v1/deliver-mdm-beta-signup': { action: 'deliver-mdm-beta-signup' }, 'POST /api/v1/get-human-interpretation-from-osquery-sql': { action: 'get-human-interpretation-from-osquery-sql', csrf: false }, 'POST /api/v1/deliver-apple-csr ': { action: 'deliver-apple-csr', csrf: false}, From 8b84b06a86785af0fb4c0d40a835b8fce8a11130 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Fri, 14 Jun 2024 06:58:17 -0500 Subject: [PATCH 11/15] /api/latest/fleet/hosts/:id/lock returns `unlock_pin` for Apple hosts (#19720) /api/latest/fleet/hosts/:id/lock returns `unlock_pin` for Apple hosts #19545 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- changes/19545-unlock-pin | 2 + cmd/fleetctl/mdm_test.go | 43 +--------- ee/server/service/hosts.go | 84 ++++++++++++------- ee/server/service/mdm.go | 2 +- server/datastore/mysql/apple_mdm_test.go | 6 +- server/datastore/mysql/microsoft_mdm.go | 3 +- server/datastore/mysql/scripts.go | 23 +++-- server/datastore/mysql/scripts_test.go | 2 + server/fleet/apple_mdm.go | 2 +- server/fleet/scripts.go | 4 +- server/fleet/service.go | 2 +- server/mdm/apple/commander.go | 17 ++-- server/mdm/apple/commander_test.go | 3 +- server/service/hosts_test.go | 4 +- .../service/integration_mdm_lifecycle_test.go | 8 +- server/service/integration_mdm_test.go | 8 +- server/service/scripts.go | 21 +++-- 17 files changed, 128 insertions(+), 106 deletions(-) create mode 100644 changes/19545-unlock-pin diff --git a/changes/19545-unlock-pin b/changes/19545-unlock-pin new file mode 100644 index 0000000000..ee0f715202 --- /dev/null +++ b/changes/19545-unlock-pin @@ -0,0 +1,2 @@ +* /api/latest/fleet/hosts/:id/lock returns `unlock_pin` for Apple hosts +* UI no longer uses unlock pending state for Apple hosts diff --git a/cmd/fleetctl/mdm_test.go b/cmd/fleetctl/mdm_test.go index 117a863f7b..b0e74d647c 100644 --- a/cmd/fleetctl/mdm_test.go +++ b/cmd/fleetctl/mdm_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" "slices" @@ -361,13 +362,6 @@ func TestMDMLockCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } - macEnrolledUP := &fleet.Host{ - ID: 9, - UUID: "mac-enrolled-up", - Platform: "darwin", - MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, - MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, - } winEnrolledLP := &fleet.Host{ ID: 10, @@ -409,7 +403,6 @@ func TestMDMLockCommand(t *testing.T) { macPending, winPending, winEnrolledUP, - macEnrolledUP, winEnrolledLP, macEnrolledLP, winEnrolledWP, @@ -421,7 +414,6 @@ func TestMDMLockCommand(t *testing.T) { unlockPending := map[uint]*fleet.Host{ winEnrolledUP.ID: winEnrolledUP, - macEnrolledUP.ID: macEnrolledUP, } lockPending := map[uint]*fleet.Host{ @@ -446,9 +438,7 @@ func TestMDMLockCommand(t *testing.T) { if _, ok := unlockPending[host.ID]; ok { if fleetPlatform == "darwin" { - status.UnlockPIN = "1234" - status.UnlockRequestedAt = time.Now() - return &status, nil + return nil, errors.New("apple devices do not have an unlock pending state") } status.UnlockScript = &fleet.HostScriptResult{} @@ -542,7 +532,6 @@ fleetctl mdm unlock --host=%s {appCfgWinMDM, "valid windows but pending ", []string{"--host", winPending.UUID}, `Can't lock the host because it doesn't have MDM turned on.`}, {appCfgMacMDM, "valid macos but pending", []string{"--host", macPending.UUID}, `Can't lock the host because it doesn't have MDM turned on.`}, {appCfgAllMDM, "valid windows but pending unlock", []string{"--host", winEnrolledUP.UUID}, "Host has pending unlock request."}, - {appCfgAllMDM, "valid macos but pending unlock", []string{"--host", macEnrolledUP.UUID}, "Host has pending unlock request."}, {appCfgAllMDM, "valid windows but pending lock", []string{"--host", winEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid macos but pending lock", []string{"--host", macEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid windows but pending wipe", []string{"--host", winEnrolledWP.UUID}, "Host has pending wipe request."}, @@ -603,13 +592,6 @@ func TestMDMUnlockCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } - macEnrolledUP := &fleet.Host{ - ID: 9, - UUID: "mac-enrolled-up", - Platform: "darwin", - MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, - MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, - } winEnrolledLP := &fleet.Host{ ID: 10, UUID: "win-enrolled-lp", @@ -650,7 +632,6 @@ func TestMDMUnlockCommand(t *testing.T) { macPending, winPending, winEnrolledUP, - macEnrolledUP, winEnrolledLP, macEnrolledLP, winEnrolledWP, @@ -667,7 +648,6 @@ func TestMDMUnlockCommand(t *testing.T) { unlockPending := map[uint]*fleet.Host{ winEnrolledUP.ID: winEnrolledUP, - macEnrolledUP.ID: macEnrolledUP, } lockPending := map[uint]*fleet.Host{ @@ -701,9 +681,7 @@ func TestMDMUnlockCommand(t *testing.T) { if _, ok := unlockPending[host.ID]; ok { if fleetPlatform == "darwin" { - status.UnlockPIN = "1234" - status.UnlockRequestedAt = time.Now() - return &status, nil + return nil, errors.New("apple devices do not have an unlock pending state") } status.UnlockScript = &fleet.HostScriptResult{} @@ -800,7 +778,6 @@ fleetctl get host %s {appCfgWinMDM, "valid windows but pending mdm enroll", []string{"--host", winPending.UUID}, `Can't unlock the host because it doesn't have MDM turned on.`}, {appCfgMacMDM, "valid macos but pending mdm enroll", []string{"--host", macPending.UUID}, `Can't unlock the host because it doesn't have MDM turned on.`}, {appCfgAllMDM, "valid windows but pending unlock", []string{"--host", winEnrolledUP.UUID}, "Host has pending unlock request."}, - {appCfgAllMDM, "valid macos but pending unlock", []string{"--host", macEnrolledUP.UUID}, ""}, {appCfgAllMDM, "valid windows but pending lock", []string{"--host", winEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid macos but pending lock", []string{"--host", macEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid windows but pending wipe", []string{"--host", winEnrolledWP.UUID}, "Host has pending wipe request."}, @@ -856,13 +833,6 @@ func TestMDMWipeCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } - macEnrolledUP := &fleet.Host{ - ID: 9, - UUID: "mac-enrolled-up", - Platform: "darwin", - MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, - MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, - } winEnrolledLP := &fleet.Host{ ID: 10, UUID: "win-enrolled-lp", @@ -950,7 +920,6 @@ func TestMDMWipeCommand(t *testing.T) { macPending, winPending, winEnrolledUP, - macEnrolledUP, winEnrolledLP, macEnrolledLP, winEnrolledWP, @@ -971,7 +940,6 @@ func TestMDMWipeCommand(t *testing.T) { unlockPending := map[uint]*fleet.Host{ winEnrolledUP.ID: winEnrolledUP, - macEnrolledUP.ID: macEnrolledUP, } lockPending := map[uint]*fleet.Host{ @@ -1010,9 +978,7 @@ func TestMDMWipeCommand(t *testing.T) { if _, ok := unlockPending[host.ID]; ok { if fleetPlatform == "darwin" { - status.UnlockPIN = "1234" - status.UnlockRequestedAt = time.Now() - return &status, nil + return nil, errors.New("apple devices do not have an unlock pending state") } status.UnlockScript = &fleet.HostScriptResult{} @@ -1129,7 +1095,6 @@ func TestMDMWipeCommand(t *testing.T) { {appCfgWinMDM, "valid windows but pending mdm enroll", []string{"--host", winPending.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, {appCfgMacMDM, "valid macos but pending mdm enroll", []string{"--host", macPending.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, {appCfgAllMDM, "valid windows but pending unlock", []string{"--host", winEnrolledUP.UUID}, "Host has pending unlock request."}, - {appCfgAllMDM, "valid macos but pending unlock", []string{"--host", macEnrolledUP.UUID}, "Host has pending unlock request."}, {appCfgAllMDM, "valid windows but pending lock", []string{"--host", winEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid macos but pending lock", []string{"--host", macEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid windows but pending wipe", []string{"--host", winEnrolledWP.UUID}, "Host has pending wipe request."}, diff --git a/ee/server/service/hosts.go b/ee/server/service/hosts.go index e6833ee5a2..a4c0a5bd06 100644 --- a/ee/server/service/hosts.go +++ b/ee/server/service/hosts.go @@ -38,22 +38,22 @@ func (svc *Service) OSVersion(ctx context.Context, osID uint, teamID *uint, incl return svc.Service.OSVersion(ctx, osID, teamID, true) } -func (svc *Service) LockHost(ctx context.Context, hostID uint) error { +func (svc *Service) LockHost(ctx context.Context, hostID uint) (unlockPIN string, err error) { // First ensure the user has access to list hosts, then check the specific // host once team_id is loaded. if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { - return err + return "", err } host, err := svc.ds.HostLite(ctx, hostID) if err != nil { - return ctxerr.Wrap(ctx, err, "get host lite") + return "", ctxerr.Wrap(ctx, err, "get host lite") } // Authorize again with team loaded now that we have the host's team_id. // Authorize as "execute mdm_command", which is the correct access // requirement and is what happens for macOS platforms. if err := svc.authz.Authorize(ctx, fleet.MDMCommandAuthz{TeamID: host.TeamID}, fleet.ActionWrite); err != nil { - return err + return "", err } // locking validations are based on the platform of the host @@ -63,19 +63,23 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { if errors.Is(err, fleet.ErrMDMNotConfigured) { err = fleet.NewInvalidArgumentError("host_id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) } - return ctxerr.Wrap(ctx, err, "check macOS MDM enabled") + return "", ctxerr.Wrap(ctx, err, "check macOS MDM enabled") } // on macOS, the lock command requires the host to be MDM-enrolled in Fleet hostMDM, err := svc.ds.GetHostMDM(ctx, host.ID) if err != nil { if fleet.IsNotFound(err) { - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock the host because it doesn't have MDM turned on.")) + return "", ctxerr.Wrap( + ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock the host because it doesn't have MDM turned on."), + ) } - return ctxerr.Wrap(ctx, err, "get host MDM information") + return "", ctxerr.Wrap(ctx, err, "get host MDM information") } if !hostMDM.IsFleetEnrolled() { - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock the host because it doesn't have MDM turned on.")) + return "", ctxerr.Wrap( + ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock the host because it doesn't have MDM turned on."), + ) } case "windows", "linux": @@ -84,27 +88,30 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { if errors.Is(err, fleet.ErrMDMNotConfigured) { err = fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) } - return ctxerr.Wrap(ctx, err, "check windows MDM enabled") + return "", ctxerr.Wrap(ctx, err, "check windows MDM enabled") } } // on windows and linux, a script is used to lock the host so scripts must // be enabled appCfg, err := svc.ds.AppConfig(ctx) if err != nil { - return ctxerr.Wrap(ctx, err, "get app config") + return "", ctxerr.Wrap(ctx, err, "get app config") } if appCfg.ServerSettings.ScriptsDisabled { - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock host because running scripts is disabled in organization settings.")) + return "", ctxerr.Wrap( + ctx, + fleet.NewInvalidArgumentError("host_id", "Can't lock host because running scripts is disabled in organization settings."), + ) } hostOrbitInfo, err := svc.ds.GetHostOrbitInfo(ctx, host.ID) switch { case err != nil: // If not found, then do nothing. We do not know if this host has scripts enabled or not if !fleet.IsNotFound(err) { - return ctxerr.Wrap(ctx, err, "get host orbit info") + return "", ctxerr.Wrap(ctx, err, "get host orbit info") } case hostOrbitInfo.ScriptsEnabled != nil && !*hostOrbitInfo.ScriptsEnabled: - return ctxerr.Wrap( + return "", ctxerr.Wrap( ctx, fleet.NewInvalidArgumentError( "host_id", "Couldn't lock host. To lock, deploy the fleetd agent with --enable-scripts and refetch host vitals.", ), @@ -112,26 +119,37 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { } default: - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform))) + return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform))) } // if there's a lock, unlock or wipe action pending, do not accept the lock // request. lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host) if err != nil { - return ctxerr.Wrap(ctx, err, "get host lock/wipe status") + return "", ctxerr.Wrap(ctx, err, "get host lock/wipe status") } switch { case lockWipe.IsPendingLock(): - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending lock request. The host will lock when it comes online.")) + return "", ctxerr.Wrap( + ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending lock request. The host will lock when it comes online."), + ) case lockWipe.IsPendingUnlock(): - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending unlock request. Host cannot be locked again until unlock is complete.")) + return "", ctxerr.Wrap( + ctx, fleet.NewInvalidArgumentError( + "host_id", "Host has pending unlock request. Host cannot be locked again until unlock is complete.", + ), + ) case lockWipe.IsPendingWipe(): - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending wipe request. Cannot process lock requests once host is wiped.")) + return "", ctxerr.Wrap( + ctx, + fleet.NewInvalidArgumentError("host_id", "Host has pending wipe request. Cannot process lock requests once host is wiped."), + ) case lockWipe.IsWiped(): - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is wiped. Cannot process lock requests once host is wiped.")) + return "", ctxerr.Wrap( + ctx, fleet.NewInvalidArgumentError("host_id", "Host is wiped. Cannot process lock requests once host is wiped."), + ) case lockWipe.IsLocked(): - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is already locked.").WithStatus(http.StatusConflict)) + return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is already locked.").WithStatus(http.StatusConflict)) } // all good, go ahead with queuing the lock request. @@ -331,19 +349,21 @@ func (svc *Service) WipeHost(ctx context.Context, hostID uint) error { return svc.enqueueWipeHostRequest(ctx, host, lockWipe) } -func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host, lockStatus *fleet.HostLockWipeStatus) error { +func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host, lockStatus *fleet.HostLockWipeStatus) ( + unlockPIN string, err error, +) { vc, ok := viewer.FromContext(ctx) if !ok { - return fleet.ErrNoContext + return "", fleet.ErrNoContext } if lockStatus.HostFleetPlatform == "darwin" { lockCommandUUID := uuid.NewString() - if err := svc.mdmAppleCommander.DeviceLock(ctx, host, lockCommandUUID); err != nil { - return ctxerr.Wrap(ctx, err, "enqueuing lock request for darwin") + if unlockPIN, err = svc.mdmAppleCommander.DeviceLock(ctx, host, lockCommandUUID); err != nil { + return "", ctxerr.Wrap(ctx, err, "enqueuing lock request for darwin") } - if err := svc.NewActivity( + if err = svc.NewActivity( ctx, vc.User, fleet.ActivityTypeLockedHost{ @@ -351,10 +371,10 @@ func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host HostDisplayName: host.DisplayName(), }, ); err != nil { - return ctxerr.Wrap(ctx, err, "create activity for darwin lock host request") + return "", ctxerr.Wrap(ctx, err, "create activity for darwin lock host request") } - return nil + return unlockPIN, nil } script := windowsLockScript @@ -374,7 +394,7 @@ func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host UserID: &vc.User.ID, SyncRequest: false, }, host.FleetPlatform()); err != nil { - return err + return "", err } if err := svc.NewActivity( @@ -385,10 +405,10 @@ func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host HostDisplayName: host.DisplayName(), }, ); err != nil { - return ctxerr.Wrap(ctx, err, "create activity for lock host request") + return "", ctxerr.Wrap(ctx, err, "create activity for lock host request") } - return nil + return "", nil } func (svc *Service) enqueueUnlockHostRequest(ctx context.Context, host *fleet.Host, lockStatus *fleet.HostLockWipeStatus) (string, error) { @@ -399,7 +419,9 @@ func (svc *Service) enqueueUnlockHostRequest(ctx context.Context, host *fleet.Ho var unlockPIN string if lockStatus.HostFleetPlatform == "darwin" { - // record the unlock request if it was not already recorded + // Record the unlock request time if it was not already recorded. + // It should be always recorded, since the UnlockRequestedAt time is created after the lock command is acknowledged. + // This code is left here to catch potential issues. if lockStatus.UnlockRequestedAt.IsZero() { if err := svc.ds.UnlockHostManually(ctx, host.ID, host.FleetPlatform(), time.Now().UTC()); err != nil { return "", err diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 7af4d4f9b8..890371700f 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -135,7 +135,7 @@ func (svc *Service) MDMAppleDeviceLock(ctx context.Context, hostID uint) error { return err } - err = svc.mdmAppleCommander.DeviceLock(ctx, host, uuid.New().String()) + _, err = svc.mdmAppleCommander.DeviceLock(ctx, host, uuid.New().String()) if err != nil { return err } diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 4d2baf7dba..5240581e9b 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -4603,14 +4603,14 @@ func testLockUnlockWipeMacOS(t *testing.T, ds *Datastore) { require.NoError(t, err) checkLockWipeState(t, status, false, true, false, false, false, false) - // request an unlock, to make it pending unlock + // request an unlock. This is a NOOP for Apple MDM. err = ds.UnlockHostManually(ctx, host.ID, host.FleetPlatform(), time.Now().UTC()) require.NoError(t, err) - // it is now locked pending unlock + // it is still locked status, err = ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) - checkLockWipeState(t, status, false, true, false, true, false, false) + checkLockWipeState(t, status, false, true, false, false, false, false) // execute CleanMacOSMDMLock to simulate successful unlock err = ds.CleanMacOSMDMLock(ctx, host.UUID) diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index f8035b1bd2..eaf8a4b4ff 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -359,7 +359,8 @@ ON DUPLICATE KEY UPDATE // if we received a Wipe command result, update the host's status if wipeCmdUUID != "" { if err := updateHostLockWipeStatusFromResultAndHostUUID(ctx, tx, enrollment.HostUUID, - "wipe_ref", wipeCmdUUID, strings.HasPrefix(wipeCmdStatus, "2")); err != nil { + "wipe_ref", wipeCmdUUID, strings.HasPrefix(wipeCmdStatus, "2"), false, + ); err != nil { return ctxerr.Wrap(ctx, err, "updating wipe command result in host_mdm_actions") } } diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go index b89155a4f8..efac957e3c 100644 --- a/server/datastore/mysql/scripts.go +++ b/server/datastore/mysql/scripts.go @@ -1023,7 +1023,7 @@ func (ds *Datastore) UnlockHostManually(ctx context.Context, hostID uint, hostFl return ctxerr.Wrap(ctx, err, "record manual unlock host request") } -func buildHostLockWipeStatusUpdateStmt(refCol string, succeeded bool, joinPart string) string { +func buildHostLockWipeStatusUpdateStmt(refCol string, succeeded bool, joinPart string, setUnlockRef bool) string { var alias string stmt := `UPDATE host_mdm_actions ` @@ -1039,7 +1039,14 @@ func buildHostLockWipeStatusUpdateStmt(refCol string, succeeded bool, joinPart s // Note that this must not clear the unlock_pin, because recording the // lock request does generate the PIN and store it there to be used by an // eventual unlock. - stmt += fmt.Sprintf("%sunlock_ref = NULL, %[1]swipe_ref = NULL", alias) + if !setUnlockRef { + // Currently only used for Apple MDM devices. + // We set the unlock_ref to current time since the device can be unlocked any time after the lock. + // Apple MDM does not have a concept of unlock pending. + stmt += fmt.Sprintf("%sunlock_ref = NULL, %[1]swipe_ref = NULL", alias) + } else { + stmt += fmt.Sprintf("%sunlock_ref = '%s', %[1]swipe_ref = NULL", alias, time.Now().Format(time.DateTime)) + } case "unlock_ref": // a successful unlock clears itself as well as the lock ref, because // unlock is the default state so we don't need to keep its unlock_ref @@ -1061,26 +1068,30 @@ func (ds *Datastore) UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Cont // a bit of MDM protocol leaking in the mysql layer, but it's either that or // the other way around (MDM protocol would translate to database column) var refCol string + var setUnlockRef bool switch requestType { case "EraseDevice": refCol = "wipe_ref" case "DeviceLock": refCol = "lock_ref" + setUnlockRef = true default: return nil } - return updateHostLockWipeStatusFromResultAndHostUUID(ctx, ds.writer(ctx), hostUUID, refCol, cmdUUID, succeeded) + return updateHostLockWipeStatusFromResultAndHostUUID(ctx, ds.writer(ctx), hostUUID, refCol, cmdUUID, succeeded, setUnlockRef) } -func updateHostLockWipeStatusFromResultAndHostUUID(ctx context.Context, tx sqlx.ExtContext, hostUUID, refCol, cmdUUID string, succeeded bool) error { - stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, `JOIN hosts h ON hma.host_id = h.id`) +func updateHostLockWipeStatusFromResultAndHostUUID( + ctx context.Context, tx sqlx.ExtContext, hostUUID, refCol, cmdUUID string, succeeded bool, setUnlockRef bool, +) error { + stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, `JOIN hosts h ON hma.host_id = h.id`, setUnlockRef) stmt += ` WHERE h.uuid = ? AND hma.` + refCol + ` = ?` _, err := tx.ExecContext(ctx, stmt, hostUUID, cmdUUID) return ctxerr.Wrap(ctx, err, "update host lock/wipe status from result via host uuid") } func updateHostLockWipeStatusFromResult(ctx context.Context, tx sqlx.ExtContext, hostID uint, refCol string, succeeded bool) error { - stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, "") + stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, "", false) stmt += ` WHERE host_id = ?` _, err := tx.ExecContext(ctx, stmt, hostID) return ctxerr.Wrap(ctx, err, "update host lock/wipe status from result") diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go index 09736a0dd7..0dbb22e1f4 100644 --- a/server/datastore/mysql/scripts_test.go +++ b/server/datastore/mysql/scripts_test.go @@ -15,6 +15,7 @@ import ( "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -764,6 +765,7 @@ func testLockHostViaScript(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, "windows", status.HostFleetPlatform) require.NotNil(t, status.LockScript) + assert.Nil(t, status.UnlockScript) s := status.LockScript require.Equal(t, script, s.ScriptContents) diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 5691650265..96b0e89a18 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -18,7 +18,7 @@ import ( type MDMAppleCommandIssuer interface { InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string) error RemoveProfile(ctx context.Context, hostUUIDs []string, identifier string, uuid string) error - DeviceLock(ctx context.Context, host *Host, uuid string) error + DeviceLock(ctx context.Context, host *Host, uuid string) (unlockPIN string, err error) EraseDevice(ctx context.Context, host *Host, uuid string) error InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error } diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 9d3d612e12..5497591e4f 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -412,8 +412,8 @@ func (s *HostLockWipeStatus) IsPendingLock() bool { func (s HostLockWipeStatus) IsPendingUnlock() bool { if s.HostFleetPlatform == "darwin" { - // pending unlock if an unlock was requested - return !s.UnlockRequestedAt.IsZero() + // Apple MDM does not have a concept of pending unlock. + return false } // pending unlock if script execution request is queued but no result yet return s.UnlockScript != nil && s.UnlockScript.ExitCode == nil diff --git a/server/fleet/service.go b/server/fleet/service.go index bc97e3f7ea..4707054dfb 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1041,7 +1041,7 @@ type Service interface { BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []ScriptPayload, dryRun bool) error // Script-based methods (at least for some platforms, MDM-based for others) - LockHost(ctx context.Context, hostID uint) error + LockHost(ctx context.Context, hostID uint) (unlockPIN string, err error) UnlockHost(ctx context.Context, hostID uint) (unlockPIN string, err error) WipeHost(ctx context.Context, hostID uint) error diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index 0afb0666b2..fbacedfe4a 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -90,8 +90,8 @@ func (svc *MDMAppleCommander) RemoveProfile(ctx context.Context, hostUUIDs []str return ctxerr.Wrap(ctx, err, "commander remove profile") } -func (svc *MDMAppleCommander) DeviceLock(ctx context.Context, host *fleet.Host, uuid string) error { - pin := GenerateRandomPin(6) +func (svc *MDMAppleCommander) DeviceLock(ctx context.Context, host *fleet.Host, uuid string) (unlockPIN string, err error) { + unlockPIN = GenerateRandomPin(6) raw := fmt.Sprintf(` @@ -106,22 +106,23 @@ func (svc *MDMAppleCommander) DeviceLock(ctx context.Context, host *fleet.Host, %s -`, uuid, pin) +`, uuid, unlockPIN, + ) cmd, err := mdm.DecodeCommand([]byte(raw)) if err != nil { - return ctxerr.Wrap(ctx, err, "decoding command") + return "", ctxerr.Wrap(ctx, err, "decoding command") } - if err := svc.storage.EnqueueDeviceLockCommand(ctx, host, cmd, pin); err != nil { - return ctxerr.Wrap(ctx, err, "enqueuing for DeviceLock") + if err := svc.storage.EnqueueDeviceLockCommand(ctx, host, cmd, unlockPIN); err != nil { + return "", ctxerr.Wrap(ctx, err, "enqueuing for DeviceLock") } if err := svc.sendNotifications(ctx, []string{host.UUID}); err != nil { - return ctxerr.Wrap(ctx, err, "sending notifications for DeviceLock") + return "", ctxerr.Wrap(ctx, err, "sending notifications for DeviceLock") } - return nil + return unlockPIN, nil } func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, host *fleet.Host, uuid string) error { diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go index 361aece383..5978944b52 100644 --- a/server/mdm/apple/commander_test.go +++ b/server/mdm/apple/commander_test.go @@ -132,8 +132,9 @@ func TestMDMAppleCommander(t *testing.T) { require.Len(t, pin, 6) return nil } - err = cmdr.DeviceLock(ctx, host, cmdUUID) + pin, err := cmdr.DeviceLock(ctx, host, cmdUUID) require.NoError(t, err) + require.Len(t, pin, 6) require.True(t, mdmStorage.EnqueueDeviceLockCommandFuncInvoked) mdmStorage.EnqueueDeviceLockCommandFuncInvoked = false require.True(t, mdmStorage.RetrievePushInfoFuncInvoked) diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index bc07f5d7d2..65dec1c23f 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -1648,9 +1648,9 @@ func TestLockUnlockWipeHostAuth(t *testing.T) { } ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) - err := svc.LockHost(ctx, globalHostID) + _, err := svc.LockHost(ctx, globalHostID) checkAuthErr(t, tt.shouldFailGlobalWrite, err) - err = svc.LockHost(ctx, teamHostID) + _, err = svc.LockHost(ctx, teamHostID) checkAuthErr(t, tt.shouldFailTeamWrite, err) // Pretend we locked the host diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index 75fad197f0..72bcb19bf0 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "encoding/xml" "fmt" + "github.com/stretchr/testify/assert" "net/http" "os" "path/filepath" @@ -73,12 +74,15 @@ func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsApple() { { "locked host turns on MDM", func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) { - s.Do( + var resp lockHostResponse + s.DoJSON( "POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, - http.StatusNoContent, + http.StatusOK, + &resp, ) + assert.Len(t, resp.UnlockPIN, 6) cmd, err := device.Idle() require.NoError(t, err) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index e61a5e2ad7..408b6964bb 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -8038,7 +8038,9 @@ func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() { s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusConflict, &unlockResp) // lock the host - s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusNoContent) + var lockResp lockHostResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusOK, &lockResp) + assert.Len(t, lockResp.UnlockPIN, 6) // refresh the host's status, it is now pending lock s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) @@ -8084,12 +8086,12 @@ func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() { unlockActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeUnlockedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "host_platform": %q}`, host.ID, host.DisplayName(), host.FleetPlatform()), 0) - // refresh the host's status, it is locked pending unlock + // refresh the host's status, it is still locked s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus) require.NotNil(t, getHostResp.Host.MDM.PendingAction) - require.Equal(t, "unlock", *getHostResp.Host.MDM.PendingAction) + assert.Empty(t, *getHostResp.Host.MDM.PendingAction) // try unlocking the host again simply returns the PIN again unlockResp = unlockHostResponse{} diff --git a/server/service/scripts.go b/server/service/scripts.go index 4bf2d85297..b9d8fec23a 100644 --- a/server/service/scripts.go +++ b/server/service/scripts.go @@ -915,26 +915,37 @@ type lockHostRequest struct { } type lockHostResponse struct { - Err error `json:"error,omitempty"` + Err error `json:"error,omitempty"` + UnlockPIN string `json:"unlock_pin,omitempty"` + StatusCode int `json:"-"` } -func (r lockHostResponse) Status() int { return http.StatusNoContent } +func (r lockHostResponse) Status() int { + if r.StatusCode != 0 { + return r.StatusCode + } + return http.StatusNoContent +} func (r lockHostResponse) error() error { return r.Err } func lockHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*lockHostRequest) - if err := svc.LockHost(ctx, req.HostID); err != nil { + unlockPIN, err := svc.LockHost(ctx, req.HostID) + if err != nil { return lockHostResponse{Err: err}, nil } + if unlockPIN != "" { + return lockHostResponse{UnlockPIN: unlockPIN, StatusCode: http.StatusOK}, nil + } return lockHostResponse{}, nil } -func (svc *Service) LockHost(ctx context.Context, hostID uint) error { +func (svc *Service) LockHost(ctx context.Context, hostID uint) (string, error) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) - return fleet.ErrMissingLicense + return "", fleet.ErrMissingLicense } //////////////////////////////////////////////////////////////////////////////// From 8921cfe53745ee1f8a4add1ba0f92fe00dd6f853 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Fri, 14 Jun 2024 07:08:54 -0500 Subject: [PATCH 12/15] Code review fixes. (#19755) Fixes from #19720 code review --- server/datastore/mysql/scripts.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go index efac957e3c..67c223f01f 100644 --- a/server/datastore/mysql/scripts.go +++ b/server/datastore/mysql/scripts.go @@ -1040,11 +1040,11 @@ func buildHostLockWipeStatusUpdateStmt(refCol string, succeeded bool, joinPart s // lock request does generate the PIN and store it there to be used by an // eventual unlock. if !setUnlockRef { + stmt += fmt.Sprintf("%sunlock_ref = NULL, %[1]swipe_ref = NULL", alias) + } else { // Currently only used for Apple MDM devices. // We set the unlock_ref to current time since the device can be unlocked any time after the lock. // Apple MDM does not have a concept of unlock pending. - stmt += fmt.Sprintf("%sunlock_ref = NULL, %[1]swipe_ref = NULL", alias) - } else { stmt += fmt.Sprintf("%sunlock_ref = '%s', %[1]swipe_ref = NULL", alias, time.Now().Format(time.DateTime)) } case "unlock_ref": From f62d5eda2083c6b10d233c9a7419e0f8d3f98717 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Fri, 14 Jun 2024 11:08:49 -0300 Subject: [PATCH 13/15] use Fleet instead of FleetDM in certificates (#19748) for #18427 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Added/updated tests - [ ] Manual QA for all new/changed functionality --- changes/18427-cert-names | 1 + server/datastore/mysql/mdm_test.go | 2 +- server/datastore/mysql/scep_test.go | 2 +- server/mdm/apple/apple_mdm.go | 6 +++--- server/mdm/apple/cert.go | 4 ++-- server/service/integration_mdm_test.go | 6 +++--- server/worker/macos_setup_assistant_test.go | 2 +- 7 files changed, 12 insertions(+), 11 deletions(-) create mode 100644 changes/18427-cert-names diff --git a/changes/18427-cert-names b/changes/18427-cert-names new file mode 100644 index 0000000000..f5f9bea1d8 --- /dev/null +++ b/changes/18427-cert-names @@ -0,0 +1 @@ +* Use Fleet instead of FleetDM in MDM certificates diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index ce8e89602f..6cc7904198 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -6122,7 +6122,7 @@ func testSCEPRenewalHelpers(t *testing.T, ds *Datastore) { cert := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ - CommonName: "FleetDM Identity", + CommonName: "Fleet Identity", }, NotAfter: notAfter, // use a random value, just to make sure they're diff --git a/server/datastore/mysql/scep_test.go b/server/datastore/mysql/scep_test.go index 32347df118..d420d2af18 100644 --- a/server/datastore/mysql/scep_test.go +++ b/server/datastore/mysql/scep_test.go @@ -39,7 +39,7 @@ func TestAppleMDMSCEPSerial(t *testing.T) { func TestAppleMDMPutAndHasCN(t *testing.T) { depot := setup(t) - name := "FleetDM Identity" + name := "Fleet Identity" serial, err := depot.Serial() require.NoError(t, err) cert := x509.Certificate{ diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index 0e9f79982d..69280f82f4 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -91,7 +91,7 @@ type DEPService struct { // getDefaultProfile returns a godep.Profile with default values set. func (d *DEPService) getDefaultProfile() *godep.Profile { return &godep.Profile{ - ProfileName: "FleetDM default enrollment profile", + ProfileName: "Fleet default enrollment profile", AllowPairing: true, AutoAdvanceSetup: false, IsSupervised: false, @@ -688,8 +688,8 @@ var enrollmentProfileMobileconfigTemplate = template.Must(template.New("").Parse {{ .SCEPURL }} Subject - OFleetDM - CNFleetDM Identity + OFleet + CNFleet Identity PayloadIdentifier diff --git a/server/mdm/apple/cert.go b/server/mdm/apple/cert.go index ec47d0d438..a0393122d2 100644 --- a/server/mdm/apple/cert.go +++ b/server/mdm/apple/cert.go @@ -20,7 +20,7 @@ import ( const ( defaultFleetDMAPIURL = "https://fleetdm.com" getSignedAPNSCSRPath = "/api/v1/deliver-apple-csr" - depCertificateCommonName = "FleetDM" + depCertificateCommonName = "Fleet" depCertificateExpiryDays = 30 ) @@ -208,7 +208,7 @@ func NewSCEPCACertKey() (*x509.Certificate, *rsa.PrivateKey, error) { caCert := depot.NewCACert( depot.WithYears(10), - depot.WithCommonName("FleetDM"), + depot.WithCommonName("Fleet"), ) crtBytes, err := caCert.SelfSign(rand.Reader, key.Public(), key) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 408b6964bb..277f22cf55 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -531,9 +531,9 @@ func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() { var mdmResp getAppleMDMResponse s.DoJSON("GET", "/api/latest/fleet/apns", nil, http.StatusOK, &mdmResp) // returned values are dummy, this is a test certificate - require.Equal(t, "FleetDM", mdmResp.Issuer) + require.Equal(t, "Fleet", mdmResp.Issuer) require.NotZero(t, mdmResp.SerialNumber) - require.Equal(t, "FleetDM", mdmResp.CommonName) + require.Equal(t, "Fleet", mdmResp.CommonName) require.NotZero(t, mdmResp.RenewDate) s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -8836,7 +8836,7 @@ func (s *integrationMDMTestSuite) appleCoreCertsSetup() { ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, Subject: pkix.Name{ - CommonName: "FleetDM", + CommonName: "Fleet", ExtraNames: []pkix.AttributeTypeAndValue{ { Type: asn1.ObjectIdentifier{0, 9, 2342, 19200300, 100, 1, 1}, diff --git a/server/worker/macos_setup_assistant_test.go b/server/worker/macos_setup_assistant_test.go index cf18ae367b..2a19a9cf91 100644 --- a/server/worker/macos_setup_assistant_test.go +++ b/server/worker/macos_setup_assistant_test.go @@ -72,7 +72,7 @@ func TestMacosSetupAssistant(t *testing.T) { DEPClient: apple_mdm.NewDEPClient(depStorage, ds, logger), } - const defaultProfileName = "FleetDM default enrollment profile" + const defaultProfileName = "Fleet default enrollment profile" // track the profile assigned to each device serialsToProfile := map[string]string{ From 904e8a68254c0b9cc718f28466f7da9f6e8acdd8 Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Fri, 14 Jun 2024 12:24:01 -0300 Subject: [PATCH 14/15] Added `server_settings.query_report_cap` (#19692) #19600 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [X] Added/updated tests - [X] Manual QA for all new/changed functionality --- .../19600-add-config-to-set-query-report-cap | 1 + cmd/fleetctl/gitops_test.go | 3 +- .../expectedGetConfigAppConfigJson.json | 1 + .../expectedGetConfigAppConfigYaml.yml | 1 + ...ectedGetConfigIncludeServerConfigJson.json | 1 + ...pectedGetConfigIncludeServerConfigYaml.yml | 1 + .../gitops/global_config_no_paths.yml | 1 + .../macosSetupExpectedAppConfigEmpty.yml | 1 + .../macosSetupExpectedAppConfigSet.yml | 1 + frontend/__mocks__/queryReportMock.ts | 1 + frontend/interfaces/query_report.ts | 1 + .../QueryDetailsPage/QueryDetailsPage.tsx | 4 +- .../QueryDetailsPageConfig.tsx | 2 - .../QueryReport/QueryReport.tests.tsx | 3 + pkg/spec/gitops_test.go | 1 + pkg/spec/testdata/global_config_no_paths.yml | 1 + pkg/spec/testdata/org-settings.yml | 1 + server/datastore/mysql/hosts_test.go | 4 +- server/datastore/mysql/query_results.go | 6 +- server/datastore/mysql/query_results_test.go | 60 +++++++++---------- server/datastore/mysql/schema.sql | 2 +- server/fleet/app.go | 10 ++++ server/fleet/datastore.go | 2 +- server/fleet/osquery.go | 3 +- server/fleet/service.go | 7 ++- server/mock/datastore_mock.go | 6 +- server/service/hosts.go | 7 ++- server/service/integration_core_test.go | 51 ++++++++++++++-- server/service/osquery.go | 18 ++++-- server/service/osquery_test.go | 14 ++--- server/service/queries.go | 42 ++++++++----- server/service/queries_test.go | 11 ++-- .../generated_files/appconfig.txt | 1 + 33 files changed, 178 insertions(+), 91 deletions(-) create mode 100644 changes/19600-add-config-to-set-query-report-cap diff --git a/changes/19600-add-config-to-set-query-report-cap b/changes/19600-add-config-to-set-query-report-cap new file mode 100644 index 0000000000..a016c325ff --- /dev/null +++ b/changes/19600-add-config-to-set-query-report-cap @@ -0,0 +1 @@ +* Added a server setting to configure the query repory cap size, `server_settings.query_report_cap` (default is 1000). diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index c9e56d636e..564f5466fe 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -418,6 +418,7 @@ func TestFullGlobalGitOps(t *testing.T) { assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) assert.Contains(t, string(*savedAppConfig.AgentOptions), "distributed_denylist_duration") + assert.Equal(t, 2000, savedAppConfig.ServerSettings.QueryReportCap) assert.Len(t, enrolledSecrets, 2) assert.True(t, policyDeleted) assert.Len(t, appliedPolicySpecs, 5) @@ -923,7 +924,6 @@ team_settings: _ = runAppForTest(t, []string{"gitops", "-f", globalFile.Name(), "-f", teamFile.Name(), "--delete-other-teams"}) assert.True(t, ds.ListTeamsFuncInvoked) assert.True(t, ds.DeleteTeamFuncInvoked) - } func TestFullGlobalAndTeamGitOps(t *testing.T) { @@ -1059,7 +1059,6 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) { } }) } - } func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, **fleet.Team) { diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index 7d0df63743..c6624c7110 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -11,6 +11,7 @@ "server_settings": { "server_url": "", "live_query_disabled": false, + "query_report_cap": 0, "query_reports_disabled": false, "enable_analytics": false, "deferred_save_host": false, diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index 01834e56a5..92254b6052 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -59,6 +59,7 @@ spec: deferred_save_host: false enable_analytics: false live_query_disabled: false + query_report_cap: 0 query_reports_disabled: false server_url: "" scripts_disabled: false diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 114ba52a9c..18d980b320 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -11,6 +11,7 @@ "server_settings": { "server_url": "", "live_query_disabled": false, + "query_report_cap": 0, "query_reports_disabled": false, "enable_analytics": false, "deferred_save_host": false, diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 203246eb0d..3138a7d349 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -98,6 +98,7 @@ spec: deferred_save_host: false enable_analytics: false live_query_disabled: false + query_report_cap: 0 query_reports_disabled: false server_url: "" scripts_disabled: false diff --git a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml index 0a73e7392d..76936e3ad5 100644 --- a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml @@ -101,6 +101,7 @@ org_settings: deferred_save_host: false enable_analytics: true live_query_disabled: false + query_report_cap: 2000 query_reports_disabled: false scripts_disabled: false server_url: $FLEET_SERVER_URL diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index 237ea64e3a..67bb96e8c3 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -59,6 +59,7 @@ spec: deferred_save_host: false enable_analytics: true live_query_disabled: false + query_report_cap: 0 query_reports_disabled: false server_url: https://example.org scripts_disabled: false diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 95b1be28ac..d73894e4a1 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -59,6 +59,7 @@ spec: deferred_save_host: false enable_analytics: true live_query_disabled: false + query_report_cap: 0 query_reports_disabled: false server_url: https://example.org scripts_disabled: false diff --git a/frontend/__mocks__/queryReportMock.ts b/frontend/__mocks__/queryReportMock.ts index eb538473d9..016812e7d4 100644 --- a/frontend/__mocks__/queryReportMock.ts +++ b/frontend/__mocks__/queryReportMock.ts @@ -320,6 +320,7 @@ const DEFAULT_QUERY_REPORT_MOCK: IQueryReport = { }, }, ], + report_clipped: false, }; const createMockQueryReport = ( diff --git a/frontend/interfaces/query_report.ts b/frontend/interfaces/query_report.ts index 051357827e..97fc32dd90 100644 --- a/frontend/interfaces/query_report.ts +++ b/frontend/interfaces/query_report.ts @@ -9,4 +9,5 @@ export interface IQueryReportResultRow { export interface IQueryReport { query_id: number; results: IQueryReportResultRow[]; + report_clipped: boolean; } diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx index 712ae3a0f5..f7177bf718 100644 --- a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx @@ -42,7 +42,6 @@ import NoResults from "../components/NoResults/NoResults"; import { DEFAULT_SORT_HEADER, DEFAULT_SORT_DIRECTION, - QUERY_REPORT_RESULTS_LIMIT, } from "./QueryDetailsPageConfig"; interface IQueryDetailsPageProps { @@ -199,8 +198,7 @@ const QueryDetailsPage = ({ const isLoading = isStoredQueryLoading || isQueryReportLoading; const isApiError = storedQueryError || queryReportError; - const isClipped = - (queryReport?.results?.length ?? 0) >= QUERY_REPORT_RESULTS_LIMIT; + const isClipped = queryReport?.report_clipped; const disabledLiveQuery = config?.server_settings.live_query_disabled; const renderHeader = () => { diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx index 05ef2ba604..10cc329d00 100644 --- a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx @@ -11,5 +11,3 @@ export type QueryDetailsPageQueryParams = Record< export const DEFAULT_SORT_HEADER = "host_name"; export const DEFAULT_SORT_DIRECTION = "asc"; - -export const QUERY_REPORT_RESULTS_LIMIT = 1000; diff --git a/frontend/pages/queries/details/components/QueryReport/QueryReport.tests.tsx b/frontend/pages/queries/details/components/QueryReport/QueryReport.tests.tsx index 17a04dffda..93492b3556 100644 --- a/frontend/pages/queries/details/components/QueryReport/QueryReport.tests.tsx +++ b/frontend/pages/queries/details/components/QueryReport/QueryReport.tests.tsx @@ -24,6 +24,7 @@ describe("QueryReport", () => { columns: { col1: "value3", col2: "value4" }, }, ], + report_clipped: false, }, ]; render(); @@ -56,6 +57,7 @@ describe("QueryReport", () => { }, }, ], + report_clipped: false, }, ]; render(); @@ -83,6 +85,7 @@ describe("QueryReport", () => { columns: { col1: "value1", col2: "value2" }, }, ], + report_clipped: true, }, ]; render(); diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 586915a33d..8d9b00bffc 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -137,6 +137,7 @@ func TestValidGitOpsYaml(t *testing.T) { serverSettings, ok := gitops.OrgSettings["server_settings"] assert.True(t, ok, "server_settings not found") assert.Equal(t, "https://fleet.example.com", serverSettings.(map[string]interface{})["server_url"]) + assert.EqualValues(t, 2000, serverSettings.(map[string]interface{})["query_report_cap"]) assert.Contains(t, gitops.OrgSettings, "org_info") orgInfo, ok := gitops.OrgSettings["org_info"].(map[string]interface{}) assert.True(t, ok) diff --git a/pkg/spec/testdata/global_config_no_paths.yml b/pkg/spec/testdata/global_config_no_paths.yml index cdc6e78923..7fabc5119a 100644 --- a/pkg/spec/testdata/global_config_no_paths.yml +++ b/pkg/spec/testdata/global_config_no_paths.yml @@ -101,6 +101,7 @@ org_settings: deferred_save_host: false enable_analytics: true live_query_disabled: false + query_report_cap: 2000 query_reports_disabled: false scripts_disabled: false server_url: https://fleet.example.com diff --git a/pkg/spec/testdata/org-settings.yml b/pkg/spec/testdata/org-settings.yml index 22f17b97a5..fb51b15a2a 100644 --- a/pkg/spec/testdata/org-settings.yml +++ b/pkg/spec/testdata/org-settings.yml @@ -4,6 +4,7 @@ server_settings: deferred_save_host: false enable_analytics: true live_query_disabled: false + query_report_cap: 2000 query_reports_disabled: false scripts_disabled: false server_url: https://fleet.example.com diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 91928dd5f5..aa3fd09ed0 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -4255,7 +4255,7 @@ func testHostsIncludesScheduledQueriesInPackStats(t *testing.T, ds *Datastore) { Data: ptr.RawMessage(json.RawMessage(`{"foo": "baz"}`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), queryResultRow) + err = ds.OverwriteQueryResultRows(context.Background(), queryResultRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) hostResult, err = ds.Host(context.Background(), host.ID) @@ -9067,7 +9067,7 @@ func testHostsAddToTeamCleansUpTeamQueryResults(t *testing.T, ds *Datastore) { h4Global0Results, h4Query1Results, } { - err = ds.OverwriteQueryResultRows(ctx, results) + err = ds.OverwriteQueryResultRows(ctx, results, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) } diff --git a/server/datastore/mysql/query_results.go b/server/datastore/mysql/query_results.go index fe0aac913b..02e222399e 100644 --- a/server/datastore/mysql/query_results.go +++ b/server/datastore/mysql/query_results.go @@ -13,7 +13,7 @@ import ( // OverwriteQueryResultRows overwrites the query result rows for a given query and host // in a single transaction, ensuring that the number of rows for the given query // does not exceed the maximum allowed -func (ds *Datastore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) (err error) { +func (ds *Datastore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) (err error) { if len(rows) == 0 { return nil } @@ -31,7 +31,7 @@ func (ds *Datastore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet return ctxerr.Wrap(ctx, err, "counting existing query results") } - if countExisting >= fleet.MaxQueryReportRows { + if countExisting >= maxQueryReportRows { // do not delete any rows if we are already at the limit return nil } @@ -53,7 +53,7 @@ func (ds *Datastore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet // Calculate how many new rows can be added given the maximum limit netRowsAfterDeletion := countExisting - int(countDeleted) - allowedNewRows := fleet.MaxQueryReportRows - netRowsAfterDeletion + allowedNewRows := maxQueryReportRows - netRowsAfterDeletion if allowedNewRows == 0 { return nil } diff --git a/server/datastore/mysql/query_results_test.go b/server/datastore/mysql/query_results_test.go index 284ecc6143..ce1f3c8a4c 100644 --- a/server/datastore/mysql/query_results_test.go +++ b/server/datastore/mysql/query_results_test.go @@ -62,7 +62,7 @@ func testGetQueryResultRows(t *testing.T, ds *Datastore) { }`)), }, } - err := ds.OverwriteQueryResultRows(context.Background(), query1Rows) + err := ds.OverwriteQueryResultRows(context.Background(), query1Rows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Insert Result Row for different Scheduled Query @@ -76,7 +76,7 @@ func testGetQueryResultRows(t *testing.T, ds *Datastore) { }, } - err = ds.OverwriteQueryResultRows(context.Background(), query2Rows) + err = ds.OverwriteQueryResultRows(context.Background(), query2Rows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) results, err := ds.QueryResultRows(context.Background(), query.ID, fleet.TeamFilter{User: test.UserAdmin}) @@ -125,7 +125,7 @@ func testGetQueryResultRowsForHost(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } - err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRows) + err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Insert 1 Result Row for Query1 Host2 @@ -137,7 +137,7 @@ func testGetQueryResultRowsForHost(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRows) + err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that Query1 returns 2 results for Host1 @@ -215,7 +215,7 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { }, } - err = ds.OverwriteQueryResultRows(context.Background(), globalRow) + err = ds.OverwriteQueryResultRows(context.Background(), globalRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) teamRow := []*fleet.ScheduledQueryResultRow{ @@ -229,7 +229,7 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { }`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), teamRow) + err = ds.OverwriteQueryResultRows(context.Background(), teamRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) observerTeamRow := []*fleet.ScheduledQueryResultRow{ @@ -243,7 +243,7 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { }`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), observerTeamRow) + err = ds.OverwriteQueryResultRows(context.Background(), observerTeamRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) filter := fleet.TeamFilter{ @@ -286,7 +286,7 @@ func testCountResultsForQuery(t *testing.T, ds *Datastore) { }`)), }, } - err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRow) + err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Insert Nil Result Row for Query1, nil data rows are not counted @@ -298,7 +298,7 @@ func testCountResultsForQuery(t *testing.T, ds *Datastore) { Data: nil, }, } - err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRow) + err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Insert 5 Result Rows for Query2 @@ -317,7 +317,7 @@ func testCountResultsForQuery(t *testing.T, ds *Datastore) { resultRows = append(resultRows, resultRow2) } - err = ds.OverwriteQueryResultRows(context.Background(), resultRows) + err = ds.OverwriteQueryResultRows(context.Background(), resultRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that ResultCountForQuery returns 1 @@ -366,7 +366,7 @@ func testCountResultsForQueryAndHost(t *testing.T, ds *Datastore) { }`)), }, } - err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRows) + err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) host1Query2 := []*fleet.ScheduledQueryResultRow{ @@ -380,7 +380,7 @@ func testCountResultsForQueryAndHost(t *testing.T, ds *Datastore) { }`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), host1Query2) + err = ds.OverwriteQueryResultRows(context.Background(), host1Query2, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) host2ResultRow := []*fleet.ScheduledQueryResultRow{ @@ -394,7 +394,7 @@ func testCountResultsForQueryAndHost(t *testing.T, ds *Datastore) { }`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRow) + err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) host3ResultRow := []*fleet.ScheduledQueryResultRow{ @@ -405,7 +405,7 @@ func testCountResultsForQueryAndHost(t *testing.T, ds *Datastore) { Data: nil, }, } - err = ds.OverwriteQueryResultRows(context.Background(), host3ResultRow) + err = ds.OverwriteQueryResultRows(context.Background(), host3ResultRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that Query1 returns 2 @@ -451,7 +451,7 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { }, } - err := ds.OverwriteQueryResultRows(context.Background(), initialRow) + err := ds.OverwriteQueryResultRows(context.Background(), initialRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Overwrite Result Rows with new data @@ -465,7 +465,7 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { }, } - err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows) + err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that we get the overwritten data (1 result with USB Mouse data) @@ -486,7 +486,7 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows) + err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that the data has not changed @@ -511,7 +511,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { mockTime := time.Now().UTC().Truncate(time.Second) // Generate max rows -1 - maxRows := fleet.MaxQueryReportRows - 1 + maxRows := fleet.DefaultMaxQueryReportRows - 1 maxMinusOneRows := make([]*fleet.ScheduledQueryResultRow, maxRows) for i := 0; i < maxRows; i++ { maxMinusOneRows[i] = &fleet.ScheduledQueryResultRow{ @@ -521,7 +521,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), } } - err := ds.OverwriteQueryResultRows(context.Background(), maxMinusOneRows) + err := ds.OverwriteQueryResultRows(context.Background(), maxMinusOneRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Add an empty data rows which do not count towards the max @@ -532,7 +532,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { LastFetched: mockTime, Data: nil, }, - }) + }, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Confirm that we can still add a row @@ -543,13 +543,13 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { LastFetched: mockTime, Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, - }) + }, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that we now have max rows count, err := ds.ResultCountForQuery(context.Background(), query.ID) require.NoError(t, err) - require.Equal(t, fleet.MaxQueryReportRows, count) + require.Equal(t, fleet.DefaultMaxQueryReportRows, count) // Attempt to add another row err = ds.OverwriteQueryResultRows(context.Background(), []*fleet.ScheduledQueryResultRow{ @@ -559,7 +559,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { LastFetched: mockTime, Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, - }) + }, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that the last row was not added @@ -568,7 +568,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { require.Len(t, host4result, 0) // Generate more than max rows in Query 2 - rows := fleet.MaxQueryReportRows + 50 + rows := fleet.DefaultMaxQueryReportRows + 50 largeBatchRows := make([]*fleet.ScheduledQueryResultRow, rows) for i := 0; i < rows; i++ { largeBatchRows[i] = &fleet.ScheduledQueryResultRow{ @@ -578,13 +578,13 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), } } - err = ds.OverwriteQueryResultRows(context.Background(), largeBatchRows) + err = ds.OverwriteQueryResultRows(context.Background(), largeBatchRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Confirm only max rows are stored for the queryID allResults, err := ds.QueryResultRowsForHost(context.Background(), query2.ID, host1.ID) require.NoError(t, err) - require.Len(t, allResults, fleet.MaxQueryReportRows) + require.Len(t, allResults, fleet.DefaultMaxQueryReportRows) // Confirm that new rows are not added when the max is reached newMockTime := mockTime.Add(2 * time.Minute) @@ -597,7 +597,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { }, } - err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows) + err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) host2Results, err := ds.QueryResultRowsForHost(context.Background(), query2.ID, host2.ID) @@ -619,7 +619,7 @@ func testQueryResultRows(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } - err := ds.OverwriteQueryResultRows(context.Background(), overwriteRows) + err := ds.OverwriteQueryResultRows(context.Background(), overwriteRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) filter := fleet.TeamFilter{User: user, IncludeObserver: true} @@ -655,7 +655,7 @@ func testCleanupQueryResultRows(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "Keyboard", "vendor": "Microsoft"}`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), rows) + err = ds.OverwriteQueryResultRows(context.Background(), rows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Call OverwriteQueryResultRows again with different rows @@ -673,7 +673,7 @@ func testCleanupQueryResultRows(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "Speakers", "vendor": "Bose"}`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows) + err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Cleanup query result rows diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 5abe3d4d53..4b895e53e1 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -41,7 +41,7 @@ CREATE TABLE `app_config_json` ( UNIQUE KEY `id` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `calendar_events` ( diff --git a/server/fleet/app.go b/server/fleet/app.go index 4c10781c60..0a04ae8886 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -888,6 +888,16 @@ type ServerSettings struct { QueryReportsDisabled bool `json:"query_reports_disabled"` ScriptsDisabled bool `json:"scripts_disabled"` AIFeaturesDisabled bool `json:"ai_features_disabled"` + QueryReportCap int `json:"query_report_cap"` +} + +const DefaultMaxQueryReportRows int = 1000 + +func (f *ServerSettings) GetQueryReportCap() int { + if f.QueryReportCap <= 0 { + return DefaultMaxQueryReportRows + } + return f.QueryReportCap } // HostExpirySettings contains settings pertaining to automatic host expiry. diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 89d129a439..f2599a4e52 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -457,7 +457,7 @@ type Datastore interface { QueryResultRowsForHost(ctx context.Context, queryID, hostID uint) ([]*ScheduledQueryResultRow, error) ResultCountForQuery(ctx context.Context, queryID uint) (int, error) ResultCountForQueryAndHost(ctx context.Context, queryID, hostID uint) (int, error) - OverwriteQueryResultRows(ctx context.Context, rows []*ScheduledQueryResultRow) error + OverwriteQueryResultRows(ctx context.Context, rows []*ScheduledQueryResultRow, maxQueryReportRows int) error // CleanupDiscardedQueryResults deletes all query results for queries with DiscardData enabled. // Used in cleanups_then_aggregation cron to cleanup rows that were inserted immediately // after DiscardData was set to true due to query caching. diff --git a/server/fleet/osquery.go b/server/fleet/osquery.go index 23722ccddb..9e11d2bc15 100644 --- a/server/fleet/osquery.go +++ b/server/fleet/osquery.go @@ -18,8 +18,7 @@ type Stats struct { const ( // StatusOK is the success code returned by osquery - StatusOK OsqueryStatus = 0 - MaxQueryReportRows int = 1000 + StatusOK OsqueryStatus = 0 ) // QueryContent is the format of a query stanza in an osquery configuration. diff --git a/server/fleet/service.go b/server/fleet/service.go index 4707054dfb..b493b1bff0 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -275,12 +275,13 @@ type Service interface { // included in the results. ListQueries(ctx context.Context, opt ListOptions, teamID *uint, scheduled *bool, mergeInherited bool) ([]*Query, error) GetQuery(ctx context.Context, id uint) (*Query, error) - // GetQueryReportResults returns all the stored results of a query for hosts the requestor has access to - GetQueryReportResults(ctx context.Context, id uint) ([]HostQueryResultRow, error) + // GetQueryReportResults returns all the stored results of a query for hosts the requestor has access to. + // Returns a boolean indicating whether the report is clipped. + GetQueryReportResults(ctx context.Context, id uint) ([]HostQueryResultRow, bool, error) // GetHostQueryReportResults returns all stored results of a query for a specific host GetHostQueryReportResults(ctx context.Context, hid uint, queryID uint) (rows []HostQueryReportResult, lastFetched *time.Time, err error) // QueryReportIsClipped returns true if the number of query report rows exceeds the maximum - QueryReportIsClipped(ctx context.Context, queryID uint) (bool, error) + QueryReportIsClipped(ctx context.Context, queryID uint, maxQueryReportRows int) (bool, error) NewQuery(ctx context.Context, p QueryPayload) (*Query, error) ModifyQuery(ctx context.Context, id uint, p QueryPayload) (*Query, error) DeleteQuery(ctx context.Context, teamID *uint, name string) error diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 32b3a98910..9113d897b7 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -339,7 +339,7 @@ type ResultCountForQueryFunc func(ctx context.Context, queryID uint) (int, error type ResultCountForQueryAndHostFunc func(ctx context.Context, queryID uint, hostID uint) (int, error) -type OverwriteQueryResultRowsFunc func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error +type OverwriteQueryResultRowsFunc func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error type CleanupDiscardedQueryResultsFunc func(ctx context.Context) error @@ -3508,11 +3508,11 @@ func (s *DataStore) ResultCountForQueryAndHost(ctx context.Context, queryID uint return s.ResultCountForQueryAndHostFunc(ctx, queryID, hostID) } -func (s *DataStore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { +func (s *DataStore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error { s.mu.Lock() s.OverwriteQueryResultRowsFuncInvoked = true s.mu.Unlock() - return s.OverwriteQueryResultRowsFunc(ctx, rows) + return s.OverwriteQueryResultRowsFunc(ctx, rows, maxQueryReportRows) } func (s *DataStore) CleanupDiscardedQueryResults(ctx context.Context) error { diff --git a/server/service/hosts.go b/server/service/hosts.go index 2841ba8b80..d9c6ab9edb 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1231,7 +1231,12 @@ func getHostQueryReportEndpoint(ctx context.Context, request interface{}, svc fl return getHostQueryReportResponse{Err: err}, nil } - isClipped, err := svc.QueryReportIsClipped(ctx, req.QueryID) + appConfig, err := svc.AppConfigObfuscated(ctx) + if err != nil { + return getHostQueryReportResponse{Err: err}, nil + } + + isClipped, err := svc.QueryReportIsClipped(ctx, req.QueryID, appConfig.ServerSettings.GetQueryReportCap()) if err != nil { return getHostQueryReportResponse{Err: err}, nil } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index d445736523..e946968424 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -10837,12 +10837,14 @@ func (s *integrationTestSuite) TestQueryReports() { }, http.StatusOK, &applyResp) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 0) + require.False(t, gqrr.ReportClipped) // Re-add results to our query and check that they're actually there s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 1) + require.False(t, gqrr.ReportClipped) // don't change platform or min_osquery_version and results should not be deleted s.DoJSON("POST", "/api/latest/fleet/spec/queries", applyQuerySpecsRequest{ @@ -10850,6 +10852,7 @@ func (s *integrationTestSuite) TestQueryReports() { }, http.StatusOK, &applyResp) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 1) + require.False(t, gqrr.ReportClipped) // now update the platform and results should be deleted. osqueryInfoQuerySpec.Platform = "darwin" @@ -10858,30 +10861,35 @@ func (s *integrationTestSuite) TestQueryReports() { }, http.StatusOK, &applyResp) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 0) + require.False(t, gqrr.ReportClipped) // Update logging type, which should cause results deletion s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/queries/%d", usbDevicesQuery.ID), modifyQueryRequest{ID: usbDevicesQuery.ID, QueryPayload: fleet.QueryPayload{Logging: &fleet.LoggingDifferential}}, http.StatusOK, &modifyQueryResp) require.Equal(t, fleet.LoggingDifferential, modifyQueryResp.Query.Logging) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", usbDevicesQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 0) + require.False(t, gqrr.ReportClipped) // Re-add results to our query and check that they're actually there s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 1) + require.False(t, gqrr.ReportClipped) discardData := true s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/queries/%d", osqueryInfoQuery.ID), modifyQueryRequest{ID: osqueryInfoQuery.ID, QueryPayload: fleet.QueryPayload{DiscardData: &discardData}}, http.StatusOK, &modifyQueryResp) require.True(t, modifyQueryResp.Query.DiscardData) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 0) + require.False(t, gqrr.ReportClipped) // check that now that discardData is set, we don't add new results s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 0) + require.False(t, gqrr.ReportClipped) // Verify that we can't have more than 1k results @@ -10893,7 +10901,7 @@ func (s *integrationTestSuite) TestQueryReports() { NodeKey: *host1Global.NodeKey, LogType: "result", Data: json.RawMessage(`[{ - "snapshot": [` + results(1000, host1Global.UUID) + ` + "snapshot": [` + results(fleet.DefaultMaxQueryReportRows, host1Global.UUID) + ` ], "action": "snapshot", "name": "pack/Global/` + osqueryInfoQuery.Name + `", @@ -10916,13 +10924,14 @@ func (s *integrationTestSuite) TestQueryReports() { s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) - require.Len(t, gqrr.Results, fleet.MaxQueryReportRows) + require.Len(t, gqrr.Results, fleet.DefaultMaxQueryReportRows) + require.True(t, gqrr.ReportClipped) ghqrr = getHostQueryReportResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/queries/%d", host1Global.ID, osqueryInfoQuery.ID), getHostQueryReportRequest{}, http.StatusOK, &ghqrr) require.NoError(t, ghqrr.Err) + require.Len(t, ghqrr.Results, fleet.DefaultMaxQueryReportRows) require.True(t, ghqrr.ReportClipped) - require.Len(t, ghqrr.Results, fleet.MaxQueryReportRows) slreq.Data = json.RawMessage(`[{ "snapshot": [` + results(1, host1Global.UUID) + ` @@ -10944,7 +10953,41 @@ func (s *integrationTestSuite) TestQueryReports() { s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) - require.Len(t, gqrr.Results, fleet.MaxQueryReportRows) + require.Len(t, gqrr.Results, fleet.DefaultMaxQueryReportRows) + require.True(t, gqrr.ReportClipped) + + appConfigSpec := map[string]map[string]int{ + "server_settings": {"query_report_cap": fleet.DefaultMaxQueryReportRows + 1}, + } + s.Do("PATCH", "/api/latest/fleet/config", appConfigSpec, http.StatusOK) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) + require.Len(t, gqrr.Results, fleet.DefaultMaxQueryReportRows) + require.False(t, gqrr.ReportClipped) + + slreq.Data = json.RawMessage(`[{ + "snapshot": [` + results(1002, host1Global.UUID) + ` + ], + "action": "snapshot", + "name": "pack/Global/` + osqueryInfoQuery.Name + `", + "hostIdentifier": "` + *host1Global.OsqueryHostID + `", + "calendarTime": "Fri Oct 6 18:13:04 2023 UTC", + "unixTime": 1696615984, + "epoch": 0, + "counter": 0, + "numerics": false, + "decorations": { + "host_uuid": "187c4d56-8e45-1a9d-8513-ac17efd2f0fd", + "hostname": "` + host1Global.Hostname + `" + } +}]`) + + s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) + require.NoError(t, slres.Err) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) + require.Len(t, gqrr.Results, fleet.DefaultMaxQueryReportRows+1) + require.True(t, gqrr.ReportClipped) // TODO: Set global discard flag and verify that all data is gone. } diff --git a/server/service/osquery.go b/server/service/osquery.go index a806798c03..c4112f7d0d 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -1799,7 +1799,8 @@ func (svc *Service) SubmitResultLogs(ctx context.Context, logs []json.RawMessage unmarshaledResults, queriesDBData := svc.preProcessOsqueryResults(ctx, logs, queryReportsDisabled) if !queryReportsDisabled { - svc.saveResultLogsToQueryReports(ctx, unmarshaledResults, queriesDBData) + maxQueryReportRows := appConfig.ServerSettings.GetQueryReportCap() + svc.saveResultLogsToQueryReports(ctx, unmarshaledResults, queriesDBData, maxQueryReportRows) } var filteredLogs []json.RawMessage @@ -1861,7 +1862,12 @@ func (svc *Service) SubmitResultLogs(ctx context.Context, logs []json.RawMessage // Query Reports //////////////////////////////////////////////////////////////////////////////// -func (svc *Service) saveResultLogsToQueryReports(ctx context.Context, unmarshaledResults []*fleet.ScheduledQueryResult, queriesDBData map[string]*fleet.Query) { +func (svc *Service) saveResultLogsToQueryReports( + ctx context.Context, + unmarshaledResults []*fleet.ScheduledQueryResult, + queriesDBData map[string]*fleet.Query, + maxQueryReportRows int, +) { // skipauth: Authorization is currently for user endpoints only. svc.authz.SkipAuthorization(ctx) @@ -1903,11 +1909,11 @@ func (svc *Service) saveResultLogsToQueryReports(ctx context.Context, unmarshale level.Error(svc.logger).Log("msg", "get result count for query", "err", err, "query_id", dbQuery.ID) continue } - if count >= fleet.MaxQueryReportRows { + if count >= maxQueryReportRows { continue } - if err := svc.overwriteResultRows(ctx, result, dbQuery.ID, host.ID); err != nil { + if err := svc.overwriteResultRows(ctx, result, dbQuery.ID, host.ID, maxQueryReportRows); err != nil { level.Error(svc.logger).Log("msg", "overwrite results", "err", err, "query_id", dbQuery.ID, "host_id", host.ID) continue } @@ -1919,7 +1925,7 @@ func (svc *Service) saveResultLogsToQueryReports(ctx context.Context, unmarshale // The "snapshot" array in a ScheduledQueryResult can contain multiple rows. // Each row is saved as a separate ScheduledQueryResultRow, i.e. a result could contain // many USB Devices or a result could contain all user accounts on a host. -func (svc *Service) overwriteResultRows(ctx context.Context, result *fleet.ScheduledQueryResult, queryID, hostID uint) error { +func (svc *Service) overwriteResultRows(ctx context.Context, result *fleet.ScheduledQueryResult, queryID, hostID uint, maxQueryReportRows int) error { fetchTime := time.Now() rows := make([]*fleet.ScheduledQueryResultRow, 0, len(result.Snapshot)) @@ -1945,7 +1951,7 @@ func (svc *Service) overwriteResultRows(ctx context.Context, result *fleet.Sched rows = append(rows, row) } - if err := svc.ds.OverwriteQueryResultRows(ctx, rows); err != nil { + if err := svc.ds.OverwriteQueryResultRows(ctx, rows, maxQueryReportRows); err != nil { return ctxerr.Wrap(ctx, err, "overwriting query result rows") } return nil diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index 878e0ce8e0..eb5eba0aee 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -614,7 +614,7 @@ func TestSubmitResultLogsToLogDestination(t *testing.T) { return 0, nil } teamQueryResultsStored := false - ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error { if len(rows) == 0 { return nil } @@ -766,7 +766,7 @@ func TestSaveResultLogsToQueryReports(t *testing.T) { Logging: fleet.LoggingSnapshot, }, } - serv.saveResultLogsToQueryReports(ctx, results, discardDataFalse) + serv.saveResultLogsToQueryReports(ctx, results, discardDataFalse, fleet.DefaultMaxQueryReportRows) assert.False(t, ds.OverwriteQueryResultRowsFuncInvoked) // Happy Path: Results saved @@ -777,13 +777,13 @@ func TestSaveResultLogsToQueryReports(t *testing.T) { Logging: fleet.LoggingSnapshot, }, } - ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error { return nil } ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { return 0, nil } - serv.saveResultLogsToQueryReports(ctx, results, discardDataTrue) + serv.saveResultLogsToQueryReports(ctx, results, discardDataTrue, fleet.DefaultMaxQueryReportRows) require.True(t, ds.OverwriteQueryResultRowsFuncInvoked) } @@ -825,7 +825,7 @@ func TestSubmitResultLogsToQueryResultsWithEmptySnapShot(t *testing.T) { return 0, nil } - ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error { require.Len(t, rows, 1) require.Equal(t, uint(999), rows[0].HostID) require.NotZero(t, rows[0].LastFetched) @@ -876,7 +876,7 @@ func TestSubmitResultLogsToQueryResultsDoesNotCountNullDataRows(t *testing.T) { return 0, nil } - ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error { require.Len(t, rows, 1) require.Equal(t, uint(999), rows[0].HostID) require.NotZero(t, rows[0].LastFetched) @@ -933,7 +933,7 @@ func TestSubmitResultLogsFail(t *testing.T) { ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { return 0, nil } - ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error { return nil } diff --git a/server/service/queries.go b/server/service/queries.go index dcfe1a455e..07b9c43a69 100644 --- a/server/service/queries.go +++ b/server/service/queries.go @@ -121,16 +121,17 @@ type getQueryReportRequest struct { } type getQueryReportResponse struct { - QueryID uint `json:"query_id"` - Results []fleet.HostQueryResultRow `json:"results"` - Err error `json:"error,omitempty"` + QueryID uint `json:"query_id"` + Results []fleet.HostQueryResultRow `json:"results"` + ReportClipped bool `json:"report_clipped"` + Err error `json:"error,omitempty"` } func (r getQueryReportResponse) error() error { return r.Err } func getQueryReportEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*getQueryReportRequest) - queryReportResults, err := svc.GetQueryReportResults(ctx, req.ID) + queryReportResults, reportClipped, err := svc.GetQueryReportResults(ctx, req.ID) if err != nil { return listQueriesResponse{Err: err}, nil } @@ -140,44 +141,53 @@ func getQueryReportEndpoint(ctx context.Context, request interface{}, svc fleet. results = queryReportResults } return getQueryReportResponse{ - QueryID: req.ID, - Results: results, + QueryID: req.ID, + Results: results, + ReportClipped: reportClipped, }, nil } -func (svc *Service) GetQueryReportResults(ctx context.Context, id uint) ([]fleet.HostQueryResultRow, error) { +func (svc *Service) GetQueryReportResults(ctx context.Context, id uint) ([]fleet.HostQueryResultRow, bool, error) { // Load query first to get its teamID. query, err := svc.ds.Query(ctx, id) if err != nil { setAuthCheckedOnPreAuthErr(ctx) - return nil, ctxerr.Wrap(ctx, err, "get query from datastore") + return nil, false, ctxerr.Wrap(ctx, err, "get query from datastore") } if err := svc.authz.Authorize(ctx, query, fleet.ActionRead); err != nil { - return nil, err + return nil, false, err } if query.DiscardData { - return nil, nil + return nil, false, nil } vc, ok := viewer.FromContext(ctx) if !ok { - return nil, fleet.ErrNoContext + return nil, false, fleet.ErrNoContext } filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true} queryReportResultRows, err := svc.ds.QueryResultRows(ctx, id, filter) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get query report results") + return nil, false, ctxerr.Wrap(ctx, err, "get query report results") } queryReportResults, err := fleet.MapQueryReportResultsToRows(queryReportResultRows) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "map db rows to results") + return nil, false, ctxerr.Wrap(ctx, err, "map db rows to results") } - return queryReportResults, nil + appConfig, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, false, ctxerr.Wrap(ctx, err, "get app config") + } + reportClipped, err := svc.QueryReportIsClipped(ctx, id, appConfig.ServerSettings.GetQueryReportCap()) + if err != nil { + return nil, false, ctxerr.Wrap(ctx, err, "check query report is clipped") + } + return queryReportResults, reportClipped, nil } -func (svc *Service) QueryReportIsClipped(ctx context.Context, queryID uint) (bool, error) { +func (svc *Service) QueryReportIsClipped(ctx context.Context, queryID uint, maxQueryReportRows int) (bool, error) { query, err := svc.ds.Query(ctx, queryID) if err != nil { setAuthCheckedOnPreAuthErr(ctx) @@ -191,7 +201,7 @@ func (svc *Service) QueryReportIsClipped(ctx context.Context, queryID uint) (boo if err != nil { return false, err } - return count >= fleet.MaxQueryReportRows, nil + return count >= maxQueryReportRows, nil } //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/queries_test.go b/server/service/queries_test.go index fc8631264b..95ae244d7a 100644 --- a/server/service/queries_test.go +++ b/server/service/queries_test.go @@ -644,7 +644,7 @@ func TestQueryAuth(t *testing.T) { _, err = svc.GetQuery(ctx, tt.qid) checkAuthErr(t, tt.shouldFailRead, err) - _, err = svc.QueryReportIsClipped(ctx, tt.qid) + _, err = svc.QueryReportIsClipped(ctx, tt.qid, fleet.DefaultMaxQueryReportRows) checkAuthErr(t, tt.shouldFailRead, err) _, err = svc.ListQueries(ctx, fleet.ListOptions{}, query.TeamID, nil, false) @@ -688,15 +688,15 @@ func TestQueryReportIsClipped(t *testing.T) { return 0, nil } - isClipped, err := svc.QueryReportIsClipped(viewerCtx, 1) + isClipped, err := svc.QueryReportIsClipped(viewerCtx, 1, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) require.False(t, isClipped) ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { - return fleet.MaxQueryReportRows, nil + return fleet.DefaultMaxQueryReportRows, nil } - isClipped, err = svc.QueryReportIsClipped(viewerCtx, 1) + isClipped, err = svc.QueryReportIsClipped(viewerCtx, 1, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) require.True(t, isClipped) } @@ -725,9 +725,10 @@ func TestQueryReportReturnsNilIfDiscardDataIsTrue(t *testing.T) { }, nil } - results, err := svc.GetQueryReportResults(viewerCtx, 1) + results, reportClipped, err := svc.GetQueryReportResults(viewerCtx, 1) require.NoError(t, err) require.Nil(t, results) + require.False(t, reportClipped) } func TestComparePlatforms(t *testing.T) { diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index 3babb18073..79a8c47a76 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -12,6 +12,7 @@ github.com/fleetdm/fleet/v4/server/fleet/ServerSettings DeferredSaveHost bool github.com/fleetdm/fleet/v4/server/fleet/ServerSettings QueryReportsDisabled bool github.com/fleetdm/fleet/v4/server/fleet/ServerSettings ScriptsDisabled bool github.com/fleetdm/fleet/v4/server/fleet/ServerSettings AIFeaturesDisabled bool +github.com/fleetdm/fleet/v4/server/fleet/ServerSettings QueryReportCap int github.com/fleetdm/fleet/v4/server/fleet/AppConfig SMTPSettings *fleet.SMTPSettings github.com/fleetdm/fleet/v4/server/fleet/SMTPSettings SMTPEnabled bool github.com/fleetdm/fleet/v4/server/fleet/SMTPSettings SMTPConfigured bool From 5d93f27f20498b1f57f4dc3bf6a97b14ba3cd3b3 Mon Sep 17 00:00:00 2001 From: Sharon Katz <121527325+sharon-fdm@users.noreply.github.com> Date: Fri, 14 Jun 2024 11:34:39 -0400 Subject: [PATCH 15/15] use reader for stats (#19398) # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. @xpkoala the main things to QA: - Statistics should be sent by the server to our Heroku service. - The should be a theoretical small improvement to DB load (using the reader instance instead of the writer). Not sure it will be measureable. --- changes/part-of-19072-use-reader-db-for-stats | 1 + server/datastore/mysql/statistics.go | 34 +++++++++---------- 2 files changed, 18 insertions(+), 17 deletions(-) create mode 100644 changes/part-of-19072-use-reader-db-for-stats diff --git a/changes/part-of-19072-use-reader-db-for-stats b/changes/part-of-19072-use-reader-db-for-stats new file mode 100644 index 0000000000..a4ad45d70c --- /dev/null +++ b/changes/part-of-19072-use-reader-db-for-stats @@ -0,0 +1 @@ +- Improved db usage when sending statistics diff --git a/server/datastore/mysql/statistics.go b/server/datastore/mysql/statistics.go index 4dc5e5e0d4..11236d9adf 100644 --- a/server/datastore/mysql/statistics.go +++ b/server/datastore/mysql/statistics.go @@ -24,47 +24,47 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du lic, _ := license.FromContext(ctx) computeStats := func(stats *fleet.StatisticsPayload, since time.Time) error { - enrolledHostsByOS, amountEnrolledHosts, err := amountEnrolledHostsByOSDB(ctx, ds.writer(ctx)) + enrolledHostsByOS, amountEnrolledHosts, err := amountEnrolledHostsByOSDB(ctx, ds.reader(ctx)) if err != nil { return ctxerr.Wrap(ctx, err, "amount enrolled hosts by os") } - amountUsers, err := tableRowsCount(ctx, ds.writer(ctx), "users") + amountUsers, err := tableRowsCount(ctx, ds.reader(ctx), "users") if err != nil { return ctxerr.Wrap(ctx, err, "amount users") } - amountSoftwaresVersions, err := tableRowsCount(ctx, ds.writer(ctx), "software") + amountSoftwaresVersions, err := tableRowsCount(ctx, ds.reader(ctx), "software") if err != nil { return ctxerr.Wrap(ctx, err, "amount software") } - amountHostSoftwares, err := tableRowsCount(ctx, ds.writer(ctx), "host_software") + amountHostSoftwares, err := tableRowsCount(ctx, ds.reader(ctx), "host_software") if err != nil { return ctxerr.Wrap(ctx, err, "amount host_software") } - amountSoftwareTitles, err := tableRowsCount(ctx, ds.writer(ctx), "software_titles") + amountSoftwareTitles, err := tableRowsCount(ctx, ds.reader(ctx), "software_titles") if err != nil { return ctxerr.Wrap(ctx, err, "amount software_titles") } - amountHostSoftwareInstalledPaths, err := tableRowsCount(ctx, ds.writer(ctx), "host_software_installed_paths") + amountHostSoftwareInstalledPaths, err := tableRowsCount(ctx, ds.reader(ctx), "host_software_installed_paths") if err != nil { return ctxerr.Wrap(ctx, err, "amount host_software_installed_paths") } - amountSoftwareCpes, err := tableRowsCount(ctx, ds.writer(ctx), "software_cpe") + amountSoftwareCpes, err := tableRowsCount(ctx, ds.reader(ctx), "software_cpe") if err != nil { return ctxerr.Wrap(ctx, err, "amount software_cpe") } - amountSoftwareCves, err := tableRowsCount(ctx, ds.writer(ctx), "software_cve") + amountSoftwareCves, err := tableRowsCount(ctx, ds.reader(ctx), "software_cve") if err != nil { return ctxerr.Wrap(ctx, err, "amount software_cve") } - amountTeams, err := amountTeamsDB(ctx, ds.writer(ctx)) + amountTeams, err := amountTeamsDB(ctx, ds.reader(ctx)) if err != nil { return ctxerr.Wrap(ctx, err, "amount teams") } - amountPolicies, err := amountPoliciesDB(ctx, ds.writer(ctx)) + amountPolicies, err := amountPoliciesDB(ctx, ds.reader(ctx)) if err != nil { return ctxerr.Wrap(ctx, err, "amount policies") } - amountLabels, err := amountLabelsDB(ctx, ds.writer(ctx)) + amountLabels, err := amountLabelsDB(ctx, ds.reader(ctx)) if err != nil { return ctxerr.Wrap(ctx, err, "amount labels") } @@ -72,11 +72,11 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du if err != nil { return ctxerr.Wrap(ctx, err, "statistics app config") } - amountWeeklyUsers, err := amountActiveUsersSinceDB(ctx, ds.writer(ctx), since) + amountWeeklyUsers, err := amountActiveUsersSinceDB(ctx, ds.reader(ctx), since) if err != nil { return ctxerr.Wrap(ctx, err, "amount active users") } - amountPolicyViolationDaysActual, amountPolicyViolationDaysPossible, err := amountPolicyViolationDaysDB(ctx, ds.writer(ctx)) + amountPolicyViolationDaysActual, amountPolicyViolationDaysPossible, err := amountPolicyViolationDaysDB(ctx, ds.reader(ctx)) if err == sql.ErrNoRows { level.Debug(ds.logger).Log("msg", "amount policy violation days", "err", err) //nolint:errcheck } else if err != nil { @@ -86,15 +86,15 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du if err != nil { return ctxerr.Wrap(ctx, err, "statistics error store") } - amountHostsNotResponding, err := countHostsNotRespondingDB(ctx, ds.writer(ctx), ds.logger, config) + amountHostsNotResponding, err := countHostsNotRespondingDB(ctx, ds.reader(ctx), ds.logger, config) if err != nil { return ctxerr.Wrap(ctx, err, "amount hosts not responding") } - amountHostsByOrbitVersion, err := amountHostsByOrbitVersionDB(ctx, ds.writer(ctx)) + amountHostsByOrbitVersion, err := amountHostsByOrbitVersionDB(ctx, ds.reader(ctx)) if err != nil { return ctxerr.Wrap(ctx, err, "amount hosts by orbit version") } - amountHostsByOsqueryVersion, err := amountHostsByOsqueryVersionDB(ctx, ds.writer(ctx)) + amountHostsByOsqueryVersion, err := amountHostsByOsqueryVersionDB(ctx, ds.reader(ctx)) if err != nil { return ctxerr.Wrap(ctx, err, "amount hosts by osquery version") } @@ -134,7 +134,7 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du } dest := statistics{} - err := sqlx.GetContext(ctx, ds.writer(ctx), &dest, `SELECT created_at, updated_at, anonymous_identifier FROM statistics LIMIT 1`) + err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, `SELECT created_at, updated_at, anonymous_identifier FROM statistics LIMIT 1`) if err != nil { if err == sql.ErrNoRows { anonIdentifier, err := server.GenerateRandomText(64)