From 2a0740a60f21e29270a7208ce8d3b5955b388d08 Mon Sep 17 00:00:00 2001 From: mrlee <470349233@qq.com> Date: Wed, 7 Oct 2026 19:26:02 +0800 Subject: [PATCH] =?UTF-8?q?chore(release):=20=E5=8F=91=E5=B8=83=20v1.9.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「公网服务端」新增 Nginx 启停控制(Agent 0.15.2,停止带二次确认) - frps 启动失败不再误报成功,并增强启动诊断 - frps vhost 端口禁止 80/443,避免与 VPS 上 Nginx 冲突 - 旧服务端配置的 tcp_mux / login_fail_exit 默认值补齐 - 新增安全审查、页面布局方案与场景操作文档 --- CHANGELOG.md | 5 +- README.md | 469 ++++++--- VERSION | 2 +- docker-compose.host.yml | 1 + docs/DEPLOYMENT.md | 156 ++- docs/README.md | 7 +- docs/agent-server-automation-vision.md | 424 ++++++++ docs/agent-server-form-review.md | 170 ++++ docs/agent-standalone-project-analysis.md | 360 +++++++ .../agent-view-layout-benchmark-2026-10-07.md | 160 +++ docs/agent-view-layout-plan-2026-10-07.md | 159 +++ docs/install-progress-log-plan.md | 951 ++++++++++++++++++ docs/project-analysis-2026-10-03.md | 397 ++++++++ docs/scenario-a-multi-app-https-entry.md | 211 ++++ docs/screenshots/guide-status.png | Bin 0 -> 27589 bytes docs/security-audit-2026-10-07.md | 271 +++++ docs/system-upgrade.md | 10 + internal/agent/insights.go | 78 ++ internal/agent/server.go | 3 +- internal/api/frpmulti_handler.go | 29 +- internal/api/router.go | 1 + internal/app/app.go | 48 +- internal/app/gateway_test.go | 25 + internal/config/config.go | 10 + internal/config/config_test.go | 9 + internal/config/gateway_test.go | 16 + internal/frp/agent_client.go | 24 + internal/frp/agent_insights.go | 25 +- internal/frp/agent_installer.go | 6 +- internal/frp/agent_installer_test.go | 6 + internal/frp/agent_tunnel.go | 163 ++- internal/frp/agent_tunnel_integration_test.go | 246 +++++ internal/frp/agent_tunnel_test.go | 18 +- .../frp/agentbin/havline-agent-linux-amd64 | Bin 10818765 -> 7512226 bytes .../frp/agentbin/havline-agent-linux-arm64 | Bin 10046623 -> 6946978 bytes internal/frp/config_test.go | 26 + internal/frp/multi_manager.go | 8 + internal/frp/service.go | 212 +++- internal/frp/service_diagnostics_test.go | 42 + internal/frp/ssh_installer.go | 4 +- internal/frp/traffic.go | 46 +- internal/frp/traffic_test.go | 50 + internal/frp/validate.go | 26 + web/package.json | 2 +- web/src/api/base.ts | 6 + web/src/api/client.ts | 65 +- web/src/api/types.ts | 69 +- web/src/components/MiniDonutChart.vue | 93 -- web/src/constants/app.ts | 4 +- web/src/router/index.ts | 2 +- web/src/views/AboutView.vue | 176 +++- web/src/views/AgentView.vue | 231 ++++- web/src/views/DdnsView.vue | 4 +- web/src/views/ProxyView.vue | 3 +- web/vite.config.ts | 3 + 55 files changed, 5065 insertions(+), 467 deletions(-) create mode 100644 docs/agent-server-automation-vision.md create mode 100644 docs/agent-server-form-review.md create mode 100644 docs/agent-standalone-project-analysis.md create mode 100644 docs/agent-view-layout-benchmark-2026-10-07.md create mode 100644 docs/agent-view-layout-plan-2026-10-07.md create mode 100644 docs/install-progress-log-plan.md create mode 100644 docs/project-analysis-2026-10-03.md create mode 100644 docs/scenario-a-multi-app-https-entry.md create mode 100644 docs/screenshots/guide-status.png create mode 100644 docs/security-audit-2026-10-07.md create mode 100644 internal/app/gateway_test.go create mode 100644 internal/config/gateway_test.go create mode 100644 internal/frp/agent_tunnel_integration_test.go create mode 100644 internal/frp/traffic_test.go create mode 100644 web/src/api/base.ts delete mode 100644 web/src/components/MiniDonutChart.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index 4292ff6..40adea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,12 @@ All notable changes to this project will be documented in this file. -## [Unreleased] +## [v1.9.2] - 2026-10-07 ### 新增 - **updater Token 零配置且无额外容器**:主程序首次启动时在共享卷中自动生成 `/run/havline-updater/updater.token`,sidecar 等待并读取同一文件;主服务与 sidecar 支持 `HAVLINE_UPDATER_TOKEN_FILE`,旧部署仍可用 `HAVLINE_UPDATER_TOKEN` 覆盖 +- **Nginx 启停控制**:「公网服务端」页的 Nginx 卡片新增启动 / 停止按钮,停止前有二次确认并提示影响范围;Agent 提升到 `0.15.2`,新增 `POST /api/v1/nginx/action` 端点(start / stop / restart),走 nginx 自身信号而非 systemctl,兼容宝塔、源码编译等没有 systemd unit 的安装方式;两个内嵌 Agent 二进制已重编 ### 修复 @@ -15,11 +16,13 @@ All notable changes to this project will be documented in this file. - **frps 与 Nginx 端口冲突防呆**:frps `vhostHTTPPort` / `vhostHTTPSPort` 禁止使用 `80/443`,服务端保存和 `frps.toml` 生成阶段都会提示改用 `8080/8443`,避免 VPS 上 Nginx 已占用 80/443 时 frps 启动即退出 - **frps 启动前清理旧进程**:systemd 服务未运行但检测到 systemd 之外的手工 `frps` 进程时,启动/重启前先停止旧进程,避免端口占用导致 systemd 反复 auto-restart - **公网反代隧道提示修正**:frps 管理接口可访问但没有任何注册代理时,不再显示 `frps 已注册代理: ;`,改为提示 frpc 未登录或尚未注册该域名 +- **旧服务端配置的布尔默认值补齐**:`config_json` 缺少 `tcp_mux` / `login_fail_exit` 键时(016 迁移给已有服务端补的默认值是 `{}`)按表单默认值补成 `true`,不再静默生成 `transport.tcpMux = false`(每条新连接都要重建 TCP+TLS,首次建连明显变慢)与 `loginFailExit = false`;显式配置为 `false` 的仍然保留 ### 测试 - 新增 Agent 操作结果解析、Agent 版本解析、frps vhost 端口冲突校验和隧道提示文案测试 - 新增 updater Token 环境变量优先级、共享文件生成与解析、等待超时、缺失文件和空文件测试 +- 新增服务端选项缺省 `tcp_mux` / `login_fail_exit` 键时的默认值兜底测试 - `go test ./...`、`npm run typecheck` 通过 ### 变更 diff --git a/README.md b/README.md index 1fbf0f7..8e7dd7e 100644 --- a/README.md +++ b/README.md @@ -1,232 +1,397 @@
-
+
让每一台 NAS,都能安全地连接世界。
+让 NAS 访问更简单
- 面向 飞牛 fnOS 等 NAS 环境的轻量公网访问管理控制台
- 反向代理 · DDNS · HTTPS 证书 · 内网穿透 · Cloudflare Tunnel · 一键升级
+ 面向 飞牛 fnOS 的轻量公网访问管理工具
+ 反向代理 · DDNS · HTTPS 证书 · 内网穿透 · Cloudflare Tunnel · 一站式 Web 管理
+
-
+
+
-
-
-
-
+
+
|
+ 仪表盘 +
+ |
+
+ 反向代理 +
+ |
+
|
+ 新建反向代理规则 +
+ |
+
+ DDNS 添加任务 +
+ |
+
|
+ 证书申请 +
+ |
+
+ 内网穿透 +
+ |
+
|
+ 公网服务端 +
+ |
+
+ Cloudflare 隧道 +
+ |
+
|
+ Cloudflare 新建隧道 +
+ |
+
+ 系统设置 +
+ |
+
frq@kHXF??X-YE9@9W+aH&6-)8;kn=jav<$R*@e4V@z< z*JGlx9UPzR{wSZ$-E#KwG}4}$y$|axjQbE7C1&mDfII2 z)dU_Z%6ReO2MGtywxx+pzX%WE%hXBCdFB)}Pa%7!9k)-H#lb1Ba6{IE+utJV2JM~O z^n)&p)={6FI~F0j8EsV3!Uk0cxo_Xx{aCHSYpLS)PHnVIk%v9CRl$+Wn0sX}P_aI^ z%LTJNijBM+4gwv#W0re);1e8j*HleSjcKp~Icq~Nbaf$E#Xu_PwKcDy8tleb%P;JS zku$}4UP=)*z8a0W=>1-^LY~rr>y+nQ@h`DYZ>CyG#}veGp^~|5z)eLO#Ymt_Bv(sD z@ss(UYztj@5R|B0qwjC+n<+Br9`KiWku!sn@BG4dg3IhkXNo%tsP}j 1y(?Nb&nWLET<-D{pMqWC2wfj!efDEBL9l)j zON&_rlvj(ZU!$n{sJI@s@5rl)##a?zQGI9BDB)lQCnBJ%*T*w?KN#~F8X@tgSk!G4 z>fI`9%uR^Wy!F=KiPl`vEx9knQ=Lmjp5W42b%^~RC~aGI6jt9t bqB`_8E9Du`yirmqNydLt@+zq< zO$NKzC2&M8S>8^xzQ}ALiDv?UfV$6PgG>k|6Y&d<5+?f1g^wglqLY-Sky#@u;_byO z?P(Fn%i%Nm4@~cZd0mBlEw_(A3O+mqk6IFmR$NZ6_{$W}BdvjcbtC|+Kk84RG0j1@ z&Xk<96fVYS+&XFVt(Akj@6T7WE|6wmQzFWRgYBjq> 3NR*Wqj6BB5KJ;Z)suIxFmva@KZCR9&}hOdvcBDw&@ zr65e|H!CO#fd{?T$l^64g1vx#G)mc#hQpWuRu<0{2Af_$a$A8%YN0n!@m6`-SUqfM zr*SA?8#rK_JLz7KQ%K3ENOl+?$0(xXQQS62P2uI}{vt2o`HpE+E~mKi?&Fasc~SbU zS0g(L8YEtbB~ebE9;~f|2H8fCEh1-YtFizC{6jI&cRg%;eC)Jl#fsRCI5DUnN=Wa5 z2*k9Xsu0^zTWP;MTDJab2Kq!;`v!N}H;L25eOsu%eG_`J(OcUQx92-v7CUXo2CaSC z04ng2NZ_tc RPEBT9P%Te=ks`BPq#ami zld1uzSy{Y#;45W+ciSg-E5b+>I5pg@fU|%a%+v@@*vb@1XrRJP-*^?vLKK(Hz!$ZW zi!hvb1)Q&1TYfijy^v{Vh#V7B4dAu};4D%X14;LO1XLxz2U8jxa&8r!)oAu;GJA(y zNn^WEWa8NJisfy@P1nNK&JQTIQn9FqMnz9~UxIZp(ZecHi!F9BHTQj8ldJ&qRWcpx zUPvUe`{YmQH0fbGlWRy}(=0Oqu;WAIipt7I4bAUhfg2Z`T(PKpJ}ygO
vg3A?@VCsVV7sM7u*g<#pne!dhPA>)& zZbIx7M{$l>;BDcV^rr*SMU$vot2RNgPPRq8z2Lu@OF9IW NLPul$!orfdkLr8~;+_5FjTYxJV1Ol3o!cDX5aVU KDyTe1mRV!|lJu5vpyZVPf7C*-o$S ;|n^g!Ri^2 z`yVFjx965)C=f(w=U%26eq{`+g6ITa9-}r2TU%DpS)tI%3HVICKPhDERfhkjy4n^# zLVFmo?K+AGnThwGxEVC79lEtMOQ|Fl*ZOS>_>+1Mt`2=p;yG!P6nfA;w=ZEyO~>LG zX#tKy#nwR^A*(eg8|P)5yoL1N$NrI5%3z{?KAfx(8s*nQ{s^ng0*Itp)f%1jbAk~} z+m53oenmsDL0#k4F-Ey{gdlf|Pn<3K{`GYj12WlMY+rtF#A~yUmeW=N^$hY|hT^^Z z1A8}x I{VPoqD#NP;YUDsVt|;xHi_rBQ8P91Y;nS{^+k7ls+!k! z6BTi@J9Q<1YThqvo ~)2ixfbfQ}gIgR?&l(-Y)PYRl;mr0aBJO$Su zzFiGu*r$AcEHVnrfjmazP@GAs9-0>IzEow>MxG@wSYd$CIRbL%?Uou3?zHRs@=q;5 zJ76QWim;=c$&R?lv1Lx5qDn;L(>F7s+~C0zb*lz#gIbrN7Sa{8O?qaa_vYiMnKznW zT&MbkxEe8-rsv&wr^NCkD2^Px!W2$k!dPn%oK-b;Gng>uSN$BMf5` ztb{T6S#k7gY)-B61iTrw-gg^-mm@$BN&kr)`0RMZ1On$0e$)ni&nf?%)-0N77TVet zS81-Id()tryn(GIlj~F6Yo*Hz2C?*lLyz(Jy8e ~ByW1e?*R;mb
04e8kR?{f)jBToZv%epIAe#DY z?FFCJTv%goEJO{mJ{LDuzZL5{3hYAbX5Sn3BcRE9Ky2T6>tsuf9X1!6m_p0D?%mJ3 zQ^;+Q7iesG#N>mHtA(!y{6R(EG0NDcfknkYHkIB;#;VcZ=di*50EHzn=N=?GI^O#c zNAx85E40nLh$V{?F`jIL0;}m&75Fk@r4*@bpT4ffF?JSeGOk(~HBhi(Rvfs=lR-L+ zc(y3YW4eB)Yy$SNmPgRFD!TfRsa7?#Gc$a`BH5fq@NN`l2I3EC&|151X8mdi8lPgP zsi*l<;P|s`EFU7iF=;+3t_@oH>*Z$Yd`_xOjLec}T861I;R6mpKZTD>^izpdj4qi= z1I7>1&mLFpln=!-_Q1%OpUHO! #y9-Zm=}45b*~$ zL7?-0Rv8eMM*^;AuzA1{JHJDklI>7vGceIDW$D!#A4uTCpIbI|_Nnk0ca_hReS~&* zp4e5%m|WfaVm)k{^4eglaV?@F!`#h^H~xJXlIQcbF-Nd$dsVkGLly`aD@CTq*wkg8 z5v5hqXAqw5c0^pJKCLeQ)e#wLh(~+9o;;qSJi3n_$NH(!VF$}&o0!lZO&wm}wz{P6 z$}_F|O`apZl>E{l3?y 2PP(&KUqkvlC2kHwwtM$y*s9#$hDW&+0z2Mjc z T7!V0o*J95k8tiQ_aou&z7*tOIT&^|!r$Z&YZ(%m0%wxz!E&f&bUtL2i}^hljLN zAXiO}|Bs*3Su-x%_jV85pZ5cfFFnz?b{zC1+b?u6UQhk-SMN^sj(88ri@1PfB{8PF z=PiR~0w2o7DDAfThs-;#s*3=r`7RKQZI0~^&GG9QT8kSv`R@HrRwu)mKgjEk%moE} zahvbzoDCreJVVAEqguR#*v!*&a| jN{NQWxTd>FaR74*HIThVW&s7bMkwDC@o&uqd+B<2Hl_`&w(+!-l2IY zAg?~T4d`9^KNAdH>ehJ777Z%)SFn}UfGwnN09}|TF9Gw-DPt_DAGvqxsR{B?sg?Rg zp2TVa5)=#MrT=4Wc&XOjwtBB0Dmz@tv_GY5G@*r<8Ru8+J~NJ15AM9##v#{tj1>gB zFMdWqkR9~nJ}|6IQ}|Z{pcg?S&>q&1vgbX3P$eMH0?aWN>j)kB zw$?x5@fm7K8m4XB2-Yv@5>%Ud| z07NiI8t+xZA2I%Tjy^r{qR{yB_jDP+k LLK9dO6n?y3zt{C@?Z C9#I*3a;YFL8JES{PU(#!E|H9 z1ka5EoWnfHEpXJHL90qwqfmr)a}=s*OB>Is*bvmU4H7=!yJ_f#A%(!Ryjka1;yd4J zO?&n09SHOTa^~_MVF}Y)RdhdLy&yV8>XeiULD_1|i=n=izNw6KD(WZg#9FTuAs*ZH zFS?ui-n9Ta(3a_km3qLXYNT>cxLsz+L5KCqSl#{}u=Zu` V_1e#J@C6^dtP0t|&P1a`+ecvIecRqA z_KtuW!Y^o?0*N#xiU_U-(-B3oZarzCt&UUcB7&IWfKY#H;NxNqm|N9&F&(?IKSk}s zfXxpvN`FgBs54%cc+U*^=l2L6P7etI?a_vt|G3+t`#znqY>YUBB4`|!SIQ2Rd6tUK zTn?tMGJJPa)y)_$5Yd?yCC`_os2Gv};0HO^|D#wp2+!c584P5B3??Ool?H$r>$ee4 zwY9%I620f#rwwx8`%k-L*y#uP3V#HA9McEV4FHTkP&x@pkx|-58#=ki^|Q@&ftX_E z1IWXVDtkYsuKc?YxPyNFJ-L?AaQ7G@Ea`e}%su&pg~W59ut8>Z4K}`Jn4x6XfloSd ztfBKPGenuK4TtpKLA;V?dL?gTBIFpkBy8sFxKcLX10TLOZr6aafqB5~+8i+-Cl=n6 zf#*319EtII28;i{M1%$)R`h?2DVDJdDSAydWF0#h^ z_6AF z|8EAE9rww2ZkH2)U3Iqq^bP^G{_lHin0au;D)D!gb+_8tJM5_s+)9MIr%_jcET3di zr!*GQwi9XDUAHGB6hvZ%_&cn$Pk;g-yIfD|T%7q=U0{2K7x!C?W?&(sJ5m pNvRbX(x+?f}6>om%e=V5Nr- za|)*5q+9jY%+#DALA@)ZJCH0j{bvL)pu_OLQN8!hFoW6xw#MI%u9}XibE6LkFj?)x z!qDB_gwF0R)R&d _smQA$rF6@^x5OT zc~);zE11q`>rb+vt&50DnF3oFcd)SBIpoUcTVPft5jK;3k{jgOBM}agzUk=0m1jWD zZ0D^dY`!xyq!O#f&+)*RPfqO3$gK6)?h|Pi+Ct39GN}_oIQ)0Dzfb0 8o;i{Kp;=< z-Y& e#;JkF@O`DVp6@Qp&QeBz4^U+>G@qLiXj`nbo#_6le77>eCl4i@%9#9seN&^ z>QB& Ws}JAO*$W#KQgXIQFBwqsMChk${$< zjdl_0@+glTtXJcM`SNg?2uoSE?BisvVtw19tgI}6Vg12mR{67QjjE3miPadlTpQO9 zHb!5F>S;H6R5l+HeB3H`vIOYb@h$C}+}1|dSDZ?{R2J7-T8wv|NflVs_JS7s1|qKzp@m(MmDx;9w%>YQT?|J z6*nIyeN|Sc@6NOzncgDj4I?cHjA8*2BIr;`!1f+#(%Z1h{NS^Emtl9ZfU;*UqHSW~ zX<`&4Ql*>lq$ODMlp=U(v rd~zOqpA=dzVrgY{-9gE}`@6J{hav4*-+<}x2y5Rr za?PRisw~lxr#rPALAn`r-2hepmzwSJ&&!xv6d_r 0*7nPXc@Lo6R-TmGRrf0`1d9GNkgSK3RQ}Q&Z9x;u_HA^3KwH&tU3rrg96{ zXKK9dRjC#}4fCj1`#b#nEZQf~^AD)5xAedCmPaa;Of@5ABHN9h6D_}eXrJ-`J$ 4xf7Z%TGl4(ZPnSt`Z4O5*2CW6U*`e55^BK5ky)P@~eF(5U>XV1`|Ju~HwyWcG|9 zf>G1>(b5!MBzjG6qqLRMep_$yldIN62q!e+*+!##2*vzyGFU+Gf+XN8Qh<)i)RNX@ zyjP?o)pmcx((apjKD*6$Ido91Y_7cdUB;gMn&zo_Lk^-U+V=dswMYA8qC0rsp@q`y znW};nS3#y<7W@dieni=S?<1VBZ800f-NQHHDLO9Ac)8aj%$K3G1JiNfalAhGGwY>e zr0-M&&k4}vRnap7azLx8@_$Ph9)AH7UH;kZNO@1^;ulcI{Nw5mMb;9}2{;WdeQeSA zl9K}y<;<2{yaFm`127tZw}1Iom(M^>K5OXauyzUfewaL<*86wRmmKC3tr?co-t`CQ z+C64*m~y+&HXz_CsNUt@!Yxq!atNrM16fu6n>y2}^ z+xP{Pz)NsPX;A%{-&H7Z&LeD)Imdi-uKS~`k=W|E{3mu+1@_;0;~zCFC@hhA!BeY{ z5wwQr(jwf`gVA_JJ5t!R;ii^+^n%}? zBDzdjg;~>`-()3K3+rGzkX&lQN83zrk3ZxvtC-WEhI;^SdUAAPZJDsw{{C2d_85x- z`#Qh?m=zKrHZYGp7t`v-N$y3sqY7%C0uUd))1W)fzbmkwWt!WD f2~sr?rarUP2Fj*fB0DmrORqjI6)09tTuCg%`}L zC1G0_d+yBhv{mK($qnDl6NKQ^q%vJ}n>&4DEbg#li|a9}!fm2Fgl>*c!(GDk)dn9h zP!+3>K%h@r03izFUZQOb*G*?3q`E2l8@L|)6gdtL8 1v?%K9StA`Q);Po% zH c0j^_9x-T r2+b)(@#}gHzFmi;$)X& zWbG$545L;!z?C~{RD1UT;LNdyK64}9B>QyNqSb3L!-*n%8T5t34*dwB>RrQ((>>J4 ztS |YueJn8PbP}+$S!R!%7<>X0Bd9V+xWhHen_31-ITr1;a5lH6QQ$!!FF50 z!1E3!N1P4wv*p$>h!p)h2>SLs>k4UOw&cLlMlasHrE6TuP;Wxp7ZDeal~GQZE{b_! zj5IxU M?vMk z6M_<)RuA%cS)B*EL-~CXRVqkxW4u8K;N N#4 zTgfVZovL23HL_q_5P15<;8$<<`|>dhgIPN)pjgfSbZf;Em7#X@&gi56LYer#2s< q$s@Js)k#)RVTM1&?$`YHaM%TAK(7w=@|?CGV6tmlk%?oD)Sds zYZsm44(H#mcpylo&_TKuxM%nKqwU3)WjyREW$;g-ah`~4GwFFNv%StdLO+ua1K-ik zt9Fn^Z+63|_6c;d$f5m3*YTUSM_0x7wtcC2Zx;y>t=0<#RmN0qvenP;1@d*io6B&X zxV^UmJ-vq$ps*R{h{tL3Ae;GRsymD0^rO8sYsTHG!_B>n{Qaik&bGJE%H@@~*e{{Y z=Y1(OXG6457GL})NgFNMrPnj5&urFZ-^yc2<+jUB6RlfWe&xX6UUle7?3dwtJ&|>_ zl#JFo%fX6ifuZ7p<@L4YxGp?iXIeV6o6B-7rIXUVzEoy2#ntH~o@iC=L7|)1_6Vep zn8>`o@5z7GAW@)>HYaIr%6hF*wGzHFO}Vedu_ hK?z;JG=|0Dq?@K6z zYeJ5!-s{S7pyY5i_@@>yR9Tz<`&>pR1cNKODPOZRJt7Fqgl=w`p8uxJZ4pdQS(??s zdk-Gi*(}CNR+09;HZ- dbGTha0HDe)aa5~e@QjC-SHhH`v zAiKu!U 0juQePWor5`(K|9$1mTS#ojyOgWOY}d^bi-=yBatjBO*hk>gp7*uY ziJ4OcoHL>+kgj(fld+bUAYdJvzAIOgzW76}7Z&TB={bvn>r&3$w#WpxYG>${;E;qt z=so4R`YZIBmQJE(cZO`$N{8SCZ#N-yIQbU3R(x!8axT9cr(&8(EX293e%3~<%Cq=z z`+;}Hw!V!a7Y!BI@6TKDoAD6(j}60z2_E2cKZ{{1wxhA#VCNedB#%IC^eZ&NubedU zQa5_2DRZ{Ktn7z8nWX1OK8>^k3Im!5&VKbpB j(8lql82C_B&%lT#jg6kF2d%4By0;A~3O6@B mHC9U+rC*t5P?bZ&&|>&2i$|=2f}i6NWsHeQi?@ %0#tSw(`->hYK6p9dDt2?|{-jv$rAodPL$>seqYHb0 zn5Z~1h>gT%I0>A_ rgf_Cd^)FxRWCV*T_ZN4*}NBW?fV&IYuy|ZAb0{eB2 zFG-s>LwqN@AUVi+7Os6vcd&|@;_Q~EZH+I%7_^zKAKhW<>Pk<`RW&rUkL zK6`7;W1OXLF}F1;FGl)Rpkh$QX;4Pt%!*-O8wU$lv5H!os_ UNrUAfRsOtuV?m0DCE0Yu)Yy8|e{_Wms*kK->?NLjvZ zT;et{n}KACN); 8{j z&I=BvuW_D;@FY#+Dz0MI6ylEPE$WBQXtf?R+hz#-tFz#mW*jr`#3ZoifUW3+VhOCA zC2%ytw$qcu5tWZi)uRV`cUL%4KDNm?*A8taJ}S&{`BG?xL3 }~ezVdZ#xzHJ^RW2B~(uap0S)*p% zqbbihRBz+7YWy~aF|7-XlPV`m|5zz0mX8;(U5Gkfts_wuZ5vzSP`8mGked3ZA9`|P zQuJ4$Sg1g2wn@r>277HFHNEWYa-2x(`)^ho%V!(i#9-o>vtjr9%3~{je>%DI`8Jn* zoi18E1JKaVw=cso*TF!ZXPA9*Sxt=z3qn0(e+G}m GEm0BZ5KLYsFB3zhOP{^UE9q=R-GDs!YFqG@DrDM^Pud9hvR7@I{=lULCEvX(+Gf zRH@t4yx^-93)!v&YK?e=gm#~*`Qg`3Y_$&@NJp9s9kQ-SF0lz*iLsnB=48VcHbv|z z@6h6x(Q8c;z08?~dLB^+8BsmDUB8Bcyq!4QgY4B*RuRhnG*>NFl2h_5^yV{Jqhf|9 zW>h69kK@!1>=YD Ku$e-18h`Km0p|251C^N`xEoW-EX+`9D*uDl)je~JzH&!M5qICr z;_|~{+VR#}et-PWck68rOFrYP|0R7bt4+Bmje7jFBLBiYSjfw&h%N$N=aiWLpdo`8 z!ie#3sp 7IADXFGEvtdkKB(St*&E|{%UW9IrEl#T1hZ&KZPrB^R8kZV*c@|2$ zw-laaH%lIM*!o8I!3Dq$uM{+(6Qpf|X19VFjP&63aC9HblRs7~b6N6rVDEg{{ei9> zM~lI?3pin9&sSRP 2kMbN`%r} zc~bFJp&Gh+F~+rp+GSCTy8V_t^fYu>k~p)G+Sn55pr5R&*sw%x*SRjx##+diz^MfZ zePTtJpdzD-OV)~qo_n>2L3{$L-Fz)rFAp=51J=+J-tg_b55u9+A&>PsSx@`opZUGK zig#(>s)!@yAS 07PQBg}8w7McELtT`X Ttq5=f04ATg<$u G!|9$;zOm z`^c~a8hojmdGmdnIO1bNWol)eXrzerPTz$irZXQm^kW`< RyngFseH%S4os7r*6lW~=JE?K?jYgMFGMebVlgI2ZZ%Musm7*FP zwCY3DkzI_YX7oZ4OCBNKn2qTzchU9skd_X7v&5t^tsveqJjwjF8WBGT7N?Oti)s_r z;)WuIaBUxvWWQ1KK|*9&F1Pv0>yY7#A6lzw8>~iGw}54$=w}b-kUWGMkry_j+_7XI z*zIp=>9=!{l$dXOttP;>vb2!1RQAFQ*;d|ewnlFgwX?z;N%&N?JCHYaM~GmSs&ziN z9F5^F5y*ySB^ONK8BOkO$c-oJUC~n}UVq*g;i}|G_W+mWDlsQBDCL}U=O|ks>B_|n zQ)Zg`An;}G8eG~wbBkSZISV^=4MtDer5hNHwdQg#Ud2b<@fzWvkJN1Ry)UK- #?u6DFG~FhYNgwmk1T%q^?Fh|)zKA0=1-q1Nf}WKUg;{!FILGk9uLj$ z>>Yzrcd5667fa5eqqR9p ZVmFpfH&A#;+Bto%xJ zPuv;pII-n?^SB(@*!F3~G`71#*Qr1z+*;bJ*6(xu@}-Hwr=gMfGwL&qQ^v<|>~?$0 zL!;ix+Q-5wBtDKH> ^%8 znLUE4a|F%hp EjUo*;VS5%CjK1DP57UL5#2b0{YYv#4 z$JLi)A3g0c+gJXT)@5{YL~eOzm*lR mgRFd$LidxL%i$f)f+BC& jw(?BNtLJd3{kp?cvgs zVJj~L%u5khPa|IL?brd4(eHWCrnwL(m#&OM|F3!HuU~5)^CY6&*T&+3!~9dglIU8% zR9ztnDAynzjzCu+_jaT3lhVe-QfIZ9g?^#c-X?KqZb+06)Iny}c-nJa!<@N+&<}Qv zm2+b#y*Xk2z4rT_(n3klO=tv|lk-I#bTNh)hr?hfAnVlMWsQXALKQP)=oP@w+dbG2 zOkzw3hKw`)rkY!_o=FCb#=mR?;tbdDy&m_ec&tmtMO~@GkF`8ymJp<`tKfPOz*Jnj zey?YincC#qA=~%frp#q_@V-V&W%UzbD7>H@_!ss#^0)q$LMlR kg$u{JO+WAdVk{oXdIZf)KX-VB$*|q0l3xY_DrO;}3@>=H* zI)Q%jY2(@&nEj#Fh?S$`M^$N@qY%jOo{VD!5wJyZUYTxLONay d9R$LEVedTxHK O%qZe3)@9-Wf#}?(ZpEEC1?31X0Uo4p%IO^ks17oN|*amcY#NhvH z{_Ee;;{T+=V2?5VpSkz{l&OXS%ewybWNbw1HIUgtvGR+B%GHoqdAty`5vUBQP(Psr zDe nMfT%~_hpNJp)R<=*+PNUlR|c%)(KxA{DIj @==+EW^*)860azvU(0^K%wD{XIGMieypflh{U4{*~#JT6M=8hIMwkD;DbuQ9Sin z1GTbwn8-g9l)YlTd?F~9pIiXH4!ozO+|v>dRfR$-O=L`R|6qm5Mh{;5n;>4XFHIS} zFbq^<9huzyARZc10(xu5q&4fg&DVeK`mOGPp`E>isH5=UDQG+al)1`WAcv(`vIG@- zN~yo#=DC^+suAjKF**lNifIZNXL(QqPi^p>ZhU1B_&}L$Z`x(NI0&7ELn^fa#B|)M zEy3=rC{@+Qt+`l}aW zO0gC}Syp^*k-|~1h@7h63$03%wlO?kZsH3aE*L8j-WiJk^PlD)N@tt)|1(z_MeC@m z?LXI9ie+TU1|!U$#NSb=TkSmdSL(g;440d3xz-`TFgpdM2}9_ht9FHBGE?+r2w5i< zGkaap?`{h$ X(be4n~MsB*5p**X-1zOGqdXFNk6=kJ_u zx%&zWZY(#dLF<~M*unj0Q$BI~X;)p*S2uLu2Wx3RoHlIFW9C~U2a_K1G#}hA0+IDd zDE+XyDL?ueci_~bzx^TPn_!;iOW?@OC1&X|2jV#8WK7(DTW%l!L)%?v`G@)55B4cN z2WRVVEKce4NWax1?0={S*io{uAOuqWpk|NUzmfvB1wTH1K=5M}{CE(4)Pf)7@c-kC pK#_z%xLnUf!#zvFp}p1f0mxK!at>FH&l7wDF)}dMCtSV#=pSbViW&d_ literal 0 HcmV?d00001 diff --git a/docs/security-audit-2026-10-07.md b/docs/security-audit-2026-10-07.md new file mode 100644 index 0000000..4448274 --- /dev/null +++ b/docs/security-audit-2026-10-07.md @@ -0,0 +1,271 @@ +# Havline 安全审查与代码审查报告 + +> 审查日期:2026-10-07 +> 审查对象:Havline 主仓(`F:\Fonu-main\havline`),版本 `1.9.2` +> 审查方式:定向安全面扫描 + 关键路径人工复核 + 自动化检查 + +## 一、审查范围与方法 + +代码规模: + +| 部分 | 文件数 | 约行数 | +| --- | --- | --- | +| Go 后端(`internal/`、`cmd/`) | 292 | 40,173 | +| 前端 Vue/TS(`web/src`) | 61 | 23,538 | + +覆盖的安全面: + +1. 命令执行(`os/exec` 全量枚举,回溯参数来源) +2. SQL 构造(拼接式查询扫描) +3. 路径处理(`filepath.Join` 与用户输入的交汇点) +4. 认证与授权(路由注册、中间件、会话、令牌) +5. 加密与密钥(TLS 配置、证书、令牌生成) +6. 出站请求(SSRF 面) +7. 文件解压与导入(tar/zip slip) +8. 前端注入与凭据存储 +9. 日志与备份的信息泄漏链 + +自动化检查结果: + +``` +go vet ./... 通过(无输出) +npm run typecheck 通过(havline-web@1.9.2) +gosec / govulncheck / staticcheck 本机未安装,未执行 +``` + +`gosec`、`govulncheck` 未安装,依赖漏洞与规则化静态扫描属于本次未覆盖范围,建议在 CI 中补上。 + +## 二、总体结论 + +整体安全基线扎实,不是"到处漏风"的状态。认证、路径安全、解压安全和 agent 暴露面这几处明显经过专门设计,多处还能看到针对具体攻击手法的注释(例如备份解压用 `filepath.Rel` 而非前缀比较,理由写在代码里)。 + +本次共确认 1 个高风险、4 个中风险、6 个低风险问题,均不属于"未授权访问"级别的直接可利用漏洞。高风险项是公网部署下的认证入口缺口,建议优先处理。 + +> 修订说明(2026-10-07):初版把「Agent 远程监听为明文 HTTP」列为 P1-2。复核后确认该结论有误——项目已实现 SSH 隧道与 HTTPS 证书指纹固定两种安全传输模式,`transport_security` 字段只描述 Agent 本机监听地址的安全等级,与 Havline→Agent 的连接方式无关。该条已改写为 P3-6。 + +## 三、漏洞与风险清单 + +### P1 高风险 + +#### P1-1 登录接口无速率限制与失败锁定 + +位置:[auth_handler.go](F:/Fonu-main/havline/internal/api/auth_handler.go) 的 `Login`、[auth.go](F:/Fonu-main/havline/internal/auth/auth.go:120) 的 `Service.Login` + +`Login` 对密码错误直接返回 `ErrUnauthorized`,没有失败计数、没有按 IP 或用户名限流、没有临时锁定,也没有写入安全审计日志。扫描 `internal/api/auth_handler.go` 与 `internal/auth/auth.go` 全文件,未发现任何 `rate` / `limit` / `attempt` / `lockout` / `backoff` 逻辑。 + +影响:Havline 面板常被端口映射到公网。攻击者可以对 `admin` 账号持续爆破。`bcrypt`(DefaultCost)把单次尝试成本抬到约几十毫秒,显著提高了爆破代价,但这只是延缓,不构成阻断;分布式来源的尝试更不受影响。 + +建议:按「用户名 + 客户端 IP」做令牌桶限流,失败累积到阈值后指数退避并短期锁定;成功登录后清零计数;失败事件写入系统日志便于发现。 + +#### P1-2(已降级为 P3-6) + +初版结论「Agent 远程监听为明文 HTTP」经复核不成立,已改写为 P3-6,原因见该条。此处保留占位,便于与初版对照。 + +### P2 中风险 + +#### P2-1 证书域名校验过弱,与其它模块不一致 + +位置:[import.go](F:/Fonu-main/havline/internal/certificate/import.go:104) `validateDomainLabel` + +```go +func validateDomainLabel(domain string) error { + if domain == "" { + return fmt.Errorf("域名格式无效") + } + parts := strings.Split(domain, ".") + for _, part := range parts { + if part == "" || len(part) > 63 { + return fmt.Errorf("域名格式无效") + } + } + return nil +} +``` + +只检查「按 `.` 切分后每段非空且不超过 63 字符」,没有字符集白名单。对比另外两处的严格程度: + +- Agent:[server.go](F:/Fonu-main/havline/internal/agent/server.go:112) 用正则 `domainPattern` 限定 `[a-z0-9-]` +- FRP:[validate.go](F:/Fonu-main/havline/internal/frp/validate.go) 的 `validDomain` 做了完整标签校验 + +具体后果:导入证书时域名取自证书的 `DNSNames` 与 `CommonName`(攻击者上传自签证书即可完全控制),`a/b` 这类含斜杠的值能通过校验,随后进入 `certStorageNames` → `filepath.Join(certsDir, name)` → [service.go](F:/Fonu-main/havline/internal/acme/service.go:624) 创建嵌套目录并写入 `fullchain.pem` / `privatekey.pem`。 + +好消息是 `..` 恰好会被「空段」检查挡住(`../..` 切分后产生空段),所以目前无法逃出 `certsDir`,实际危害限于目录污染。但这是靠巧合防护而非显式设计——`..` 与 `/` 的拦截不应该依赖同一条规则。而且这条宽松规则与另外两个模块的严格规则并存,属于典型的校验口径分裂。 + +建议:抽出统一的域名校验函数,三处共用;`certStorageNames` 侧再加一道 `filepath.Base` 与字符白名单兜底。 + +#### P2-2 TLS 私钥可经 API 明文导出 + +位置:[cert_handler.go](F:/Fonu-main/havline/internal/api/cert_handler.go:112) `Download`、[download.go](F:/Fonu-main/havline/internal/acme/download.go:23) + +`GET /api/certificates/{domain}/download?part=key` 会直接返回 `privatekey.pem` 内容。 + +该接口有正确的鉴权(走 `RequireAuth`),也做了路径校验(`validateCertFilePath` 用 `filepath.Rel` 确认在 `CertsDir` 内,写得很好),且域名必须存在于数据库记录中,不能任意读文件。 + +风险点在于权限粒度:任何有效会话或具备 `write` scope 的 API Token 都能一次性导出全部站点私钥,没有二次确认、没有审计记录。结合 P1-1,一旦口令被爆破成功,攻击者可直接拖走所有证书私钥。 + +建议:私钥导出要求重新输入口令或二次确认,并记录审计日志;或对 `part=key` 单独要求更高权限。 + +#### P2-3 备份文件包含应用日志,日志里含初始管理员口令 + +位置:[auth.go](F:/Fonu-main/havline/internal/auth/auth.go:64) 首次启动分支、[service.go](F:/Fonu-main/havline/internal/backup/service.go:139) `addDir`、[config.go](F:/Fonu-main/havline/internal/config/config.go:102) + +三处叠加形成一条泄漏链: + +1. 首次启动自动生成管理员密码后,以明文写入系统日志(`"首次启动已创建管理员账户…"` 携带 `password` 字段) +2. `LogsDir()` 返回 `DataDir + "/logs"`,位于数据目录内 +3. 备份打包 `addDir` 时只排除了 `backups` 子目录,其余目录整体入包 + +结果:导出的备份归档里带走了含明文初始口令的 `app.log`。 + +影响取决于用户是否已改密码——初始口令是一次性的,若未及时修改则等价于口令泄漏;即便已修改,日志中残留的其他敏感信息(DDNS 凭据、Agent Token 片段等)也可能随备份外传。 + +建议:首次口令只输出到标准输出或一次性文件,不落常规日志;备份打包时排除 `logs/`(或至少排除 `app.log`)。 + +#### P2-4 自更新按可变 tag 拉取镜像,不固定 digest + +位置:[runner.go](F:/Fonu-main/havline/internal/updater/runner.go) `compose` / `currentImage` 相关逻辑 + +更新流程通过 `docker compose` 拉取 `havline: ` 镜像并以 tag 为主键,全文件搜索未见 `sha256` / `checksum` / 签名校验。 + +`docker pull` 自身会校验 manifest 与层 digest,所以这不是"完全不校验"。真正的风险是 tag 可变:同一个 `1.9.2` 在不同时间可能指向不同内容,镜像仓库一旦被入侵或凭据泄漏,可以覆盖 tag 下发任意镜像,且本地无法察觉版本内容被替换。 + +建议:发布时记录镜像 digest,更新流程固定 digest 或校验发布清单;至少在状态里展示 digest 便于比对。 + +#### P2-5 会话 Cookie 的 Secure 判定信任 `X-Forwarded-Proto` + +位置:[auth_handler.go](F:/Fonu-main/havline/internal/api/auth_handler.go:181) `requestIsHTTPS` + +```go +func requestIsHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") +} +``` + +任何来源都可以设置该请求头,没有可信代理地址白名单。代码注释解释了为什么这样取舍(明文部署下若强制加 `Secure` 会导致无法登录),属于有意为之,但它把"是否启用 Secure"的控制权交给了客户端。 + +实际影响有限:伪造该头只会让服务器给 Cookie 加上 `Secure`,在明文场景下反而导致自己的登录态失效(自伤)。真正的隐患是它掩盖了部署形态——反代场景下若请求直达后端,判定的可信度无从保证。 + +建议:改为按配置项显式声明「对外协议」(部署时设定),或限定只信任来自已知反代地址的 `X-Forwarded-Proto`。 + +### P3 低风险 + +#### P3-1 备份恢复未防符号链接 + +位置:[service.go](F:/Fonu-main/havline/internal/backup/service.go:108) `Restore` + +解压逻辑已经做对了最关键的部分:跳过非 `TypeReg` / `TypeDir` 条目,并用 `filepath.Rel` 拒绝逃出 `dataDir` 的路径(注释还专门说明了为什么不能用前缀比较)。剩余缺口是写入前没有 `Lstat` 检查目标是否已是指向目录外的符号链接——若 `dataDir` 内预先存在恶意符号链接,写入会跟随它。 + +触发前提较苛刻(需要先能在数据目录内造符号链接),定级 P3。建议写入前用 `os.Lstat` 检查并拒绝符号链接目标。 + +#### P3-2 随机口令生成存在取模偏置 + +位置:[auth.go](F:/Fonu-main/havline/internal/auth/auth.go:211) `generatePassword` + +```go +out[i] = letters[int(randBytes[i])%len(letters)] +``` + +`len(letters)` 为 58,不能整除 256,因此字符分布轻微偏向字母表前部。熵损失极小(约 0.01 bit/字符),实际不构成风险,但属于应当消除的实现瑕疵。建议改用 `crypto/rand.Int` 或拒绝采样。 + +#### P3-3 会话生命周期固定,无滑动续期与绝对上限 + +位置:[auth.go](F:/Fonu-main/havline/internal/auth/auth.go:22) `sessionTTL` + +会话固定 7 天过期,既不在活跃使用时续期,也没有"最长生命期"概念。两个方向都不理想:长期不用的会话仍保留 7 天有效窗口;活跃会话也不会因超期而强制重新认证。 + +#### P3-4 FRP 服务端布尔选项缺少默认值兜底(已修复) + +位置:[validate.go](F:/Fonu-main/havline/internal/frp/validate.go:289) + +本次审查中已修复:迁移 `016` 给已有服务端补的 `config_json` 默认值是 `{}`,而 `normalizeServerOptions` 只兜底整型字段,导致 `tcp_mux` / `login_fail_exit` 保持 Go 零值 `false`,生成 `transport.tcpMux = false`(每条新连接都要重建 TCP+TLS)与 `loginFailExit = false`。现已按「键缺省取默认、显式 false 保留」反序列化,并补了单元测试。 + +#### P3-5 Agent 路由内容可写入任意 Nginx 配置(设计如此) + +位置:[server.go](F:/Fonu-main/havline/internal/agent/server.go:1370) `routePath` / `applyRouteContent` + +`PUT /api/v1/routes/{domain}` 的内容由管理端提供,Agent 落盘后执行 `nginx -t` 并 reload。域名已严格校验,且改前留版本、校验失败自动回滚,工程上做得很细。 + +需要记录的是权限含义:持有 Agent Token 即可写入任意 Nginx 指令。若 Nginx 以 root 运行且存在可用的危险指令组合,等于把文件写入能力提升为更高权限。这属于"Agent 本就拥有主机管理权"的设计前提,建议在文档中明确这一信任边界,而不是在代码层修补。 + +#### P3-6 Agent 传输模式的默认值是明文 http + +位置:[028_agent_transport.sql](F:/Fonu-main/havline/migrations/028_agent_transport.sql)、[AgentView.vue](F:/Fonu-main/havline/web/src/views/AgentView.vue:255) + +初版把这里误判为 P1。复核后的准确情况是:Agent 本机监听 `127.0.0.1:7700` 明文 HTTP 属于设计(回环流量不出主机,`validateListenAddr` 也会拦截非回环地址),而 Havline→Agent 的连接有三种模式,其中两种已经是安全的。 + +| 模式 | 路径 | 安全性 | +| --- | --- | --- | +| `tunnel` | Havline 侧建立 SSH 隧道,转发到 VPS 的 `127.0.0.1:7700` | 安全,Agent 无需对外开放 | +| `https-pin` | Agent 侧 Nginx 监听 TLS 端口(默认 7443),证书由 Havline 生成下发,客户端做 SHA-256 指纹固定 | 安全,证书 365 天且到期前自动轮换 | +| `http` | 明文直连 Agent 的 7700 | 明文,Token 可被嗅探 | + +实现依据:[service.go](F:/Fonu-main/havline/internal/frp/service.go:831) 的 tunnel 分支、[service.go](F:/Fonu-main/havline/internal/frp/service.go:885) 的 https-pin 分支、[agent_https.go](F:/Fonu-main/havline/internal/frp/agent_https.go) 的证书生成、[agent_client.go](F:/Fonu-main/havline/internal/frp/agent_client.go:79) 的指纹固定。 + +前端也已经处理到位:`agent_transport === 'http'` 时显示告警「当前仍是公网明文 HTTP。点击「升级 Agent」可自动迁移到 SSH 隧道安全模式。」,并提供一键切换。 + +残留风险只在默认值:迁移 `028` 把 `agent_transport` 默认设为 `'http'`,已有记录与新建记录都落在明文模式,需要用户主动迁移。若部署时开了 `HAVLINE_AGENT_ALLOW_REMOTE=1` 且未切换,就会形成长期明文暴露。 + +建议:把默认值改为 `tunnel`(或在新装流程中默认选择安全模式),并在「Agent 处于远程监听且 transport 仍为 `http`」时于服务端列表持续标红,而不是只弹一次告警。 + +## 四、代码质量审查 + +### 结构与规模 + +- 分层清晰:`cmd`(入口)/ `internal`(领域服务)/ `web`(前端)/ `migrations`(增量迁移),跨模块调用基本走接口。 +- 单文件过大是主要结构问题:[agent/server.go](F:/Fonu-main/havline/internal/agent/server.go) 达 71 KB,把路由、Nginx 管理、证书、frps 控制、命令执行混在一处。按领域拆分(route / nginx / frps / certs)会显著降低审查难度。 +- `internal/frp/service.go` 同样偏大,本次修 bug 时也要在多处来回定位。 + +### 规范一致性 + +- 中文注释质量高,多处注释解释"为什么这么写"而非"做了什么"(例如备份解压用 `filepath.Rel` 的原因、`natTraversal` 只在显式禁用时输出),这类注释对后续维护价值很大。 +- 主要不一致是同一语义三种实现:域名校验存在 Agent 正则、FRP `validDomain`、证书 `validateDomainLabel` 三套口径,宽松程度不同。建议统一。 +- 文档与实现存在偏差:[FrpView.vue](F:/Fonu-main/havline/web/src/views/FrpView.vue:385) 描述诊断有「TCP 可达 / frpc 登录 / 代理可达 / DNS 解析 / 归属地」五项,而 [diagnostics.go](F:/Fonu-main/havline/internal/frp/diagnostics.go:88) 实际只产出 `tcp_reachable`、`frpc_login`、`dns_resolution`、`location` 四项,「代理可达」并不存在;且诊断只探测控制端口,不覆盖规则的公网远程端口。 + +### 测试 + +测试密度在同类项目中属于良好水平:认证、令牌、备份、Agent 授权、路由版本、frp 配置渲染等关键路径均有单元测试,`agent` 包甚至有遍历全部路由、断言无 Token 必须返回 401 的测试(`router_auth_test.go`),这是很有效的防回归手段。 + +本次验证命令均通过: + +``` +go test ./internal/frp/ 通过 +go vet ./... 通过 +npm run typecheck 通过 +``` + +未覆盖:`-race` 竞态检测、依赖漏洞扫描(`govulncheck`)、SAST(`gosec`)本次均未执行。 + +## 五、修复优先级建议 + +按投入产出排序: + +1. 登录限流与失败锁定(P1-1)——改动集中在认证层,收益最大 +2. 统一域名校验函数(P2-1)——顺手消除三处口径分裂 +3. 私钥导出加二次确认与审计(P2-2) +4. 备份排除 `logs/`,初始口令不落常规日志(P2-3) +5. Agent 传输默认值改为 `tunnel`,并对明文模式持续标红(P3-6)——迁移里改一个默认值即可改善默认安全姿态 + +## 六、已确认的安全设计 + +以下设计经代码复核确认正确,无需修改: + +1. 口令使用 `bcrypt`(DefaultCost),会话 ID 由 `crypto/rand` 生成 32 字节 +2. API Token 仅存 SHA-256、区分读写 scope,且 `password` / `logout` / `tokens` / `update` 路径禁止 Token 访问,避免令牌自提权([middleware.go](F:/Fonu-main/havline/internal/api/middleware.go)) +3. Cookie 设置 `HttpOnly` + `SameSite=Lax`,条件启用 `Secure` +4. 路由默认全部经过 `SessionMiddleware` + `RequireAuth`,`public()` 仅用于登录与登出 +5. 备份 tar 解压拒绝目录逃逸,归档名走正则白名单,解压不照搬权限位 +6. 版本文件读取拒绝 `.` / `..` / 含路径分隔符([fsutil.go](F:/Fonu-main/havline/internal/fsutil/fsutil.go:185)) +7. Agent 全部路由统一鉴权、常量时间比较 Token、默认仅监听回环、`upstream` 与域名过滤防 Nginx 配置注入 +8. TLS 指纹固定实现正确:pin 为空时 fail-closed([agent_client.go](F:/Fonu-main/havline/internal/frp/agent_client.go:79));Agent 自检的 `InsecureSkipVerify` 只用于回环地址并随后比对指纹 +9. 未放宽 CORS;前端不把 Token 存入 `localStorage`(仅主题偏好) +10. SQL 全量使用参数占位符,未发现拼接式查询 +11. Agent 远程传输已提供 SSH 隧道与「HTTPS + 证书指纹固定」两种安全模式,证书自动轮换,前端对明文模式给出告警和一键迁移入口 + +## 七、结论 + +本次未发现未授权访问、远程代码执行或 SQL 注入级别的直接可利用漏洞。代码在这几类问题上防守到位,甚至优于多数同类自托管项目的平均水平。 + +真正需要处理的是公网暴露面下的认证入口缺口:登录没有限流(P1-1)。Agent 的远程传输已经具备 SSH 隧道与 HTTPS 指纹固定两种安全模式,遗留问题只是默认值仍落在明文 `http`(P3-6)。其余问题集中在校验口径不统一、敏感数据出口缺少二次确认与审计这两条主线上。 diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index 274a058..00efed9 100644 --- a/docs/system-upgrade.md +++ b/docs/system-upgrade.md @@ -59,6 +59,9 @@ ## 4. 首次部署 +`docker-compose.yml` 和 `docker-compose.hub.yml` 会由主程序在共享卷中自动生成 updater Token 文件,sidecar 等待并读取该文件。`HAVLINE_SESSION_SECRET` 可以留空,由应用生成并写入数据目录;已有数据时必须沿用原值。 + + 一键升级依赖 `havline-updater` 已经运行。 旧版本升级到 v1.3.0 时,需要先手动执行一次: @@ -85,6 +88,11 @@ docker compose -f docker-compose.hub.yml up -d --build | 变量 | 说明 | | --- | --- | +| `HAVLINE_UPDATER_TOKEN` | 可选覆盖值;显式设置时优先于 Token 文件 | +| `HAVLINE_UPDATER_TOKEN_FILE` | 主程序与 sidecar 共享的 Token 文件,默认 `/run/havline-updater/updater.token` | +| `HAVLINE_UPDATER_SOCKET` | sidecar 监听的 Unix Socket | + +| 变量 | 说明 | | `HAVLINE_UPDATER_SOCKET` | sidecar 监听的 Unix Socket | | `HAVLINE_UPDATE_PROJECT_DIR` | Compose 项目目录,容器内为 `/workspace` | | `HAVLINE_UPDATE_COMPOSE_FILE` | 只允许 `docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml` | @@ -194,6 +202,8 @@ docker compose -f /实际/的/Compose/目录/docker-compose.hub.yml up -d --buil ### `docker-compose.hub.yml` 模式无法读取远端版本 +当前 hub 发布源固定为 `https://jayhub.top/mrlee/Havline.git` 的 `main` 分支;检查 Compose 文件中的 `build.context`、`HAVLINE_UPDATE_REPO` 和 `HAVLINE_UPDATE_BRANCH` 三者是否一致。 + 检查 sidecar 容器内是否可以访问 Git 仓库: ```bash diff --git a/internal/agent/insights.go b/internal/agent/insights.go index b05ff67..63b3659 100644 --- a/internal/agent/insights.go +++ b/internal/agent/insights.go @@ -3,6 +3,7 @@ package agent import ( "bytes" "crypto/x509" + "encoding/json" "encoding/pem" "fmt" "io" @@ -309,6 +310,83 @@ func (s *Server) nginxReload(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, payload) } +// nginxAction 启动 / 停止 / 重启 VPS 上的 Nginx。 +// +// 不走 systemctl:宝塔、源码编译等安装方式没有 systemd unit, +// 统一用 nginx 自身信号控制,与 reloadNginx 走同一条路径,行为一致可预期。 +func (s *Server) nginxAction(w http.ResponseWriter, r *http.Request) { + var req struct { + Action string `json:"action"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.Action != "start" && req.Action != "stop" && req.Action != "restart" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action 需为 start/stop/restart"}) + return + } + nginx := s.nginxPath() + if nginx == "" { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "未找到 nginx 可执行文件:请先安装 Nginx,或用 HAVLINE_AGENT_NGINX_BIN 指定路径"}) + return + } + s.mu.Lock() + defer s.mu.Unlock() + if err := runNginxAction(nginx, req.Action); err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + // 启停异步生效,留出短暂窗口再重新探测,避免页面读到旧状态 + time.Sleep(600 * time.Millisecond) + s.invalidateNginxProbe() + probe := s.probeNginx() + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "action": req.Action, + "nginx_running": probe.Running, + "nginx_ok": probe.OK, + "nginx_error": probe.Error, + }) +} + +// runNginxAction 执行一次启停;已经是目标状态时按幂等处理,不报错。 +func runNginxAction(nginx, action string) error { + running := nginxRunning() + switch action { + case "start": + if running { + return nil + } + return startNginx(nginx) + case "stop": + if !running { + return nil + } + return stopNginx(nginx) + default: // restart + if running { + _ = stopNginx(nginx) + time.Sleep(500 * time.Millisecond) + } + return startNginx(nginx) + } +} + +func startNginx(nginx string) error { + if out, err := exec.Command(nginx).CombinedOutput(); err != nil { + return fmt.Errorf("启动 Nginx 失败:%s", strings.TrimSpace(string(out))) + } + return nil +} + +func stopNginx(nginx string) error { + if out, err := exec.Command(nginx, "-s", "stop").CombinedOutput(); err != nil { + return fmt.Errorf("停止 Nginx 失败:%s", strings.TrimSpace(string(out))) + } + return nil +} + // frpsConfig 回读 VPS 上的 frps.toml,供应用侧与「即将下发的内容」比对,检出被手工修改的情况 func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) { path := strings.TrimSpace(s.cfg.FrpsConfigPath) diff --git a/internal/agent/server.go b/internal/agent/server.go index c0c00a1..511bef9 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -31,7 +31,7 @@ import ( "github.com/havline/havline/internal/sysinfo" ) -const AgentVersion = "0.15.1" +const AgentVersion = "0.15.2" // Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 type Config struct { @@ -224,6 +224,7 @@ func agentRoutes(s *Server) []agentRoute { {http.MethodGet, "/api/v1/frps/config", s.frpsConfig}, {http.MethodGet, "/api/v1/certs", s.certs}, {http.MethodPost, "/api/v1/nginx/reload", s.nginxReload}, + {http.MethodPost, "/api/v1/nginx/action", s.nginxAction}, } } diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go index e41304a..beb0026 100644 --- a/internal/api/frpmulti_handler.go +++ b/internal/api/frpmulti_handler.go @@ -277,7 +277,12 @@ func (h *FRPMultiHandler) AgentInstall(w http.ResponseWriter, r *http.Request) { writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效") return } - server, err := h.svc.AgentInstall(r.Context(), id) + var server frp.Server + if r.URL.Query().Get("trust_new_host_key") == "true" { + server, err = h.svc.AgentInstallTrustHostKey(r.Context(), id) + } else { + server, err = h.svc.AgentInstall(r.Context(), id) + } if err != nil { writeError(r, w, http.StatusBadGateway, err.Error()) return @@ -796,6 +801,28 @@ func (h *FRPMultiHandler) AgentNginxReload(w http.ResponseWriter, r *http.Reques writeJSON(w, http.StatusOK, result) } +// AgentNginxAction 启动 / 停止 / 重启 VPS 上的 Nginx(需 agent ≥ 0.15.2) +func (h *FRPMultiHandler) AgentNginxAction(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r.PathValue("id")) + if err != nil { + writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效") + return + } + var body struct { + Action string `json:"action"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(r, w, http.StatusBadRequest, "请求格式无效") + return + } + result, err := h.svc.AgentNginxAction(r.Context(), id, body.Action) + if err != nil { + writeError(r, w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, result) +} + func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) { id, err := parseID(r.PathValue("id")) if err != nil { diff --git a/internal/api/router.go b/internal/api/router.go index 34dbb5d..c3bb373 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -168,6 +168,7 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) { protect("GET /api/frpmulti/servers/{id}/agent/certs", frpHandler.AgentCerts) protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload) protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx) + protect("POST /api/frpmulti/servers/{id}/agent/nginx/action", frpHandler.AgentNginxAction) protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf) protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions", frpHandler.AgentRouteVersions) protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}", frpHandler.AgentRouteVersion) diff --git a/internal/app/app.go b/internal/app/app.go index bd895d0..15031aa 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -6,9 +6,11 @@ import ( "io" "io/fs" "log/slog" + "net" "net/http" "os" "path/filepath" + "strings" "time" "github.com/havline/havline/internal/acme" @@ -152,6 +154,9 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) StaticFS: staticFS, StartedAt: startedAt, }) + if prefix := strings.TrimSpace(cfg.GatewayPrefix); prefix != "" { + handler = stripPrefixIfPresent(prefix, handler) + } server := &http.Server{ Addr: cfg.ListenAddr, Handler: handler, @@ -243,7 +248,32 @@ func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService } func (a *App) Run() error { - return a.server.ListenAndServe() + if strings.TrimSpace(a.cfg.GatewaySocket) == "" { + return a.server.ListenAndServe() + } + // 先准备两端监听,任何一端失败都不留下半启动的服务。 + tcp, err := net.Listen("tcp", a.server.Addr) + if err != nil { + return err + } + defer tcp.Close() + socket := strings.TrimSpace(a.cfg.GatewaySocket) + if err := os.Remove(socket); err != nil && !os.IsNotExist(err) { + return err + } + listener, err := net.Listen("unix", socket) + if err != nil { + return err + } + defer listener.Close() + defer os.Remove(socket) + if err := os.Chmod(socket, 0o660); err != nil { + return err + } + errCh := make(chan error, 2) + go func() { errCh <- a.server.Serve(tcp) }() + go func() { errCh <- a.server.Serve(listener) }() + return <-errCh } func (a *App) Shutdown(ctx context.Context) error { @@ -254,6 +284,9 @@ func (a *App) Shutdown(ctx context.Context) error { if err := a.server.Shutdown(ctx); err != nil { return err } + if socket := strings.TrimSpace(a.cfg.GatewaySocket); socket != "" { + _ = os.Remove(socket) + } if err := a.nginx.Stop(ctx); err != nil { a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error()) } @@ -268,6 +301,19 @@ func (a *App) Shutdown(ctx context.Context) error { return nil } +func stripPrefixIfPresent(prefix string, next http.Handler) http.Handler { + prefix = strings.TrimRight(prefix, "/") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == prefix || strings.HasPrefix(r.URL.Path, prefix+"/") { + r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix) + if r.URL.Path == "" { + r.URL.Path = "/" + } + } + next.ServeHTTP(w, r) + }) +} + func ResolveMigrationsDir() (string, error) { candidates := []string{ "migrations", diff --git a/internal/app/gateway_test.go b/internal/app/gateway_test.go new file mode 100644 index 0000000..09c853c --- /dev/null +++ b/internal/app/gateway_test.go @@ -0,0 +1,25 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestGatewayPrefix(t *testing.T) { + for _, tc := range []struct{ input, want string }{ + {"/app/havline/api/version", "/api/version"}, + {"/app/havline", "/"}, + {"/api/version", "/api/version"}, + {"/app/havline-other/api/version", "/app/havline-other/api/version"}, + } { + t.Run(tc.input, func(t *testing.T) { + handler := stripPrefixIfPresent("/app/havline/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != tc.want { + t.Errorf("路径为 %q,期望 %q", r.URL.Path, tc.want) + } + })) + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, tc.input, nil)) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index b9e0442..fd7bb92 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,8 @@ import ( type Config struct { ListenAddr string + GatewaySocket string + GatewayPrefix string DataDir string SessionSecret string NginxBin string @@ -33,6 +35,8 @@ func Load() Config { } return Config{ ListenAddr: envOr("HAVLINE_LISTEN", ":6893"), + GatewaySocket: envOr("HAVLINE_GATEWAY_SOCKET", ""), + GatewayPrefix: envOr("HAVLINE_GATEWAY_PREFIX", ""), DataDir: dataDir, SessionSecret: envOr("HAVLINE_SESSION_SECRET", ""), NginxBin: envOr("HAVLINE_NGINX_BIN", "nginx"), @@ -108,6 +112,12 @@ func (c Config) SSHKnownHostsPath() string { return filepath.Join(c.DataDir, "ssh_known_hosts") } +// SSHKnownHostsPathForServer 为每台 FRP 服务端提供独立的 known_hosts, +// 避免一台 VPS 的主机密钥记录影响另一台服务器的 SSH 连接。 +func (c Config) SSHKnownHostsPathForServer(serverID int64) string { + return filepath.Join(c.DataDir, "ssh_known_hosts", fmt.Sprintf("server-%d", serverID)) +} + func (c Config) ErrorsDir() string { return c.NginxDir() + "/errors" } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e1a514f..f8cb509 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "os" + "path/filepath" "testing" ) @@ -42,3 +43,11 @@ func TestLoadUpdaterTokenFile(t *testing.T) { t.Fatalf("updater token file %q", cfg.UpdaterTokenFile) } } + +func TestSSHKnownHostsPathForServer(t *testing.T) { + cfg := Config{DataDir: "/data"} + want := filepath.Join("/data", "ssh_known_hosts", "server-3") + if got := cfg.SSHKnownHostsPathForServer(3); got != want { + t.Fatalf("per-server known_hosts path %q", got) + } +} diff --git a/internal/config/gateway_test.go b/internal/config/gateway_test.go new file mode 100644 index 0000000..813c31b --- /dev/null +++ b/internal/config/gateway_test.go @@ -0,0 +1,16 @@ +package config + +import "testing" + +func TestNativeGatewayConfig(t *testing.T) { + t.Setenv("HAVLINE_GATEWAY_SOCKET", "/tmp/havline-test.sock") + t.Setenv("HAVLINE_GATEWAY_PREFIX", "/app/havline") + t.Setenv("HAVLINE_UPDATER_SOCKET", "") + cfg := Load() + if cfg.GatewaySocket != "/tmp/havline-test.sock" || cfg.GatewayPrefix != "/app/havline" { + t.Fatal("原生网关配置未生效") + } + if cfg.UpdaterSocket != "" { + t.Fatal("原生应用显式空值应禁用 Docker 升级") + } +} diff --git a/internal/frp/agent_client.go b/internal/frp/agent_client.go index e254c54..f0c77a0 100644 --- a/internal/frp/agent_client.go +++ b/internal/frp/agent_client.go @@ -219,9 +219,15 @@ const agentMinVersionInsights = "0.11.0" // agentMinVersionRouteVersions 是域名 vhost 版本列表 / 对比 / 回滚端点要求的最低 agent 版本 const agentMinVersionRouteVersions = "0.12.2" +// agentMinVersionNginxAction 是 Nginx 启停(start / stop / restart)端点要求的最低 agent 版本 +const agentMinVersionNginxAction = "0.15.2" + // ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405) var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口") +// ErrAgentTokenMismatch 表示 Havline 保存的 Token 与 VPS 上 Agent 配置不一致。 +var ErrAgentTokenMismatch = errors.New("Agent Token 不匹配,请点击「升级 Agent」重新同步") + // statusError 保留 HTTP 状态码,便于上层区分「端点不存在」与真实错误 type statusError struct { code int @@ -232,6 +238,14 @@ func (e *statusError) Error() string { return fmt.Sprintf("agent 返回 HTTP %d: %s", e.code, e.body) } +func wrapAgentAuth(err error) error { + var statusErr *statusError + if errors.As(err, &statusErr) && statusErr.code == http.StatusUnauthorized { + return fmt.Errorf("%w: %s", ErrAgentTokenMismatch, statusErr.body) + } + return err +} + // wrapEndpointMissing 把「端点不存在」统一成带升级提示的错误(按端点给出最低版本),其它错误原样返回 func wrapEndpointMissing(err error, minVersion string) error { var statusErr *statusError @@ -280,6 +294,16 @@ func (c *AgentClient) NginxReload(ctx context.Context) (map[string]any, error) { return result, nil } +// NginxAction 启动 / 停止 / 重启 VPS 上的 Nginx(需 agent ≥ 0.15.2) +func (c *AgentClient) NginxAction(ctx context.Context, action string) (map[string]any, error) { + var result map[string]any + payload := map[string]string{"action": action} + if err := c.request(ctx, http.MethodPost, "/api/v1/nginx/action", payload, &result); err != nil { + return nil, wrapEndpointMissing(err, agentMinVersionNginxAction) + } + return result, nil +} + // FRPSConfig 回读 VPS 上的 frps.toml(用于与下发内容比对) func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) { var result map[string]any diff --git a/internal/frp/agent_insights.go b/internal/frp/agent_insights.go index 6ed317e..315edd7 100644 --- a/internal/frp/agent_insights.go +++ b/internal/frp/agent_insights.go @@ -14,7 +14,8 @@ func (s *Service) AgentMetrics(ctx context.Context, serverID int64) (map[string] if !ok { return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") } - return client.Metrics(ctx) + result, err := client.Metrics(ctx) + return result, wrapAgentAuth(err) } // AgentNginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部 @@ -29,7 +30,8 @@ func (s *Service) AgentNginxLogs(ctx context.Context, serverID int64, kind strin if lines <= 0 || lines > 2000 { lines = 200 } - return client.NginxLogs(ctx, kind, lines) + result, err := client.NginxLogs(ctx, kind, lines) + return result, wrapAgentAuth(err) } // AgentCerts 列出 VPS 证书目录下的域名与到期时间,供与 Havline 证书库交叉核对 @@ -38,7 +40,8 @@ func (s *Service) AgentCerts(ctx context.Context, serverID int64) (map[string]an if !ok { return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") } - return client.Certs(ctx) + result, err := client.Certs(ctx) + return result, wrapAgentAuth(err) } // AgentNginxReload 显式校验并重载 VPS 上的 Nginx,返回失败原因 @@ -47,7 +50,18 @@ func (s *Service) AgentNginxReload(ctx context.Context, serverID int64) (map[str if !ok { return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") } - return client.NginxReload(ctx) + result, err := client.NginxReload(ctx) + return result, wrapAgentAuth(err) +} + +// AgentNginxAction 启动 / 停止 / 重启 VPS 上的 Nginx(需 agent ≥ 0.15.2) +func (s *Service) AgentNginxAction(ctx context.Context, serverID int64, action string) (map[string]any, error) { + client, ok := s.agentClient(ctx, serverID) + if !ok { + return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") + } + result, err := client.NginxAction(ctx, action) + return result, wrapAgentAuth(err) } // AgentFRPSConfig 回读 VPS 上的 frps.toml,供与「即将下发的内容」比对 @@ -56,5 +70,6 @@ func (s *Service) AgentFRPSConfig(ctx context.Context, serverID int64) (map[stri if !ok { return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") } - return client.FRPSConfig(ctx) + result, err := client.FRPSConfig(ctx) + return result, wrapAgentAuth(err) } diff --git a/internal/frp/agent_installer.go b/internal/frp/agent_installer.go index 67384f5..ac5bd55 100644 --- a/internal/frp/agent_installer.go +++ b/internal/frp/agent_installer.go @@ -116,7 +116,11 @@ func extractToken(output string) string { func extractAgentVersion(output string) string { for _, line := range strings.Split(output, "\n") { if version, found := strings.CutPrefix(strings.TrimSpace(line), "agent version: "); found { - return strings.TrimSpace(version) + fields := strings.Fields(version) + if len(fields) == 0 { + return "" + } + return strings.TrimPrefix(fields[len(fields)-1], "v") } } return "" diff --git a/internal/frp/agent_installer_test.go b/internal/frp/agent_installer_test.go index 1cff19b..f3b7ce7 100644 --- a/internal/frp/agent_installer_test.go +++ b/internal/frp/agent_installer_test.go @@ -7,6 +7,12 @@ func TestExtractAgentVersion(t *testing.T) { if got := extractAgentVersion(output); got != "0.15.1" { t.Fatalf("版本解析失败,got %q", got) } + if got := extractAgentVersion("agent version: havline-agent 0.15.1\nhavline-agent installed"); got != "0.15.1" { + t.Fatalf("带程序名前缀的版本解析失败,got %q", got) + } + if got := extractAgentVersion("agent version: v0.15.1"); got != "0.15.1" { + t.Fatalf("带 v 前缀的版本解析失败,got %q", got) + } if got := extractAgentVersion("havline-agent installed\nToken: abc123"); got != "" { t.Fatalf("无版本行时应返回空串,got %q", got) } diff --git a/internal/frp/agent_tunnel.go b/internal/frp/agent_tunnel.go index 9577277..cacd76f 100644 --- a/internal/frp/agent_tunnel.go +++ b/internal/frp/agent_tunnel.go @@ -2,6 +2,7 @@ package frp import ( "context" + "encoding/json" "fmt" "io" "net" @@ -14,25 +15,31 @@ import ( const agentRemoteAddr = "127.0.0.1:7700" const ( - agentTunnelKeepalive = 30 * time.Second - agentTunnelBackoffMin = time.Second - agentTunnelBackoffMax = 60 * time.Second + agentTunnelKeepalive = 30 * time.Second + agentTunnelKeepaliveTimeout = 5 * time.Second + agentTunnelBackoffMin = time.Second + agentTunnelBackoffMax = 60 * time.Second ) type agentTunnel struct { - serverID int64 - localPort int - ssh SSHInstaller + serverID int64 + localPort int + remoteAddr string + ssh SSHInstaller ctx context.Context cancel context.CancelFunc + connectMu sync.Mutex mu sync.Mutex listener net.Listener client *ssh.Client state string lastError string wg sync.WaitGroup + + connsMu sync.Mutex + conns map[net.Conn]struct{} } func newAgentTunnel(parent context.Context, serverID int64, localPort int, installer SSHInstaller) *agentTunnel { @@ -42,13 +49,15 @@ func newAgentTunnel(parent context.Context, serverID int64, localPort int, insta func newAgentTunnelWithListener(parent context.Context, serverID int64, localPort int, installer SSHInstaller, listener net.Listener) *agentTunnel { ctx, cancel := context.WithCancel(parent) return &agentTunnel{ - serverID: serverID, - localPort: localPort, - ssh: installer, - ctx: ctx, - cancel: cancel, - listener: listener, - state: "connecting", + serverID: serverID, + localPort: localPort, + remoteAddr: agentRemoteAddr, + ssh: installer, + ctx: ctx, + cancel: cancel, + listener: listener, + state: "connecting", + conns: map[net.Conn]struct{}{}, } } @@ -61,8 +70,13 @@ func (t *agentTunnel) Start() error { } t.listener = listener } + if t.localPort == 0 { + if addr, ok := t.listener.Addr().(*net.TCPAddr); ok { + t.localPort = addr.Port + } + } t.mu.Lock() - t.state = "connected" + t.state = "connecting" t.lastError = "" t.mu.Unlock() t.wg.Add(2) @@ -76,7 +90,7 @@ func (t *agentTunnel) monitorLoop() { defer t.wg.Done() backoff := agentTunnelBackoffMin for { - _, err := t.ensureClient() + err := t.check() wait := agentTunnelKeepalive if err != nil { t.setState("reconnecting", err) @@ -92,6 +106,24 @@ func (t *agentTunnel) monitorLoop() { } } +// check 同时验证 SSH 连接和远端 Agent 端口,避免只连上 SSH 就误判隧道可用。 +func (t *agentTunnel) check() error { + client, err := t.ensureClient() + if err != nil { + return err + } + if err := t.keepalive(client); err != nil { + t.invalidateClient(client) + return fmt.Errorf("SSH keepalive 失败: %w", err) + } + remote, err := client.Dial("tcp", t.remoteAddr) + if err != nil { + return fmt.Errorf("连接远端 Agent 端口失败: %w", err) + } + _ = remote.Close() + return nil +} + func (t *agentTunnel) sleep(d time.Duration) bool { timer := time.NewTimer(d) defer timer.Stop() @@ -126,8 +158,13 @@ func (t *agentTunnel) acceptLoop() { return } t.wg.Add(1) + if !t.trackConn(conn) { + t.wg.Done() + return + } go func() { defer t.wg.Done() + defer t.untrackConn(conn) t.handle(conn) }() } @@ -138,31 +175,53 @@ func (t *agentTunnel) handle(local net.Conn) { client, err := t.ensureClient() if err != nil { t.setState("reconnecting", err) + writeTunnelError(local, err) return } - remote, err := client.Dial("tcp", agentRemoteAddr) + remote, err := client.Dial("tcp", t.remoteAddr) if err != nil { - t.invalidateClient(client) - t.setState("reconnecting", err) - return + remote, err = t.retryRemoteDial(client, err) + if err != nil { + t.setState("reconnecting", err) + writeTunnelError(local, err) + return + } } defer remote.Close() t.setState("connected", nil) proxyTunnel(local, remote) } +// retryRemoteDial 仅在 SSH 连接本身失效时重连,远端 Agent 端口暂时不可用不会关闭共享连接。 +func (t *agentTunnel) retryRemoteDial(client *ssh.Client, firstErr error) (net.Conn, error) { + if err := t.keepalive(client); err == nil { + return nil, fmt.Errorf("连接远端 Agent 端口失败: %w", firstErr) + } + t.invalidateClient(client) + reconnected, err := t.ensureClient() + if err != nil { + return nil, fmt.Errorf("SSH 连接失效,重连失败: %w", err) + } + remote, err := reconnected.Dial("tcp", t.remoteAddr) + if err != nil { + return nil, fmt.Errorf("SSH 已重连,但远端 Agent 端口仍不可用: %w", err) + } + return remote, nil +} + func (t *agentTunnel) ensureClient() (*ssh.Client, error) { + // 单飞:监控循环和请求处理可能同时触发建连,串行化后只允许一条 SSH 握手。 + // 正常请求只复用连接,不在热路径做 keepalive,避免多个接口互相排队。 + t.connectMu.Lock() + defer t.connectMu.Unlock() + t.mu.Lock() if t.client != nil { client := t.client t.mu.Unlock() - if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err == nil { - return client, nil - } - t.invalidateClient(client) - } else { - t.mu.Unlock() + return client, nil } + t.mu.Unlock() ctx, cancel := context.WithTimeout(t.ctx, 15*time.Second) defer cancel() client, err := t.ssh.client(ctx) @@ -181,6 +240,30 @@ func (t *agentTunnel) ensureClient() (*ssh.Client, error) { return client, nil } +func (t *agentTunnel) keepalive(client *ssh.Client) error { + done := make(chan error, 1) + go func() { + _, _, err := client.SendRequest("keepalive@openssh.com", true, nil) + done <- err + }() + select { + case err := <-done: + return err + case <-time.After(agentTunnelKeepaliveTimeout): + return fmt.Errorf("SSH keepalive 超时") + case <-t.ctx.Done(): + return t.ctx.Err() + } +} + +// writeTunnelError 让本地 HTTP 客户端立即得到明确错误,而不是一直等到 context deadline。 +func writeTunnelError(conn net.Conn, err error) { + body, _ := json.Marshal(map[string]string{"error": "SSH 隧道不可用: " + err.Error()}) + _, _ = fmt.Fprintf(conn, + "HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", + len(body), body) +} + func (t *agentTunnel) invalidateClient(client *ssh.Client) { t.mu.Lock() if t.client == client { @@ -217,9 +300,39 @@ func (t *agentTunnel) Stop() { _ = t.client.Close() } t.mu.Unlock() + t.closeConns() t.wg.Wait() } +func (t *agentTunnel) trackConn(conn net.Conn) bool { + t.connsMu.Lock() + defer t.connsMu.Unlock() + if t.ctx.Err() != nil { + _ = conn.Close() + return false + } + t.conns[conn] = struct{}{} + return true +} + +func (t *agentTunnel) untrackConn(conn net.Conn) { + t.connsMu.Lock() + delete(t.conns, conn) + t.connsMu.Unlock() +} + +func (t *agentTunnel) closeConns() { + t.connsMu.Lock() + conns := make([]net.Conn, 0, len(t.conns)) + for conn := range t.conns { + conns = append(conns, conn) + } + t.connsMu.Unlock() + for _, conn := range conns { + _ = conn.Close() + } +} + func proxyTunnel(a, b net.Conn) { var wg sync.WaitGroup wg.Add(2) diff --git a/internal/frp/agent_tunnel_integration_test.go b/internal/frp/agent_tunnel_integration_test.go new file mode 100644 index 0000000..c6f3470 --- /dev/null +++ b/internal/frp/agent_tunnel_integration_test.go @@ -0,0 +1,246 @@ +package frp + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "golang.org/x/crypto/ssh" +) + +type directTCPIPPayload struct { + DestAddr string + DestPort uint32 + OriginAddr string + OriginPort uint32 +} + +func TestAgentTunnelReturnsBadGatewayWhenRemoteAgentIsDown(t *testing.T) { + sshAddr := startFakeSSHServer(t) + tunnel := startTestAgentTunnel(t, sshAddr, "127.0.0.1:1") + + response, err := (&http.Client{Timeout: 5 * time.Second}).Get( + fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort), + ) + if err != nil { + t.Fatalf("应返回 HTTP 502,而不是 EOF 或超时: %v", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusBadGateway { + t.Fatalf("状态码 = %d,期望 502", response.StatusCode) + } + body, _ := io.ReadAll(response.Body) + if !strings.Contains(string(body), "SSH 隧道不可用") { + t.Fatalf("响应应包含隧道失败原因,实际为 %q", string(body)) + } +} + +func TestAgentTunnelForwardsHTTPToRemoteAgent(t *testing.T) { + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer remote.Close() + + sshAddr := startFakeSSHServer(t) + tunnel := startTestAgentTunnel(t, sshAddr, strings.TrimPrefix(remote.URL, "http://")) + response, err := (&http.Client{Timeout: 5 * time.Second}).Get( + fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort), + ) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if response.StatusCode != http.StatusOK || string(body) != `{"ok":true}` { + t.Fatalf("隧道转发失败: status=%d body=%q", response.StatusCode, string(body)) + } +} + +func TestAgentTunnelSerializesConcurrentSSHConnections(t *testing.T) { + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer remote.Close() + + sshAddr, connectionCount := startFakeSSHServerWithCounter(t, 300*time.Millisecond) + tunnel := startTestAgentTunnel(t, sshAddr, strings.TrimPrefix(remote.URL, "http://")) + client := &http.Client{Timeout: 5 * time.Second} + var wait sync.WaitGroup + for index := 0; index < 4; index++ { + wait.Add(1) + go func() { + defer wait.Done() + response, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort)) + if err == nil { + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + } + }() + } + wait.Wait() + if got := connectionCount.Load(); got != 1 { + t.Fatalf("并发请求建立了 %d 条 SSH 连接,期望单飞复用 1 条", got) + } +} + +func TestAgentTunnelDoesNotBlockRequestsOnSlowKeepalive(t *testing.T) { + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer remote.Close() + + sshAddr, _ := startFakeSSHServerWithDelays(t, 0, 3*time.Second) + tunnel := startTestAgentTunnel(t, sshAddr, strings.TrimPrefix(remote.URL, "http://")) + started := time.Now() + response, err := (&http.Client{Timeout: 5 * time.Second}).Get( + fmt.Sprintf("http://127.0.0.1:%d/api/v1/status", tunnel.localPort), + ) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, response.Body) + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("正常请求被慢 keepalive 阻塞了 %v", elapsed) + } +} + +func startTestAgentTunnel(t *testing.T, sshAddr, remoteAddr string) *agentTunnel { + t.Helper() + host, portText, err := net.SplitHostPort(sshAddr) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatal(err) + } + tunnel := newAgentTunnel(context.Background(), 1, 0, SSHInstaller{ + Host: host, Port: port, User: "test", Password: "test", + Timeout: 2 * time.Second, KnownHostsPath: filepath.Join(t.TempDir(), "known_hosts"), + }) + tunnel.remoteAddr = remoteAddr + if err := tunnel.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(tunnel.Stop) + return tunnel +} + +func startFakeSSHServer(t *testing.T) string { + t.Helper() + addr, _ := startFakeSSHServerWithCounter(t, 0) + return addr +} + +func startFakeSSHServerWithCounter(t *testing.T, handshakeDelay time.Duration) (string, *atomic.Int32) { + t.Helper() + return startFakeSSHServerWithDelays(t, handshakeDelay, 0) +} + +func startFakeSSHServerWithDelays(t *testing.T, handshakeDelay, keepaliveDelay time.Duration) (string, *atomic.Int32) { + t.Helper() + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := ssh.NewSignerFromKey(privateKey) + if err != nil { + t.Fatal(err) + } + config := &ssh.ServerConfig{ + PasswordCallback: func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) { + return nil, nil + }, + } + config.AddHostKey(signer) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + var connectionCount atomic.Int32 + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go serveFakeSSHConn(conn, config, &connectionCount, handshakeDelay, keepaliveDelay) + } + }() + return listener.Addr().String(), &connectionCount +} + +func serveFakeSSHConn( + conn net.Conn, + config *ssh.ServerConfig, + connectionCount *atomic.Int32, + handshakeDelay, keepaliveDelay time.Duration, +) { + if handshakeDelay > 0 { + time.Sleep(handshakeDelay) + } + connectionCount.Add(1) + serverConn, channels, requests, err := ssh.NewServerConn(conn, config) + if err != nil { + _ = conn.Close() + return + } + defer serverConn.Close() + go func() { + for request := range requests { + if request.Type == "keepalive@openssh.com" && keepaliveDelay > 0 { + time.Sleep(keepaliveDelay) + } + if request.WantReply { + _ = request.Reply(true, nil) + } + } + }() + for newChannel := range channels { + if newChannel.ChannelType() != "direct-tcpip" { + _ = newChannel.Reject(ssh.UnknownChannelType, "unsupported") + continue + } + var payload directTCPIPPayload + if err := ssh.Unmarshal(newChannel.ExtraData(), &payload); err != nil { + _ = newChannel.Reject(ssh.ConnectionFailed, "invalid direct-tcpip payload") + continue + } + target := net.JoinHostPort(payload.DestAddr, strconv.Itoa(int(payload.DestPort))) + remote, err := net.DialTimeout("tcp", target, 2*time.Second) + if err != nil { + _ = newChannel.Reject(ssh.ConnectionFailed, err.Error()) + continue + } + channel, channelRequests, err := newChannel.Accept() + if err != nil { + _ = remote.Close() + continue + } + go ssh.DiscardRequests(channelRequests) + go func() { + _, _ = io.Copy(remote, channel) + _ = remote.Close() + }() + go func() { + _, _ = io.Copy(channel, remote) + _ = channel.Close() + }() + } +} diff --git a/internal/frp/agent_tunnel_test.go b/internal/frp/agent_tunnel_test.go index 9219bfb..de7171e 100644 --- a/internal/frp/agent_tunnel_test.go +++ b/internal/frp/agent_tunnel_test.go @@ -24,8 +24,8 @@ func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) { t.Fatal("隧道启动后本地端口应已被占用") } state, _ := tunnel.State() - if state != "connected" && state != "reconnecting" { - t.Fatalf("state=%q, want connected 或 reconnecting", state) + if state != "connecting" && state != "connected" && state != "reconnecting" { + t.Fatalf("state=%q, want connecting、connected 或 reconnecting", state) } tunnel.Stop() if listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err != nil { @@ -51,3 +51,17 @@ func TestNextTunnelBackoff(t *testing.T) { } } } + +func TestAgentTunnelStopClosesActiveConnections(t *testing.T) { + tunnel := newAgentTunnel(context.Background(), 1, 0, SSHInstaller{}) + server, client := net.Pipe() + defer client.Close() + if !tunnel.trackConn(server) { + t.Fatal("隧道运行时应能登记活动连接") + } + tunnel.Stop() + _ = client.SetReadDeadline(time.Now().Add(time.Second)) + if _, err := client.Read(make([]byte, 1)); err == nil { + t.Fatal("隧道停止后活动连接应被关闭") + } +} diff --git a/internal/frp/agentbin/havline-agent-linux-amd64 b/internal/frp/agentbin/havline-agent-linux-amd64 index 4f835b0d459ede68b787d463dcf5b0009c5a34ac..eadeeacfe52e30162bde42d3aa8135ba2d3625f6 100644 GIT binary patch delta 1218705 zcmcG%cYKUl)Hpu(Ook*fCXr0~q)4JfOArzaNrX%YqD1ekUQ%@u5+ueD9vq9++ag*p z$Rg2MomJPWyT-EGTAh5)eV#m#dEfW*`|s!T+1zu_J^l9b%%1;wk!o%6S!G@`rK3W# zpv8iW%i&=P{!dO7j$&59D6!#Sq*ShmOqU8uQ`=RI#k`!B=c~X=?vIk_?{BkCi&SF% z{!2R`pr8ZuXUfYp!f@Ws5jr6coT`*5k`zzuS{c7>AMT17J4aL$E`g(4=8`N?MCOu~ zV)~OuO`hVKox?l?N2Q~a-%d%V-%iPfxk9M2o_*0330rhU7@=%zUuM@RT(D@ERwz>X z+dV3^^^x$mvay`Yd?W W7;lJb^o+vF J$;R*1Vw3Xh(*Qp`(-j|NfKd-m>vRuo0$c95Rw) z2X*Y0l-#L(+KAZHt{n%)Htd;HuUpp!@xwa~4G-%vZgg7bu+-#``ort>8#+FrL2S2_ zap5roy7g!^u0vvj`t?SPso!$g#Po!TY2A86jWTrl;fL74Vk>bWuaxv(Z2Lq-gXssB z|Do9QU!-B*rMIfPEf=P@xDwZNh@)m!kx Fyvl5%$3BOR3rZo#=83(9BwyOScZDpaxt5h zL@Ew=lWIfmDVeOsA1xG72e>_ ov^Yjcg*O;96Kwq-fkx+)*w7|&F zqP(Y*sjFBT9kU^8RP|~K9dthMwon*L$*h~gd|d>v)52NG)w(JCtqZWLH=lTo>X7+@ zt4}9bHBK1e;{$C63v+y;JtoXcQN%~w)MTkhW*pgCd|wrB4C*mY_|vCTLp7BejA?=4 zhKTE$tUT_sQTxioky8RgPR2BGXM}jd5fRr@$B>;@O~OduKv=y|Sm5gegRcv_d>c3I zLIkaH;=v=%6v+$`uQaChpG!*OjjEPZ5Fb&L8WfjXBx%;xAXL=J7;tCtMs0ve2=GhE z7& _$X;Gg;o68Oks&`PE9>mU5U!G^ft0=?Xx)~dx$K%Gnajn zP=?&2#+bl(L$2-$<>E~(KgUrsYI7 #Dw0qK3)4h6feRkStrZ1N;QdrR zu#~Vr!3zroQ-HryxQLnynM6t0zJ)} zViaPCAWN?=;1PDCs0C79?BvVmltx*Mgqq7Ki$uj-p+%q$UhXD`Q`85plsOx4kUQJB z!f)T5{c=F^EQEU|wo=iv?xNifu6+-|!vjLSAg{{5l%lw^2M-9{gZwj`MFnNf8byh7 zfw#)64>lUpZtC9DIJV6ez5j_PqW7vf93=0X%Jo0-Bh~+&CZgw4sW3}FZkOD)`cU3- zGpBspYQy<+mAbaGh0j6W&~TUFQEi;sQEdAz)IGJs_6sYjc{XbHn78OIwtj?)+RwX8 z7hCF-F}4QYmWL2P?oyd|DP1JNp8JL8)jTVIc_gv+C)B@Y!KJ#N({&NG$d=bFVUL-G zR@D=s{Z?T`_39nEnbQ=SgZG(cp~@hEb_7OCpfiEt66iu;T?up}P$z*N1is%VO7J33 zWfrQ`= %E`NLjnoN||~fSTQ6oo#GIcqvAQu!3UK{`G-9sXiwl9 z0!hjfF;PFDgp@xL6a6(xGzTBr5ZT2&LaX48jb;!gOTS|q$xXMVfu+mLqGwuzQPp<> zwZ5lWV(XL8H2-yUil(-tik={x3$CHHBUVM4hDvS8XD;(&K^k_P>F*-wY6XQJ5&Ljp zrrs<9UYYtGB2Xn$zexmmt*# Xh-t z2cH9cg}Wg&;8T7<#n9eLSg}R;q2XPJYd@zcSeoi-{fY{O5ZTN{7#QZ~u&6w;KOxtK zHH4Z!7u*aBvH{->1tk#&7{snCIM}ojK;HwxDSam>K3m|`{Elsv$~}`685IwDarOIY zs(sf(u#N2mTY3n+V?AJYkAm^B$&BR-PYnr5XNPE^S)700Q{8BRVe0(1A>y)T)*;?m zjOwj~d!un&bSt5xq~wjJ$Js(xDj dS1Iab3;?#X?$bI$cQ(ZS)FP1LxXKzXJLCg zuX VL_y5n|@@IGfNb&xTVZ`f_IC$+8i7!diD0KD6_NI-LcN_FiDyxu8+| z0LC^7Jv(?-$%!C?G_7tOAfNfr(f^e&t3y!rjvQ@L@3x_2`tcl^oBJHobSsLdb%{yW zP%2z(6D?fnP!h0V6s-k6mXsKBZ}HWgF*}gc6I-6hmZ^WfsNiiv3l_YN2xtCVQZmG# z8B>&(eWy^;sovGd+J@YJgg%{us!!r*lll%5ZIFbO+0aKSJj|knpF3q&x){c3bs yo5XJC^{FKVw* z`U2hHgEnlgaC4Y9{rzv)iJ(!Y7Bq@J^NpYRDXsDWX)UT9G{1%7nS-3`F<-bhJP vv4c`{@9Ai@f4o@rSpB7XBaDp& gPN?jN`PS`PJ5ZI3sT&6CCq;z5X)P?YST0!)*00!n%VQ5Bu z$VwHKXY_% $p+=$v2S@o=F@?nD?qykKu`K4Z-a1{tR^*0kWk+y_e5 zxZqeG<-!W07A7d+U{ryy_zPnpLLQD`1wsifXG;r~FRjAtUUnBlW_w21_v6BVE>f<~ zX=*l`f20kYNfjlbnsC%&LZMSaB~J`-t5`uWE*tC&gCi9NR=l4oRPoG_npdT00ha&c z41@kBKB@p#gfaLtQHc2IlCz~hFaLB{;-k&w>*j*@%Ia{di_mK2ZTQ-`z<*U93yAC+ zsc5a)SG(R2aWIU_`Vy6r|8xj(#hW^n#G7=!eT8eQ=fS~_1%uaAX1*D{MfzqfzLOt& zRzJ?vnP{~GdkYKJhC5#EAiDhJ2zgX}K{=097-_7c627jz0gWSs>+53Rb-3WYz85SC z7bdO`fq~%#Ti2(vPG^3mifnJl`UvUG_VZDM>QtCVxF(X7RH96;yx&183C*Aq8dN^o z5RMNZM16Y&;T-n!v4wIgX9+1A;~=80aA@N 0G(Q`oh&MWB?aObLyg#6A7^YEsDOF5T N! !{3g28kP^{XTWTbtufbA5 zt^ZZvBo$QoUj-koiK0GIVlC>PRB-=)6`Z`5RDsviscQu-eu-rES49D -aFygMkuu(67Ry z6Knm?iX;;$ktIk}tE7U}XA3r-tid4Tn{errH|z};UYrWl1xH6J3WKPvisOW(FsY)# z($!r&MDqJ4)I03~J;H@Hr)$93C?V~1A=Ebsp=TnMTT`O2@BFF(m|4S4BWye~)9)15 zu6L$H>;PHMGr5WCEhk0%){{cpv!5V-gAn`cU`S39@_)S)eq1!Tm=duaES39YH>sez zb%EuQvuM_`kHX$_esHdVaN}H%d!)Y9e#`ZdWO}kh)AXa@a^4?CH4qw~4}$y63i_T$ z=v>V8>3Z-a>v$xYH?wG}3vD5Mr2@aQ0?7lB#HjL@N~@NY635pfQPj7h0>dS5rhKAG z6NX;itZYVUl`BhGj 3f;k_HMA+O-py|E0G?}RoF zCc*6ggp&_eg~h2Ua#e}TlAkC}aIGik6_z#OjKt6gl%+rMHkCFwI&n1{AK0JTV&DW} z*~3X}yP$p46Mp|!NPaXK`u!`scoYGF{|Yr9w}ao_6ij$roq>L@u;$OEpx9gR$DctA zp5+UwCwCoA%p#TiINcJGAar=@T3};o!C>&rf>uxaGO)=OHvAm}1D_W>`+JiT4p@Xm zFOp$xhEVZkNBH-N(DUU^_K#5MRf?l8G^Xi77e0k1uGE%%;!5*(71q6)13rHV^ sA$o<+Y(ASx!~8=`2bfR3X|SWfy9Txhqt5b!oN1+0;i8J82#T12Cwc3 zecn%p(f11Ozpt)@>Nf X zF!wBH@~|5B&@+P6ip`8piEavxC6l1|hA_8e5cIwwye_#2#_M~JvLpr`zhOgQ32^Ef z&H`2smRv(Ku&Lm7&D==Ix-mF>1?StahLCf`e8PscQ$oZ=vx_Yo#n?5RZpXBmz2|vF zc}R!K9@lV#9W%qrbJ*9OWmVpIo0jYT+@jDr6sgSKY8O;iVjIBaDQ>C6YQtX^^UX@E zBeM~nq$-e240gqqYSy>%UpHjN kri2Dg@ecSG06*smMKK+iz-6&9JemDQ;#&oP|`Z+7M&pw=u zCSjRz&K+#%L55$xjsJMCaKPJG-II-hCy9ujtQZ0l%{{%C7C_w@lXa|)(`0M=W=}Tn z(6Jomm2grH$sKnq(? WubzE4Sm08f+*GiADFCtgh2oYrcD9&E0CU8%#6rOe$}u6U50T z{vhgWF*g`?#@w|Q3t`Ger&IBDTb;LgLv7Zc)jo4FRUrmeBA;oN
ZIAX zF00Qfj6A_v(BGw|CnncpS&$QHepinHgX|XOPa$k2K*2g()__Xtu0!93)Jz^W#T5-% zBhSIsCU@rf+|w54k_!CB#0`~U%mT`O=6}N2Z!EOsCf*C^7$;l==oqJk2+%Q3T@m2N zH~|Dn$2bF*;lGX9zkXfwq7(yPe>03#&y^wVQ5n*OAHb7MSX6^D#B&)6s=A<*u}*gx zQdd4uiqpm%z)2A--mg(v*(e#(u9YF3`vI&F!6F+jDZ`wTp&(c&%MX_!^^5(o{J>KA zu4QHMGNhd$L%I+9Mft(w$}rDmC}^NiDocH{Olsu=7}Auv1db;v#lSZ|7)*Bu{`;rl zg6=N~(Z^u=bKt*s4HtqE1sv9t`2>%XrRbi^P|(0d{~wZx%3897C?|Q5C^=)CEJgcV zhI9iLmr4%0DMRWDvh{Ur Abdrw0D})o?*Ih~$v&(FW6vf&czvxS+iv zA;`^plABQ^l_*&W&6S}bSSs5v zLDr|;z8~{iGHn16S=G!e3GuyV{zE+7lD3wz4DDPQ(!r0Va)Tzwka~=)q0V|43cD>s zR$eMej#b5^NQmzh^FTtB^D^N_tkZ(^Ff@|&X-CPBZmkRj-ISp^?U%`($H+E>O_Cw~ z&i#0y1*_|Pgy &hbQ43?p=Win*# zG|91cT0)d_T0)d-jS0)~pGejtqmisnyG@34RaeN?ER`X3Wm$8b|HEl3YXV4)l{E_` z#CK~LNhOMM)+DXK^HF3?S6QF-oDAurf0C`)FGK1V=2BdpK-sRao-$-*O-o7F_h!Fa zLVUNzpQKt_llK$mM^jH7SEg^93~3+AknW}#|B7HyHOmn3D`i8E%8+`$Y^e5ySsD;; z5Iw0Q{iOq9Ya*8Pmkx+~S4zXyby e9Rt=#h;miqxEe>tP}RJXHET!7nspClD5(1y*_wH>4RwyoP}nCKvZ|U# zlCJO8bRuz9))Y&Ka@K^b!FH|qh$QRN&6S}bSS#BwLDr|;Zk9$QBC-nlED1r9ON0L3 z8fiq5WoYNhkPgIi&i#mnjnBdjotRA-I>N<8Hz_ z%x}$lG;Ad6(~Xj$ptUko=cWv)+sm4@*=B4K%j&|_bvPoHd1t(nX|$u(mwKUlC__Qr zH^>nylTasHmKAnThOGQ~L=vt1=|tkJYfVCwueA-RHc*FhEz=h-L)sZKqzgBjXBn6~ zQ}(SGZC(?{hB3I4Yp&Fmtp#XN+q|P4yH}yZtoC%?ZG@6b{BWy3t?5m=w$oA-hZ*fl zUTJnU&@4KZeJ8yE-RumODDya}x !1 zYCTv}U11bmC75)3CWs4c=`?4Pgj+qqJgo b6e={TmiQ&nrv2VHzJle?HL67> z)5f)Z#d6OzoC!sgQ$)(WWt29Y(#?wUbqkSlph#&fqkKqc%RlYP%e Fs+ECa?tKbw8o+|>o~goleBXXE-yXo4+IS52Z$WQV zTB_*A#8Q|~r;y?!s|76FTz3d-u5{fRP36y8sOYiE7P++9H=Hq?ZE<}TMP;>k*~Xvc zvMFCNdIamFADYIi#V1ogRRpw|`g9R+$UCVTKQvXrLW`btp#K7Ro3ge1xR8#i=!%Iy zb1;p@%OhAVC`!U_BUpogfMMhawX0hD6^cwn=oQ2J*M^x_(ksvg%u(Ec<<-M3BUui( zPr*M%vZ?{+CZ{Sa-IPjEVkGUChmq79gnVVt6!SJ%xEljTv9>T~Hzti@uFz-_=8R%3 zoh^JTU7K(FW6zO8CRO`g_{S*L-2eW2s)xcE6iJgRZ7^?ry#pOBTT(!gmq)1L_oyGu z4A9{t&K}L^g-*En{Af0q!NEq@U@ZGR@NpPDHPmElsOx6xbpt6hnwdbEQ!#;5JshbT zXl{_i&M>&q0KX+OKiJm*{ZiOspX%+>6om>$e!z6g;@O_=N@td6W=YJ(`k}s>gjXHkEbq&g)BBhvd-(rBU~O5O+cSK}_@?2jSjS zHoV%j2a)34bu~(dXkMOuqj8E4bSLO&o~3GA@m^bP|G*rR#%vjj!FK7aD*QeGlhauX zSUv!Er;{ap2H>l777U>S(0d#mcYN!Q9mcVs>YHDSXG_U{NAkx&hs5AsszA|p_LWX&8!#4aP8n*J8X#M!dkqS$SSwtlS5e fj vIz_tpT>E!*bNvHk3(mZO>Y*WPY$~a z3GMM~4uwV69z$}eZa=oeF1aiWjyz4UDWC6t~5>Myj@@ZE>%WMZmvWe8uw(+oH!@s>U~M%)REa(F{Hp z;aL;A2KfeDJ&%r>QVr(Y^OzSvrFQr}kByAI70Cw^I+NJYjsyHm;wOUmnM9_3nuv4M z>{D+dvaB;KN4=7O?4mTnsRWKY4LPbgk*OHf)~0l>Ik|xK*VT OUQFvh?o9$IX;^Yaqexzp6DSAGXc82aYHZH^@ zf!bQrR9r5wR{m*3NHb5Jg)~^3Y-m`c=bE`HAIj1tZiz~_(99N5tPHCYX=It)gPP4C zb(dzm`^E=S7wy7tv#v}Vc~Z5nYqElgqf9y{-PuPWjX4+fG{{90BOic{>T&%NR@duI zQ$lW-$w$5z5|eejMxSFMuTvuavxK$s`!Pac`74Xh2z)v>>6Z1QGu+%_%fdQJ8iejQ z!A{5uT-Nze hanFlG&V47WltcrE!B?u&72SseUz85gak@~|K9$XYsjJiZa%uVo$F&d+X6ZHZsW z#^oN3%e^Bm*L0V$&pI{)v`g^BI@Z^2@8aePOGA}PfywRaxMK8rmI|AeqIo^_Dx0O| z+v^!!HF>r}n~kKoVLbl5k-dWGw&veAF$04Sapr12GdlBMYB0~*LWj=|LA10l2|P {{V&>A&6XuKa3 zgH3nP5d24LoUnua1VbmG+fG(XSARVn?eiXHxzt3d(Cnl2qGe-!s#KHe<$Ub7lSZ u}{x<^hZ6 }n{oG?|YsyI4z|u8gEGLMG`bk=&k#X`JNH zT3Jf3GLqK~L@ATz;WbXur;OxtxGZIXMB+IQ8}25OuWMu}%S%ZNmR*wh2Tiz$ll)Xh z(zKK$($Y~P>1o2ZoJ3zna;LGRDAMveRJ7&OT @vJG z;hF<%IJhRG!$InEI16JB(#)n`g-Z_d0kItxA7u61Yv &4?uk-UV*ZGAFWtBF&(8!V=kf~qof~yX(wZ8qw z(GXROH>}IGl?rMkH4KA_EI(8iopryA{SUJm?v>9pr!592nlp(M|LHH5UJ1t4hpEA( z48_93ECBYO#`}kvyH0 G4CCh>8gqxR(@>{rCrLk~Ap6|-Hw z+g)k65B 5`9mkxdwFk9`3uozS2o$N<{5&mMQd7*oo@YxK1XjaA7ugNZs<-$p1fSH5Y9A4% zl_kxZp0}{eC01Q?G=lFxmbc-H+T#^can2 i{u zM7Jw6iXEMS@mE++7%~GlUttf~8Z=&|W%p`)b(Q@P{|@+U hC=hV{8;-xp zik!8d`1|!3qFhcdKgqe;r?Jf~S_@71h}Uni4i43uDlGp-a<{y{V(4ueDc2NXpWAG) zSW-F@==@sevtF!&A|UA-FdOsA)@S^6!X>5HRu9p4zyglBM!(Kt<@O*nty5P8IW z_zvsMTprRPg~8--_F2FXaiTQpl8#~ByKI#6K)J-PwBX}WbN*eH%AnQ 01xWzIS3tzlGT8?>6rGC9e{-o z&GlaK3%g%GnH#)j(F~s4Gmn46+A-(4y{LKB vIPq-_!DR_FMD#_iQC|zqnznB127K zdtRAj@b3Zmdy-unu+B&3<~XK=-aOcdNRz6uU32X6k@@RNZlx%u^fQ )2eey zds-11-ru5);_tU`;YSu6u#-p(HC4NKl6zCycw0tp06G<*KcsG)3*P(4x@qf=R5(hJ z)XX$1dCSsPx`k~&u_(B76X$(mKI&~ZseREuLzaI1O+4_4Rd?|oNlSx<5SjUnjRSg~ zW(xZ|6yJVgevK@{Y0aB;lu_-|GMPFWEuz1W06IBP8cK@jv8{5-drMWI(}P@9Eh?bL z OZ<>k0FG?dsBaV0=B0ADSUh8v%xM-Vj13P?SZgKCfrzp~|K27{qQev@x zEJma$A4T0b+mA1JqfuQU8cV)KEUYLO(q@=SEsZ3e&kJ$K7v>RuK9P8 P@Enk^WlOyj- zMOoT#DJn@y;t;akh5Od9sTkkw#|YuW5djk7Yg>eGY_?t7rxasTShY{M@Ea{OwrKH( z5_ZULS#6rxOkp;)7S{q_NU6Oz9-6tHyFy<}(bg$EKKHcP?5Sq`YA;?1c~OHcB)=+` z|G2TJ@LUrLfOyJtRYD}sBMNc9f0QFm714*Y3d_5lBDK2~=@rm+@NI#<05{>tfA|rg zgZh_2WF7xjE?2c{5GE?2rh3+Q)Z{^ER6 Sck z8_HX5;T#+A0epp9c fb+(5vQ=zW*F!}6 zqDEl{TkwEE={UlcBz$qkIkr#(K6J!AwlEhSpEXC@!7K(@=kTmOv}SkBPL)8zJZw&r z1NsltNyobyOcN>^ieD<}CG8Ks7~udxtTy&@pic+=%o7}-A%j(CaJw2>vD@aiYN*Y8 zY) ~xIi;_cNDw1Ks39G%Ul4J0ZuKjp%%75%wc?}g;o$)j5S@MIXo`H;jYkz zUB*4G@H;H}1sA%(S|~h$johJqok&_*TCUQlNJD^opJSQ&) Ls z0msc(Jivv)3pccQ!YYrS_ES~NyponoOQKunMi5g9J@E; pPoG(UuT$b>T>fCS)(oq*#8g6c_gDwoB z58w(P@Pir$@URc)?aruqPf|P7LK|NQf!o2@$QOFo?!>K$xSp;PPY#+<&VtuyVZ@h6 ze5Ejqf3g}$N2+$}R6OhpI^|P`RQ$si+^ftgAXYl&&!u%09khmxD?nR6i0rb$%tfB1 z#TdOv`k7iVUncM&qtIPEJTs|Mh{AIIYDvkn--#yv3ef}*jb#}T5aO-__1R#K$dwR< z<8g%_WJ36E^zny&F#e=D!yob(G~0tt0k8~K7hrw>=wU#C`FQ{|tN^w0v1%~Xh3sb7 zDHs~T^Ks@m!4LzmF%2)(rZD@w#zu9Z4gC5u&Zq+s;1OY_XR_59jCpDPt1cv{U`(Vr zydiioIOl?iVbB>?)x|Ag)Wkadhku7r=j-qf+Bc%K=|33U2;#h*`BdLr^p!@G lniw@YydpH1~S#5d(3O;)`&qjMw-56KLq`M1{&aap{cz9+WS8$@kd zJgCoQM)jJY6vgbESG3L$+e)WE+!hJ>F3L&toYt3W{Ea62EQvIRT@J+2QBYN@u0{>P zWD{!0y=w8VM50WpCU f<3iZiv{`r-~@J@8OmW2Y!4`eKjCB%hLnWCD6SlW+j zhk~TAXeGp=JLuRFet{5wywDN?dOy2OCRM8~nH0`%4-6sFc_9aB|EfB}DUK6`W?y_@ zf6cx#{QNG2PJ^YdWp=MiDZO-%eCpw6D1OA3XUS`^T?|ySJLAve5k_x CB9l^uN*oe?AQtv?i8%H*rU6@CS#Rc%?P ?4xoJt3+vhYQFXx(hT*y^*% zC50kFy^M0{ZApUDM5>CJXVPmL)zb)P)J~zl5ogC&p|XE?nNj;*b;2?2j~b%$zR{ z9lbYUe@-=Q15WM;)f}cxpgIt@XUR`6d4kf7|9FB5&vb-DIJpe{I>8UnZ<%>^Cvaj= znI_fFa1?GF#J*j?9Xz( z5}-Gt-C-l-uD~zdN$9Dc(6 8G-QezW+};D?L05?MB-tAVEl1Cu zP|tmiKMfAy+=DZJ&<$tcEKzFWd>qgdHo88inqt#h^NGE`W!OxyY}{(p^`d|$F2nF% z6oaWEO%*=Pea t$oU^k~QbNB+FxC9JKVMVLK8uY9(sa5g&3s@WnFV(*;l#l( z-f3 JsT7tp5Xv`Z5vC1l}1>PP?i?Bu` z%sIn=F=#Lxjl*FvtQdw>M^IDK{fZMuz<{bLU8tk;``FMkvkj$(8rwTlKOR0=oNWen z#xEnF1%1nf`jIdS=6A-eBWe0h8HsmB!oN^$GF}=51K`*sbA!>~#~{UkiDMuN-nik? zG2}|K!RR>_yef_xK_@BcW3bs+NN3}4_gH94$+xi(;oX0pUU8%Xuj8f{N>BXwoI}s} zCymC=Ne~k3QbzIeJW +{6IW2``{yQV{4|5|gR@@ac%z~-P|M|Xu_=zu#A|6#!+FSk(ig(ba6K;SyH$dk zbV%w_(3?h@^g+}bu4aZwli-0?oX+NNqC2Hp(;!K~XGQ{763C0P^f-Vod+Di#b{gR< zAL%qr{ACRBHycJaz^mzGXKVv}PiaOiBB9ylmwh7?tu$tbNJGS>l;clF(J=}&O;;+U zVYebrO6Y__^ bBR j>BMLN$0=Y|zC~tX5`@iolP3M#* z)u&~RrE}rXGnUHhxHZ{<-DzJU9>l5taBQwP!lyQ(yhM~1
}yA~$fHKP!( zO@JDXORelV^8-3eq>xlA$1}2wC%F*y6G^o-Pckhtj_`X+i+`!={j%yKeZEK5rc6wm z(va+wLgH*K?#+4 ?}%hQ9Xo E8o z_eef0l~wze6EnC>%u%!0P}}B7AxX6sH@m4EVb4-=A{`IO{$Ax<$>;jAT?5RRKly*z z)v2{?*RBG|t{mB}4x%8bl`JX~17>uYLLpgf<(_{C#!sP;{MGY&7;BaBtl5WCr;uuE zp4F{n)em+{swc>*Gs=lM9x8hfEsF7Jn LM!>!h%Sgcse|(RGdi1L*g!qlUqrz zGJy~FV!Nqim$jf5o+QYVH#etJLBnLbcJ%n}-dj5NkvjB@y|`y8g~VFS^O5*`DutwV zIUZvfkN;lOO(WHUl@G7zNbiUrR~@xW3Q2ca^}X)jRR@-dIl2e`nFc;>6Qq#PCxhjO zvzT`@`-yw8+dCz3A{`IO1W}wElEQkjFT+KF)uQ+}|KG<&%^ect% dLBrF2F+>6p{s2s;50B)#5qjnjMl4;j-!$ z<-}x{iD^@Src4S+h!hgRTHNtQ6k%~_YEmjrq~jsE*_B%*hs3K);M3h);QzEM{2AFr zFZ}4@bGu|$4cV@-q9Cc`?5QKWH)l7#&7_dzS-E%d3)Y@aA*oP~r)?Qe&E42%I;pni zc^6DRh}Sf%RJFUTdR5~0$Ud$ut3JDnd*F@tr$fcy`BwVpJuD3`zxT`Mj&z|jB_57u ziRd(gX3Vj>u=)%LcC_Xzc!L9GfQQHBE~GH8bl}vMX{@x1_jsQSTd5KPt(m9Xkek!5 z+oafubX@CjF0WMF_FA$7dv@~QOG=>UJE~jrtn8oppwxcp`foCqcZzC-&-uD87?K4p z-bvrNP1f8m4q^W+(7Ipm{9UV8nby#qXv(7KTk|ZwE*pP&t7N=Fj{ewkV)g{f#$VZi zZ?m9=lgY~PbF|{DlMU|f70NNSEn`aFfqk-}T81@Kl^SI=U#j~Rc`8=ds!rcscwAG~ zU1djE;562v+ |QCh79_GD#=rT6wbQJO!i*mo&P(BuzPk zx|E4o_+3nfwV3mCEF|^JYTw0pX6a9N{O*{#Oit(RVlQ{MaQ{szZ);g&WAO4!>Qw{E z@ouak2jy+P$cuinz_yjq%9o>q$cU;WlNR$oOSOBSYv4`uV*)os! zdKR@4Yf-I7ktp%(^z~**(oI>?d{I*A`m4HZR+D_ZHj4&`IaU@`>W&Vx$(3s5ct)1- z+}eiv*`(T<=R`NTZv9JDpO#f0>F_<8Hf3TaZ47V;ib-$gW%~UyO92dPICI(y>&~e%Y> aX*xat0akw z&y&QZS&Mt^M&iWvj#sHTk&cTy5O38|KL^ML?)n+?=TK~{C3(yxN#Z+;UF#*oa^%=_ z5G9qa0n5a6`Wan}(8qC>m4Ec6bGi{c`tNE>+T|(XzpAqC!p&s^h@UlMlnv<@@BL1# zBmMI5J)Fytr-CvuQ#Rv06Zo{8K<%_>rj-GU{78;CFAn8$XeOH=c3j(Xg4&e{3XlY8 zlSoiMYeD}^$9Z$X)qPW&?-o4wmmN8~Nwh#quh~`K_-_?`Une~ugD*K_>vD|7GREXm z#{R_klQrX^37B9a#y{h}YYZ-9bT4J>PK?d08QYJ+U7Rty9OD^3+0-K&xv2qTv#hLi z8bfgkq$;+|SR=(LL5x#CIT^#sWDMC@R&Ro}oP8f@;CJV8ns7OCz5Q;$ k8VI> zXfK9g^Fb3|u^e}N8F$Ke?(x>#HVMSdCr&;Ic$K%~P%P%nrxjDk2HZHGMlEZ;5^wxt zKDcIdi{(z! ?Bf9)4(%MRMEaI?ILaay#3`<#u+Y(+98QQbqB(+SWu)T<;r>B`)oUhBz+|CdRF& ziTD8Ds?xDhR R+zAW zqOo)}4p~44zgr_QcL9v7gpp*oX68j&G~uOh-X0m^Vd&T>It)G7M?6LyNihG=q(at2 z#~;#@>48dr!YI;PiZ3MS6MmCAX&I4MGV+5YRSYLLY7=>q>qt-o5z=yerv6VcOt$<- zhMV(sno;c{(s!hvdezzy$bbLz6WL_Q #NZh`VE^Vj9;KhY7N7t6nqM^YA zSbpnYQj&MTmcl|2b!ma)1p3{l&irb_fv#J1>sqHMavVw@#zamz{*+E~_=C2@w{%u0 zc9^IQ($T+P7y97plK_o0-J8?x%9-?_&?0iLZF6j~2((~WgIyQV3H%d1u3H2NHGU)_ z%j2G6lr|8|?@j22TD_(eb`(uaZH``xp+nWGoO(1-XX&q#qtsaV=~Ny?;TivVrk0hG zaPDGouT-N_lH}VxJ?>Zx-r8b4F*- R=IRla(A`-e z{R|d4-A9k1OQ2 @jGH!gul20yew z3(_@1lNRW_l iVHiwmj#rn`-K!}Azb=IYXc~bXexwuFM|vFbBQ)>!;2T9% z+^>&VB_6J}9M(kaoV})V*Rx4c>Epem|M8g4lhHY$b{SnO>~x{)Z8A8CUeZ6iM$XlE zhb2EkOyygR$@+-XX?9dAb>hq&mw_++=*zE|vYf8!Vxw`(a;W7I+lW8B$)x5$w3jv6 zQ|QE*WaPz~Un~cDdscg>`OONt(sTPy;N6M;*jTyO>1Wzt=T*?$ZT0_EnAQe2ucBMP z54-WVRS@VF{(liUv_Z$!5Ky(|8ZO?E#Pe39+c%4zx$m{Jp-10K#lT$pgo&$RM&P8= z^09eSsi4VwLPQduC0IfPFT~HQ!MAGc94?XH-ii{>OeKjIY)Rr%6eS)Ku-O{ARjAqw zudbovau)#|*Fra#+Z2bdg()zkAwF44$Lf9!vCcZE4(d>I*L9$0-fxbVnjj7wlUwt9 znP%dd`Jh|4V?B&gCfX;V&j#?)W>MP}$J$NYif5InCc^7t`wigj^d^v6?nzY}N*Fa# zdXGAJ12m~ObSRyr)0oK5 2_KArG!TW|Ukwqu;gMKpGYy?fs$#-s&{gihJ9O}VGMs+%v^h@R41dD2IymrW zcnx-bn79R+fVKfH*a9^@nvS4fE(tvoT5?#td%v2c6u(gE))cR90e{;}-y|9wovP!P zEszuV;Tt`@&D|3y?k0`;*J5@`;Dpd3`T@u0{IY7F4{qK{H_1^4@#|IyQP0ohrX=$Y z=3EP#Y=iFo>}T;A%qGy{|3pQ14c$rx#~T|4eW3S!Mpb2cZ;-2M=tH;7s`~WTs8$dr zqIhbBT(uA7>FS|#6F#1)VtDS9DwGobk6)+sC-hVuzTQS}W*+I#IUl+^KF|>vQ$6H~ zN!4H`PR@sF9xW;RN>fa)UV76F-XL`)dKW>(HW7F*pY9qD*T(ylj&S!*Qe>Y=t9DXV znIQVAhre~y0>QKFxwCoME$F#$2kA1Wf1dc%fCxGU(klylqdJb#tdA_cqFC^(0{w)b zW>ImD`gIK&(oU(yc_k_MtHx8RDPA~cJKfsa2V%~42+=hp4F;n_prKZLpn)D#oTlM1 zljE3rx;Or|9on 8NGMX1<>d{7`YR+yH@k$YXXSRRoM_^FnW^2Wi`=e7c7LmLAZ1m z41s~}OLjxIUJax+L%n8lhLR3Y0{!TARWmb=x{^^l!HwVaWQ(;Dq3ubz^jMlYFKbCC zy&DKMYMT=-#;9saNmRt8@$>`7+O4i!xoVp$?%oZxeM%L`jdI6iC4Curn&{an)e>L) zvYT#~duE__0aRBf`0`HU#RG7=J|+~ )W6&e)!QY82#p_;=< zmAwzS{H!t`Z|;!X92HV&oUt|O%H+EEKl&x-s6wb<8zdEZze?A-i 8S ztKBe;X6oTu{AVw !=`q_VUe146m>!CVf*kqfIX%8mw{gNR`ynR8L8Q2)$@)sZ z^2wf5X (lJ2XjH`yj-HuOdX*T&m8cTv|O#6;WTh z4;eC^FFtE&C-5X!JDn#+?Fiz|KM4NSn)7_bo2g0UX$rwKoN`|;iJo)`q}5?qXQF+3 z5W4!FtxU8vFOc$Vf@s(XB~$Y`GFQF5auN <%vnH;I9aBkMbwdbU?!?>9?9^ z+tR0%@1|15sID&3g<0r%fg#s{eqCL4mlv4S*V8%1rI-{cISo_&!f9yukPh*08EO_A z2C0tFDv-vcVk&pxWEo7Q6-BCEP>DFyS!(a5@1N-x1UYFf)nAlox)r@KXo8Cf>qgX? zZfAJ*^cziI`WVMaHHb{e6PL999;9 %WQ7t{P0rPY_x#6% zvbcrNXsU7&y;0rfm>=l1ySR8f&U4)wimU5O8|aN(tz#5XlWM&u_i#lZKjg8P{rS5% zdhIl)3OW`+^_Byw&~q@&E_)iaX&N{|CDmV!Rpb^=e?a7!v t{VN|%wo)TfXy(nOClK_2RQ0nUtu8K4LhVuTw>xYr zhWn)xwm1q~;mt&Rew5nSkp>uij0VhB^|8${8X^4aW709|iT$VIykp=;Z&jM(E{+%% ziWiU3GfIbwB*!#2Fq3*deZmq!|4rn(hc6WfpDW=h`(J4`hsY@VqCyh-ABX+$c{1KP zPH(2ybwkYw@bvCnmB(1!MNG7bJYkpV$(^=S)f8-hN$2i7NSCW3(Z;YPJgF5nTa|=K zC+H|VK}4%Zi)eLULjQaM%#bz-2cD$2CG?zg;YkQcoK5qOq1Zy74rK?5*EB{|Hb)uN z?QBS3@lz_98|Ww58C?z~-iOdn%a%hqN>na>XnzWHt|ye-=j^})LR$ Eb33wkYLc_fYVU3#hgmCg37POS5SSmRR5))Ys~wNw~MfI)TC?Pq-d&_ zz2l!nX^mo{`{e=8g&lf;XHJ8`ug(#9M@b)wQ$cp1g|jd67F9Zqz|b?$IOBX &KEd4SypJQnh)lH{7 zz4m&{Cs{9AuZ^ZW|0nj!7|KWo0>NB1J~;!it>#gOu+&^k()C0aIZj70X)~;J$(VfR ze=He!mpbhwtw$`WL#4%Qp% We=^y`O?5Phg#a6 zbCBt>NX54{rE4nnB+5 lVa|L%YK_7+#&O@S06~gm%u%A&~k-!M`e>N$2_B{By zeWU$ry>?`fNzIpn;(n&RUCNRR@TyiXTV7Z6c2Zmq@$0>Nr7I$B^k6;Fig;2Ja1^~R z(sRN%HvBM>80k^|7fh#ex|>rFEvLINX?Tzz oJ~niZ$^F2?%y&C#u1(l*2Gfrt>~Y;%&7hAU!3q8?Q~l#!OOqV8h=$HKKqSE zdF^I&xek#TjlU!-Kr@qGosq+;8N_5%kK%u|BSZmVPhOK8dTE%zw|wt3Gl$R!5^+{D zyDz2mBCsNFh$_nia)kH4pNT0;zgEHPl-9WF1(dfuzQJph|BpMSQI`Lsk{wsMTwC$` z0PWVRc;`AaQ)be;Mb8`1%&r RFkE`nrj4^rt*_rnx$figY z(ujxz5ky2RL9n8qUQTa^h?3|oAyL)}Q6Elkhv?<3ayp4}N_3(-?v3cZ=l7Y}<$L$r zKXzuGXWnP}GtWFT^GqeDVKfW&(%H9II&~GSZEtIOe3dn1zL8Yr8cy$P)oJK8R*|KS zpyk(~)Z Iv5@39FGQlTSk;)-7dC$k{7-CtfA}?<-%D#=ST;vcCp45$ zhM;rix(N0q?wGf7l*lG!ffQ!~tsA@hZ1~DrPBDoO|2IAvhx%<1g0*ed2)Z~BfP7^m zFxG}QYAz0vK#}^ui-UXC1UlRBxDsyt{!HWld>?JY`+v{Cs#d^@wKcw{Z`YZxdx}>C zD$}RP6bzqB9VKJBxdOLMS^Z?s55n-wq&zVRp_*bOn;bq3!b@{^%mC_IGkS|{9eVy! z0+yN9HR*1^jZ>hv)BieMm>Y6WmX1$<;h6LbrQ`k^oK(Yb^f;>prZEU>moG}*EFa8D zHu>}e%t~{Tm8Lb2Vhc!N%Opha7-+RiK)or~#EoTM1k;L Zo^A`Xttab2w(@?v#LdFT7#ig@hf8_h2Y$Kk0A6060 z5%6G>>h+ZR-eN6c{Enh%xG97u$E0B}{jSsB%W8JtF8w&@4{en_R)bS~#ifp@^W$q6 z&D9QPq!>DUq_?+Nwd6TiY-pQZWDaM-FW%s(YcFckXvz`1)6Zybg}LXG=sk%KjOLEm zc}Dw0pO;OWp98a75+yi7b3rUXVgyHsMo|1@#f1pm8uT<7svts $1g}8<^_?}++Pcpr<8kPl* z*Z^JaQ?G`~GtTz;HPc&vOZlW5DMj{GKfq;DJ}XHfVhohSg2HGzB~C=!%gUPZiefb8 z!RC*n4jMS#>o^*vVV%odi8M@m_Uq|@#@ZK|atIy395M9}`R1`Ij%`E`B@y$ue^2U~ zhZTLWjAAH%h{or!dTjbrI-JLXSnsElm&bl7vht3gpq8{y=f})gtQCUe#hhupLz^En zzvM6hScTa$2%h}k5Vh?%!4vcig=11@TAd6ysfJch&}A~@A>jl7wCzV_Gh!1+2s>Rw z)Wdfbe0A@30%X ?-h?uPg0fb3dQ2Pg
*7ZA|o=P6{6;T%rGfWixKvjRamCWs^1Y`Kt~qh1U|}GK<2VV#~vHlO{c7t-BO2 zf|w%HT1~_f^I{Ec>lYbymore-c@dnTqz&z*g};|*J8z%`3$e~>pT$Q)bktm|_*oHD z?HM*x%uT54GuGR6KyHHF$Qn^Tec_I%jFw%2jfQ8eqH86As2b%HA`qcK^w>`B&soLf zVSf*`%bPERl+QY+w11FP>SvZDR8J{j@p-s|g(^971JcyRVIEOF+3<~UEPqiuf=uHH z(98J1?7mInohFJmI-`8nBIGprBQO|2nFy4>XpFk;H2J=SxMu)jG`rov68^UgI{X}$ zpo-j}ThCdCVqq`{2UeY~UWuic7cAb_d>wHy!*<6~(dO--?R_rmW)0El7Z|!vw$bAk z%$G+h1}gRvcmBF5s5ImoTEmZMlr`4qp}$bfOUyDp_vx3HtOZ-G(cYJ=9rM3N#b04J z$N4RFeT8d4tFO{D5nk}da^)5KS;xM%rHD7|e$4pyVvxjw?7mMBrp@|90L<DR+2zfE~Z9`CSkQEn);f5-gwUxx@r;dTQHx1WYk+B?>rbx5E`@0gh#x01_gg*tyt>_8v7OZOIlo_17BGd zn{tsRe`Ckk;R}|g1>#2LA6Mor;9XhUEAu|^05&p*V9eqYJM)lU>Uabzdzpe6&veX0 zH~wyi@W55N%lPnm;n`qEX-{NZRfON{9*KV;e*3}AR?FgF{G=p-)^ir-bL_S4J!}%k z%PgA5dA&xPFpb&9U;eiOvwr>Aq1fYbO3T5wO(F98W3Vg~Qyo-NaA4 T06`xb1}D~}ubf4L z74GR