From b1c2264cc699b3ede27af9b019584bb82df263f7 Mon Sep 17 00:00:00 2001 From: devmatrix Date: Fri, 20 Feb 2026 15:15:11 +0000 Subject: [PATCH] Add auth, SSH support, and web config --- __pycache__/fan_controller.cpython-312.pyc | Bin 0 -> 36295 bytes fan_controller.py | 291 ++++- requirements.txt | 2 + server.log | 7 + web_server.py | 1283 +++++++++++++++----- 5 files changed, 1260 insertions(+), 323 deletions(-) create mode 100644 __pycache__/fan_controller.cpython-312.pyc create mode 100644 server.log diff --git a/__pycache__/fan_controller.cpython-312.pyc b/__pycache__/fan_controller.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae16cad99b8f8abf7a1bae4e2cb8eac79d8675da GIT binary patch literal 36295 zcmd7532;-zHN196)1zUwwnxisU5}30`W`*A4Lt^C8+(k*Huacb>xRt3mLAKnwa2QVa`Z#C zVSA5#IHf0LIJGBr*wN!);f5jSu(QX>+{U4_;q;z#4X5S2hQUg?X>l8&RdJu};!P}l zwd{}EZ^oP~zBV2%yLj`P8p(Z9`v@giOW)ARIW(NN zey}&*bJ-=&i5ZR1&T^K;b1;8-+>a9ala>OHP^+{Gk<;DkNo3uuDH!N$SH(_ zEXR7;&t|l4@Ju|d?Ba`8rCG-}#{CbeWrLg|wr-ka|0C4W9M6MVCgj${ZFwwMnd|tC z@$?hg2`%g0rg#|qU%Or@pQj~o#JczKgL~aweIxFU(GkBeIyB@J+-Ekp>)eM2hR5Kk zbsroRyzVDQkB|HO)~->(-RT_~av#~;xZd42!n^xNhsXN-1ILHFZl6~;;}v|?PpRSE z9<6BM`}}?VLw!D;X}XS_oR{$a1r*LM=Jfr1AA@L0pZ*ziEz ziN2A#ex-NyL!&1>I?=$gipCS8!f+p2atfV6cYXcwQRleWrolZZMd&5Y%VE3ZdbCe* zUY!>kBmrNq0WK}MCUods3Wl;{9OLl#=)efN>Guwgi3Z=8*UNW%jH0=>ms;D~D_VPd zhe!GGA#&S$d!HZgWBq{7aAIf_Epqhs_Kl2;`Z2hpBR+)dP~dg$0k!YTwuV!q!`=qo zdj^dc2F^8%38RDFe!s6_b!Y3&Y@m?dgzk?`2o4m%j6yhkb{OC1?phr$?T^}0U*0DW ztYp#ZW3dU*4;c#@6*d-ZRCH{7C``}B$AAGyKY{`A3Vr@@!TY4Qj~^I0De7rRMDx&S zKkKV#IMX*Y?iGzb^nKiiVH47+MHytcV2C=OUl6j$n@L6v8LZKH~!FtC8Nu z054j}9vL4#?iEA}n;yNw*sz!)IWa>0-VuMd$CT7{p#+slUC$<$iHxNFQ?~^&YVh;9 zVW9tZdb`eymkvc8*>k!JQ(=8UG$VhmW41k_cS|EdSyqjN1p)Lt_%;x!_(<@Y9vwgo z-ZZb1krU?E^M+M^Bd=fOH}R%belu@b<+t!w*?-aq#IuaId5ydszZCpZy`~dp-U0N+ z%$M^{uf=Pmk>}IACO+M3^&+i=7M(Ei8AxSEsO^N7cfG9dNr|~L;ZBXYv*32{6+oR# zK%I;%)X2~$C)^fq8kLUxc}SPeLNa0@`3Q0G1uTydC|4o;nHFr$e6gI5cYCwo)>97F z%5=2e$d{m9*|GXdkt=7S$g@sNBN&W15{JNntql4OoEY%(ZU&(-T>xqo2z_?If66T| znC$in0-!R3Usz4`pBACcPicvNO8E4rRQ;#GEx1o<5q{c51|f5w5{4n_PmKZ^o5umW zrByfvXnl56;6?q|sNnB6Ad&nKBCNyjfgi>tZc;PJ#c`rZEqpPAo8u;R@KJj9OzPpI zob2H#Uku^G`{UqKYT>CA5Gk>gR0>UT_DpadeRr2YrG82oJZ7N@me5SbMluL8iMHXs z5dc$yu3piF&Bx#C8}IMO#&BYMs8;~Y5nUKbpSOS9KXAs|d!lb(2*AiE+K2njDL#)u z)cd?cC)BF&QSEMe1Ohk&)CWce{Jp(_yftsKUXFVVDSg{uyvRjeIn(-!mS~O}R*G!J z0`n0VG3P|ftH>^imRCr2LE*IimN}O#RZ-t3ob-t%+7m~|{i0Fu0-lcaD?^NGQt)Fh z>n|{1ml(Cj>v+5f5Ql3X-hgoBb;YB3Bf^zel@M<7E3XA9%<&l6C1WEeZIhY+0;hBq z&To@r2WihqdM0(enKw@otrn|sNx>q$g3ZujUwqep*@T# zc1|EZ3DgTQUl^BVVtR_-yV-4yjy)RHpJFH_^( zAkChnr<@ZSHGf!pX){lH_|!=)FmOjAC3BRUaQX>Gum|;ZPT}qU0pp`qU)rQ0c`OoU zib4mR(DrMOa(w!f;k$;zG5f6MEO$X{c{xY-rpE&mR1q!tZhX#*P5pOYQF)Bb=ib>tzf}28h`?zwj8wKmsTP zxdNFC)XFsyVDR!=-CcpgRpGSicsI&%_nq(qTNX7wkG4xR9g$XJz_lthYw}iix5t?b zO4wG;K$g`vejErK!6;EDjE{)c*n$QK6I#(S!i9LY(t*651j=AK+b4_=?nDu&LQIX- zzU$oBfZ*jt1FKiG>^j%)WjHplsCMe@b}}BLGIjmKJb@11_>f=JczpzR8B~jbHA^p= z(n}*8$X9`Da^yZ_^R;9AUgTDcTuSzwer_OYcg~bw^2~Z9N8M~)qT|P9GyJ82*@0K< zS8PUyb;Zu5q}@v~I?|?fE6z+y$(_vNOV7z&l;b3-6&{^}lJC59CM^(sCb)VB&%F*U! zd&NzA#X?%hUVY16amSwi%7F_9g83~=*-MriX~Eo%h`sZ6M%f)l4&^9$c#eie&4MqO z+Z3@kM>BF>FPrOot#YRQmfijP6%#Uk=_3x-FLiAl2JSs>haT4Zh8>z+F5L%aGnvjU zyBxY-St#UJ4g<`7Wv@j>0+Y2~ZyOqIS4n)DMiD82ywW*L4k>JsyqlE%NEutP)MJEmqX@`lunw z-ste7Od>yG#RW>%PNk$Ir;*=Bi{nM^Ze}jcg!v;2o94ThcFsH(&fI>%I&GRsi#oEV zQ`u&yJ`lvDc-TOk#8CVzjSR2jgCGl(;2?KE_;B0QfM(Tl7=!CwrqJI6kru9NU3L@wFMrN)*639EqP?uF1y{t(7x}J@< zumzcfZZdkvI0j?&fagjXdSh4+kr^?(F;KI1=pUMRj0z^qimmFbJbDc`%lC&f4@l#G z+m?>SIo%nt7tpHMe_?+xr#WKZ`2WlI;(n6hM{fV9yKme-3X*xCKXH)pVua%~ob*a0 zgC`dmU4fzKRhiUdSy=-OhrNFGx)^cn zu3BH$xPE#om#wu8fwV3V zR8o09l#ZG4rKZB8lNOn>zT{0)89&i-%6qPtl7PihCLl%z*yVuIEEU$E~Ga<4WI6hyj{d&SU?GW)g1fYxIU z8PveATnQy^OD)W%noqiUBthu;t5?l!)iKk4e=?rYn8<2|ebs#D* zPMP5|u{@TqCSfp#EeYto3h5$?p{A^pRbeOF?pLfJJD){PwYwLWx00ett%q!XwGJUC;6k1Tfgm4ou5KZGF zr$_!@FgG6=+M862dWVm5? zQrNl`t-i6L0sqjz2&j4RT;YZV_UM?X8}g2bT7j5~-aeuKlxW?%yZg|SyE=9p-o==R zqT!@4IzA?5_cDIl6Gwskzih(G(jr(SQx8|CH2x>WGhMbqVv1nG#>Yc@khGVE}!B(zTEzIn9r#z<-1R zV3JpKrT%jLP0#Ld-ku8%piNn~9a+(g%;~*%o$1qts4H*TRS|Mkgk9_Ib7_|B>CU@V zwQpOmTNj(&bbRD$nQxqV;?lQfzx6MV23;-FyI|aLq|a>pVPNiwAD$=N`H9(cGx|Ff zRSTIv?O81OSI^C5&OQI9IZL@S#%NygyykM%jOmk{yepQ=mSE}58@vDJ$=@9N^|5fy zGcyJ;Bj-**$$Z7NnyWPneLt-a7c|c70!Fc1+!`uwUFr-M@0i&Wb>&_Cz}34ki$ZG@U2-acjfnpUPvn!jNy?UVLZt+apRR;Es+d@TsJOPfAnXie6AQ3k`48i%C9%LowlXqr#>7nIT0O z69ZR9FOM$tg^Sil@)~b+&UD;$iafZu3YU9_|!T3HP^T$1(wFgRR-Ua!=0 zPS>>Ui~Alfw_Sq6)!Dl;jqK(1({?XEGSOLWDqUF z{CeisOn~x;g@F1%3eALhLc?2Pxva4;3vY|LY+&O`C8oqe>=UUTN1&pEMAg{(SD`u* zV&j$(fqL-G#>OEX0l!YvG>W=WZ1;KuHHw-IqRxMIl=(J@hJleGuU|Cv4IdxCiWN=X zbEoLJm5!FJvp4uUyy{D%}*t#?D`OVa_`r2o*I(vkRkX zn8LI9^M-}Ikh_TjGIF9Rt~qNcrF4E^aciiuJ(`ghP05<84yBYwGYX<9nR9uel(P9V zi+Q1St&lE)$Y30rZsGgX9|K_58Sg3zlvs{zCUnB2nc&k*;BCKX2ab!V3QP0j0G<|g%>oIC^Njdsc~lAe%C zk5p*Q+EfX2g_bwG%)MolrJ7U5Nun$mEip*Uw4~=W1OkFXNug|}@njHXPzh#2qkImTx?QLqh2->{-h#KrFTTNurX3?6`=}`QWK>Q*wBBgdTrRkEo!Y$ zPt8g*phS!w&=dvz@hjBaq)i@o*_QePWI%rBDt%UHH63U*YoM{CZ>WEK2wF65Kg5Kq zSSQE(d?dOUNd$1LxvuF3Tq7#&x>nTIuNO6b9~Bytn|^6&JLw(qo*NUk1zOfZvwpc7@qN3# zjiB(5Z(F^R^HY@PBbnWQ<9_!dcQbDjcGSizo8QnC13elh6Z<6m1p@x?-u!neE}=IY;=S4Mh440FSM`Kg12l~u0B%U^8raFtVUD#I412sf z4i=$RKFTWLMqirShOkHRecJ+?AN4K7GX4)L5o-lI%wprao8H;w$@xSbMDZZip@0DXDm7amK8a`$ z`bHrAVz?zClL#0cAK^WAY1gD(M)=oc5cVcouqiY0*(aJwv`>_@XqO7@WqXmR84$H& zjrd`^hECGJD65BQL?vR85;>lsG9v>O-)PAG4(;)Mk71Mdj38G*gWt6|r`w?ku|SNlD8q8yXs?R06gxxN>z1=?L)o=AZMBp{8_FnK&hUgXJVCqXleDZK`{vrh z*;NaTND@w~yU&>{p6MOY%#!(%+1~k~#m2?w-`Nj-#*}1BSqi+8A zwUbv*e&nb_pD%5m-8^^v%E`+oZ)Q|P^9!%ETyFW%p=fT&{LX8;ukH@z)+}n@HN9h6 zD*eT}pRWt8-x12$Ib(=|gJ8aFp5M5z^X)y?_bdwUo_pt9sCIj(?6FYJV>1Sf$m<*D zGhd_T-E>y1IMA|{bk12Yw=?7@UUpQ392G%x#TWM*xXj%eHcx-z?kd#3tB0{_JrQtX z=mcq1`n!QSZ?* z(XP;j3IHYv?@L&fqIJO6d&=84A z2@pj6ame2#zWH}3@FW=nWQ>tfK*kTrC?n&?WPFbd6B+N3@hdV&tAaI@EjGbJUNUG0 z^6f?+U*>M>JHgVnWX&9(D-D|qZ<{k8!BrME7p2Jh*O4sC4TC4xW`wg$CEWe!eH< zUjKlDdu5vgnbAa8iyaTh0=uh2vs-gdS7NAKa0Tlh3wd^|aPUM+s~4IVQ$jV{LZ#bR zC_1{o`vKW2T^g;SeXjE}4wf{=G_w8pDUU7YdLYSyWKCDn^|Nr$Z3ERsdYt$fNVk*b z8xLK_n9Bs+J1cL7b{numuNhi%R%o|@;(D3u0gDE@d3MTya67_NpwFjI2-Wkc2uP zs}L&9k|eu3NdrSPV{_^~0WA-vIo40qi2PuwNTA>Z97LHw)ku!xzPYX&u=R)Qx>1=* zozQO6cC(!lN}n|86ZG2thP+Hu#3=QYK@M3s^{~XrVkv(JCJ)7KqV-_0O}2l=OxS|KPr(PH6Z$CEDqyBb?Vp(;2wLW#AM^pHly>w2KWoEp1RXo}M7*5 zs4*7h_6kn*ypYH0XzLi+fcUJWhQZ77sf3dpm*vay6Ub%HV7}~%+tB5Wy(5&jTLp5E2 zGzR3PxC#m0dQ-UWc z*c4Ovk(5*pQ+O+k7<&uKcv4q-PeYCEq)&JnajRKe%LrjTOoa9Iv1~X*2$>lS=%(oN zEV*~`N|*C$LwU8~y!z?Ri~FI4MG7g)_NI`%>EG{JZr&Ga-WP5@5V0QwmR(pv4Af{& z0g0ue1@0>cFCPRhde34gN}cXrNd*`4m8l<0{meDbUmLzU94={IE@=&wv_?wWqm3Ki zEqteNCUx$+H=Xs-w9HF|KPrsYHNJi9`Y{9sZaS+!fu@;#)*dX}8*%P~i0rPtc)C++ zO>xMM|Hk?HoA$;8seI?#d#~?ZXbt9Uj@VnG8QH)4L{cWpRkY1E0J$y;=5C7EH?LJN z%gA9GWVv7ZNR;@!j{MF-?){=|Fh3}?>?+iKP+7MtNB6571Kg`%v$Df|2l*a`>g@zDmAN$2}8w%L(xGJla5J$Iz(H%a%>-J(6}AjtNQA(a=xP!Uw2n6&eh^m{{^a z9%k^W`4Ep=Pg9}-d9iuf9Iv$gpjDX!g&})k#9kcDE1K^7u4mAa6alXs z2ek^F+U6+7e`Q{%SO_@@3Ji+8FJ2!s$dfdD(OZR~7*98;$S0?uN`dL1Y{`>GrUjiY zrv*UY3uO!fC+tz%48h5<1n1BDf)i)@}Ueb)qCWM(G- zeM_K%gSs zmI8)1FKHH`!rL0Lx2*xg1Z@soI25r%Op^J>dw;)TK~4!I&+l~ZY~Yp=U}C2eA0{gEsUj>5&&8OVx`sYQQF5rXeo1m{c%R zC<0W`c5(6iq#-6%`KsOofjDT(-}C@G080BRI{DGA!J^R1GApJ)To`ZOKp+(l>9JQj&h%U}zE z71173XXx!eH83U#{imtuLNbV8V60&uEeRR$NK9+tE`kCT$&f}B?sXHau`+Z~QhUnI z1z&WP?o+m<5JG!Epsll8Uu~Q21e!=#;DxEV!x2ZxLiyVb*Bh3~Bh}lXp>*Yi%P%Z6 zg^L?z>``Y9rOch{yomz|PcQi24qOksIeD|9mC#Fw|L2=tbAUm)Mx=jb+vRQH{F<50 z6|FV1@Q%A;-n+2l>ZxlZS4YB?o5JqRbB1Wyy5+L=P+9vDf8#*7?1?#ZG^g;2{jxn$ zu{m14etz3x+G6u!Ww2yRv;qS8bx03^d}SUKqjK|CO3<%;32=p+gK&1RdB^WK|bP!d=)HW=nIBB z`H@#V)T1<$Iwr}7JeNuGnM5CQdvKRxAxxr=5Yq%siL%s?>Ok4fcM z;lIiYcB2IkBL*b(aB#V`-JcTF1TUGWx@4T4@pIQA>v4|tdFm?7KpcfsIM$6JM{Uqt z%TRNVuDkowEUFX)Ycgdi)p~`Ij zYl^VVZa&OfzrtS`cdnr_~yBTi~|E+{DkdGk$Igc(@$6wI;8_>x^-AQxKbm zkaUzlyxa`%a$PJZIJ)p>;*>U(PJG;~i40GsNc#63(s|~?xHe*--y5jgiN!}+xH#=c zj1`12j;{KV4bcX&@WjALfPDX{RR|PO5;Q#@yok4%%(~@4oyCtSGat=;_As)Qa!Ni* zDGw;eP6iQ`D8;QhrhJl8D)buj5vt6dI16x)U!o-OP6LS!ACyB3B7t3bTyQ8qoY1k@gCM6lhVt-0rw7_X>eI zq8qm`E4u)eJ-T5NlO_}vQJOqD{Q^5T+DyTYXiXig)H|-iOWm{GVOKePmK9@hw)L(( zdoF#&02lDEl!Cb@S4`wKOKuCftz1gZT*iux+)gefZ*JpC8oATiQPq_Ua=W;c{JGAR zOmb&Q?rd`BNa=IQohOCole>WOYF7%$UCO0EVRNO7+~rbu1-UD!-t?7qagCrW&qAB* zD5t^S#X9L|IwTPa7zb5WI;BsjQK9tW?I#ug)ultepqbKtR}T$UW+$+k^nt$RMQ(M2aYS1ogC)X8aEW@T9*l_k zLEk7$N-Gp#CWyxIF}idq;aI7{B#NQD%4SRL5H%-6Bjtu}&eJG18R;!VnSmk|pg?(I z5amB2jgR&TV&R)DEqC&Y=KRaW4WZ(OaNhdq{ZU8WvZEyAD2X`A?quf9)r2!ES2%52 z!$Qd&Fz}CFK05#WYtPPr+rt!-B_*2qGZ9z)=LV#L5=>h5CEKhmSk$;!zPw>)Xv5B6 zV@I%{^OkehiiLBQf38I`R^snJGlPT!eS}uX=euQ&`?7s=$iDgaAet;KU-}4;e{cQH zeD2>i?sVvWSym0_dk(|S9MgL)6S?y(9Y)=IJ{9%WuU&q` zMo|GXs~5W)3VqUf&9aLXS_*^4}4j+ z^p4;*GZ2S#^a9#sOz71+dRo++70zIEpiN^O7~v7w9dJ@9@**U@ZsPj%#cNo-K1HKc zIL?Nh<_83!7*j-W!+@6f`0yB8r>g5yFYU}_l)jt{wjyb=5W_^`(%O5e-q^}hU6~2- z@1YRi0`k+!G^wmid-k%uC}b~MwwH(OXOl9jbk9#lGuru5AUWN(b@8wxK47&HqaymHO`=rj) z{i?Ah^rH#C2bD1XrAE;z2E9aD#TRfaBc?@cm9>Z^ZCm1nUe^h4!4fqYb@fD_=L35@@J-J^?tWHI>vVviz8J<2_|P%>9Ccjon)Q0}=egl0rzXV<|J^kfj< zLoks+T0jEO9>|@{64WWBka`%Ru5V-l3}1HF6(in&hRF1Z4niJ*1oNU(N)V@4Fm8-` zXrfrVlo7X;oy09Qj6~~~JU#}rz9R#zlvQsnQ2aHo^}nES-xR$=@XyTlS8Nw-%jUe0 zId5)L#9SIR+aV$LT8+5B+caB%%lByTvJJv?o{la(9JJr`^^8_7Kv&N?@3``DJv zL_C!tdu7C4b=#ITx9Q5(%UkD%Bl*osM}E=!m%S{$1fr;0_OcZnx9y-NnBOe1WpIIU z@)}|QJtU6ZQLG-P+Cv=UlJS#A$OX=boGX5F7gP4H_)E zcT5eBkw#-R#xY?_D2sTWNe_;qedDrluUJCACiI?ETrGi%=LY(Q1_C(kamuS)-VK(5$T=wK`oz-U<`Pmp!fA>w37CC?mNAGX{1seSv;?OUln^(;3nh+3B_vWycK7+A zj8qt>K>!r!t{*~um-@*r9-oa=J$tP>v0^j=AD{%8{TQS(GcG7WH19# zo+E_(cVFlZ=5LJHH-S(7NnQ1%1<;aSahtp!}w}6yfh9*QXqD z16xAD)S%dlOR;3w`nAwexnu*pO>F{nL@AHZ2jyn^NA81i9mpfpW|e#Q2zhWsR)MN1 zZ^lMz74%^TW)q+fM6v2>*tm`dYq3khHW&iMP!7VP0@*T5V!7iWNkT{%juppBh*b-$ zB?&D34MLf^E%0xjzJB`c(d(m2r^EI8aB3XV z=GNKPOWS9+&u32@QJNnM(jnVL?r>D&fa?Mo*Gs+^4as@uIYqkhJ zc>r~ynT3ha=(%ua^Ucg}FX?|_`?>8Gj-NX|bc9>Jotz>OTIDS~8BSaOnavo3R!&I1 z*TNM&y2>z^bLjZPC?tq$X(dy-q(|OavA(Dz_Na0%l$D6}MJz!@jw0s*6tczLvI`dt zqh0hcnn*>JL~cQ{a$;PqL`{VoEl8KjO*Fw_Q7$^hnjo0V9;8;7gK`+!tHKtHwddWFQyYkEJEXw(I!XqeUd-!Vrx| zVgd>xzNjon6-LJd7sWy43|*1-`w!6X#I1f85)j`cNkmxBvOpP~^L-yVu=b$q+4h4r ztR;5z$rEyTB90oFjhDM8V&6;4ku*Xtw9+0t*B-IarIgEdLfs?wvb)*&*rY)L6fb90 zhO#Ob_+=casekN4S0t;OxauGsBw~Wt>L4HPm{Z9gbTvlI8z8=3yH;rav$aC}7`k~E zb3}Qsm?J72Frh#_Toq3yjZu_L58H1QOjimcQ^-SkD&cAve|#nH@UVY$?4i@TOS-@0Sv2ZlGjkNTGvS7Pi!?Ra1k(@V3VwlM zmIE{Ajh-T8n!=2A)M<7dW=8cY#LZ0t9io(Q;$*q!fjSE8dg83(c_JW9g z9VAkO_x)gdOgC`ZzA0pf4tEPp$Hd?mk@<--v2K|+tesT~4S64Yq=!OadhhX9r?|cf zCbK!DSCT4@8J&RX^3CTF9fpx{2EcinT#y>|m3VjN#mCg!%;vCMNP_)UJSpE(2^?WH zYAa#hp;qGgBt7yB{@NZWcqoKJSJiv)wWIzOI|DmZl+-rzK6F%6b`QBPBN|d7MH}~PKpghro}}kgF9dO~ z?dM?G+Aw@>AO=U1F9h*d$f-%?CGj#4-u{;I%_q8&@Dcie1CH|gpj)xDvn2Gt%=2?a4gJ$$i0vEXLS{EWm!IjDC+?2QID1 zV*DW^pG~%$0~fDk;o=n&u0v6WRyA@7aT{|~`FO;t5m4ULBeXszKAZfcqQRa=$e)|| zHq^#e?x}3AYB9+xt(1#{XlCLHGDb>~euxFz`q@ z$WNCO2slZ?xOesS^-Qm+JK)AS#l*ue%FUlh>&5poc!5-_nZDFN!xb`Ld)11HxMvlu zp(5@fCcKXbHA&i2+`6!1UUSX#XC_=cDP6K69mCd$Cf<9Z54YnBsnkV$(-7n(8KeOg z$b5W9_udZo!9$(9wz@kWe~OKgdz^5J_ZB;9K{ff=n1W985i?X6L5x7h78A6@Y~;0* z(L!Ys;u*V@xQ`#iWnt1`&p=9ryGpKNt2>~pZ#*$hQydj}09?u_;WJ;6B+-R zj6WjdUz5S4+Jpi_s4YE`$gdMctI38|&4G3F!mS|5*j?sHNcqN)9Sq;icT>|}8T#SS zinckW7+({(^up{5m(I_gpPyK)h-A0iftty_*?n`*|M+0kRRGLBr{L20ADstN2MnF% z`aPlgJ(2o-cX0Fw3TnZ!Z$(_+j^-CH=Wh&wo1DM-PV6+mhSyW^wHMM^0VjId)gE%S zM_k)cTyEiVPJJk+KAf{2_nowE|Hbyd+&<%{1}tZ93}tVOWN!{W{^X3FG{cY19=!)m znCfUH&JX@yKao%YCQO-PXr6PXDl%8rO$=^L=zKah> z#S5HVdNN%6*v*W`qPRQOcHQ>2iM(E0ZM>-*n6bp6oM{tq+4wFf`+FCY3&=+Jkl6eNmzWN@Ibp@hC@DqprWa#s9ddKwc|LMMqD?Ft6;=YqB9M=5qvm(w_ zc%Rdz6w@&`I(}T-w76rjDOk8U;%p&33@UpAeFEe6D+VO{(#K;h{fEZxP24Zn?Qp|- z-@Ky;)(4f2?qb~s8+W9_^H&BvnHG0uX%tz3#ExFx|`d^oJ*1_|)#Rhz* z>~G7{$-T*PXp`=5x7QtN)cvN>0QV|6D1j`-86jA7iLMGKejx&rpNCR8lsoB>sT@`o z)sXb4@);FrD#OB6xQs&Eq=s>2zG;aGA46jr6)I(_aW0kC<8n&@KhlC=-;!wDz53K? z?5b@^q&C7gNLK?S9RHH|?4ol_@SgAry~L|#O21vWCxkIUeu#ld;E*$R$loJOW{DFR z#<%F?e5)DbbciZYww5olW~bl@*wR)9CJ9|K9cP2cV6QpkYG&sxrKm*C90AmSGkQBC zXRh%HX9J+icSi9|pAyUvqC-vc7F4!I_*d8v}F=M%nJ0}Y&W_I0mxTfuE8773f zvdQ){yuybuOc?pA%92w@BGo5TobiENDleYbjcd+nggnM-QHlSmiJ@Mikfk23#%#g0 z$VB`r1U|+k_8?TzmzBeqI6pn^lU*S5G%+#{?DZ*4!mWkMVgh03(9^B)w5+q_oN*gv z;KNVsMt>?pn^1!%3`RrK+0sYuv4ie$u^QSQ~u3OUN&=4xk6wpX;Bgs^m$`+HU z8Y#?11|umL)xxMVqBIzbc-}@bhSdV z_$VGbPP^1)l!9FlT!NayJdc*l``2N6^-pjo>s#n-@ zv+yXXfc?C8x&2$A_HTvTk4Crc{Ke>BjshtLhP_<8^=9!_prX-|ie+55jgJ?&%a`4a zA$Mc6x#itM?;QHb-MR4m&$1RYf0ldG-ANaMcVF-RD5rJ)`PZ}NHE-nJ%xT5eSXy~4 z|7!lV;;Y4r`C;5WQ&O?oU*VzazOVIO?Op5*SM0i5w(eT%)z)j*7M zYx;YeoN&KyOyA47-p|>U3(tqGR+zsw81_0$?B>o@=Kx9HmLULt0ms7-0J?CRBy?(0 zIbPd_oN-+dUKiKR#RUh-u@iaGKMb2yle)!^$q4xH5jbrIRK=bN10=o1K;~h9f`ku= zv7=q}U5u$C9pNV~#1R>}W7It(!w=EgVAM9PNcrd%3E>itZi!BI{gd>mR93c{+Cy@h zpU|>7fTFN$K$D#R!Fdv|mf&O;$wX)NhRj8GoEbAaa9L>6yy4aJVduJO1Cw`=`cqZN zhA)LgYze1^KQeyw_a^y=8_`)_dI^*w}C;5GD;@B%4q(jz0+B;pX( zsqZ}ku`a*iei<(hDpDF>m3q#HmPq`Iq$jaN_DObKpnR9~5ji0Ev2TJUe6%bkMwd2) zZfL`>_fFcm3GAK$R3g830@bVDJNq^j9jRp>$W`N=&`Vbm{FL7KQz#H`yab=5Ro-)= zO6|wCbmCV0=)5B8B(BOW`{vteIdey@^!_Pn%hLTQk__~gy#*SxMRhaMMNGIPI#Ax= z_4liNt-S#~#$l*>I&J~}JD`JTSsP%w`xI$_=aj>c+e zxu0W!k!3fOuM}h$a_9XII9MyyE?n_h{(ytEQtLFhW)KeRetC+a>3&fw4vPnH0z7lR zIaswdRMtu-zcW`#iw#+GopkAF7Ebxu5Z%AR!CK%SkhPL$Hf)(c@)-`KZ;_fxtrXL7 zFAqCL4Gl?biX-^GsB{=?2Vz$d=E_>zgBSvpe%R7;MkA#Am9;gg2qEGrCOHNsMgHKzN>p`j|D?3SA(9r2DG9dOfOZn_!w7>zu20(T7j6RZT~5YfiA zaj9nlo#pODA3I@cO9ue)O_oI4Hm%b>oRjSzjb zc^Le3+;MvjIRhO}`Ec*UR`=Kh?%<=#^pwvg;&#ybu?cq@oj2a*Zj%oi!zrIM-sXOW z&JrJEtaf%FNcbJfWO}rLEc&P>@z!JL5-H!%sURI#?Bg_}{{Hn>-5oM2<&J-u zCd_|oXy7!O6bL|(JmkI$`5_}HE3?6cr!@Zm85$Jj+ZPO15m zwIvyBBXO3rU3`aJKO}?r07=*GNId^nD58Q4T4B=d&J4RaNaIOcO%jB}ee(F&>G+U$ zn=pzb_~(3X3@Hv#YBcw?I*tB;nbV~Imb3hpv$6lw-*V31a*qGXdHyR`ALi=+jw}2- zj&9b`xL!0Za~UBnBWg{1rS?K?)VAZUxp3OFY%UC$3&ZB(s5zf}`5|+DuwZN0+`3}W zr5gXyWYTDH+S{5xoxNldjrWXtO%2^D0xQ_v`+#iJrMFK9!n)jl(5HTEvQ9U>(sH5YrKy?c zZ<=y$n^S_Rha=`AQL}Se_lo&~c_!_m?XE4A(#-6*(E1|2I+Xs(xeMp!G#6fYanCBx z?c&n;rfV%%TV9)5c>ZSb`U_3dJ~?T`mcQ8lV&}(crSr`TIg2ezN5W}4U)=Yx-V(GU z2;Ya%1+BFaeO=U)wrna1nM!V%%2;cvBj%cqsaeGlGo%_t%Pw@TA?#|T!?e3K)19~R zb;_N<3?^nLa5OM|zVq6?tNZ5L7RDEkynF1OV~dAwGzQam-LiMl0bAT@ZR9Me)1whH z5W~XdjM`8}Z8)Qz3frlf?x4cD=9=j0q##ZXmM$EoYd04TEj0(zAG>AWL1pZe$_S>` zM$C0lTjsK@A!KWa*cy>Jm|h>TH+)Qv+K9a_N}qaO7jmpy&_ZSKwlg!BRU2{EMV)yw zdd4nq{Bg?16uLg*Y-H`J31!qQ?2KeI+;-&#^VUaPjp#E@7Q_TYtYmhFr#)-g-iVt% oBlae0G`<{NxKhm7H{2^l|AMI%5p!k2OZ;Qo3Rbl~k8Ml;A8A&}r2qf` literal 0 HcmV?d00001 diff --git a/fan_controller.py b/fan_controller.py index ee6f270..6849d00 100644 --- a/fan_controller.py +++ b/fan_controller.py @@ -8,6 +8,7 @@ import time import json import logging import threading +import paramiko from dataclasses import dataclass, asdict from typing import List, Dict, Optional, Tuple from datetime import datetime @@ -104,7 +105,7 @@ class IPMIFanController: return False, str(e) def test_connection(self) -> bool: - """Test if we can connect to the server.""" + """Test IPMI connection.""" success, _ = self._run_ipmi(["mc", "info"], timeout=10) return success @@ -257,12 +258,139 @@ class IPMIFanController: return self.consecutive_failures < self.max_failures +class SSHSensorClient: + """SSH client for lm-sensors data collection.""" + + def __init__(self, host: str, username: str, password: Optional[str] = None, + key_file: Optional[str] = None, port: int = 22): + self.host = host + self.username = username + self.password = password + self.key_file = key_file + self.port = port + self.client: Optional[paramiko.SSHClient] = None + self.consecutive_failures = 0 + + def connect(self) -> bool: + """Connect to SSH server.""" + try: + self.client = paramiko.SSHClient() + self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + connect_kwargs = { + "hostname": self.host, + "port": self.port, + "username": self.username, + "timeout": 10 + } + + if self.key_file and Path(self.key_file).exists(): + connect_kwargs["key_filename"] = self.key_file + elif self.password: + connect_kwargs["password"] = self.password + else: + logger.error("No authentication method available for SSH") + return False + + self.client.connect(**connect_kwargs) + logger.info(f"SSH connected to {self.host}") + return True + + except Exception as e: + logger.error(f"SSH connection failed: {e}") + self.consecutive_failures += 1 + return False + + def disconnect(self): + """Close SSH connection.""" + if self.client: + self.client.close() + self.client = None + + def get_lm_sensors_data(self) -> List[TemperatureReading]: + """Get temperature data from lm-sensors.""" + if not self.client: + if not self.connect(): + return [] + + try: + stdin, stdout, stderr = self.client.exec_command("sensors -u", timeout=15) + output = stdout.read().decode() + error = stderr.read().decode() + + if error: + logger.warning(f"sensors command stderr: {error}") + + temps = self._parse_sensors_output(output) + self.consecutive_failures = 0 + return temps + + except Exception as e: + logger.error(f"Failed to get sensors data: {e}") + self.consecutive_failures += 1 + self.disconnect() # Force reconnect on next attempt + return [] + + def _parse_sensors_output(self, output: str) -> List[TemperatureReading]: + """Parse lm-sensors -u output.""" + temps = [] + current_chip = "" + + for line in output.splitlines(): + line = line.strip() + + # New chip section + if line.endswith(":") and not line.startswith(" "): + current_chip = line.rstrip(":") + continue + + # Temperature reading + if "_input:" in line and "temp" in line.lower(): + parts = line.split(":") + if len(parts) == 2: + name = parts[0].strip() + try: + value = float(parts[1].strip()) + location = self._classify_sensor_name(name, current_chip) + temps.append(TemperatureReading( + name=f"{current_chip}/{name}", + location=location, + value=value, + status="ok" + )) + except ValueError: + pass + + return temps + + def _classify_sensor_name(self, name: str, chip: str) -> str: + """Classify sensor location from name.""" + name_lower = name.lower() + chip_lower = chip.lower() + + if "core" in name_lower: + if "0" in name or "1" in name: + return "cpu1" + elif "2" in name or "3" in name: + return "cpu2" + return "cpu" + elif "package" in name_lower: + return "cpu" + elif "tdie" in name_lower or "tctl" in name_lower: + return "cpu" + return "other" + + def is_healthy(self) -> bool: + return self.consecutive_failures < 3 + + class FanControlService: """Background service for automatic fan control.""" def __init__(self, config_path: str = "/etc/ipmi-fan-controller/config.json"): self.config_path = config_path self.controller: Optional[IPMIFanController] = None + self.ssh_client: Optional[SSHSensorClient] = None self.running = False self.thread: Optional[threading.Thread] = None self.current_speed = 0 @@ -271,14 +399,26 @@ class FanControlService: self.last_fans: List[FanReading] = [] self.lock = threading.Lock() - # Default config + # Default config with new structure self.config = { - "host": "", - "username": "", - "password": "", - "port": 623, + # IPMI Settings + "ipmi_host": "", + "ipmi_username": "", + "ipmi_password": "", + "ipmi_port": 623, + + # SSH Settings + "ssh_enabled": False, + "ssh_host": None, + "ssh_username": None, + "ssh_password": None, + "ssh_use_key": False, + "ssh_key_file": None, + "ssh_port": 22, + + # Fan Control Settings "enabled": False, - "interval": 10, # seconds + "interval": 10, "min_speed": 10, "max_speed": 100, "fan_curve": [ @@ -298,8 +438,9 @@ class FanControlService: def _load_config(self): """Load configuration from file.""" try: - if Path(self.config_path).exists(): - with open(self.config_path, 'r') as f: + config_file = Path(self.config_path) + if config_file.exists(): + with open(config_file) as f: loaded = json.load(f) self.config.update(loaded) logger.info(f"Loaded config from {self.config_path}") @@ -309,8 +450,9 @@ class FanControlService: def _save_config(self): """Save configuration to file.""" try: - Path(self.config_path).parent.mkdir(parents=True, exist_ok=True) - with open(self.config_path, 'w') as f: + config_file = Path(self.config_path) + config_file.parent.mkdir(parents=True, exist_ok=True) + with open(config_file, 'w') as f: json.dump(self.config, f, indent=2) logger.info(f"Saved config to {self.config_path}") except Exception as e: @@ -321,40 +463,70 @@ class FanControlService: self.config.update(kwargs) self._save_config() - # Reinitialize controller if connection params changed - if any(k in kwargs for k in ['host', 'username', 'password', 'port']): - self._init_controller() + # Reinitialize controllers if connection params changed + ipmi_changed = any(k in kwargs for k in ['ipmi_host', 'ipmi_username', 'ipmi_password', 'ipmi_port']) + ssh_changed = any(k in kwargs for k in ['ssh_host', 'ssh_username', 'ssh_password', 'ssh_key_file', 'ssh_port']) + + if ipmi_changed: + self._init_ipmi_controller() + if ssh_changed or (kwargs.get('ssh_enabled') and not self.ssh_client): + self._init_ssh_client() - def _init_controller(self): + def _init_ipmi_controller(self) -> bool: """Initialize the IPMI controller.""" - if not all([self.config.get('host'), self.config.get('username'), self.config.get('password')]): + if not all([self.config.get('ipmi_host'), self.config.get('ipmi_username')]): logger.warning("Missing IPMI credentials") return False self.controller = IPMIFanController( - host=self.config['host'], - username=self.config['username'], - password=self.config['password'], - port=self.config.get('port', 623) + host=self.config['ipmi_host'], + username=self.config['ipmi_username'], + password=self.config.get('ipmi_password', ''), + port=self.config.get('ipmi_port', 623) ) if self.controller.test_connection(): - logger.info(f"Connected to IPMI at {self.config['host']}") + logger.info(f"Connected to IPMI at {self.config['ipmi_host']}") return True else: - logger.error(f"Failed to connect to IPMI at {self.config['host']}") + logger.error(f"Failed to connect to IPMI at {self.config['ipmi_host']}") self.controller = None return False - def start(self): + def _init_ssh_client(self) -> bool: + """Initialize SSH client for lm-sensors.""" + if not self.config.get('ssh_enabled'): + return False + + host = self.config.get('ssh_host') or self.config.get('ipmi_host') + username = self.config.get('ssh_username') or self.config.get('ipmi_username') + + if not all([host, username]): + logger.warning("Missing SSH credentials") + return False + + self.ssh_client = SSHSensorClient( + host=host, + username=username, + password=self.config.get('ssh_password') or self.config.get('ipmi_password'), + key_file=self.config.get('ssh_key_file'), + port=self.config.get('ssh_port', 22) + ) + + return True + + def start(self) -> bool: """Start the fan control service.""" if self.running: - return + return True - if not self._init_controller(): + if not self._init_ipmi_controller(): logger.error("Cannot start service - IPMI connection failed") return False + if self.config.get('ssh_enabled'): + self._init_ssh_client() + self.running = True self.thread = threading.Thread(target=self._control_loop, daemon=True) self.thread.start() @@ -371,6 +543,9 @@ class FanControlService: if self.controller: self.controller.disable_manual_fan_control() + if self.ssh_client: + self.ssh_client.disconnect() + logger.info("Fan control service stopped") def _control_loop(self): @@ -385,16 +560,17 @@ class FanControlService: time.sleep(1) continue + # Ensure controllers are healthy if not self.controller or not self.controller.is_healthy(): - logger.warning("Controller unhealthy, attempting reconnect...") - if not self._init_controller(): + logger.warning("IPMI controller unhealthy, attempting reconnect...") + if not self._init_ipmi_controller(): time.sleep(30) continue self.controller.enable_manual_fan_control() - # Get sensor data - temps = self.controller.get_temperatures() - fans = self.controller.get_fan_speeds() + # Get temperature data + temps = self._get_temperatures() + fans = self.controller.get_fan_speeds() if self.controller else [] with self.lock: self.last_temps = temps @@ -406,7 +582,9 @@ class FanControlService: continue # Check for panic temperature - max_temp = max((t.value for t in temps if t.location.startswith('cpu')), default=0) + cpu_temps = [t for t in temps if t.location.startswith('cpu')] + max_temp = max((t.value for t in cpu_temps), default=0) + if max_temp >= self.config.get('panic_temp', 85): self.target_speed = self.config.get('panic_speed', 100) logger.warning(f"PANIC MODE: CPU temp {max_temp}°C, setting fans to {self.target_speed}%") @@ -431,10 +609,27 @@ class FanControlService: logger.error(f"Control loop error: {e}") time.sleep(10) + def _get_temperatures(self) -> List[TemperatureReading]: + """Get temperatures from IPMI and/or SSH lm-sensors.""" + temps = [] + + # Try IPMI first + if self.controller: + temps = self.controller.get_temperatures() + + # Try SSH lm-sensors if enabled and IPMI failed or has no data + if self.config.get('ssh_enabled') and self.ssh_client: + if not temps or self.config.get('prefer_ssh_temps', False): + ssh_temps = self.ssh_client.get_lm_sensors_data() + if ssh_temps: + temps = ssh_temps + + return temps + def get_status(self) -> Dict: """Get current status.""" with self.lock: - return { + status = { "running": self.running, "enabled": self.config.get('enabled', False), "connected": self.controller is not None and self.controller.is_healthy(), @@ -444,10 +639,25 @@ class FanControlService: "temperatures": [asdict(t) for t in self.last_temps], "fans": [asdict(f) for f in self.last_fans], "config": { - k: v for k, v in self.config.items() - if k != 'password' # Don't expose password + # IPMI + "ipmi_host": self.config.get('ipmi_host'), + "ipmi_port": self.config.get('ipmi_port'), + "ipmi_username": self.config.get('ipmi_username'), + # SSH + "ssh_enabled": self.config.get('ssh_enabled'), + "ssh_host": self.config.get('ssh_host'), + "ssh_port": self.config.get('ssh_port'), + "ssh_username": self.config.get('ssh_username'), + "ssh_use_key": self.config.get('ssh_use_key'), + # Settings + "min_speed": self.config.get('min_speed'), + "max_speed": self.config.get('max_speed'), + "panic_temp": self.config.get('panic_temp'), + "interval": self.config.get('interval'), + "fan_curve": self.config.get('fan_curve') } } + return status def set_manual_speed(self, speed: int) -> bool: """Set manual fan speed.""" @@ -473,16 +683,15 @@ class FanControlService: self.controller.disable_manual_fan_control() -# Global service instance -_service: Optional[FanControlService] = None +# Global service instances +_service_instances: Dict[str, FanControlService] = {} def get_service(config_path: str = "/etc/ipmi-fan-controller/config.json") -> FanControlService: - """Get or create the global service instance.""" - global _service - if _service is None: - _service = FanControlService(config_path) - return _service + """Get or create the service instance for a config path.""" + if config_path not in _service_instances: + _service_instances[config_path] = FanControlService(config_path) + return _service_instances[config_path] if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index b4409b3..b4b1ffa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ fastapi==0.109.0 uvicorn[standard]==0.27.0 pydantic==2.5.3 pydantic-settings==2.1.0 +python-multipart==0.0.6 +paramiko==3.4.0 diff --git a/server.log b/server.log new file mode 100644 index 0000000..cc7cf1b --- /dev/null +++ b/server.log @@ -0,0 +1,7 @@ +/home/devmatrix/projects/fan-controller-v2/web_server.py:49: PydanticDeprecatedSince20: Pydantic V1 style `@validator` validators are deprecated. You should migrate to Pydantic V2 style `@field_validator` validators, see the migration guide for more details. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.5/migration/ + @validator('new_password') +INFO: Started server process [888347] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +INFO: 127.0.0.1:44244 - "GET /api/status HTTP/1.1" 401 Unauthorized diff --git a/web_server.py b/web_server.py index 27ff4c4..44f4b17 100644 --- a/web_server.py +++ b/web_server.py @@ -1,30 +1,72 @@ """ -Web API for IPMI Fan Controller v2 +Web API for IPMI Fan Controller v2 - With Auth & SSH Support """ import asyncio import json import logging +import hashlib +import secrets +import re from contextlib import asynccontextmanager from pathlib import Path from typing import Optional, List, Dict +from datetime import datetime, timedelta -from fastapi import FastAPI, HTTPException, BackgroundTasks +from fastapi import FastAPI, HTTPException, BackgroundTasks, Depends, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import HTMLResponse, FileResponse -from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel, Field +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel, Field, validator +# Import the fan controller +import sys +sys.path.insert(0, str(Path(__file__).parent)) from fan_controller import get_service, FanControlService, IPMIFanController logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +# Security +security = HTTPBearer(auto_error=False) + +# Data directories +DATA_DIR = Path("/app/data") if Path("/app/data").exists() else Path(__file__).parent / "data" +DATA_DIR.mkdir(exist_ok=True) +CONFIG_FILE = DATA_DIR / "config.json" +USERS_FILE = DATA_DIR / "users.json" +SSH_KEYS_DIR = DATA_DIR / "ssh_keys" +SSH_KEYS_DIR.mkdir(exist_ok=True) + # Pydantic models -class ConfigUpdate(BaseModel): +class UserLogin(BaseModel): + username: str + password: str + +class ChangePassword(BaseModel): + current_password: str + new_password: str + + @validator('new_password') + def password_strength(cls, v): + if len(v) < 6: + raise ValueError('Password must be at least 6 characters') + return v + +class IPMIConfig(BaseModel): + host: str + username: str + password: Optional[str] = None # Only required on initial setup + port: int = 623 + +class SSHConfig(BaseModel): + enabled: bool = False host: Optional[str] = None username: Optional[str] = None password: Optional[str] = None - port: Optional[int] = 623 + use_key: bool = False + key_filename: Optional[str] = None + +class FanSettings(BaseModel): enabled: Optional[bool] = None interval: Optional[int] = Field(None, ge=5, le=300) min_speed: Optional[int] = Field(None, ge=0, le=100) @@ -42,22 +84,370 @@ class FanCurveUpdate(BaseModel): class ManualSpeedRequest(BaseModel): speed: int = Field(..., ge=0, le=100) -class StatusResponse(BaseModel): - running: bool - enabled: bool - connected: bool - manual_mode: bool - current_speed: int - target_speed: int - temperatures: List[Dict] - fans: List[Dict] +class SetupRequest(BaseModel): + admin_username: str = Field(..., min_length=3) + admin_password: str = Field(..., min_length=6) + ipmi_host: str + ipmi_username: str + ipmi_password: str + ipmi_port: int = 623 +# User management +class UserManager: + def __init__(self): + self.users_file = USERS_FILE + self._users = {} + self._sessions = {} # token -> (username, expiry) + self._load() + + def _load(self): + if self.users_file.exists(): + try: + with open(self.users_file) as f: + data = json.load(f) + self._users = data.get('users', {}) + except Exception as e: + logger.error(f"Failed to load users: {e}") + self._users = {} + + def _save(self): + with open(self.users_file, 'w') as f: + json.dump({'users': self._users}, f) + + def _hash_password(self, password: str) -> str: + return hashlib.sha256(password.encode()).hexdigest() + + def verify_user(self, username: str, password: str) -> bool: + if username not in self._users: + return False + return self._users[username] == self._hash_password(password) + + def create_user(self, username: str, password: str) -> bool: + if username in self._users: + return False + self._users[username] = self._hash_password(password) + self._save() + return True + + def change_password(self, username: str, current: str, new: str) -> bool: + if not self.verify_user(username, current): + return False + self._users[username] = self._hash_password(new) + self._save() + return True + + def create_token(self, username: str) -> str: + token = secrets.token_urlsafe(32) + expiry = datetime.utcnow() + timedelta(days=7) + self._sessions[token] = (username, expiry) + return token + + def verify_token(self, token: str) -> Optional[str]: + if token not in self._sessions: + return None + username, expiry = self._sessions[token] + if datetime.utcnow() > expiry: + del self._sessions[token] + return None + return username + + def is_setup_complete(self) -> bool: + return len(self._users) > 0 -# Create static directory and HTML -STATIC_DIR = Path(__file__).parent / "static" -STATIC_DIR.mkdir(exist_ok=True) +user_manager = UserManager() + +# Auth dependency +async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str: + if not credentials: + raise HTTPException(status_code=401, detail="Not authenticated") + username = user_manager.verify_token(credentials.credentials) + if not username: + raise HTTPException(status_code=401, detail="Invalid or expired token") + return username + +# HTML Templates +LOGIN_HTML = ''' + + + + + Login - IPMI Fan Controller + + + + + + +''' + +SETUP_HTML = ''' + + + + + Setup - IPMI Fan Controller + + + +
+

🌬️ Fan Controller Setup

+

Configure your server connection

+ +
+ +
+
+

👤 Admin Account

+
+
+ + +
+
+ + +
+
+
+ +
+

🖥️ IPMI Connection (Required)

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + + +''' -# Create the HTML dashboard DASHBOARD_HTML = ''' @@ -73,9 +463,17 @@ DASHBOARD_HTML = ''' color: #fff; padding: 20px; } - .container { max-width: 900px; margin: 0 auto; } - h1 { text-align: center; margin-bottom: 10px; font-size: 1.8rem; } - .subtitle { text-align: center; color: #888; margin-bottom: 30px; } + .container { max-width: 1000px; margin: 0 auto; } + header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + flex-wrap: wrap; + gap: 15px; + } + h1 { font-size: 1.6rem; } + .header-actions { display: flex; gap: 10px; } .card { background: rgba(255,255,255,0.05); @@ -84,11 +482,11 @@ DASHBOARD_HTML = ''' margin-bottom: 20px; border: 1px solid rgba(255,255,255,0.1); } - .card h2 { font-size: 1.2rem; margin-bottom: 15px; color: #64b5f6; } + .card h2 { font-size: 1.2rem; margin-bottom: 15px; color: #64b5f6; display: flex; align-items: center; gap: 10px; } .status-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 15px; margin-bottom: 20px; } @@ -98,15 +496,15 @@ DASHBOARD_HTML = ''' border-radius: 8px; text-align: center; } - .status-item .label { font-size: 0.85rem; color: #888; margin-bottom: 5px; } - .status-item .value { font-size: 1.5rem; font-weight: bold; } + .status-item .label { font-size: 0.8rem; color: #888; margin-bottom: 5px; } + .status-item .value { font-size: 1.4rem; font-weight: bold; } .status-item .value.good { color: #4caf50; } .status-item .value.warn { color: #ff9800; } .status-item .value.bad { color: #f44336; } .temp-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; } .temp-item { @@ -123,19 +521,22 @@ DASHBOARD_HTML = ''' .controls { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 15px; } button { - padding: 12px 24px; + padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer; - font-size: 1rem; + font-size: 0.95rem; transition: all 0.2s; } + button.small { padding: 6px 12px; font-size: 0.85rem; } button.primary { background: #2196f3; color: white; } button.primary:hover { background: #1976d2; } button.success { background: #4caf50; color: white; } button.success:hover { background: #388e3c; } button.danger { background: #f44336; color: white; } button.danger:hover { background: #d32f2f; } + button.secondary { background: rgba(255,255,255,0.1); color: white; } + button.secondary:hover { background: rgba(255,255,255,0.2); } button:disabled { opacity: 0.5; cursor: not-allowed; } .slider-container { margin: 15px 0; } @@ -150,17 +551,14 @@ DASHBOARD_HTML = ''' } input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; - width: 24px; - height: 24px; + width: 22px; + height: 22px; background: #2196f3; border-radius: 50%; cursor: pointer; } - .config-form { - display: grid; - gap: 15px; - } + .config-form { display: grid; gap: 15px; } .form-row { display: grid; grid-template-columns: 1fr 1fr; @@ -169,7 +567,7 @@ DASHBOARD_HTML = ''' .form-group label { display: block; margin-bottom: 5px; - font-size: 0.9rem; + font-size: 0.85rem; color: #aaa; } .form-group input, .form-group select { @@ -179,20 +577,62 @@ DASHBOARD_HTML = ''' border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; color: #fff; - font-size: 1rem; + font-size: 0.95rem; } + .tabs { + display: flex; + gap: 5px; + margin-bottom: 20px; + border-bottom: 1px solid rgba(255,255,255,0.1); + padding-bottom: 10px; + } + .tab { + padding: 8px 16px; + background: transparent; + border: none; + color: #888; + cursor: pointer; + border-radius: 6px; + } + .tab:hover { color: #fff; } + .tab.active { background: rgba(255,255,255,0.1); color: #fff; } + + .tab-content { display: none; } + .tab-content.active { display: block; } + .log-output { background: rgba(0,0,0,0.3); padding: 15px; border-radius: 6px; font-family: monospace; - font-size: 0.85rem; + font-size: 0.8rem; max-height: 200px; overflow-y: auto; white-space: pre-wrap; } + .modal { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.8); + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; + } + .modal.active { display: flex; } + .modal-content { + background: #1a1a2e; + padding: 30px; + border-radius: 12px; + max-width: 400px; + width: 100%; + border: 1px solid rgba(255,255,255,0.1); + } + .modal-content h3 { margin-bottom: 20px; } + .toast { position: fixed; bottom: 20px; @@ -201,7 +641,7 @@ DASHBOARD_HTML = ''' border-radius: 8px; color: white; animation: slideIn 0.3s ease; - z-index: 1000; + z-index: 1001; } .toast.success { background: #4caf50; } .toast.error { background: #f44336; } @@ -210,22 +650,30 @@ DASHBOARD_HTML = ''' to { transform: translateX(0); opacity: 1; } } + .hidden { display: none !important; } + @media (max-width: 600px) { .form-row { grid-template-columns: 1fr; } .status-grid { grid-template-columns: repeat(2, 1fr); } + header { flex-direction: column; align-items: flex-start; } }
-

🌬️ IPMI Fan Controller v2

-

Dell T710 & Compatible Servers

+
+

🌬️ IPMI Fan Controller v2

+
+ + +
+

📊 Current Status

-
Connection
+
IPMI Connection
-
@@ -237,13 +685,13 @@ DASHBOARD_HTML = '''
-
-
Max Temp
+
Max CPU Temp
-
- @@ -264,88 +712,184 @@ DASHBOARD_HTML = '''
-

⚙️ Configuration

+
+ + + + + +
+ +
+

IPMI Configuration

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+

SSH Configuration (for lm-sensors)

+
+
+ +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ +
+ +
+
+ +
+

Fan Control Settings

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Fan Curve: Temperature → Speed

+
+
+ +
+ +
+ +
+

System Logs

+
Ready...
+
+ + +
+
+
+
+ +