(^ghl
zd1xfRU68QD>1KYPE0oVY2yY3imTJgjp|G(+0dpv}sMUlJcl@1myv~M7uw!>LKiEGs
z94^8J@-aUBYh;`nw?MwuH;B^0@b|7v5mgc?!PP{5mUndkGpLCWEQtj+DNr(^USq_H
zZb~;hW;w6n5TlEi{Ga=XC@(KI2J4<2#heMV%IXYp^kK8Tfl!v;YC??TO1`u)kAAeT
zMzkLs4;66tTQ}uFe{!%1zbA`$3kUJh!1LK18Th(ua}f<1quvS2mItvNYIjTkLPi8d*O>mY?Gv`@e54P)JUy+*&qQMDzZ~I>^oe9rEq6?TYjln{cpcav*Ji5BC3>
z0!{hIn@X0Z{cz)vt7g%fqB8L%wOLEPA0AVPTQgW!2z{NO%?d!fCc#4e^gH64SKJmG
z9qse=o5kVCw;|Z2sxqOp{z|Zp8EDFPxvpeHg%?ki>IRyb^(Dn?;&Bj8+TmlI;o;!~
zaL3Nph}qG1>BfP{h@hv
zmp^pw^G>JoJQTCeI
zMY%Lu3~Fyt0$a+qLR*1Hf7M>C2t-^+Z4wJXbR)*#?UY;Fwseg*^!0j~=4T|@=@@X(
zsmzr89EK>{*@TyqW>X@UVN7Q@C%dS7SPIlF!z>N3w*DDrn$%gKrLX
zWA6Btk_fAWkrB&;ThB0JtTTn|>`Z>;C>aWc%liW#a8oE765r(^ln=`lg`=>nGOqo^
z1lN{7QY?y|o2d{gR1D3@q&pDVk6{FDVLF889W=$6E|LQ{)|5tBbH>MQIBYnuv0h%T
zuCBgGDpgidRx#d9>gpS;qP$62(|!9O{>-zdUbqMM?%xl&6Bd3o7_)Tx6(6s7UlqRM
z_&<4hV{}O~uX+1ptLIg@amb~C^b2*Z^x4z-`wx1M9@__jih}>ES1(^Zdp3Eo9~y_c
zE|a;>*4H;~EreeL8s3-(l1P+ZmWCyDbeul3LR=szOx;#F`e`5*XnqvMXl!IeMFG4y
zqkgpvMEW*P-^~*PxUp(zLaL`U&@Kp_Yv>=`0Gl;h*zDX~^okxDW-z8^kQ1fXnCmbB-Nmg$ns{R?rN7
zOT)RkEH}GL_WIL}pDd2+Szmp+_+njK+vKQr(6n|0NF0U56oiD=_s#BvF7}V!J&@V;
z-~!<1c(m`>vZIkw6OyhH>KBir<5Di#c;jQ~vn!nO9XlQmGr_|wWVtq1
zBuY67l~&QD4xSz(F_B0wN_H?&?CsYZcR*Xo`e&d+FhUhQaYI6`&-ywmP>S9CN!vz#(W`=rML7aF8kfcU=4#H-1K_SZiUPE>mXqcwTG16hQC
zSh5428ikE0COOXI9(TRK-5enArOl
zhE3HZIrIkm7BP#-C}u=RxK;4n9eS^+XA_J@)CmY6>X^`)Jb|I?-el9C2bq;oQa|)%
z7T|Y~)#5Z5$b#3BdYi*k)UIW{ZZ#F3@1U>M{Wg?svxn7gYk{gCB31ja9jaEUs#R)@
zs)^MdNYtI`#XD%R`tt~-smtYOGWR9p7ZTm^5$OUtI9pUsRRsIxyJBs(+v7wW&q%o!
zm-j(XW^~Y$PDpV3edl1IV24)-1WN(^sPy2JZAn@CYCg6E@(YD30{tv1ufrrg5@HNp
z_D`P~yzQ;CRC7g?BXm9*D=976BKu1fS7PX8qEgCQK=f}b6u9#L-q)H6fL;H!2+57<
zfT3m}$C8p5#Veyl>c*%0`@$is+|9i3mLpL}YHIp|o_9BZ
zkVJ9s#A07L`{GGn>AX?84|uX?q+?uOGM0G8^m=BRkvka*te;6|FmlV)X%f=ug@&&)
z){t#eQ#WvtDd~w252Ft>=ku$#tZd=XR;zoaViWYE3m0R(zJahMQa?bMXWxLqcRcrO
zC$Ne!7po0eE%A}PaUuRRfbS;$qb1X2w?kxVgSlOyWblyCT)-vp8|DhGQFCrX_rtp!
zrT!EjU4#U`Vs%Ul_-;l*>~))ZK9SuQ#ZUNm*_c2Vi|8|mUzbwQziGOEH0oR%`OLy-
zGMsB0Pu&8`wTWkRsw`W0rm8BK%1mj?rZ7|HvZ>3Iyll#{<){2rXL#bO=kwR8!z}yK
zT-E>_wb0-!#7eNm*apb42Alj?TaFDjZ~yP9
zW>oH&RZNknBry&LURjQsznLw>~)Tlp^#&QthZ2J{ddy
zwDN_`9t&FD97&F+g!^V;$gx04IyG;-nbvNX>!3Q2anRt1u|gCbictok{@p=0<=Mdq
zw|z68-D*_Oj}y_AVLW50w0);Lxw%#M#2NH{t17A%xLdVs%ODP#=8F}ZuvU#%(z2rl
zv|sq4uER`X{r0tGw3yGGuWHIuHVX&V&-u8d*uU?M9B3Qp({Iba+vPdD
zc3_bTJ_WTUct8kYoo7(&_6j_--h&~_{z3g7pu1oS{r>OtaZ?R
z+pu51Na}n8ec8^!6AlMx7gMXegH5&(n%&cE4E>U*a!cQ=acOm#3f)cLjc3X){&|`)
z8eL7f&wY;Tm;(`Hr^TrP8=|FL)DU)yC**tayhy+8lt|Kcdk(t#N&=H2Q=_Qw%Uwg%
z=Y|+jU;)|D$q{m7^6f*yXhE@eqb7rMHOyoZ({v%w2}-i-)vA;HcqKbz=QFaHy`uY7
z!|3E78Ul*j>{qHMpL7L!AK58}GiUX+pQ;}_U1T-$`h0lGb|j~q;|{{j1fKsYsbWr94yFUVO3tit7x}VR?P&6
zkS2pcRAIE~}^{2+29FG^Bs37N-*
z+3c{?v;g`tR!Q{c^uzOquCCrZ9u3rGyXKd2Vmb@pkY!$dID!VqlysZ;7>LD+A;Swm
zP;Iq3J5@q9gUtMj+_J4IN&GW8VhK9m{y4o!gE8s-y=!N6h-Avw@EN1j9We>9L~1c3
z8x0{TiS&@t;O5xU3VKrRbXLM@?$q?2Q?9O&WK!n9k-sAG#NNJ)^ah|WV}d#QDfP`-
zF+(W_BY(k07LQnT=%?_6L{rF⪼DyE~g66^)LHHBIQ#^Taal@zz4geBRqgS_@t9v_z^sb*q
zVcp6T=3mons0Do7mH`?S1MRACpX{FhO2^Kw4hWuNm@))s&qtNf3*sN*+seVSK`<
zfmiP+iFlK)OWu9={wRcQ*wcLVZUO&&*IePmrwS2#yAa8rb>ZCbXl_9#Tdq+(n)p;9
zhOZN1`6I445oqLmo~ti>mtRAv9MO}+$ZGwLUmB;uiMy*yCKQ+ijne-pbY_0mfcK&s
z1DkXpklFvRpbr&dfP^5|s~E~Ke}0+m)1Zp!+Wxz}5b*LJqNb1_BZos%zx7v^m&)XQ
z3HtKa^D%1*MK-0Rcl@v3@TUR%-PLrj>w-+rO4(#@v~@)ImS_6oXuu3aBb&jv5{ZU>
zxq@&dxn+02WIYUeS@oB*k3jytP{?v_7;z|apj&H&Y#AhUYFj)O*>H%9$}|#)PsnO1
z?-XWXkbOl#I-=yhR|ihZQc0e4hM&!95~G_rA!3|F$Ta5{qC^O_?1TBa$mIFnI$;ve
zn(I0_*0gWy6{W3;j!!Y93y!;5EGP>R;^Le
zPZEfAm&t8Iu*%HyFJoBm(4
z58$0AK~%n&2sbG;qk)+vta@UDeU!v{v`N5o9(XZLD}lyc@(|xN|5sw2vr)z(2EPcs
z<~B9@m&)j^Qp9V@DA5gykanGMSiBNb-+x#7J@4?5NT8`N8|S*q%!iHLTGrg~qd;yk
z-pe6^+w=FGQ)E{m4&|gbZm<+XA6F9TzIAUkP|Z9KgaVpct)td=?BWpKPuy3+4Ox4G
zYh2e$M0|-5!H)}({9PALL@1>O*CcKUB7Q=M;cvKNMQCFjl!A>H3C1DkqZZqlPE$Mz
zAE72F7&vo#GTm?uIv{iGb~GOORVF!#kYX{2J!&%GyK{J&ZLK1;Tp((^9g&<`-5lRU
zQD0<_Mq>hm&W_~!!G?jn4l}NmkJOU!86{gfw4~o#YY;m|L^q#PAeu`0Spu>(HEg=pn9
z+|1YqIOEw{JRy^Qewezg%~I|STsM45Bux56Izu~2ZOGzDAXY>#W=fRwrSbQUdD;W9
zt6QrF;sBf&Gb?n&8)J9B7kFc~DQ>gMfSG10Xb>Y!=>7`>=RHA-4qTEgkmzl)IX>&&R$7zcS
z_Jv+g2>XJhC|un>YU@zwVfb*l-ir*bQD%``+O9Xd163zRjlId222cl>S;!SKM~Blt)j$4B!qd~8$P;EL5}pOCbt%aB=*
z4`IqVv=#9GV^JdXT(>EJ0T}x)_UG38z5mPcmxI;6f6D)F?w|iUy07Hz2RxT-v;HyQ
z_7CA-?6ZGAPUCkZ^rV*m?328_f50!B8T`GkkJ>R{zs8R?(LZ-%ThDp|HovOctDO@4
z95#?+)j|S>`>l&u2IhNylvp2vqY{+mk@Z1GW=~H5MSE?&9Y8#ni)SETB<}Da+3$v9
znU)(~3z9MpSD|Jl01Go!}HuD|yLat07GnJc&HyGNg
zvxP>kxBPg)gbS^*(7D5^)>?v=^W3=i9Zp
zo1`ll)>rTUvH{psp=n4j!jy9n6o@R6CZB6^^{pm|-DhBI7i&gXMOe%vl$!~T$Rh5@
zU`(ilEulqg(bAY4iD$#jzD&I2vbM=ebFw|n-t=|1ix@Yl5(<)VlfQ{_+L|?pgh$x$
zUv@p&IK(^^k41t;Z2ZaG_P+GdPYrF`m9Et^HkH)+m_3}BoQG4ig(P|}Oy({=j9q=T
z52f*u#wR`nCB(rlaC9QX_D)PYL>cv2PsnvahM)Dczxd;A2*GwjY2K?`C1PisKTe`D
z#q%>RCDfwu5{Xc8>5ou+!Z(Srq8GYkgR`o&Zo7Z%nU0;`1|3IYbKv_XI-{B!^Ac&EN4WQ
z0Pv+VJYoUNH!kAvs1hHFw4QHNh-3jPU`|zSKldl_;Dij5eYd
z@x9~Lw?3k|RK##22&94HOar{wV}n@R3v3=0$ya>IXH9{4g6NYwh_!uTK$VtPD@ysc
zmem4IR(h@Xk_9Kj9d^Q
zdN$AXQMNv#G3T7s#81eNiRB}dq!R=ak^PxY&IT8s1pguFM}mE4K0(iI9I<>d0|XJ&
z5UjWLOXdrf)V|%ILbF1g{OmT3xM>`Y>GR3)+bm%rF%Cd3?ypiJ>^*BFcsz3i&lH!4
zWNAr9%|04E7f|O%=(~(58(xXeHkoe2hc!f5byywwVq_Up$nrS$kFXxYwo(yM~6pu65XuG9f0
zXvayC)nVZP!l4X25Ah}v37$?
zhUYk*`k*#580N*UAz_ncYvZ+gHo6|k742`Z)I<_c8$D`07h4<|X*8897D-2S0si3+
z7-werGiImLso6Wl(P1DK0y!OXwRJ?vD&FjE>RSSxWaJp0j`o&kP@CZkb}4UoD|eRskzE;pdqi^gWvGNYW?keRiA&&@c(Q+txvcV
zLGnS1<}FwORY?p~hn!Me2E=EAid>ch49Q4_1gXe1>GCQYFO>AzCAn$NO+iJM4`8zh
z%R1+{5Q@J~jYs
z{JEtf7c=K)YEngUAuhkv1?j)?JIB6*4r~9$!WC-9aXxVJS~~$1`N#C$O%ys}-4t(#
zqIefA-N7%^1#r}}mY5_RTB@P%;OBKdyrOxvRi8E014L5^v8q1J^+Cmvqsruw;KL>x
z2n{sM9Z-!fBB&A+PhMb-4)Y0ncHGtkGfR0^L_^pOxRo$2+p(5{pz~CglA10a5SS9l
zB2L=Y#p$OkH5hCi+Ri8+9yL^-EjY-B9^`UcgD%WvSoPE6%XEmHF1M^flN^s43n8|2
zc$yU(7*k)GHc{5a8~14k<4Y7uu=~1dPE42)39488iKr?x#U*J4-zVQuuOyLZscy3x
zD$T4&4X9aQr_rLt3k3`O0&U7#lQx0`VpyG87yYOkD1vI5A*KDme4D*9Jw(@}&6hdL
zE5v4zc-Ro;6vSmSVs||5(|?WngQ!-37~!DN85o%`#WXX_GRHg%EV7Pn<4j=TP7Ugz
zd8O`ErW{@R{h+V0tekoUB~}w7#$ZgCGGoqyB`a{&Y}m464*?0qfg>kq7+B75T)1+B
z=gxyCFW!9kBJg|WV9S3vNXRGx1ql{{iY8PTx^N6k5h6v2#=;gOR-AYV5+&i_O2(6d
zPasvAbV3<2Wf2j}mLpf5d=dqu3Kc0
z(rv3Az54XqX278Bh721qYRtF^lcr3YF>B5a^LE;0!J;L*EnBh2sx>l0lgB>b;dBiv
ztpHkwZo`;f2!TXdNibQQodi=PSxVR(l}2YUS!@oMXEVVJMOG8cRAx89ELD~h%vNVR
zVW`n$wpeWj6uugtF0O9w9?(k?X}QvK=gG*-o0Ts+e~!|@fY;?}w{P8Ee=r=4C)1e~
zng5&GAC9N<<$Ak6p07|i5{<%LyVLFU2SbDjWn4&gFw%x;
z*^cY^!Qs*I$?4ho#pTsC075W=VmLukG{bVdAWE{LYPw-sw&Qw!5Jqv5W_eLob<=kJ
zFi!KbZu@aw_v86`10Vz=D25XxMKdhN3!)?|s-_#JWjn6t2VoQ^X_gmdRX1(d592g1
z>$V@~<$AjV3J97@cY6%eYx!)yL(u}ENGy@c!lALYnag@-%wwsk38M
zQJ;;W_yOeD?5gS(Cf1UZc^k&-J(C~Am0Fsg{}g)d>ie0u1y&dKmNCYcJt#6*q?Yf!
z)+|l(A~iryZUaszbDltafQ|OV%J7O5dGf7BMt#)7FncJ?_^k3Q{m{gwhId>4co>*!
zes#WPprv9>3sG4*Xdz6f?JzDRD7SR*v=AoLb{H2?ZMS79GEEie$Vmc&u~slvC=|`aAJ~#CbiRc(qYMX@)1sgiaTnsH+RszJ+xX`
zojUbp&ok8`JTPio&FkzelzVGy=uxLjrBbO(9lDfFnsjVf_&Dlr&-6v`SaPNgmJ~bf
zVT_P1y4R+!a^g1T(4%aZN@c-HE3LGKC~s4iB1I0FGG$6gNJvnnAE#C-&Fk!(d7M#o
zDNF&&tdtx-oQ~rCWq;^&T6=1%^0Yvnm}&)$^Q51Fx_6q^$KWdGZUft4!-*J64GC?A
z69$eI2?h6ly?ze6D}IHpaLDK<$1a__Ot)nSl!zTJ2eD4oto%$
zIWvnClhCnD$pEU{=hxr&9Oonb?3I!hN`w=WU*vBbR`-6qql~dsr^{JPr@rZ`OUEBR
z>9Az{!e5_F2X#|5@1S$+GL@Y}m$JA@=XaVjB`72)ck7Sx+vVBPZ@ORo(Ww*5cEa8o
z>T4uZ=I8QEf8E+Nd-mjWZyd&jgi>)rLa8w0LV|KjhffP(LfvOQM(k$Q8^NT$q<+wND^rnz%p_HFT{
zbTn;vf6ufKCbq-4kf7YsVbemGP}^Z#NKkI+@EPl^t9Fe%r)SdZ*5XWqA=HjDjt1>&
z+HgzCa75%-N({J!wwRoY5vy4UA%qY@2qA=!BPT7Tlu}A5rIbeQ(aZf|dIYilczJ#pg1hK7dz{(c~P!h{Jxl@P^1kvVhbgoK35pFbZ+
z0+nTFX9F2PP+nf1n3&k!-ad2YOb7c@20$xSOM?7@865WiZ)o`Mu>bsl1qc4`IRD^(
z!G-(Zf8Kw;pyB@i|3LBQ_ZR%HEPbE|)WBKb5n0T@z;^_M8K-LVNi#4oF?zZU}_Jc)%}&cd}l?ADDl
zon38a=jZOYJhl3f@$HlC-qxo*YY+6!jD8nvo_gf$oY$O}qqKU>e!QP(E*|^%!R%Q%
zGLNr(^+J10rs;~J~-QK`Mv2!!vx*i-tq}@IgOXs=6+9O{khm-96)EA9`|i&`-w5>tDX#4!C!^=Xl%3KA!ez
zGaTak&0P-qtNLyEZS3)|{7`q7MV-}(7J)c#H3dSRxL^7e5`$@JLJlgu|(
na^zY+iqMEOuHW_4?;Gp81CnArajL)s!oc9^>gTe~DWM4f=
+
\ No newline at end of file
diff --git a/frontend/src/components/Addr.vue b/frontend/src/components/Addr.vue
new file mode 100644
index 0000000..cb50323
--- /dev/null
+++ b/frontend/src/components/Addr.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('in.mdOption') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/DateTime.vue b/frontend/src/components/DateTime.vue
new file mode 100644
index 0000000..deb4c61
--- /dev/null
+++ b/frontend/src/components/DateTime.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t('submit') }}
+
+
+ {{ $t('reset') }}
+
+
+ {{ $t('now') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/Dial.vue b/frontend/src/components/Dial.vue
new file mode 100644
index 0000000..b97be8c
--- /dev/null
+++ b/frontend/src/components/Dial.vue
@@ -0,0 +1,238 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('dial.options') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/DnsRule.vue b/frontend/src/components/DnsRule.vue
new file mode 100644
index 0000000..08ffd59
--- /dev/null
+++ b/frontend/src/components/DnsRule.vue
@@ -0,0 +1,359 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('rule.options') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/Editor.vue b/frontend/src/components/Editor.vue
new file mode 100644
index 0000000..e6692b2
--- /dev/null
+++ b/frontend/src/components/Editor.vue
@@ -0,0 +1,133 @@
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/ExpTextarea.vue b/frontend/src/components/ExpTextarea.vue
new file mode 100644
index 0000000..f34894e
--- /dev/null
+++ b/frontend/src/components/ExpTextarea.vue
@@ -0,0 +1,76 @@
+
+
+
+ {{ label }}
+
+
+
+ {{ $t('rule.etaHint') }}
+
+
+
+
+
+
+
+
+ {{ $t('reset') }}
+
+ {{ $t('actions.close') }}
+ {{ $t('actions.save') }}
+
+
+
+
+
+
diff --git a/frontend/src/components/Headers.vue b/frontend/src/components/Headers.vue
new file mode 100644
index 0000000..01bede3
--- /dev/null
+++ b/frontend/src/components/Headers.vue
@@ -0,0 +1,104 @@
+
+
+
+
+ {{ $t('objects.headers') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/Listen.vue b/frontend/src/components/Listen.vue
new file mode 100644
index 0000000..5b169b9
--- /dev/null
+++ b/frontend/src/components/Listen.vue
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('listen.options') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/Main.vue b/frontend/src/components/Main.vue
new file mode 100644
index 0000000..25116f7
--- /dev/null
+++ b/frontend/src/components/Main.vue
@@ -0,0 +1,295 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('main.tiles') }}
+
+
+
+
+
+ {{ $t('main.tiles') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('main.backup.title') }}
+
+ {{ $t('basic.log.title') }}
+
+ {{ $t('main.stats.title') }}
+
+
+
+
+
+
+
+ {{ menuItems.flatMap(cat => cat.value).find(m => m.value == i)?.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('main.info.host') }}
+ {{ tilesData.sys?.hostName }}
+ {{ $t('main.info.cpu') }}
+
+
+
+ {{ tilesData.sys?.cpuType }}
+
+ {{ tilesData.sys?.cpuCount }} {{ $t('main.info.core') }}
+
+
+ IP
+
+
+
+
+
+ IPv4
+
+
+
+
+
+ IPv6
+
+
+ S-UI
+
+
+ v{{ tilesData.sys?.appVersion }}
+
+
+ {{ $t('main.info.uptime') }}
+
+ {{ HumanReadable.formatSecond((Date.now()/1000) - tilesData.sys?.bootTime) }}
+
+
+
+
+
+ {{ $t('main.info.running') }}
+
+ {{ $t('yes') }}
+ {{ $t('no') }}
+
+
+ {{ $t('actions.restartSb') }}
+
+
+
+
+ {{ $t('main.info.memory') }}
+
+
+ {{ HumanReadable.sizeFormat(tilesData.sbd?.stats?.Alloc) }}
+
+
+ {{ $t('main.info.threads') }}
+
+
+ {{ tilesData.sbd?.stats?.NumGoroutine }}
+
+
+ {{ $t('main.info.uptime') }}
+ {{ HumanReadable.formatSecond(tilesData.sbd?.stats?.Uptime) }}
+ {{ $t('online') }}
+
+
+
+
+
+ {{ user }}
+
+ {{ Data().onlines.user?.length }}
+
+
+
+
+ {{ i }}
+
+ {{ Data().onlines.inbound?.length }}
+
+
+
+
+ {{ o }}
+
+ {{ Data().onlines.outbound?.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Multiplex.vue b/frontend/src/components/Multiplex.vue
new file mode 100644
index 0000000..335895e
--- /dev/null
+++ b/frontend/src/components/Multiplex.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/Network.vue b/frontend/src/components/Network.vue
new file mode 100644
index 0000000..28bd0f4
--- /dev/null
+++ b/frontend/src/components/Network.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/OutJson.vue b/frontend/src/components/OutJson.vue
new file mode 100644
index 0000000..2deef0a
--- /dev/null
+++ b/frontend/src/components/OutJson.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/Rule.vue b/frontend/src/components/Rule.vue
new file mode 100644
index 0000000..517cecc
--- /dev/null
+++ b/frontend/src/components/Rule.vue
@@ -0,0 +1,568 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('rule.options') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/SimpleDNS.vue b/frontend/src/components/SimpleDNS.vue
new file mode 100644
index 0000000..7195139
--- /dev/null
+++ b/frontend/src/components/SimpleDNS.vue
@@ -0,0 +1,61 @@
+
+
+ {{ label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/SubClashExt.vue b/frontend/src/components/SubClashExt.vue
new file mode 100644
index 0000000..68f6538
--- /dev/null
+++ b/frontend/src/components/SubClashExt.vue
@@ -0,0 +1,248 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('editor') }}
+
+
+ {{ $t('setting.jsonSubOptions') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/SubJsonExt.vue b/frontend/src/components/SubJsonExt.vue
new file mode 100644
index 0000000..7a61678
--- /dev/null
+++ b/frontend/src/components/SubJsonExt.vue
@@ -0,0 +1,514 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('editor') }}
+
+
+ {{ $t('setting.jsonSubOptions') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/Transport.vue b/frontend/src/components/Transport.vue
new file mode 100644
index 0000000..9009631
--- /dev/null
+++ b/frontend/src/components/Transport.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/UoT.vue b/frontend/src/components/UoT.vue
new file mode 100644
index 0000000..51b9fac
--- /dev/null
+++ b/frontend/src/components/UoT.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/Users.vue b/frontend/src/components/Users.vue
new file mode 100644
index 0000000..7812edb
--- /dev/null
+++ b/frontend/src/components/Users.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/WgPeer.vue b/frontend/src/components/WgPeer.vue
new file mode 100644
index 0000000..f37f1ca
--- /dev/null
+++ b/frontend/src/components/WgPeer.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/message.vue b/frontend/src/components/message.vue
new file mode 100644
index 0000000..0d39e42
--- /dev/null
+++ b/frontend/src/components/message.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/AnyTls.vue b/frontend/src/components/protocols/AnyTls.vue
new file mode 100644
index 0000000..6fe32b5
--- /dev/null
+++ b/frontend/src/components/protocols/AnyTls.vue
@@ -0,0 +1,80 @@
+
+
+ AnyTls
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Direct.vue b/frontend/src/components/protocols/Direct.vue
new file mode 100644
index 0000000..52f4bdf
--- /dev/null
+++ b/frontend/src/components/protocols/Direct.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Http.vue b/frontend/src/components/protocols/Http.vue
new file mode 100644
index 0000000..0a77632
--- /dev/null
+++ b/frontend/src/components/protocols/Http.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Hysteria.vue b/frontend/src/components/protocols/Hysteria.vue
new file mode 100644
index 0000000..1cf775e
--- /dev/null
+++ b/frontend/src/components/protocols/Hysteria.vue
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.hy.hyOptions') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Hysteria2.vue b/frontend/src/components/protocols/Hysteria2.vue
new file mode 100644
index 0000000..7b0055a
--- /dev/null
+++ b/frontend/src/components/protocols/Hysteria2.vue
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.hy.hy2Options') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Naive.vue b/frontend/src/components/protocols/Naive.vue
new file mode 100644
index 0000000..c294128
--- /dev/null
+++ b/frontend/src/components/protocols/Naive.vue
@@ -0,0 +1,138 @@
+
+
+ Naive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/protocols/OutShadowTls.vue b/frontend/src/components/protocols/OutShadowTls.vue
new file mode 100644
index 0000000..73ceace
--- /dev/null
+++ b/frontend/src/components/protocols/OutShadowTls.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Selector.vue b/frontend/src/components/protocols/Selector.vue
new file mode 100644
index 0000000..125e5f9
--- /dev/null
+++ b/frontend/src/components/protocols/Selector.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/ShadowTls.vue b/frontend/src/components/protocols/ShadowTls.vue
new file mode 100644
index 0000000..cfa44dc
--- /dev/null
+++ b/frontend/src/components/protocols/ShadowTls.vue
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ key }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Shadowsocks.vue b/frontend/src/components/protocols/Shadowsocks.vue
new file mode 100644
index 0000000..162410f
--- /dev/null
+++ b/frontend/src/components/protocols/Shadowsocks.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Socks.vue b/frontend/src/components/protocols/Socks.vue
new file mode 100644
index 0000000..41ab7e1
--- /dev/null
+++ b/frontend/src/components/protocols/Socks.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Ssh.vue b/frontend/src/components/protocols/Ssh.vue
new file mode 100644
index 0000000..222c154
--- /dev/null
+++ b/frontend/src/components/protocols/Ssh.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+ {{ $t('tls.usePath') }}
+ {{ $t('tls.useText') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.ssh.options') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/TProxy.vue b/frontend/src/components/protocols/TProxy.vue
new file mode 100644
index 0000000..11bf1d2
--- /dev/null
+++ b/frontend/src/components/protocols/TProxy.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Tailscale.vue b/frontend/src/components/protocols/Tailscale.vue
new file mode 100644
index 0000000..47c5a25
--- /dev/null
+++ b/frontend/src/components/protocols/Tailscale.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.ts.options') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Tor.vue b/frontend/src/components/protocols/Tor.vue
new file mode 100644
index 0000000..0d8aac5
--- /dev/null
+++ b/frontend/src/components/protocols/Tor.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Torrc
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Trojan.vue b/frontend/src/components/protocols/Trojan.vue
new file mode 100644
index 0000000..5c4f017
--- /dev/null
+++ b/frontend/src/components/protocols/Trojan.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Tuic.vue b/frontend/src/components/protocols/Tuic.vue
new file mode 100644
index 0000000..c3736ae
--- /dev/null
+++ b/frontend/src/components/protocols/Tuic.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Tun.vue b/frontend/src/components/protocols/Tun.vue
new file mode 100644
index 0000000..f853176
--- /dev/null
+++ b/frontend/src/components/protocols/Tun.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/protocols/UrlTest.vue b/frontend/src/components/protocols/UrlTest.vue
new file mode 100644
index 0000000..8c4f99f
--- /dev/null
+++ b/frontend/src/components/protocols/UrlTest.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.lb.urlTestOptions') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Vless.vue b/frontend/src/components/protocols/Vless.vue
new file mode 100644
index 0000000..b3a3115
--- /dev/null
+++ b/frontend/src/components/protocols/Vless.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Vmess.vue b/frontend/src/components/protocols/Vmess.vue
new file mode 100644
index 0000000..2ce85ff
--- /dev/null
+++ b/frontend/src/components/protocols/Vmess.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Warp.vue b/frontend/src/components/protocols/Warp.vue
new file mode 100644
index 0000000..6bdcbc4
--- /dev/null
+++ b/frontend/src/components/protocols/Warp.vue
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+ | Device ID |
+ {{ data.ext.device_id }} |
+
+
+ | Access Token |
+ {{ data.ext.access_token }} |
+
+
+ | {{ $t('types.wg.privKey') }} |
+ {{ data.private_key }} |
+
+
+ | {{ $t('types.wg.localIp') }} |
+ {{ data.address.join(',') }} |
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | {{ $t('types.wg.pubKey') }} |
+ {{ data.peers[0].public_key }} |
+
+
+ | {{ $t('types.wg.allowedIp') }} |
+ {{ data.peers[0].allowed_ips.join(',') }} |
+
+
+ | Reserved |
+ [{{ data.peers[0].reserved.join(',') }}] |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.wg.options') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/protocols/Wireguard.vue b/frontend/src/components/protocols/Wireguard.vue
new file mode 100644
index 0000000..4e31593
--- /dev/null
+++ b/frontend/src/components/protocols/Wireguard.vue
@@ -0,0 +1,197 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.wg.options') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.wg.peers') }}
+
+
+
+
+
+ {{ $t('types.wg.peer') + ' ' + (Number(index)+1) }}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/services/Ccm.vue b/frontend/src/components/services/Ccm.vue
new file mode 100644
index 0000000..49be011
--- /dev/null
+++ b/frontend/src/components/services/Ccm.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.ccm.users') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/services/Derp.vue b/frontend/src/components/services/Derp.vue
new file mode 100644
index 0000000..1c64dea
--- /dev/null
+++ b/frontend/src/components/services/Derp.vue
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.derp.verifyClientUrl') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.derp.meshWith') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.derp.meshPsk') }}
+ {{ $t('types.derp.meshPskFile') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.derp.options') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/services/Ocm.vue b/frontend/src/components/services/Ocm.vue
new file mode 100644
index 0000000..1e91e7a
--- /dev/null
+++ b/frontend/src/components/services/Ocm.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('types.ocm.users') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/services/SSMAPI.vue b/frontend/src/components/services/SSMAPI.vue
new file mode 100644
index 0000000..e0443d5
--- /dev/null
+++ b/frontend/src/components/services/SSMAPI.vue
@@ -0,0 +1,100 @@
+
+
+ Shadowsocks API
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/tiles/Gauge.vue b/frontend/src/components/tiles/Gauge.vue
new file mode 100644
index 0000000..d7eefad
--- /dev/null
+++ b/frontend/src/components/tiles/Gauge.vue
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/tiles/History.vue b/frontend/src/components/tiles/History.vue
new file mode 100644
index 0000000..604ecc5
--- /dev/null
+++ b/frontend/src/components/tiles/History.vue
@@ -0,0 +1,213 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/tls/Acme.vue b/frontend/src/components/tls/Acme.vue
new file mode 100644
index 0000000..e8e3027
--- /dev/null
+++ b/frontend/src/components/tls/Acme.vue
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('tls.acme.options') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/tls/Ech.vue b/frontend/src/components/tls/Ech.vue
new file mode 100644
index 0000000..856836e
--- /dev/null
+++ b/frontend/src/components/tls/Ech.vue
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('tls.usePath') }}
+ {{ $t('tls.useText') }}
+
+
+
+
+
+
+
+ {{ $t('actions.generate') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/tls/InTLS.vue b/frontend/src/components/tls/InTLS.vue
new file mode 100644
index 0000000..1982bfe
--- /dev/null
+++ b/frontend/src/components/tls/InTLS.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/tls/OutTLS.vue b/frontend/src/components/tls/OutTLS.vue
new file mode 100644
index 0000000..e02b1dd
--- /dev/null
+++ b/frontend/src/components/tls/OutTLS.vue
@@ -0,0 +1,381 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('tls.usePath') }}
+ {{ $t('tls.useText') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ECH
+
+
+
+
+ {{ $t('tls.usePath') }}
+ {{ $t('tls.useText') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('tls.options') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/transports/Http.vue b/frontend/src/components/transports/Http.vue
new file mode 100644
index 0000000..66f4778
--- /dev/null
+++ b/frontend/src/components/transports/Http.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/transports/HttpUpgrade.vue b/frontend/src/components/transports/HttpUpgrade.vue
new file mode 100644
index 0000000..a52a085
--- /dev/null
+++ b/frontend/src/components/transports/HttpUpgrade.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/transports/WebSocket.vue b/frontend/src/components/transports/WebSocket.vue
new file mode 100644
index 0000000..ff56859
--- /dev/null
+++ b/frontend/src/components/transports/WebSocket.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/transports/gRPC.vue b/frontend/src/components/transports/gRPC.vue
new file mode 100644
index 0000000..aef1c91
--- /dev/null
+++ b/frontend/src/components/transports/gRPC.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/default/AppBar.vue b/frontend/src/layouts/default/AppBar.vue
new file mode 100644
index 0000000..fc76534
--- /dev/null
+++ b/frontend/src/layouts/default/AppBar.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+ mdi-translate
+
+
+
+
+ {{ lang.title }}
+
+
+
+
+
+
+ mdi-theme-light-dark
+
+
+
+
+ {{ $t(`theme.${th.value}`) }}
+
+
+
+
+
+
+
diff --git a/frontend/src/layouts/default/Default.vue b/frontend/src/layouts/default/Default.vue
new file mode 100644
index 0000000..3a57e3d
--- /dev/null
+++ b/frontend/src/layouts/default/Default.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/default/Drawer.vue b/frontend/src/layouts/default/Drawer.vue
new file mode 100644
index 0000000..ca0e7b5
--- /dev/null
+++ b/frontend/src/layouts/default/Drawer.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/default/View.vue b/frontend/src/layouts/default/View.vue
new file mode 100644
index 0000000..0889462
--- /dev/null
+++ b/frontend/src/layouts/default/View.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/Admin.vue b/frontend/src/layouts/modals/Admin.vue
new file mode 100644
index 0000000..6f74eda
--- /dev/null
+++ b/frontend/src/layouts/modals/Admin.vue
@@ -0,0 +1,97 @@
+
+
+
+
+ {{ $t('admin.changeCred') + " " + user.username }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/Backup.vue b/frontend/src/layouts/modals/Backup.vue
new file mode 100644
index 0000000..45cabb8
--- /dev/null
+++ b/frontend/src/layouts/modals/Backup.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+ {{ $t('main.backup.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('main.backup.backup') }}
+
+
+
+ {{ $t('main.backup.restore') }}
+
+
+
+
+
+ {{ $t('main.backup.sbConfig') }}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/Changes.vue b/frontend/src/layouts/modals/Changes.vue
new file mode 100644
index 0000000..0f00624
--- /dev/null
+++ b/frontend/src/layouts/modals/Changes.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+ {{ $t('admin.changes') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ dateFormatted(value) }}
+
+
+
+
+ {{ $t('actions.' + value) }}
+
+
+
+
+
+ Index: {{ item.index }}
+ {{ item.obj }}
+ |
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/Client.vue b/frontend/src/layouts/modals/Client.vue
new file mode 100644
index 0000000..44e67be
--- /dev/null
+++ b/frontend/src/layouts/modals/Client.vue
@@ -0,0 +1,370 @@
+
+
+
+
+ {{ $t('actions.' + title) + " " + $t('objects.client') }}
+
+
+
+
+
+
+ {{ $t('client.basics') }}
+ {{ $t('client.config') }}
+ {{ $t('client.links') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('stats.usage') }}: {{ total }}({{ percent }}%)
+
+
+
+ {{ $t('reset') }}
+
+
+
+
+
+
+
+
+ {{ up }}
+ /
+ {{ down }}
+
+
+
+
+ {{ $t('client.nextReset') }}
+ {{ nextResetFormatted }}
+
+
+ {{ $t('main.stats.totalUsage') }}
+
+ {{ totalUp }}
+ /
+ {{ totalDown }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('reset') + ' - ' + $t('all') }}
+
+
+
+
+ {{ key }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ index + 1 }}
+ {{ lnk.uri }}
+
+
+
+ {{ $t('actions.add') }} {{ $t('client.external') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.add') }} {{ $t('client.sub') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/ClientAddBulk.vue b/frontend/src/layouts/modals/ClientAddBulk.vue
new file mode 100644
index 0000000..af7042b
--- /dev/null
+++ b/frontend/src/layouts/modals/ClientAddBulk.vue
@@ -0,0 +1,228 @@
+
+
+
+
+ {{ $t('actions.addbulk') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/ClientEditBulk.vue b/frontend/src/layouts/modals/ClientEditBulk.vue
new file mode 100644
index 0000000..e5718b0
--- /dev/null
+++ b/frontend/src/layouts/modals/ClientEditBulk.vue
@@ -0,0 +1,196 @@
+
+
+
+
+ {{ $t('actions.editbulk') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
diff --git a/frontend/src/layouts/modals/Dns.vue b/frontend/src/layouts/modals/Dns.vue
new file mode 100644
index 0000000..ad109e9
--- /dev/null
+++ b/frontend/src/layouts/modals/Dns.vue
@@ -0,0 +1,210 @@
+
+
+
+
+ {{ $t('actions.' + title) + " " + $t('objects.dnsserver') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Predefined
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+ {{ $t('actions.save') }}
+
+
+
+
+
+
diff --git a/frontend/src/layouts/modals/DnsRule.vue b/frontend/src/layouts/modals/DnsRule.vue
new file mode 100644
index 0000000..951fdf2
--- /dev/null
+++ b/frontend/src/layouts/modals/DnsRule.vue
@@ -0,0 +1,313 @@
+
+
+
+
+ {{ $t('actions.' + title) + " " + $t('objects.dnsrule') }}
+
+
+
+
+
+
+
+
+
+ {})" hide-details>{{ $t('actions.add') + " " + $t('objects.rule') }}
+
+
+
+ {{ $t('objects.rule') + ' ' + (Number(index)+1) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/Endpoint.vue b/frontend/src/layouts/modals/Endpoint.vue
new file mode 100644
index 0000000..f9248f5
--- /dev/null
+++ b/frontend/src/layouts/modals/Endpoint.vue
@@ -0,0 +1,227 @@
+
+
+
+
+ {{ $t('actions.' + title) + " " + $t('objects.endpoint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/Inbound.vue b/frontend/src/layouts/modals/Inbound.vue
new file mode 100644
index 0000000..b4292d3
--- /dev/null
+++ b/frontend/src/layouts/modals/Inbound.vue
@@ -0,0 +1,287 @@
+
+
+
+
+ {{ $t('actions.' + title) + " " + $t('objects.inbound') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('in.sSide') }}
+ {{ $t('in.cSide') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('in.multiDomain') }}
+
+
+
+ {{ $t('in.addr') }} #{{ (index+1) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/Logs.vue b/frontend/src/layouts/modals/Logs.vue
new file mode 100644
index 0000000..e8b6e00
--- /dev/null
+++ b/frontend/src/layouts/modals/Logs.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+ {{ $t('basic.log.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/Outbound.vue b/frontend/src/layouts/modals/Outbound.vue
new file mode 100644
index 0000000..96d68ce
--- /dev/null
+++ b/frontend/src/layouts/modals/Outbound.vue
@@ -0,0 +1,212 @@
+
+
+
+
+ {{ $t('actions.' + title) + " " + $t('objects.outbound') }}
+
+
+
+
+
+ {{ $t('client.basics') }}
+ {{ $t('client.external') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('submit') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/OutboundBulk.vue b/frontend/src/layouts/modals/OutboundBulk.vue
new file mode 100644
index 0000000..4b8a2ae
--- /dev/null
+++ b/frontend/src/layouts/modals/OutboundBulk.vue
@@ -0,0 +1,155 @@
+
+
+
+
+ {{ $t('actions.addbulk') }} {{ $t('objects.outbound') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('submit') }}
+
+
+
+
+
+
+
+
+
+
+ {{ item.type }}
+
+
+ {{ item.tag }}
+
+
+ {{ Object.hasOwn(item,'tls') ? $t(item.tls?.enabled ? 'enable' : 'disable') : '-' }}
+
+
+ {{ item.server }}{{ item.server_port ? ':' + item.server_port : '' }}
+
+
+
+
+
+ {{ $t('actions.close') }}
+ {{ $t('actions.save') }}
+
+
+
+
+
+
diff --git a/frontend/src/layouts/modals/QrCode.vue b/frontend/src/layouts/modals/QrCode.vue
new file mode 100644
index 0000000..762b6bd
--- /dev/null
+++ b/frontend/src/layouts/modals/QrCode.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
+ QrCode
+
+
+
+
+
+
+
+
+ {{ $t('setting.sub') }}
+ {{ $t('client.links') }}
+
+
+
+
+
+ {{ $t('setting.sub') }}
+
+
+
+
+
+ {{ $t('setting.jsonSub') }}
+
+
+
+
+
+ {{ $t('setting.clashSub') }}
+
+
+
+
+
+ SING-BOX (scan only)
+
+
+
+
+
+
+
+ {{ l.remark?? $t('client.' + l.type) }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/Rule.vue b/frontend/src/layouts/modals/Rule.vue
new file mode 100644
index 0000000..a332890
--- /dev/null
+++ b/frontend/src/layouts/modals/Rule.vue
@@ -0,0 +1,324 @@
+
+
+
+
+ {{ $t('actions.' + title) + " " + $t('objects.rule') }}
+
+
+
+
+
+
+
+
+
+ {})" hide-details>{{ $t('actions.add') + " " + $t('objects.rule') }}
+
+
+
+ {{ $t('objects.rule') + ' ' + (Number(index)+1) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/RuleImport.vue b/frontend/src/layouts/modals/RuleImport.vue
new file mode 100644
index 0000000..69bba22
--- /dev/null
+++ b/frontend/src/layouts/modals/RuleImport.vue
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+ {{ $t('rule.import.rulesTitle') }}
+
+
+
+ {{ parsed.rules?.length ?? 0 }} {{ $t('pages.rules') }} · {{ parsed.rule_set?.length ?? 0 }} {{ $t('rule.ruleset') }}
+
+
+
+
+
+
+
+ JSON
+ {{ $t('rule.import.uploadFile') }}
+ {{ $t('rule.import.fromUrl') }}
+
+
+
+
+ {{ $t('rule.import.jsonHint') }}
+
+
+
+
+ {{ $t('rule.import.fileJsonHint') }}
+
+
+
+
+ {{ $t('rule.import.urlHint') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('rule.import.conflictDesc', { rules: existingRulesCount, rulesets: existingRulesetsCount }) }}
+
+
+
+
+
+
+
+ {{ $t('rule.import.finalOutbound') }}:
+ {{ parsed.final }}
+
+
+
+
+ {{ $t('pages.rules') }}
+
+
+
+
+ | # | {{ $t('type') }} | {{ $t('admin.action') }} | {{ $t('objects.outbound') }} |
+
+
+
+ | {{ (i as number) + 1 }} |
+ {{ r.type ?? 'simple' }} |
+ {{ r.action }} |
+ {{ r.outbound ?? '-' }} |
+
+
+
+
+
+ {{ $t('rule.ruleset') }}
+
+
+
+
+
+
+
+ | {{ $t('objects.tag') }} | {{ $t('ruleset.format') }} | {{ $t('type') }} | {{ $t('ruleset.interval') }} |
+
+
+
+ | {{ rs.tag }} |
+ {{ rs.format }} |
+ {{ rs.type }} |
+ {{ rs.update_interval ?? '-' }} |
+
+
+
+
+
+
+
+ {{ $t('rule.import.parse') }}
+
+
+
+ {{ $t('actions.close') }}
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
diff --git a/frontend/src/layouts/modals/Ruleset.vue b/frontend/src/layouts/modals/Ruleset.vue
new file mode 100644
index 0000000..ae5404f
--- /dev/null
+++ b/frontend/src/layouts/modals/Ruleset.vue
@@ -0,0 +1,133 @@
+
+
+
+
+ {{ $t('actions.' + title) + " " + $t('objects.ruleset') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/RulesetImport.vue b/frontend/src/layouts/modals/RulesetImport.vue
new file mode 100644
index 0000000..0410bbe
--- /dev/null
+++ b/frontend/src/layouts/modals/RulesetImport.vue
@@ -0,0 +1,187 @@
+
+
+
+
+
+ {{ $t('rule.import.title') }}
+
+
+ {{ $t('count') }}: {{ importPreview.length }}
+
+
+
+
+
+
+
+
+ {{ $t('rule.import.pasteUrls') }}
+
+
+ {{ $t('rule.import.uploadTxt') }}
+
+
+
+
+ {{ $t('rule.import.urlsHint') }}
+
+
+
+ {{ $t('rule.import.fileHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('rule.import.preview') }}
+
+
+
+
+
+ | {{ $t('objects.tag') }} | {{ $t('ruleset.format') }} | URL | {{ $t('actions.del') }} |
+
+
+
+ |
+ {{ item.tag }}
+ |
+ {{ item.format }} |
+ .../{{ item.url.split('/').pop() ?? item.url }} |
+ |
+
+
+
+
+
+
+
+
+ {{ $t('rule.import.parse') }}
+
+
+ {{ $t('actions.close') }}
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
diff --git a/frontend/src/layouts/modals/Service.vue b/frontend/src/layouts/modals/Service.vue
new file mode 100644
index 0000000..fe79478
--- /dev/null
+++ b/frontend/src/layouts/modals/Service.vue
@@ -0,0 +1,128 @@
+
+
+
+
+ {{ $t('actions.' + title) + " " + $t('objects.service') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/Stats.vue b/frontend/src/layouts/modals/Stats.vue
new file mode 100644
index 0000000..86fcb68
--- /dev/null
+++ b/frontend/src/layouts/modals/Stats.vue
@@ -0,0 +1,218 @@
+
+
+
+
+
+
+ {{ $t('stats.graphTitle') }}
+
+
+
+
+
+
+
+
+ {{ $t('objects.' + resource) + " : " + tag }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/Tls.vue b/frontend/src/layouts/modals/Tls.vue
new file mode 100644
index 0000000..f179c4d
--- /dev/null
+++ b/frontend/src/layouts/modals/Tls.vue
@@ -0,0 +1,592 @@
+
+
+
+
+ {{ $t('actions.' + title) + " " + $t('objects.tls') }}
+
+
+
+
+
+
+
+
+
+
+
+ TLS
+ Reality
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('tls.usePath') }}
+ {{ $t('tls.useText') }}
+
+
+
+
+
+
+
+ {{ $t('actions.generate') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.generate') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('tls.options') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/Token.vue b/frontend/src/layouts/modals/Token.vue
new file mode 100644
index 0000000..3cf954a
--- /dev/null
+++ b/frontend/src/layouts/modals/Token.vue
@@ -0,0 +1,258 @@
+
+
+
+
+
+ {{ $t('admin.api.title') }}
+
+
+
+
+
+
+
+ {{ $t('admin.api.msg') }}
+
+
+
+
+
+ | # |
+ {{ $t('admin.api.token') }} |
+ {{ $t('client.desc') }} |
+ {{ $t('date.expiry') }} |
+ {{ $t('actions.del') }} |
+
+
+
+
+ | {{ token.id }} |
+ {{ token.token }} |
+ {{ token.desc }} |
+ {{ dateFormatted(token.expiry) }} |
+
+
+
+
+ mdi-delete
+
+
+
+
+ {{ $t('confirm') }}
+
+ {{ $t('yes') }}
+ {{ $t('no') }}
+
+
+
+ |
+
+
+
+
+ {{ $t('actions.add') }}
+
+
+
+
+
+ {{ $t('admin.api.token') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+ {{ $t('actions.add') }}
+
+
+
+
+
+
+
+
+ {{ $t('actions.close') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/UsageStats.vue b/frontend/src/layouts/modals/UsageStats.vue
new file mode 100644
index 0000000..7c3b951
--- /dev/null
+++ b/frontend/src/layouts/modals/UsageStats.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+ {{ $t('main.stats.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ |
+ {{ row.label }} |
+ {{ row.value }} |
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/layouts/modals/WgQrCode.vue b/frontend/src/layouts/modals/WgQrCode.vue
new file mode 100644
index 0000000..d44d794
--- /dev/null
+++ b/frontend/src/layouts/modals/WgQrCode.vue
@@ -0,0 +1,129 @@
+
+
+
+
+
+ Wireguard QrCode
+
+
+
+
+
+
+
+ {{ $t('types.wg.peer') + ' ' + (i+1) }}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts
new file mode 100644
index 0000000..c03ac0f
--- /dev/null
+++ b/frontend/src/locales/en.ts
@@ -0,0 +1,637 @@
+export default {
+ success: "success",
+ failed: "failed",
+ enable: "Enable",
+ disable: "Disable",
+ none: "None",
+ all: "All",
+ loading: "Loading...",
+ confirm: "Are you sure ?",
+ yes: "yes",
+ no: "no",
+ unlimited: "infinite",
+ type: "Type",
+ protocol: "Protocol",
+ submit: "Submit",
+ reset: "Reset",
+ now: "Now",
+ network: "Network",
+ copyToClipboard: "Copy to clipboard",
+ noData: "No data!",
+ invalidLogin: "Invalid Login!",
+ online: "Online",
+ version: "Version",
+ email: "Email",
+ commaSeparated: "(comma separated)",
+ count: "Count",
+ template: "Template",
+ editor: "Editor",
+ error: {
+ dplData: "Duplicate Data",
+ core: "Sing-Box Error",
+ invalidData: "Invalid Data",
+ },
+ theme: {
+ light: "Light",
+ dark: "Dark",
+ system: "System",
+ },
+ pages: {
+ login: "Login",
+ home: "Home",
+ inbounds: "Inbounds",
+ outbounds: "Outbounds",
+ services: "Services",
+ endpoints: "Endpoints",
+ clients: "Clients",
+ rules: "Rules",
+ tls: "TLS Settings",
+ basics: "Basics",
+ dns: "DNS",
+ admins: "Admins",
+ settings: "Settings",
+ },
+ main: {
+ tiles: "Tiles",
+ gauges: "Gauges",
+ charts: "Charts",
+ infos: "Information",
+ gauge: {
+ cpu: "CPU Gauge",
+ mem: "RAM Gauge",
+ dsk: "Disk Gauge",
+ swp: "Swap Gauge",
+ },
+ chart: {
+ cpu: "CPU Monitor",
+ mem: "RAM Monitor",
+ net: "Network Bandwidth",
+ pnet: "Network Packets",
+ dio: "Disk I/O",
+ },
+ info: {
+ sys: "System Info",
+ sbd: "Sing-Box Info",
+ host: "Host",
+ cpu: "CPU",
+ core: "Core",
+ uptime: "Uptime",
+ startupTime: "Startup time",
+ threads: "Threads",
+ memory: "Memory",
+ running: "Running"
+ },
+ backup: {
+ title: "Backup & Restore",
+ backup: "Download Backup",
+ restore: "Restore Backup",
+ exclStats: "Exclude graphs",
+ exclChanges: "Exclude changes",
+ sbConfig: "Download Sing-Box Config",
+ },
+ stats: {
+ title: "Usage & Counts",
+ totalUsage: "Total Usage",
+ },
+ },
+ objects: {
+ inbound: "Inbound",
+ client: "Client",
+ outbound: "Outbound",
+ endpoint: "Endpoint",
+ config: "Config",
+ rule: "Rule",
+ ruleset: "Ruleset",
+ service: "Service",
+ dnsserver: "DNS Server",
+ dnsrule: "DNS Rule",
+ user: "User",
+ tag: "Tag",
+ listen: "Listen",
+ dial: "Dial",
+ tls: "TLS",
+ multiplex: "Multiplex",
+ transport: "Transport",
+ headers: "Headers",
+ key: "Key",
+ value: "Value",
+ },
+ actions: {
+ action: "Action",
+ add: "Add",
+ addbulk: "Add Bulk",
+ editbulk: "Edit Bulk",
+ delbulk: "Delete Bulk",
+ new: "New",
+ edit: "Edit",
+ del: "Delete",
+ clone: "Clone",
+ test: "Test",
+ testAll: "Test all",
+ save: "Save",
+ update: "Update",
+ submit: "Submit",
+ set: "Set",
+ generate: "Generate",
+ disable: "Disable",
+ close: "Close",
+ restartApp: "Restart App",
+ restartSb: "Restart Singbox",
+ },
+ login: {
+ title: "Login",
+ username: "Username",
+ unRules: "Username can not be empty",
+ password: "Password",
+ pwRules: "Password can not be empty",
+ },
+ menu: {
+ logout: "Logout",
+ },
+ admin: {
+ changeCred: "Change credentials",
+ oldPass: "Current Password",
+ newUname: "New Username",
+ newPass: "New Password",
+ lastLogin: "Last login",
+ date: "Date",
+ time: "Time",
+ changes: "Changes",
+ actor: "Actor",
+ key: "Key",
+ action: "Action",
+ api: {
+ title: "API Tokens",
+ msg: "Please copy the token below and store it somewhere safe. It will not be shown again.",
+ token: "Token",
+ },
+ },
+ setting: {
+ interface: "Interface",
+ sub: "Subscription",
+ addr: "Address",
+ port: "Port",
+ webPath: "Base URI",
+ domain: "Domain",
+ sslKey: "SSL Key Path",
+ sslCert: "SSL Certificate Path",
+ webUri: "Panel URI",
+ sessionAge: "Session Maximum Age",
+ trafficAge: "Traffic Maximum Age",
+ timeLoc: "Timezone Location",
+ subEncode: "Enable Encoding",
+ subInfo: "Enable Client Info",
+ path: "Default Path",
+ update: "Automatic Update Time",
+ subUri: "Subscription URI",
+ jsonSub: "JSON Subscription",
+ toDirect: "Route to Direct",
+ toBlock: "Route to Block",
+ timestamp: "Timestamp",
+ globalDns: "Global DNS",
+ directDns: "Direct DNS",
+ toDirectDns: "Route to Direct DNS",
+ jsonSubOptions: "Other Options",
+ excludePkg: "Exclude Packages",
+ clashSub: "Clash Subscription",
+ mixedPort: "Mixed Inbound Port",
+ tun: "Tun Inbound",
+ },
+ client: {
+ name: "Name",
+ desc: "Description",
+ group: "Group",
+ inboundTags: "Inbound Tags",
+ basics: "Basics",
+ config: "Config",
+ links: "Links",
+ external: "External Link",
+ sub: "External Subscription",
+ delayStart: "Delay Start",
+ autoReset: "Auto Reset",
+ resetDays: "Reset Days",
+ nextReset: "Next Reset",
+ },
+ bulk: {
+ order: "Order",
+ random: "Random",
+ changeLimits: "Change limits",
+ addInbounds: "Add inbounds",
+ removeInbounds: "Remove inbounds",
+ addDays: "Add days",
+ addVolume: "Add volume",
+ },
+ types: {
+ un: "Username",
+ pw: "Password",
+ direct: {
+ overrideAddr: "Override Address",
+ overridePort: "Override Port",
+ },
+ hy: {
+ obfs: "Obfuscated Password",
+ auth: "Authentication Password",
+ hyOptions: "Hysteria Options",
+ hy2Options: "Hysteria2 Options",
+ ignoreBw: "Ignore Client Bandwidth",
+ },
+ shdwTls: {
+ hs: "Handshake Server",
+ addHS: "Add Handshake Server",
+ },
+ ssh: {
+ passphrase: "Passphrase",
+ hostKey: "Host Keys",
+ algorithm: "Key Algorithms",
+ clientVer: "Client Version",
+ options: "SSH Options",
+ },
+ tor: {
+ execPath: "Executable File Path",
+ dataDir: "Data Directory",
+ extArgs: "Extra Args",
+ },
+ tuic: {
+ congControl: "Congestion Control",
+ authTimeout: "Authentication Timeout",
+ hb: "Heartbeat",
+ },
+ tun: {
+ addr: "Addresses",
+ ifName: "Interface Name",
+ excludeMptcp: "Exclude MPTCP",
+ fallbackRuleIndex: "iproute2 Fallback Rule Index",
+ },
+ vless: {
+ flow: "Flow",
+ udpEnc: "UDP Packet Encoding",
+ },
+ vmess: {
+ security: "Security",
+ globalPadding: "Global Padding",
+ authLen: "Encryptrd Length",
+ },
+ wg: {
+ privKey: "Private Key",
+ pubKey: "Peer Public Key",
+ psk: "Pre-Shared Key",
+ localIp: "Local IPs",
+ worker: "Workers",
+ ifName: "Interface Name",
+ sysIf: "System Interface",
+ options: "Wireguard Options",
+ allowedIp: "Allowed IPs",
+ peer: "Peer",
+ peers: "Peers",
+ },
+ lb: {
+ defaultOut: "Default Outbound",
+ interruptConn: "Interrupt exist connections",
+ testUrl: "Test URL",
+ interval: "Interval",
+ tolerance: "Tolerance",
+ urlTestOptions: "URLTest Options"
+ },
+ ts: {
+ options: "Tailscale Options",
+ stateDir: "State Directory",
+ authKey: "Authentication Key",
+ relayServer: "Relay Server",
+ relayServerPort: "Relay Server Port",
+ relayEndpoints: "Relay Static Endpoints",
+ systemInterface: "System Interface",
+ sysIfName: "Interface Name",
+ sysIfMtu: "Interface MTU",
+ controlUrl: "Control URL",
+ ephemeral: "Ephemeral Mode",
+ hostname: "Hostname",
+ acceptRoutes: "Accept Routes",
+ exitNode: "Exit Node",
+ allowLanAccess: "Allow LAN Access",
+ advRoutes: "Advertise Routes",
+ advExitNode: "Advertise Exit Node",
+ udpTimeout: "UDP Timeout",
+ },
+ ocm: {
+ credentialPath: "Credential Path",
+ usagesPath: "Usages Path",
+ users: "Users",
+ userName: "Name",
+ userToken: "Token",
+ },
+ ccm: {
+ credentialPath: "Credential Path",
+ usagesPath: "Usages Path",
+ users: "Users",
+ userName: "Name",
+ userToken: "Token",
+ },
+ derp: {
+ configPath: "Config Path",
+ verifyClientEndpoint: "Verify Client Endpoint",
+ verifyClientUrl: "Verify Client URL",
+ meshWith: "Mesh With",
+ meshPsk: "Mesh PSK",
+ meshPskFile: "Mesh PSK File",
+ stun: "STUN Server",
+ options: "DERP Options",
+ },
+ anytls: {
+ idleInterval: "Idle Session Check Interval",
+ idleTimeout: "Idle Session Timeout",
+ minIdle: "Minimum Idle Session",
+ },
+ naive: {
+ insecureConcurrency: "Insecure Concurrency",
+ quic: "QUIC",
+ quicCongestion: "QUIC Congestion Control",
+ udpOverTcp: "UDP over TCP",
+ },
+ },
+ in: {
+ addr: "Address",
+ port: "Port",
+ ssMethod: "Method",
+ ssManageable: "Manageable",
+ sSide: "Server Side",
+ cSide: "Client Side",
+ multiDomain: "Multi Domain",
+ remark: "Remark",
+ mdOption: "Multi Domain Options",
+ },
+ listen: {
+ options: "Listen Options",
+ tcpOptions: "TCP Options",
+ udpOptions: "UDP Options",
+ detour: "Detour",
+ detourText: "Forward to inbound",
+ disableTcpKeepAlive: "Disable TCP Keep Alive",
+ tcpKeepAlive: "TCP Keep Alive",
+ tcpKeepAliveInterval: "TCP Keep Alive Interval",
+ },
+ dial: {
+ bindIf: "Bind to Network Interface",
+ bindIp4: "Bind to IPv4",
+ bindIp6: "Bind to IPv6",
+ bindNoPort: "Bind Address No Port",
+ reuseAddr: "Reuse Listener Address",
+ connTimeout: "Connection Timeout",
+ disableTcpKeepAlive: "Disable TCP Keep Alive",
+ tcpKeepAlive: "TCP Keep Alive",
+ tcpKeepAliveInterval: "TCP Keep Alive Interval",
+ domainResolver: "Domain Resolver",
+ options: "Dial Options",
+ detourText: "Forward to outbound",
+ },
+ transport: {
+ enable: "Enable Transport",
+ host: "Host",
+ hosts: "Hosts",
+ path: "Path",
+ httpMethod: "Request Method",
+ idleTimeout: "Idle Timeout",
+ pingTimeout: "Ping Timeout",
+ grpcServiceName: "Service Name",
+ grpcPws: "Permit Without Stream",
+ },
+ mux: {
+ enable: "Enable Multiplex",
+ maxConn: "Max Connections",
+ minStr: "Min Streams",
+ maxStr: "Max Streams",
+ padding: "Only padding",
+ enableBrutal: "Enable Brutal",
+ },
+ out: {
+ addr: "Server Address",
+ port: "Server Port",
+ addUrlTest: "Add URLTest",
+ delay: "Delay",
+ },
+ rule: {
+ add: "Add Rule",
+ simple: "Simple",
+ logical: "Logical",
+ mode: "Mode",
+ invert: "Invert",
+ ipVer: "IP Version",
+ domain: "Domains",
+ domainSufix: "Domain Suffixes",
+ domainKw: "Domain Keywords",
+ domainRgx: "Domain Regexes",
+ ip: "IP CIDRs",
+ privateIp: "Invalid IP Ranges",
+ port: "Ports",
+ portRange: "Port Ranges",
+ srcCidr: "Source IP CIDRs",
+ srcPrivateIp: "Invalid Source IPs",
+ srcPort: "Source Ports",
+ srcPortRange: "Source Port Ranges",
+ ruleset: "Rulesets",
+ rulesetMatchSrc: "Ruleset IPcidr Match Source",
+ preferredBy: "Preferred By (Outbound)",
+ interfaceAddr: "Interface Address",
+ options: "Rule Options",
+ domainRules: "Domain/IP",
+ srcIpRules: "Source IP",
+ srcPortRules: "Source Port",
+ udpDisableDomainUnmapping: "UDP Disable Domain Unmapping",
+ udpConnect: "UDP Connect",
+ udpTimeout: "UDP Timeout",
+ method: "Method",
+ noDrop: "No Drop",
+ sniffer: "Sniffer",
+ timeout: "Timeout",
+ strategy: "Strategy",
+ etaHint: "Enter one item per line. Blank lines and duplicate entries will be skipped.",
+ import: {
+ title: "Mass import of rulesets",
+ rulesTitle: "Importing rules",
+ urlsHint: "One URL per line. The tag is determined from the file name without the extension.",
+ fileHint: "Upload it .a txt file with URLs, one per line.",
+ jsonHint: "Insert a JSON object with the rules and/or rule_set arrays. You can insert the entire \"route\" block: {'{'}...{'}'} or just its contents.",
+ fileJsonHint: "Upload it .a json file with the route block.",
+ urlHint: "Specify a direct link to the JSON file (for example, a raw GitHub link).",
+ preview: "Preview",
+ skipped: "already exist, shown in gray",
+ conflict: "Conflicts detected",
+ merge: "Merge - add imported rules (skip duplicate set tags)",
+ replace: "Replace - delete existing rules and sets, and import them again",
+ pasteUrls: "Insert URL",
+ uploadTxt: "Upload .txt",
+ uploadFile: "Upload a file",
+ fromUrl: "By URL",
+ selectTxt: "Choose .txt file",
+ selectJson: "Choose .json file",
+ parse: "Parse",
+ conflictDesc: "The config already has {rules} of rules and {rulesets} of rule sets. Select an action:",
+ finalOutbound: "Outbound by default (final)",
+ applyFinal: "Apply as outbound by default",
+ errNoArrays: 'No "rules" or "rule_set" arrays found.',
+ errJsonParse: "JSON parse error: {message}",
+ errNoArraysFetched: 'No "rules" or "rule_set" found in fetched JSON.',
+ errFetch: "Fetch error: {message}",
+ errNoFile: "No file selected.",
+ errNoArraysInFile: 'No "rules" or "rule_set" found in the file.',
+ },
+ },
+ ruleset: {
+ add: "Add Ruleset",
+ format: "Data Format",
+ interval: "Update Intervals",
+ remote: "Remote",
+ local: "Local",
+ },
+ dns: {
+ add: "Add Dns Server",
+ title: "Dns Servers",
+ final: "Final",
+ server: "Server",
+ firstServer: "First Server",
+ cacheCapacity: "Cache Capacity",
+ disableCache: "Disable Cache",
+ disableExpire: "Disable Expire",
+ independentCache: "Independent Cache",
+ reverseMapping: "Reverse Mapping",
+ domainStrategy: "Domain Strategy",
+ local: {
+ preferGo: "Prefer Go",
+ },
+ rule: {
+ add: "Add Dns Rule",
+ title: "Dns Rules",
+ inet4Range: "IPv4 Range",
+ inet6Range: "IPv6 Range",
+ acceptDefault: "Accept Default Resolvers",
+ action: {
+ title: "Action",
+ route: "Route",
+ routeOptions: "Route Options",
+ reject: "Reject",
+ predefined: "Predefined",
+ rewriteTtl: "Rewrite TTL",
+ clientSubnet: "Client Subnet",
+ rcode: "Response Code",
+ rcodes: {
+ noError: "Ok",
+ formerr: "Bad request",
+ servFail: "Server failure",
+ nxDomain: "Not found",
+ refused: "Refused",
+ notImp: "Not Implemented",
+ },
+ answer: "Answers",
+ ns: "Nameservers",
+ extra: "Extra",
+ },
+ }
+ },
+ basic: {
+ log: {
+ title: "Logs",
+ level: "Level",
+ output: "Output",
+ timestamp: "Enable Timestamp",
+ },
+ routing: {
+ title: "Routing",
+ defaultOut: "Default Outbound",
+ defaultIf: "Default NIC",
+ defaultRm: "Default Routing Mark",
+ defaultDns: "Default DNS Resolver",
+ autoBind: "Auto Bind NIC",
+ },
+ exp: {
+ storeFakeIp: "Store Fake IP",
+ extController: "External Controller",
+ extUi: "External UI",
+ extUiDownloadUrl: "UI Download URL",
+ extUiDownloadDetour: "UI Download detour",
+ secret: "Secret",
+ defaultMode: "Default Mode",
+ allowOrigin: "Allow Origin",
+ allowPrivate: "Allow Private Network",
+ },
+ },
+ tls: {
+ enable: "Enable TLS",
+ usePath: "Use Path",
+ useText: "Use Text",
+ certPath: "Certificate File Path",
+ keyPath: "Key File Path",
+ cert: "Certificate",
+ key: "Key",
+ options: "TLS Options",
+ minVer: "Minimum Version",
+ maxVer: "Maximum Version",
+ cs: "Cipher suits",
+ privKey: "Private Key",
+ pubKey: "Public Key",
+ disableSni: "Disable SNI",
+ insecure: "Allow Insecure",
+ fragment: "Fragment",
+ fragmentDelay: "Fragment Fallback Delay",
+ recordFragment: "Multiple records Fragmentation",
+ store: "Root Store",
+ ktls: "Kernel TLS",
+ kernelTx: "TX",
+ kernelRx: "RX",
+ queryServerName: "ECH Query Server Name",
+ acme: {
+ options: "ACME Options",
+ dataDir: "Data Directory",
+ defaultDomain: "Default Domain",
+ disableChallenges: "Disable Challenges",
+ httpChallenge: "Disable HTTP Challenge",
+ tlsChallenge: "Disable TLS Challenge",
+ altPorts: "Alternative Ports",
+ altHport: "Alternative HTTP Port",
+ altTport: "Alternative TLS Port",
+ caProvider: "CA Provider",
+ customCa: "Custom CA Provider",
+ extAcc: "External Account",
+ dns01: "DNS01 Challenge",
+ dns01Provider: "DNS01 Challenge Provider",
+ dns01Params: {
+ api_token: "API Token",
+ zone_token: "Zone Token",
+ access_key_id: "Access Key ID",
+ access_key_secret: "Access Key Secret",
+ region_id: "Region ID",
+ security_token: "Security Token",
+ username: "Username",
+ password: "Password",
+ subdomain: "Subdomain",
+ server_url: "Server URL",
+ },
+ },
+ },
+ stats: {
+ upload: "Upload",
+ download: "Download",
+ volume: "Volume",
+ usage: "Usage",
+ enable: "Enable Statistics",
+ graphTitle: "Traffic Chart",
+ B: "B",
+ KB: "KB",
+ MB: "MB",
+ GB: "GB",
+ TB: "TB",
+ PB: "PB",
+ p: "p",
+ Kp: "Kp",
+ Mp: "Mp",
+ Gp: "Gp",
+ Mbps: "Mbps",
+ },
+ date: {
+ expiry: "Expiry",
+ expired: "Expired",
+ d: "d",
+ h: "h",
+ m: "m",
+ s: "s",
+ ms: "ms",
+ },
+}
diff --git a/frontend/src/locales/fa.ts b/frontend/src/locales/fa.ts
new file mode 100644
index 0000000..ad6072d
--- /dev/null
+++ b/frontend/src/locales/fa.ts
@@ -0,0 +1,635 @@
+export default {
+ success: "موفق",
+ failed: "خطا",
+ enable: "فعال",
+ disable: "غیرفعال",
+ none: "هیچ",
+ all: "همه",
+ loading: "در حال بارگذاری...",
+ confirm: "آیا مطمئن هستید ؟",
+ yes: "بله",
+ no: "خیر",
+ unlimited: "نامحدود",
+ type: "مدل",
+ protocol: "پروتکل",
+ submit: "تایید",
+ reset: "ریست",
+ now: "اکنون",
+ network: "شبکه",
+ copyToClipboard: "کپی در حافظه",
+ noData: "بدون داده!",
+ invalidLogin: "ورود نامعتبر!",
+ online: "آنلاین",
+ version: "نسخه",
+ email: "ایمیل",
+ commaSeparated: "(جداشده با کاما)",
+ count: "تعداد",
+ template: "الگو",
+ editor: "ویرایشگر",
+ error: {
+ dplData: "داده تکراری",
+ core: "خطا در سینگباکس",
+ invalidData: "داده نامعتبر",
+ },
+ theme: {
+ light: "روشن",
+ dark: "تیره",
+ system: "سیستم",
+ },
+ pages: {
+ login: "ورود",
+ home: "خانه",
+ inbounds: "ورودیها",
+ outbounds: "خروجیها",
+ endpoints: "درگاهها",
+ services: "خدمات",
+ clients: "کاربران",
+ rules: "قوانین",
+ tls: "رمزنگاریها",
+ basics: "ترازها",
+ dns: "DNS",
+ admins: "ادمینها",
+ settings: "پیکربندی",
+ },
+ main: {
+ tiles: "کاشیها",
+ gauges: "سنجشها",
+ charts: "نمودارها",
+ infos: "دادهها",
+ gauge: {
+ cpu: "سنجش پردازنده",
+ mem: "سنجش حافظه",
+ dsk: "سنجش دیسک",
+ swp: "سنجش Swap",
+ },
+ chart: {
+ cpu: "نمودار پردازنده",
+ mem: "نمودار حافظه",
+ net: "ترافیک شبکه",
+ pnet: "بستههای شبکه",
+ dio: "نمودار دیسک",
+ },
+ info: {
+ sys: "دادههای سیستم",
+ sbd: "دادههای سینگباکس",
+ host: "نام",
+ cpu: "پردازنده",
+ core: "هسته",
+ uptime: "مدت",
+ startupTime: "زمان راهاندازی",
+ threads: "نخها",
+ memory: "حافظه",
+ running: "اجرا"
+ },
+ backup: {
+ title: "پشتیبانگیری و بازیابی",
+ backup: "دریافت پشتیبان",
+ restore: "بازیابی پشتیبان",
+ exclStats: "بدون گرافها",
+ exclChanges: "بدون تغییرات",
+ sbConfig: "دریافت پیکربندی سینگباکس",
+ },
+ stats: {
+ title: "آمار و تعداد",
+ totalUsage: "مجموع مصرف",
+ },
+ },
+ objects: {
+ inbound: "ورودی",
+ client: "کاربر",
+ outbound: "خروجی",
+ endpoint: "درگاه",
+ config: "پیکربندی",
+ rule: "قانون",
+ ruleset: "مجموعه",
+ service: "خدمت",
+ dnsserver: "سرور DNS",
+ dnsrule: "قانون DNS",
+ user: "کاربر",
+ tag: "برچسب",
+ listen: "گوشدادن",
+ dial: "تماس",
+ tls: "رمزنگاری",
+ multiplex: "تسهیم",
+ transport: "انتقال",
+ headers: "سربرگها",
+ key: "نام",
+ value: "مقدار",
+ },
+ actions: {
+ action: "فرمان",
+ add: "ایجاد",
+ addbulk: "ایجاد انبوه",
+ editbulk: "ویرایش انبوه",
+ delbulk: "حذف انبوه",
+ new: "جدید",
+ edit: "ویرایش",
+ del: "حذف",
+ clone: "شبیهسازی",
+ test: "تست",
+ testAll: "تست همه",
+ save: "ذخیره",
+ update: "بروزرسانی",
+ submit: "ارسال",
+ set: "تنظیم",
+ generate: "تولید",
+ disable: "غیرفعال",
+ close: "بستن",
+ restartApp: "ریستارت پنل",
+ restartSb: "ریستارت سینگباکس",
+ },
+ login: {
+ title: "ورود",
+ username: "نام کاربری",
+ unRules: "نام کاربری نمیتواند خالی باشد",
+ password: "کلمه عبور",
+ pwRules: "کلمه عبور نمیتواند خالی باشد",
+ },
+ menu: {
+ logout: "خروج",
+ },
+ admin: {
+ changeCred: "ویرایش دادهها",
+ oldPass: "رمز کنونی",
+ newUname: "نام کاربری جدید",
+ newPass: "رمز جدید",
+ lastLogin: "آخرین ورود",
+ date: "تاریخ",
+ time: "ساعت",
+ changes: "تغییرات",
+ actor: "مجری",
+ key: "کلید",
+ action: "عمل",
+ api: {
+ title: "توکنهای API",
+ msg: "لطفا توکن زیر را کپی کنید و در یک مکان امن نگهدارید. این توکن دیگر نمایش داده نخواهد شد.",
+ token: "توکن",
+ },
+ },
+ setting: {
+ interface: "نما",
+ sub: "سابسکریپشن",
+ addr: "آدرس",
+ port: "پورت",
+ webPath: "مسیر پایه",
+ domain: "دامنه",
+ sslKey: "مسیر فایل کلید",
+ sslCert: "مسیر فایل گواهی",
+ webUri: "آدرس نهایی پنل",
+ sessionAge: "بیشینه زمان لاگین ماندن",
+ trafficAge: "بیشینه زمان ذخیره ترافیک",
+ timeLoc: "منطقه زمانی",
+ subEncode: "رمزگذاری",
+ subInfo: "نمایش اطلاعات کاربر",
+ path: "مسیر پیشفرض",
+ update: "زمان بروزرسانی خودکار",
+ subUri: "آدرس نهایی سابسکریپشن",
+ jsonSub: "سابسکریپشن JSON",
+ toDirect: "هدایت مستقیم",
+ toBlock: "بستن مسیر",
+ timestamp: "نمایش زمان",
+ globalDns: "DNS کلی",
+ directDns: "DNS مستقیم",
+ toDirectDns: "هدایت به DNS مستقیم",
+ jsonSubOptions: "گزینههای دیگر",
+ excludePkg: "نرمافزارهای استثنا",
+ clashSub: "سابسکریپشن CLASH",
+ mixedPort: "ورودی Mixed",
+ tun: "ورودی TUN",
+ },
+ client: {
+ name: "نام",
+ desc: "شرح",
+ group: "گروه",
+ inboundTags: "برچسبهای ورودی",
+ basics: "پایه",
+ config: "تنظیم",
+ links: "لینکها",
+ external: "لینک خارجی",
+ sub: "سابسکریپشن خارجی",
+ delayStart: "تأخیر شروع",
+ autoReset: "بازنشانی خودکار",
+ resetDays: "روزهای بازنشانی",
+ nextReset: "بازنشانی بعدی",
+ },
+ bulk: {
+ order: "ترتیب",
+ random: "تصادفی",
+ changeLimits: "تغییر محدودیتها",
+ addInbounds: "افزودن اینباندها",
+ removeInbounds: "حذف اینباندها",
+ addDays: "افزودن روز",
+ addVolume: "افزودن حجم",
+ },
+ types: {
+ un: "نام کاربری",
+ pw: "رمز",
+ direct: {
+ overrideAddr: "جایگزین آدرس",
+ overridePort: "جایگزین پورت",
+ },
+ hy: {
+ obfs: "رمز مبهم کننده",
+ auth: "رمز احراز هویت",
+ hyOptions: "گزینههای Hysteria",
+ hy2Options: "گزینههای Hysteria2",
+ ignoreBw: "نادیدهگرفتن پهنایباند کاربر",
+ },
+ shdwTls: {
+ hs: "سرور دستتکانی",
+ addHS: "افزودن سرور دستتکانی",
+ },
+ ssh: {
+ passphrase: "عبارت عبور",
+ hostKey: "کلیدهای هاستها",
+ algorithm: "الگوریتمها",
+ clientVer: "نسخه کلاینت",
+ options: "گزینههای SSH",
+ },
+ tor: {
+ execPath: "مسیر فایل اجرایی",
+ dataDir: "پوشه دادهها",
+ extArgs: "آرگومانهای اضافی",
+ },
+ tuic: {
+ congControl: "کنترل ازدحام",
+ authTimeout: "مهلت احراز هویت",
+ hb: "ضربان قلب",
+ },
+ tun: {
+ addr: "آدرسها",
+ ifName: "نام اینترفیس",
+ excludeMptcp: "حذف MPTCP",
+ fallbackRuleIndex: "شاخص قوانین fallback در iproute2",
+ },
+ vless: {
+ flow: "جریان",
+ udpEnc: "کدگذاری بسته UDP",
+ },
+ vmess: {
+ security: "امنیت",
+ globalPadding: "لایه بندی کلی",
+ authLen: "رمزگذاری اندازه بسته",
+ },
+ wg: {
+ privKey: "کلید خصوصی",
+ pubKey: "کلید عمومی همتا",
+ psk: "کلید مشترک",
+ localIp: "آدرسهای محلی",
+ worker: "عملگرها",
+ ifName: "نام اینترفیس",
+ sysIf: "استفاده از اینترفیس سیستم",
+ options: "گزینههای Wireguard",
+ allowedIp: "آدرسهای مجاز",
+ peer: "همتا",
+ peers: "همتاها",
+ },
+ lb: {
+ defaultOut: "خروجی پیشفرض",
+ interruptConn: "قطع ارتباط موجود",
+ testUrl: "URL تست",
+ interval: "فاصله زمانی",
+ tolerance: "تحمل",
+ urlTestOptions: "گزینههای URLTest"
+ },
+ ts: {
+ options: "گزینههای Tailscale",
+ stateDir: "مسیر پوشه وضعیت",
+ authKey: "کلید احراز هویت",
+ relayServer: "سرور رله",
+ relayServerPort: "پورت سرور رله",
+ relayEndpoints: "نقاط انتهایی ثابت رله",
+ systemInterface: "رابط سیستمی",
+ sysIfName: "نام رابط",
+ sysIfMtu: "MTU رابط",
+ controlUrl: "درگاه کنترل",
+ ephemeral: "حالت موقتی",
+ hostname: "نام هاست",
+ acceptRoutes: "پذیرش مسیرها",
+ exitNode: "درگاه خروج",
+ allowLanAccess: "دسترسی به LAN",
+ advRoutes: "تبلیغ مسیرها",
+ advExitNode: "تبلیغ درگاه خروج",
+ udpTimeout: "مهلت UDP",
+ },
+ ocm: {
+ credentialPath: "مسیر اعتبارنامه",
+ usagesPath: "مسیر آمار استفاده",
+ users: "کاربران",
+ userName: "نام",
+ userToken: "توکن",
+ },
+ ccm: {
+ credentialPath: "مسیر اعتبارنامه",
+ usagesPath: "مسیر آمار استفاده",
+ users: "کاربران",
+ userName: "نام",
+ userToken: "توکن",
+ },
+ derp: {
+ configPath: "مسیر پیکربندی",
+ verifyClientEndpoint: "درگاه تایید کلاینت",
+ verifyClientUrl: "URL تایید کلاینت",
+ meshWith: "شبکه مش",
+ meshPsk: "کلید پیشاشتراکگذاری",
+ meshPskFile: "فایل کلید پیشاشتراکگذاری",
+ stun: "سرور STUN",
+ options: "گزینههای DERP",
+ },
+ naive: {
+ insecureConcurrency: "همزمانی ناامن",
+ quic: "QUIC",
+ quicCongestion: "کنترل تراکم QUIC",
+ udpOverTcp: "UDP روی TCP",
+ },
+ anytls: {
+ idleInterval: "فاصله بررسی جلسه غیرفعال",
+ idleTimeout: "زمان پایان جلسه غیرفعال",
+ minIdle: "حداقل جلسات غیرفعال",
+ },
+ },
+ in: {
+ addr: "آدرس",
+ port: "پورت",
+ ssMethod: "روش",
+ ssManageable: "قابل مدیریت",
+ sSide: "سمت سرور",
+ cSide: "سمت کاربر",
+ multiDomain: "دامنه چندگانه",
+ remark: "شرح",
+ mdOption: "گزینههای دامنه چندگانه",
+ },
+ listen: {
+ options: "گزینههای گوشدادن",
+ tcpOptions: "گزینههای TCP",
+ udpOptions: "گزینههای UDP",
+ detour: "انحراف مسیر",
+ detourText: "ارسال به ورودی دیگر",
+ disableTcpKeepAlive: "غیرفعالسازی TCP Keep Alive",
+ tcpKeepAlive: "TCP Keep Alive",
+ tcpKeepAliveInterval: "فاصله TCP Keep Alive",
+ },
+ dial: {
+ bindIf: "اتصال به کارت شبکه",
+ bindIp4: "اتصال به IPv4",
+ bindIp6: "اتصال به IPv6",
+ bindNoPort: "اتصال آدرس بدون پورت",
+ reuseAddr: "استفاده مجدد از آدرس",
+ connTimeout: "مهلت ارتباط",
+ disableTcpKeepAlive: "غیرفعالسازی TCP Keep Alive",
+ tcpKeepAlive: "TCP Keep Alive",
+ tcpKeepAliveInterval: "فاصله TCP Keep Alive",
+ domainResolver: "حلکننده دامنه",
+ options: "گزینههای تماس",
+ detourText: "ارسال به خروجی دیگر",
+ },
+ transport: {
+ enable: "فعالسازی انتقال",
+ host: "دامنه",
+ hosts: "دامنهها",
+ path: "مسیر",
+ httpMethod: "متد درخواست",
+ idleTimeout: "مهلت بیکاری",
+ pingTimeout: "مهلت پینگ",
+ grpcServiceName: "نام سرویس",
+ grpcPws: "حفظ ارتباط بدون دیتا",
+ },
+ mux: {
+ enable: "فعالسازی تسهیم",
+ maxConn: "بیشینه ارتباطات",
+ minStr: "کمینه استریم",
+ maxStr: "بیشینه استریم",
+ padding: "فقط با پدینگ",
+ enableBrutal: "فعالسازی شدت",
+ },
+ out: {
+ addr: "آدرس سرور",
+ port: "پورت سرور",
+ addUrlTest: "افزودن URLTest",
+ delay: "تأخیر"
+ },
+ rule: {
+ add: "ایجاد قانون",
+ simple: "ساده",
+ logical: "منطقی",
+ mode: "حالت",
+ invert: "برعکس",
+ ipVer: "نسخه IP",
+ domain: "دامنهها",
+ domainSufix: "پسوندهای دامنه",
+ domainKw: "کلمات کلیدی دامنه",
+ domainRgx: "رجکس دامنه",
+ ip: "محدودههای IP",
+ privateIp: "آدرس های IP نامعتبر",
+ port: "پورتها",
+ portRange: "محدودههای پورت",
+ srcCidr: "محدودههای آدرس IP مبدا",
+ srcPrivateIp: "آدرسهای IP مبدا نامعتبر",
+ srcPort: "پورتهای مبدا",
+ srcPortRange: "محدوده پورتهای منبع",
+ ruleset: "مجموعهها",
+ rulesetMatchSrc: "تطابق آدرسهای مبدا با مجموعه قوانین",
+ preferredBy: "ترجیح داده شده توسط",
+ interfaceAddr: "آدرس رابط شبکه",
+ options: "گزینههای قوانین",
+ domainRules: "دامنه/آدرس",
+ srcIpRules: "آدرس مبدا",
+ srcPortRules: "پورت مبدا",
+ udpDisableDomainUnmapping: "عدم تبدیل مسیریابی دامنه",
+ udpConnect: "اتصال UDP",
+ udpTimeout: "مهلت UDP",
+ method: "روش",
+ noDrop: "عدم رهاکردن",
+ sniffer: "شنود کننده",
+ timeout: "مهلت",
+ strategy: "استراتژی",
+ etaHint: "یک آیتم برای هر خط وارد کنید. خطوط خالی و تکراری حذف خواهند شد.",
+ import: {
+ title: "ورود دستهای مجموعههای قانون",
+ rulesTitle: "ورود قوانین",
+ urlsHint: "در هر خط یک URL. برچسب از نام فایل بدون پسوند تعیین میشود.",
+ fileHint: "فایل .txt حاوی URLها را بارگذاری کنید؛ در هر خط یک مورد.",
+ jsonHint: "یک شی JSON با آرایههای rules و/یا rule_set قرار دهید. میتوانید کل بلوک «route»: {'{'}...{'}'} یا فقط محتوایش را بچسبانید.",
+ fileJsonHint: "فایل .json حاوی بلوک route را بارگذاری کنید.",
+ urlHint: "لینک مستقیم فایل JSON را وارد کنید (مثلاً لینک raw گیتهاب).",
+ preview: "پیشنمایش",
+ skipped: "از قبل وجود دارند، بهصورت خاکستری نمایش داده شدهاند",
+ conflict: "تعارض شناسایی شد",
+ merge: "ادغام — افزودن قوانین واردشده (رد برچسب تکراری مجموعهها)",
+ replace: "جایگزینی — حذف قوانین و مجموعههای موجود و ورود دوباره",
+ pasteUrls: "چسباندن URL",
+ uploadTxt: "بارگذاری .txt",
+ uploadFile: "بارگذاری فایل",
+ fromUrl: "از طریق لینک",
+ selectTxt: "انتخاب فایل .txt",
+ selectJson: "انتخاب فایل .json",
+ parse: "تجزیه",
+ conflictDesc: "پیکربندی از قبل {rules} قانون و {rulesets} مجموعه قانون دارد. عمل را انتخاب کنید:",
+ finalOutbound: "خروجی پیشفرض (final)",
+ applyFinal: "اعمال بهعنوان خروجی پیشفرض",
+ errNoArrays: 'هیچ آرایهٔ «rules» یا «rule_set» یافت نشد.',
+ errJsonParse: "خطای تجزیهٔ JSON: {message}
",
+ errNoArraysFetched: 'در JSON دریافتشده «rules» یا «rule_set» یافت نشد.',
+ errFetch: "خطای دریافت: {message}
",
+ errNoFile: "فایلی انتخاب نشده است.",
+ errNoArraysInFile: 'در فایل «rules» یا «rule_set» یافت نشد.',
+ },
+ },
+ ruleset: {
+ add: "ایجاد مجموعه",
+ format: "فرمت دادهها",
+ interval: "بازه بروزرسانیها",
+ remote: "راه دور",
+ local: "محلی",
+ },
+ dns: {
+ add: "ایجاد سرور DNS",
+ title: "سرورهای DNS",
+ final: "سرور نهایی",
+ server: "سرور",
+ firstServer: "سرور نخست",
+ cacheCapacity: "ظرفیت cache",
+ disableCache: "غیرفعالسازی cache",
+ disableExpire: "بدون انقضا",
+ independentCache: "استقلال cache",
+ reverseMapping: "نگاشت معکوس",
+ domainStrategy: "استراتژی دامنه",
+ local: { preferGo: "ترجیح Go" },
+ rule: {
+ add: "ایجاد قانون DNS",
+ title: "قوانین DNS",
+ inet4Range: "محدوده IPv4",
+ inet6Range: "محدوده IPv6",
+ acceptDefault: "پذیرش پیشفرض",
+ action: {
+ title: "عملیات",
+ route: "مسیریابی",
+ routeOptions: "گزینههای مسیریابی",
+ reject: "رد کردن",
+ predefined: "پیش تعریف شده",
+ rewriteTtl: "بازنویسی TTL",
+ clientSubnet: "زیر شبکه کاربر",
+ rcode: "کد جواب",
+ rcodes: {
+ noError: "درست",
+ formerr: "درخواست نامعتبر",
+ servFail: "خطای سرور",
+ nxDomain: "یافت نشد",
+ refused: "رد شده",
+ notImp: "پیادهسازی نشده",
+ },
+ answer: "پاسخها",
+ ns: "سرورهای دامنه",
+ extra: "اضافی",
+ },
+ },
+ },
+ basic: {
+ log: {
+ title: "گزارشها",
+ level: "سطح",
+ output: "خروجی",
+ timestamp: "فعالسازی ثبت زمان",
+ },
+ routing: {
+ title: "مسیریابی",
+ defaultOut: "خروجی پیشفرض",
+ defaultIf: "کارت شبکه پیشفرض",
+ defaultRm: "Routing Mark پیشفرض",
+ defaultDns: "DNS پیشفرض",
+ autoBind: "انتخاب اتوماتیک کارت شبکه",
+ },
+ exp: {
+ storeFakeIp: "ذخیره آدرسهای نامعتبر",
+ extController: "کنترلگر خارجی",
+ extUi: "رابطکاربری خارجی",
+ extUiDownloadUrl: "آدرس دانلود رابطکاربری",
+ extUiDownloadDetour: "خروجی دانلود رابطکاربری",
+ secret: "رمز",
+ defaultMode: "حالت پیشفرض",
+ allowOrigin: "اجازه از مبدا",
+ allowPrivate: "اجازه شبکه خصوصی",
+ },
+ },
+ tls: {
+ enable: "فعالسازی رمزنگاری",
+ usePath: "مسیر فایل",
+ useText: "متن گواهی",
+ certPath: "مسیر فایل گواهی",
+ keyPath: "مسیر فایل کلید",
+ cert: "گواهی",
+ key: "کلید",
+ options: "گزینههای رمزنگاری",
+ minVer: "کمینه نسخه",
+ maxVer: "بیشینه نسخه",
+ cs: "مدلهای رمزنگاری",
+ privKey: "کلید خصوصی",
+ pubKey: "کلید عمومی",
+ disableSni: "غیرفعالسازی SNI",
+ insecure: "تایید ارتباط ناامن",
+ fragment: "تکهبندی",
+ fragmentDelay: "تاخیر تکهبندی جایگزین",
+ recordFragment: "تکهبندی چندگانه",
+ store: "انبار ریشه",
+ ktls: "TLS هسته",
+ kernelTx: "ارسال",
+ kernelRx: "دریافت",
+ queryServerName: "نام سرور ECH برای جستجو",
+ acme: {
+ options: "گزینههای ACME",
+ dataDir: "مسیر دادهها",
+ defaultDomain: "دامنه پیشفرض",
+ disableChallenges: "بستن چالشها",
+ httpChallenge: "بستن چالش HTTP",
+ tlsChallenge: "بستن چالش TLS",
+ altPorts: "پورتهای جایگزین",
+ altHport: "پورت جایگزین HTTP",
+ altTport: "پورت جایگزین TLS",
+ caProvider: "فراهم کننده گواهی",
+ customCa: "فراهم کننده دیگر",
+ extAcc: "حساب خارجی",
+ dns01: "چالش DNS01",
+ dns01Provider: "فراهم کننده چالش DNS01",
+ dns01Params: {
+ api_token: "توکن API",
+ zone_token: "توکن زون",
+ access_key_id: "شناسه کلید دسترسی",
+ access_key_secret: "رمز کلید دسترسی",
+ region_id: "شناسه منطقه",
+ security_token: "توکن امنیتی",
+ username: "نام کاربری",
+ password: "رمز عبور",
+ subdomain: "زیردامنه",
+ server_url: "آدرس سرور",
+ },
+ },
+ },
+ stats: {
+ upload: "آپلود",
+ download: "دانلود",
+ volume: "حجم",
+ usage: "استفاده",
+ enable: "فعال سازی گزارش ترافیک",
+ graphTitle: "نمودار ترافیک",
+ B: "ب",
+ KB: "کب",
+ MB: "مب",
+ GB: "گب",
+ TB: "تب",
+ PB: "پب",
+ p: "پ",
+ Kp: "کپ",
+ Mp: "مپ",
+ Gp: "گپ",
+ Mbps: "مب/ث",
+ },
+ date: {
+ expiry: "انقضا",
+ expired: "منقضی",
+ d: "ر",
+ h: "س",
+ m: "د",
+ s: "ث",
+ ms: "مث",
+ }
+}
diff --git a/frontend/src/locales/index.ts b/frontend/src/locales/index.ts
new file mode 100644
index 0000000..9005f59
--- /dev/null
+++ b/frontend/src/locales/index.ts
@@ -0,0 +1,42 @@
+import { createI18n } from 'vue-i18n'
+import en from './en'
+import fa from './fa'
+import vi from './vi'
+import zhcn from './zhcn'
+import zhtw from './zhtw'
+import ru from './ru'
+
+export const i18n = createI18n({
+ legacy: false,
+ locale: localStorage.getItem("locale") ?? 'en',
+ fallbackLocale: 'en',
+ messages: {
+ en: en,
+ fa: fa,
+ vi: vi,
+ zhHans: zhcn,
+ zhHant: zhtw,
+ ru: ru
+ },
+})
+
+export const locale = (() => {
+ const l = i18n.global.locale.value
+ switch (l) {
+ case "zhHans":
+ return "zh-cn"
+ case "zhHant":
+ return "zh-tw"
+ default:
+ return l
+ }
+})()
+
+export const languages = [
+ { title: 'English', value: 'en' },
+ { title: 'فارسی', value: 'fa' },
+ { title: 'Tiếng Việt', value: 'vi' },
+ { title: '简体中文', value: 'zhHans' },
+ { title: '繁體中文', value: 'zhHant' },
+ { title: 'Русский', value: 'ru' },
+]
diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts
new file mode 100644
index 0000000..2029e43
--- /dev/null
+++ b/frontend/src/locales/ru.ts
@@ -0,0 +1,640 @@
+export default {
+ success: "успех",
+ failed: "ошибка",
+ enable: "Включить",
+ disable: "Отключить",
+ none: "Никакие",
+ all: "Все",
+ loading: "Загрузка...",
+ confirm: "Вы уверены?",
+ yes: "да",
+ no: "нет",
+ unlimited: "бесконечный",
+ type: "Тип",
+ protocol: "Протокол",
+ submit: "Отправить",
+ reset: "Сбросить",
+ now: "Сейчас",
+ network: "Сеть",
+ copyToClipboard: "Копировать в буфер обмена",
+ noData: "Нет данных!",
+ invalidLogin: "Неверный логин!",
+ online: "В сети",
+ version: "Версия",
+ email: "Электронная почта",
+ commaSeparated: "(разделено запятыми)",
+ count: "Количество",
+ template: "Шаблон",
+ editor: "Редактор",
+ error: {
+ dplData: "Дублирующие данные",
+ core: "Ошибка Sing-Box",
+ invalidData: "Неверные данные",
+ },
+ theme: {
+ light: "Светлый",
+ dark: "Темный",
+ system: "Система",
+ },
+ pages: {
+ login: "Вход",
+ home: "Главная",
+ inbounds: "Входящие",
+ outbounds: "Исходящие",
+ services: "Устройства",
+ endpoints: "Эндпоинты",
+ clients: "Клиенты",
+ rules: "Правила",
+ tls: "Настройки TLS",
+ basics: "Основы",
+ dns: "DNS",
+ admins: "Администраторы",
+ settings: "Настройки",
+ },
+ main: {
+ tiles: "Плитки",
+ gauges: "Датчики",
+ charts: "Графики",
+ infos: "Информация",
+ gauge: {
+ cpu: "Загрузка ЦП",
+ mem: "Загрузка ОЗУ",
+ dsk: "Загрузка диска",
+ swp: "Загрузка Swap",
+ },
+ chart: {
+ cpu: "Мониторинг ЦП",
+ mem: "Мониторинг ОЗУ",
+ net: "Сетевой трафик",
+ pnet: "Сетевые пакеты",
+ dio: "Мониторинг диска",
+ },
+ info: {
+ sys: "Информация о системе",
+ sbd: "Информация о Sing-Box",
+ host: "Хост",
+ cpu: "ЦП",
+ core: "Ядро",
+ uptime: "Время работы",
+ startupTime: "Время запуска",
+ threads: "Потоки",
+ memory: "Память",
+ running: "Работает"
+ },
+ backup: {
+ title: "Резервное копирование и восстановление",
+ backup: "Скачать резервную копию",
+ restore: "Восстановить резервную копию",
+ exclStats: "Исключить графики",
+ exclChanges: "Исключить изменения",
+ sbConfig: "Скачать конфигурацию Sing-Box",
+ },
+ stats: {
+ title: "Использование и количество",
+ totalUsage: "Общее использование",
+ },
+ },
+ objects: {
+ inbound: "Входящий",
+ client: "Клиент",
+ outbound: "Исходящий",
+ endpoint: "Точка входа",
+ config: "Настройки",
+ rule: "Правило",
+ ruleset: "Набор правил",
+ service: "Устройство",
+ dnsserver: "DNS сервер",
+ dnsrule: "Правило DNS",
+ user: "Пользователь",
+ tag: "Тег",
+ listen: "Прослушивание",
+ dial: "Звонок",
+ tls: "TLS",
+ multiplex: "Мультиплекс",
+ transport: "Транспорт",
+ headers: "Заголовки",
+ key: "Ключ",
+ value: "Значение",
+ },
+ actions: {
+ action: "Действие",
+ add: "Добавить",
+ addbulk: "Добавить пакетно",
+ editbulk: "Редактировать пакетно",
+ delbulk: "Удалить пакетно",
+ new: "Новый",
+ edit: "Редактировать",
+ del: "Удалить",
+ clone: "Клонировать",
+ test: "Тест",
+ testAll: "Тестировать все",
+ save: "Сохранить",
+ update: "Обновить",
+ submit: "Отправить",
+ set: "Установить",
+ generate: "Генерировать",
+ disable: "Отключить",
+ close: "Закрыть",
+ restartApp: "Перезапустить приложение",
+ restartSb: "Перезапустить Singbox",
+ apply: "Применить",
+ },
+ login: {
+ title: "Вход",
+ username: "Имя пользователя",
+ unRules: "Имя пользователя не может быть пустым",
+ password: "Пароль",
+ pwRules: "Пароль не может быть пустым",
+ },
+ menu: {
+ logout: "Выйти",
+ },
+ admin: {
+ changeCred: "Изменить учетные данные",
+ oldPass: "Текущий пароль",
+ newUname: "Новое имя пользователя",
+ newPass: "Новый пароль",
+ lastLogin: "Последний вход",
+ date: "Дата",
+ time: "Время",
+ changes: "Изменения",
+ actor: "Исполнитель",
+ key: "Ключ",
+ action: "Действие",
+ api: {
+ title: "Токены API",
+ msg: "Пожалуйста, скопируйте токен ниже и сохраните его в безопасном месте. Он не будет показан заново.",
+ token: "Токен",
+ },
+ },
+ setting: {
+ interface: "Интерфейс",
+ sub: "Подписка",
+ addr: "Адрес",
+ port: "Порт",
+ webPath: "Базовый URI",
+ domain: "Домен",
+ sslKey: "Путь к SSL ключу",
+ sslCert: "Путь к SSL сертификату",
+ webUri: "URI панели",
+ sessionAge: "Максимальная длительность сессии",
+ trafficAge: "Максимальная длительность трафика",
+ timeLoc: "Часовой пояс",
+ subEncode: "Включить кодирование",
+ subInfo: "Включить информацию о клиенте",
+ path: "Путь по умолчанию",
+ update: "Время автоматического обновления",
+ subUri: "URI подписки",
+ jsonSub: "JSON подписка",
+ toDirect: "Маршрутизация на Direct",
+ toBlock: "Маршрутизация на Block",
+ timestamp: "Метка времени",
+ globalDns: "Глобальный DNS",
+ directDns: "Прямой DNS",
+ toDirectDns: "Маршрутизация на Direct DNS",
+ jsonSubOptions: "Другие параметры",
+ excludePkg: "Исключить пакеты",
+ clashSub: "Clash подписка",
+ mixedPort: "Смешанный порт",
+ tun: "Tun инбоунд",
+ },
+ client: {
+ name: "Имя",
+ desc: "Описание",
+ group: "Группа",
+ inboundTags: "Теги входящих",
+ basics: "Основы",
+ config: "Конфигурация",
+ links: "Ссылки",
+ external: "Внешняя ссылка",
+ sub: "Внешняя подписка",
+ delayStart: "Отложенный старт",
+ autoReset: "Авто сброс",
+ resetDays: "Дней до сброса",
+ nextReset: "Следующий сброс",
+ },
+ bulk: {
+ order: "Порядок",
+ random: "Случайный",
+ changeLimits: "Изменить лимиты",
+ addInbounds: "Добавить входящие",
+ removeInbounds: "Удалить входящие",
+ addDays: "Добавить дни",
+ addVolume: "Добавить объём",
+ },
+ types: {
+ un: "Имя пользователя",
+ pw: "Пароль",
+ direct: {
+ overrideAddr: "Переопределить адрес",
+ overridePort: "Переопределить порт",
+ },
+ hy: {
+ obfs: "Обфусцированный пароль",
+ auth: "Пароль аутентификации",
+ hyOptions: "Параметры Hysteria",
+ hy2Options: "Параметры Hysteria2",
+ ignoreBw: "Игнорировать пропускную способность клиента",
+ },
+ shdwTls: {
+ hs: "Сервер рукопожатий",
+ addHS: "Добавить сервер рукопожатий",
+ },
+ ssh: {
+ passphrase: "Парольная фраза",
+ hostKey: "Ключи хоста",
+ algorithm: "Алгоритмы ключей",
+ clientVer: "Версия клиента",
+ options: "Параметры SSH",
+ },
+ tor: {
+ execPath: "Путь к исполняемому файлу",
+ dataDir: "Каталог данных",
+ extArgs: "Дополнительные аргументы",
+ },
+ tuic: {
+ congControl: "Контроль перегрузок",
+ authTimeout: "Таймаут аутентификации",
+ hb: "Сердцебиение",
+ },
+ tun: {
+ addr: "Адреса",
+ ifName: "Имя интерфейса",
+ excludeMptcp: "Исключить MPTCP",
+ fallbackRuleIndex: "Индекс правила iproute2 fallback",
+ },
+ vless: {
+ flow: "Поток",
+ udpEnc: "Кодирование UDP пакетов",
+ },
+ vmess: {
+ security: "Безопасность",
+ globalPadding: "Глобальная подкладка",
+ authLen: "Длина шифрования",
+ },
+ wg: {
+ privKey: "Приватный ключ",
+ pubKey: "Публичный ключ пира",
+ psk: "Предварительно разделенный ключ",
+ localIp: "Локальные IP",
+ worker: "Работники",
+ ifName: "Имя интерфейса",
+ sysIf: "Системный интерфейс",
+ options: "Параметры Wireguard",
+ allowedIp: "Разрешенные IP",
+ peer: "Пир",
+ peers: "Пиры",
+ },
+ lb: {
+ defaultOut: "Исходящий по умолчанию",
+ interruptConn: "Прервать существующие соединения",
+ testUrl: "Тестовый URL",
+ interval: "Интервал",
+ tolerance: "Толерантность",
+ urlTestOptions: "Параметры URLTest"
+ },
+ ts: {
+ options: "Параметры Tailscale",
+ stateDir: "Каталог состояния",
+ authKey: "Ключ аутентификации",
+ relayServer: "Сервер ретрансляции",
+ relayServerPort: "Порт сервера ретрансляции",
+ relayEndpoints: "Статические точки ретрансляции",
+ systemInterface: "Системный интерфейс",
+ sysIfName: "Имя интерфейса",
+ sysIfMtu: "MTU интерфейса",
+ controlUrl: "URL управления",
+ ephemeral: "Эфемерный",
+ hostname: "Имя хоста",
+ acceptRoutes: "Принять маршруты",
+ exitNode: "Выходной узел",
+ allowLanAccess: "Разрешить доступ LAN",
+ advRoutes: "Рекламируемые маршруты",
+ advExitNode: "Рекламируемый выходной узел",
+ udpTimeout: "Таймаут UDP",
+ },
+ ocm: {
+ credentialPath: "Путь к учетным данным",
+ usagesPath: "Путь к статистике",
+ users: "Пользователи",
+ userName: "Имя",
+ userToken: "Токен",
+ },
+ ccm: {
+ credentialPath: "Путь к учетным данным",
+ usagesPath: "Путь к статистике",
+ users: "Пользователи",
+ userName: "Имя",
+ userToken: "Токен",
+ },
+ derp: {
+ configPath: "Путь к конфигурации",
+ verifyClientEndpoint: "Проверить конечную точку клиента",
+ verifyClientUrl: "Проверить URL клиента",
+ meshWith: "Сеть с",
+ meshPsk: "Предварительно разделенный ключ",
+ meshPskFile: "Файл предварительно разделенного ключа",
+ stun: "Сервер STUN",
+ options: "Параметры DERP",
+ },
+ naive: {
+ insecureConcurrency: "Небезопасная параллельность",
+ quic: "QUIC",
+ quicCongestion: "Управление перегрузкой QUIC",
+ udpOverTcp: "UDP через TCP",
+ },
+ anytls: {
+ idleInterval: "Интервал проверки неактивных сессий",
+ idleTimeout: "Тайм-аут неактивной сессии",
+ minIdle: "Минимум неактивных сессий"
+ },
+ },
+ in: {
+ addr: "Адрес",
+ port: "Порт",
+ ssMethod: "Метод",
+ ssManageable: "Управляемый",
+ sSide: "Сторона сервера",
+ cSide: "Сторона клиента",
+ multiDomain: "Мультидомен",
+ remark: "Примечание",
+ mdOption: "Параметры мультидомена",
+ },
+ listen: {
+ options: "Параметры прослушивания",
+ tcpOptions: "Параметры TCP",
+ udpOptions: "Параметры UDP",
+ detour: "Обход",
+ detourText: "Переадресация на входящий",
+ disableTcpKeepAlive: "Отключить TCP Keep Alive",
+ tcpKeepAlive: "TCP Keep Alive",
+ tcpKeepAliveInterval: "Интервал TCP Keep Alive",
+ },
+ dial: {
+ bindIf: "Привязка к сетевому интерфейсу",
+ bindIp4: "Привязка к IPv4",
+ bindIp6: "Привязка к IPv6",
+ bindNoPort: "Привязка адреса без порта",
+ reuseAddr: "Повторное использование адреса слушателя",
+ connTimeout: "Таймаут подключения",
+ disableTcpKeepAlive: "Отключить TCP Keep Alive",
+ tcpKeepAlive: "TCP Keep Alive",
+ tcpKeepAliveInterval: "Интервал TCP Keep Alive",
+ domainResolver: "Разрешение домена",
+ options: "Параметры вызова",
+ detourText: "Переадресация на исходящий",
+ },
+ transport: {
+ enable: "Включить транспорт",
+ host: "Хост",
+ hosts: "Хосты",
+ path: "Путь",
+ httpMethod: "Метод запроса",
+ idleTimeout: "Таймаут бездействия",
+ pingTimeout: "Таймаут пинга",
+ grpcServiceName: "Имя службы",
+ grpcPws: "Разрешить без потока",
+ },
+ mux: {
+ enable: "Включить мультиплекс",
+ maxConn: "Максимальное количество соединений",
+ minStr: "Минимальное количество потоков",
+ maxStr: "Максимальное количество потоков",
+ padding: "Только подкладка",
+ enableBrutal: "Включить Brutal",
+ },
+ out: {
+ addr: "Адрес сервера",
+ port: "Порт сервера",
+ addUrlTest: "Добавить URLTest",
+ delay: "Задержка",
+ },
+ rule: {
+ add: "Добавить правило",
+ simple: "Простое",
+ logical: "Логическое",
+ mode: "Режим",
+ invert: "Инвертировать",
+ ipVer: "Версия IP",
+ domain: "Домены",
+ domainSufix: "Суффиксы доменов",
+ domainKw: "Ключевые слова домена",
+ domainRgx: "Регулярные выражения домена",
+ ip: "CIDR IP",
+ privateIp: "Недействительные диапазоны IP",
+ port: "Порты",
+ portRange: "Диапазоны портов",
+ srcCidr: "CIDR исходного IP",
+ srcPrivateIp: "Недействительные исходные IP",
+ srcPort: "Исходные порты",
+ srcPortRange: "Диапазоны исходных портов",
+ ruleset: "Наборы правил",
+ rulesetMatchSrc: "Набор правил для соответствия источника IPcidr",
+ preferredBy: "Предпочтительный исходящий",
+ interfaceAddr: "Адрес интерфейса",
+ options: "Параметры правила",
+ domainRules: "Домен/IP",
+ srcIpRules: "Источник IP",
+ srcPortRules: "Источник порта",
+ udpDisableDomainUnmapping: "Отключить перенос доменных имен",
+ udpConnect: "Подключение UDP",
+ udpTimeout: "Таймаут UDP",
+ method: "Метод",
+ noDrop: "Не сбрасывать",
+ sniffer: "Обнаружение",
+ timeout: "Таймаут",
+ strategy: "Стратегия",
+ etaHint: "По одной записи в строке. Пустые строки и дубликаты игнорируются.",
+ import: {
+ title: "Массовый импорт наборов правил",
+ rulesTitle: "Импорт правил",
+ urlsHint: "По одному URL в строке. Тег определяется из имени файла без расширения.",
+ fileHint: "Загрузите .txt файл с URL-адресами, по одному в строке.",
+ jsonHint: "Вставьте JSON-объект с массивами rules и/или rule_set. Можно вставить весь блок \"route\": {'{'}...{'}'} или только его содержимое.",
+ fileJsonHint: "Загрузите .json файл с блоком route.",
+ urlHint: "Укажите прямую ссылку на JSON-файл (например, raw-ссылку GitHub).",
+ preview: "Предпросмотр",
+ skipped: "уже существуют, показаны серым",
+ conflict: "Обнаружены конфликты",
+ merge: "Объединить — добавить импортируемые правила (пропустить дубликаты тегов наборов)",
+ replace: "Заменить — удалить существующие правила и наборы, импортировать заново",
+ pasteUrls: "Вставить URL",
+ uploadTxt: "Загрузить .txt",
+ uploadFile: "Загрузить файл",
+ fromUrl: "По ссылке",
+ selectTxt: "Выберите .txt файл",
+ selectJson: "Выберите .json файл",
+ parse: "Разобрать",
+ conflictDesc: "В конфиге уже есть {rules} правил и {rulesets} наборов правил. Выберите действие:",
+ finalOutbound: "Outbound по умолчанию (final)",
+ applyFinal: "Применить как outbound по умолчанию",
+ errNoArrays: 'Массивы "rules" или "rule_set" не найдены.',
+ errJsonParse: "Ошибка разбора JSON: {message}",
+ errNoArraysFetched: 'В полученном JSON нет "rules" или "rule_set".',
+ errFetch: "Ошибка загрузки: {message}",
+ errNoFile: "Файл не выбран.",
+ errNoArraysInFile: 'В файле нет "rules" или "rule_set".',
+ },
+ },
+ ruleset: {
+ add: "Добавить набор правил",
+ format: "Формат данных",
+ interval: "Интервалы обновления",
+ remote: "Удаленный",
+ local: "Локальный",
+ },
+ dns: {
+ add: "Добавить DNS сервер",
+ title: "DNS серверы",
+ final: "Итоговый",
+ server: "Сервер",
+ firstServer: "Первый сервер",
+ cacheCapacity: "Вместимость кэша",
+ disableCache: "Отключить кэш",
+ disableExpire: "Отключить истечение",
+ independentCache: "Независимый кэш",
+ reverseMapping: "Обратное отображение",
+ domainStrategy: "Стратегия домена",
+ local: { preferGo: "Предпочитать Go" },
+ rule: {
+ add: "Добавить правило DNS",
+ title: "Правила DNS",
+ inet4Range: "Диапазон IPv4",
+ inet6Range: "Диапазон IPv6",
+ acceptDefault: "Принять резолверы по умолчанию",
+ action: {
+ title: "Действие",
+ route: "Маршрутизация",
+ routeOptions: "Параметры маршрутизации",
+ reject: "Отклонить",
+ predefined: "Предопределенные",
+ rewriteTtl: "Перезаписать TTL",
+ clientSubnet: "Подсеть клиента",
+ rcode: "Код ответа",
+ rcodes: {
+ noError: "ОК",
+ formerr: "Неверный запрос",
+ servFail: "Сбой сервера",
+ nxDomain: "Не найдено",
+ refused: "Отклонено",
+ notImp: "Не реализовано"
+ },
+ answer: "Ответ",
+ ns: "Серверы имён",
+ extra: "Дополнительные",
+ },
+ }
+ },
+ basic: {
+ log: {
+ title: "Журналы",
+ level: "Уровень",
+ output: "Вывод",
+ timestamp: "Включить метку времени",
+ },
+ routing: {
+ title: "Маршрутизация",
+ defaultOut: "Исходящий по умолчанию",
+ defaultIf: "Сетевой интерфейс по умолчанию",
+ defaultRm: "Маршрут по умолчанию",
+ defaultDns: "DNS по умолчанию",
+ autoBind: "Автопривязка сетевого интерфейса",
+ },
+ exp: {
+ storeFakeIp: "Хранить поддельный IP",
+ extController: "Внешний контроллер",
+ extUi: "Внешний интерфейс",
+ extUiDownloadUrl: "URL загрузки интерфейса",
+ extUiDownloadDetour: "Обход загрузки интерфейса",
+ secret: "Секрет",
+ defaultMode: "Режим по умолчанию",
+ allowOrigin: "Разрешить источник",
+ allowPrivate: "Разрешить частную сеть"
+ },
+ },
+ tls: {
+ enable: "Включить TLS",
+ usePath: "Использовать путь",
+ useText: "Использовать текст",
+ certPath: "Путь к файлу сертификата",
+ keyPath: "Путь к файлу ключа",
+ cert: "Сертификат",
+ key: "Ключ",
+ options: "Параметры TLS",
+ minVer: "Минимальная версия",
+ maxVer: "Максимальная версия",
+ cs: "Наборы шифров",
+ privKey: "Приватный ключ",
+ pubKey: "Публичный ключ",
+ disableSni: "Отключить SNI",
+ insecure: "Разрешить небезопасное",
+ fragment: "Фрагментация",
+ fragmentDelay: "Задержка фрагментации",
+ recordFragment: "Фрагментация записей",
+ store: "Корневое хранилище",
+ ktls: "Ядро TLS",
+ kernelTx: "TX",
+ kernelRx: "RX",
+ queryServerName: "ECH имя сервера для запроса",
+ acme: {
+ options: "Параметры ACME",
+ dataDir: "Каталог данных",
+ defaultDomain: "Домен по умолчанию",
+ disableChallenges: "Отключить вызовы",
+ httpChallenge: "Отключить HTTP вызов",
+ tlsChallenge: "Отключить TLS вызов",
+ altPorts: "Альтернативные порты",
+ altHport: "Альтернативный HTTP порт",
+ altTport: "Альтернативный TLS порт",
+ caProvider: "Поставщик CA",
+ customCa: "Пользовательский поставщик CA",
+ extAcc: "Внешний аккаунт",
+ dns01: "DNS01 вызов",
+ dns01Provider: "Поставщик DNS01 вызова",
+ dns01Params: {
+ api_token: "API Токен",
+ zone_token: "Токен зоны",
+ access_key_id: "ID ключа доступа",
+ access_key_secret: "Секрет ключа доступа",
+ region_id: "ID региона",
+ security_token: "Токен безопасности",
+ username: "Имя пользователя",
+ password: "Пароль",
+ subdomain: "Поддомен",
+ server_url: "URL сервера",
+ },
+ },
+ },
+ stats: {
+ upload: "Загрузка",
+ download: "Скачивание",
+ volume: "Объем",
+ usage: "Использование",
+ enable: "Включить статистику",
+ graphTitle: "График трафика",
+ B: "Б",
+ KB: "КБ",
+ MB: "МБ",
+ GB: "ГБ",
+ TB: "ТБ",
+ PB: "ПБ",
+ p: "п",
+ Kp: "Кп",
+ Mp: "Мп",
+ Gp: "Гп",
+ Mbps: "Мб/с",
+ },
+ date: {
+ expiry: "Срок действия",
+ expired: "Истек",
+ d: "д",
+ h: "ч",
+ m: "м",
+ s: "с",
+ ms: "мс",
+ },
+}
+
+
+
+
diff --git a/frontend/src/locales/vi.ts b/frontend/src/locales/vi.ts
new file mode 100644
index 0000000..11cbaa2
--- /dev/null
+++ b/frontend/src/locales/vi.ts
@@ -0,0 +1,635 @@
+export default {
+ success: "Thành công",
+ failed: "Thất bại",
+ enable: "Kích hoạt",
+ disable: "Vô hiệu hóa",
+ none: "Không",
+ all: "Tất cả",
+ loading: "Đang tải...",
+ confirm: "Bạn chắc chắn chứ?",
+ yes: "có",
+ no: "không",
+ unlimited: "vô hạn",
+ type: "Loại",
+ protocol: "Giao thức",
+ submit: "Gửi",
+ reset: "Đặt lại",
+ now: "Hiện tại",
+ network: "Mạng",
+ copyToClipboard: "Sao chép vào clipboard",
+ noData: "Không có dữ liệu!",
+ invalidLogin: "Đăng nhập không hợp lệ!",
+ online: "Trực tuyến",
+ version: "Phiên bản",
+ email: "Email",
+ commaSeparated: "(được phân tách bằng dấu phẩy)",
+ count: "Đếm",
+ template: "Mẫu",
+ editor: "Bản sử dụng",
+ error: {
+ dplData: "Dữ liệu trùng lặp",
+ core: "Lỗi Sing-Box",
+ invalidData: "Dữ liệu khỏ hợp lệ",
+ },
+ theme: {
+ light: "Nhật",
+ dark: "Xanh",
+ system: "Phòng bán",
+ },
+ pages: {
+ login: "Đăng nhập",
+ home: "Trang chủ",
+ inbounds: "Đầu Vào",
+ outbounds: "Đầu ra",
+ services: "Thiết bị",
+ endpoints: "Câu hình",
+ clients: "Khách hàng",
+ rules: "Quy tắc",
+ tls: "Cài đặt TLS",
+ basics: "Cơ bản",
+ dns: "DNS",
+ admins: "Quản trị viên",
+ settings: "Cài đặt",
+ },
+ main: {
+ tiles: "OHB",
+ gauges: "Đồng hồ đo",
+ charts: "Biểu đồ",
+ infos: "Thông tin",
+ gauge: {
+ cpu: "Đồng hồ CPU",
+ mem: "Đồng hồ RAM",
+ dsk: "Đồng hồ Disk",
+ swp: "Đồng hồ Swap",
+ },
+ chart: {
+ cpu: "Máy theo dõi CPU",
+ mem: "Máy theo dõi RAM",
+ net: "Băng thông mạng",
+ pnet: "Gói mạng",
+ dio: "Disk I/O",
+ },
+ info: {
+ sys: "Thông tin hệ thống",
+ sbd: "Thông tin Sing-Box",
+ host: "Máy chủ",
+ cpu: "CPU",
+ core: "Nhân",
+ uptime: "Thời gian hoạt động",
+ startupTime: "Thời gian khởi động",
+ threads: "Luồng",
+ memory: "Bộ nhớ",
+ running: "Đang chạy"
+ },
+ backup: {
+ title: "Sao lưu và khôi phục",
+ backup: "Tải xuống bản sao lưu",
+ restore: "Khôi phục bản sao lưu",
+ exclStats: "Loại trừ các biểu đồ",
+ exclChanges: "Loại trừ các thay đổi",
+ sbConfig: "Tải xuống cấu hình Sing-Box",
+ },
+ stats: {
+ title: "Sử dụng và số lượng",
+ totalUsage: "Tổng sử dụng",
+ },
+ },
+ objects: {
+ inbound: "Đầu Vào",
+ client: "Máy Khách hàng",
+ outbound: "Đầu Ra",
+ endpoint: "Điểm cuối",
+ config: "Câu hình",
+ rule: "Quy tắc",
+ ruleset: "Bộ quy tắc",
+ service: "Dịch vụ",
+ dnsserver: "Máy chủ DNS",
+ dnsrule: "Quy tắc DNS",
+ user: "Người dùng",
+ tag: "Thẻ",
+ listen: "Nghe",
+ dial: "Quay số",
+ tls: "TLS",
+ multiplex: "Ghép đa truyền thông ",
+ transport: "Giao thông",
+ headers: "Tiêu đề",
+ key: "Chìa khóa",
+ value: "Giá trị",
+ },
+ actions: {
+ action: "Hành động",
+ add: "Thêm",
+ addbulk: "Thêm Hàng loạt",
+ editbulk: "Chỉnh sửa hàng loạt",
+ delbulk: "Xóa hàng loạt",
+ new: "Mới",
+ edit: "Chỉnh sửa",
+ del: "Xóa",
+ clone: "Nhân bản",
+ test: "Kiểm tra",
+ testAll: "Kiểm tra tất cả",
+ save: "Lưu",
+ update: "Cập nhật",
+ submit: "Gửi",
+ set: "Đặt",
+ generate: "Tạo ra",
+ disable: "Vô hiệu hóa",
+ close: "Đóng",
+ restartApp: "Khởi động lại ứng dụng",
+ restartSb: "Khởi động lại Singbox",
+ },
+ login: {
+ title: "Đăng nhập",
+ username: "Tên người dùng",
+ unRules: "Tên người dùng không thể trống",
+ password: "Mật khẩu",
+ pwRules: "Mật khẩu không thể trống",
+ },
+ menu: {
+ logout: "Đăng xuất",
+ },
+ admin: {
+ changeCred: "Thay đổi thông tin đăng nhập",
+ oldPass: "Mật khẩu hiện tại",
+ newUname: "Tên người dùng mới",
+ newPass: "Mật khẩu mới",
+ lastLogin: "Lân đăng nhập cuôi",
+ date: "Ngày",
+ time: "Thời gian",
+ changes: "Thay đổi",
+ actor: "Diễn viên",
+ key: "Khóa",
+ action: "Hành động",
+ api: {
+ title: "Mã thông báo API",
+ msg: "Vui lòng sao chép mã thông báo bên dưới và lưu trữ nó ở nơi an toàn. Nó sẽ không được hiển thị lại.",
+ token: "Mã thông báo"
+ },
+ },
+ setting: {
+ interface: "Giao diện",
+ sub: "Đăng ký",
+ addr: "Địa chỉ",
+ port: "Cổng",
+ webPath: "Đường dẫn gốc",
+ domain: "Miền",
+ sslKey: "Đường dẫn khóa SSL",
+ sslCert: "Đường dẫn chứng chỉ SSL",
+ webUri: "URI bảng điều khiển",
+ sessionAge: "Tuổi tối đa của phiên",
+ trafficAge: "Tuổi lưu thông tối đa",
+ timeLoc: "Vị trí múi giờ",
+ subEncode: "Kích hoạt mã hóa",
+ subInfo: "Kích hoạt thông tin khách hàng",
+ path: "Đường dẫn mặc định",
+ update: "Thời gian cập nhật tự động",
+ subUri: "URI đăng ký",
+ jsonSub: "Đăng ký JSON",
+ toDirect: "Chuyển hướng tới Trực tiếp",
+ toBlock: "Chuyển hướng tới Chặn",
+ timestamp: "Dấu thời gian",
+ globalDns: "DNS Toàn cầu",
+ directDns: "DNS Trực tiếp",
+ toDirectDns: "Chuyển hướng tới DNS Trực tiếp",
+ jsonSubOptions: "Tùy chọn Khác",
+ excludePkg: "Loại trừ Gói",
+ clashSub: "Clash đăng ký",
+ mixedPort: "Cổng khóa",
+ tun: "Tun đăng ký",
+ },
+ client: {
+ name: "Tên",
+ desc: "Mô tả",
+ group: "Nhóm",
+ inboundTags: "Thẻ đầu vào",
+ basics: "Cơ bản",
+ config: "Cấu hình",
+ links: "Liên kết",
+ external: "Liên kết bên ngoài",
+ sub: "Đăng ký bên ngoài",
+ delayStart: "Trì hoãn bắt đầu",
+ autoReset: "Tự động đặt lại",
+ resetDays: "Số ngày đặt lại",
+ nextReset: "Đặt lại lần sau",
+ },
+ bulk: {
+ order: "Sắp xếp",
+ random: "Ngẫu nhiên",
+ changeLimits: "Thay đổi giới hạn",
+ addInbounds: "Thêm inbound",
+ removeInbounds: "Xóa inbound",
+ addDays: "Thêm ngày",
+ addVolume: "Thêm dung lượng",
+ },
+ types: {
+ un: "Tên người dùng",
+ pw: "Mật khẩu",
+ direct: {
+ overrideAddr: "Ghi đè Địa chỉ",
+ overridePort: "Ghi đè Cổng",
+ },
+ hy: {
+ obfs: "Mật khẩu đã được Ẩn",
+ auth: "Mật khẩu Xác thực",
+ hyOptions: "Tùy chọn Hysteria",
+ hy2Options: "Tùy chọn Hysteria2",
+ ignoreBw: "Bỏ qua Băng thông của Client",
+ },
+ shdwTls: {
+ hs: "Máy chủ Handshake",
+ addHS: "Thêm Máy chủ Handshake",
+ },
+ ssh: {
+ passphrase: "Cụm từ mật khẩu",
+ hostKey: "Khóa Máy chủ",
+ algorithm: "Thuật toán Khóa",
+ clientVer: "Phiên bản Client",
+ options: "Tùy chọn SSH",
+ },
+ tor: {
+ execPath: "Đường dẫn File thực thi",
+ dataDir: "Thư mục Dữ liệu",
+ extArgs: "Đối số Bổ sung",
+ },
+ tuic: {
+ congControl: "Kiểm soát Tắc nghẽn",
+ authTimeout: "Thời gian chờ Xác thực",
+ hb: "Nhịp tim",
+ },
+ tun: {
+ addr: "Địa chỉ",
+ ifName: "Tên Giao diện",
+ excludeMptcp: "Loại trừ MPTCP",
+ fallbackRuleIndex: "Chỉ số quy tắc dự phòng iproute2",
+ },
+ vless: {
+ flow: "Luồng",
+ udpEnc: "Mã hóa Gói UDP",
+ },
+ vmess: {
+ security: "Bảo mật",
+ globalPadding: "Đệm Toàn cầu",
+ authLen: "Chiều dài Mã hóa",
+ },
+ wg: {
+ privKey: "Khóa Riêng tư",
+ pubKey: "Khóa Công khai của Đối tác",
+ psk: "Khóa được Chia sẻ trước",
+ localIp: "IPs Cục bộ",
+ worker: "Công nhân",
+ ifName: "Tên Giao diện",
+ sysIf: "Giao diện Hệ thống",
+ options: "Tùy chọn Wireguard",
+ allowedIp: "IPs được Phép",
+ peer: "Đối tác",
+ peers: "Đối tác",
+ },
+ lb: {
+ defaultOut: "Đầu ra Mặc định",
+ interruptConn: "Ngắt kết nối hiện tại",
+ testUrl: "URL Kiểm tra",
+ interval: "Khoảng thời gian",
+ tolerance: "Sự dung hòa",
+ urlTestOptions: "Tùy chọn URLTest",
+ },
+ ts: {
+ options: "Tùy chọn Tailscale",
+ stateDir: "Thư mục Trạng thái",
+ authKey: "Khóa Xác thực",
+ relayServer: "Máy chủ chuyển tiếp",
+ relayServerPort: "Cổng máy chủ chuyển tiếp",
+ relayEndpoints: "Điểm cuối tĩnh chuyển tiếp",
+ systemInterface: "Giao diện hệ thống",
+ sysIfName: "Tên giao diện",
+ sysIfMtu: "MTU giao diện",
+ controlUrl: "URL Cấu hình",
+ ephemeral: "Tạm thời",
+ hostname: "Tên máy chủ",
+ acceptRoutes: "Chấp nhận Đường dẫn",
+ exitNode: "Nút thoát",
+ allowLanAccess: "Cho phép Truy cập LAN",
+ advRoutes: "Quảng bá Đường dẫn",
+ advExitNode: "Quảng bá Nút thoát",
+ udpTimeout: "Thời gian Chờ UDP",
+ },
+ ocm: {
+ credentialPath: "Đường dẫn Thông tin xác thực",
+ usagesPath: "Đường dẫn Thống kê",
+ users: "Người dùng",
+ userName: "Tên",
+ userToken: "Token",
+ },
+ ccm: {
+ credentialPath: "Đường dẫn Thông tin xác thực",
+ usagesPath: "Đường dẫn Thống kê",
+ users: "Người dùng",
+ userName: "Tên",
+ userToken: "Token",
+ },
+ derp: {
+ configPath: "Đường dẫn Cấu hình",
+ verifyClientEndpoint: "Điểm cuối Xác minh Máy khách",
+ verifyClientUrl: "URL Xác minh Máy khách",
+ meshWith: "Kết nối Mesh với",
+ meshPsk: "Khóa PSK Mesh",
+ meshPskFile: "Tệp Khóa PSK Mesh",
+ stun: "Máy chủ STUN",
+ options: "Tùy chọn DERP",
+ },
+ naive: {
+ insecureConcurrency: "Đồng thời không an toàn",
+ quic: "QUIC",
+ quicCongestion: "Điều khiển tắc nghẽn QUIC",
+ udpOverTcp: "UDP qua TCP",
+ },
+ anytls: {
+ idleInterval: "Khoảng kiểm tra phiên nhàn rỗi",
+ idleTimeout: "Thời gian chờ phiên nhàn rỗi",
+ minIdle: "Số phiên nhàn rỗi tối thiểu"
+ },
+ },
+ in: {
+ addr: "Địa chỉ",
+ port: "Cổng",
+ ssMethod: "Phương thức",
+ ssManageable: "Quản lý được",
+ sSide: "Phía Máy chủ",
+ cSide: "Phía Khách hàng",
+ multiDomain: "Nhiều Tên miền",
+ remark: "Ghi chú",
+ mdOption: "Tùy chọn Nhiều Tên miền",
+ },
+ listen: {
+ options: "Tùy chọn Nghe",
+ tcpOptions: "Tùy chọn TCP",
+ udpOptions: "Tùy chọn UDP",
+ detour: "Lạc đạo",
+ detourText: "Chuyển tiếp tới đầu vào",
+ disableTcpKeepAlive: "Tắt TCP Keep Alive",
+ tcpKeepAlive: "TCP Keep Alive",
+ tcpKeepAliveInterval: "Khoảng thời gian TCP Keep Alive",
+ },
+ dial: {
+ bindIf: "Ràng buộc tới Giao diện Mạng",
+ bindIp4: "Ràng buộc tới IPv4",
+ bindIp6: "Ràng buộc tới IPv6",
+ bindNoPort: "Ràng buộc địa chỉ không cổng",
+ reuseAddr: "Sử dụng lại Địa chỉ Nghe",
+ connTimeout: "Thời gian Chờ Kết nối",
+ disableTcpKeepAlive: "Tắt TCP Keep Alive",
+ tcpKeepAlive: "TCP Keep Alive",
+ tcpKeepAliveInterval: "Khoảng thời gian TCP Keep Alive",
+ domainResolver: "Trình phân giải Tên miền",
+ options: "Tùy chọn Gọi",
+ detourText: "Chuyển tiếp tới thư đi",
+ },
+ transport: {
+ enable: "Kích hoạt vận chuyển",
+ host: "Máy chủ",
+ hosts: "Máy chủ",
+ path: "Đường dẫn",
+ httpMethod: "Phương thức Yêu cầu",
+ idleTimeout: "Thời gian Chờ Chờ đợi",
+ pingTimeout: "Thời gian Chờ Ping",
+ grpcServiceName: "Tên Dịch vụ",
+ grpcPws: "Cho phép mà không Có Luồng",
+ },
+ mux: {
+ enable: "Bật Multiplex",
+ maxConn: "Số kết nối Tối đa",
+ minStr: "Số Luồng Tối thiểu",
+ maxStr: "Số Luồng Tối đa",
+ padding: "Chỉ đệm",
+ enableBrutal: "Bật Brutal",
+ },
+ out: {
+ addr: "Địa chỉ Máy chủ",
+ port: "Cổng Máy chủ",
+ addUrlTest: "Thêm URLTest",
+ delay: "Độ trễ",
+ },
+ rule: {
+ add: "Thêm Quy tắc",
+ simple: "Đơn giản",
+ logical: "Logic",
+ mode: "Chế độ",
+ invert: "Nghịch đảo",
+ ipVer: "Phiên bản IP",
+ domain: "Tên miền",
+ domainSufix: "Hậu tố Miền",
+ domainKw: "Từ khóa Miền",
+ domainRgx: "Regex Miền",
+ ip: "CIDRs IP",
+ privateIp: "Dải IP Không hợp lệ",
+ port: "Cổng",
+ portRange: "Dải Cổng",
+ srcCidr: "CIDRs IP Nguồn",
+ srcPrivateIp: "IP Nguồn Không hợp lệ",
+ srcPort: "Cổng Nguồn",
+ srcPortRange: "Dải Cổng Nguồn",
+ ruleset: "Bộ quy tắc",
+ rulesetMatchSrc: "Bộ quy tắc IPcidr Phù hợp Nguồn",
+ preferredBy: "Ưu tiên theo đầu ra",
+ interfaceAddr: "Địa chỉ giao diện",
+ options: "Tùy chọn Quy tắc",
+ domainRules: "Tên miền/IP",
+ srcIpRules: "IP Nguồn",
+ srcPortRules: "Cổng Nguồn",
+ udpDisableDomainUnmapping: "Không màm mạng tiền lập tên miền",
+ udpConnect: "Kết nối UDP",
+ udpTimeout: "Thời gian Chờ UDP",
+ method: "Phương pháp",
+ noDrop: "Không Tháp",
+ sniffer: "Kiểm tra Sniffer",
+ timeout: "Thời gian Chờ Sniffing",
+ strategy: "Chiến lệ",
+ etaHint: "Mỗi dòng một mục. Dòng trống và mục trùng lặp sẽ bị bỏ qua.",
+ import: {
+ title: "Nhập hàng loạt bộ quy tắc",
+ rulesTitle: "Nhập quy tắc",
+ urlsHint: "Một URL mỗi dòng. Thẻ được lấy từ tên tệp (không gồm phần mở rộng).",
+ fileHint: "Tải lên tệp .txt chứa URL, mỗi dòng một URL.",
+ jsonHint: "Dán đối tượng JSON có mảng rules và/hoặc rule_set. Có thể dán cả khối \"route\": {'{'}...{'}'} hoặc chỉ nội dung bên trong.",
+ fileJsonHint: "Tải lên tệp .json có khối route.",
+ urlHint: "Nhập liên kết trực tiếp tới tệp JSON (ví dụ liên kết raw GitHub).",
+ preview: "Xem trước",
+ skipped: "đã tồn tại, hiển thị màu xám",
+ conflict: "Phát hiện xung đột",
+ merge: "Gộp — thêm quy tắc đã nhập (bỏ qua thẻ bộ trùng)",
+ replace: "Thay thế — xóa quy tắc và bộ hiện có, nhập lại",
+ pasteUrls: "Dán URL",
+ uploadTxt: "Tải lên .txt",
+ uploadFile: "Tải lên tệp",
+ fromUrl: "Theo URL",
+ selectTxt: "Chọn tệp .txt",
+ selectJson: "Chọn tệp .json",
+ parse: "Phân tích",
+ conflictDesc: "Cấu hình đã có {rules} quy tắc và {rulesets} bộ quy tắc. Chọn thao tác:",
+ finalOutbound: "Outbound mặc định (final)",
+ applyFinal: "Áp dụng làm outbound mặc định",
+ errNoArrays: 'Không tìm thấy mảng "rules" hoặc "rule_set".',
+ errJsonParse: "Lỗi phân tích JSON: {message}",
+ errNoArraysFetched: 'Không tìm thấy "rules" hoặc "rule_set" trong JSON đã tải.',
+ errFetch: "Lỗi tải: {message}",
+ errNoFile: "Chưa chọn tệp.",
+ errNoArraysInFile: 'Không tìm thấy "rules" hoặc "rule_set" trong tệp.',
+ },
+ },
+ ruleset: {
+ add: "Thêm Bộ quy tắc",
+ format: "Định dạng Dữ liệu",
+ interval: "Khoảng cách Cập nhật",
+ remote: "Xa",
+ local: "Cục bộ",
+ },
+ dns: {
+ add: "Thêm Máy chủ DNS",
+ title: "Máy chủ DNS",
+ final: "Cuối cùng",
+ server: "Máy chủ",
+ firstServer: "Máy chủ Đầu tiên",
+ cacheCapacity: "Nội dung bộ nhớ",
+ disableCache: "Vô hiệu hóa bộ nhớ đệm",
+ disableExpire: "Vô hiệu hóa hệ thống",
+ independentCache: "Bộ nhớ rẽ",
+ reverseMapping: "Màm mạng tên lập",
+ domainStrategy: "Chiến lược Domain",
+ local: { preferGo: "Ưu tiên Go" },
+ rule: {
+ add: "Thêm Quy tắc DNS",
+ title: "Quy tắc DNS",
+ inet4Range: "Dải CIDR IPv4",
+ inet6Range: "Dải CIDR IPv6",
+ acceptDefault: "Chấp nhận Mặc định",
+ action: {
+ title: "Hành động",
+ route: "Định tuyến",
+ routeOptions: "Tùy chọn định tuyến",
+ reject: "Từ chối",
+ predefined: "Định nghĩa sẵn",
+ rewriteTtl: "Ghi đè TTL",
+ clientSubnet: "Subnet của máy khách",
+ rcode: "Máy chủ tên",
+ rcodes: {
+ noError: "OK",
+ formerr: "Yêu cầu không hợp lệ",
+ servFail: "Lỗi máy chủ",
+ nxDomain: "Không tìm thấy",
+ refused: "Bị từ chối",
+ notImp: "Chưa được hỗ trợ"
+ },
+ answer: "Câu trả lời",
+ ns: "Máy chủ tên",
+ extra: "Bổ sung"
+ }
+ }
+ },
+ basic: {
+ log: {
+ title: "Nhật ký",
+ level: "Mức độ",
+ output: "Đầu ra",
+ timestamp: "Bật Dấu thời gian",
+ },
+ routing: {
+ title: "Định tuyến",
+ defaultOut: "Ra ngoài Mặc định",
+ defaultIf: "NIC Mặc định",
+ defaultRm: "Đánh dấu Định tuyến Mặc định",
+ defaultDns: "Máy chủ DNS Mặc định",
+ autoBind: "Tự động Ràng buộc NIC",
+ },
+ exp: {
+ storeFakeIp: "Lưu IP Giả mạo",
+ extController: "Trình điều khiển bên ngoài",
+ extUi: "Giao diện người dùng bên ngoài",
+ extUiDownloadUrl: "URL tải giao diện",
+ extUiDownloadDetour: "Chuyển hướng tải giao diện",
+ secret: "Mã bí mật",
+ defaultMode: "Chế độ mặc định",
+ allowOrigin: "Cho phép nguồn gốc",
+ allowPrivate: "Cho phép mạng riêng",
+ },
+ },
+ tls : {
+ enable: "Kích hoạt TLS",
+ usePath: "Sử dụng đường dẫn",
+ useText: "Sử dụng văn bản",
+ certPath: "Đường dẫn tệp chứng chỉ",
+ keyPath: "Đường dẫn tệp khóa",
+ cert: "Chứng chỉ",
+ key: "Khóa",
+ options: "Tùy chọn TLS",
+ minVer: "Phiên bản Tối thiểu",
+ maxVer: "Phiên bản Tối đa",
+ cs: "Các bộ mã hóa",
+ privKey: "Khóa riêng",
+ pubKey: "Khóa Công khai",
+ disableSni: "Tắt SNI",
+ insecure: "Cho phép Không an toàn",
+ fragment: "Kiểm tra hệ thống",
+ fragmentDelay: "Khoảng thời gian hệ thống",
+ recordFragment: "Kiểm tra bộ nhớ",
+ store: "Kho lưu trữ gốc",
+ ktls: "TLS nhân",
+ kernelTx: "TX",
+ kernelRx: "RX",
+ queryServerName: "Tên máy chủ truy vấn ECH",
+ acme: {
+ options: "Tùy chọn ACME",
+ dataDir: "Thư mục Dữ liệu",
+ defaultDomain: "Tên miền Mặc định",
+ disableChallenges: "Vô hiệu hóa Thách thức",
+ httpChallenge: "Vô hiệu hóa Thách thức HTTP",
+ tlsChallenge: "Vô hiệu hóa Thách thức TLS",
+ altPorts: "Cổng Thay thế",
+ altHport: "Cổng HTTP Thay thế",
+ altTport: "Cổng TLS Thay thế",
+ caProvider: "Nhà cung cấp CA",
+ customCa: "Nhà cung cấp CA Tùy chỉnh",
+ extAcc: "Tài khoản Bên ngoài",
+ dns01: "Thách thức DNS01",
+ dns01Provider: "Nhà cung cấp Thách thức DNS01",
+ dns01Params: {
+ api_token: "Mã API",
+ zone_token: "Mã Vùng",
+ access_key_id: "ID Khóa Truy cập",
+ access_key_secret: "Bí mật Khóa Truy cập",
+ region_id: "ID Khu vực",
+ security_token: "Mã Bảo mật",
+ username: "Tên đăng nhập",
+ password: "Mật khẩu",
+ subdomain: "Tên miền phụ",
+ server_url: "URL Máy chủ",
+ },
+ },
+ },
+ stats: {
+ upload: "Tải lên",
+ download: "Tải xuống",
+ volume: "Thể tích",
+ usage: "Sử dụng",
+ enable: "Kích hoạt thống kê",
+ graphTitle: "Biểu đồ lưu lượng",
+ B: "B",
+ KB: "KB",
+ MB: "MB",
+ GB: "GB",
+ TB: "TB",
+ PB: "PB",
+ p: "ph",
+ Kp: "Kph",
+ Mp: "Mph",
+ Gp: "Gph",
+ Mbps: "Mbps",
+ },
+ date: {
+ expiry: "Hết hạn",
+ expired: "Đã hết hạn",
+ d: "ng",
+ h: "g",
+ m: "p",
+ s: "s",
+ ms: "ms",
+ },
+}
diff --git a/frontend/src/locales/zhcn.ts b/frontend/src/locales/zhcn.ts
new file mode 100644
index 0000000..0a9f012
--- /dev/null
+++ b/frontend/src/locales/zhcn.ts
@@ -0,0 +1,635 @@
+export default {
+ success: "成功",
+ failed: "失败",
+ enable: "启用",
+ disable: "禁用",
+ none: "无",
+ all: "全部",
+ loading: "加载中...",
+ confirm: "是否确定?",
+ yes: "确认",
+ no: "取消",
+ unlimited: "无限",
+ type: "类型",
+ protocol: "协议",
+ submit: "提交",
+ reset: "重置",
+ now: "当前",
+ network: "网络",
+ copyToClipboard: "复制到剪贴板",
+ noData: "无数据!",
+ invalidLogin: "登录无效!",
+ online: "在线",
+ version: "版本",
+ email: "电子邮件",
+ commaSeparated: "(逗号分隔)",
+ count: "计数",
+ template: "模板",
+ editor: "编辑器",
+ error: {
+ dplData: "重复数据",
+ core: "Sing-Box 错误",
+ invalidData: "无效数据",
+ },
+ theme: {
+ light: "浅色",
+ dark: "深色",
+ system: "跟随系统",
+ },
+ pages: {
+ login: "登录",
+ home: "主页",
+ inbounds: "入站管理",
+ outbounds: "出站管理",
+ services: "服务管理",
+ endpoints: "节点管理",
+ clients: "用户管理",
+ rules: "路由列表",
+ tls: "TLS 设置",
+ basics: "基础信息",
+ dns: "DNS",
+ admins: "管理员",
+ settings: "设置",
+ },
+ main: {
+ tiles: "信息卡",
+ gauges: "仪表板",
+ charts: "图表",
+ infos: "信息",
+ gauge: {
+ cpu: "CPU 仪表",
+ mem: "RAM 仪表",
+ dsk: "Disk 仪表",
+ swp: "Swap 仪表",
+ },
+ chart: {
+ cpu: "CPU 监视器",
+ mem: "RAM 监视器",
+ net: "网络带宽",
+ pnet: "网络数据包",
+ dio: "Disk I/O",
+ },
+ info: {
+ sys: "系统信息",
+ sbd: "运行信息",
+ host: "主机",
+ cpu: "CPU",
+ core: "核心",
+ uptime: "运行时间",
+ startupTime: "启动时间",
+ threads: "线程",
+ memory: "内存",
+ running: "运行状态"
+ },
+ backup: {
+ title: "备份与恢复",
+ backup: "下载备份",
+ restore: "恢复备份",
+ exclStats: "排除图表数据",
+ exclChanges: "排除变更数据",
+ sbConfig: "下载 Sing-Box 配置",
+ },
+ stats: {
+ title: "使用量与统计",
+ totalUsage: "总用量",
+ },
+ },
+ objects: {
+ inbound: "入站",
+ client: "客户端",
+ outbound: "出站",
+ endpoint: "节点",
+ config: "配置",
+ rule: "规则",
+ ruleset: "规则集",
+ service: "服务",
+ dnsserver: "DNS 服务器",
+ dnsrule: "DNS规则",
+ user: "用户",
+ tag: "标签",
+ listen: "监听",
+ dial: "拨号",
+ tls: "TLS",
+ multiplex: "多路复用",
+ transport: "传输",
+ headers: "标头",
+ key: "键",
+ value: "值",
+ },
+ actions: {
+ action: "操作",
+ add: "添加",
+ addbulk: "批量添加",
+ editbulk: "批量编辑",
+ delbulk: "批量删除",
+ new: "新建",
+ edit: "编辑",
+ del: "删除",
+ clone: "克隆",
+ test: "测试",
+ testAll: "测试全部",
+ save: "保存",
+ update: "更新",
+ submit: "提交",
+ set: "设置",
+ generate: "生成",
+ disable: "禁用",
+ close: "关闭",
+ restartApp: "重启面板",
+ restartSb: "重启 Singbox",
+ },
+ login: {
+ title: "登录",
+ username: "用户名",
+ unRules: "用户名不能为空",
+ password: "密码",
+ pwRules: "密码不能为空",
+ },
+ menu: {
+ logout: "退出登录",
+ },
+ admin: {
+ changeCred: "更改凭据",
+ oldPass: "当前密码",
+ newUname: "新用户名",
+ newPass: "新密码",
+ lastLogin: "上次登录",
+ date: "日期",
+ time: "时间",
+ changes: "更改",
+ actor: "执行者",
+ key: "键",
+ action: "操作",
+ api: {
+ title: "API 令牌",
+ msg: "请复制令牌并保存到安全的地方。它将不再显示。",
+ token: "令牌",
+ },
+ },
+ setting: {
+ interface: "界面",
+ sub: "订阅",
+ addr: "地址",
+ port: "端口",
+ webPath: "面板路径",
+ domain: "域名",
+ sslKey: "SSL 密钥 (Key) 路径",
+ sslCert: "SSL 证书 (cert) 路径",
+ webUri: "面板 URI",
+ sessionAge: "会话超时时限",
+ trafficAge: "流量过期时限",
+ timeLoc: "时区",
+ subEncode: "启用 Base64 编码",
+ subInfo: "启用用户信息",
+ path: "默认路径",
+ update: "自动更新时间",
+ subUri: "订阅 URI",
+ jsonSub: "JSON 订阅",
+ toDirect: "路由到直连",
+ toBlock: "路由到阻止",
+ timestamp: "时间戳",
+ globalDns: "全局 DNS",
+ directDns: "直连 DNS",
+ toDirectDns: "路由到直连 DNS",
+ jsonSubOptions: "其他选项",
+ excludePkg: "排除包",
+ clashSub: "Clash 订阅",
+ mixedPort: "混合入站端口",
+ tun: "Tun 入站",
+ },
+ client: {
+ name: "名称",
+ desc: "描述",
+ group: "组",
+ inboundTags: "入站标签",
+ basics: "基础",
+ config: "配置",
+ links: "链接",
+ external: "外部链接",
+ sub: "外部订阅",
+ delayStart: "延迟启动",
+ autoReset: "自动重置",
+ resetDays: "重置天数",
+ nextReset: "下次重置",
+ },
+ bulk: {
+ order: "排序",
+ random: "随机",
+ changeLimits: "更改限制",
+ addInbounds: "添加入站",
+ removeInbounds: "移除入站",
+ addDays: "增加天数",
+ addVolume: "增加流量",
+ },
+ types: {
+ un: "用户名",
+ pw: "密码",
+ direct: {
+ overrideAddr: "覆盖地址",
+ overridePort: "覆盖端口",
+ },
+ hy: {
+ obfs: "混淆密码",
+ auth: "认证密码",
+ hyOptions: "Hysteria 选项",
+ hy2Options: "Hysteria2 选项",
+ ignoreBw: "忽略客户端带宽",
+ },
+ shdwTls: {
+ hs: "握手服务器",
+ addHS: "添加握手服务器",
+ },
+ ssh: {
+ passphrase: "密码短语",
+ hostKey: "主机密钥",
+ algorithm: "密钥算法",
+ clientVer: "客户端版本",
+ options: "SSH 选项",
+ },
+ tor: {
+ execPath: "可执行文件路径",
+ dataDir: "数据目录",
+ extArgs: "额外参数",
+ },
+ tuic: {
+ congControl: "拥塞控制",
+ authTimeout: "认证超时",
+ hb: "心跳包",
+ },
+ tun: {
+ addr: "地址",
+ ifName: "接口名称",
+ excludeMptcp: "排除 MPTCP",
+ fallbackRuleIndex: "iproute2 回退规则索引",
+ },
+ vless: {
+ flow: "流控",
+ udpEnc: "UDP 数据包编码",
+ },
+ vmess: {
+ security: "安全性",
+ globalPadding: "全局填充",
+ authLen: "加密长度",
+ },
+ wg: {
+ privKey: "私钥",
+ pubKey: "对等方公钥",
+ psk: "预共享密钥",
+ localIp: "本地 IP 地址",
+ worker: "工作线程",
+ ifName: "接口名称",
+ sysIf: "系统接口",
+ options: "WireGuard 选项",
+ allowedIp: "允许的 IP 地址",
+ peer: "对等体",
+ peers: "对等体",
+ },
+ lb: {
+ defaultOut: "默认出站",
+ interruptConn: "中断现有连接",
+ testUrl: "测试 URL",
+ interval: "间隔",
+ tolerance: "容错",
+ urlTestOptions: "URLTest 选项",
+ },
+ ts: {
+ options: "Tailscale 选项",
+ stateDir: "状态目录",
+ authKey: "认证密钥",
+ relayServer: "中继服务器",
+ relayServerPort: "中继服务器端口",
+ relayEndpoints: "中继静态端点",
+ systemInterface: "系统接口",
+ sysIfName: "接口名称",
+ sysIfMtu: "接口 MTU",
+ controlUrl: "控制 URL",
+ ephemeral: "临时节点",
+ hostname: "主机名",
+ acceptRoutes: "接受路由",
+ exitNode: "出口节点",
+ allowLanAccess: "允许 LAN 访问",
+ advRoutes: "广告路由",
+ advExitNode: "广告出口节点",
+ udpTimeout: "UDP 超时",
+ },
+ ocm: {
+ credentialPath: "凭据路径",
+ usagesPath: "用量统计路径",
+ users: "用户",
+ userName: "名称",
+ userToken: "令牌",
+ },
+ ccm: {
+ credentialPath: "凭据路径",
+ usagesPath: "用量统计路径",
+ users: "用户",
+ userName: "名称",
+ userToken: "令牌",
+ },
+ derp: {
+ configPath: "配置路径",
+ verifyClientEndpoint: "验证客户端端点",
+ verifyClientUrl: "验证客户端 URL",
+ meshWith: "与其他 DERP 节点网格连接",
+ meshPsk: "网格预共享密钥",
+ meshPskFile: "网格预共享密钥文件",
+ stun: "STUN 服务器",
+ options: "DERP 选项",
+ },
+ naive: {
+ insecureConcurrency: "不安全并发数",
+ quic: "QUIC",
+ quicCongestion: "QUIC 拥塞控制",
+ udpOverTcp: "UDP over TCP",
+ },
+ anytls: {
+ idleInterval: "空闲会话检查间隔",
+ idleTimeout: "空闲会话超时",
+ minIdle: "最小空闲会话数"
+ },
+ },
+ in: {
+ addr: "地址",
+ port: "端口",
+ ssMethod: "方法",
+ ssManageable: "可管理的",
+ sSide: "服务器端",
+ cSide: "客户端",
+ multiDomain: "多域名",
+ remark: "备注",
+ mdOption: "多域名选项",
+ },
+ listen: {
+ options: "监听选项",
+ tcpOptions: "TCP 选项",
+ udpOptions: "UDP 选项",
+ detour: "转发",
+ detourText: "转发到入站",
+ disableTcpKeepAlive: "禁用 TCP Keep Alive",
+ tcpKeepAlive: "TCP Keep Alive",
+ tcpKeepAliveInterval: "TCP Keep Alive 间隔",
+ },
+ dial: {
+ bindIf: "绑定到网络接口",
+ bindIp4: "绑定到 IPv4",
+ bindIp6: "绑定到 IPv6",
+ bindNoPort: "绑定地址不占端口",
+ reuseAddr: "重用监听地址",
+ connTimeout: "连接超时",
+ disableTcpKeepAlive: "禁用 TCP Keep Alive",
+ tcpKeepAlive: "TCP Keep Alive",
+ tcpKeepAliveInterval: "TCP Keep Alive 间隔",
+ domainResolver: "域名解析器",
+ options: "拨号选项",
+ detourText: "转发至出站",
+ },
+ transport: {
+ enable: "启用传输",
+ host: "主机域名",
+ hosts: "主机域名列表",
+ path: "HTTP 请求路径",
+ httpMethod: "HTTP 请求方法",
+ idleTimeout: "空闲超时",
+ pingTimeout: "Ping 超时",
+ grpcServiceName: "gRPC 服务名称",
+ grpcPws: "允许无流时保持连接",
+ },
+ mux: {
+ enable: "启用多路复用",
+ maxConn: "最大连接数",
+ minStr: "最小流数",
+ maxStr: "最大流数",
+ padding: "仅允许填充连接",
+ enableBrutal: "启用 TCP Brutal",
+ },
+ out: {
+ addr: "服务器地址",
+ port: "服务器端口",
+ addUrlTest: "添加 URLTest",
+ delay: "延迟",
+ },
+ rule: {
+ add: "添加规则",
+ simple: "简单",
+ logical: "逻辑",
+ mode: "模式",
+ invert: "反选结果",
+ ipVer: "IP 版本",
+ domain: "域名",
+ domainSufix: "域名后缀",
+ domainKw: "域名关键词",
+ domainRgx: "域名正则表达式",
+ ip: "IP CIDR",
+ privateIp: "匹配非公开 IP",
+ port: "端口",
+ portRange: "端口范围",
+ srcCidr: "源 IP CIDR",
+ srcPrivateIp: "匹配非公开源 IP",
+ srcPort: "源端口",
+ srcPortRange: "源端口范围",
+ ruleset: "规则集",
+ rulesetMatchSrc: "规则集 IP CIDR 匹配源 IP",
+ preferredBy: "优选出站",
+ interfaceAddr: "接口地址",
+ options: "规则选项",
+ domainRules: "域名/IP",
+ srcIpRules: "源 IP",
+ srcPortRules: "源端口",
+ udpDisableDomainUnmapping: "禁用域名解析映射",
+ udpConnect: "启用 UDP 连接",
+ udpTimeout: "UDP 超时",
+ method: "方法",
+ noDrop: "不丢弃",
+ sniffer: "嗅探",
+ timeout: "超时",
+ strategy: "策略",
+ etaHint: "每行一项。空行和重复项将被忽略。",
+ import: {
+ title: "批量导入规则集",
+ rulesTitle: "导入规则",
+ urlsHint: "每行一个 URL。标签由文件名(不含扩展名)决定。",
+ fileHint: "上传包含 URL 的 .txt 文件,每行一个。",
+ jsonHint: "粘贴包含 rules 和/或 rule_set 数组的 JSON 对象。可粘贴整个 \"route\" 块:{'{'}...{'}'} 或仅其内容。",
+ fileJsonHint: "上传包含 route 块的 .json 文件。",
+ urlHint: "填写 JSON 文件的直链(例如 GitHub raw 链接)。",
+ preview: "预览",
+ skipped: "已存在,以灰色显示",
+ conflict: "检测到冲突",
+ merge: "合并 — 添加导入的规则(跳过重复的规则集标签)",
+ replace: "替换 — 删除现有规则与规则集后重新导入",
+ pasteUrls: "粘贴 URL",
+ uploadTxt: "上传 .txt",
+ uploadFile: "上传文件",
+ fromUrl: "通过链接",
+ selectTxt: "选择 .txt 文件",
+ selectJson: "选择 .json 文件",
+ parse: "解析",
+ conflictDesc: "配置中已有 {rules} 条规则和 {rulesets} 个规则集。请选择操作:",
+ finalOutbound: "默认出站(final)",
+ applyFinal: "设为默认出站",
+ errNoArrays: '未找到 "rules" 或 "rule_set" 数组。',
+ errJsonParse: "JSON 解析错误:{message}",
+ errNoArraysFetched: '获取的 JSON 中未找到 "rules" 或 "rule_set"。',
+ errFetch: "获取失败:{message}",
+ errNoFile: "未选择文件。",
+ errNoArraysInFile: '文件中未找到 "rules" 或 "rule_set"。',
+ },
+ },
+ ruleset: {
+ add: "添加规则集",
+ format: "数据格式",
+ interval: "更新间隔",
+ remote: "远程",
+ local: "本地",
+ },
+ dns: {
+ add: "添加 DNS 服务器",
+ title: "DNS 服务器",
+ final: "最终",
+ server: "服务器",
+ firstServer: "首选服务器",
+ cacheCapacity: "缓存容量",
+ disableCache: "禁用缓存",
+ disableExpire: "禁用过期",
+ independentCache: "独立缓存",
+ reverseMapping: "反向映射",
+ domainStrategy: "域名解析策略",
+ local: { preferGo: "优先使用 Go" },
+ rule: {
+ add: "添加 DNS 规则",
+ title: "DNS 规则",
+ inet4Range: "IPv4 范围",
+ inet6Range: "IPv6 范围",
+ acceptDefault: "接受默认",
+ action: {
+ title: "操作",
+ route: "路由",
+ routeOptions: "路由选项",
+ reject: "拒绝",
+ predefined: "预定义",
+ rewriteTtl: "重写 TTL",
+ clientSubnet: "客户端子网",
+ rcode: "响应码",
+ rcodes: {
+ noError: "正常",
+ formerr: "请求错误",
+ servFail: "服务器故障",
+ nxDomain: "未找到",
+ refused: "被拒绝",
+ notImp: "未实现"
+ },
+ answer: "回答",
+ ns: "名称服务器",
+ extra: "附加"
+ }
+ }
+ },
+ basic: {
+ log: {
+ title: "日志",
+ level: "级别",
+ output: "输出",
+ timestamp: "启用时间戳",
+ },
+ routing: {
+ title: "路由",
+ defaultOut: "默认出站",
+ defaultIf: "默认网卡",
+ defaultRm: "默认路由标记",
+ defaultDns: "默认 DNS 解析器",
+ autoBind: "自动绑定网卡",
+ },
+ exp: {
+ storeFakeIp: "持久化 Fake-IP",
+ extController: "外部控制器",
+ extUi: "外部界面",
+ extUiDownloadUrl: "界面下载地址",
+ extUiDownloadDetour: "界面下载绕行",
+ secret: "密钥",
+ defaultMode: "默认模式",
+ allowOrigin: "允许来源",
+ allowPrivate: "允许私有网络",
+ },
+ },
+ tls : {
+ enable: "启用 TLS",
+ usePath: "使用外部路径",
+ useText: "使用文件内容",
+ certPath: "证书文件路径",
+ keyPath: "私钥文件路径",
+ cert: "证书文件内容",
+ key: "私钥文件内容",
+ options: "TLS 选项",
+ minVer: "最低版本",
+ maxVer: "最高版本",
+ cs: "密码套件",
+ privKey: "私钥",
+ pubKey: "公钥",
+ disableSni: "禁用 SNI",
+ insecure: "允许不安全",
+ fragment: "启用",
+ fragmentDelay: "启用后延迟",
+ recordFragment: "启用",
+ store: "根证书库",
+ ktls: "内核 TLS",
+ kernelTx: "发送",
+ kernelRx: "接收",
+ queryServerName: "ECH 查询服务器名称",
+ acme: {
+ options: "ACME 选项",
+ dataDir: "数据目录",
+ defaultDomain: "默认域名",
+ disableChallenges: "禁用挑战",
+ httpChallenge: "禁用 HTTP 挑战",
+ tlsChallenge: "禁用 TLS 挑战",
+ altPorts: "替代端口",
+ altHport: "替代 HTTP 端口",
+ altTport: "替代 TLS 端口",
+ caProvider: "CA 提供商",
+ customCa: "自定义 CA 提供商",
+ extAcc: "外部账户",
+ dns01: "DNS01 挑战",
+ dns01Provider: "DNS01 挑战提供商",
+ dns01Params: {
+ api_token: "API 令牌",
+ zone_token: "Zone 令牌",
+ access_key_id: "访问密钥 ID",
+ access_key_secret: "访问密钥密文",
+ region_id: "区域 ID",
+ security_token: "安全令牌",
+ username: "用户名",
+ password: "密码",
+ subdomain: "子域名",
+ server_url: "服务器 URL",
+ },
+ },
+ },
+ stats: {
+ upload: "上传",
+ download: "下载",
+ volume: "流量",
+ usage: "已用",
+ enable: "启用统计",
+ graphTitle: "流量图表",
+ B: "B",
+ KB: "KB",
+ MB: "MB",
+ GB: "GB",
+ TB: "TB",
+ PB: "PB",
+ p: "p",
+ Kp: "Kp",
+ Mp: "Mp",
+ Gp: "Gp",
+ Mbps: "Mbps",
+ },
+ date: {
+ expiry: "到期",
+ expired: "已到期",
+ d: "天",
+ h: "时",
+ m: "分",
+ s: "秒",
+ ms: "毫秒",
+ },
+}
diff --git a/frontend/src/locales/zhtw.ts b/frontend/src/locales/zhtw.ts
new file mode 100644
index 0000000..727cef3
--- /dev/null
+++ b/frontend/src/locales/zhtw.ts
@@ -0,0 +1,635 @@
+export default {
+ success: "成功",
+ failed: "失敗",
+ enable: "啟用",
+ disable: "禁用",
+ none: "無",
+ all: "全部",
+ loading: "加載中...",
+ confirm: "是否確定?",
+ yes: "確認",
+ no: "取消",
+ unlimited: "無限",
+ type: "類型",
+ protocol: "協定",
+ submit: "提交",
+ reset: "重置",
+ now: "當前",
+ network: "網絡",
+ copyToClipboard: "復製到剪貼板",
+ noData: "無數據!",
+ invalidLogin: "登錄無效!",
+ online: "在線",
+ version: "版本",
+ email: "電子郵件",
+ commaSeparated: "(逗號分隔)",
+ count: "計數",
+ template: "模板",
+ editor: "編輯器",
+ error: {
+ dplData: "重複數據",
+ core: "Sing-Box 錯誤",
+ invalidData: "無效數據",
+ },
+ theme: {
+ light: "明亮",
+ dark: "暗黑",
+ system: "系統",
+ },
+ pages: {
+ login: "登錄",
+ home: "主頁",
+ inbounds: "入站管理",
+ outbounds: "出站管理",
+ services: "服務管理",
+ endpoints: "端點管理",
+ clients: "用戶管理",
+ rules: "路由列表",
+ tls: "TLS 設置",
+ basics: "基礎信息",
+ dns: "DNS",
+ admins: "管理員",
+ settings: "設置",
+ },
+ main: {
+ tiles: "信息卡",
+ gauges: "儀表板",
+ charts: "圖表",
+ infos: "信息",
+ gauge: {
+ cpu: "CPU 儀表",
+ mem: "RAM 儀表",
+ dsk: "Disk 儀表",
+ swp: "Swap 儀表",
+ },
+ chart: {
+ cpu: "CPU 監視器",
+ mem: "RAM 監視器",
+ net: "網絡帶寬",
+ pnet: "網絡數據包",
+ dio: "Disk I/O",
+ },
+ info: {
+ sys: "系統信息",
+ sbd: "運行信息",
+ host: "主機",
+ cpu: "CPU",
+ core: "核心",
+ uptime: "運行時間",
+ startupTime: "啟動時間",
+ threads: "線程",
+ memory: "內存",
+ running: "運行狀態"
+ },
+ backup: {
+ title: "備份與恢復",
+ backup: "下載備份",
+ restore: "恢復備份",
+ exclStats: "排除圖表記錄",
+ exclChanges: "排除更改記錄",
+ sbConfig: "下載 Sing-Box 配置",
+ },
+ stats: {
+ title: "使用量與統計",
+ totalUsage: "總用量",
+ },
+ },
+ objects: {
+ inbound: "入站",
+ client: "客戶端",
+ outbound: "出站",
+ endpoint: "端點",
+ config: "配置",
+ rule: "規則",
+ ruleset: "規則集",
+ service: "服務",
+ dnsserver: "DNS 服務器",
+ dnsrule: "DNS 規則",
+ user: "用戶",
+ tag: "標簽",
+ listen: "聽",
+ dial: "撥號",
+ tls: "TLS",
+ multiplex: "多路復用",
+ transport: "傳輸",
+ headers: "方法",
+ key: "鑰匙",
+ value: "價值",
+ },
+ actions: {
+ action: "操作",
+ add: "添加",
+ addbulk: "批量添加",
+ editbulk: "批量編輯",
+ delbulk: "批量刪除",
+ new: "新建",
+ edit: "編輯",
+ del: "刪除",
+ clone: "克隆",
+ test: "測試",
+ testAll: "測試全部",
+ save: "保存",
+ update: "更新",
+ submit: "提交",
+ set: "設置",
+ generate: "生成",
+ disable: "禁用",
+ close: "關閉",
+ restartApp: "重啟面板",
+ restartSb: "重啟 Singbox",
+ },
+ login: {
+ title: "登錄",
+ username: "用戶名",
+ unRules: "用戶名不能為空",
+ password: "密碼",
+ pwRules: "密碼不能為空",
+ },
+ menu: {
+ logout: "退出登錄",
+ },
+ admin: {
+ changeCred: "更改憑據",
+ oldPass: "當前密碼",
+ newUname: "新用戶名",
+ newPass: "新密碼",
+ lastLogin: "上次登入",
+ date: "日期",
+ time: "時間",
+ changes: "更改",
+ actor: "執行者",
+ key: "鍵",
+ action: "操作",
+ api: {
+ title: "API 憑據",
+ msg: "請複製下面的憑據並儲存在安全的地方。它將不再顯示。",
+ token: "憑據",
+ },
+ },
+ setting: {
+ interface: "界面",
+ sub: "訂閱",
+ addr: "地址",
+ port: "端口",
+ webPath: "基本 URI",
+ domain: "域名",
+ sslKey: "SSL 密鑰 (Key) 路徑",
+ sslCert: "SSL 證書 (cert) 路徑",
+ webUri: "面板 URI",
+ sessionAge: "會話最大連接數",
+ trafficAge: "流量最大年齡",
+ timeLoc: "時區",
+ subEncode: "啟用編碼",
+ subInfo: "啟用用戶信息",
+ path: "默認路徑",
+ update: "自動更新時間",
+ subUri: "訂閱 URL",
+ jsonSub: "JSON 訂閱",
+ toDirect: "路由到直連",
+ toBlock: "路由到阻止",
+ timestamp: "時間戳",
+ globalDns: "全局 DNS",
+ directDns: "直連 DNS",
+ toDirectDns: "路由到直連 DNS",
+ jsonSubOptions: "其他選項",
+ excludePkg: "排除包",
+ clashSub: "Clash 訂閱",
+ mixedPort: "混合入站端口",
+ tun: "Tun 入站",
+ },
+ client: {
+ name: "名稱",
+ desc: "描述",
+ group: "組",
+ inboundTags: "入站標簽",
+ basics: "基礎",
+ config: "配置",
+ links: "鏈接",
+ external: "外部鏈接",
+ sub: "外部訂閱",
+ delayStart: "延遲啟動",
+ autoReset: "自動重置",
+ resetDays: "重置天數",
+ nextReset: "下次重置",
+ },
+ bulk: {
+ order: "排序",
+ random: "隨機",
+ changeLimits: "變更限制",
+ addInbounds: "添加入站",
+ removeInbounds: "移除入站",
+ addDays: "增加天數",
+ addVolume: "增加流量",
+ },
+ types: {
+ un: "用戶名",
+ pw: "密碼",
+ direct: {
+ overrideAddr: "覆蓋地址",
+ overridePort: "覆蓋端口",
+ },
+ hy: {
+ obfs: "混淆密碼",
+ auth: "驗證密碼",
+ hyOptions: "Hysteria 選項",
+ hy2Options: "Hysteria2 選項",
+ ignoreBw: "忽略客戶端帶寬",
+ },
+ shdwTls: {
+ hs: "握手服務器",
+ addHS: "添加握手服務器",
+ },
+ ssh: {
+ passphrase: "密語",
+ hostKey: "主機密鑰",
+ algorithm: "密鑰算法",
+ clientVer: "客戶端版本",
+ options: "SSH 選項",
+ },
+ tor: {
+ execPath: "可執行文件路徑",
+ dataDir: "數據目錄",
+ extArgs: "額外參數",
+ },
+ tuic: {
+ congControl: "擁塞控制",
+ authTimeout: "身份驗證超時",
+ hb: "心跳",
+ },
+ tun: {
+ addr: "地址",
+ ifName: "介面名稱",
+ excludeMptcp: "排除 MPTCP",
+ fallbackRuleIndex: "iproute2 回退規則索引",
+ },
+ vless: {
+ flow: "流量",
+ udpEnc: "UDP 封包編碼",
+ },
+ vmess: {
+ security: "安全性",
+ globalPadding: "全局填充",
+ authLen: "加密長度",
+ },
+ wg: {
+ privKey: "私鑰",
+ pubKey: "對等方公鑰",
+ psk: "預共享密鑰",
+ localIp: "本地 IP",
+ worker: "工作線程",
+ ifName: "介面名稱",
+ sysIf: "系統介面",
+ options: "Wireguard 選項",
+ allowedIp: "允許的 IP",
+ peer: "對等方",
+ peers: "對等方",
+ },
+ lb: {
+ defaultOut: "默認外部",
+ interruptConn: "中斷現有連接",
+ testUrl: "測試 URL",
+ interval: "間隔",
+ tolerance: "容忍度",
+ urlTestOptions: "URLTest 選項"
+ },
+ ts: {
+ options: "Tailscale 選項",
+ stateDir: "狀態目錄",
+ authKey: "授權密鑰",
+ relayServer: "轉發伺服器",
+ relayServerPort: "轉發伺服器端口",
+ relayEndpoints: "轉發靜態端點",
+ systemInterface: "系統介面",
+ sysIfName: "介面名稱",
+ sysIfMtu: "介面 MTU",
+ controlUrl: "控制 URL",
+ ephemeral: "臨時節點",
+ hostname: "主機名",
+ acceptRoutes: "接受路由",
+ exitNode: "出口節點",
+ allowLanAccess: "允許 LAN 訪問",
+ advRoutes: "廣告路由",
+ advExitNode: "廣告出口節點",
+ udpTimeout: "UDP 超時",
+ },
+ ocm: {
+ credentialPath: "憑證路徑",
+ usagesPath: "用量統計路徑",
+ users: "用戶",
+ userName: "名稱",
+ userToken: "令牌",
+ },
+ ccm: {
+ credentialPath: "憑證路徑",
+ usagesPath: "用量統計路徑",
+ users: "用戶",
+ userName: "名稱",
+ userToken: "令牌",
+ },
+ derp: {
+ configPath: "配置路徑",
+ verifyClientEndpoint: "驗證客戶端端點",
+ verifyClientUrl: "驗證客戶端 URL",
+ meshWith: "網狀連接",
+ meshPsk: "網狀 PSK",
+ meshPskFile: "網狀 PSK 文件",
+ stun: "STUN 服務器",
+ options: "DERP 選項",
+ },
+ naive: {
+ insecureConcurrency: "不安全並發數",
+ quic: "QUIC",
+ quicCongestion: "QUIC 擁塞控制",
+ udpOverTcp: "UDP over TCP",
+ },
+ anytls: {
+ idleInterval: "閒置會話檢查間隔",
+ idleTimeout: "閒置會話逾時",
+ minIdle: "最小閒置會話數"
+ },
+ },
+ in: {
+ addr: "地址",
+ port: "端口",
+ ssMethod: "方法",
+ ssManageable: "可管理的",
+ sSide: "服務器端",
+ cSide: "客戶端",
+ multiDomain: "多域名",
+ remark: "備註",
+ mdOption: "多域名選項",
+ },
+ listen: {
+ options: "監聽選項",
+ tcpOptions: "TCP 選項",
+ udpOptions: "UDP 選項",
+ detour: "繞道",
+ detourText: "轉發到入站",
+ disableTcpKeepAlive: "停用 TCP Keep Alive",
+ tcpKeepAlive: "TCP Keep Alive",
+ tcpKeepAliveInterval: "TCP Keep Alive 間隔",
+ },
+ dial: {
+ bindIf: "綁定到網路接口",
+ bindIp4: "綁定到 IPv4",
+ bindIp6: "綁定到 IPv6",
+ bindNoPort: "綁定地址不佔用端口",
+ reuseAddr: "重用監聽地址",
+ connTimeout: "連接超時",
+ disableTcpKeepAlive: "停用 TCP Keep Alive",
+ tcpKeepAlive: "TCP Keep Alive",
+ tcpKeepAliveInterval: "TCP Keep Alive 間隔",
+ domainResolver: "域名解析器",
+ options: "撥號選項",
+ detourText: "轉寄至出站",
+ },
+ transport: {
+ enable: "啟用傳輸",
+ host: "主機",
+ hosts: "主機列表",
+ path: "路徑",
+ httpMethod: "請求方法",
+ idleTimeout: "閒置超時",
+ pingTimeout: "Ping 超時",
+ grpcServiceName: "服務名稱",
+ grpcPws: "允許無流",
+ },
+ mux: {
+ enable: "啟用多路徑",
+ maxConn: "最大連接數",
+ minStr: "最小串流數",
+ maxStr: "最大串流數",
+ padding: "僅填充",
+ enableBrutal: "啟用暴力",
+ },
+ out: {
+ addr: "伺服器地址",
+ port: "伺服器端口",
+ addUrlTest: "新增 URLTest",
+ delay: "延遲",
+ },
+ rule: {
+ add: "添加規則",
+ simple: "簡單",
+ logical: "邏輯",
+ mode: "模式",
+ invert: "反轉",
+ ipVer: "IP 版本",
+ domain: "域名",
+ domainSufix: "域名後綴",
+ domainKw: "域名關鍵詞",
+ domainRgx: "域名正則表達式",
+ ip: "IP CIDR",
+ privateIp: "無效 IP 範圍",
+ port: "端口",
+ portRange: "端口範圍",
+ srcCidr: "源 IP CIDR",
+ srcPrivateIp: "無效源 IP",
+ srcPort: "源端口",
+ srcPortRange: "源端口範圍",
+ ruleset: "規則集",
+ rulesetMatchSrc: "規則集 IP 範圍匹配源",
+ preferredBy: "優選出站",
+ interfaceAddr: "介面地址",
+ options: "規則選項",
+ domainRules: "域名/IP",
+ srcIpRules: "源 IP",
+ srcPortRules: "源端口",
+ udpDisableDomainUnmapping: "禁用域名解析映射",
+ udpConnect: "啟用 UDP 連接",
+ udpTimeout: "UDP 超時",
+ method: "方法",
+ noDrop: "不丟弃",
+ sniffer: "嗅探",
+ timeout: "超時",
+ strategy: "策略",
+ etaHint: "每行一項。空白行與重複項目將被略過。",
+ import: {
+ title: "批次匯入規則集",
+ rulesTitle: "匯入規則",
+ urlsHint: "每行一個 URL。標籤由檔名(不含副檔名)決定。",
+ fileHint: "上傳含 URL 的 .txt 檔案,每行一個。",
+ jsonHint: "貼上含 rules 及/或 rule_set 陣列的 JSON 物件。可貼上整個 \"route\" 區塊:{'{'}...{'}'} 或僅其內容。",
+ fileJsonHint: "上傳含 route 區塊的 .json 檔案。",
+ urlHint: "填寫 JSON 檔案的直連(例如 GitHub raw 連結)。",
+ preview: "預覽",
+ skipped: "已存在,以灰色顯示",
+ conflict: "偵測到衝突",
+ merge: "合併 — 加入匯入的規則(略過重複的規則集標籤)",
+ replace: "取代 — 刪除現有規則與規則集後重新匯入",
+ pasteUrls: "貼上 URL",
+ uploadTxt: "上傳 .txt",
+ uploadFile: "上傳檔案",
+ fromUrl: "透過連結",
+ selectTxt: "選擇 .txt 檔案",
+ selectJson: "選擇 .json 檔案",
+ parse: "解析",
+ conflictDesc: "設定中已有 {rules} 條規則與 {rulesets} 個規則集。請選擇操作:",
+ finalOutbound: "預設出站(final)",
+ applyFinal: "設為預設出站",
+ errNoArrays: '找不到 "rules" 或 "rule_set" 陣列。',
+ errJsonParse: "JSON 解析錯誤:{message}",
+ errNoArraysFetched: '取得的 JSON 中找不到 "rules" 或 "rule_set"。',
+ errFetch: "取得失敗:{message}",
+ errNoFile: "未選擇檔案。",
+ errNoArraysInFile: '檔案中找不到 "rules" 或 "rule_set"。',
+ },
+ },
+ ruleset: {
+ add: "添加規則集",
+ format: "數據格式",
+ interval: "更新間隔",
+ remote: "遠端",
+ local: "本地",
+ },
+ dns: {
+ add: "添加 DNS 服務器",
+ title: "DNS 服務器",
+ final: "最終",
+ server: "服務器",
+ firstServer: "首選服務器",
+ cacheCapacity: "快取容量",
+ disableCache: "停用快取",
+ disableExpire: "停用過期",
+ independentCache: "獨立快取",
+ reverseMapping: "反向映射",
+ domainStrategy: "域名策略",
+ local: { preferGo: "優先使用 Go" },
+ rule: {
+ add: "添加 DNS 規則",
+ title: "DNS 規則",
+ inet4Range: "IPv4 範圍",
+ inet6Range: "IPv6 範圍",
+ acceptDefault: "接受默認",
+ action: {
+ title: "操作",
+ route: "路由",
+ routeOptions: "路由選項",
+ reject: "拒絕",
+ predefined: "預設",
+ rewriteTtl: "重寫 TTL",
+ clientSubnet: "用戶端子網",
+ rcode: "回應碼",
+ rcodes: {
+ noError: "正常",
+ formerr: "請求錯誤",
+ servFail: "伺服器故障",
+ nxDomain: "未找到",
+ refused: "被拒絕",
+ notImp: "尚未實作"
+ },
+ answer: "回應",
+ ns: "名稱伺服器",
+ extra: "額外資訊"
+ }
+ },
+ },
+ basic: {
+ log: {
+ title: "日誌",
+ level: "級別",
+ output: "輸出",
+ timestamp: "啟用時間戳記",
+ },
+ routing: {
+ title: "路由",
+ defaultOut: "默認外部",
+ defaultIf: "默認網卡",
+ defaultRm: "默認路由標記",
+ defaultDns: "默認 DNS 解析器",
+ autoBind: "自動綁定網卡",
+ },
+ exp: {
+ storeFakeIp: "存儲假 IP",
+ extController: "外部控制器",
+ extUi: "外部介面",
+ extUiDownloadUrl: "介面下載網址",
+ extUiDownloadDetour: "介面下載繞行",
+ secret: "密鑰",
+ defaultMode: "預設模式",
+ allowOrigin: "允許來源",
+ allowPrivate: "允許私人網路",
+ },
+ },
+ tls : {
+ enable: "啟用 TLS",
+ usePath: "使用外部路徑",
+ useText: "使用文件內容",
+ certPath: "證書文件路徑",
+ keyPath: "私鑰文件路徑",
+ cert: "證書文件內容",
+ key: "私鑰文件內容",
+ options: "TLS 選項",
+ minVer: "最低版本",
+ maxVer: "最高版本",
+ cs: "加密套件",
+ privKey: "私鑰",
+ pubKey: "公鑰",
+ disableSni: "停用 SNI",
+ insecure: "允許不安全連線",
+ fragment: "分段",
+ fragmentDelay: "分段回應延遲",
+ recordFragment: "多筆記錄分段",
+ store: "根憑證庫",
+ ktls: "內核 TLS",
+ kernelTx: "發送",
+ kernelRx: "接收",
+ queryServerName: "ECH 查詢伺服器名稱",
+ acme: {
+ options: "ACME 選項",
+ dataDir: "數據目錄",
+ defaultDomain: "默認域名",
+ disableChallenges: "禁用挑戰",
+ httpChallenge: "禁用 HTTP 挑戰",
+ tlsChallenge: "禁用 TLS 挑戰",
+ altPorts: "替代端口",
+ altHport: "替代 HTTP 端口",
+ altTport: "替代 TLS 端口",
+ caProvider: "CA 提供商",
+ customCa: "自定義 CA 提供商",
+ extAcc: "外部賬戶",
+ dns01: "DNS01 挑戰",
+ dns01Provider: "DNS01 挑戰提供商",
+ dns01Params: {
+ api_token: "API 令牌",
+ zone_token: "Zone 令牌",
+ access_key_id: "存取金鑰 ID",
+ access_key_secret: "存取金鑰密文",
+ region_id: "區域 ID",
+ security_token: "安全令牌",
+ username: "用戶名",
+ password: "密碼",
+ subdomain: "子網域",
+ server_url: "伺服器 URL",
+ },
+ },
+ },
+ stats: {
+ upload: "上傳",
+ download: "下載",
+ volume: "流量",
+ usage: "已用",
+ enable: "啟用統計",
+ graphTitle: "流量圖表",
+ B: "B",
+ KB: "KB",
+ MB: "MB",
+ GB: "GB",
+ TB: "TB",
+ PB: "PB",
+ p: "p",
+ Kp: "Kp",
+ Mp: "Mp",
+ Gp: "Gp",
+ Mbps: "Mbps",
+ },
+ date: {
+ expiry: "到期",
+ expired: "已到期",
+ d: "天",
+ h: "時",
+ m: "分",
+ s: "秒",
+ ms: "毫秒",
+ },
+}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
new file mode 100644
index 0000000..fb7d25d
--- /dev/null
+++ b/frontend/src/main.ts
@@ -0,0 +1,55 @@
+/**
+ * main.ts
+ *
+ * Bootstraps Vuetify and other plugins then mounts the App`
+ */
+
+// Composables
+import { createApp, ref } from 'vue'
+
+// Components
+import App from './App.vue'
+
+// Use router
+import router from './router'
+
+// Store
+import store from './store'
+
+// Plugins
+import { registerPlugins } from '@/plugins'
+
+// Locale
+import { i18n } from '@/locales'
+import Vue3PersianDatetimePicker from 'vue3-persian-datetime-picker'
+
+// Notivue
+import { createNotivue } from 'notivue'
+import 'notivue/notification.css'
+import 'notivue/animations.css'
+const notivue = createNotivue({
+ position: 'bottom-center',
+ limit: 4,
+ enqueue: false,
+ avoidDuplicates: true,
+ notifications: {
+ global: {
+ duration: 3000
+ }
+ },
+})
+
+const loading = ref(false)
+
+const app = createApp(App)
+app.provide('loading', loading)
+
+registerPlugins(app)
+
+app
+ .use(router)
+ .use(store)
+ .use(i18n)
+ .use(notivue)
+ .component('DatePicker', Vue3PersianDatetimePicker)
+ .mount('#app')
diff --git a/frontend/src/plugins/api.ts b/frontend/src/plugins/api.ts
new file mode 100644
index 0000000..52b9e81
--- /dev/null
+++ b/frontend/src/plugins/api.ts
@@ -0,0 +1,57 @@
+import axios from 'axios'
+
+axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
+axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
+
+axios.defaults.baseURL = "./"
+const pendingRequests = new Map()
+
+axios.interceptors.request.use(
+ (config) => {
+ // Generate a unique key for the request
+ const requestKey = `${config.method}:${config.url}`
+
+ // Check if there is already a pending request with the same key
+ if (pendingRequests.has(requestKey)) {
+ const cancelSource = pendingRequests.get(requestKey)
+ cancelSource.cancel('Duplicate request cancelled')
+ }
+
+ // Create a new cancel token for the request
+ const cancelSource = axios.CancelToken.source()
+ config.cancelToken = cancelSource.token
+
+ // Store the cancel token in the pending requests map
+ pendingRequests.set(requestKey, cancelSource)
+
+ if (config.data instanceof FormData) {
+ config.headers['Content-Type'] = 'multipart/form-data'
+ }
+ return config
+ },
+ (error) => Promise.reject(error),
+)
+
+axios.interceptors.response.use(
+ (response) => {
+ // Remove the request from the pending requests map
+ const requestKey = `${response.config.method}:${response.config.url}`
+ pendingRequests.delete(requestKey)
+ return response
+ },
+ (error) => {
+ if (axios.isCancel(error)) {
+ // Handle duplicate request cancellation here if needed
+ console.warn(error.message)
+ } else {
+ // Remove the request from the pending requests map on error
+ const requestKey = `${error.config.method}:${error.config.url}`
+ pendingRequests.delete(requestKey)
+ }
+ return Promise.reject(error)
+ }
+)
+
+const api = axios.create()
+
+export default api
diff --git a/frontend/src/plugins/httputil.ts b/frontend/src/plugins/httputil.ts
new file mode 100644
index 0000000..99f0b52
--- /dev/null
+++ b/frontend/src/plugins/httputil.ts
@@ -0,0 +1,88 @@
+import api from './api'
+import { i18n } from '@/locales'
+import router from '@/router'
+import { push } from 'notivue'
+
+export interface Msg {
+ success: boolean
+ msg: string
+ obj: any | null
+}
+
+function _handleMsg(msg: any): void {
+ if (!isMsg(msg)) {
+ return
+ }
+ if(msg.msg){
+ if (!msg.success && msg.msg == "Invalid login") {
+ push.error({
+ title: i18n.global.t('invalidLogin'),
+ })
+ logout()
+ return
+ }
+ if (msg.success) {
+ push.success({
+ message: i18n.global.t('success') + ": " + i18n.global.t('actions.' + msg.msg),
+ })
+ } else {
+ push.error({
+ title: i18n.global.t('failed'),
+ message: msg.msg
+ })
+ }
+ }
+}
+
+export const logout = async () => {
+ const response = await HttpUtils.get('api/logout')
+ if(response.success){
+ router.push('/login')
+ }
+}
+
+function _respToMsg(resp: any): Msg {
+ const data = resp.data
+ if (data == null) {
+ return { success: true, msg: "", obj: null }
+ } else if (isMsg(data)) {
+ if (data.hasOwnProperty('success')) {
+ return { success: data.success, msg: data.msg, obj: data.obj || null }
+ } else {
+ return data
+ }
+ } else {
+ return { success: false, msg: `unknown data: ${data}`, obj: null }
+ }
+}
+
+function isMsg(obj: any): obj is Msg {
+ return Object.hasOwn(obj,'success') && Object.hasOwn(obj,'msg') && Object.hasOwn(obj, 'obj')
+}
+
+const HttpUtils = {
+ async get(url: string, data: object = {}, options: any[] = []): Promise {
+ let msg: Msg
+ try {
+ const resp = await api.get(url, { params: data, ...options })
+ msg = _respToMsg(resp)
+ } catch (e: any) {
+ msg = { success: false, msg: e.toString(), obj: null }
+ }
+ _handleMsg(msg)
+ return msg
+ },
+ async post(url: string, data: object | null, options: any = undefined): Promise {
+ let msg: Msg
+ try {
+ const resp = await api.post(url, data, options)
+ msg = _respToMsg(resp)
+ } catch (e: any) {
+ msg = { success: false, msg: e.toString(), obj: null }
+ }
+ _handleMsg(msg)
+ return msg
+ },
+}
+
+export default HttpUtils
\ No newline at end of file
diff --git a/frontend/src/plugins/index.ts b/frontend/src/plugins/index.ts
new file mode 100644
index 0000000..d4cc1ee
--- /dev/null
+++ b/frontend/src/plugins/index.ts
@@ -0,0 +1,10 @@
+// Plugins
+import vuetify from './vuetify'
+
+// Types
+import type { App } from 'vue'
+
+export function registerPlugins (app: App) {
+ app
+ .use(vuetify)
+}
diff --git a/frontend/src/plugins/randomUtil.ts b/frontend/src/plugins/randomUtil.ts
new file mode 100644
index 0000000..2ac7582
--- /dev/null
+++ b/frontend/src/plugins/randomUtil.ts
@@ -0,0 +1,82 @@
+const seq = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
+
+const RandomUtil = {
+ randomIntRange(min: number, max: number): number {
+ if (!Number.isSafeInteger(min)){
+ return this.randomIntRange(Number.MIN_SAFE_INTEGER, max)
+ }
+ if (!Number.isSafeInteger(max)){
+ return this.randomIntRange(min, Number.MAX_SAFE_INTEGER)
+ }
+ if (max < min) {
+ return this.randomIntRange(max, min)
+ }
+ const array = new Uint32Array(2);
+ window.crypto.getRandomValues(array);
+ const highbits = array[0]
+ const lowbits = array[1] >>> 11
+ const random = (highbits * 2 ** 21 + lowbits) / (Number.MAX_SAFE_INTEGER + 1)
+ return Math.floor(random * (max - min + 1) + min)
+ },
+ randomInt(n: number) {
+ return this.randomIntRange(0, n)
+ },
+ randomSeq(count: number): string {
+ if (count <= 0) {
+ return ''
+ }
+ let str = ''
+ for (let i = 0; i < count; ++i) {
+ str += seq[this.randomInt(62)]
+ }
+ return str
+ },
+ randomLowerAndNum(count: number): string {
+ if (count <= 0) {
+ return ''
+ }
+ let str = ''
+ for (let i = 0; i < count; ++i) {
+ str += seq[this.randomInt(36)]
+ }
+ return str
+ },
+ randomUUID(): string {
+ const rng = new Uint8Array(16);
+ window.crypto.getRandomValues(rng);
+ rng[6] = (rng[6] & 0x0f) | 0x40;
+ rng[8] = (rng[8] & 0x3f) | 0x80;
+ return (
+ byteToHex[rng[0]] + byteToHex[rng[1]] + byteToHex[rng[2]] + byteToHex[rng[3]] + '-' +
+ byteToHex[rng[4]] + byteToHex[rng[5]] + '-' +
+ byteToHex[rng[6]] + byteToHex[rng[7]] + '-' +
+ byteToHex[rng[8]] + byteToHex[rng[9]] + '-' +
+ byteToHex[rng[10]] + byteToHex[rng[11]] + byteToHex[rng[12]] +
+ byteToHex[rng[13]] + byteToHex[rng[14]] + byteToHex[rng[15]]
+ );
+ },
+ randomShadowsocksPassword(n: number): string {
+ const array = new Uint8Array(n)
+ window.crypto.getRandomValues(array)
+ return btoa(String.fromCharCode(...array))
+ },
+ randomShortId(): string[] {
+ let shortIds = new Array(24).fill('')
+ for (var ii = 1; ii < 24; ii++) {
+ for (var jj = 0; jj <= this.randomInt(7); jj++){
+ let randomNum = this.randomInt(256)
+ shortIds[ii] += ('0' + randomNum.toString(16)).slice(-2)
+ }
+ }
+ return shortIds
+ }
+}
+
+const byteToHex = Array.from(
+ { length: 256 },
+ (_, i) => (i + 0x100)
+ .toString(16)
+ .slice(1)
+)
+
+export default RandomUtil
\ No newline at end of file
diff --git a/frontend/src/plugins/utils.ts b/frontend/src/plugins/utils.ts
new file mode 100644
index 0000000..d1578ed
--- /dev/null
+++ b/frontend/src/plugins/utils.ts
@@ -0,0 +1,104 @@
+import { i18n } from "@/locales"
+
+type OBJ = {
+ [key: string]: any
+}
+
+export const FindDiff = {
+ deepCompare(obj1: any, obj2: any): boolean {
+ // Check if the types of both objects are the same
+ if (typeof obj1 !== typeof obj2) {
+ return false
+ }
+
+ // Check if both objects are arrays
+ if (Array.isArray(obj1) && Array.isArray(obj2)) {
+ if (obj1.length !== obj2.length) {
+ return false
+ }
+
+ for (let i = 0; i < obj1.length; i++) {
+ if (!this.deepCompare(obj1[i], obj2[i])) {
+ return false
+ }
+ }
+ return true
+ }
+
+ // Check if both objects are plain objects
+ if (typeof obj1 === 'object' && typeof obj2 === 'object' && obj1 !== null && obj2 !== null) {
+ const keys1 = Object.keys(obj1).filter(key => obj1[key] !== undefined)
+ const keys2 = Object.keys(obj2).filter(key => obj2[key] !== undefined)
+
+ if (keys1.length !== keys2.length) {
+ return false
+ }
+
+ for (const key of keys1) {
+ if (!keys2.includes(key) || !this.deepCompare(obj1[key], obj2[key])) {
+ return false
+ }
+ }
+ return true
+ }
+
+ // Check primitive values
+ return obj1 === obj2
+ }
+}
+
+const ONE_KB = 1024
+const ONE_MB = ONE_KB * 1024
+const ONE_GB = ONE_MB * 1024
+const ONE_TB = ONE_GB * 1024
+const ONE_PB = ONE_TB * 1024
+
+export const HumanReadable = {
+ sizeFormat(size:number, fix:number=2) {
+ if (!size || size<0) return "-"
+ if (size < ONE_KB) {
+ return size.toFixed(0) + " " + i18n.global.t('stats.B')
+ } else if (size < ONE_MB) {
+ return (size / ONE_KB).toFixed(fix) + " " + i18n.global.t('stats.KB')
+ } else if (size < ONE_GB) {
+ return (size / ONE_MB).toFixed(fix) + " " + i18n.global.t('stats.MB')
+ } else if (size < ONE_TB) {
+ return (size / ONE_GB).toFixed(fix) + " " + i18n.global.t('stats.GB')
+ } else if (size < ONE_PB) {
+ return (size / ONE_TB).toFixed(fix) + " " + i18n.global.t('stats.TB')
+ } else {
+ return (size / ONE_PB).toFixed(fix) + " " + i18n.global.t('stats.PB')
+ }
+ },
+ packetFormat(size:number, fix:number=2) {
+ if (!size || size<0) return "-"
+ if (size < 1000) {
+ return size.toFixed(0) + " " + i18n.global.t('stats.p')
+ } else if (size < 1000000) {
+ return (size / 1000).toFixed(fix) + " " + i18n.global.t('stats.Kp')
+ } else if (size < 1000000000) {
+ return (size / 1000000).toFixed(fix) + " " + i18n.global.t('stats.Mp')
+ } else {
+ return (size / 1000000000).toFixed(fix) + " " + i18n.global.t('stats.Gp')
+ }
+ },
+ formatSecond(second:number): string {
+ if (!second || second<0) return "-"
+ if (second < 60) {
+ return second.toFixed(0) + i18n.global.t('date.s')
+ } else if (second < 3600) {
+ return (second / 60).toFixed(0) + i18n.global.t('date.m')
+ } else if (second < 3600 * 24) {
+ return (second / 3600).toFixed(0) + i18n.global.t('date.h')
+ }
+ const day = Math.floor(second / 3600 / 24)
+ const remain = Math.floor((second/3600) - (day*24))
+ return day + i18n.global.t('date.d') + (remain > 0 ? ' ' + remain + i18n.global.t('date.h') : '')
+ },
+ remainedDays(exp:number): string {
+ if (exp == 0) return i18n.global.t('unlimited')
+ const now = Date.now()/1000
+ if (exp < now) return i18n.global.t('date.expired')
+ return Math.floor((exp - now) / (3600*24)) + " " + i18n.global.t('date.d')
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/plugins/vuetify.ts b/frontend/src/plugins/vuetify.ts
new file mode 100644
index 0000000..fdcde87
--- /dev/null
+++ b/frontend/src/plugins/vuetify.ts
@@ -0,0 +1,56 @@
+/**
+ * plugins/vuetify.ts
+ *
+ * Framework documentation: https://vuetifyjs.com`
+ */
+
+// Styles
+import '@mdi/font/css/materialdesignicons.css'
+import 'vuetify/styles/main.css'
+
+import colors from 'vuetify/util/colors'
+import { fa, en, vi, zhHans, zhHant, ru } from 'vuetify/locale'
+
+// Composables
+import { createVuetify } from 'vuetify'
+
+// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
+export default createVuetify({
+ defaults: {
+ VRow: { density: 'compact' },
+ VTextField: {
+ variant: 'solo-filled',
+ },
+ VSelect: {
+ variant: 'solo-filled',
+ },
+ VCombobox: {
+ variant: 'solo-filled',
+ },
+ VTextarea: {
+ variant: 'solo-filled',
+ },
+ },
+ theme: {
+ defaultTheme: localStorage.getItem('theme') ?? 'system',
+ themes: {
+ light: {
+ colors: {
+ error: '#FF5252',
+ background: colors.grey.lighten4,
+ },
+ },
+ dark: {
+ colors: {
+ primary: colors.blue.darken4,
+ error: colors.red.accent3,
+ },
+ },
+ },
+ },
+ locale: {
+ locale: localStorage.getItem("locale") ?? 'en',
+ fallback: 'en',
+ messages: { en, fa, vi, zhHans, zhHant, ru },
+ },
+})
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
new file mode 100644
index 0000000..69088b3
--- /dev/null
+++ b/frontend/src/router/index.ts
@@ -0,0 +1,123 @@
+// Composables
+import { createRouter, createWebHistory } from 'vue-router'
+import Login from '@/views/Login.vue'
+import Data from '@/store/modules/data'
+
+const routes = [
+ {
+ path: '/login',
+ name: 'pages.login',
+ component: Login,
+ },
+ {
+ path: '/',
+ component: () => import('@/layouts/default/Default.vue'),
+ meta: { requiresAuth: true },
+ children: [
+ {
+ path: '/',
+ name: 'pages.home',
+ component: () => import('@/views/Home.vue'),
+ },
+ {
+ path: '/inbounds',
+ name: 'pages.inbounds',
+ component: () => import('@/views/Inbounds.vue'),
+ },
+ {
+ path: '/clients',
+ name: 'pages.clients',
+ component: () => import('@/views/Clients.vue'),
+ },
+ {
+ path: '/outbounds',
+ name: 'pages.outbounds',
+ component: () => import('@/views/Outbounds.vue'),
+ },
+ {
+ path: '/services',
+ name: 'pages.services',
+ component: () => import('@/views/Services.vue'),
+ },
+ {
+ path: '/endpoints',
+ name: 'pages.endpoints',
+ component: () => import('@/views/Endpoints.vue'),
+ },
+ {
+ path: '/rules',
+ name: 'pages.rules',
+ component: () => import('@/views/Rules.vue'),
+ },
+ {
+ path: '/tls',
+ name: 'pages.tls',
+ component: () => import('@/views/Tls.vue'),
+ },
+ {
+ path: '/basics',
+ name: 'pages.basics',
+ component: () => import('@/views/Basics.vue'),
+ },
+ {
+ path: '/dns',
+ name: 'pages.dns',
+ component: () => import('@/views/Dns.vue'),
+ },
+ {
+ path: '/admins',
+ name: 'pages.admins',
+ component: () => import('@/views/Admins.vue'),
+ },
+ {
+ path: '/settings',
+ name: 'pages.settings',
+ component: () => import('@/views/Settings.vue'),
+ },
+ ],
+ },
+]
+
+const router = createRouter({
+ history: createWebHistory((window as any).BASE_URL),
+ routes,
+})
+
+const DEFAULT_TITLE = 'S-UI'
+let intervalId:any
+
+// Navigation guard to check authentication state
+router.beforeEach((to) => {
+ // Check the session cookie
+ const sessionCookie = document.cookie.split(';').find(cookie => cookie.trim().startsWith('s-ui='))
+ const isAuthenticated = !!sessionCookie
+
+ // If the route requires authentication and the user is not authenticated, redirect to /login
+ if (to.meta.requiresAuth && !isAuthenticated) {
+ return '/login'
+ }
+ if (to.path === '/login' && isAuthenticated) {
+ // If already authenticated and visiting /login, redirect to '/'
+ return '/'
+ }
+
+ // Load default data
+ if (to.path !== '/login') {
+ loadDataInterval()
+ } else {
+ if (intervalId) {
+ clearInterval(intervalId)
+ intervalId = undefined
+ }
+ }
+})
+
+const loadDataInterval = () => {
+ if (intervalId) return
+ Data().loadData()
+ intervalId = setInterval(() => {
+ Data().loadData()
+ }, 10000)
+}
+
+export default router
diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts
new file mode 100644
index 0000000..7b565e2
--- /dev/null
+++ b/frontend/src/store/index.ts
@@ -0,0 +1,5 @@
+import { createPinia } from 'pinia'
+
+const pinia = createPinia()
+
+export default pinia
\ No newline at end of file
diff --git a/frontend/src/store/modules/data.ts b/frontend/src/store/modules/data.ts
new file mode 100644
index 0000000..0853565
--- /dev/null
+++ b/frontend/src/store/modules/data.ts
@@ -0,0 +1,143 @@
+import HttpUtils from '@/plugins/httputil'
+import { defineStore } from 'pinia'
+import { push } from 'notivue'
+import { i18n } from '@/locales'
+import { Inbound } from '@/types/inbounds'
+import { Client } from '@/types/clients'
+
+const Data = defineStore('Data', {
+ state: () => ({
+ lastLoad: 0,
+ reloadItems: localStorage.getItem("reloadItems")?.split(',')?? [],
+ subURI: "",
+ enableTraffic: false,
+ onlines: {inbound: [], outbound: [], user: []},
+ config: {},
+ inbounds: [],
+ outbounds: [],
+ services: [],
+ endpoints: [],
+ clients: [],
+ tlsConfigs: [],
+ }),
+ actions: {
+ async loadData() {
+ const msg = await HttpUtils.get('api/load', this.lastLoad >0 ? {lu: this.lastLoad} : {} )
+ if(msg.success) {
+ this.onlines = msg.obj.onlines
+ if (msg.obj.lastLog) {
+ push.error({
+ title: i18n.global.t('error.core'),
+ duration: 5000,
+ message: msg.obj.lastLog
+ })
+ }
+
+ if (msg.obj.config) {
+ this.setNewData(msg.obj)
+ }
+ }
+ },
+ setNewData(data: any) {
+ this.lastLoad = Math.floor((new Date()).getTime()/1000)
+ if (data.subURI) this.subURI = data.subURI
+ if (data.enableTraffic) this.enableTraffic = data.enableTraffic
+ if (data.config) this.config = data.config
+ if (Object.hasOwn(data, 'clients')) this.clients = data.clients ?? []
+ if (Object.hasOwn(data, 'inbounds')) this.inbounds = data.inbounds ?? []
+ if (Object.hasOwn(data, 'outbounds')) this.outbounds = data.outbounds ?? []
+ if (Object.hasOwn(data, 'services')) this.services = data.services ?? []
+ if (Object.hasOwn(data, 'endpoints')) this.endpoints = data.endpoints ?? []
+ if (Object.hasOwn(data, 'tls')) this.tlsConfigs = data.tls ?? []
+ },
+ async loadInbounds(ids: number[]): Promise {
+ const options = ids.length > 0 ? {id: ids.join(",")} : {}
+ const msg = await HttpUtils.get('api/inbounds', options)
+ if(msg.success) {
+ return msg.obj.inbounds
+ }
+ return []
+ },
+ async loadClients(id: number): Promise {
+ const options = id > 0 ? {id: id} : {}
+ const msg = await HttpUtils.get('api/clients', options)
+ if(msg.success) {
+ return msg.obj.clients[0]??{}
+ }
+ return {}
+ },
+ async save (object: string, action: string, data: any, initUsers?: number[]): Promise {
+ let postData = {
+ object: object,
+ action: action,
+ data: JSON.stringify(data, null, 2),
+ initUsers: initUsers?.join(',') ?? undefined
+ }
+ const msg = await HttpUtils.post('api/save', postData)
+ if (msg.success) {
+ const objectName = ['tls', 'config'].includes(object) ? object : object.substring(0, object.length - 1)
+ push.success({
+ title: i18n.global.t('success'),
+ duration: 5000,
+ message: i18n.global.t('actions.' + action) + " " + i18n.global.t('objects.' + objectName)
+ })
+ this.setNewData(msg.obj)
+ }
+ return msg.success
+ },
+ // Check duplicate client name
+ checkClientName (id: number, newName: string): boolean {
+ const oldName = id > 0 ? this.clients.findLast((i: any) => i.id == id)?.name : null
+ if (newName != oldName && this.clients.findIndex((c: any) => c.name == newName) != -1) {
+ push.error({
+ message: i18n.global.t('error.dplData') + ": " + i18n.global.t('client.name')
+ })
+ return true
+ }
+ return false
+ },
+ // Check bulk client names
+ checkBulkClientNames (names: string[]): boolean {
+ const newNames = new Set(names)
+ const oldNames = new Set(this.clients.map((c: any) => c.name))
+ const allNames = new Set([...oldNames, ...newNames])
+ if (newNames.size != names.length || oldNames.size + newNames.size != allNames.size) {
+ push.error({
+ message: i18n.global.t('error.dplData') + ": " + i18n.global.t('client.name')
+ })
+ return true
+ }
+ return false
+ },
+ // check duplicate tag
+ checkTag (object: string, id: number, tag: string): boolean {
+ let objects = []
+ switch (object) {
+ case 'inbound':
+ objects = this.inbounds
+ break
+ case 'outbound':
+ objects = this.outbounds
+ break
+ case 'service':
+ objects = this.services
+ break
+ case 'endpoint':
+ objects = this.endpoints
+ break
+ default:
+ return false
+ }
+ const oldObject = id > 0 ? objects.findLast((i: any) => i.id == id) : null
+ if (tag != oldObject?.tag && objects.findIndex((i: any) => i.tag == tag) != -1) {
+ push.error({
+ message: i18n.global.t('error.dplData') + ": " + i18n.global.t('objects.tag')
+ })
+ return true
+ }
+ return false
+ },
+ }
+})
+
+export default Data
\ No newline at end of file
diff --git a/frontend/src/styles/settings.scss b/frontend/src/styles/settings.scss
new file mode 100644
index 0000000..ef640dd
--- /dev/null
+++ b/frontend/src/styles/settings.scss
@@ -0,0 +1,39 @@
+/**
+ * src/styles/settings.scss
+ *
+ * Configures SASS variables and Vuetify overwrites
+ */
+
+// https://vuetifyjs.com/features/sass-variables/`
+// @use 'vuetify/settings' with (
+// $color-pack: false
+// );
+@font-face {
+ font-display: swap;
+ font-family: 'Vazirmatn';
+ font-style: normal;
+ font-weight: 400;
+ src: url('@/assets/Vazirmatn-UI-NL-Regular.woff2') format('woff2');
+ unicode-range: U+0600-06FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE80-FEFC, U+0030-0039;
+}
+$body-font-family: "Vazirmatn";
+
+$typoOptions: text-h1, text-sm-h1, text-md-h1, text-lg-h1, text-h2, text-sm-h2,
+ text-md-h2, text-lg-h2, text-h3, text-sm-h3, text-md-h3, text-lg-h3, text-h4,
+ text-sm-h4, text-md-h4, text-lg-h4, text-h5, text-sm-h5, text-md-h5,
+ text-lg-h5, text-h6, text-sm-h6, text-md-h6, text-lg-h6, headline, title,
+ subtitle-1, subtitle-2, text-body-1, text-sm-body-1, text-md-body-1,
+ text-lg-body-1, text-body-2, text-sm-body-2, text-md-body-2, text-lg-body-2,
+ text-caption;
+body {
+ font-family: -apple-system, BlinkMacSystemFont, $body-font-family, sans-serif !important;
+ @each $typoOption in $typoOptions {
+ .#{$typoOption} {
+ font-family: -apple-system, BlinkMacSystemFont, $body-font-family, sans-serif !important;
+ }
+ }
+}
+
+.v-btn {
+ letter-spacing: 0;
+}
\ No newline at end of file
diff --git a/frontend/src/types/brutal.ts b/frontend/src/types/brutal.ts
new file mode 100644
index 0000000..8ba9b3e
--- /dev/null
+++ b/frontend/src/types/brutal.ts
@@ -0,0 +1,5 @@
+export interface Brutal {
+ enabled: boolean
+ up_mbps: number
+ down_mbps: number
+}
\ No newline at end of file
diff --git a/frontend/src/types/clients.ts b/frontend/src/types/clients.ts
new file mode 100644
index 0000000..0d4ea42
--- /dev/null
+++ b/frontend/src/types/clients.ts
@@ -0,0 +1,185 @@
+import RandomUtil from "@/plugins/randomUtil"
+
+export interface Link {
+ type: "local" | "external" | "sub"
+ remark?: string
+ uri: string
+}
+
+export interface Client {
+ id?: number
+ enable: boolean
+ name: string
+ config?: Config
+ inbounds: number[]
+ links?: Link[]
+ volume: number
+ expiry: number
+ up: number
+ down: number
+ desc: string
+ group: string
+ delayStart?: boolean
+ autoReset?: boolean
+ resetDays?: number
+ nextReset?: number
+ totalUp?: number
+ totalDown?: number
+}
+
+const defaultClient: Client = {
+ enable: true,
+ name: "",
+ config: {},
+ inbounds: [],
+ links: [],
+ volume: 0,
+ expiry: 0,
+ up: 0,
+ down: 0,
+ desc: "",
+ group: "",
+ delayStart: false,
+ autoReset: false,
+ resetDays: 0,
+ nextReset: 0,
+ totalUp: 0,
+ totalDown: 0,
+}
+
+type Config = {
+ [key: string]: {
+ name?: string
+ username?: string
+ [key: string]: any
+ }
+}
+
+export function updateConfigs(configs: Config, newUserName: string): Config {
+ for (const key in configs) {
+ if (configs.hasOwnProperty(key)) {
+ const config = configs[key]
+ if (config.hasOwnProperty("name")) {
+ config.name = newUserName
+ } else if (config.hasOwnProperty("username")) {
+ config.username = newUserName
+ }
+ }
+ }
+ return configs
+}
+
+export function shuffleConfigs(configs: Config, key?: string) {
+ const keys = key ? [key] : Object.keys(configs)
+ keys.forEach(k => {
+ switch (k) {
+ case "mixed":
+ case "socks":
+ case "http":
+ case "anytls":
+ case "trojan":
+ case "naive":
+ case "hysteria2":
+ configs[k].password = RandomUtil.randomSeq(10)
+ break
+ case "shadowsocks":
+ configs[k].password = RandomUtil.randomShadowsocksPassword(32)
+ break
+ case "shadowsocks16":
+ configs[k].password = RandomUtil.randomShadowsocksPassword(16)
+ break
+ case "shadowtls":
+ configs[k].password = RandomUtil.randomShadowsocksPassword(32)
+ break
+ case "hysteria":
+ configs[k].auth_str = RandomUtil.randomSeq(10)
+ break
+ case "tuic":
+ configs[k].password = RandomUtil.randomSeq(10)
+ configs[k].uuid = RandomUtil.randomUUID()
+ break
+ case "vmess":
+ case "vless":
+ configs[k].uuid = RandomUtil.randomUUID()
+ break
+ }
+ })
+}
+
+export function randomConfigs(user: string): Config {
+ const mixedPassword = RandomUtil.randomSeq(10)
+ const ssPassword16 = RandomUtil.randomShadowsocksPassword(16)
+ const ssPassword32 = RandomUtil.randomShadowsocksPassword(32)
+ const uuid = RandomUtil.randomUUID()
+ return {
+ mixed: {
+ username: user,
+ password: mixedPassword,
+ },
+ socks: {
+ username: user,
+ password: mixedPassword,
+ },
+ http: {
+ username: user,
+ password: mixedPassword,
+ },
+ shadowsocks: {
+ name: user,
+ password: ssPassword32,
+ },
+ shadowsocks16: {
+ name: user,
+ password: ssPassword16,
+ },
+ shadowtls: {
+ name: user,
+ password: ssPassword32,
+ },
+ vmess: {
+ name: user,
+ uuid: uuid,
+ alterId: 0,
+ },
+ vless: {
+ name: user,
+ uuid: uuid,
+ flow: "xtls-rprx-vision",
+ },
+ anytls: {
+ name: user,
+ password: mixedPassword,
+ },
+ trojan: {
+ name: user,
+ password: mixedPassword,
+ },
+ naive: {
+ username: user,
+ password: mixedPassword,
+ },
+ hysteria: {
+ name: user,
+ auth_str: mixedPassword,
+ },
+ tuic: {
+ name: user,
+ uuid: uuid,
+ password: mixedPassword,
+ },
+ hysteria2: {
+ name: user,
+ password: mixedPassword,
+ },
+ }
+}
+
+export function createClient(json?: Partial): Client {
+ defaultClient.name = RandomUtil.randomSeq(8)
+ const defaultObject: Client = { ...defaultClient, ...(json || {}) }
+
+ // Add missing config
+ defaultObject.config = { ...randomConfigs(defaultObject.name), ...defaultObject.config }
+
+ return defaultObject
+}
diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts
new file mode 100644
index 0000000..581a5eb
--- /dev/null
+++ b/frontend/src/types/config.ts
@@ -0,0 +1,116 @@
+import { Inbound } from './inbounds'
+import { Outbound } from './outbounds'
+import { Dns } from './dns'
+import { Dial } from './dial'
+
+interface Log {
+ disabled?: boolean
+ level?: string
+ output?: string
+ timestamp?: boolean
+}
+
+export interface Ntp extends Dial{
+ enabled?: boolean
+ server: string
+ server_port?: number
+ interval?: string
+}
+
+interface Route {
+ rules: RouteRule[] | RouteRuleLogical[]
+ rule_set: RouteRuleSet[]
+ final?: string,
+ auto_detect_interface?: boolean
+ default_interface?: string
+ default_mark?: number
+ default_domain_resolver: string
+}
+
+interface RouteRule {
+ inbound?: string[] | string
+ ip_version?: 4 | 6,
+ network?: "tcp" | "udp" | "icmp"
+ auth_user?: string[]
+ protocol?: string[] | string
+ domain?: string[] | string
+ domain_suffix?: string[] | string
+ domain_keyword?: string[] | string
+ domain_regex?: string[] | string
+ source_ip_cidr?: string[] | string
+ source_ip_is_private?: boolean
+ ip_cidr?: string[] | string
+ ip_is_private?: boolean
+ source_port?: number[] | number
+ source_port_range?: string[] | string
+ port?: number[] | number
+ port_range?: string[] | string
+ clash_mode?: string
+ rule_set?: string[] | string
+ invert?: boolean
+ outbound: string
+}
+
+interface RouteRuleLogical {
+ type: "logical"
+ mode: "and" | "or"
+ rules: RouteRule[]
+ invert?: boolean
+ outbound: string
+}
+
+interface RouteRuleSet {
+ type: string
+ tag: string
+ format: string
+ path?: string
+ url?: string
+ download_detour?: string
+ update_interval?: string
+}
+
+interface Experimental {
+ cache_file?: CacheFile
+ clash_api?: ClashApi
+ v2ray_api?: V2rayApi
+}
+
+interface CacheFile {
+ enabled?: boolean
+ path?: string
+ cache_id?: string
+ store_fakeip?: boolean
+}
+
+interface V2rayApi {
+ listen: string
+ stats: V2rayApiStats
+}
+
+export interface V2rayApiStats {
+ enabled: boolean
+ inbounds: string[]
+ outbounds: string[]
+ users: string[]
+}
+
+interface ClashApi {
+ external_controller?: string
+ external_ui?: string
+ external_ui_download_url?: string
+ external_ui_download_detour?: string
+ secret?: string
+ default_mode?: string
+ access_control_allow_origin?: string[]
+ access_control_allow_private_network?: boolean
+}
+
+export interface Config {
+ log: Log
+ dns: Dns
+ ntp?: Ntp
+ inbounds: Inbound[]
+ outbounds: Outbound[]
+ route: Route
+ experimental: Experimental
+}
\ No newline at end of file
diff --git a/frontend/src/types/dial.ts b/frontend/src/types/dial.ts
new file mode 100644
index 0000000..9b624c9
--- /dev/null
+++ b/frontend/src/types/dial.ts
@@ -0,0 +1,18 @@
+export interface Dial {
+ detour?: string
+ bind_interface?: string
+ inet4_bind_address?: string
+ inet6_bind_address?: string
+ bind_address_no_port?: boolean
+ routing_mark?: number
+ reuse_addr?: boolean
+ connect_timeout?: string
+ tcp_fast_open?: boolean
+ tcp_multi_path?: boolean
+ udp_fragment?: boolean
+ fallback_delay?: string
+ domain_resolver?: string | any
+ disable_tcp_keep_alive?: boolean
+ tcp_keep_alive?: string
+ tcp_keep_alive_interval?: string
+}
\ No newline at end of file
diff --git a/frontend/src/types/dns.ts b/frontend/src/types/dns.ts
new file mode 100644
index 0000000..de60dd0
--- /dev/null
+++ b/frontend/src/types/dns.ts
@@ -0,0 +1,127 @@
+export interface Dns {
+ servers: DnsServer[]
+ rules: dnsRule[]
+ final?: string
+ strategy?: string
+ disable_cache?: boolean,
+ disable_expire?: boolean,
+ independent_cache?: boolean,
+ cache_capacity?: number,
+ reverse_mapping?: boolean,
+ client_subnet?: string,
+}
+
+export const DnsTypes = {
+ Local: 'local',
+ Hosts: 'hosts',
+ TCP: 'tcp',
+ UDP: 'udp',
+ TLS: 'tls',
+ QUIC: 'quic',
+ HTTPS: 'https',
+ HTTP3: 'h3',
+ DHCP: 'dhcp',
+ FakeIP: 'fakeip',
+ Tailscale: 'tailscale',
+ Resolved: 'resolved',
+}
+
+export type DnsType = typeof DnsTypes[keyof typeof DnsTypes]
+
+type InterfaceMap = {
+ [Key in keyof typeof DnsTypes]: {
+ type: string
+ [otherProperties: string]: any
+ }
+}
+
+export type DnsServer = InterfaceMap[keyof InterfaceMap]
+
+const defaultValues: Record = {
+ local: { type: 'local' },
+ hosts: { type: 'hosts', path: ['/etc/hosts'] },
+ tcp: { type: 'tcp', server_port: 53 },
+ udp: { type: 'udp', server_port: 53 },
+ tls: { type: 'tls', server_port: 853, tls: {} },
+ quic: { type: 'quic', server_port: 853, tls: {} },
+ https: { type: 'https', server_port: 443, tls: {}, headers: {} },
+ h3: { type: 'h3', server_port: 443, tls: {}, headers: {} },
+ predefined: { type: 'predefined', rcode: 'NOERROR' },
+ dhcp: { type: 'dhcp' },
+ fakeip: { type: 'fakeip', inet4_range: '198.18.0.0/15', inet6_range: 'fc00::/18' },
+ tailscale: { type: 'tailscale' },
+ resolved: { type: 'resolved' },
+}
+export function createDnsServer(type: string, json?: Partial): DnsServer {
+ const defaultObject: DnsServer = { ...defaultValues[type], ...(json || {}) }
+ return defaultObject
+}
+
+interface generalDnsRule {
+ invert: boolean
+ action: 'route' | 'route-options' | 'reject' | 'predefined'
+ server?: string
+ strategy?: string
+ disable_cache?: boolean
+ rewrite_ttl?: number
+ client_subnet?: string
+ method?: string
+ no_drop?: boolean
+ rcode?: string
+ answer?: string[]
+ ns?: string[]
+ extra?: string[]
+}
+
+export const actionDnsRuleKeys = [
+ 'invert',
+ 'action',
+ 'server',
+ 'strategy',
+ 'disable_cache',
+ 'rewrite_ttl',
+ 'client_subnet',
+ 'method',
+ 'no_drop',
+ 'rcode',
+ 'answer',
+ 'ns',
+ 'extra',
+]
+export interface logicalDnsRule extends generalDnsRule {
+ type: 'logical' | 'simple'
+ mode: 'and' | 'or'
+ rules: dnsRule[]
+}
+
+export interface dnsRule extends generalDnsRule {
+ inbound?: string[]
+ ip_version?: 4 | 6
+ query_type?: string
+ network?: string[]
+ auth_user?: string[]
+ protocol?: string[]
+ domain?: string[]
+ domain_suffix?: string[]
+ domain_keyword?: string[]
+ domain_regex?: string[]
+ source_ip_cidr?: string[]
+ source_ip_is_private?: boolean
+ ip_cidr?: string[]
+ ip_is_private: boolean
+ ip_accept_any: boolean
+ source_port?: number[]
+ source_port_range?: string[]
+ port?: number[]
+ port_range?: string[]
+ process_name?: string[]
+ process_path?: string[]
+ process_path_regex?: string[]
+ package_name?: string[]
+ user?: string[]
+ user_id?: number[]
+ clash_mode?: string
+ rule_set?: string[]
+ rule_set_ip_cidr_match_source?: boolean
+ rule_set_ip_cidr_accept_empty?: boolean
+}
diff --git a/frontend/src/types/endpoints.ts b/frontend/src/types/endpoints.ts
new file mode 100644
index 0000000..56540e7
--- /dev/null
+++ b/frontend/src/types/endpoints.ts
@@ -0,0 +1,82 @@
+import { Dial } from "./dial"
+
+export const EpTypes = {
+ Wireguard: 'wireguard',
+ Warp: 'warp',
+ Tailscale: 'tailscale',
+}
+
+type EpType = typeof EpTypes[keyof typeof EpTypes]
+
+interface EndpointBasics {
+ id: number
+ type: EpType
+ tag: string
+}
+
+export interface WgPeer {
+ address: string
+ port: number
+ public_key: string
+ pre_shared_key?: string
+ allowed_ips?: string[]
+ persistent_keepalive_interval?: number
+ reserved?: number[]
+}
+
+export interface WireGuard extends EndpointBasics, Dial {
+ system?: boolean
+ name?: string
+ mtu?: number
+ address: string[]
+ private_key: string
+ listen_port: number
+ peers: WgPeer[]
+ udp_timeout?: string
+ workers?: number
+ ext: any
+}
+
+export interface Warp extends WireGuard {}
+
+export interface Tailscale extends EndpointBasics, Dial {
+ state_directory?: string
+ auth_key?: string
+ control_url?: string
+ ephemeral?: boolean
+ hostname?: string
+ accept_routes?: boolean
+ exit_node?: string
+ exit_node_allow_lan_access?: boolean
+ advertise_routes?: string[]
+ advertise_exit_node?: boolean
+ relay_server_port?: number
+ relay_server_static_endpoints?: string[]
+ system_interface?: boolean
+ system_interface_name?: string
+ system_interface_mtu?: number
+ udp_timeout?: string
+}
+
+// Create interfaces dynamically based on EpTypes keys
+type InterfaceMap = {
+ [Key in keyof typeof EpTypes]: {
+ type: string
+ [otherProperties: string]: any // You can add other properties as needed
+ }
+}
+
+// Create union type from InterfaceMap
+export type Endpoint = InterfaceMap[keyof InterfaceMap]
+
+// Create defaultValues object dynamically
+const defaultValues: Record = {
+ wireguard: { type: EpTypes.Wireguard, address: ['10.0.0.2/32','fe80::2/128'], private_key: '', listen_port: 0 },
+ warp: { type: EpTypes.Warp, address: [], private_key: '', listen_port: 0, mtu: 1420, peers: [{ address: '', port: 0, public_key: ''}] },
+ tailscale: { type: EpTypes.Tailscale, domain_resolver: 'local' },
+}
+
+export function createEndpoint(type: string,json?: Partial): Endpoint {
+ const defaultObject: Endpoint = { ...defaultValues[type], ...(json || {}) }
+ return defaultObject
+}
\ No newline at end of file
diff --git a/frontend/src/types/inbounds.ts b/frontend/src/types/inbounds.ts
new file mode 100644
index 0000000..841c07a
--- /dev/null
+++ b/frontend/src/types/inbounds.ts
@@ -0,0 +1,240 @@
+import { iMultiplex } from "./multiplex"
+import { iTls } from "./tls"
+import { Dial } from "./dial"
+import { Transport } from "./transport"
+
+export const InTypes = {
+ Direct: 'direct',
+ Mixed: 'mixed',
+ SOCKS: 'socks',
+ HTTP: 'http',
+ Shadowsocks: 'shadowsocks',
+ VMess: 'vmess',
+ Trojan: 'trojan',
+ Naive: 'naive',
+ Hysteria: 'hysteria',
+ ShadowTLS: 'shadowtls',
+ TUIC: 'tuic',
+ Hysteria2: 'hysteria2',
+ VLESS: 'vless',
+ AnyTls: 'anytls',
+ Tun: 'tun',
+ Redirect: 'redirect',
+ TProxy: 'tproxy',
+}
+
+type InType = typeof InTypes[keyof typeof InTypes]
+
+export interface Addr {
+ server: string
+ server_port: number
+ tls?: boolean
+ insecure?: boolean
+ server_name?: string
+ remark?: string
+}
+
+export interface Listen {
+ listen: string
+ listen_port: number
+ tcp_fast_open?: boolean
+ tcp_multi_path?: boolean
+ udp_fragment?: boolean
+ udp_timeout?: string
+ detour?: string
+ disable_tcp_keep_alive?: boolean
+ tcp_keep_alive?: string
+ tcp_keep_alive_interval?: string
+}
+
+interface InboundBasics extends Listen {
+ id: number
+ type: InType
+ tag: string
+ tls_id: number
+ addrs?: Addr[]
+ out_json?: any
+}
+
+interface ShadowTLSHandShake extends Dial {
+ server: string
+ server_port: number
+}
+
+export interface Direct extends InboundBasics {
+ network?: "udp" | "tcp"
+ override_address?: string
+ override_port?: number
+}
+export interface Mixed extends InboundBasics {}
+export interface SOCKS extends InboundBasics {}
+export interface HTTP extends InboundBasics {}
+export interface Shadowsocks extends InboundBasics {
+ method: string
+ password: string
+ network?: "udp" | "tcp"
+ multiplex?: iMultiplex
+ managed?: boolean
+}
+export interface VMess extends InboundBasics {
+ tls: iTls
+ multiplex?: iMultiplex
+ transport?: Transport
+}
+export interface Trojan extends InboundBasics {
+ tls: iTls
+ fallback?: {
+ server: string
+ server_port: number
+ }
+ multiplex?: iMultiplex
+ transport?: Transport
+}
+export interface Naive extends InboundBasics {
+ tls: iTls,
+ quic_congestion_control?: "" | "bbr" | "bbr2" | "cubic" | "reno"
+}
+export interface Hysteria extends InboundBasics {
+ up_mbps: number
+ down_mbps: number
+ obfs?: string
+ recv_window_conn?: number
+ recv_window_client?: number
+ max_conn_client?: number
+ disable_mtu_discovery?: boolean
+}
+export interface ShadowTLS extends InboundBasics {
+ version: 1|2|3
+ password?: string
+ handshake: ShadowTLSHandShake
+ handshake_for_server_name?: {
+ [server_name: string]: ShadowTLSHandShake
+ }
+ strict_mode?: boolean
+ wildcard_sni?: string
+}
+export interface VLESS extends InboundBasics {
+ multiplex?: iMultiplex
+ transport?: Transport
+ tls: iTls
+}
+
+export interface AnyTls extends InboundBasics {
+ padding_scheme: string[]
+ tls: iTls
+}
+export interface TUIC extends InboundBasics {
+ congestion_control: ""|"cubic"|"new_reno"|"bbr"
+ auth_timeout?: string
+ zero_rtt_handshake?: boolean
+ heartbeat?: string
+}
+export interface Hysteria2 extends InboundBasics {
+ up_mbps?: number
+ down_mbps?: number
+ obfs?: {
+ type?: "salamander"
+ password: string
+ }
+ ignore_client_bandwidth?: boolean
+ masquerade?: string | {
+ type: string
+ directory?: string
+ url?: string
+ rewrite_host?: boolean
+ status_code?: number
+ headers?: Headers[]
+ content?: string
+ }
+ brutal_debug?: boolean
+}
+export interface Tun extends InboundBasics {
+ interface_name?: string
+ address?: string[]
+ mtu?: number
+ endpoint_independent_nat?: boolean
+ udp_timeout?: string
+ stack?: string
+ auto_route?: boolean
+ strict_route?: boolean
+ auto_redirect?: boolean
+ exclude_mptcp?: boolean
+ auto_redirect_iproute2_fallback_rule_index?: number
+ // auto_redirect_input_mark?: string
+ // auto_redirect_output_mark?: string
+ // route_address?: string[]
+ // route_exclude_address?: string[]
+ // include_interface?: string[]
+ // exclude_interface?: string[]
+ // include_uid?: string[]
+ // include_uid_range?: string[]
+ // exclude_uid?: number[]
+ // exclude_uid_range?: string[]
+ // include_android_user?: number[]
+ // include_package?: string[]
+ // exclude_package?: string[]
+}
+export interface Redirect extends InboundBasics {}
+export interface TProxy extends InboundBasics {
+ network?: "udp" | "tcp"
+}
+
+// Create interfaces dynamically based on InTypes keys
+type InterfaceMap = {
+ direct: Direct
+ mixed: Mixed
+ socks: SOCKS
+ http: SOCKS
+ shadowsocks: Shadowsocks
+ vmess: VMess
+ trojan: Trojan
+ naive: Naive
+ hysteria: Hysteria
+ shadowtls: ShadowTLS
+ tuic: TUIC
+ hysteria2: Hysteria2
+ vless: VLESS
+ anytls: AnyTls
+ tun: Tun
+ redirect: Redirect
+ tproxy: TProxy
+}
+
+// Create union type from InterfaceMap
+export type Inbound = InterfaceMap[keyof InterfaceMap]
+
+// Create defaultValues object dynamically
+const defaultValues: Record = {
+ direct: { type: InTypes.Direct },
+ mixed: { type: InTypes.Mixed },
+ socks: { type: InTypes.SOCKS },
+ http: { type: InTypes.HTTP, tls_id: 0 },
+ shadowsocks: { type: InTypes.Shadowsocks, method: 'none' },
+ vmess: { type: InTypes.VMess, tls_id: 0, transport: {} },
+ trojan: { type: InTypes.Trojan, tls_id: 0, transport: {} },
+ naive: { type: InTypes.Naive, tls_id: 0 },
+ hysteria: { type: InTypes.Hysteria, up_mbps: 100, down_mbps: 100, tls_id: 0 },
+ shadowtls: { type: InTypes.ShadowTLS, version: 3, handshake: {}, handshake_for_server_name: {} },
+ tuic: { type: InTypes.TUIC, congestion_control: "cubic", tls_id: 0 },
+ hysteria2: { type: InTypes.Hysteria2, tls_id: 0 },
+ vless: { type: InTypes.VLESS, tls_id: 0, transport: {} },
+ anytls: { type: InTypes.AnyTls, tls_id: 0, padding_scheme: [
+ "stop=8",
+ "0=30-30",
+ "1=100-400",
+ "2=400-500,c,500-1000,c,500-1000,c,500-1000,c,500-1000",
+ "3=9-9,500-1000",
+ "4=500-1000",
+ "5=500-1000",
+ "6=500-1000",
+ "7=500-1000"
+ ]},
+ tun: { type: InTypes.Tun, mtu: 9000, stack: 'system', udp_timeout: '5m', auto_route: false },
+ redirect: { type: InTypes.Redirect },
+ tproxy: { type: InTypes.TProxy },
+}
+
+export function createInbound(type: InType,json?: Partial): Inbound {
+ const defaultObject: Inbound = { ...defaultValues[type] ?? {}, ...(json ?? {}) }
+ return defaultObject
+}
\ No newline at end of file
diff --git a/frontend/src/types/multiplex.ts b/frontend/src/types/multiplex.ts
new file mode 100644
index 0000000..ab8c0a2
--- /dev/null
+++ b/frontend/src/types/multiplex.ts
@@ -0,0 +1,14 @@
+import { Brutal } from "./brutal"
+
+export interface iMultiplex{
+ enabled: boolean
+ padding?: boolean
+ brutal?: Brutal
+}
+
+export interface oMultiplex extends iMultiplex{
+ protocol?: "smux" | "yamux" | "h2mux"
+ max_connections?: number
+ min_streams?: number
+ max_streams?: number
+}
\ No newline at end of file
diff --git a/frontend/src/types/outbounds.ts b/frontend/src/types/outbounds.ts
new file mode 100644
index 0000000..4f9dacf
--- /dev/null
+++ b/frontend/src/types/outbounds.ts
@@ -0,0 +1,268 @@
+import { oTls } from "./tls"
+import { oMultiplex } from "./multiplex"
+import { Transport } from "./transport"
+import { Dial } from "./dial"
+
+export const OutTypes = {
+ Direct: 'direct',
+ SOCKS: 'socks',
+ HTTP: 'http',
+ Shadowsocks: 'shadowsocks',
+ VMess: 'vmess',
+ Trojan: 'trojan',
+ Naive: 'naive',
+ Hysteria: 'hysteria',
+ VLESS: 'vless',
+ ShadowTLS: 'shadowtls',
+ TUIC: 'tuic',
+ Hysteria2: 'hysteria2',
+ AnyTls: 'anytls',
+ Tor: 'tor',
+ SSH: 'ssh',
+ Selector: 'selector',
+ URLTest: 'urltest',
+}
+
+type OutType = typeof OutTypes[keyof typeof OutTypes]
+
+interface OutboundBasics {
+ id: number
+ type: OutType
+ tag: string
+}
+
+export interface WgPeer {
+ server: string
+ server_port: number
+ public_key: string
+ pre_shared_key?: string
+ allowed_ips?: string[]
+ reserved?: number[]
+}
+
+export interface Direct extends OutboundBasics, Dial {}
+
+export interface SOCKS extends OutboundBasics, Dial {
+ server: string
+ server_port: number
+ version?: "4" | "4a" | "5"
+ username?: string
+ password?: string
+ network?: "udp" | "tcp"
+ udp_over_tcp?: false | {
+ enabled: true
+ version?: number
+ }
+}
+
+export interface HTTP extends OutboundBasics, Dial {
+ server: string
+ server_port: number
+ username?: string
+ password?: string
+ path?: string
+ headers?: {
+ [key: string]: string
+ }
+ tls?: oTls
+}
+
+export interface Shadowsocks extends OutboundBasics, Dial {
+ server: string
+ server_port: number
+ method: string
+ password: string
+ network?: "udp" | "tcp"
+ udp_over_tcp?: false | {
+ enabled: true
+ version?: number
+ }
+ multiplex?: oMultiplex
+}
+
+export interface VMESS extends OutboundBasics, Dial {
+ server: string
+ server_port: number
+ uuid: string
+ security?: string
+ alter_id: 0
+ global_padding?: boolean
+ authenticated_length?: boolean
+ network?: "udp" | "tcp"
+ packet_encoding?: string
+ tls?: oTls
+ multiplex?: oMultiplex
+ transport?: Transport
+}
+
+export interface Trojan extends OutboundBasics, Dial {
+ server: string
+ server_port: number
+ password: string
+ network?: "udp" | "tcp"
+ tls?: oTls
+ multiplex?: oMultiplex
+ transport?: Transport
+}
+
+export interface Naive extends OutboundBasics, Dial {
+ server: string
+ server_port: number
+ username?: string
+ password?: string
+ insecure_concurrency?: number
+ extra_headers?: { [key: string]: string }
+ udp_over_tcp?: false | { enabled?: boolean; version?: number }
+ quic?: boolean
+ quic_congestion_control?: "" | "bbr" | "bbr2" | "cubic" | "reno"
+ tls: oTls
+}
+
+export interface Hysteria extends OutboundBasics, Dial {
+ server: string
+ server_port: number
+ up_mbps: number
+ down_mbps: number
+ obfs?: string
+ auth_str?: string
+ recv_window_conn?: number
+ recv_window?: number
+ disable_mtu_discovery?: boolean
+ network?: "udp" | "tcp"
+ tls: oTls
+}
+
+export interface ShadowTLS extends OutboundBasics, Dial {
+ server: string
+ server_port: number
+ version: 1|2|3
+ password?: string
+ tls: oTls
+}
+
+export interface VLESS extends OutboundBasics, Dial {
+ server: string
+ server_port: number
+ uuid: string
+ flow?: string
+ network?: "udp" | "tcp"
+ packet_encoding?: string
+ tls?: oTls
+ multiplex?: oMultiplex
+ transport?: Transport
+}
+
+export interface TUIC extends OutboundBasics, Dial {
+ server: string
+ server_port: number
+ uuid: string
+ password?: string
+ congestion_control?: "cubic"|"new_reno"|"bbr"
+ udp_relay_mode?: "native" | "quic"
+ udp_over_stream?: boolean
+ zero_rtt_handshake?: boolean
+ heartbeat?: string
+ network?: "udp" | "tcp"
+ tls: oTls
+}
+
+export interface Hysteria2 extends OutboundBasics, Dial {
+ server: string
+ server_port: number
+ server_ports?: string[]
+ hop_interval: string
+ up_mbps?: number
+ down_mbps?: number
+ obfs?: {
+ type?: "salamander"
+ password: string
+ }
+ password?: string
+ network?: "udp" | "tcp"
+ tls: oTls
+ brutal_debug?: boolean
+}
+
+export interface AnyTls extends OutboundBasics, Dial {
+ server: string
+ server_port: number
+ password: string
+ idle_session_check_interval: string
+ idle_session_timeout: string
+ min_idle_session: number
+ tls: oTls
+}
+
+export interface Tor extends OutboundBasics, Dial {
+ executable_path?: string
+ extra_args?: string[]
+ data_directory: string
+ torrc?: {
+ [options: string]: string
+ }
+}
+
+export interface SSH extends OutboundBasics, Dial {
+ server: string
+ server_port?: number
+ user?: string
+ password?: string
+ private_key?: string
+ private_key_path?: string
+ private_key_passphrase?: string
+ host_key?: string[]
+ host_key_algorithms?: string[]
+ client_version?: string
+}
+
+export interface Selector extends OutboundBasics {
+ outbounds: string[]
+ url?: string
+ interval?: string
+ tolerance?: number
+ idle_timeout?: string
+ interrupt_exist_connections?: boolean
+}
+
+export interface URLTest extends OutboundBasics {
+ outbounds: string[]
+ default?: string
+ interrupt_exist_connections?: boolean
+}
+
+// Create interfaces dynamically based on OutTypes keys
+type InterfaceMap = {
+ [Key in keyof typeof OutTypes]: {
+ type: string
+ [otherProperties: string]: any // You can add other properties as needed
+ }
+}
+
+// Create union type from InterfaceMap
+export type Outbound = InterfaceMap[keyof InterfaceMap]
+
+// Create defaultValues object dynamically
+const defaultValues: Record = {
+ direct: { type: OutTypes.Direct },
+ socks: { type: OutTypes.SOCKS, version: "5" },
+ http: { type: OutTypes.HTTP, tls: {} },
+ shadowsocks: { type: OutTypes.Shadowsocks, method: 'none', multiplex: {} },
+ vmess: { type: OutTypes.VMess, tls: {}, multiplex: {}, transport: {}, security: 'auto', global_padding: false },
+ trojan: { type: OutTypes.Trojan, tls: {}, multiplex: {}, transport: {} },
+ naive: { type: OutTypes.Naive, tls: { enabled: true } },
+ hysteria: { type: OutTypes.Hysteria, up_mbps: 100, down_mbps: 100, tls: { enabled: true } },
+ shadowtls: { type: OutTypes.ShadowTLS, version: 3, tls: { enabled: true } },
+ vless: { type: OutTypes.VLESS, tls: {}, multiplex: {}, transport: {} },
+ tuic: { type: OutTypes.TUIC, congestion_control: 'cubic', tls: { enabled: true } },
+ hysteria2: { type: OutTypes.Hysteria2, tls: { enabled: true } },
+ anytls: { type: OutTypes.AnyTls, tls: { enabled: true } },
+ tor: { type: OutTypes.Tor, executable_path: './tor', data_directory: '$HOME/.cache/tor', torrc: { ClientOnly: '1' } },
+ ssh: { type: OutTypes.SSH },
+ selector: { type: OutTypes.Selector },
+ urltest: { type: OutTypes.URLTest },
+}
+
+export function createOutbound(type: string,json?: Partial): Outbound {
+ const defaultObject: Outbound = { ...defaultValues[type], ...(json || {}) }
+ return defaultObject
+}
\ No newline at end of file
diff --git a/frontend/src/types/rules.ts b/frontend/src/types/rules.ts
new file mode 100644
index 0000000..967c1c7
--- /dev/null
+++ b/frontend/src/types/rules.ts
@@ -0,0 +1,81 @@
+interface generalRule {
+ invert: boolean
+ action: 'route' | 'route-options' | 'reject' | 'hijack-dns' | 'sniff' | 'resolve' | 'bypass'
+ outbound?: string
+ override_address?: string
+ override_port?: number
+ udp_disable_domain_unmapping?: boolean
+ udp_connect?: boolean
+ udp_timeout?: string
+ method?: string
+ no_drop?: boolean
+ sniffer: string[]
+ timeout: string
+ strategy: string
+ server: string
+}
+
+export const actionKeys = [
+ 'invert',
+ 'action',
+ 'outbound',
+ 'override_address',
+ 'override_port',
+ 'udp_disable_domain_unmapping',
+ 'udp_connect',
+ 'udp_timeout',
+ 'method',
+ 'no_drop',
+ 'sniffer',
+ 'timeout',
+ 'strategy',
+ 'server'
+]
+export interface logicalRule extends generalRule {
+ type: 'logical' | 'simple'
+ mode: 'and' | 'or'
+ rules: rule[]
+}
+
+export interface rule extends generalRule {
+ inbound?: string[]
+ ip_version?: 4 | 6
+ network?: string[]
+ auth_user?: string[]
+ protocol?: string[]
+ domain?: string[]
+ domain_suffix?: string[]
+ domain_keyword?: string[]
+ domain_regex?: string[]
+ source_ip_cidr?: string[]
+ source_ip_is_private?: boolean
+ ip_cidr?: string[]
+ ip_is_private?: boolean
+ source_port?: number[]
+ source_port_range?: string[]
+ port?: number[]
+ port_range?: string[]
+ process_name?: string[]
+ process_path?: string[]
+ process_path_regex?: string[]
+ package_name?: string[]
+ user?: string[]
+ user_id?: number[]
+ clash_mode?: string
+ rule_set?: string[]
+ rule_set_ip_cidr_match_source?: boolean
+ preferred_by?: string[]
+ interface_address?: string[]
+ network_interface_address?: string[]
+ default_interface_address?: string[]
+}
+
+export interface ruleset {
+ type: 'local' | 'remote'
+ tag: string
+ format: 'source' | 'binary'
+ path?: string
+ url?: string
+ download_detour?: string
+ update_interval?: string
+}
\ No newline at end of file
diff --git a/frontend/src/types/services.ts b/frontend/src/types/services.ts
new file mode 100644
index 0000000..942f66c
--- /dev/null
+++ b/frontend/src/types/services.ts
@@ -0,0 +1,77 @@
+import { Listen } from "./inbounds"
+import { iTls } from "./tls"
+
+export const SrvTypes = {
+ DERP: 'derp',
+ Resolved: 'resolved',
+ SSMAPI: 'ssm-api',
+ OCM: 'ocm',
+ CCM: 'ccm',
+}
+
+type SrvType = typeof SrvTypes[keyof typeof SrvTypes]
+
+interface SrvBasics extends Listen {
+ id: number
+ type: SrvType
+ tag: string
+ tls_id: number
+}
+
+export interface DERP extends SrvBasics {
+ tls: iTls
+ config_path: string
+ verify_client_endpoint?: string[]
+ verify_client_url?: any[]
+ home?: string
+ mesh_with?: any[]
+ mesh_psk?: string
+ mesh_psk_file?: string
+ stun?: any
+}
+
+export interface Resolved extends SrvBasics {}
+
+export interface SSMAPI extends SrvBasics {
+ servers: any
+ tls?: iTls
+}
+
+export interface OCM extends SrvBasics {
+ credential_path?: string
+ usages_path?: string
+ users?: { name: string; token: string }[]
+ headers?: { [key: string]: string }
+ detour?: string
+}
+
+export interface CCM extends SrvBasics {
+ credential_path?: string
+ usages_path?: string
+ users?: { name: string; token: string }[]
+ headers?: { [key: string]: string }
+ detour?: string
+}
+
+type InterfaceMap = {
+ derp: DERP
+ resolved: Resolved
+ 'ssm-api': SSMAPI
+ ocm: OCM
+ ccm: CCM
+}
+
+export type Srv = InterfaceMap[keyof InterfaceMap]
+
+const defaultValues: Record = {
+ derp: { type: 'derp', config_path: '', tls_id:0 },
+ resolved: { type: 'resolved', listen: '::', listen_port: 53 },
+ 'ssm-api': { type: 'ssm-api', tls_id: 0, servers: {} },
+ ocm: { type: 'ocm', id: 0, tag: '', listen: '::', listen_port: 8080, tls_id: 0, users: [] } as OCM,
+ ccm: { type: 'ccm', id: 0, tag: '', listen: '::', listen_port: 8080, tls_id: 0, users: [] } as CCM,
+}
+
+export function createSrv(type: string, json?: Partial): Srv {
+ const defaultObject: Srv = { ...defaultValues[type], ...(json || {}) }
+ return defaultObject
+}
\ No newline at end of file
diff --git a/frontend/src/types/tls.ts b/frontend/src/types/tls.ts
new file mode 100644
index 0000000..919d32b
--- /dev/null
+++ b/frontend/src/types/tls.ts
@@ -0,0 +1,139 @@
+import { Dial } from "./dial"
+
+export interface tls {
+ id: number
+ name: string
+ server: iTls
+ client: oTls
+}
+
+export interface iTls {
+ enabled?: boolean
+ server_name?: string
+ alpn?: string[]
+ min_version?: string
+ max_version?: string
+ cipher_suites?: string[]
+ curve_preferences?: string[]
+ certificate?: string[]
+ certificate_path?: string
+ key?: string[]
+ key_path?: string
+ client_authentication?: string
+ client_certificate?: string[]
+ client_certificate_path?: string[]
+ client_certificate_public_key_sha256?: string[]
+ acme?: acme
+ ech?: ech
+ reality?: reality
+ store?: 'mozilla' | 'chrome'
+ kernel_tx?: boolean
+ kernel_rx?: boolean
+}
+
+export interface acme {
+ domain: string[]
+ data_directory?: string
+ default_server_name?: string
+ email?: string
+ provider?: string
+ disable_http_challenge?: boolean
+ disable_tls_alpn_challenge?: boolean
+ alternative_http_port?: number
+ alternative_tls_port?: number
+ external_account?: {
+ key_id: string
+ mac_key: string
+ }
+ dns01_challenge?: {
+ provider: string
+ [key: string]: string
+ }
+}
+
+export interface ech {
+ enabled: boolean
+ key?: string[]
+ key_path?: string
+}
+
+interface realityHanshake extends Dial {
+ server: string
+ server_port: number
+}
+
+export interface reality {
+ enabled: boolean
+ handshake: realityHanshake
+ private_key: string
+ short_id: string[]
+ max_time_difference?: string
+}
+
+export const defaultInTls: iTls = {
+ alpn: ['h3', 'h2', 'http/1.1'],
+ min_version: "1.2",
+ max_version: "1.3",
+ cipher_suites: [],
+}
+
+export interface oTls {
+ enabled?: boolean
+ disable_sni?: boolean
+ server_name?: string
+ insecure?: boolean
+ alpn?: string[]
+ min_version?: string
+ max_version?: string
+ cipher_suites?: string[]
+ curve_preferences?: string[]
+ certificate?: string
+ certificate_path?: string
+ certificate_public_key_sha256?: string[]
+ client_certificate?: string[]
+ client_certificate_path?: string
+ client_key?: string[]
+ client_key_path?: string
+ fragment?: boolean
+ fragment_fallback_delay?: string
+ record_fragment?: boolean
+ ech?: {
+ enabled: boolean
+ pq_signature_schemes_enabled?: boolean
+ dynamic_record_sizing_disabled?: boolean
+ config?: string[]
+ config_path?: string
+ query_server_name?: string
+ },
+ utls?: {
+ enabled: boolean
+ fingerprint: string
+ },
+ reality?: {
+ enabled: boolean
+ public_key: string
+ short_id: string
+ }
+}
+
+export const defaultOutTls: oTls = {
+ alpn: ['h3', 'h2', 'http/1.1'],
+ min_version: "1.2",
+ max_version: "1.3",
+ cipher_suites: [],
+ utls: {
+ enabled: true,
+ fingerprint: "chrome",
+ },
+ reality: {
+ enabled: true,
+ public_key: "",
+ short_id: "",
+ },
+ ech: {
+ enabled: true,
+ pq_signature_schemes_enabled: false,
+ dynamic_record_sizing_disabled: false,
+ config_path: "",
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/types/transport.ts b/frontend/src/types/transport.ts
new file mode 100644
index 0000000..4336dc2
--- /dev/null
+++ b/frontend/src/types/transport.ts
@@ -0,0 +1,48 @@
+export const TrspTypes = {
+ HTTP: 'http',
+ WebSocket: 'ws',
+ QUIC: 'quic',
+ gRPC: 'grpc',
+ HTTPUpgrade: "httpupgrade"
+}
+
+export type TrspType = typeof TrspTypes[keyof typeof TrspTypes]
+
+export type Transport = HTTP|WebSocket|QUIC|gRPC|HTTPUpgrade
+
+interface TransportBasics {
+ type: TrspType
+}
+
+export interface HTTP extends TransportBasics {
+ host?: string[]
+ path?: string
+ method?: string
+ headers?: {}
+ idle_timeout?: string
+ ping_timeout?: string
+}
+
+export interface WebSocket extends TransportBasics {
+ path: string
+ headers?: {
+ Host: string
+ }
+ max_early_data?: number
+ early_data_header_name?: string
+}
+
+export interface QUIC extends TransportBasics {}
+
+export interface gRPC extends TransportBasics {
+ service_name?: string
+ idle_timeout?: string
+ ping_timeout?: string
+ permit_without_stream?: boolean
+}
+
+export interface HTTPUpgrade extends TransportBasics {
+ host?: string
+ path?: string
+ headers?: {}
+}
diff --git a/frontend/src/views/Admins.vue b/frontend/src/views/Admins.vue
new file mode 100644
index 0000000..9b2178f
--- /dev/null
+++ b/frontend/src/views/Admins.vue
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+ {{ $t('admin.changes') }}
+ {{ $t('admin.api.token') }}
+
+
+
+
+
+
+ {{ $t('admin.lastLogin') }}
+
+
+
+ {{ $t('admin.date') }}
+
+ {{ item.loginDate }}
+
+
+
+ {{ $t('admin.time') }}
+
+ {{ item.loginTime }}
+
+
+
+ IP
+
+ {{ item.ip }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/views/Basics.vue b/frontend/src/views/Basics.vue
new file mode 100644
index 0000000..1b98349
--- /dev/null
+++ b/frontend/src/views/Basics.vue
@@ -0,0 +1,324 @@
+
+
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cache File
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Clash API
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ V2Ray API
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/views/Clients.vue b/frontend/src/views/Clients.vue
new file mode 100644
index 0000000..ce53b35
--- /dev/null
+++ b/frontend/src/views/Clients.vue
@@ -0,0 +1,424 @@
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.add') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.del') }}
+
+
+ {{ $t('actions.update') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ inbounds.find(inb => inb.id == i)?.tag }}
+
+ {{ item.inbounds?.length }}
+
+
+
+
+ {{ HumanReadable.sizeFormat(item.up + item.down) + ' / ' + (item.volume == 0 ? $t('unlimited') : HumanReadable.sizeFormat(item.volume)) }}
+
+
+
+
+
+
+
+ {{ HumanReadable.remainedDays(item.expiry) }}
+
+
+
+
+
+ {{ $t('online') }}
+
+ -
+
+
+
+
+ mdi-pencil
+
+
+
+
+ mdi-delete
+
+
+
+
+ {{ $t('confirm') }}
+
+ {{ $t('yes') }}
+ c.id == item.id)] = false">{{ $t('no') }}
+
+
+
+
+ mdi-qrcode
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/views/Dns.vue b/frontend/src/views/Dns.vue
new file mode 100644
index 0000000..b7f62b2
--- /dev/null
+++ b/frontend/src/views/Dns.vue
@@ -0,0 +1,372 @@
+
+
+
+
+
+ {{ $t('dns.add') }}
+ {{ $t('dns.rule.add') }}
+
+ {{ $t('actions.save') }}
+
+
+
+
+ {{ $t('pages.basics') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('dns.title') }}
+
+
+
+
+ {{ item.type }}
+
+
+
+
+ {{ $t('dns.server') }}
+
+ {{ item.server?? '-' }}
+
+
+
+ {{ $t('in.port') }}
+
+ {{ item.server_port?? '-' }}
+
+
+
+ {{ $t('objects.tls') }}
+
+ {{ Object.hasOwn(item,'tls') ? $t(item.tls?.enabled ? 'enable' : 'disable') : '-' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('confirm') }}
+
+ {{ $t('yes') }}
+ {{ $t('no') }}
+
+
+
+
+
+
+
+
+ {{ $t('dns.rule.title') }}
+
+
+
+
+ {{ item.type != undefined ? $t('rule.logical') + ' (' + item.mode + ')' : $t('rule.simple') }}
+
+
+
+
+ {{ $t('admin.action') }}
+
+ {{ item.action }}
+
+
+
+ {{ $t('dns.server') }}
+
+ {{ item.server?? '-' }}
+
+
+
+ {{ $t('pages.rules') }}
+
+ {{ item.rules ? item.rules.length : Object.keys(item).filter(r => !actionDnsRuleKeys.includes(r)).length }}
+
+
+
+ {{ $t('rule.invert') }}
+
+ {{ $t( (item.invert?? false)? 'yes' : 'no') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('confirm') }}
+
+ {{ $t('yes') }}
+ {{ $t('no') }}
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/views/Endpoints.vue b/frontend/src/views/Endpoints.vue
new file mode 100644
index 0000000..6a3d84b
--- /dev/null
+++ b/frontend/src/views/Endpoints.vue
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+ {{ $t('actions.add') }}
+
+
+
+
+
+
+
+ {{ item.type }}
+
+
+
+
+ {{ $t('in.addr') }}
+
+ {{ item.address?.length>0 ? item.address[0] : '-' }}
+
+
+
+ {{ $t('in.port') }}
+
+ {{ item.listen_port>0 ? item.listen_port : '-' }}
+
+
+
+ {{ $t('types.wg.peers') }}
+
+ {{ item.peers?.length?? '-' }}
+
+
+
+ {{ $t('online') }}
+
+
+ {{ $t('online') }}
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('confirm') }}
+
+ {{ $t('yes') }}
+ {{ $t('no') }}
+
+
+
+
+ mdi-qrcode
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue
new file mode 100644
index 0000000..b5e57fe
--- /dev/null
+++ b/frontend/src/views/Home.vue
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/frontend/src/views/Inbounds.vue b/frontend/src/views/Inbounds.vue
new file mode 100644
index 0000000..eecb546
--- /dev/null
+++ b/frontend/src/views/Inbounds.vue
@@ -0,0 +1,190 @@
+
+
+
+
+
+ {{ $t('actions.add') }}
+
+
+
+
+
+
+
+ {{ item.type }}
+
+
+
+
+ {{ $t('in.addr') }}
+
+ {{ item.listen }}
+
+
+
+ {{ $t('in.port') }}
+
+ {{ item.listen_port }}
+
+
+
+ {{ $t('objects.tls') }}
+
+ {{ item.tls_id > 0 ? $t('enable') : $t('disable') }}
+
+
+
+ {{ $t('pages.clients') }}
+
+
+
+ {{ u }}
+
+ {{ item.users.length }}
+
+ -
+
+
+
+ {{ $t('online') }}
+
+
+ {{ $t('online') }}
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('confirm') }}
+
+ {{ $t('yes') }}
+ {{ $t('no') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue
new file mode 100644
index 0000000..b488d95
--- /dev/null
+++ b/frontend/src/views/Login.vue
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mdi-theme-light-dark
+
+
+
+
+ {{ $t(`theme.${th.value}`) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/views/Outbounds.vue b/frontend/src/views/Outbounds.vue
new file mode 100644
index 0000000..227017c
--- /dev/null
+++ b/frontend/src/views/Outbounds.vue
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+ {{ $t('actions.add') }}
+
+
+ {{ $t('actions.addbulk') }}
+
+
+
+ {{ $t('actions.testAll') || 'Test all' }}
+
+
+
+
+
+
+
+
+ {{ item.type }}
+
+
+
+
+ {{ $t('in.addr') }}
+
+ {{ item.server?? '-' }}
+
+
+
+ {{ $t('in.port') }}
+
+ {{ item.server_port?? '-' }}
+
+
+
+ {{ $t('objects.tls') }}
+
+ {{ Object.hasOwn(item,'tls') ? $t(item.tls?.enabled ? 'enable' : 'disable') : '-' }}
+
+
+
+ {{ $t('online') }}
+
+
+ {{ $t('online') }}
+
+ -
+
+
+
+ {{ $t('out.delay') }}
+
+
+
+
+
+
+
+
+ {{ checkResults[item.tag].data?.Delay + $t('date.ms') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('confirm') }}
+
+ {{ $t('yes') }}
+ {{ $t('no') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/views/Rules.vue b/frontend/src/views/Rules.vue
new file mode 100644
index 0000000..ad3ffe7
--- /dev/null
+++ b/frontend/src/views/Rules.vue
@@ -0,0 +1,319 @@
+
+
+
+
+
+
+
+ {{ $t('rule.add') }}
+ {{ $t('ruleset.add') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+ {{ $t('basic.routing.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('rule.ruleset') }}
+
+
+
+ {{ $t('ruleset.' + item.type) }}
+
+
+ {{ $t('ruleset.format') }}{{ item.format }}
+ {{ $t('objects.outbound') }}{{ item.download_detour ?? '-' }}
+ {{ $t('actions.update') }}{{ item.update_interval ?? '-' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('confirm') }}
+
+ {{ $t('yes') }}
+ {{ $t('no') }}
+
+
+
+
+
+
+
+
+ {{ $t('pages.rules') }}
+
+
+
+ {{ item.type != undefined ? $t('rule.logical') + ' (' + item.mode + ')' : $t('rule.simple') }}
+
+
+ {{ $t('admin.action') }}{{ item.action }}
+ {{ $t('objects.outbound') }}{{ item.outbound ?? '-' }}
+ {{ $t('pages.rules') }}{{ item.rules ? item.rules.length : Object.keys(item).filter(r => !actionKeys.includes(r)).length }}
+ {{ $t('rule.invert') }}{{ $t((item.invert ?? false) ? 'yes' : 'no') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('confirm') }}
+
+ {{ $t('yes') }}
+ {{ $t('no') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Services.vue b/frontend/src/views/Services.vue
new file mode 100644
index 0000000..9e4d874
--- /dev/null
+++ b/frontend/src/views/Services.vue
@@ -0,0 +1,131 @@
+
+
+
+
+ {{ $t('actions.add') }}
+
+
+
+
+
+
+
+ {{ item.type }}
+
+
+
+
+ {{ $t('in.addr') }}
+
+ {{ item.listen }}
+
+
+
+ {{ $t('in.port') }}
+
+ {{ item.listen_port }}
+
+
+
+ {{ $t('objects.tls') }}
+
+ {{ item.tls_id > 0 ? $t('enable') : $t('disable') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('confirm') }}
+
+ {{ $t('yes') }}
+ {{ $t('no') }}
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue
new file mode 100644
index 0000000..aa3b989
--- /dev/null
+++ b/frontend/src/views/Settings.vue
@@ -0,0 +1,284 @@
+
+
+
+ {{ $t('setting.interface') }}
+ {{ $t('setting.sub') }}
+ {{ $t('setting.jsonSub') }}
+ {{ $t('setting.clashSub') }}
+
+
+
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+ {{ $t('actions.restartApp') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Tls.vue b/frontend/src/views/Tls.vue
new file mode 100644
index 0000000..0bda504
--- /dev/null
+++ b/frontend/src/views/Tls.vue
@@ -0,0 +1,141 @@
+
+
+
+
+ {{ $t('actions.add') }}
+
+
+
+
+
+
+ {{ item.server?.server_name?.length>0 ? item.server.server_name : "-" }}
+
+
+
+ {{ $t('pages.inbounds') }}
+
+
+
+ {{ i }}
+
+ {{ tlsInbounds(item.id).length }}
+
+ -
+
+
+
+ ACME
+
+ {{ $t(item.server?.acme == undefined ? 'no' : 'yes') }}
+
+
+
+ ECH
+
+ {{ $t(item.server?.ech == undefined ? 'no' : 'yes') }}
+
+
+
+ Reality
+
+ {{ $t(item.server?.reality == undefined ? 'no' : 'yes') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('confirm') }}
+
+ {{ $t('yes') }}
+ {{ $t('no') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..7a17990
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,12 @@
+///
+
+declare module 'moment/locale/ru'
+declare module 'moment/locale/vi'
+declare module 'moment/locale/zh-cn'
+declare module 'moment/locale/zh-tw'
+
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue'
+ const component: DefineComponent<{}, {}, any>
+ export default component
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..275a67c
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "strict": true,
+ "jsx": "preserve",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "lib": ["ESNext", "DOM"],
+ "skipLibCheck": true,
+ "noEmit": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
+ "references": [{ "path": "./tsconfig.node.json" }],
+ "exclude": ["node_modules"],
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..5aeca0e
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.mts"],
+ "exclude": []
+}
diff --git a/frontend/vite.config.mts b/frontend/vite.config.mts
new file mode 100644
index 0000000..d406000
--- /dev/null
+++ b/frontend/vite.config.mts
@@ -0,0 +1,64 @@
+// Plugins
+import vue from '@vitejs/plugin-vue'
+import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
+
+// Utilities
+import { defineConfig } from 'vite'
+import { fileURLToPath, URL } from 'node:url'
+import { randomBytes } from 'crypto'
+
+function getUniqueFileName(template) {
+ if (template.includes('.js') || template.includes('.css')) {
+ const hash = randomBytes(8).toString('hex')
+ return template.replace('[name]', hash)
+ }
+ return template
+}
+
+export default defineConfig({
+ base: '',
+ plugins: [
+ vue({
+ template: { transformAssetUrls },
+ }),
+ vuetify({
+ autoImport: true,
+ styles: {
+ configFile: 'src/styles/settings.scss',
+ },
+ })
+ ],
+ build: {
+ manifest: false,
+ outDir: 'dist',
+ chunkSizeWarningLimit: 2000,
+ rollupOptions: {
+ output: {
+ codeSplitting: false,
+ entryFileNames: getUniqueFileName('assets/[name].js'),
+ chunkFileNames: getUniqueFileName('assets/[name].js'),
+ assetFileNames: (assetInfo) => {
+ if (assetInfo.names.some(name => name.endsWith('.css')))
+ return getUniqueFileName('assets/[name].css')
+ return 'assets/' + assetInfo.names[0]
+ },
+ },
+ }
+ },
+ define: { 'process.env': {} },
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
+ },
+ server: {
+ port: 3000,
+ proxy: {
+ '/app/api': {
+ target: 'http://localhost:2095',
+ changeOrigin: true,
+ },
+ },
+ }
+})
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..2c6ced3
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,199 @@
+module github.com/alireza0/s-ui
+
+go 1.25.7
+
+require (
+ github.com/gin-contrib/gzip v1.2.5
+ github.com/gin-contrib/sessions v1.0.4
+ github.com/gin-gonic/gin v1.12.0
+ github.com/gofrs/uuid/v5 v5.4.0
+ github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
+ github.com/robfig/cron/v3 v3.0.1
+ github.com/sagernet/sing v0.8.3-0.20260315153529-ed51f65fbfde
+ github.com/sagernet/sing-box v1.13.4
+ github.com/shirou/gopsutil/v4 v4.26.2
+ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
+ gopkg.in/yaml.v3 v3.0.1
+ gorm.io/driver/sqlite v1.6.0
+ gorm.io/gorm v1.31.1
+)
+
+require (
+ filippo.io/edwards25519 v1.2.0 // indirect
+ github.com/ajg/form v1.5.1 // indirect
+ github.com/akutz/memconn v0.1.0 // indirect
+ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
+ github.com/andybalholm/brotli v1.1.0 // indirect
+ github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect
+ github.com/anytls/sing-anytls v0.0.11 // indirect
+ github.com/bytedance/gopkg v0.1.3 // indirect
+ github.com/bytedance/sonic v1.15.0 // indirect
+ github.com/bytedance/sonic/loader v0.5.0 // indirect
+ github.com/caddyserver/certmagic v0.25.2 // indirect
+ github.com/caddyserver/zerossl v0.1.5 // indirect
+ github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/coder/websocket v1.8.14 // indirect
+ github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect
+ github.com/cretz/bine v0.2.0 // indirect
+ github.com/database64128/netx-go v0.1.1 // indirect
+ github.com/database64128/tfo-go/v2 v2.3.2 // indirect
+ github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
+ github.com/ebitengine/purego v0.10.0 // indirect
+ github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect
+ github.com/fsnotify/fsnotify v1.7.0 // indirect
+ github.com/fxamacker/cbor/v2 v2.7.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.12 // indirect
+ github.com/gaissmai/bart v0.18.0 // indirect
+ github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/go-chi/chi/v5 v5.2.5 // indirect
+ github.com/go-chi/render v1.0.3 // indirect
+ github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.30.1 // indirect
+ github.com/gobwas/httphead v0.1.0 // indirect
+ github.com/gobwas/pool v0.2.1 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/goccy/go-yaml v1.19.2 // indirect
+ github.com/godbus/dbus/v5 v5.2.2 // indirect
+ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+ github.com/google/btree v1.1.3 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/context v1.1.2 // indirect
+ github.com/gorilla/securecookie v1.1.2 // indirect
+ github.com/gorilla/sessions v1.4.0 // indirect
+ github.com/hashicorp/yamux v0.1.2 // indirect
+ github.com/hdevalence/ed25519consensus v0.2.0 // indirect
+ github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/jsimonetti/rtnetlink v1.4.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/keybase/go-keychain v0.0.1 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/libdns/acmedns v0.5.0 // indirect
+ github.com/libdns/alidns v1.0.6 // indirect
+ github.com/libdns/cloudflare v0.2.2 // indirect
+ github.com/libdns/libdns v1.1.1 // indirect
+ github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
+ github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-sqlite3 v1.14.30 // indirect
+ github.com/mdlayher/netlink v1.9.0 // indirect
+ github.com/mdlayher/socket v0.5.1 // indirect
+ github.com/metacubex/utls v1.8.4 // indirect
+ github.com/mholt/acmez/v3 v3.1.6 // indirect
+ github.com/miekg/dns v1.1.72 // indirect
+ github.com/mitchellh/go-ps v1.0.0 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/openai/openai-go/v3 v3.26.0 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/pierrec/lz4/v4 v4.1.21 // indirect
+ github.com/pires/go-proxyproto v0.8.1 // indirect
+ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
+ github.com/prometheus-community/pro-bing v0.4.0 // indirect
+ github.com/quic-go/qpack v0.6.0 // indirect
+ github.com/quic-go/quic-go v0.59.0 // indirect
+ github.com/safchain/ethtool v0.3.0 // indirect
+ github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect
+ github.com/sagernet/cors v1.2.1 // indirect
+ github.com/sagernet/cronet-go v0.0.0-20260309102448-2fef65f9dba9 // indirect
+ github.com/sagernet/cronet-go/all v0.0.0-20260309102448-2fef65f9dba9 // indirect
+ github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
+ github.com/sagernet/fswatch v0.1.1 // indirect
+ github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 // indirect
+ github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
+ github.com/sagernet/nftables v0.3.0-beta.4 // indirect
+ github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 // indirect
+ github.com/sagernet/sing-mux v0.3.4 // indirect
+ github.com/sagernet/sing-quic v0.6.0 // indirect
+ github.com/sagernet/sing-shadowsocks v0.2.8 // indirect
+ github.com/sagernet/sing-shadowsocks2 v0.2.1 // indirect
+ github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect
+ github.com/sagernet/sing-tun v0.8.6 // indirect
+ github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 // indirect
+ github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect
+ github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 // indirect
+ github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c // indirect
+ github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect
+ github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
+ github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
+ github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
+ github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
+ github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect
+ github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
+ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
+ github.com/tidwall/gjson v1.18.0 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ github.com/tidwall/sjson v1.2.5 // indirect
+ github.com/tklauser/go-sysconf v0.3.16 // indirect
+ github.com/tklauser/numcpus v0.11.0 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
+ github.com/ugorji/go/codec v1.3.1 // indirect
+ github.com/vishvananda/netns v0.0.5 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
+ github.com/zeebo/blake3 v0.2.4 // indirect
+ go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.27.1 // indirect
+ go.uber.org/zap/exp v0.3.0 // indirect
+ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
+ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
+ golang.org/x/arch v0.22.0 // indirect
+ golang.org/x/crypto v0.48.0 // indirect
+ golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
+ golang.org/x/mod v0.33.0 // indirect
+ golang.org/x/net v0.51.0 // indirect
+ golang.org/x/oauth2 v0.34.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
+ golang.org/x/term v0.40.0 // indirect
+ golang.org/x/text v0.34.0 // indirect
+ golang.org/x/time v0.12.0 // indirect
+ golang.org/x/tools v0.42.0 // indirect
+ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
+ golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
+ google.golang.org/grpc v1.79.3 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ lukechampine.com/blake3 v1.4.1 // indirect
+)
+
+replace github.com/quic-go/quic-go => github.com/quic-go/quic-go v0.57.1
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..5d3e1bc
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,493 @@
+code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE=
+code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM=
+filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
+filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
+github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
+github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
+github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
+github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
+github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
+github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
+github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc=
+github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8=
+github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
+github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
+github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
+github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
+github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
+github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc=
+github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg=
+github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE=
+github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
+github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
+github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
+github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
+github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo=
+github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI=
+github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM=
+github.com/database64128/netx-go v0.1.1/go.mod h1:LNlYVipaYkQArRFDNNJ02VkNV+My9A5XR/IGS7sIBQc=
+github.com/database64128/tfo-go/v2 v2.3.2 h1:UhZMKiMq3swZGUiETkLBDzQnZBPSAeBMClpJGlnJ5Fw=
+github.com/database64128/tfo-go/v2 v2.3.2/go.mod h1:GC3uB5oa4beGpCUbRb2ZOWP73bJJFmMyAVgQSO7r724=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
+github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
+github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
+github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
+github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
+github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE=
+github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
+github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
+github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
+github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo=
+github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY=
+github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
+github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
+github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
+github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
+github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
+github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
+github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
+github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
+github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
+github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
+github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
+github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
+github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I=
+github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
+github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
+github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
+github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
+github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
+github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
+github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
+github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
+github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
+github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
+github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
+github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
+github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
+github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
+github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
+github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 h1:u9i04mGE3iliBh0EFuWaKsmcwrLacqGmq1G3XoaM7gY=
+github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
+github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
+github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU=
+github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk=
+github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U=
+github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ=
+github.com/libdns/acmedns v0.5.0 h1:5pRtmUj4Lb/QkNJSl1xgOGBUJTWW7RjpNaIhjpDXjPE=
+github.com/libdns/acmedns v0.5.0/go.mod h1:X7UAFP1Ep9NpTwWpVlrZzJLR7epynAy0wrIxSPFgKjQ=
+github.com/libdns/alidns v1.0.6 h1:/Ii428ty6WHFJmE24rZxq2taq++gh7rf9jhgLfp8PmM=
+github.com/libdns/alidns v1.0.6/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec=
+github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
+github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
+github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
+github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
+github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
+github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
+github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
+github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.30 h1:bVreufq3EAIG1Quvws73du3/QgdeZ3myglJlrzSYYCY=
+github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco=
+github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg=
+github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
+github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
+github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg=
+github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
+github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk=
+github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY=
+github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
+github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
+github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
+github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
+github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
+github.com/openai/openai-go/v3 v3.26.0 h1:bRt6H/ozMNt/dDkN4gobnLqaEGrRGBzmbVs0xxJEnQE=
+github.com/openai/openai-go/v3 v3.26.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
+github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
+github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
+github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
+github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
+github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
+github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
+github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
+github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkkD2QgdTuzQG263YZ+2emfpeyGqW0=
+github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM=
+github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ=
+github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI=
+github.com/sagernet/cronet-go v0.0.0-20260309102448-2fef65f9dba9 h1:xq5Yr10jXEppD3cnGjE3WENaB6D0YsZu6KptZ8d3054=
+github.com/sagernet/cronet-go v0.0.0-20260309102448-2fef65f9dba9/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw=
+github.com/sagernet/cronet-go/all v0.0.0-20260309102448-2fef65f9dba9 h1:uxQyy6Y/boOuecVA66tf79JgtoRGfeDJcfYZZLKVA5E=
+github.com/sagernet/cronet-go/all v0.0.0-20260309102448-2fef65f9dba9/go.mod h1:Xm6cCvs0/twozC1JYNq0sVlOVmcSGzV7YON1XGcD97w=
+github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260309101654-0cbdcfddded9 h1:Qi0IKBpoPP3qZqIXuOKMsT2dv+l/MLWMyBHDMLRw2EA=
+github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw=
+github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260309101654-0cbdcfddded9 h1:p+wCMjOhj46SpSD/AJeTGgkCcbyA76FyH631XZatyU8=
+github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM=
+github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260309101654-0cbdcfddded9 h1:Y7lWrZwEhC/HX8Pb5C92CrQihuaE7hrHmWB2ykst3iQ=
+github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc=
+github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260309101654-0cbdcfddded9 h1:3Ggy5wiyjA6t+aVVPnXlSEIVj9zkxd4ybH3NsvsNefs=
+github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ=
+github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260309101654-0cbdcfddded9 h1:DuFTCnZloblY+7olXiZoRdueWfxi34EV5UheTFKM2rA=
+github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs=
+github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260309101654-0cbdcfddded9 h1:x/6T2gjpLw9yNdCVR6xBlzMUzED9fxNFNt6U6A6SOh8=
+github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0=
+github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260309101654-0cbdcfddded9 h1:Lx9PExM70rg8aNxPm0JPeSr5SWC3yFiCz4wIq86ugx8=
+github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0=
+github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260309101654-0cbdcfddded9 h1:BTEpw7/vKR9BNBsHebfpiGHDCPpjVJ3vLIbHNU3VUfM=
+github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4=
+github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260309101654-0cbdcfddded9 h1:hdEph9nQXRnKwc/lIDwo15rmzbC6znXF5jJWHPN1Fiw=
+github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo=
+github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260309101654-0cbdcfddded9 h1:Iq++oYV7dtRJHTpu8yclHJdn+1oj2t1e84/YpdXYWW8=
+github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ=
+github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260309101654-0cbdcfddded9 h1:Y43fuLL8cgwRHpEKwxh0O3vYp7g/SZGvbkJj3cQ6USA=
+github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU=
+github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260309101654-0cbdcfddded9 h1:bX2GJmF0VCC+tBrVAa49YEsmJ4A9dLmwoA6DJUxRtCY=
+github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI=
+github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260309101654-0cbdcfddded9 h1:gQTR/2azUCInE0r3kmesZT9xu+x801+BmtDY0d0Tw9Y=
+github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ=
+github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260309101654-0cbdcfddded9 h1:X4mP3jlYvxgrKpZLOKMmc/O8T5/zP83/23pgfQOc3tY=
+github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0=
+github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260309101654-0cbdcfddded9 h1:c6xj2nXr/65EDiRFddUKQIBQ/b/lAPoH8WFYlgadaPc=
+github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s=
+github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260309101654-0cbdcfddded9 h1:ahbl7yjOvGVVNUwk9TcQk+xejVfoYAYFRlhWnby0/YM=
+github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ=
+github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260309101654-0cbdcfddded9 h1:JC5Zv5+J85da6g5G56VhdaK53fmo6Os2q/wWi5QlxOw=
+github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow=
+github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260309101654-0cbdcfddded9 h1:4bt7Go588BoM4VjNYMxx0MrvbwlFQn3DdRDCM7BmkRo=
+github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk=
+github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260309101654-0cbdcfddded9 h1:E1z0BeLUh8EZfCjIyS9BrfCocZrt+0KPS0bzop3Sxf4=
+github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E=
+github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260309101654-0cbdcfddded9 h1:d8ejxRHO7Vi9JqR/6DxR7RyI/swA2JfDWATR4T7otBw=
+github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8=
+github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260309101654-0cbdcfddded9 h1:iUDVEVu3RxL5ArPIY72BesbuX5zQ1la/ZFwKpQcGc5c=
+github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w=
+github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260309101654-0cbdcfddded9 h1:xB6ikOC/R3n3hjy68EJ0sbZhH4vwEhd6JM9jZ1U2SVY=
+github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0=
+github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260309101654-0cbdcfddded9 h1:mBOuLCPOOMMq8N1+dUM5FqZclqga1+u6fAbPqQcbIhc=
+github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs=
+github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260309101654-0cbdcfddded9 h1:cwPyDfj+ZNFE7kvcWbayQJyeC/KQA16HTXOxgHphL0w=
+github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc=
+github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260309101654-0cbdcfddded9 h1:Zk9zG8kt3mXAboclUXQlvvxKQuhnI8u5NdDEl8uotNY=
+github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4=
+github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260309101654-0cbdcfddded9 h1:Lu05srGqddQRMnl1MZtGAReln2yJljeGx9b1IadlMJ8=
+github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc=
+github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260309101654-0cbdcfddded9 h1:Tk9bDywUmOtc0iMjjCVIwMlAQNsxCy+bK+bTNA0OaBE=
+github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc=
+github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260309101654-0cbdcfddded9 h1:tQqDQw3tEHdQpt7NTdAwF3UvZ3CjNIj/IJKMRFmm388=
+github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8=
+github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260309101654-0cbdcfddded9 h1:biUIbI2YxUrcQikEfS/bwPA8NsHp/WO+VZUG4morUmE=
+github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw=
+github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs=
+github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o=
+github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 h1:AzCE2RhBjLJ4WIWc/GejpNh+z30d5H1hwaB0nD9eY3o=
+github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1/go.mod h1:NJKBtm9nVEK3iyOYWsUlrDQuoGh4zJ4KOPhSYVidvQ4=
+github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis=
+github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM=
+github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I=
+github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8=
+github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o=
+github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4=
+github.com/sagernet/sing v0.8.2 h1:kX1IH9SWJv4S0T9M8O+HNahWgbOuY1VauxbF7NU5lOg=
+github.com/sagernet/sing v0.8.2/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
+github.com/sagernet/sing v0.8.3-0.20260315153529-ed51f65fbfde h1:RNQzlpnsXIuu1HGts/fIzJ1PR7RhrzaNlU52MDyiX1c=
+github.com/sagernet/sing v0.8.3-0.20260315153529-ed51f65fbfde/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
+github.com/sagernet/sing-box v1.13.3 h1:aaJz3LxpqEO2oHsynSaD4947CtVHtj5B75ChQKeEM3U=
+github.com/sagernet/sing-box v1.13.3/go.mod h1:XxKnCcvh3OYE7KCqDoZ9D2U+wSh1GKm/O9TqqNkI1Hc=
+github.com/sagernet/sing-box v1.13.4 h1:XfDZ4lvIFUuKS4SJDa2LjWnLxYwJfy5OF4jgI8lWUi4=
+github.com/sagernet/sing-box v1.13.4/go.mod h1:ZlRKCQgJCu9ht00xse/BtsLPWYy+901l5clVvKEfJ+Y=
+github.com/sagernet/sing-mux v0.3.4 h1:ZQplKl8MNXutjzbMVtWvWG31fohhgOfCuUZR4dVQ8+s=
+github.com/sagernet/sing-mux v0.3.4/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk=
+github.com/sagernet/sing-quic v0.6.0 h1:dhrFnP45wgVKEOT1EvtsToxdzRnHIDIAgj6WHV9pLyM=
+github.com/sagernet/sing-quic v0.6.0/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8=
+github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE=
+github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI=
+github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo=
+github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ=
+github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w=
+github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA=
+github.com/sagernet/sing-tun v0.8.3 h1:mozxmuIoRhFdVHnheenLpBaammVj7bZPcnkApaYKDPY=
+github.com/sagernet/sing-tun v0.8.3/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs=
+github.com/sagernet/sing-tun v0.8.6 h1:NydXFikSXhiKqhahHKtuZ90HQPZFzlOFVRONmkr4C7I=
+github.com/sagernet/sing-tun v0.8.6/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs=
+github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o=
+github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY=
+github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478=
+github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8=
+github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6.0.20260311131347-f88b27eeb76e h1:Sv1qUhJIidjSTc24XEknovDZnbmVSlAXj8wNVgIfgGo=
+github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6.0.20260311131347-f88b27eeb76e/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc=
+github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 h1:8zc1Aph1+ElqF9/7aSPkO0o4vTd+AfQC+CO324mLWGg=
+github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc=
+github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c h1:f9cXNB+IOOPnR8DOLMTpr42jf7naxh5Un5Y09BBf5Cg=
+github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c/go.mod h1:WUxgxUDZoCF2sxVmW+STSxatP02Qn3FcafTiI2BLtE0=
+github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc=
+github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA=
+github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
+github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ=
+github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
+github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
+github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
+github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio=
+github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
+github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
+github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
+github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
+github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
+github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
+github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
+github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
+github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
+github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
+github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
+github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
+github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
+github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
+github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
+github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
+github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
+github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
+github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
+github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
+github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
+github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
+github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
+go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
+go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
+go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
+go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
+go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
+go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
+go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
+go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
+go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
+go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
+go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
+go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
+go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
+go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
+go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
+go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
+go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
+golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
+golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
+golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
+golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
+golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
+golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
+golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
+golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
+golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
+golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
+golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
+golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
+golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
+golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
+golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
+google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
+google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
+gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
+lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
+software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
+software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
diff --git a/install.sh b/install.sh
new file mode 100644
index 0000000..b36188e
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,188 @@
+#!/bin/bash
+
+red='\033[0;31m'
+green='\033[0;32m'
+yellow='\033[0;33m'
+plain='\033[0m'
+
+cur_dir=$(pwd)
+
+# check root
+[[ $EUID -ne 0 ]] && echo -e "${red}Fatal error: ${plain} Please run this script with root privilege \n " && exit 1
+
+# Check OS and set release variable
+if [[ -f /etc/os-release ]]; then
+ source /etc/os-release
+ release=$ID
+elif [[ -f /usr/lib/os-release ]]; then
+ source /usr/lib/os-release
+ release=$ID
+else
+ echo "Failed to check the system OS, please contact the author!" >&2
+ exit 1
+fi
+echo "The OS release is: $release"
+
+arch() {
+ case "$(uname -m)" in
+ x86_64 | x64 | amd64) echo 'amd64' ;;
+ i*86 | x86) echo '386' ;;
+ armv8* | armv8 | arm64 | aarch64) echo 'arm64' ;;
+ armv7* | armv7 | arm) echo 'armv7' ;;
+ armv6* | armv6) echo 'armv6' ;;
+ armv5* | armv5) echo 'armv5' ;;
+ s390x) echo 's390x' ;;
+ *) echo -e "${green}Unsupported CPU architecture! ${plain}" && rm -f install.sh && exit 1 ;;
+ esac
+}
+
+echo "arch: $(arch)"
+
+install_base() {
+ case "${release}" in
+ centos | almalinux | rocky | oracle)
+ yum -y update && yum install -y -q wget curl tar tzdata
+ ;;
+ fedora)
+ dnf -y update && dnf install -y -q wget curl tar tzdata
+ ;;
+ arch | manjaro | parch)
+ pacman -Syu && pacman -Syu --noconfirm wget curl tar tzdata
+ ;;
+ opensuse-tumbleweed)
+ zypper refresh && zypper -q install -y wget curl tar timezone
+ ;;
+ *)
+ apt-get update && apt-get install -y -q wget curl tar tzdata
+ ;;
+ esac
+}
+
+config_after_install() {
+ echo -e "${yellow}Migration... ${plain}"
+ /usr/local/s-ui/sui migrate
+
+ echo -e "${yellow}Install/update finished! For security it's recommended to modify panel settings ${plain}"
+ read -p "Do you want to continue with the modification [y/n]? ": config_confirm
+ if [[ "${config_confirm}" == "y" || "${config_confirm}" == "Y" ]]; then
+ echo -e "Enter the ${yellow}panel port${plain} (leave blank for existing/default value):"
+ read config_port
+ echo -e "Enter the ${yellow}panel path${plain} (leave blank for existing/default value):"
+ read config_path
+
+ # Sub configuration
+ echo -e "Enter the ${yellow}subscription port${plain} (leave blank for existing/default value):"
+ read config_subPort
+ echo -e "Enter the ${yellow}subscription path${plain} (leave blank for existing/default value):"
+ read config_subPath
+
+ # Set configs
+ echo -e "${yellow}Initializing, please wait...${plain}"
+ params=""
+ [ -z "$config_port" ] || params="$params -port $config_port"
+ [ -z "$config_path" ] || params="$params -path $config_path"
+ [ -z "$config_subPort" ] || params="$params -subPort $config_subPort"
+ [ -z "$config_subPath" ] || params="$params -subPath $config_subPath"
+ /usr/local/s-ui/sui setting ${params}
+
+ read -p "Do you want to change admin credentials [y/n]? ": admin_confirm
+ if [[ "${admin_confirm}" == "y" || "${admin_confirm}" == "Y" ]]; then
+ # First admin credentials
+ read -p "Please set up your username:" config_account
+ read -p "Please set up your password:" config_password
+
+ # Set credentials
+ echo -e "${yellow}Initializing, please wait...${plain}"
+ /usr/local/s-ui/sui admin -username ${config_account} -password ${config_password}
+ else
+ echo -e "${yellow}Your current admin credentials: ${plain}"
+ /usr/local/s-ui/sui admin -show
+ fi
+ else
+ echo -e "${red}cancel...${plain}"
+ if [[ ! -f "/usr/local/s-ui/db/s-ui.db" ]]; then
+ local usernameTemp=$(head -c 6 /dev/urandom | base64)
+ local passwordTemp=$(head -c 6 /dev/urandom | base64)
+ echo -e "this is a fresh installation,will generate random login info for security concerns:"
+ echo -e "###############################################"
+ echo -e "${green}username:${usernameTemp}${plain}"
+ echo -e "${green}password:${passwordTemp}${plain}"
+ echo -e "###############################################"
+ echo -e "${red}if you forgot your login info,you can type ${green}s-ui${red} for configuration menu${plain}"
+ /usr/local/s-ui/sui admin -username ${usernameTemp} -password ${passwordTemp}
+ else
+ echo -e "${red} this is your upgrade,will keep old settings,if you forgot your login info,you can type ${green}s-ui${red} for configuration menu${plain}"
+ fi
+ fi
+}
+
+prepare_services() {
+ if [[ -f "/etc/systemd/system/sing-box.service" ]]; then
+ echo -e "${yellow}Stopping sing-box service... ${plain}"
+ systemctl stop sing-box
+ rm -f /usr/local/s-ui/bin/sing-box /usr/local/s-ui/bin/runSingbox.sh /usr/local/s-ui/bin/signal
+ fi
+ if [[ -e "/usr/local/s-ui/bin" ]]; then
+ echo -e "###############################################################"
+ echo -e "${green}/usr/local/s-ui/bin${red} directory exists yet!"
+ echo -e "Please check the content and delete it manually after migration ${plain}"
+ echo -e "###############################################################"
+ fi
+ systemctl daemon-reload
+}
+
+install_s-ui() {
+ cd /tmp/
+
+ if [ $# == 0 ]; then
+ last_version=$(curl -Ls "https://api.github.com/repos/alireza0/s-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
+ if [[ ! -n "$last_version" ]]; then
+ echo -e "${red}Failed to fetch s-ui version, it maybe due to Github API restrictions, please try it later${plain}"
+ exit 1
+ fi
+ echo -e "Got s-ui latest version: ${last_version}, beginning the installation..."
+ wget -N --no-check-certificate -O /tmp/s-ui-linux-$(arch).tar.gz https://github.com/alireza0/s-ui/releases/download/${last_version}/s-ui-linux-$(arch).tar.gz
+ if [[ $? -ne 0 ]]; then
+ echo -e "${red}Downloading s-ui failed, please be sure that your server can access Github ${plain}"
+ exit 1
+ fi
+ else
+ last_version=$1
+ url="https://github.com/alireza0/s-ui/releases/download/${last_version}/s-ui-linux-$(arch).tar.gz"
+ echo -e "Beginning the install s-ui v$1"
+ wget -N --no-check-certificate -O /tmp/s-ui-linux-$(arch).tar.gz ${url}
+ if [[ $? -ne 0 ]]; then
+ echo -e "${red}download s-ui v$1 failed,please check the version exists${plain}"
+ exit 1
+ fi
+ fi
+
+ if [[ -e /usr/local/s-ui/ ]]; then
+ systemctl stop s-ui
+ fi
+
+ tar zxvf s-ui-linux-$(arch).tar.gz
+ rm s-ui-linux-$(arch).tar.gz -f
+
+ chmod +x s-ui/sui s-ui/s-ui.sh
+ cp s-ui/s-ui.sh /usr/bin/s-ui
+ cp -rf s-ui /usr/local/
+ cp -f s-ui/*.service /etc/systemd/system/
+ rm -rf s-ui
+
+ config_after_install
+ prepare_services
+
+ systemctl enable s-ui --now
+
+ echo -e "${green}s-ui v${last_version}${plain} installation finished, it is up and running now..."
+ echo -e "You may access the Panel with following URL(s):${green}"
+ /usr/local/s-ui/sui uri
+ echo -e "${plain}"
+ echo -e ""
+ s-ui help
+}
+
+echo -e "${green}Executing...${plain}"
+install_base
+install_s-ui $1
diff --git a/logger/logger.go b/logger/logger.go
new file mode 100644
index 0000000..1f47d14
--- /dev/null
+++ b/logger/logger.go
@@ -0,0 +1,128 @@
+package logger
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/op/go-logging"
+)
+
+var (
+ logger *logging.Logger
+ logBuffer []struct {
+ time string
+ level logging.Level
+ log string
+ }
+)
+
+func InitLogger(level logging.Level) {
+ newLogger := logging.MustGetLogger("s-ui")
+ var err error
+ var backend logging.Backend
+ var format logging.Formatter
+
+ _, inContainer := os.LookupEnv("container")
+ if !inContainer {
+ if _, statErr := os.Stat("/.dockerenv"); statErr == nil {
+ inContainer = true
+ }
+ }
+ if inContainer {
+ backend = logging.NewLogBackend(os.Stderr, "", 0)
+ format = logging.MustStringFormatter(`%{time:2006/01/02 15:04:05} %{level} - %{message}`)
+ } else {
+ backend, err = logging.NewSyslogBackend("")
+ if err != nil {
+ fmt.Println("Unable to use syslog: " + err.Error())
+ backend = logging.NewLogBackend(os.Stderr, "", 0)
+ }
+ if err != nil {
+ format = logging.MustStringFormatter(`%{time:2006/01/02 15:04:05} %{level} - %{message}`)
+ } else {
+ format = logging.MustStringFormatter(`%{level} - %{message}`)
+ }
+ }
+
+ backendFormatter := logging.NewBackendFormatter(backend, format)
+ backendLeveled := logging.AddModuleLevel(backendFormatter)
+ backendLeveled.SetLevel(level, "s-ui")
+ newLogger.SetBackend(backendLeveled)
+
+ logger = newLogger
+}
+
+func GetLogger() *logging.Logger {
+ return logger
+}
+
+func Debug(args ...interface{}) {
+ logger.Debug(args...)
+ addToBuffer("DEBUG", fmt.Sprint(args...))
+}
+
+func Debugf(format string, args ...interface{}) {
+ logger.Debugf(format, args...)
+ addToBuffer("DEBUG", fmt.Sprintf(format, args...))
+}
+
+func Info(args ...interface{}) {
+ logger.Info(args...)
+ addToBuffer("INFO", fmt.Sprint(args...))
+}
+
+func Infof(format string, args ...interface{}) {
+ logger.Infof(format, args...)
+ addToBuffer("INFO", fmt.Sprintf(format, args...))
+}
+
+func Warning(args ...interface{}) {
+ logger.Warning(args...)
+ addToBuffer("WARNING", fmt.Sprint(args...))
+}
+
+func Warningf(format string, args ...interface{}) {
+ logger.Warningf(format, args...)
+ addToBuffer("WARNING", fmt.Sprintf(format, args...))
+}
+
+func Error(args ...interface{}) {
+ logger.Error(args...)
+ addToBuffer("ERROR", fmt.Sprint(args...))
+}
+
+func Errorf(format string, args ...interface{}) {
+ logger.Errorf(format, args...)
+ addToBuffer("ERROR", fmt.Sprintf(format, args...))
+}
+
+func addToBuffer(level string, newLog string) {
+ t := time.Now()
+ if len(logBuffer) >= 10240 {
+ logBuffer = logBuffer[1:]
+ }
+
+ logLevel, _ := logging.LogLevel(level)
+ logBuffer = append(logBuffer, struct {
+ time string
+ level logging.Level
+ log string
+ }{
+ time: t.Format("2006/01/02 15:04:05"),
+ level: logLevel,
+ log: newLog,
+ })
+}
+
+func GetLogs(c int, level string) []string {
+ var output []string
+ logLevel, _ := logging.LogLevel(level)
+
+ for i := len(logBuffer) - 1; i >= 0 && len(output) <= c; i-- {
+ if logBuffer[i].level <= logLevel {
+ output = append(output, fmt.Sprintf("%s %s - %s", logBuffer[i].time, logBuffer[i].level, logBuffer[i].log))
+ }
+ }
+ return output
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..2d54dd2
--- /dev/null
+++ b/main.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/alireza0/s-ui/app"
+ "github.com/alireza0/s-ui/cmd"
+)
+
+func runApp() {
+ app := app.NewApp()
+
+ err := app.Init()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ err = app.Start()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ sigCh := make(chan os.Signal, 1)
+ // Trap shutdown signals
+ signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM)
+ for {
+ sig := <-sigCh
+
+ switch sig {
+ case syscall.SIGHUP:
+ app.RestartApp()
+ default:
+ app.Stop()
+ return
+ }
+ }
+}
+
+func main() {
+ if len(os.Args) < 2 {
+ runApp()
+ return
+ } else {
+ cmd.ParseCmd()
+ }
+}
diff --git a/middleware/domainValidator.go b/middleware/domainValidator.go
new file mode 100644
index 0000000..44f0de5
--- /dev/null
+++ b/middleware/domainValidator.go
@@ -0,0 +1,25 @@
+package middleware
+
+import (
+ "net"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+func DomainValidator(domain string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ host := c.Request.Host
+ if colonIndex := strings.LastIndex(host, ":"); colonIndex != -1 {
+ host, _, _ = net.SplitHostPort(c.Request.Host)
+ }
+
+ if host != domain {
+ c.AbortWithStatus(http.StatusForbidden)
+ return
+ }
+
+ c.Next()
+ }
+}
diff --git a/network/auto_https_conn.go b/network/auto_https_conn.go
new file mode 100644
index 0000000..f0b700b
--- /dev/null
+++ b/network/auto_https_conn.go
@@ -0,0 +1,67 @@
+package network
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "net"
+ "net/http"
+ "sync"
+)
+
+type AutoHttpsConn struct {
+ net.Conn
+
+ firstBuf []byte
+ bufStart int
+
+ readRequestOnce sync.Once
+}
+
+func NewAutoHttpsConn(conn net.Conn) net.Conn {
+ return &AutoHttpsConn{
+ Conn: conn,
+ }
+}
+
+func (c *AutoHttpsConn) readRequest() bool {
+ c.firstBuf = make([]byte, 2048)
+ n, err := c.Conn.Read(c.firstBuf)
+ c.firstBuf = c.firstBuf[:n]
+ if err != nil {
+ return false
+ }
+ reader := bytes.NewReader(c.firstBuf)
+ bufReader := bufio.NewReader(reader)
+ request, err := http.ReadRequest(bufReader)
+ if err != nil {
+ return false
+ }
+ resp := http.Response{
+ Header: http.Header{},
+ }
+ resp.StatusCode = http.StatusTemporaryRedirect
+ location := fmt.Sprintf("https://%v%v", request.Host, request.RequestURI)
+ resp.Header.Set("Location", location)
+ resp.Write(c.Conn)
+ c.Close()
+ c.firstBuf = nil
+ return true
+}
+
+func (c *AutoHttpsConn) Read(buf []byte) (int, error) {
+ c.readRequestOnce.Do(func() {
+ c.readRequest()
+ })
+
+ if c.firstBuf != nil {
+ n := copy(buf, c.firstBuf[c.bufStart:])
+ c.bufStart += n
+ if c.bufStart >= len(c.firstBuf) {
+ c.firstBuf = nil
+ }
+ return n, nil
+ }
+
+ return c.Conn.Read(buf)
+}
diff --git a/network/auto_https_listener.go b/network/auto_https_listener.go
new file mode 100644
index 0000000..87fee83
--- /dev/null
+++ b/network/auto_https_listener.go
@@ -0,0 +1,21 @@
+package network
+
+import "net"
+
+type AutoHttpsListener struct {
+ net.Listener
+}
+
+func NewAutoHttpsListener(listener net.Listener) net.Listener {
+ return &AutoHttpsListener{
+ Listener: listener,
+ }
+}
+
+func (l *AutoHttpsListener) Accept() (net.Conn, error) {
+ conn, err := l.Listener.Accept()
+ if err != nil {
+ return nil, err
+ }
+ return NewAutoHttpsConn(conn), nil
+}
diff --git a/runSUI.sh b/runSUI.sh
new file mode 100644
index 0000000..3b8526c
--- /dev/null
+++ b/runSUI.sh
@@ -0,0 +1,2 @@
+./build.sh
+SUI_DB_FOLDER="db" SUI_DEBUG=true ./sui
\ No newline at end of file
diff --git a/s-ui.service b/s-ui.service
new file mode 100644
index 0000000..cac1b08
--- /dev/null
+++ b/s-ui.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=s-ui Service
+After=network.target
+Wants=network.target
+
+[Service]
+Type=simple
+WorkingDirectory=/usr/local/s-ui/
+ExecStart=/usr/local/s-ui/sui
+Restart=on-failure
+RestartSec=10s
+
+[Install]
+WantedBy=multi-user.target
\ No newline at end of file
diff --git a/s-ui.sh b/s-ui.sh
new file mode 100644
index 0000000..2526292
--- /dev/null
+++ b/s-ui.sh
@@ -0,0 +1,934 @@
+#!/bin/bash
+
+red='\033[0;31m'
+green='\033[0;32m'
+yellow='\033[0;33m'
+plain='\033[0m'
+
+function LOGD() {
+ echo -e "${yellow}[DEG] $* ${plain}"
+}
+
+function LOGE() {
+ echo -e "${red}[ERR] $* ${plain}"
+}
+
+function LOGI() {
+ echo -e "${green}[INF] $* ${plain}"
+}
+
+[[ $EUID -ne 0 ]] && LOGE "ERROR: You must be root to run this script! \n" && exit 1
+
+if [[ -f /etc/os-release ]]; then
+ source /etc/os-release
+ release=$ID
+elif [[ -f /usr/lib/os-release ]]; then
+ source /usr/lib/os-release
+ release=$ID
+else
+ echo "Failed to check the system OS, please contact the author!" >&2
+ exit 1
+fi
+
+echo "The OS release is: $release"
+
+confirm() {
+ if [[ $# > 1 ]]; then
+ echo && read -p "$1 [Default$2]: " temp
+ if [[ x"${temp}" == x"" ]]; then
+ temp=$2
+ fi
+ else
+ read -p "$1 [y/n]: " temp
+ fi
+ if [[ x"${temp}" == x"y" || x"${temp}" == x"Y" ]]; then
+ return 0
+ else
+ return 1
+ fi
+}
+
+confirm_restart() {
+ confirm "Restart the ${1} service" "y"
+ if [[ $? == 0 ]]; then
+ restart
+ else
+ show_menu
+ fi
+}
+
+before_show_menu() {
+ echo && echo -n -e "${yellow}Press enter to return to the main menu: ${plain}" && read temp
+ show_menu
+}
+
+install() {
+ bash <(curl -Ls https://raw.githubusercontent.com/alireza0/s-ui/main/install.sh)
+ if [[ $? == 0 ]]; then
+ if [[ $# == 0 ]]; then
+ start
+ else
+ start 0
+ fi
+ fi
+}
+
+update() {
+ confirm "This function will forcefully reinstall the latest version, and the data will not be lost. Do you want to continue?" "n"
+ if [[ $? != 0 ]]; then
+ LOGE "Cancelled"
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+ return 0
+ fi
+ bash <(curl -Ls https://raw.githubusercontent.com/alireza0/s-ui/main/install.sh)
+ if [[ $? == 0 ]]; then
+ LOGI "Update is complete, Panel has automatically restarted "
+ exit 0
+ fi
+}
+
+custom_version() {
+ echo "Enter the panel version (like 0.0.1):"
+ read panel_version
+
+ if [ -z "$panel_version" ]; then
+ echo "Panel version cannot be empty. Exiting."
+ exit 1
+ fi
+
+ download_link="https://raw.githubusercontent.com/alireza0/s-ui/master/install.sh"
+
+ install_command="bash <(curl -Ls $download_link) $panel_version"
+
+ echo "Downloading and installing panel version $panel_version..."
+ eval $install_command
+}
+
+uninstall() {
+ confirm "Are you sure you want to uninstall the panel?" "n"
+ if [[ $? != 0 ]]; then
+ if [[ $# == 0 ]]; then
+ show_menu
+ fi
+ return 0
+ fi
+ systemctl stop s-ui
+ systemctl disable s-ui
+ rm /etc/systemd/system/s-ui.service -f
+ systemctl daemon-reload
+ systemctl reset-failed
+ rm /etc/s-ui/ -rf
+ rm /usr/local/s-ui/ -rf
+
+ echo ""
+ echo -e "Uninstalled Successfully, If you want to remove this script, then after exiting the script run ${green}rm /usr/local/s-ui -f${plain} to delete it."
+ echo ""
+
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+}
+
+reset_admin() {
+ echo "It is not recommended to set admin's credentials to default!"
+ confirm "Are you sure you want to reset admin's credentials to default ?" "n"
+ if [[ $? == 0 ]]; then
+ /usr/local/s-ui/sui admin -reset
+ fi
+ before_show_menu
+}
+
+set_admin() {
+ echo "It is not recommended to set admin's credentials to a complex text."
+ read -p "Please set up your username:" config_account
+ read -p "Please set up your password:" config_password
+ /usr/local/s-ui/sui admin -username ${config_account} -password ${config_password}
+ before_show_menu
+}
+
+view_admin() {
+ /usr/local/s-ui/sui admin -show
+ before_show_menu
+}
+
+reset_setting() {
+ confirm "Are you sure you want to reset settings to default ?" "n"
+ if [[ $? == 0 ]]; then
+ /usr/local/s-ui/sui setting -reset
+ fi
+ before_show_menu
+}
+
+set_setting() {
+ echo -e "Enter the ${yellow}panel port${plain} (leave blank for existing/default value):"
+ read config_port
+ echo -e "Enter the ${yellow}panel path${plain} (leave blank for existing/default value):"
+ read config_path
+
+ echo -e "Enter the ${yellow}subscription port${plain} (leave blank for existing/default value):"
+ read config_subPort
+ echo -e "Enter the ${yellow}subscription path${plain} (leave blank for existing/default value):"
+ read config_subPath
+
+ echo -e "${yellow}Initializing, please wait...${plain}"
+ params=""
+ [ -z "$config_port" ] || params="$params -port $config_port"
+ [ -z "$config_path" ] || params="$params -path $config_path"
+ [ -z "$config_subPort" ] || params="$params -subPort $config_subPort"
+ [ -z "$config_subPath" ] || params="$params -subPath $config_subPath"
+ /usr/local/s-ui/sui setting ${params}
+ before_show_menu
+}
+
+view_setting() {
+ /usr/local/s-ui/sui setting -show
+ view_uri
+ before_show_menu
+}
+
+view_uri() {
+ info=$(/usr/local/s-ui/sui uri)
+ if [[ $? != 0 ]]; then
+ LOGE "Get current uri error"
+ before_show_menu
+ fi
+ LOGI "You may access the Panel with following URL(s):"
+ echo -e "${green}${info}${plain}"
+}
+
+start() {
+ check_status $1
+ if [[ $? == 0 ]]; then
+ echo ""
+ LOGI -e "${1} is running, No need to start again, If you need to restart, please select restart"
+ else
+ systemctl start $1
+ sleep 2
+ check_status $1
+ if [[ $? == 0 ]]; then
+ LOGI "${1} Started Successfully"
+ else
+ LOGE "Failed to start ${1}, Probably because it takes longer than two seconds to start, Please check the log information later"
+ fi
+ fi
+
+ if [[ $# == 1 ]]; then
+ before_show_menu
+ fi
+}
+
+stop() {
+ check_status $1
+ if [[ $? == 1 ]]; then
+ echo ""
+ LOGI "${1} stopped, No need to stop again!"
+ else
+ systemctl stop $1
+ sleep 2
+ check_status
+ if [[ $? == 1 ]]; then
+ LOGI "${1} stopped successfully"
+ else
+ LOGE "Failed to stop ${1}, Probably because the stop time exceeds two seconds, Please check the log information later"
+ fi
+ fi
+
+ if [[ $# == 1 ]]; then
+ before_show_menu
+ fi
+}
+
+restart() {
+ systemctl restart $1
+ sleep 2
+ check_status $1
+ if [[ $? == 0 ]]; then
+ LOGI "${1} Restarted successfully"
+ else
+ LOGE "Failed to restart ${1}, Probably because it takes longer than two seconds to start, Please check the log information later"
+ fi
+ if [[ $# == 1 ]]; then
+ before_show_menu
+ fi
+}
+
+status() {
+ systemctl status s-ui -l
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+}
+
+enable() {
+ systemctl enable $1
+ if [[ $? == 0 ]]; then
+ LOGI "Set ${1} to boot automatically on startup successfully"
+ else
+ LOGE "Failed to set ${1} Autostart"
+ fi
+
+ if [[ $# == 1 ]]; then
+ before_show_menu
+ fi
+}
+
+disable() {
+ systemctl disable $1
+ if [[ $? == 0 ]]; then
+ LOGI "Autostart ${1} Cancelled successfully"
+ else
+ LOGE "Failed to cancel ${1} autostart"
+ fi
+
+ if [[ $# == 1 ]]; then
+ before_show_menu
+ fi
+}
+
+show_log() {
+ journalctl -u $1.service -e --no-pager -f
+ if [[ $# == 1 ]]; then
+ before_show_menu
+ fi
+}
+
+update_shell() {
+ wget -O /usr/bin/s-ui -N --no-check-certificate https://github.com/alireza0/s-ui/raw/main/s-ui.sh
+ if [[ $? != 0 ]]; then
+ echo ""
+ LOGE "Failed to download script, Please check whether the machine can connect Github"
+ before_show_menu
+ else
+ chmod +x /usr/bin/s-ui
+ LOGI "Upgrade script succeeded, Please rerun the script" && exit 0
+ fi
+}
+
+check_status() {
+ if [[ ! -f "/etc/systemd/system/$1.service" ]]; then
+ return 2
+ fi
+ temp=$(systemctl status "$1" | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1)
+ if [[ x"${temp}" == x"running" ]]; then
+ return 0
+ else
+ return 1
+ fi
+}
+
+check_enabled() {
+ temp=$(systemctl is-enabled $1)
+ if [[ x"${temp}" == x"enabled" ]]; then
+ return 0
+ else
+ return 1
+ fi
+}
+
+check_uninstall() {
+ check_status s-ui
+ if [[ $? != 2 ]]; then
+ echo ""
+ LOGE "Panel is already installed, Please do not reinstall"
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+ return 1
+ else
+ return 0
+ fi
+}
+
+check_install() {
+ check_status s-ui
+ if [[ $? == 2 ]]; then
+ echo ""
+ LOGE "Please install the panel first"
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+ return 1
+ else
+ return 0
+ fi
+}
+
+show_status() {
+ check_status $1
+ case $? in
+ 0)
+ echo -e "${1} state: ${green}Running${plain}"
+ show_enable_status $1
+ ;;
+ 1)
+ echo -e "${1} state: ${yellow}Not Running${plain}"
+ show_enable_status $1
+ ;;
+ 2)
+ echo -e "${1} state: ${red}Not Installed${plain}"
+ ;;
+ esac
+}
+
+show_enable_status() {
+ check_enabled $1
+ if [[ $? == 0 ]]; then
+ echo -e "Start ${1} automatically: ${green}Yes${plain}"
+ else
+ echo -e "Start ${1} automatically: ${red}No${plain}"
+ fi
+}
+
+check_s-ui_status() {
+ count=$(ps -ef | grep "sui" | grep -v "grep" | wc -l)
+ if [[ count -ne 0 ]]; then
+ return 0
+ else
+ return 1
+ fi
+}
+
+show_s-ui_status() {
+ check_s-ui_status
+ if [[ $? == 0 ]]; then
+ echo -e "s-ui state: ${green}Running${plain}"
+ else
+ echo -e "s-ui state: ${red}Not Running${plain}"
+ fi
+}
+
+bbr_menu() {
+ echo -e "${green}\t1.${plain} Enable BBR"
+ echo -e "${green}\t2.${plain} Disable BBR"
+ echo -e "${green}\t0.${plain} Back to Main Menu"
+ read -p "Choose an option: " choice
+ case "$choice" in
+ 0)
+ show_menu
+ ;;
+ 1)
+ enable_bbr
+ ;;
+ 2)
+ disable_bbr
+ ;;
+ *) echo "Invalid choice" ;;
+ esac
+}
+
+disable_bbr() {
+ if ! grep -q "net.core.default_qdisc=fq" /etc/sysctl.conf || ! grep -q "net.ipv4.tcp_congestion_control=bbr" /etc/sysctl.conf; then
+ echo -e "${yellow}BBR is not currently enabled.${plain}"
+ exit 0
+ fi
+ sed -i 's/net.core.default_qdisc=fq/net.core.default_qdisc=pfifo_fast/' /etc/sysctl.conf
+ sed -i 's/net.ipv4.tcp_congestion_control=bbr/net.ipv4.tcp_congestion_control=cubic/' /etc/sysctl.conf
+ sysctl -p
+ if [[ $(sysctl net.ipv4.tcp_congestion_control | awk '{print $3}') == "cubic" ]]; then
+ echo -e "${green}BBR has been replaced with CUBIC successfully.${plain}"
+ else
+ echo -e "${red}Failed to replace BBR with CUBIC. Please check your system configuration.${plain}"
+ fi
+}
+
+enable_bbr() {
+ if grep -q "net.core.default_qdisc=fq" /etc/sysctl.conf && grep -q "net.ipv4.tcp_congestion_control=bbr" /etc/sysctl.conf; then
+ echo -e "${green}BBR is already enabled!${plain}"
+ exit 0
+ fi
+ case "${release}" in
+ ubuntu | debian | armbian)
+ apt-get update && apt-get install -yqq --no-install-recommends ca-certificates
+ ;;
+ centos | almalinux | rocky | oracle)
+ yum -y update && yum -y install ca-certificates
+ ;;
+ fedora)
+ dnf -y update && dnf -y install ca-certificates
+ ;;
+ arch | manjaro | parch)
+ pacman -Sy --noconfirm ca-certificates
+ ;;
+ *)
+ echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
+ exit 1
+ ;;
+ esac
+ echo "net.core.default_qdisc=fq" | tee -a /etc/sysctl.conf
+ echo "net.ipv4.tcp_congestion_control=bbr" | tee -a /etc/sysctl.conf
+ sysctl -p
+ if [[ $(sysctl net.ipv4.tcp_congestion_control | awk '{print $3}') == "bbr" ]]; then
+ echo -e "${green}BBR has been enabled successfully.${plain}"
+ else
+ echo -e "${red}Failed to enable BBR. Please check your system configuration.${plain}"
+ fi
+}
+
+install_acme() {
+ cd ~
+ LOGI "install acme..."
+ curl https://get.acme.sh | sh
+ if [ $? -ne 0 ]; then
+ LOGE "install acme failed"
+ return 1
+ else
+ LOGI "install acme succeed"
+ fi
+ return 0
+}
+
+ssl_cert_issue_main() {
+ echo -e "${green}\t1.${plain} Get SSL"
+ echo -e "${green}\t2.${plain} Revoke"
+ echo -e "${green}\t3.${plain} Force Renew"
+ echo -e "${green}\t4.${plain} Self-signed Certificate"
+ read -p "Choose an option: " choice
+ case "$choice" in
+ 1) ssl_cert_issue ;;
+ 2)
+ local domain=""
+ read -p "Please enter your domain name to revoke the certificate: " domain
+ ~/.acme.sh/acme.sh --revoke -d ${domain}
+ LOGI "Certificate revoked"
+ ;;
+ 3)
+ local domain=""
+ read -p "Please enter your domain name to forcefully renew an SSL certificate: " domain
+ ~/.acme.sh/acme.sh --renew -d ${domain} --force ;;
+ 4)
+ generate_self_signed_cert
+ ;;
+ *) echo "Invalid choice" ;;
+ esac
+}
+
+ssl_cert_issue() {
+ if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
+ echo "acme.sh could not be found. we will install it"
+ install_acme
+ if [ $? -ne 0 ]; then
+ LOGE "install acme failed, please check logs"
+ exit 1
+ fi
+ fi
+ case "${release}" in
+ ubuntu | debian | armbian)
+ apt update && apt install socat -y
+ ;;
+ centos | almalinux | rocky | oracle)
+ yum -y update && yum -y install socat
+ ;;
+ fedora)
+ dnf -y update && dnf -y install socat
+ ;;
+ arch | manjaro | parch)
+ pacman -Sy --noconfirm socat
+ ;;
+ *)
+ echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
+ exit 1
+ ;;
+ esac
+ if [ $? -ne 0 ]; then
+ LOGE "install socat failed, please check logs"
+ exit 1
+ else
+ LOGI "install socat succeed..."
+ fi
+
+ local domain=""
+ read -p "Please enter your domain name:" domain
+ LOGD "your domain is:${domain},check it..."
+ local currentCert=$(~/.acme.sh/acme.sh --list | tail -1 | awk '{print $1}')
+
+ if [ ${currentCert} == ${domain} ]; then
+ local certInfo=$(~/.acme.sh/acme.sh --list)
+ LOGE "system already has certs here,can not issue again,current certs details:"
+ LOGI "$certInfo"
+ exit 1
+ else
+ LOGI "your domain is ready for issuing cert now..."
+ fi
+
+ certPath="/root/cert/${domain}"
+ if [ ! -d "$certPath" ]; then
+ mkdir -p "$certPath"
+ else
+ rm -rf "$certPath"
+ mkdir -p "$certPath"
+ fi
+
+ local WebPort=80
+ read -p "please choose which port do you use,default will be 80 port:" WebPort
+ if [[ ${WebPort} -gt 65535 || ${WebPort} -lt 1 ]]; then
+ LOGE "your input ${WebPort} is invalid,will use default port"
+ fi
+ LOGI "will use port:${WebPort} to issue certs,please make sure this port is open..."
+ ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt
+ ~/.acme.sh/acme.sh --issue -d ${domain} --standalone --httpport ${WebPort}
+ if [ $? -ne 0 ]; then
+ LOGE "issue certs failed,please check logs"
+ rm -rf ~/.acme.sh/${domain}
+ exit 1
+ else
+ LOGE "issue certs succeed,installing certs..."
+ fi
+ ~/.acme.sh/acme.sh --installcert -d ${domain} \
+ --key-file /root/cert/${domain}/privkey.pem \
+ --fullchain-file /root/cert/${domain}/fullchain.pem
+
+ if [ $? -ne 0 ]; then
+ LOGE "install certs failed,exit"
+ rm -rf ~/.acme.sh/${domain}
+ exit 1
+ else
+ LOGI "install certs succeed,enable auto renew..."
+ fi
+
+ ~/.acme.sh/acme.sh --upgrade --auto-upgrade
+ if [ $? -ne 0 ]; then
+ LOGE "auto renew failed, certs details:"
+ ls -lah cert/*
+ chmod 755 $certPath/*
+ exit 1
+ else
+ LOGI "auto renew succeed, certs details:"
+ ls -lah cert/*
+ chmod 755 $certPath/*
+ fi
+}
+
+ssl_cert_issue_CF() {
+ echo -E ""
+ LOGD "******Instructions for use******"
+ echo "1) New certificate from Cloudflare"
+ echo "2) Force renew existing Certificates"
+ echo "3) Back to Menu"
+ read -p "Enter your choice [1-3]: " choice
+
+ certPath="/root/cert-CF"
+
+ case $choice in
+ 1|2)
+ force_flag=""
+ if [ "$choice" -eq 2 ]; then
+ force_flag="--force"
+ echo "Forcing SSL certificate reissuance..."
+ else
+ echo "Starting SSL certificate issuance..."
+ fi
+
+ LOGD "******Instructions for use******"
+ LOGI "This Acme script requires the following data:"
+ LOGI "1.Cloudflare Registered e-mail"
+ LOGI "2.Cloudflare Global API Key"
+ LOGI "3.The domain name that has been resolved DNS to the current server by Cloudflare"
+ LOGI "4.The script applies for a certificate. The default installation path is /root/cert "
+ confirm "Confirmed?[y/n]" "y"
+ if [ $? -eq 0 ]; then
+ if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
+ echo "acme.sh could not be found. Installing..."
+ install_acme
+ if [ $? -ne 0 ]; then
+ LOGE "Install acme failed, please check logs"
+ exit 1
+ fi
+ fi
+
+ CF_Domain=""
+ if [ ! -d "$certPath" ]; then
+ mkdir -p $certPath
+ else
+ rm -rf $certPath
+ mkdir -p $certPath
+ fi
+
+ LOGD "Please set a domain name:"
+ read -p "Input your domain here: " CF_Domain
+ LOGD "Your domain name is set to: ${CF_Domain}"
+
+ CF_GlobalKey=""
+ CF_AccountEmail=""
+ LOGD "Please set the API key:"
+ read -p "Input your key here: " CF_GlobalKey
+ LOGD "Your API key is: ${CF_GlobalKey}"
+
+ LOGD "Please set up registered email:"
+ read -p "Input your email here: " CF_AccountEmail
+ LOGD "Your registered email address is: ${CF_AccountEmail}"
+
+ ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt
+ if [ $? -ne 0 ]; then
+ LOGE "Default CA, Let's Encrypt failed, script exiting..."
+ exit 1
+ fi
+
+ export CF_Key="${CF_GlobalKey}"
+ export CF_Email="${CF_AccountEmail}"
+
+ ~/.acme.sh/acme.sh --issue --dns dns_cf -d ${CF_Domain} -d *.${CF_Domain} $force_flag --log
+ if [ $? -ne 0 ]; then
+ LOGE "Certificate issuance failed, script exiting..."
+ exit 1
+ else
+ LOGI "Certificate issued Successfully, Installing..."
+ fi
+
+ mkdir -p ${certPath}/${CF_Domain}
+ if [ $? -ne 0 ]; then
+ LOGE "Failed to create directory: ${certPath}/${CF_Domain}"
+ exit 1
+ fi
+
+ ~/.acme.sh/acme.sh --installcert -d ${CF_Domain} -d *.${CF_Domain} \
+ --fullchain-file ${certPath}/${CF_Domain}/fullchain.pem \
+ --key-file ${certPath}/${CF_Domain}/privkey.pem
+
+ if [ $? -ne 0 ]; then
+ LOGE "Certificate installation failed, script exiting..."
+ exit 1
+ else
+ LOGI "Certificate installed Successfully, Turning on automatic updates..."
+ fi
+
+ ~/.acme.sh/acme.sh --upgrade --auto-upgrade
+ if [ $? -ne 0 ]; then
+ LOGE "Auto update setup failed, script exiting..."
+ exit 1
+ else
+ LOGI "The certificate is installed and auto-renewal is turned on."
+ ls -lah ${certPath}/${CF_Domain}
+ chmod 755 ${certPath}/${CF_Domain}
+ fi
+ fi
+ show_menu
+ ;;
+ 3)
+ echo "Exiting..."
+ show_menu
+ ;;
+ *)
+ echo "Invalid choice, please select again."
+ show_menu
+ ;;
+ esac
+}
+
+generate_self_signed_cert() {
+ cert_dir="/etc/sing-box"
+ mkdir -p "$cert_dir"
+ LOGI "Choose certificate type:"
+ echo -e "${green}\t1.${plain} Ed25519 (*recommended*)"
+ echo -e "${green}\t2.${plain} RSA 2048"
+ echo -e "${green}\t3.${plain} RSA 4096"
+ echo -e "${green}\t4.${plain} ECDSA prime256v1"
+ echo -e "${green}\t5.${plain} ECDSA secp384r1"
+ read -p "Enter your choice [1-5, default 1]: " cert_type
+ cert_type=${cert_type:-1}
+
+ case "$cert_type" in
+ 1)
+ algo="ed25519"
+ key_opt="-newkey ed25519"
+ ;;
+ 2)
+ algo="rsa"
+ key_opt="-newkey rsa:2048"
+ ;;
+ 3)
+ algo="rsa"
+ key_opt="-newkey rsa:4096"
+ ;;
+ 4)
+ algo="ecdsa"
+ key_opt="-newkey ec -pkeyopt ec_paramgen_curve:prime256v1"
+ ;;
+ 5)
+ algo="ecdsa"
+ key_opt="-newkey ec -pkeyopt ec_paramgen_curve:secp384r1"
+ ;;
+ *)
+ algo="ed25519"
+ key_opt="-newkey ed25519"
+ ;;
+ esac
+
+ LOGI "Generating self-signed certificate ($algo)..."
+ sudo openssl req -x509 -nodes -days 3650 $key_opt \
+ -keyout "${cert_dir}/self.key" \
+ -out "${cert_dir}/self.crt" \
+ -subj "/CN=myserver"
+ if [[ $? -eq 0 ]]; then
+ sudo chmod 600 "${cert_dir}/self."*
+ LOGI "Self-signed certificate generated successfully!"
+ LOGI "Certificate path: ${cert_dir}/self.crt"
+ LOGI "Key path: ${cert_dir}/self.key"
+ else
+ LOGE "Failed to generate self-signed certificate."
+ fi
+ before_show_menu
+}
+
+show_usage() {
+ echo -e "S-UI Control Menu Usage"
+ echo -e "------------------------------------------"
+ echo -e "SUBCOMMANDS:"
+ echo -e "s-ui - Admin Management Script"
+ echo -e "s-ui start - Start s-ui"
+ echo -e "s-ui stop - Stop s-ui"
+ echo -e "s-ui restart - Restart s-ui"
+ echo -e "s-ui status - Current Status of s-ui"
+ echo -e "s-ui enable - Enable Autostart on OS Startup"
+ echo -e "s-ui disable - Disable Autostart on OS Startup"
+ echo -e "s-ui log - Check s-ui Logs"
+ echo -e "s-ui update - Update"
+ echo -e "s-ui install - Install"
+ echo -e "s-ui uninstall - Uninstall"
+ echo -e "s-ui help - Control Menu Usage"
+ echo -e "------------------------------------------"
+}
+
+show_menu() {
+ echo -e "
+ ${green}S-UI Admin Management Script ${plain}
+————————————————————————————————
+ ${green}0.${plain} Exit
+————————————————————————————————
+ ${green}1.${plain} Install
+ ${green}2.${plain} Update
+ ${green}3.${plain} Custom Version
+ ${green}4.${plain} Uninstall
+————————————————————————————————
+ ${green}5.${plain} Reset admin credentials to default
+ ${green}6.${plain} Set admin credentials
+ ${green}7.${plain} View admin credentials
+————————————————————————————————
+ ${green}8.${plain} Reset Panel Settings
+ ${green}9.${plain} Set Panel settings
+ ${green}10.${plain} View Panel Settings
+————————————————————————————————
+ ${green}11.${plain} S-UI Start
+ ${green}12.${plain} S-UI Stop
+ ${green}13.${plain} S-UI Restart
+ ${green}14.${plain} S-UI Check State
+ ${green}15.${plain} S-UI Check Logs
+ ${green}16.${plain} S-UI Enable Autostart
+ ${green}17.${plain} S-UI Disable Autostart
+————————————————————————————————
+ ${green}18.${plain} Enable or Disable BBR
+ ${green}19.${plain} SSL Certificate Management
+ ${green}20.${plain} Cloudflare SSL Certificate
+————————————————————————————————
+ "
+ show_status s-ui
+ echo && read -p "Please enter your selection [0-20]: " num
+
+ case "${num}" in
+ 0)
+ exit 0
+ ;;
+ 1)
+ check_uninstall && install
+ ;;
+ 2)
+ check_install && update
+ ;;
+ 3)
+ check_install && custom_version
+ ;;
+ 4)
+ check_install && uninstall
+ ;;
+ 5)
+ check_install && reset_admin
+ ;;
+ 6)
+ check_install && set_admin
+ ;;
+ 7)
+ check_install && view_admin
+ ;;
+ 8)
+ check_install && reset_setting
+ ;;
+ 9)
+ check_install && set_setting
+ ;;
+ 10)
+ check_install && view_setting
+ ;;
+ 11)
+ check_install && start s-ui
+ ;;
+ 12)
+ check_install && stop s-ui
+ ;;
+ 13)
+ check_install && restart s-ui
+ ;;
+ 14)
+ check_install && status s-ui
+ ;;
+ 15)
+ check_install && show_log s-ui
+ ;;
+ 16)
+ check_install && enable s-ui
+ ;;
+ 17)
+ check_install && disable s-ui
+ ;;
+ 18)
+ bbr_menu
+ ;;
+ 19)
+ ssl_cert_issue_main
+ ;;
+ 20)
+ ssl_cert_issue_CF
+ ;;
+ *)
+ LOGE "Please enter the correct number [0-20]"
+ ;;
+ esac
+}
+
+if [[ $# > 0 ]]; then
+ case $1 in
+ "start")
+ check_install 0 && start s-ui 0
+ ;;
+ "stop")
+ check_install 0 && stop s-ui 0
+ ;;
+ "restart")
+ check_install 0 && restart s-ui 0
+ ;;
+ "status")
+ check_install 0 && status 0
+ ;;
+ "enable")
+ check_install 0 && enable s-ui 0
+ ;;
+ "disable")
+ check_install 0 && disable s-ui 0
+ ;;
+ "log")
+ check_install 0 && show_log s-ui 0
+ ;;
+ "update")
+ check_install 0 && update 0
+ ;;
+ "install")
+ check_uninstall 0 && install 0
+ ;;
+ "uninstall")
+ check_install 0 && uninstall 0
+ ;;
+ *) show_usage ;;
+ esac
+else
+ show_menu
+fi
diff --git a/service/client.go b/service/client.go
new file mode 100644
index 0000000..3bece6b
--- /dev/null
+++ b/service/client.go
@@ -0,0 +1,548 @@
+package service
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "time"
+
+ "github.com/alireza0/s-ui/database"
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/logger"
+ "github.com/alireza0/s-ui/util"
+ "github.com/alireza0/s-ui/util/common"
+
+ "gorm.io/gorm"
+)
+
+type ClientService struct{}
+
+func (s *ClientService) Get(id string) (*[]model.Client, error) {
+ if id == "" {
+ return s.GetAll()
+ }
+ return s.getById(id)
+}
+
+func (s *ClientService) getById(id string) (*[]model.Client, error) {
+ db := database.GetDB()
+ var client []model.Client
+ err := db.Model(model.Client{}).Where("id in ?", strings.Split(id, ",")).Scan(&client).Error
+ if err != nil {
+ return nil, err
+ }
+
+ return &client, nil
+}
+
+func (s *ClientService) GetAll() (*[]model.Client, error) {
+ db := database.GetDB()
+ var clients []model.Client
+ err := db.Model(model.Client{}).
+ Select("`id`, `enable`, `name`, `desc`, `group`, `inbounds`, `up`, `down`, `volume`, `expiry`").
+ Scan(&clients).Error
+ if err != nil {
+ return nil, err
+ }
+ return &clients, nil
+}
+
+func (s *ClientService) Save(tx *gorm.DB, act string, data json.RawMessage, hostname string) ([]uint, error) {
+ var err error
+ var inboundIds []uint
+
+ switch act {
+ case "new", "edit":
+ var client model.Client
+ err = json.Unmarshal(data, &client)
+ if err != nil {
+ return nil, err
+ }
+ err = s.updateLinksWithFixedInbounds(tx, []*model.Client{&client}, hostname)
+ if err != nil {
+ return nil, err
+ }
+ if act == "edit" {
+ // Find changed inbounds
+ inboundIds, err = s.findInboundsChanges(tx, &client, false)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ err = json.Unmarshal(client.Inbounds, &inboundIds)
+ if err != nil {
+ return nil, err
+ }
+ }
+ err = tx.Save(&client).Error
+ if err != nil {
+ return nil, err
+ }
+ case "addbulk":
+ var clients []*model.Client
+ err = json.Unmarshal(data, &clients)
+ if err != nil {
+ return nil, err
+ }
+ err = json.Unmarshal(clients[0].Inbounds, &inboundIds)
+ if err != nil {
+ return nil, err
+ }
+ err = s.updateLinksWithFixedInbounds(tx, clients, hostname)
+ if err != nil {
+ return nil, err
+ }
+ err = tx.Save(clients).Error
+ if err != nil {
+ return nil, err
+ }
+ case "editbulk":
+ var clients []*model.Client
+ err = json.Unmarshal(data, &clients)
+ if err != nil {
+ return nil, err
+ }
+ for _, client := range clients {
+ changedInboundIds, err := s.findInboundsChanges(tx, client, true)
+ if err != nil {
+ return nil, err
+ }
+ if len(changedInboundIds) > 0 {
+ inboundIds = common.UnionUintArray(inboundIds, changedInboundIds)
+ }
+ }
+ if len(inboundIds) > 0 {
+ err = s.updateLinksWithFixedInbounds(tx, clients, hostname)
+ if err != nil {
+ return nil, err
+ }
+ }
+ err = tx.Save(clients).Error
+ if err != nil {
+ return nil, err
+ }
+ case "delbulk":
+ var ids []uint
+ err = json.Unmarshal(data, &ids)
+ if err != nil {
+ return nil, err
+ }
+ for _, id := range ids {
+ var client model.Client
+ err = tx.Where("id = ?", id).First(&client).Error
+ if err != nil {
+ return nil, err
+ }
+ var clientInbounds []uint
+ err = json.Unmarshal(client.Inbounds, &clientInbounds)
+ if err != nil {
+ return nil, err
+ }
+ inboundIds = common.UnionUintArray(inboundIds, clientInbounds)
+ }
+ err = tx.Where("id in ?", ids).Delete(model.Client{}).Error
+ if err != nil {
+ return nil, err
+ }
+ case "del":
+ var id uint
+ err = json.Unmarshal(data, &id)
+ if err != nil {
+ return nil, err
+ }
+ var client model.Client
+ err = tx.Where("id = ?", id).First(&client).Error
+ if err != nil {
+ return nil, err
+ }
+ err = json.Unmarshal(client.Inbounds, &inboundIds)
+ if err != nil {
+ return nil, err
+ }
+ err = tx.Where("id = ?", id).Delete(model.Client{}).Error
+ if err != nil {
+ return nil, err
+ }
+ default:
+ return nil, common.NewErrorf("unknown action: %s", act)
+ }
+
+ return inboundIds, nil
+}
+
+func (s *ClientService) updateLinksWithFixedInbounds(tx *gorm.DB, clients []*model.Client, hostname string) error {
+ var err error
+ var inbounds []model.Inbound
+ var inboundIds []uint
+
+ err = json.Unmarshal(clients[0].Inbounds, &inboundIds)
+ if err != nil {
+ return err
+ }
+
+ // Zero inbounds means removing local links only
+ if len(inboundIds) > 0 {
+ err = tx.Model(model.Inbound{}).Preload("Tls").Where("id in ? and type in ?", inboundIds, util.InboundTypeWithLink).Find(&inbounds).Error
+ if err != nil {
+ return err
+ }
+ }
+ for index, client := range clients {
+ var clientLinks []map[string]string
+ err = json.Unmarshal(client.Links, &clientLinks)
+ if err != nil {
+ return err
+ }
+
+ newClientLinks := []map[string]string{}
+ for _, inbound := range inbounds {
+ newLinks := util.LinkGenerator(client.Config, &inbound, hostname)
+ for _, newLink := range newLinks {
+ newClientLinks = append(newClientLinks, map[string]string{
+ "remark": inbound.Tag,
+ "type": "local",
+ "uri": newLink,
+ })
+ }
+ }
+
+ // Add non local links
+ for _, clientLink := range clientLinks {
+ if clientLink["type"] != "local" {
+ newClientLinks = append(newClientLinks, clientLink)
+ }
+ }
+
+ clients[index].Links, err = json.MarshalIndent(newClientLinks, "", " ")
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *ClientService) UpdateClientsOnInboundAdd(tx *gorm.DB, initIds string, inboundId uint, hostname string) error {
+ clientIds := strings.Split(initIds, ",")
+ var clients []model.Client
+ err := tx.Model(model.Client{}).Where("id in ?", clientIds).Find(&clients).Error
+ if err != nil {
+ return err
+ }
+ var inbound model.Inbound
+ err = tx.Model(model.Inbound{}).Preload("Tls").Where("id = ?", inboundId).Find(&inbound).Error
+ if err != nil {
+ return err
+ }
+ for _, client := range clients {
+ // Add inbounds
+ var clientInbounds []uint
+ json.Unmarshal(client.Inbounds, &clientInbounds)
+ clientInbounds = append(clientInbounds, inboundId)
+ client.Inbounds, err = json.MarshalIndent(clientInbounds, "", " ")
+ if err != nil {
+ return err
+ }
+ // Add links
+ var clientLinks, newClientLinks []map[string]string
+ json.Unmarshal(client.Links, &clientLinks)
+ newLinks := util.LinkGenerator(client.Config, &inbound, hostname)
+ for _, newLink := range newLinks {
+ newClientLinks = append(newClientLinks, map[string]string{
+ "remark": inbound.Tag,
+ "type": "local",
+ "uri": newLink,
+ })
+ }
+ for _, clientLink := range clientLinks {
+ if clientLink["remark"] != inbound.Tag {
+ newClientLinks = append(newClientLinks, clientLink)
+ }
+ }
+
+ client.Links, err = json.MarshalIndent(newClientLinks, "", " ")
+ if err != nil {
+ return err
+ }
+ err = tx.Save(&client).Error
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *ClientService) UpdateClientsOnInboundDelete(tx *gorm.DB, id uint, tag string) error {
+ var clientIds []uint
+ err := tx.Raw("SELECT clients.id FROM clients, json_each(clients.inbounds) AS je WHERE je.value = ?", id).Scan(&clientIds).Error
+ if err != nil {
+ return err
+ }
+ if len(clientIds) == 0 {
+ return nil
+ }
+ var clients []model.Client
+ err = tx.Model(model.Client{}).Where("id IN ?", clientIds).Find(&clients).Error
+ if err != nil {
+ return err
+ }
+ for _, client := range clients {
+ // Delete inbounds
+ var clientInbounds, newClientInbounds []uint
+ json.Unmarshal(client.Inbounds, &clientInbounds)
+ for _, clientInbound := range clientInbounds {
+ if clientInbound != id {
+ newClientInbounds = append(newClientInbounds, clientInbound)
+ }
+ }
+ client.Inbounds, err = json.MarshalIndent(newClientInbounds, "", " ")
+ if err != nil {
+ return err
+ }
+ // Delete links
+ var clientLinks, newClientLinks []map[string]string
+ json.Unmarshal(client.Links, &clientLinks)
+ for _, clientLink := range clientLinks {
+ if clientLink["remark"] != tag {
+ newClientLinks = append(newClientLinks, clientLink)
+ }
+ }
+ client.Links, err = json.MarshalIndent(newClientLinks, "", " ")
+ if err != nil {
+ return err
+ }
+ err = tx.Save(&client).Error
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *ClientService) UpdateLinksByInboundChange(tx *gorm.DB, inbounds *[]model.Inbound, hostname string, oldTag string) error {
+ var err error
+ for _, inbound := range *inbounds {
+ var clientIds []uint
+ err = tx.Raw("SELECT clients.id FROM clients, json_each(clients.inbounds) AS je WHERE je.value = ?", inbound.Id).Scan(&clientIds).Error
+ if err != nil {
+ return err
+ }
+ if len(clientIds) == 0 {
+ continue
+ }
+ var clients []model.Client
+ err = tx.Model(model.Client{}).Where("id IN ?", clientIds).Find(&clients).Error
+ if err != nil {
+ return err
+ }
+ for _, client := range clients {
+ var clientLinks, newClientLinks []map[string]string
+ json.Unmarshal(client.Links, &clientLinks)
+ newLinks := util.LinkGenerator(client.Config, &inbound, hostname)
+ for _, newLink := range newLinks {
+ newClientLinks = append(newClientLinks, map[string]string{
+ "remark": inbound.Tag,
+ "type": "local",
+ "uri": newLink,
+ })
+ }
+ for _, clientLink := range clientLinks {
+ if clientLink["type"] != "local" || (clientLink["remark"] != inbound.Tag && clientLink["remark"] != oldTag) {
+ newClientLinks = append(newClientLinks, clientLink)
+ }
+ }
+
+ client.Links, err = json.MarshalIndent(newClientLinks, "", " ")
+ if err != nil {
+ return err
+ }
+ err = tx.Save(&client).Error
+ if err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func (s *ClientService) DepleteClients() ([]uint, error) {
+ var err error
+ var clients []model.Client
+ var changes []model.Changes
+ var users []string
+ var inboundIds []uint
+
+ dt := time.Now().Unix()
+ db := database.GetDB()
+
+ tx := db.Begin()
+ defer func() {
+ if err == nil {
+ tx.Commit()
+ if err1 := db.Exec("PRAGMA wal_checkpoint(FULL)").Error; err1 != nil {
+ logger.Error("Error checkpointing WAL: ", err1.Error())
+ }
+ } else {
+ tx.Rollback()
+ }
+ }()
+
+ // Reset clients
+ inboundIds, err = s.ResetClients(tx, dt)
+ if err != nil {
+ return nil, err
+ }
+
+ // Deplete clients
+ err = tx.Model(model.Client{}).Where("enable = true AND ((volume >0 AND up+down > volume) OR (expiry > 0 AND expiry < ?))", dt).Scan(&clients).Error
+ if err != nil {
+ return nil, err
+ }
+
+ for _, client := range clients {
+ logger.Debug("Client ", client.Name, " is going to be disabled")
+ users = append(users, client.Name)
+ var userInbounds []uint
+ json.Unmarshal(client.Inbounds, &userInbounds)
+ // Find changed inbounds
+ inboundIds = common.UnionUintArray(inboundIds, userInbounds)
+ changes = append(changes, model.Changes{
+ DateTime: dt,
+ Actor: "DepleteJob",
+ Key: "clients",
+ Action: "disable",
+ Obj: json.RawMessage("\"" + client.Name + "\""),
+ })
+ }
+
+ // Save changes
+ if len(changes) > 0 {
+ err = tx.Model(model.Client{}).Where("enable = true AND ((volume >0 AND up+down > volume) OR (expiry > 0 AND expiry < ?))", dt).Update("enable", false).Error
+ if err != nil {
+ return nil, err
+ }
+ err = tx.Model(model.Changes{}).Create(&changes).Error
+ if err != nil {
+ return nil, err
+ }
+ LastUpdate = dt
+ }
+
+ return inboundIds, nil
+}
+
+func (s *ClientService) ResetClients(tx *gorm.DB, dt int64) ([]uint, error) {
+ var err error
+ var resetClients, allClients []*model.Client
+ var changes []model.Changes
+ var inboundIds []uint
+ // Set delay start without periodic reset
+ err = tx.Model(model.Client{}).
+ Where("enable = true AND delay_start = true AND auto_reset = false AND (Up + Down) > 0").Find(&resetClients).Error
+ if err != nil {
+ return nil, err
+ }
+ for _, client := range resetClients {
+ client.Expiry = dt + (int64(client.ResetDays) * 86400)
+ client.DelayStart = false
+ changes = append(changes, model.Changes{
+ DateTime: dt,
+ Actor: "ResetJob",
+ Key: "clients",
+ Action: "reset",
+ Obj: json.RawMessage("\"" + client.Name + "\""),
+ })
+ }
+ allClients = append(allClients, resetClients...)
+
+ // Set delay start with periodic reset
+ err = tx.Model(model.Client{}).
+ Where("enable = true AND delay_start = true AND auto_reset = true AND (Up + Down) > 0").Find(&resetClients).Error
+ if err != nil {
+ return nil, err
+ }
+ for _, client := range resetClients {
+ client.NextReset = dt + (int64(client.ResetDays) * 86400)
+ client.DelayStart = false
+ changes = append(changes, model.Changes{
+ DateTime: dt,
+ Actor: "ResetJob",
+ Key: "clients",
+ Action: "reset",
+ Obj: json.RawMessage("\"" + client.Name + "\""),
+ })
+ }
+ allClients = append(allClients, resetClients...)
+
+ // Set periodic reset
+ err = tx.Model(model.Client{}).
+ Where("delay_start = false AND auto_reset = true AND next_reset < ?", dt).Find(&resetClients).Error
+ if err != nil {
+ return nil, err
+ }
+ for _, client := range resetClients {
+ client.NextReset = dt + (int64(client.ResetDays) * 86400)
+ client.TotalUp += client.Up
+ client.TotalDown += client.Down
+ client.Up = 0
+ client.Down = 0
+ if !client.Enable {
+ client.Enable = true
+ var clientInboundIds []uint
+ json.Unmarshal(client.Inbounds, &clientInboundIds)
+ inboundIds = common.UnionUintArray(inboundIds, clientInboundIds)
+ }
+ }
+ allClients = append(allClients, resetClients...)
+
+ // Save clients
+ if len(allClients) > 0 {
+ err = tx.Save(allClients).Error
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // Save changes
+ if len(changes) > 0 {
+ err = tx.Model(model.Changes{}).Create(&changes).Error
+ if err != nil {
+ return nil, err
+ }
+ LastUpdate = dt
+ }
+ return inboundIds, nil
+}
+
+func (s *ClientService) findInboundsChanges(tx *gorm.DB, client *model.Client, fillOmitted bool) ([]uint, error) {
+ var err error
+ var oldClient model.Client
+ var oldInboundIds, newInboundIds []uint
+ err = tx.Model(model.Client{}).Where("id = ?", client.Id).First(&oldClient).Error
+ if err != nil {
+ return nil, err
+ }
+ if fillOmitted {
+ client.Links = oldClient.Links
+ client.Config = oldClient.Config
+ }
+ err = json.Unmarshal(oldClient.Inbounds, &oldInboundIds)
+ if err != nil {
+ return nil, err
+ }
+ err = json.Unmarshal(client.Inbounds, &newInboundIds)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check client.Config changes
+ if !bytes.Equal(oldClient.Config, client.Config) ||
+ oldClient.Name != client.Name ||
+ oldClient.Enable != client.Enable {
+ return common.UnionUintArray(oldInboundIds, newInboundIds), nil
+ }
+
+ // Check client.Inbounds changes
+ diffInbounds := common.DiffUintArray(oldInboundIds, newInboundIds)
+
+ return diffInbounds, nil
+}
diff --git a/service/config.go b/service/config.go
new file mode 100644
index 0000000..5908f76
--- /dev/null
+++ b/service/config.go
@@ -0,0 +1,297 @@
+package service
+
+import (
+ "encoding/json"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/alireza0/s-ui/core"
+ "github.com/alireza0/s-ui/database"
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/logger"
+ "github.com/alireza0/s-ui/util/common"
+)
+
+var (
+ LastUpdate int64
+ corePtr *core.Core
+ startCoreMu sync.Mutex
+ startCoreInProgress bool
+ lastStartFailTime time.Time
+ startCooldown = 15 * time.Second
+)
+
+type ConfigService struct {
+ ClientService
+ TlsService
+ SettingService
+ InboundService
+ OutboundService
+ ServicesService
+ EndpointService
+}
+
+type SingBoxConfig struct {
+ Log json.RawMessage `json:"log"`
+ Dns json.RawMessage `json:"dns"`
+ Ntp json.RawMessage `json:"ntp"`
+ Inbounds []json.RawMessage `json:"inbounds"`
+ Outbounds []json.RawMessage `json:"outbounds"`
+ Services []json.RawMessage `json:"services"`
+ Endpoints []json.RawMessage `json:"endpoints"`
+ Route json.RawMessage `json:"route"`
+ Experimental json.RawMessage `json:"experimental"`
+}
+
+func NewConfigService(core *core.Core) *ConfigService {
+ corePtr = core
+ return &ConfigService{}
+}
+
+func (s *ConfigService) GetConfig(data string) (*[]byte, error) {
+ var err error
+ if len(data) == 0 {
+ data, err = s.SettingService.GetConfig()
+ if err != nil {
+ return nil, err
+ }
+ }
+ singboxConfig := SingBoxConfig{}
+ err = json.Unmarshal([]byte(data), &singboxConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ singboxConfig.Inbounds, err = s.InboundService.GetAllConfig(database.GetDB())
+ if err != nil {
+ return nil, err
+ }
+ singboxConfig.Outbounds, err = s.OutboundService.GetAllConfig(database.GetDB())
+ if err != nil {
+ return nil, err
+ }
+ singboxConfig.Services, err = s.ServicesService.GetAllConfig(database.GetDB())
+ if err != nil {
+ return nil, err
+ }
+ singboxConfig.Endpoints, err = s.EndpointService.GetAllConfig(database.GetDB())
+ if err != nil {
+ return nil, err
+ }
+ rawConfig, err := json.MarshalIndent(singboxConfig, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ return &rawConfig, nil
+}
+
+func (s *ConfigService) StartCore() error {
+ if corePtr.IsRunning() {
+ return nil
+ }
+ startCoreMu.Lock()
+ if startCoreInProgress {
+ startCoreMu.Unlock()
+ return nil
+ }
+ if time.Since(lastStartFailTime) < startCooldown {
+ logger.Info("start core cooldown ", startCooldown/time.Second, " seconds")
+ startCoreMu.Unlock()
+ return nil
+ }
+ startCoreInProgress = true
+ startCoreMu.Unlock()
+ defer func() {
+ startCoreMu.Lock()
+ startCoreInProgress = false
+ startCoreMu.Unlock()
+ }()
+
+ logger.Info("starting core")
+ rawConfig, err := s.GetConfig("")
+ if err != nil {
+ return err
+ }
+ err = corePtr.Start(*rawConfig)
+ if err != nil {
+ startCoreMu.Lock()
+ lastStartFailTime = time.Now()
+ startCoreMu.Unlock()
+ logger.Error("start sing-box err:", err.Error())
+ return err
+ }
+ logger.Info("sing-box started")
+ return nil
+}
+
+func (s *ConfigService) RestartCore() error {
+ err := s.StopCore()
+ if err != nil {
+ return err
+ }
+ return s.StartCore()
+}
+
+func (s *ConfigService) restartCoreWithConfig(config json.RawMessage) error {
+ startCoreMu.Lock()
+ if startCoreInProgress {
+ startCoreMu.Unlock()
+ return nil
+ }
+ startCoreInProgress = true
+ startCoreMu.Unlock()
+ defer func() {
+ startCoreMu.Lock()
+ startCoreInProgress = false
+ startCoreMu.Unlock()
+ }()
+
+ if corePtr.IsRunning() {
+ if err := corePtr.Stop(); err != nil {
+ logger.Error("restart sing-box err (stop):", err.Error())
+ return err
+ }
+ }
+ rawConfig, err := s.GetConfig(string(config))
+ if err != nil {
+ logger.Error("restart sing-box err (get config):", err.Error())
+ return err
+ }
+ if err := corePtr.Start(*rawConfig); err != nil {
+ logger.Error("restart sing-box err (start):", err.Error())
+ return err
+ }
+ logger.Info("sing-box restarted with new config")
+ return nil
+}
+
+func (s *ConfigService) StopCore() error {
+ err := corePtr.Stop()
+ if err != nil {
+ return err
+ }
+ logger.Info("sing-box stopped")
+ return nil
+}
+
+func (s *ConfigService) CheckOutbound(tag string, link string) core.CheckOutboundResult {
+ if tag == "" {
+ return core.CheckOutboundResult{Error: "missing query parameter: tag"}
+ }
+ if corePtr == nil || !corePtr.IsRunning() {
+ return core.CheckOutboundResult{Error: "core not running"}
+ }
+ return core.CheckOutbound(corePtr.GetCtx(), tag, link)
+}
+
+func (s *ConfigService) Save(obj string, act string, data json.RawMessage, initUsers string, loginUser string, hostname string) ([]string, error) {
+ var err error
+ var objs []string = []string{obj}
+
+ db := database.GetDB()
+ tx := db.Begin()
+ defer func() {
+ if err == nil {
+ tx.Commit()
+ // Try to start core if it is not running
+ if !corePtr.IsRunning() {
+ s.StartCore()
+ }
+ } else {
+ tx.Rollback()
+ }
+ }()
+
+ switch obj {
+ case "clients":
+ var inboundIds []uint
+ inboundIds, err = s.ClientService.Save(tx, act, data, hostname)
+ if err == nil && len(inboundIds) > 0 {
+ objs = append(objs, "inbounds")
+ err = s.InboundService.RestartInbounds(tx, inboundIds)
+ if err != nil {
+ return nil, common.NewErrorf("failed to update users for inbounds: %v", err)
+ }
+ }
+ case "tls":
+ err = s.TlsService.Save(tx, act, data, hostname)
+ objs = append(objs, "clients", "inbounds")
+ case "inbounds":
+ err = s.InboundService.Save(tx, act, data, initUsers, hostname)
+ objs = append(objs, "clients")
+ case "outbounds":
+ err = s.OutboundService.Save(tx, act, data)
+ case "services":
+ err = s.ServicesService.Save(tx, act, data)
+ case "endpoints":
+ err = s.EndpointService.Save(tx, act, data)
+ case "config":
+ err = s.SettingService.SaveConfig(tx, data)
+ if err != nil {
+ return nil, err
+ }
+ configData := make(json.RawMessage, len(data))
+ copy(configData, data)
+ go func() { _ = s.restartCoreWithConfig(configData) }()
+ case "settings":
+ err = s.SettingService.Save(tx, data)
+ default:
+ return nil, common.NewError("unknown object: ", obj)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ dt := time.Now().Unix()
+ err = tx.Create(&model.Changes{
+ DateTime: dt,
+ Actor: loginUser,
+ Key: obj,
+ Action: act,
+ Obj: data,
+ }).Error
+ if err != nil {
+ return nil, err
+ }
+
+ LastUpdate = time.Now().Unix()
+
+ return objs, nil
+}
+
+func (s *ConfigService) CheckChanges(lu string) (bool, error) {
+ if lu == "" {
+ return true, nil
+ }
+ if LastUpdate == 0 {
+ db := database.GetDB()
+ var count int64
+ err := db.Model(model.Changes{}).Where("date_time > " + lu).Count(&count).Error
+ if err == nil {
+ LastUpdate = time.Now().Unix()
+ }
+ return count > 0, err
+ } else {
+ intLu, err := strconv.ParseInt(lu, 10, 64)
+ return LastUpdate > intLu, err
+ }
+}
+
+func (s *ConfigService) GetChanges(actor string, chngKey string, count string) []model.Changes {
+ c, _ := strconv.Atoi(count)
+ whereString := "`id`>0"
+ if len(actor) > 0 {
+ whereString += " and `actor`='" + actor + "'"
+ }
+ if len(chngKey) > 0 {
+ whereString += " and `key`='" + chngKey + "'"
+ }
+ db := database.GetDB()
+ var chngs []model.Changes
+ err := db.Model(model.Changes{}).Where(whereString).Order("`id` desc").Limit(c).Scan(&chngs).Error
+ if err != nil {
+ logger.Warning(err)
+ }
+ return chngs
+}
diff --git a/service/endpoints.go b/service/endpoints.go
new file mode 100644
index 0000000..146f2bc
--- /dev/null
+++ b/service/endpoints.go
@@ -0,0 +1,140 @@
+package service
+
+import (
+ "encoding/json"
+ "os"
+
+ "github.com/alireza0/s-ui/database"
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/util/common"
+
+ "gorm.io/gorm"
+)
+
+type EndpointService struct {
+ WarpService
+}
+
+func (o *EndpointService) GetAll() (*[]map[string]interface{}, error) {
+ db := database.GetDB()
+ endpoints := []*model.Endpoint{}
+ err := db.Model(model.Endpoint{}).Scan(&endpoints).Error
+ if err != nil {
+ return nil, err
+ }
+ var data []map[string]interface{}
+ for _, endpoint := range endpoints {
+ epData := map[string]interface{}{
+ "id": endpoint.Id,
+ "type": endpoint.Type,
+ "tag": endpoint.Tag,
+ "ext": endpoint.Ext,
+ }
+ if endpoint.Options != nil {
+ var restFields map[string]json.RawMessage
+ if err := json.Unmarshal(endpoint.Options, &restFields); err != nil {
+ return nil, err
+ }
+ for k, v := range restFields {
+ epData[k] = v
+ }
+ }
+ data = append(data, epData)
+ }
+ return &data, nil
+}
+
+func (o *EndpointService) GetAllConfig(db *gorm.DB) ([]json.RawMessage, error) {
+ var endpointsJson []json.RawMessage
+ var endpoints []*model.Endpoint
+ err := db.Model(model.Endpoint{}).Scan(&endpoints).Error
+ if err != nil {
+ return nil, err
+ }
+ for _, endpoint := range endpoints {
+ endpointJson, err := endpoint.MarshalJSON()
+ if err != nil {
+ return nil, err
+ }
+ endpointsJson = append(endpointsJson, endpointJson)
+ }
+ return endpointsJson, nil
+}
+
+func (s *EndpointService) Save(tx *gorm.DB, act string, data json.RawMessage) error {
+ var err error
+
+ switch act {
+ case "new", "edit":
+ var endpoint model.Endpoint
+ err = endpoint.UnmarshalJSON(data)
+ if err != nil {
+ return err
+ }
+
+ if endpoint.Type == "warp" {
+ if act == "new" {
+ err = s.WarpService.RegisterWarp(&endpoint)
+ if err != nil {
+ return err
+ }
+ } else {
+ var old_license string
+ err = tx.Model(model.Endpoint{}).Select("json_extract(ext, '$.license_key')").Where("id = ?", endpoint.Id).Find(&old_license).Error
+ if err != nil {
+ return err
+ }
+ err = s.WarpService.SetWarpLicense(old_license, &endpoint)
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ if corePtr.IsRunning() {
+ configData, err := endpoint.MarshalJSON()
+ if err != nil {
+ return err
+ }
+ if act == "edit" {
+ var oldTag string
+ err = tx.Model(model.Endpoint{}).Select("tag").Where("id = ?", endpoint.Id).Find(&oldTag).Error
+ if err != nil {
+ return err
+ }
+ err = corePtr.RemoveEndpoint(oldTag)
+ if err != nil && err != os.ErrInvalid {
+ return err
+ }
+ }
+ err = corePtr.AddEndpoint(configData)
+ if err != nil {
+ return err
+ }
+ }
+
+ err = tx.Save(&endpoint).Error
+ if err != nil {
+ return err
+ }
+ case "del":
+ var tag string
+ err = json.Unmarshal(data, &tag)
+ if err != nil {
+ return err
+ }
+ if corePtr.IsRunning() {
+ err = corePtr.RemoveEndpoint(tag)
+ if err != nil && err != os.ErrInvalid {
+ return err
+ }
+ }
+ err = tx.Where("tag = ?", tag).Delete(model.Endpoint{}).Error
+ if err != nil {
+ return err
+ }
+ default:
+ return common.NewErrorf("unknown action: %s", act)
+ }
+ return nil
+}
diff --git a/service/inbounds.go b/service/inbounds.go
new file mode 100644
index 0000000..d9686f2
--- /dev/null
+++ b/service/inbounds.go
@@ -0,0 +1,362 @@
+package service
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/alireza0/s-ui/database"
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/util"
+ "github.com/alireza0/s-ui/util/common"
+
+ "gorm.io/gorm"
+)
+
+type InboundService struct {
+ ClientService
+}
+
+func (s *InboundService) Get(ids string) (*[]map[string]interface{}, error) {
+ if ids == "" {
+ return s.GetAll()
+ }
+ return s.getById(ids)
+}
+
+func (s *InboundService) getById(ids string) (*[]map[string]interface{}, error) {
+ var inbound []model.Inbound
+ var result []map[string]interface{}
+ db := database.GetDB()
+ err := db.Model(model.Inbound{}).Where("id in ?", strings.Split(ids, ",")).Scan(&inbound).Error
+ if err != nil {
+ return nil, err
+ }
+ for _, inb := range inbound {
+ inbData, err := inb.MarshalFull()
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, *inbData)
+ }
+ return &result, nil
+}
+
+func (s *InboundService) GetAll() (*[]map[string]interface{}, error) {
+ db := database.GetDB()
+ inbounds := []model.Inbound{}
+ err := db.Model(model.Inbound{}).Scan(&inbounds).Error
+ if err != nil {
+ return nil, err
+ }
+ var data []map[string]interface{}
+ for _, inbound := range inbounds {
+ var shadowtls_version uint
+ ss_managed := false
+ inbData := map[string]interface{}{
+ "id": inbound.Id,
+ "type": inbound.Type,
+ "tag": inbound.Tag,
+ "tls_id": inbound.TlsId,
+ }
+ if inbound.Options != nil {
+ var restFields map[string]json.RawMessage
+ if err := json.Unmarshal(inbound.Options, &restFields); err != nil {
+ return nil, err
+ }
+ inbData["listen"] = restFields["listen"]
+ inbData["listen_port"] = restFields["listen_port"]
+ if inbound.Type == "shadowtls" {
+ json.Unmarshal(restFields["version"], &shadowtls_version)
+ }
+ if inbound.Type == "shadowsocks" {
+ json.Unmarshal(restFields["managed"], &ss_managed)
+ }
+ }
+ if s.hasUser(inbound.Type) &&
+ !(inbound.Type == "shadowtls" && shadowtls_version < 3) &&
+ !(inbound.Type == "shadowsocks" && ss_managed) {
+ users := []string{}
+ err = db.Raw("SELECT clients.name FROM clients, json_each(clients.inbounds) as je WHERE je.value = ?", inbound.Id).Scan(&users).Error
+ if err != nil {
+ return nil, err
+ }
+ inbData["users"] = users
+ }
+
+ data = append(data, inbData)
+ }
+ return &data, nil
+}
+
+func (s *InboundService) FromIds(ids []uint) ([]*model.Inbound, error) {
+ db := database.GetDB()
+ inbounds := []*model.Inbound{}
+ err := db.Model(model.Inbound{}).Where("id in ?", ids).Scan(&inbounds).Error
+ if err != nil {
+ return nil, err
+ }
+ return inbounds, nil
+}
+
+func (s *InboundService) Save(tx *gorm.DB, act string, data json.RawMessage, initUserIds string, hostname string) error {
+ var err error
+
+ switch act {
+ case "new", "edit":
+ var inbound model.Inbound
+ err = inbound.UnmarshalJSON(data)
+ if err != nil {
+ return err
+ }
+ if inbound.TlsId > 0 {
+ err = tx.Model(model.Tls{}).Where("id = ?", inbound.TlsId).Find(&inbound.Tls).Error
+ if err != nil {
+ return err
+ }
+ }
+ var oldTag string
+ if act == "edit" {
+ err = tx.Model(model.Inbound{}).Select("tag").Where("id = ?", inbound.Id).Find(&oldTag).Error
+ if err != nil {
+ return err
+ }
+ }
+
+ if corePtr.IsRunning() {
+ if act == "edit" {
+ err = corePtr.RemoveInbound(oldTag)
+ if err != nil && err != os.ErrInvalid {
+ return err
+ }
+ }
+
+ inboundConfig, err := inbound.MarshalJSON()
+ if err != nil {
+ return err
+ }
+
+ if act == "edit" {
+ inboundConfig, err = s.addUsers(tx, inboundConfig, inbound.Id, inbound.Type)
+ } else {
+ inboundConfig, err = s.initUsers(tx, inboundConfig, initUserIds, inbound.Type)
+ }
+ if err != nil {
+ return err
+ }
+
+ err = corePtr.AddInbound(inboundConfig)
+ if err != nil {
+ return err
+ }
+ }
+
+ err = util.FillOutJson(&inbound, hostname)
+ if err != nil {
+ return err
+ }
+
+ err = tx.Save(&inbound).Error
+ if err != nil {
+ return err
+ }
+ switch act {
+ case "new":
+ err = s.ClientService.UpdateClientsOnInboundAdd(tx, initUserIds, inbound.Id, hostname)
+ case "edit":
+ err = s.ClientService.UpdateLinksByInboundChange(tx, &[]model.Inbound{inbound}, hostname, oldTag)
+ }
+ if err != nil {
+ return err
+ }
+ case "del":
+ var tag string
+ err = json.Unmarshal(data, &tag)
+ if err != nil {
+ return err
+ }
+ if corePtr.IsRunning() {
+ err = corePtr.RemoveInbound(tag)
+ if err != nil && err != os.ErrInvalid {
+ return err
+ }
+ }
+ var id uint
+ err = tx.Model(model.Inbound{}).Select("id").Where("tag = ?", tag).Scan(&id).Error
+ if err != nil {
+ return err
+ }
+ err = s.ClientService.UpdateClientsOnInboundDelete(tx, id, tag)
+ if err != nil {
+ return err
+ }
+ err = tx.Where("tag = ?", tag).Delete(model.Inbound{}).Error
+ if err != nil {
+ return err
+ }
+ default:
+ return common.NewErrorf("unknown action: %s", act)
+ }
+ return nil
+}
+
+func (s *InboundService) UpdateOutJsons(tx *gorm.DB, inboundIds []uint, hostname string) error {
+ var inbounds []model.Inbound
+ err := tx.Model(model.Inbound{}).Preload("Tls").Where("id in ?", inboundIds).Find(&inbounds).Error
+ if err != nil {
+ return err
+ }
+ for _, inbound := range inbounds {
+ err = util.FillOutJson(&inbound, hostname)
+ if err != nil {
+ return err
+ }
+ err = tx.Model(model.Inbound{}).Where("tag = ?", inbound.Tag).Update("out_json", inbound.OutJson).Error
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (s *InboundService) GetAllConfig(db *gorm.DB) ([]json.RawMessage, error) {
+ var inboundsJson []json.RawMessage
+ var inbounds []*model.Inbound
+ err := db.Model(model.Inbound{}).Preload("Tls").Find(&inbounds).Error
+ if err != nil {
+ return nil, err
+ }
+ for _, inbound := range inbounds {
+ inboundJson, err := inbound.MarshalJSON()
+ if err != nil {
+ return nil, err
+ }
+ inboundJson, err = s.addUsers(db, inboundJson, inbound.Id, inbound.Type)
+ if err != nil {
+ return nil, err
+ }
+ inboundsJson = append(inboundsJson, inboundJson)
+ }
+ return inboundsJson, nil
+}
+
+func (s *InboundService) hasUser(inboundType string) bool {
+ switch inboundType {
+ case "mixed", "socks", "http", "shadowsocks", "vmess", "trojan", "naive", "hysteria", "shadowtls", "tuic", "hysteria2", "vless", "anytls":
+ return true
+ }
+ return false
+}
+
+func (s *InboundService) fetchUsers(db *gorm.DB, inboundType string, condition string, inbound map[string]interface{}) ([]json.RawMessage, error) {
+ if inboundType == "shadowtls" {
+ version, _ := inbound["version"].(float64)
+ if int(version) < 3 {
+ return nil, nil
+ }
+ }
+ if inboundType == "shadowsocks" {
+ method, _ := inbound["method"].(string)
+ if method == "2022-blake3-aes-128-gcm" {
+ inboundType = "shadowsocks16"
+ }
+ }
+
+ var users []string
+
+ err := db.Raw(
+ fmt.Sprintf(`SELECT json_extract(clients.config, "$.%s")
+ FROM clients WHERE enable = true AND %s`,
+ inboundType, condition)).Scan(&users).Error
+ if err != nil {
+ return nil, err
+ }
+ var usersJson []json.RawMessage
+ for _, user := range users {
+ if inboundType == "vless" && inbound["tls"] == nil {
+ user = strings.Replace(user, "xtls-rprx-vision", "", -1)
+ }
+ usersJson = append(usersJson, json.RawMessage(user))
+ }
+ return usersJson, nil
+}
+
+func (s *InboundService) addUsers(db *gorm.DB, inboundJson []byte, inboundId uint, inboundType string) ([]byte, error) {
+ if !s.hasUser(inboundType) {
+ return inboundJson, nil
+ }
+
+ var inbound map[string]interface{}
+ err := json.Unmarshal(inboundJson, &inbound)
+ if err != nil {
+ return nil, err
+ }
+
+ condition := fmt.Sprintf("%d IN (SELECT json_each.value FROM json_each(clients.inbounds))", inboundId)
+ inbound["users"], err = s.fetchUsers(db, inboundType, condition, inbound)
+ if err != nil {
+ return nil, err
+ }
+
+ return json.Marshal(inbound)
+}
+
+func (s *InboundService) initUsers(db *gorm.DB, inboundJson []byte, clientIds string, inboundType string) ([]byte, error) {
+ ClientIds := strings.Split(clientIds, ",")
+ if len(ClientIds) == 0 {
+ return inboundJson, nil
+ }
+
+ if !s.hasUser(inboundType) {
+ return inboundJson, nil
+ }
+
+ var inbound map[string]interface{}
+ err := json.Unmarshal(inboundJson, &inbound)
+ if err != nil {
+ return nil, err
+ }
+
+ condition := fmt.Sprintf("id IN (%s)", strings.Join(ClientIds, ","))
+ inbound["users"], err = s.fetchUsers(db, inboundType, condition, inbound)
+ if err != nil {
+ return nil, err
+ }
+
+ return json.Marshal(inbound)
+}
+
+func (s *InboundService) RestartInbounds(tx *gorm.DB, ids []uint) error {
+ if !corePtr.IsRunning() {
+ return nil
+ }
+ var inbounds []*model.Inbound
+ err := tx.Model(model.Inbound{}).Preload("Tls").Where("id in ?", ids).Find(&inbounds).Error
+ if err != nil {
+ return err
+ }
+ for _, inbound := range inbounds {
+ err = corePtr.RemoveInbound(inbound.Tag)
+ if err != nil && err != os.ErrInvalid {
+ return err
+ }
+ // Close all existing connections
+ corePtr.GetInstance().ConnTracker().CloseConnByInbound(inbound.Tag)
+
+ inboundConfig, err := inbound.MarshalJSON()
+ if err != nil {
+ return err
+ }
+ inboundConfig, err = s.addUsers(tx, inboundConfig, inbound.Id, inbound.Type)
+ if err != nil {
+ return err
+ }
+ err = corePtr.AddInbound(inboundConfig)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/service/outbounds.go b/service/outbounds.go
new file mode 100644
index 0000000..5f463fe
--- /dev/null
+++ b/service/outbounds.go
@@ -0,0 +1,118 @@
+package service
+
+import (
+ "encoding/json"
+ "os"
+
+ "github.com/alireza0/s-ui/database"
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/util/common"
+
+ "gorm.io/gorm"
+)
+
+type OutboundService struct{}
+
+func (o *OutboundService) GetAll() (*[]map[string]interface{}, error) {
+ db := database.GetDB()
+ outbounds := []*model.Outbound{}
+ err := db.Model(model.Outbound{}).Scan(&outbounds).Error
+ if err != nil {
+ return nil, err
+ }
+ var data []map[string]interface{}
+ for _, outbound := range outbounds {
+ outData := map[string]interface{}{
+ "id": outbound.Id,
+ "type": outbound.Type,
+ "tag": outbound.Tag,
+ }
+ if outbound.Options != nil {
+ var restFields map[string]json.RawMessage
+ if err := json.Unmarshal(outbound.Options, &restFields); err != nil {
+ return nil, err
+ }
+ for k, v := range restFields {
+ outData[k] = v
+ }
+ }
+ data = append(data, outData)
+ }
+ return &data, nil
+}
+
+func (o *OutboundService) GetAllConfig(db *gorm.DB) ([]json.RawMessage, error) {
+ var outboundsJson []json.RawMessage
+ var outbounds []*model.Outbound
+ err := db.Model(model.Outbound{}).Scan(&outbounds).Error
+ if err != nil {
+ return nil, err
+ }
+ for _, outbound := range outbounds {
+ outboundJson, err := outbound.MarshalJSON()
+ if err != nil {
+ return nil, err
+ }
+ outboundsJson = append(outboundsJson, outboundJson)
+ }
+ return outboundsJson, nil
+}
+
+func (s *OutboundService) Save(tx *gorm.DB, act string, data json.RawMessage) error {
+ var err error
+
+ switch act {
+ case "new", "edit":
+ var outbound model.Outbound
+ err = outbound.UnmarshalJSON(data)
+ if err != nil {
+ return err
+ }
+
+ if corePtr.IsRunning() {
+ configData, err := outbound.MarshalJSON()
+ if err != nil {
+ return err
+ }
+ if act == "edit" {
+ var oldTag string
+ err = tx.Model(model.Outbound{}).Select("tag").Where("id = ?", outbound.Id).Find(&oldTag).Error
+ if err != nil {
+ return err
+ }
+ err = corePtr.RemoveOutbound(oldTag)
+ if err != nil && err != os.ErrInvalid {
+ return err
+ }
+ }
+ err = corePtr.AddOutbound(configData)
+ if err != nil {
+ return err
+ }
+ }
+
+ err = tx.Save(&outbound).Error
+ if err != nil {
+ return err
+ }
+ case "del":
+ var tag string
+ err = json.Unmarshal(data, &tag)
+ if err != nil {
+ return err
+ }
+ if corePtr.IsRunning() {
+ err = corePtr.RemoveOutbound(tag)
+ if err != nil && err != os.ErrInvalid {
+ return err
+ }
+ }
+ err = tx.Where("tag = ?", tag).Delete(model.Outbound{}).Error
+ if err != nil {
+ return err
+ }
+ default:
+ return common.NewErrorf("unknown action: %s", act)
+ }
+ return nil
+}
diff --git a/service/panel.go b/service/panel.go
new file mode 100644
index 0000000..2dd5aa5
--- /dev/null
+++ b/service/panel.go
@@ -0,0 +1,32 @@
+package service
+
+import (
+ "os"
+ "runtime"
+ "syscall"
+ "time"
+
+ "github.com/alireza0/s-ui/logger"
+)
+
+type PanelService struct {
+}
+
+func (s *PanelService) RestartPanel(delay time.Duration) error {
+ p, err := os.FindProcess(syscall.Getpid())
+ if err != nil {
+ return err
+ }
+ go func() {
+ time.Sleep(delay)
+ if runtime.GOOS == "windows" {
+ err = p.Kill()
+ } else {
+ err = p.Signal(syscall.SIGHUP)
+ }
+ if err != nil {
+ logger.Error("send signal SIGHUP failed:", err)
+ }
+ }()
+ return nil
+}
diff --git a/service/server.go b/service/server.go
new file mode 100644
index 0000000..2ce1f0f
--- /dev/null
+++ b/service/server.go
@@ -0,0 +1,283 @@
+package service
+
+import (
+ "encoding/base64"
+ "os"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/alireza0/s-ui/config"
+ "github.com/alireza0/s-ui/database"
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/logger"
+
+ "github.com/sagernet/sing-box/common/tls"
+ "github.com/shirou/gopsutil/v4/cpu"
+ "github.com/shirou/gopsutil/v4/disk"
+ "github.com/shirou/gopsutil/v4/host"
+ "github.com/shirou/gopsutil/v4/mem"
+ "github.com/shirou/gopsutil/v4/net"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+type ServerService struct{}
+
+func (s *ServerService) GetStatus(request string) *map[string]interface{} {
+ status := make(map[string]interface{}, 0)
+ requests := strings.Split(request, ",")
+ for _, req := range requests {
+ switch req {
+ case "cpu":
+ status["cpu"] = s.GetCpuPercent()
+ case "mem":
+ status["mem"] = s.GetMemInfo()
+ case "dsk":
+ status["dsk"] = s.GetDiskInfo()
+ case "dio":
+ status["dio"] = s.GetDiskIO()
+ case "swp":
+ status["swp"] = s.GetSwapInfo()
+ case "net":
+ status["net"] = s.GetNetInfo()
+ case "sys":
+ status["sys"] = s.GetSystemInfo()
+ case "sbd":
+ status["sbd"] = s.GetSingboxInfo()
+ case "db":
+ status["db"] = s.GetDatabaseInfo()
+ }
+ }
+ return &status
+}
+
+func (s *ServerService) GetCpuPercent() float64 {
+ percents, err := cpu.Percent(0, false)
+ if err != nil {
+ logger.Warning("get cpu percent failed:", err)
+ return 0
+ } else {
+ return percents[0]
+ }
+}
+
+func (s *ServerService) GetMemInfo() map[string]interface{} {
+ info := make(map[string]interface{}, 0)
+ memInfo, err := mem.VirtualMemory()
+ if err != nil {
+ logger.Warning("get virtual memory failed:", err)
+ } else {
+ info["current"] = memInfo.Used
+ info["total"] = memInfo.Total
+ }
+ return info
+}
+
+func (s *ServerService) GetDiskInfo() map[string]interface{} {
+ info := make(map[string]interface{}, 0)
+ diskInfo, err := disk.Usage("/")
+ if err != nil {
+ logger.Warning("get disk usage failed:", err)
+ } else {
+ info["current"] = diskInfo.Used
+ info["total"] = diskInfo.Total
+ }
+ return info
+}
+
+func (s *ServerService) GetDiskIO() map[string]interface{} {
+ info := make(map[string]interface{}, 0)
+ ioStats, err := disk.IOCounters()
+ if err != nil {
+ logger.Warning("get disk io counters failed:", err)
+ } else if len(ioStats) > 0 {
+ infoR, infoW := uint64(0), uint64(0)
+ for _, ioStat := range ioStats {
+ infoR += ioStat.ReadBytes
+ infoW += ioStat.WriteBytes
+ }
+ info["read"] = infoR
+ info["write"] = infoW
+ } else {
+ logger.Warning("can not find disk io counters")
+ }
+ return info
+}
+
+func (s *ServerService) GetSwapInfo() map[string]interface{} {
+ info := make(map[string]interface{}, 0)
+ swapInfo, err := mem.SwapMemory()
+ if err != nil {
+ logger.Warning("get swap memory failed:", err)
+ } else {
+ info["current"] = swapInfo.Used
+ info["total"] = swapInfo.Total
+ }
+ return info
+}
+
+func (s *ServerService) GetNetInfo() map[string]interface{} {
+ info := make(map[string]interface{}, 0)
+ ioStats, err := net.IOCounters(false)
+ if err != nil {
+ logger.Warning("get io counters failed:", err)
+ } else if len(ioStats) > 0 {
+ ioStat := ioStats[0]
+ info["sent"] = ioStat.BytesSent
+ info["recv"] = ioStat.BytesRecv
+ info["psent"] = ioStat.PacketsSent
+ info["precv"] = ioStat.PacketsRecv
+ } else {
+ logger.Warning("can not find io counters")
+ }
+ return info
+}
+
+func (s *ServerService) GetSingboxInfo() map[string]interface{} {
+ var rtm runtime.MemStats
+ runtime.ReadMemStats(&rtm)
+ isRunning := corePtr.IsRunning()
+ uptime := uint32(0)
+ if isRunning {
+ uptime = corePtr.GetInstance().Uptime()
+ }
+ return map[string]interface{}{
+ "running": isRunning,
+ "stats": map[string]interface{}{
+ "NumGoroutine": uint32(runtime.NumGoroutine()),
+ "Alloc": rtm.Alloc,
+ "Uptime": uptime,
+ },
+ }
+}
+
+func (s *ServerService) GetSystemInfo() map[string]interface{} {
+ info := make(map[string]interface{}, 0)
+ var rtm runtime.MemStats
+ runtime.ReadMemStats(&rtm)
+
+ info["appMem"] = rtm.Sys
+ info["appThreads"] = uint32(runtime.NumGoroutine())
+ cpuInfo, err := cpu.Info()
+ if err == nil {
+ info["cpuType"] = cpuInfo[0].ModelName
+ }
+ info["cpuCount"] = runtime.NumCPU()
+ info["hostName"], _ = os.Hostname()
+ info["appVersion"] = config.GetVersion()
+ ipv4 := make([]string, 0)
+ ipv6 := make([]string, 0)
+ // get ip address
+ netInterfaces, _ := net.Interfaces()
+ for i := 0; i < len(netInterfaces); i++ {
+ if len(netInterfaces[i].Flags) > 2 && netInterfaces[i].Flags[0] == "up" && netInterfaces[i].Flags[1] != "loopback" {
+ addrs := netInterfaces[i].Addrs
+
+ for _, address := range addrs {
+ if strings.Contains(address.Addr, ".") {
+ ipv4 = append(ipv4, address.Addr)
+ } else if address.Addr[0:6] != "fe80::" {
+ ipv6 = append(ipv6, address.Addr)
+ }
+ }
+ }
+ }
+ info["ipv4"] = ipv4
+ info["ipv6"] = ipv6
+ info["bootTime"], _ = host.BootTime()
+
+ return info
+}
+
+func (s *ServerService) GetLogs(count string, level string) []string {
+ c, err := strconv.Atoi(count)
+ if err != nil {
+ c = 10
+ }
+ return logger.GetLogs(c, level)
+}
+
+func (s *ServerService) GenKeypair(keyType string, options string) []string {
+ if len(keyType) == 0 {
+ return []string{"No keypair to generate"}
+ }
+
+ switch keyType {
+ case "ech":
+ return s.generateECHKeyPair(options)
+ case "tls":
+ return s.generateTLSKeyPair(options)
+ case "reality":
+ return s.generateRealityKeyPair()
+ case "wireguard":
+ return s.generateWireGuardKey(options)
+ }
+
+ return []string{"Failed to generate keypair"}
+}
+
+func (s *ServerService) generateECHKeyPair(serverName string) []string {
+ configPem, keyPem, err := tls.ECHKeygenDefault(serverName)
+ if err != nil {
+ return []string{"Failed to generate ECH keypair: ", err.Error()}
+ }
+ return append(strings.Split(configPem, "\n"), strings.Split(keyPem, "\n")...)
+}
+
+func (s *ServerService) generateTLSKeyPair(serverName string) []string {
+ privateKeyPem, publicKeyPem, err := tls.GenerateCertificate(nil, nil, time.Now, serverName, time.Now().AddDate(0, 12, 0))
+ if err != nil {
+ return []string{"Failed to generate TLS keypair: ", err.Error()}
+ }
+ return append(strings.Split(string(privateKeyPem), "\n"), strings.Split(string(publicKeyPem), "\n")...)
+}
+
+func (s *ServerService) generateRealityKeyPair() []string {
+ privateKey, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ return []string{"Failed to generate Reality keypair: ", err.Error()}
+ }
+ publicKey := privateKey.PublicKey()
+ return []string{"PrivateKey: " + base64.RawURLEncoding.EncodeToString(privateKey[:]), "PublicKey: " + base64.RawURLEncoding.EncodeToString(publicKey[:])}
+}
+
+func (s *ServerService) generateWireGuardKey(pk string) []string {
+ if len(pk) > 0 {
+ key, _ := wgtypes.ParseKey(pk)
+ return []string{key.PublicKey().String()}
+ }
+ wgKeys, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ return []string{"Failed to generate wireguard keypair: ", err.Error()}
+ }
+ return []string{"PrivateKey: " + wgKeys.String(), "PublicKey: " + wgKeys.PublicKey().String()}
+}
+
+func (s *ServerService) GetDatabaseInfo() map[string]int64 {
+ info := make(map[string]int64, 0)
+ db := database.GetDB()
+ if db == nil {
+ return nil
+ }
+
+ var clientsCount, inboundsCount, outboundsCount, servicesCount, endpointsCount, clientUp, clientDown int64
+
+ db.Model(&model.Client{}).Count(&clientsCount)
+ db.Model(&model.Inbound{}).Count(&inboundsCount)
+ db.Model(&model.Outbound{}).Count(&outboundsCount)
+ db.Model(&model.Service{}).Count(&servicesCount)
+ db.Model(&model.Endpoint{}).Count(&endpointsCount)
+ db.Model(&model.Client{}).Select("COALESCE(SUM(up+total_up),0)").Scan(&clientUp)
+ db.Model(&model.Client{}).Select("COALESCE(SUM(down+total_down),0)").Scan(&clientDown)
+
+ info["clients"] = clientsCount
+ info["inbounds"] = inboundsCount
+ info["outbounds"] = outboundsCount
+ info["services"] = servicesCount
+ info["endpoints"] = endpointsCount
+ info["clientUp"] = clientUp
+ info["clientDown"] = clientDown
+
+ return info
+}
diff --git a/service/services.go b/service/services.go
new file mode 100644
index 0000000..102417d
--- /dev/null
+++ b/service/services.go
@@ -0,0 +1,153 @@
+package service
+
+import (
+ "encoding/json"
+ "os"
+
+ "github.com/alireza0/s-ui/database"
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/util/common"
+
+ "gorm.io/gorm"
+)
+
+type ServicesService struct{}
+
+func (s *ServicesService) GetAll() (*[]map[string]interface{}, error) {
+ db := database.GetDB()
+ services := []model.Service{}
+ err := db.Model(model.Service{}).Scan(&services).Error
+ if err != nil {
+ return nil, err
+ }
+ var data []map[string]interface{}
+ for _, srv := range services {
+ srvData := map[string]interface{}{
+ "id": srv.Id,
+ "type": srv.Type,
+ "tag": srv.Tag,
+ "tls_id": srv.TlsId,
+ }
+ if srv.Options != nil {
+ var restFields map[string]json.RawMessage
+ if err := json.Unmarshal(srv.Options, &restFields); err != nil {
+ return nil, err
+ }
+ for k, v := range restFields {
+ srvData[k] = v
+ }
+ }
+
+ data = append(data, srvData)
+ }
+ return &data, nil
+}
+
+func (s *ServicesService) GetAllConfig(db *gorm.DB) ([]json.RawMessage, error) {
+ var servicesJson []json.RawMessage
+ var services []*model.Service
+ err := db.Model(model.Service{}).Preload("Tls").Find(&services).Error
+ if err != nil {
+ return nil, err
+ }
+ for _, srv := range services {
+ srvJson, err := srv.MarshalJSON()
+ if err != nil {
+ return nil, err
+ }
+ servicesJson = append(servicesJson, srvJson)
+ }
+ return servicesJson, nil
+}
+
+func (s *ServicesService) Save(tx *gorm.DB, act string, data json.RawMessage) error {
+ var err error
+
+ switch act {
+ case "new", "edit":
+ var srv model.Service
+ err = srv.UnmarshalJSON(data)
+ if err != nil {
+ return err
+ }
+
+ if srv.TlsId > 0 {
+ err = tx.Model(model.Tls{}).Where("id = ?", srv.TlsId).Find(&srv.Tls).Error
+ if err != nil {
+ return err
+ }
+ }
+
+ if corePtr.IsRunning() {
+ configData, err := srv.MarshalJSON()
+ if err != nil {
+ return err
+ }
+ if act == "edit" {
+ var oldTag string
+ err = tx.Model(model.Service{}).Select("tag").Where("id = ?", srv.Id).Find(&oldTag).Error
+ if err != nil {
+ return err
+ }
+ err = corePtr.RemoveService(oldTag)
+ if err != nil && err != os.ErrInvalid {
+ return err
+ }
+ }
+ err = corePtr.AddService(configData)
+ if err != nil {
+ return err
+ }
+ }
+
+ err = tx.Save(&srv).Error
+ if err != nil {
+ return err
+ }
+ case "del":
+ var tag string
+ err = json.Unmarshal(data, &tag)
+ if err != nil {
+ return err
+ }
+ if corePtr.IsRunning() {
+ err = corePtr.RemoveService(tag)
+ if err != nil && err != os.ErrInvalid {
+ return err
+ }
+ }
+ err = tx.Where("tag = ?", tag).Delete(model.Service{}).Error
+ if err != nil {
+ return err
+ }
+ default:
+ return common.NewErrorf("unknown action: %s", act)
+ }
+ return nil
+}
+
+func (s *ServicesService) RestartServices(tx *gorm.DB, ids []uint) error {
+ if !corePtr.IsRunning() {
+ return nil
+ }
+ var services []*model.Service
+ err := tx.Model(model.Service{}).Preload("Tls").Where("id in ?", ids).Find(&services).Error
+ if err != nil {
+ return err
+ }
+ for _, srv := range services {
+ err = corePtr.RemoveService(srv.Tag)
+ if err != nil && err != os.ErrInvalid {
+ return err
+ }
+ srvConfig, err := srv.MarshalJSON()
+ if err != nil {
+ return err
+ }
+ err = corePtr.AddService(srvConfig)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/service/setting.go b/service/setting.go
new file mode 100644
index 0000000..b2687fe
--- /dev/null
+++ b/service/setting.go
@@ -0,0 +1,421 @@
+package service
+
+import (
+ "encoding/json"
+ "os"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/alireza0/s-ui/config"
+ "github.com/alireza0/s-ui/database"
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/logger"
+ "github.com/alireza0/s-ui/util/common"
+
+ "gorm.io/gorm"
+)
+
+var defaultConfig = `{
+ "log": {
+ "level": "info"
+ },
+ "dns": {
+ "servers": [],
+ "rules": []
+ },
+ "route": {
+ "rules": [
+ {
+ "action": "sniff"
+ },
+ {
+ "protocol": [
+ "dns"
+ ],
+ "action": "hijack-dns"
+ }
+ ]
+ },
+ "experimental": {}
+}`
+
+var defaultValueMap = map[string]string{
+ "webListen": "",
+ "webDomain": "",
+ "webPort": "2095",
+ "secret": common.Random(32),
+ "webCertFile": "",
+ "webKeyFile": "",
+ "webPath": "/app/",
+ "webURI": "",
+ "sessionMaxAge": "0",
+ "trafficAge": "30",
+ "timeLocation": "Asia/Tehran",
+ "subListen": "",
+ "subPort": "2096",
+ "subPath": "/sub/",
+ "subDomain": "",
+ "subCertFile": "",
+ "subKeyFile": "",
+ "subUpdates": "12",
+ "subEncode": "true",
+ "subShowInfo": "false",
+ "subURI": "",
+ "subJsonExt": "",
+ "subClashExt": "",
+ "config": defaultConfig,
+ "version": config.GetVersion(),
+}
+
+type SettingService struct {
+}
+
+func (s *SettingService) GetAllSetting() (*map[string]string, error) {
+ db := database.GetDB()
+ settings := make([]*model.Setting, 0)
+ err := db.Model(model.Setting{}).Find(&settings).Error
+ if err != nil {
+ return nil, err
+ }
+ allSetting := map[string]string{}
+
+ for _, setting := range settings {
+ allSetting[setting.Key] = setting.Value
+ }
+
+ for key, defaultValue := range defaultValueMap {
+ if _, exists := allSetting[key]; !exists {
+ err = s.saveSetting(key, defaultValue)
+ if err != nil {
+ return nil, err
+ }
+ allSetting[key] = defaultValue
+ }
+ }
+
+ // Due to security principles
+ delete(allSetting, "secret")
+ delete(allSetting, "config")
+ delete(allSetting, "version")
+
+ return &allSetting, nil
+}
+
+func (s *SettingService) ResetSettings() error {
+ db := database.GetDB()
+ return db.Where("1 = 1").Delete(model.Setting{}).Error
+}
+
+func (s *SettingService) getSetting(key string) (*model.Setting, error) {
+ db := database.GetDB()
+ setting := &model.Setting{}
+ err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error
+ if err != nil {
+ return nil, err
+ }
+ return setting, nil
+}
+
+func (s *SettingService) getString(key string) (string, error) {
+ setting, err := s.getSetting(key)
+ if database.IsNotFound(err) {
+ value, ok := defaultValueMap[key]
+ if !ok {
+ return "", common.NewErrorf("key <%v> not in defaultValueMap", key)
+ }
+ return value, nil
+ } else if err != nil {
+ return "", err
+ }
+ return setting.Value, nil
+}
+
+func (s *SettingService) saveSetting(key string, value string) error {
+ setting, err := s.getSetting(key)
+ db := database.GetDB()
+ if database.IsNotFound(err) {
+ return db.Create(&model.Setting{
+ Key: key,
+ Value: value,
+ }).Error
+ } else if err != nil {
+ return err
+ }
+ setting.Key = key
+ setting.Value = value
+ return db.Save(setting).Error
+}
+
+func (s *SettingService) setString(key string, value string) error {
+ return s.saveSetting(key, value)
+}
+
+func (s *SettingService) getBool(key string) (bool, error) {
+ str, err := s.getString(key)
+ if err != nil {
+ return false, err
+ }
+ return strconv.ParseBool(str)
+}
+
+// func (s *SettingService) setBool(key string, value bool) error {
+// return s.setString(key, strconv.FormatBool(value))
+// }
+
+func (s *SettingService) getInt(key string) (int, error) {
+ str, err := s.getString(key)
+ if err != nil {
+ return 0, err
+ }
+ return strconv.Atoi(str)
+}
+
+func (s *SettingService) setInt(key string, value int) error {
+ return s.setString(key, strconv.Itoa(value))
+}
+func (s *SettingService) GetListen() (string, error) {
+ return s.getString("webListen")
+}
+
+func (s *SettingService) GetWebDomain() (string, error) {
+ return s.getString("webDomain")
+}
+
+func (s *SettingService) GetPort() (int, error) {
+ return s.getInt("webPort")
+}
+
+func (s *SettingService) SetPort(port int) error {
+ return s.setInt("webPort", port)
+}
+
+func (s *SettingService) GetCertFile() (string, error) {
+ return s.getString("webCertFile")
+}
+
+func (s *SettingService) GetKeyFile() (string, error) {
+ return s.getString("webKeyFile")
+}
+
+func (s *SettingService) GetWebPath() (string, error) {
+ webPath, err := s.getString("webPath")
+ if err != nil {
+ return "", err
+ }
+ if !strings.HasPrefix(webPath, "/") {
+ webPath = "/" + webPath
+ }
+ if !strings.HasSuffix(webPath, "/") {
+ webPath += "/"
+ }
+ return webPath, nil
+}
+
+func (s *SettingService) SetWebPath(webPath string) error {
+ if !strings.HasPrefix(webPath, "/") {
+ webPath = "/" + webPath
+ }
+ if !strings.HasSuffix(webPath, "/") {
+ webPath += "/"
+ }
+ return s.setString("webPath", webPath)
+}
+
+func (s *SettingService) GetSecret() ([]byte, error) {
+ secret, err := s.getString("secret")
+ if secret == defaultValueMap["secret"] {
+ err := s.saveSetting("secret", secret)
+ if err != nil {
+ logger.Warning("save secret failed:", err)
+ }
+ }
+ return []byte(secret), err
+}
+
+func (s *SettingService) GetSessionMaxAge() (int, error) {
+ return s.getInt("sessionMaxAge")
+}
+
+func (s *SettingService) GetTrafficAge() (int, error) {
+ return s.getInt("trafficAge")
+}
+
+func (s *SettingService) GetTimeLocation() (*time.Location, error) {
+ l, err := s.getString("timeLocation")
+ if err != nil {
+ return nil, err
+ }
+ if runtime.GOOS == "windows" {
+ l = "Local"
+ }
+ location, err := time.LoadLocation(l)
+ if err != nil {
+ defaultLocation := defaultValueMap["timeLocation"]
+ logger.Errorf("location <%v> not exist, using default location: %v", l, defaultLocation)
+ return time.LoadLocation(defaultLocation)
+ }
+ return location, nil
+}
+
+func (s *SettingService) GetSubListen() (string, error) {
+ return s.getString("subListen")
+}
+
+func (s *SettingService) GetSubPort() (int, error) {
+ return s.getInt("subPort")
+}
+
+func (s *SettingService) SetSubPort(subPort int) error {
+ return s.setInt("subPort", subPort)
+}
+
+func (s *SettingService) GetSubPath() (string, error) {
+ subPath, err := s.getString("subPath")
+ if err != nil {
+ return "", err
+ }
+ if !strings.HasPrefix(subPath, "/") {
+ subPath = "/" + subPath
+ }
+ if !strings.HasSuffix(subPath, "/") {
+ subPath += "/"
+ }
+ return subPath, nil
+}
+
+func (s *SettingService) SetSubPath(subPath string) error {
+ if !strings.HasPrefix(subPath, "/") {
+ subPath = "/" + subPath
+ }
+ if !strings.HasSuffix(subPath, "/") {
+ subPath += "/"
+ }
+ return s.setString("subPath", subPath)
+}
+
+func (s *SettingService) GetSubDomain() (string, error) {
+ return s.getString("subDomain")
+}
+
+func (s *SettingService) GetSubCertFile() (string, error) {
+ return s.getString("subCertFile")
+}
+
+func (s *SettingService) GetSubKeyFile() (string, error) {
+ return s.getString("subKeyFile")
+}
+
+func (s *SettingService) GetSubUpdates() (int, error) {
+ return s.getInt("subUpdates")
+}
+
+func (s *SettingService) GetSubEncode() (bool, error) {
+ return s.getBool("subEncode")
+}
+
+func (s *SettingService) GetSubShowInfo() (bool, error) {
+ return s.getBool("subShowInfo")
+}
+
+func (s *SettingService) GetSubURI() (string, error) {
+ return s.getString("subURI")
+}
+
+func (s *SettingService) GetFinalSubURI(host string) (string, error) {
+ allSetting, err := s.GetAllSetting()
+ if err != nil {
+ return "", err
+ }
+ SubURI := (*allSetting)["subURI"]
+ if SubURI != "" {
+ return SubURI, nil
+ }
+ protocol := "http"
+ if (*allSetting)["subKeyFile"] != "" && (*allSetting)["subCertFile"] != "" {
+ protocol = "https"
+ }
+ if (*allSetting)["subDomain"] != "" {
+ host = (*allSetting)["subDomain"]
+ }
+ port := ":" + (*allSetting)["subPort"]
+ if (port == "80" && protocol == "http") || (port == "443" && protocol == "https") {
+ port = ""
+ }
+ return protocol + "://" + host + port + (*allSetting)["subPath"], nil
+}
+
+func (s *SettingService) GetConfig() (string, error) {
+ return s.getString("config")
+}
+
+func (s *SettingService) SetConfig(config string) error {
+ return s.setString("config", config)
+}
+
+func (s *SettingService) SaveConfig(tx *gorm.DB, config json.RawMessage) error {
+ configs, err := json.MarshalIndent(config, "", " ")
+ if err != nil {
+ return err
+ }
+ return tx.Model(model.Setting{}).Where("key = ?", "config").Update("value", string(configs)).Error
+}
+
+func (s *SettingService) Save(tx *gorm.DB, data json.RawMessage) error {
+ var err error
+ var settings map[string]string
+ err = json.Unmarshal(data, &settings)
+ if err != nil {
+ return err
+ }
+ for key, obj := range settings {
+ // Secure file existence check
+ if obj != "" && (key == "webCertFile" ||
+ key == "webKeyFile" ||
+ key == "subCertFile" ||
+ key == "subKeyFile") {
+ err = s.fileExists(obj)
+ if err != nil {
+ return common.NewError(" -> ", obj, " is not exists")
+ }
+ }
+
+ // Correct Pathes start and ends with `/`
+ if key == "webPath" ||
+ key == "subPath" {
+ if !strings.HasPrefix(obj, "/") {
+ obj = "/" + obj
+ }
+ if !strings.HasSuffix(obj, "/") {
+ obj += "/"
+ }
+ }
+
+ // Delete all stats if it is set to 0
+ if key == "trafficAge" && obj == "0" {
+ err = tx.Where("id > 0").Delete(model.Stats{}).Error
+ if err != nil {
+ return err
+ }
+ }
+ err = tx.Model(model.Setting{}).Where("key = ?", key).Update("value", obj).Error
+ if err != nil {
+ return err
+ }
+ }
+ return err
+}
+
+func (s *SettingService) GetSubJsonExt() (string, error) {
+ return s.getString("subJsonExt")
+}
+
+func (s *SettingService) GetSubClashExt() (string, error) {
+ return s.getString("subClashExt")
+}
+
+func (s *SettingService) fileExists(path string) error {
+ _, err := os.Stat(path)
+ return err
+}
diff --git a/service/stats.go b/service/stats.go
new file mode 100644
index 0000000..d122342
--- /dev/null
+++ b/service/stats.go
@@ -0,0 +1,162 @@
+package service
+
+import (
+ "sort"
+ "time"
+
+ "github.com/alireza0/s-ui/database"
+ "github.com/alireza0/s-ui/database/model"
+
+ "gorm.io/gorm"
+)
+
+type onlines struct {
+ Inbound []string `json:"inbound,omitempty"`
+ User []string `json:"user,omitempty"`
+ Outbound []string `json:"outbound,omitempty"`
+}
+
+var onlineResources = &onlines{}
+
+type StatsService struct {
+}
+
+func (s *StatsService) SaveStats(enableTraffic bool) error {
+ if corePtr == nil || !corePtr.IsRunning() {
+ return nil
+ }
+ box := corePtr.GetInstance()
+ if box == nil {
+ return nil
+ }
+ st := box.StatsTracker()
+ if st == nil {
+ return nil
+ }
+ stats := st.GetStats()
+
+ // Reset onlines
+ onlineResources.Inbound = nil
+ onlineResources.Outbound = nil
+ onlineResources.User = nil
+
+ if len(*stats) == 0 {
+ return nil
+ }
+
+ var err error
+ db := database.GetDB()
+ tx := db.Begin()
+ defer func() {
+ if err == nil {
+ tx.Commit()
+ } else {
+ tx.Rollback()
+ }
+ }()
+
+ for _, stat := range *stats {
+ if stat.Resource == "user" {
+ if stat.Direction {
+ err = tx.Model(model.Client{}).Where("name = ?", stat.Tag).
+ UpdateColumn("up", gorm.Expr("up + ?", stat.Traffic)).Error
+ } else {
+ err = tx.Model(model.Client{}).Where("name = ?", stat.Tag).
+ UpdateColumn("down", gorm.Expr("down + ?", stat.Traffic)).Error
+ }
+ if err != nil {
+ return err
+ }
+ }
+ if stat.Direction {
+ switch stat.Resource {
+ case "inbound":
+ onlineResources.Inbound = append(onlineResources.Inbound, stat.Tag)
+ case "outbound":
+ onlineResources.Outbound = append(onlineResources.Outbound, stat.Tag)
+ case "user":
+ onlineResources.User = append(onlineResources.User, stat.Tag)
+ }
+ }
+ }
+
+ if !enableTraffic {
+ return nil
+ }
+ return tx.Create(&stats).Error
+}
+
+func (s *StatsService) GetStats(resource string, tag string, limit int) ([]model.Stats, error) {
+ var err error
+ var result []model.Stats
+
+ currentTime := time.Now().Unix()
+ timeDiff := currentTime - (int64(limit) * 3600)
+
+ db := database.GetDB()
+ resources := []string{resource}
+ if resource == "endpoint" {
+ resources = []string{"inbound", "outbound"}
+ }
+ err = db.Model(model.Stats{}).Where("resource in ? AND tag = ? AND date_time > ?", resources, tag, timeDiff).Scan(&result).Error
+ if err != nil {
+ return nil, err
+ }
+
+ result = s.downsampleStats(result, 60) // 60 rows for 30 buckets
+ return result, nil
+}
+
+// downsampleStats reduces stats to maxRows rows.
+// Each bucket outputs two rows (direction false and true) with average Traffic.
+func (s *StatsService) downsampleStats(stats []model.Stats, maxRows int) []model.Stats {
+ if len(stats) <= maxRows {
+ return stats
+ }
+ numBuckets := int(maxRows / 2)
+ sort.Slice(stats, func(i, j int) bool { return stats[i].DateTime < stats[j].DateTime })
+ timeMin, timeMax := stats[0].DateTime, stats[len(stats)-1].DateTime
+ bucketSpan := (timeMax - timeMin) / int64(numBuckets)
+ if bucketSpan == 0 {
+ bucketSpan = 1
+ }
+ downsampled := make([]model.Stats, 0, maxRows)
+ for i := 0; i < numBuckets; i++ {
+ bucketStart := timeMin + int64(i)*bucketSpan
+ bucketEnd := timeMin + int64(i+1)*bucketSpan
+ if i == numBuckets-1 {
+ bucketEnd = timeMax + 1
+ }
+ for _, dir := range []bool{false, true} {
+ var sum int64
+ var count int
+ for _, r := range stats {
+ if r.DateTime >= bucketStart && r.DateTime < bucketEnd && r.Direction == dir {
+ sum += r.Traffic
+ count++
+ }
+ }
+ avg := int64(0)
+ if count > 0 {
+ avg = sum / int64(count)
+ }
+ downsampled = append(downsampled, model.Stats{
+ DateTime: bucketStart,
+ Resource: stats[0].Resource,
+ Tag: stats[0].Tag,
+ Direction: dir,
+ Traffic: avg,
+ })
+ }
+ }
+ return downsampled
+}
+
+func (s *StatsService) GetOnlines() (onlines, error) {
+ return *onlineResources, nil
+}
+func (s *StatsService) DelOldStats(days int) error {
+ oldTime := time.Now().AddDate(0, 0, -(days)).Unix()
+ db := database.GetDB()
+ return db.Where("date_time < ?", oldTime).Delete(model.Stats{}).Error
+}
diff --git a/service/tls.go b/service/tls.go
new file mode 100644
index 0000000..51eafa9
--- /dev/null
+++ b/service/tls.go
@@ -0,0 +1,105 @@
+package service
+
+import (
+ "encoding/json"
+
+ "github.com/alireza0/s-ui/database"
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/util/common"
+
+ "gorm.io/gorm"
+)
+
+type TlsService struct {
+ InboundService
+ ServicesService
+}
+
+func (s *TlsService) GetAll() ([]model.Tls, error) {
+ db := database.GetDB()
+ tlsConfig := []model.Tls{}
+ err := db.Model(model.Tls{}).Scan(&tlsConfig).Error
+ if err != nil {
+ return nil, err
+ }
+
+ return tlsConfig, nil
+}
+
+func (s *TlsService) Save(tx *gorm.DB, action string, data json.RawMessage, hostname string) error {
+ var err error
+
+ switch action {
+ case "new", "edit":
+ var tls model.Tls
+ err = json.Unmarshal(data, &tls)
+ if err != nil {
+ return err
+ }
+ err = tx.Save(&tls).Error
+ if err != nil {
+ return err
+ }
+ if action == "edit" {
+ var inbounds []model.Inbound
+ err = tx.Model(model.Inbound{}).Preload("Tls").Where("tls_id = ?", tls.Id).Find(&inbounds).Error
+ if err != nil {
+ return err
+ }
+ if len(inbounds) > 0 {
+ err = s.ClientService.UpdateLinksByInboundChange(tx, &inbounds, hostname, "")
+ if err != nil {
+ return err
+ }
+ var inboundIds []uint
+ for _, inbound := range inbounds {
+ inboundIds = append(inboundIds, inbound.Id)
+ }
+ err = s.InboundService.UpdateOutJsons(tx, inboundIds, hostname)
+ if err != nil {
+ return common.NewError("unable to update out_json of inbounds: ", err.Error())
+ }
+ err = s.InboundService.RestartInbounds(tx, inboundIds)
+ if err != nil {
+ return err
+ }
+ }
+ var serviceIds []uint
+ err = tx.Model(model.Service{}).Where("tls_id = ?", tls.Id).Scan(&serviceIds).Error
+ if err != nil {
+ return err
+ }
+ if len(serviceIds) > 0 {
+ err = s.ServicesService.RestartServices(tx, serviceIds)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ case "del":
+ var id uint
+ err = json.Unmarshal(data, &id)
+ if err != nil {
+ return err
+ }
+ var inboundCount int64
+ err = tx.Model(model.Inbound{}).Where("tls_id = ?", id).Count(&inboundCount).Error
+ if err != nil {
+ return err
+ }
+ var serviceCount int64
+ err = tx.Model(model.Service{}).Where("tls_id = ?", id).Count(&serviceCount).Error
+ if err != nil {
+ return err
+ }
+ if inboundCount > 0 || serviceCount > 0 {
+ return common.NewError("tls in use")
+ }
+ err = tx.Where("id = ?", id).Delete(model.Tls{}).Error
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/service/user.go b/service/user.go
new file mode 100644
index 0000000..46043b4
--- /dev/null
+++ b/service/user.go
@@ -0,0 +1,161 @@
+package service
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/alireza0/s-ui/database"
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/logger"
+ "github.com/alireza0/s-ui/util/common"
+)
+
+type UserService struct {
+}
+
+func (s *UserService) GetFirstUser() (*model.User, error) {
+ db := database.GetDB()
+
+ user := &model.User{}
+ err := db.Model(model.User{}).
+ First(user).
+ Error
+ if err != nil {
+ return nil, err
+ }
+ return user, nil
+}
+
+func (s *UserService) UpdateFirstUser(username string, password string) error {
+ if username == "" {
+ return common.NewError("username can not be empty")
+ } else if password == "" {
+ return common.NewError("password can not be empty")
+ }
+ db := database.GetDB()
+ user := &model.User{}
+ err := db.Model(model.User{}).First(user).Error
+ if database.IsNotFound(err) {
+ user.Username = username
+ user.Password = password
+ return db.Model(model.User{}).Create(user).Error
+ } else if err != nil {
+ return err
+ }
+ user.Username = username
+ user.Password = password
+ return db.Save(user).Error
+}
+
+func (s *UserService) Login(username string, password string, remoteIP string) (string, error) {
+ user := s.CheckUser(username, password, remoteIP)
+ if user == nil {
+ return "", common.NewError("wrong user or password! IP: ", remoteIP)
+ }
+ return user.Username, nil
+}
+
+func (s *UserService) CheckUser(username string, password string, remoteIP string) *model.User {
+ db := database.GetDB()
+
+ user := &model.User{}
+ err := db.Model(model.User{}).
+ Where("username = ? and password = ?", username, password).
+ First(user).
+ Error
+ if database.IsNotFound(err) {
+ return nil
+ } else if err != nil {
+ logger.Warning("check user err:", err, " IP: ", remoteIP)
+ return nil
+ }
+
+ lastLoginTxt := time.Now().Format("2006-01-02 15:04:05") + " " + remoteIP
+ err = db.Model(model.User{}).
+ Where("username = ?", username).
+ Update("last_logins", &lastLoginTxt).Error
+ if err != nil {
+ logger.Warning("unable to log login data", err)
+ }
+ return user
+}
+
+func (s *UserService) GetUsers() (*[]model.User, error) {
+ var users []model.User
+ db := database.GetDB()
+ err := db.Model(model.User{}).Select("id,username,last_logins").Scan(&users).Error
+ if err != nil {
+ return nil, err
+ }
+ return &users, nil
+}
+
+func (s *UserService) ChangePass(id string, oldPass string, newUser string, newPass string) error {
+ db := database.GetDB()
+ user := &model.User{}
+ err := db.Model(model.User{}).Where("id = ? AND password = ?", id, oldPass).First(user).Error
+ if err != nil || database.IsNotFound(err) {
+ return err
+ }
+ user.Username = newUser
+ user.Password = newPass
+ return db.Save(user).Error
+}
+
+func (s *UserService) LoadTokens() ([]byte, error) {
+ db := database.GetDB()
+ var tokens []model.Tokens
+ err := db.Model(model.Tokens{}).Preload("User").Where("expiry == 0 or expiry > ?", time.Now().Unix()).Find(&tokens).Error
+ if err != nil {
+ return nil, err
+ }
+ var result []map[string]interface{}
+ for _, t := range tokens {
+ result = append(result, map[string]interface{}{
+ "token": t.Token,
+ "expiry": t.Expiry,
+ "username": t.User.Username,
+ })
+ }
+ jsonResult, _ := json.MarshalIndent(result, "", " ")
+ return jsonResult, nil
+}
+
+func (s *UserService) GetUserTokens(username string) (*[]model.Tokens, error) {
+ db := database.GetDB()
+ var token []model.Tokens
+ err := db.Model(model.Tokens{}).Select("id,desc,'****' as token,expiry,user_id").Where("user_id = (select id from users where username = ?)", username).Find(&token).Error
+ if err != nil && !database.IsNotFound(err) {
+ println(err.Error())
+ return nil, err
+ }
+ return &token, nil
+}
+
+func (s *UserService) AddToken(username string, expiry int64, desc string) (string, error) {
+ db := database.GetDB()
+ var userId uint
+ err := db.Model(model.User{}).Where("username = ?", username).Select("id").Scan(&userId).Error
+ if err != nil {
+ return "", err
+ }
+ if expiry > 0 {
+ expiry = expiry*86400 + time.Now().Unix()
+ }
+ token := &model.Tokens{
+ Token: common.Random(32),
+ Desc: desc,
+ Expiry: expiry,
+ UserId: userId,
+ }
+ err = db.Create(token).Error
+ if err != nil {
+ return "", err
+ }
+ return token.Token, nil
+}
+
+func (s *UserService) DeleteToken(id string) error {
+ db := database.GetDB()
+ return db.Model(model.Tokens{}).Where("id = ?", id).Delete(&model.Tokens{}).Error
+}
diff --git a/service/warp.go b/service/warp.go
new file mode 100644
index 0000000..0bf96a1
--- /dev/null
+++ b/service/warp.go
@@ -0,0 +1,224 @@
+package service
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/logger"
+ "github.com/alireza0/s-ui/util/common"
+
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+type WarpService struct{}
+
+func (s *WarpService) getWarpInfo(deviceId string, accessToken string) ([]byte, error) {
+ url := fmt.Sprintf("https://api.cloudflareclient.com/v0a2158/reg/%s", deviceId)
+
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil || resp.StatusCode != 200 {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ buffer := bytes.NewBuffer(make([]byte, 8192))
+ buffer.Reset()
+ _, err = buffer.ReadFrom(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ return buffer.Bytes(), nil
+}
+
+func (s *WarpService) RegisterWarp(ep *model.Endpoint) error {
+ tos := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
+ privateKey, _ := wgtypes.GenerateKey()
+ publicKey := privateKey.PublicKey().String()
+ hostName, _ := os.Hostname()
+
+ data := fmt.Sprintf(`{"key":"%s","tos":"%s","type": "PC","model": "s-ui", "name": "%s"}`, publicKey, tos, hostName)
+ url := "https://api.cloudflareclient.com/v0a2158/reg"
+
+ req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(data)))
+ if err != nil {
+ return err
+ }
+
+ req.Header.Add("CF-Client-Version", "a-7.21-0721")
+ req.Header.Add("Content-Type", "application/json")
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil || resp.StatusCode != 200 {
+ return err
+ }
+ defer resp.Body.Close()
+ buffer := bytes.NewBuffer(make([]byte, 8192))
+ buffer.Reset()
+ _, err = buffer.ReadFrom(resp.Body)
+ if err != nil {
+ return err
+ }
+
+ var rspData map[string]interface{}
+ err = json.Unmarshal(buffer.Bytes(), &rspData)
+ if err != nil {
+ return err
+ }
+
+ deviceId := rspData["id"].(string)
+ token := rspData["token"].(string)
+ license, ok := rspData["account"].(map[string]interface{})["license"].(string)
+ if !ok {
+ logger.Debug("Error accessing license value.")
+ return err
+ }
+
+ warpInfo, err := s.getWarpInfo(deviceId, token)
+ if err != nil {
+ return err
+ }
+
+ var warpDetails map[string]interface{}
+ err = json.Unmarshal(warpInfo, &warpDetails)
+ if err != nil {
+ return err
+ }
+
+ warpConfig, _ := warpDetails["config"].(map[string]interface{})
+ clientId, _ := warpConfig["client_id"].(string)
+ reserved := s.getReserved(clientId)
+ interfaceConfig, _ := warpConfig["interface"].(map[string]interface{})
+ addresses, _ := interfaceConfig["addresses"].(map[string]interface{})
+ v4, _ := addresses["v4"].(string)
+ v6, _ := addresses["v6"].(string)
+ peer, _ := warpConfig["peers"].([]interface{})[0].(map[string]interface{})
+ peerEndpoint, _ := peer["endpoint"].(map[string]interface{})["host"].(string)
+ peerEpAddress, peerEpPort, err := net.SplitHostPort(peerEndpoint)
+ if err != nil {
+ return err
+ }
+ peerPublicKey, _ := peer["public_key"].(string)
+ peerPort, _ := strconv.Atoi(peerEpPort)
+
+ peers := []map[string]interface{}{
+ {
+ "address": peerEpAddress,
+ "port": peerPort,
+ "public_key": peerPublicKey,
+ "allowed_ips": []string{"0.0.0.0/0", "::/0"},
+ "reserved": reserved,
+ },
+ }
+
+ warpData := map[string]interface{}{
+ "access_token": token,
+ "device_id": deviceId,
+ "license_key": license,
+ }
+
+ ep.Ext, err = json.MarshalIndent(warpData, "", " ")
+ if err != nil {
+ return err
+ }
+
+ var epOptions map[string]interface{}
+ err = json.Unmarshal(ep.Options, &epOptions)
+ if err != nil {
+ return err
+ }
+ epOptions["private_key"] = privateKey.String()
+ epOptions["address"] = []string{fmt.Sprintf("%s/32", v4), fmt.Sprintf("%s/128", v6)}
+ epOptions["listen_port"] = 0
+ epOptions["peers"] = peers
+
+ ep.Options, err = json.MarshalIndent(epOptions, "", " ")
+ return err
+}
+
+func (s *WarpService) getReserved(clientID string) []int {
+ var reserved []int
+ decoded, err := base64.StdEncoding.DecodeString(clientID)
+ if err != nil {
+ return nil
+ }
+
+ hexString := ""
+ for _, char := range decoded {
+ hex := fmt.Sprintf("%02x", char)
+ hexString += hex
+ }
+
+ for i := 0; i < len(hexString); i += 2 {
+ hexByte := hexString[i : i+2]
+ decValue, err := strconv.ParseInt(hexByte, 16, 32)
+ if err != nil {
+ return nil
+ }
+ reserved = append(reserved, int(decValue))
+ }
+
+ return reserved
+}
+
+func (s *WarpService) SetWarpLicense(old_license string, ep *model.Endpoint) error {
+ var warpData map[string]string
+ err := json.Unmarshal(ep.Ext, &warpData)
+ if err != nil {
+ return err
+ }
+
+ if warpData["license_key"] == old_license {
+ return nil
+ }
+
+ url := fmt.Sprintf("https://api.cloudflareclient.com/v0a2158/reg/%s/account", warpData["device_id"])
+ data := fmt.Sprintf(`{"license": "%s"}`, warpData["license_key"])
+
+ req, err := http.NewRequest("PUT", url, bytes.NewBuffer([]byte(data)))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ buffer := bytes.NewBuffer(make([]byte, 8192))
+ buffer.Reset()
+ _, err = buffer.ReadFrom(resp.Body)
+ if err != nil {
+ return err
+ }
+ var response map[string]interface{}
+ err = json.Unmarshal(buffer.Bytes(), &response)
+ if err != nil {
+ return err
+ }
+
+ if success, ok := response["success"].(bool); ok && success == false {
+ errorArr, _ := response["errors"].([]interface{})
+ errorObj := errorArr[0].(map[string]interface{})
+ return common.NewError(errorObj["code"], errorObj["message"])
+ }
+
+ return nil
+}
diff --git a/sub/clashService.go b/sub/clashService.go
new file mode 100644
index 0000000..a7cb81f
--- /dev/null
+++ b/sub/clashService.go
@@ -0,0 +1,393 @@
+package sub
+
+import (
+ "strings"
+
+ "github.com/alireza0/s-ui/logger"
+ "github.com/alireza0/s-ui/service"
+ "github.com/alireza0/s-ui/util"
+
+ "gopkg.in/yaml.v3"
+)
+
+type ClashService struct {
+ service.SettingService
+ JsonService
+ LinkService
+}
+
+const basicClashConfig = `mixed-port: 7890
+allow-lan: false
+mode: rule
+log-level: info
+external-controller: 127.0.0.1:9090
+tun:
+ enable: true
+ stack: system
+ auto-route: true
+ auto-detect-interface: true
+ dns-hijack:
+ - any:53
+dns:
+ enable: true
+ ipv6: false
+ enhanced-mode: fake-ip
+ fake-ip-range: 198.18.0.1/16
+ default-nameserver:
+ - 8.8.8.8
+ - 1.1.1.1
+ nameserver:
+ - https://doh.pub/dns-query
+ - https://1.0.0.1/dns-query
+ fallback:
+ - tcp://9.9.9.9:53
+ fake-ip-filter:
+ - "*.lan"
+ - localhost
+ - "*.local"
+rules:
+ - GEOIP,Private,DIRECT
+ - MATCH,Proxy
+`
+
+const ProxyGroups = `- name: Proxy
+ type: select
+ proxies: []
+- name: Auto
+ type: url-test
+ proxies: []
+ url: http://www.gstatic.com/generate_204
+ interval: 300
+ tolerance: 50
+`
+
+func (s *ClashService) GetClash(subId string) (*string, []string, error) {
+
+ client, inDatas, err := s.getData(subId)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ outbounds, outTags, err := s.getOutbounds(client.Config, inDatas)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ links := s.LinkService.GetLinks(&client.Links, "external", "")
+ tagNumEnable := 0
+ if len(links) > 1 {
+ tagNumEnable = 1
+ }
+ for index, link := range links {
+ json, tag, err := util.GetOutbound(link, (index+1)*tagNumEnable)
+ if err == nil && len(tag) > 0 {
+ *outbounds = append(*outbounds, *json)
+ *outTags = append(*outTags, tag)
+ }
+ }
+
+ basicConfig, err := s.getClashConfig()
+ if err != nil || len(basicConfig) == 0 {
+ basicConfig = basicClashConfig
+ }
+
+ resultStr, err := s.ConvertToClashMeta(outbounds, basicConfig)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ updateInterval, _ := s.SettingService.GetSubUpdates()
+ headers := util.GetHeaders(client, updateInterval)
+
+ return &resultStr, headers, nil
+}
+
+func (s *ClashService) getClashConfig() (string, error) {
+ subClashExt, err := s.SettingService.GetSubClashExt()
+ if err != nil {
+ return "", err
+ }
+
+ return subClashExt, nil
+}
+
+func (s *ClashService) ConvertToClashMeta(outbounds *[]map[string]interface{}, basicConfig string) (string, error) {
+ var proxies []interface{}
+ proxyTags := make([]string, 0)
+ for _, obMap := range *outbounds {
+
+ t, _ := obMap["type"].(string)
+ if t == "selector" || t == "urltest" || t == "direct" {
+ continue
+ }
+
+ proxy := make(map[string]interface{})
+ proxy["name"] = obMap["tag"]
+ proxy["type"] = t
+
+ server, _ := obMap["server"].(string)
+ if len(server) > 0 && strings.Contains(server, ":") && !strings.Contains(server, ".") && !(strings.HasPrefix(server, "[") && strings.HasSuffix(server, "]")) {
+ server = "'[" + server + "]'"
+ }
+ proxy["server"] = server
+
+ proxy["port"] = obMap["server_port"]
+
+ switch t {
+ case "vmess", "vless", "tuic":
+ proxy["uuid"] = obMap["uuid"]
+ if t == "vmess" {
+ if alterId, ok := obMap["alter_id"].(float64); ok {
+ proxy["alterId"] = int(alterId)
+ } else {
+ proxy["alterId"] = 0
+ }
+ proxy["cipher"] = "auto"
+ }
+ if t == "vless" {
+ if flow, ok := obMap["flow"].(string); ok {
+ proxy["flow"] = flow
+ }
+ }
+ if t == "tuic" {
+ proxy["password"] = obMap["password"]
+ if congestion_control, ok := obMap["congestion_control"].(string); ok {
+ proxy["congestion-controller"] = congestion_control
+ }
+ }
+ case "trojan":
+ proxy["password"] = obMap["password"]
+ case "socks", "http":
+ if t == "socks" {
+ proxy["type"] = "socks5"
+ }
+ proxy["username"] = obMap["username"]
+ proxy["password"] = obMap["password"]
+ case "hysteria", "hysteria2":
+ if _, ok := obMap["up_mbps"].(float64); ok {
+ proxy["up"] = obMap["up_mbps"]
+ }
+ if _, ok := obMap["down_mbps"].(float64); ok {
+ proxy["down"] = obMap["down_mbps"]
+ }
+ if t == "hysteria" {
+ proxy["auth-str"] = obMap["auth_str"]
+ if obfs, ok := obMap["obfs"].(string); ok {
+ proxy["obfs"] = obfs
+ }
+ } else {
+ proxy["password"] = obMap["password"]
+ if obfs, ok := obMap["obfs"].(map[string]interface{}); ok {
+ proxy["obfs"] = obfs["type"]
+ proxy["obfs-password"] = obfs["password"]
+ }
+ }
+
+ if portLists, ok := obMap["server_ports"].([]interface{}); ok {
+ var ports []string
+ for _, portList := range portLists {
+ portRange, _ := portList.(string)
+ ports = append(ports, strings.ReplaceAll(portRange, ":", "-"))
+ }
+ proxy["ports"] = strings.Join(ports, ",")
+ }
+ case "anytls":
+ proxy["password"] = obMap["password"]
+ if tls, ok := obMap["tls"].(map[string]interface{}); ok {
+ proxy["sni"] = tls["server_name"]
+ proxy["skip-cert-verify"] = tls["insecure"]
+ }
+ case "shadowsocks":
+ proxy["type"] = "ss"
+ proxy["cipher"] = obMap["method"]
+ proxy["password"] = obMap["password"]
+ if network, ok := obMap["network"].(string); ok && network != "tcp" {
+ proxy["udp"] = true
+ }
+ if uot, ok := obMap["udp_over_tcp"].(bool); ok && uot {
+ proxy["udp-over-tcp"] = true
+ }
+ default:
+ continue
+ }
+
+ // TLS params
+ tls, isTls := obMap["tls"].(map[string]interface{})
+ if isTls {
+ tlsEnabled, ok := tls["enabled"].(bool)
+ if ok && !tlsEnabled {
+ isTls = false
+ }
+ }
+ if isTls {
+ proxy["tls"] = tls["enabled"]
+
+ // ALPN if exists
+ if alpn, ok := tls["alpn"].([]interface{}); ok {
+ proxy["alpn"] = alpn
+ }
+
+ // Add reality if exists
+ if reality, ok := tls["reality"].(map[string]interface{}); ok && reality["enabled"].(bool) {
+ reality_opts := make(map[string]interface{})
+ if pbk, ok := reality["public_key"].(string); ok {
+ reality_opts["public-key"] = pbk
+ }
+ if sid, ok := reality["short_id"].(string); ok {
+ reality_opts["short-id"] = sid
+ }
+ proxy["reality-opts"] = reality_opts
+ }
+ if utls, ok := tls["utls"].(map[string]interface{}); ok {
+ if enabled, ok := utls["enabled"].(bool); ok && enabled {
+ if fp, ok := utls["fingerprint"].(string); ok {
+ proxy["client-fingerprint"] = fp
+ }
+ }
+ }
+ if sni, ok := tls["server_name"].(string); ok {
+ if t == "vless" || t == "vmess" {
+ proxy["servername"] = sni
+ } else {
+ proxy["sni"] = sni
+ }
+ }
+ if insecure, ok := tls["insecure"].(bool); ok && insecure {
+ proxy["skip-cert-verify"] = insecure
+ }
+ // ech outbounds
+ if ech, ok := tls["ech"].(map[string]interface{}); ok && ech["enabled"].(bool) {
+ ech_config, _ := ech["config"].([]interface{})
+ ech_string := ""
+ for i := 1; i < len(ech_config)-1; i++ {
+ ech_string += ech_config[i].(string)
+ }
+ proxy["ech-opts"] = map[string]interface{}{
+ "enable": true,
+ "config": ech_string,
+ }
+ }
+ }
+
+ // Transport if exist
+ if transport, ok := obMap["transport"].(map[string]interface{}); ok {
+ tt, _ := transport["type"].(string)
+ switch tt {
+ case "http":
+ httpOpts := make(map[string]interface{})
+ if path, ok := transport["path"].([]interface{}); ok {
+ httpOpts["path"] = path[0]
+ } else if path, ok := transport["path"].(string); ok {
+ httpOpts["path"] = path
+ }
+ if host, ok := transport["host"].([]interface{}); ok {
+ httpOpts["host"] = host[0]
+ }
+ if isTls {
+ proxy["network"] = "h2"
+ proxy["h2-opts"] = httpOpts
+ } else {
+ proxy["network"] = "http"
+ proxy["http-opts"] = map[string]interface{}{"path": []interface{}{httpOpts["path"]}, "host": httpOpts["host"]}
+ }
+ case "ws", "httpupgrade":
+ proxy["network"] = "ws"
+ wsOpts := make(map[string]interface{})
+ if path, ok := transport["path"].(string); ok {
+ wsOpts["path"] = path
+ }
+ if headers, ok := transport["headers"].([]interface{}); ok {
+ wsOpts["headers"] = headers
+ }
+ if ed, ok := transport["early_data_header_name"].(string); ok {
+ wsOpts["early-data-header-name"] = ed
+ }
+ if tt == "httpupgrade" {
+ wsOpts["v2ray-http-upgrade"] = true
+ }
+ proxy["ws-opts"] = wsOpts
+ case "grpc":
+ proxy["network"] = "grpc"
+ grpcOpts := make(map[string]interface{})
+ if service_name, ok := transport["service_name"].(string); ok {
+ grpcOpts["grpc-service-name"] = service_name
+ }
+ proxy["grpc-opts"] = grpcOpts
+ }
+ }
+
+ // Multiplex
+ if mux, ok := obMap["multiplex"].(map[string]interface{}); ok {
+ if enabled, ok := mux["enabled"].(bool); ok && enabled {
+ smux := make(map[string]interface{})
+ smux["enabled"] = true
+ if protocol, ok := mux["protocol"].(string); ok {
+ smux["protocol"] = protocol
+ }
+ if _, ok := mux["max_connections"].(float64); ok {
+ smux["max-connections"] = mux["max_connections"]
+ }
+ if _, ok := mux["min_streams"].(float64); ok {
+ smux["min-streams"] = mux["min_streams"]
+ }
+ if _, ok := mux["max_streams"].(float64); ok {
+ smux["max-streams"] = mux["max_streams"]
+ }
+ if _, ok := mux["padding"].(bool); ok {
+ smux["padding"] = mux["padding"]
+ }
+ if brutal, ok := mux["brutal"].(map[string]interface{}); ok {
+ if enabled, ok := brutal["enabled"].(bool); ok && enabled {
+ brutalOpts := make(map[string]interface{})
+ brutalOpts["enabled"] = true
+ if _, ok := brutal["up_mbps"].(float64); ok {
+ brutalOpts["up"] = brutal["up_mbps"]
+ }
+ if _, ok := brutal["down_mbps"].(float64); ok {
+ brutalOpts["down"] = brutal["down_mbps"]
+ }
+ smux["brutal-opts"] = brutalOpts
+ }
+ }
+ proxy["smux"] = smux
+ }
+ }
+
+ proxies = append(proxies, proxy)
+ proxyTags = append(proxyTags, obMap["tag"].(string))
+ }
+
+ var proxyGroups []map[string]interface{}
+ err := yaml.Unmarshal([]byte(ProxyGroups), &proxyGroups)
+ if err != nil {
+ logger.Error(err.Error())
+ }
+
+ proxyGroups[1]["proxies"] = proxyTags
+ proxyGroups[0]["proxies"] = append([]string{proxyGroups[1]["name"].(string)}, proxyTags...)
+
+ // Merge proxies and proxy groups if exist
+ var output map[string]interface{}
+ err = yaml.Unmarshal([]byte(basicConfig), &output)
+ if err != nil {
+ logger.Error(err.Error())
+ }
+
+ if p, ok := output["proxies"].([]interface{}); ok {
+ output["proxies"] = append(p, proxies...)
+ } else {
+ output["proxies"] = proxies
+ }
+
+ if pg, ok := output["proxy-groups"].([]interface{}); ok {
+ output["proxy-groups"] = append(pg, proxyGroups[0], proxyGroups[1])
+ } else {
+ output["proxy-groups"] = proxyGroups
+ }
+
+ result, err := yaml.Marshal(output)
+ if err != nil {
+ return "", err
+ }
+ return string(result), nil
+}
diff --git a/sub/jsonService.go b/sub/jsonService.go
new file mode 100644
index 0000000..61141aa
--- /dev/null
+++ b/sub/jsonService.go
@@ -0,0 +1,326 @@
+package sub
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/alireza0/s-ui/database"
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/service"
+ "github.com/alireza0/s-ui/util"
+)
+
+const defaultJson = `
+{
+ "inbounds": [
+ {
+ "type": "tun",
+ "address": [
+ "172.19.0.1/30",
+ "fdfe:dcba:9876::1/126"
+ ],
+ "mtu": 9000,
+ "auto_route": true,
+ "strict_route": false,
+ "endpoint_independent_nat": false,
+ "stack": "system",
+ "platform": {
+ "http_proxy": {
+ "enabled": true,
+ "server": "127.0.0.1",
+ "server_port": 2080
+ }
+ }
+ },
+ {
+ "type": "mixed",
+ "listen": "127.0.0.1",
+ "listen_port": 2080,
+ "users": []
+ }
+ ]
+}
+`
+
+type JsonService struct {
+ service.SettingService
+ LinkService
+}
+
+func (j *JsonService) GetJson(subId string, format string) (*string, []string, error) {
+ var jsonConfig map[string]interface{}
+
+ client, inDatas, err := j.getData(subId)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ outbounds, outTags, err := j.getOutbounds(client.Config, inDatas)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ links := j.LinkService.GetLinks(&client.Links, "external", "")
+ tagNumEnable := 0
+ if len(links) > 1 {
+ tagNumEnable = 1
+ }
+ for index, link := range links {
+ json, tag, err := util.GetOutbound(link, (index+1)*tagNumEnable)
+ if err == nil && len(tag) > 0 {
+ *outbounds = append(*outbounds, *json)
+ *outTags = append(*outTags, tag)
+ }
+ }
+
+ j.addDefaultOutbounds(outbounds, outTags)
+
+ err = json.Unmarshal([]byte(defaultJson), &jsonConfig)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ jsonConfig["outbounds"] = outbounds
+
+ // Add other objects from settings
+ j.addOthers(&jsonConfig)
+
+ result, _ := json.MarshalIndent(jsonConfig, "", " ")
+ resultStr := string(result)
+
+ updateInterval, _ := j.SettingService.GetSubUpdates()
+ headers := util.GetHeaders(client, updateInterval)
+
+ return &resultStr, headers, nil
+}
+
+func (j *JsonService) getData(subId string) (*model.Client, []*model.Inbound, error) {
+ db := database.GetDB()
+ client := &model.Client{}
+ err := db.Model(model.Client{}).Where("enable = true and name = ?", subId).First(client).Error
+ if err != nil {
+ return nil, nil, err
+ }
+ var clientInbounds []uint
+ err = json.Unmarshal(client.Inbounds, &clientInbounds)
+ if err != nil {
+ return nil, nil, err
+ }
+ var inbounds []*model.Inbound
+ err = db.Model(model.Inbound{}).Preload("Tls").Where("id in ?", clientInbounds).Find(&inbounds).Error
+ if err != nil {
+ return nil, nil, err
+ }
+ return client, inbounds, nil
+}
+
+func (j *JsonService) getOutbounds(clientConfig json.RawMessage, inbounds []*model.Inbound) (*[]map[string]interface{}, *[]string, error) {
+ var outbounds []map[string]interface{}
+ var configs map[string]interface{}
+ var outTags []string
+
+ err := json.Unmarshal(clientConfig, &configs)
+ if err != nil {
+ return nil, nil, err
+ }
+ for _, inData := range inbounds {
+ if len(inData.OutJson) < 5 {
+ continue
+ }
+ var outbound map[string]interface{}
+ err = json.Unmarshal(inData.OutJson, &outbound)
+ if err != nil {
+ return nil, nil, err
+ }
+ protocol, _ := outbound["type"].(string)
+
+ // Shadowsocks
+ if protocol == "shadowsocks" {
+ var userPass []string
+ var inbOptions map[string]interface{}
+ err = json.Unmarshal(inData.Options, &inbOptions)
+ if err != nil {
+ return nil, nil, err
+ }
+ method, _ := inbOptions["method"].(string)
+ if strings.HasPrefix(method, "2022") {
+ inbPass, _ := inbOptions["password"].(string)
+ userPass = append(userPass, inbPass)
+ }
+ var pass string
+ if method == "2022-blake3-aes-128-gcm" {
+ pass, _ = configs["shadowsocks16"].(map[string]interface{})["password"].(string)
+ } else {
+ pass, _ = configs["shadowsocks"].(map[string]interface{})["password"].(string)
+ }
+ userPass = append(userPass, pass)
+ outbound["password"] = strings.Join(userPass, ":")
+ } else { // Other protocols
+ config, _ := configs[protocol].(map[string]interface{})
+ for key, value := range config {
+ if key == "name" || key == "alterId" || (key == "flow" && inData.TlsId == 0) {
+ continue
+ }
+ outbound[key] = value
+ }
+ }
+
+ var addrs []map[string]interface{}
+ err = json.Unmarshal(inData.Addrs, &addrs)
+ if err != nil {
+ return nil, nil, err
+ }
+ tag, _ := outbound["tag"].(string)
+ if len(addrs) == 0 {
+ // For mixed protocol, use separated socks and http
+ if protocol == "mixed" {
+ outbound["tag"] = tag
+ j.pushMixed(&outbounds, &outTags, outbound)
+ } else {
+ outTags = append(outTags, tag)
+ outbounds = append(outbounds, outbound)
+ }
+ } else {
+ for index, addr := range addrs {
+ // Copy original config
+ newOut := make(map[string]interface{}, len(outbound))
+ for key, value := range outbound {
+ newOut[key] = value
+ }
+ // Change and push copied config
+ newOut["server"], _ = addr["server"].(string)
+ port, _ := addr["server_port"].(float64)
+ newOut["server_port"] = int(port)
+
+ // Override TLS
+ if addrTls, ok := addr["tls"].(map[string]interface{}); ok {
+ outTls, _ := newOut["tls"].(map[string]interface{})
+ if outTls == nil {
+ outTls = make(map[string]interface{})
+ }
+ for key, value := range addrTls {
+ outTls[key] = value
+ }
+ newOut["tls"] = outTls
+ }
+
+ remark, _ := addr["remark"].(string)
+ newTag := fmt.Sprintf("%d.%s%s", index+1, tag, remark)
+ newOut["tag"] = newTag
+ // For mixed protocol, use separated socks and http
+ if protocol == "mixed" {
+ j.pushMixed(&outbounds, &outTags, newOut)
+ } else {
+ outTags = append(outTags, newTag)
+ outbounds = append(outbounds, newOut)
+ }
+ }
+ }
+ }
+ return &outbounds, &outTags, nil
+}
+
+func (j *JsonService) addDefaultOutbounds(outbounds *[]map[string]interface{}, outTags *[]string) {
+ outbound := []map[string]interface{}{
+ {
+ "outbounds": append([]string{"auto", "direct"}, *outTags...),
+ "tag": "proxy",
+ "type": "selector",
+ },
+ {
+ "tag": "auto",
+ "type": "urltest",
+ "outbounds": outTags,
+ "url": "http://www.gstatic.com/generate_204",
+ "interval": "10m",
+ "tolerance": 50,
+ },
+ {
+ "type": "direct",
+ "tag": "direct",
+ },
+ }
+ *outbounds = append(outbound, *outbounds...)
+}
+
+func (j *JsonService) addOthers(jsonConfig *map[string]interface{}) error {
+ rules_start := []interface{}{
+ map[string]interface{}{
+ "action": "sniff",
+ },
+ map[string]interface{}{
+ "clash_mode": "Direct",
+ "action": "route",
+ "outbound": "direct",
+ },
+ }
+ rules_end := []interface{}{
+ map[string]interface{}{
+ "clash_mode": "Global",
+ "action": "route",
+ "outbound": "proxy",
+ },
+ }
+ route := map[string]interface{}{
+ "auto_detect_interface": true,
+ "final": "proxy",
+ "rules": rules_start,
+ }
+
+ othersStr, err := j.SettingService.GetSubJsonExt()
+ if err != nil {
+ return err
+ }
+ if len(othersStr) == 0 {
+ (*jsonConfig)["route"] = route
+ return nil
+ }
+ var othersJson map[string]interface{}
+ err = json.Unmarshal([]byte(othersStr), &othersJson)
+ if err != nil {
+ return err
+ }
+ if _, ok := othersJson["log"]; ok {
+ (*jsonConfig)["log"] = othersJson["log"]
+ }
+ if _, ok := othersJson["dns"]; ok {
+ (*jsonConfig)["dns"] = othersJson["dns"]
+ }
+ if _, ok := othersJson["inbounds"]; ok {
+ (*jsonConfig)["inbounds"] = othersJson["inbounds"]
+ }
+ if _, ok := othersJson["experimental"]; ok {
+ (*jsonConfig)["experimental"] = othersJson["experimental"]
+ }
+ if _, ok := othersJson["rule_set"]; ok {
+ route["rule_set"] = othersJson["rule_set"]
+ }
+ if settingRules, ok := othersJson["rules"].([]interface{}); ok {
+ rules := append(rules_start, settingRules...)
+ route["rules"] = append(rules, rules_end...)
+ }
+ if defaultDomainResolver, ok := othersJson["default_domain_resolver"].(string); ok {
+ route["default_domain_resolver"] = defaultDomainResolver
+ }
+ (*jsonConfig)["route"] = route
+
+ return nil
+}
+
+func (j *JsonService) pushMixed(outbounds *[]map[string]interface{}, outTags *[]string, out map[string]interface{}) {
+ socksOut := make(map[string]interface{}, 1)
+ httpOut := make(map[string]interface{}, 1)
+ for key, value := range out {
+ socksOut[key] = value
+ httpOut[key] = value
+ }
+ socksTag := fmt.Sprintf("%s-socks", out["tag"])
+ httpTag := fmt.Sprintf("%s-http", out["tag"])
+ socksOut["type"] = "socks"
+ httpOut["type"] = "http"
+ socksOut["tag"] = socksTag
+ httpOut["tag"] = httpTag
+ *outbounds = append(*outbounds, socksOut, httpOut)
+ *outTags = append(*outTags, socksTag, httpTag)
+}
diff --git a/sub/linkService.go b/sub/linkService.go
new file mode 100644
index 0000000..67c29e7
--- /dev/null
+++ b/sub/linkService.go
@@ -0,0 +1,74 @@
+package sub
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/alireza0/s-ui/logger"
+ "github.com/alireza0/s-ui/util"
+)
+
+type Link struct {
+ Type string `json:"type"`
+ Remark string `json:"remark"`
+ Uri string `json:"uri"`
+}
+
+type LinkService struct {
+}
+
+func (s *LinkService) GetLinks(linkJson *json.RawMessage, types string, clientInfo string) []string {
+ links := []Link{}
+ var result []string
+ err := json.Unmarshal(*linkJson, &links)
+ if err != nil {
+ return nil
+ }
+ for _, link := range links {
+ switch link.Type {
+ case "external":
+ result = append(result, link.Uri)
+ case "sub":
+ subLinks := util.GetExternalLink(link.Uri)
+ result = append(result, strings.Split(subLinks, "\n")...)
+ case "local":
+ if types == "all" {
+ result = append(result, s.addClientInfo(link.Uri, clientInfo))
+ }
+ }
+ }
+ return result
+}
+
+func (s *LinkService) addClientInfo(uri string, clientInfo string) string {
+ if len(clientInfo) == 0 {
+ return uri
+ }
+ protocol := strings.Split(uri, "://")
+ if len(protocol) < 2 {
+ return uri
+ }
+ switch protocol[0] {
+ case "vmess":
+ var vmessJson map[string]interface{}
+ config, err := util.B64StrToByte(protocol[1])
+ if err != nil {
+ logger.Warning("sub: Error decoding vmess content:", err)
+ return uri
+ }
+ err = json.Unmarshal(config, &vmessJson)
+ if err != nil {
+ logger.Warning("sub: Error decoding vmess content:", err)
+ return uri
+ }
+ vmessJson["ps"] = vmessJson["ps"].(string) + clientInfo
+ result, err := json.MarshalIndent(vmessJson, "", " ")
+ if err != nil {
+ logger.Warning("sub: Error decoding vmess + clientInfo content:", err)
+ return uri
+ }
+ return "vmess://" + util.ByteToB64Str(result)
+ default:
+ return uri + clientInfo
+ }
+}
diff --git a/sub/sub.go b/sub/sub.go
new file mode 100644
index 0000000..11276d2
--- /dev/null
+++ b/sub/sub.go
@@ -0,0 +1,162 @@
+package sub
+
+import (
+ "context"
+ "crypto/tls"
+ "io"
+ "net"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/alireza0/s-ui/config"
+ "github.com/alireza0/s-ui/logger"
+ "github.com/alireza0/s-ui/middleware"
+ "github.com/alireza0/s-ui/network"
+ "github.com/alireza0/s-ui/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+type Server struct {
+ httpServer *http.Server
+ listener net.Listener
+ ctx context.Context
+ cancel context.CancelFunc
+
+ service.SettingService
+}
+
+func NewServer() *Server {
+ ctx, cancel := context.WithCancel(context.Background())
+ return &Server{
+ ctx: ctx,
+ cancel: cancel,
+ }
+}
+
+func (s *Server) initRouter() (*gin.Engine, error) {
+ if config.IsDebug() {
+ gin.SetMode(gin.DebugMode)
+ } else {
+ gin.DefaultWriter = io.Discard
+ gin.DefaultErrorWriter = io.Discard
+ gin.SetMode(gin.ReleaseMode)
+ }
+
+ engine := gin.Default()
+
+ subPath, err := s.SettingService.GetSubPath()
+ if err != nil {
+ return nil, err
+ }
+
+ subDomain, err := s.SettingService.GetSubDomain()
+ if err != nil {
+ return nil, err
+ }
+
+ if subDomain != "" {
+ engine.Use(middleware.DomainValidator(subDomain))
+ }
+
+ g := engine.Group(subPath)
+ NewSubHandler(g)
+
+ return engine, nil
+}
+
+func (s *Server) Start() (err error) {
+ //This is an anonymous function, no function name
+ defer func() {
+ if err != nil {
+ s.Stop()
+ }
+ }()
+
+ engine, err := s.initRouter()
+ if err != nil {
+ return err
+ }
+
+ certFile, err := s.SettingService.GetSubCertFile()
+ if err != nil {
+ return err
+ }
+ keyFile, err := s.SettingService.GetSubKeyFile()
+ if err != nil {
+ return err
+ }
+ listen, err := s.SettingService.GetSubListen()
+ if err != nil {
+ return err
+ }
+ port, err := s.SettingService.GetSubPort()
+ if err != nil {
+ return err
+ }
+
+ listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
+ listener, err := net.Listen("tcp", listenAddr)
+ if err != nil {
+ return err
+ }
+
+ if certFile != "" || keyFile != "" {
+ cert, err := tls.LoadX509KeyPair(certFile, keyFile)
+ if err != nil {
+ listener.Close()
+ return err
+ }
+ c := &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ }
+ listener = network.NewAutoHttpsListener(listener)
+ listener = tls.NewListener(listener, c)
+ }
+
+ if certFile != "" || keyFile != "" {
+ logger.Info("Sub server run https on", listener.Addr())
+ } else {
+ logger.Info("Sub server run http on", listener.Addr())
+ }
+ s.listener = listener
+
+ s.httpServer = &http.Server{
+ Handler: engine,
+ }
+
+ go func() {
+ s.httpServer.Serve(listener)
+ }()
+
+ return nil
+}
+
+func (s *Server) Stop() error {
+ var err error
+ if s.httpServer != nil {
+ shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 30*time.Second)
+ err = s.httpServer.Shutdown(shutdownCtx)
+ cancelShutdown()
+ if err != nil {
+ s.cancel()
+ if s.listener != nil {
+ _ = s.listener.Close()
+ }
+ return err
+ }
+ } else if s.listener != nil {
+ err = s.listener.Close()
+ if err != nil {
+ s.cancel()
+ return err
+ }
+ }
+ s.cancel()
+ return nil
+}
+
+func (s *Server) GetCtx() context.Context {
+ return s.ctx
+}
diff --git a/sub/subHandler.go b/sub/subHandler.go
new file mode 100644
index 0000000..14c7962
--- /dev/null
+++ b/sub/subHandler.go
@@ -0,0 +1,78 @@
+package sub
+
+import (
+ "github.com/alireza0/s-ui/logger"
+ "github.com/alireza0/s-ui/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+type SubHandler struct {
+ service.SettingService
+ SubService
+ JsonService
+ ClashService
+}
+
+func NewSubHandler(g *gin.RouterGroup) {
+ a := &SubHandler{}
+ a.initRouter(g)
+}
+
+func (s *SubHandler) initRouter(g *gin.RouterGroup) {
+ g.GET("/:subid", s.subs)
+ g.HEAD("/:subid", s.subHeaders)
+}
+
+func (s *SubHandler) subs(c *gin.Context) {
+ var headers []string
+ var result *string
+ var err error
+ subId := c.Param("subid")
+ format, isFormat := c.GetQuery("format")
+ if isFormat {
+ switch format {
+ case "json":
+ result, headers, err = s.JsonService.GetJson(subId, format)
+ case "clash":
+ result, headers, err = s.ClashService.GetClash(subId)
+ }
+ if err != nil || result == nil {
+ logger.Error(err)
+ c.String(400, "Error!")
+ return
+ }
+ } else {
+ result, headers, err = s.SubService.GetSubs(subId)
+ if err != nil || result == nil {
+ logger.Error(err)
+ c.String(400, "Error!")
+ return
+ }
+ }
+
+ s.addHeaders(c, headers)
+
+ c.String(200, *result)
+}
+
+func (s *SubHandler) subHeaders(c *gin.Context) {
+ subId := c.Param("subid")
+ client, err := s.SubService.getClientBySubId(subId)
+ if err != nil {
+ logger.Error(err)
+ c.String(400, "Error!")
+ return
+ }
+
+ headers := s.SubService.getClientHeaders(client)
+ s.addHeaders(c, headers)
+
+ c.Status(200)
+}
+
+func (s *SubHandler) addHeaders(c *gin.Context, headers []string) {
+ c.Writer.Header().Set("Subscription-Userinfo", headers[0])
+ c.Writer.Header().Set("Profile-Update-Interval", headers[1])
+ c.Writer.Header().Set("Profile-Title", headers[2])
+}
diff --git a/sub/subService.go b/sub/subService.go
new file mode 100644
index 0000000..93cce41
--- /dev/null
+++ b/sub/subService.go
@@ -0,0 +1,93 @@
+package sub
+
+import (
+ "encoding/base64"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/alireza0/s-ui/database"
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/service"
+ "github.com/alireza0/s-ui/util"
+)
+
+type SubService struct {
+ service.SettingService
+ LinkService
+}
+
+func (s *SubService) GetSubs(subId string) (*string, []string, error) {
+ var err error
+
+ client, err := s.getClientBySubId(subId)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ clientInfo := ""
+ subShowInfo, _ := s.SettingService.GetSubShowInfo()
+ if subShowInfo {
+ clientInfo = s.getClientInfo(client)
+ }
+
+ linksArray := s.LinkService.GetLinks(&client.Links, "all", clientInfo)
+ result := strings.Join(linksArray, "\n")
+
+ headers := s.getClientHeaders(client)
+
+ subEncode, _ := s.SettingService.GetSubEncode()
+ if subEncode {
+ result = base64.StdEncoding.EncodeToString([]byte(result))
+ }
+
+ return &result, headers, nil
+}
+
+func (j *SubService) getClientBySubId(subId string) (*model.Client, error) {
+ db := database.GetDB()
+ client := &model.Client{}
+ err := db.Model(model.Client{}).Where("enable = true and name = ?", subId).First(client).Error
+ if err != nil {
+ return nil, err
+ }
+ return client, nil
+}
+
+func (s *SubService) getClientHeaders(client *model.Client) []string {
+ updateInterval, _ := s.SettingService.GetSubUpdates()
+ return util.GetHeaders(client, updateInterval)
+}
+
+func (s *SubService) getClientInfo(c *model.Client) string {
+ now := time.Now().Unix()
+
+ var result []string
+ if vol := c.Volume - (c.Up + c.Down); vol > 0 {
+ result = append(result, fmt.Sprintf("%s%s", s.formatTraffic(vol), "📊"))
+ }
+ if c.Expiry > 0 {
+ result = append(result, fmt.Sprintf("%d%s⏳", (c.Expiry-now)/86400, "Days"))
+ }
+ if len(result) > 0 {
+ return " " + strings.Join(result, " ")
+ } else {
+ return " ♾"
+ }
+}
+
+func (s *SubService) formatTraffic(trafficBytes int64) string {
+ if trafficBytes < 1024 {
+ return fmt.Sprintf("%.2fB", float64(trafficBytes)/float64(1))
+ } else if trafficBytes < (1024 * 1024) {
+ return fmt.Sprintf("%.2fKB", float64(trafficBytes)/float64(1024))
+ } else if trafficBytes < (1024 * 1024 * 1024) {
+ return fmt.Sprintf("%.2fMB", float64(trafficBytes)/float64(1024*1024))
+ } else if trafficBytes < (1024 * 1024 * 1024 * 1024) {
+ return fmt.Sprintf("%.2fGB", float64(trafficBytes)/float64(1024*1024*1024))
+ } else if trafficBytes < (1024 * 1024 * 1024 * 1024 * 1024) {
+ return fmt.Sprintf("%.2fTB", float64(trafficBytes)/float64(1024*1024*1024*1024))
+ } else {
+ return fmt.Sprintf("%.2fEB", float64(trafficBytes)/float64(1024*1024*1024*1024*1024))
+ }
+}
diff --git a/util/base64.go b/util/base64.go
new file mode 100644
index 0000000..13218c0
--- /dev/null
+++ b/util/base64.go
@@ -0,0 +1,20 @@
+package util
+
+import "encoding/base64"
+
+// Function to return decoded bytes if a string is Base64 encoded
+func StrOrBase64Encoded(str string) string {
+ decoded, err := base64.StdEncoding.DecodeString(str)
+ if err == nil {
+ return string(decoded)
+ }
+ return str
+}
+
+func B64StrToByte(str string) ([]byte, error) {
+ return base64.StdEncoding.DecodeString(str)
+}
+
+func ByteToB64Str(b []byte) string {
+ return base64.StdEncoding.EncodeToString(b)
+}
diff --git a/util/common/array.go b/util/common/array.go
new file mode 100644
index 0000000..43d3b79
--- /dev/null
+++ b/util/common/array.go
@@ -0,0 +1,45 @@
+package common
+
+// UnionUintArray returns a new unique slice that contains all elements from both input slices
+func UnionUintArray(a []uint, b []uint) []uint {
+ m := make(map[uint]bool)
+ for _, v := range a {
+ m[v] = true
+ }
+ for _, v := range b {
+ m[v] = true
+ }
+ var res []uint
+ for k := range m {
+ res = append(res, k)
+ }
+ return res
+}
+
+// Find different elements in two slices
+// Returns elements in 'a' that are not in 'b' and elements in 'b' that are not in 'a'
+func DiffUintArray(a []uint, b []uint) []uint {
+ different := []uint{}
+ set := make(map[uint]bool)
+
+ for _, item := range a {
+ set[item] = true
+ }
+ for _, item := range b {
+ if !set[item] {
+ different = append(different, item)
+ }
+ }
+
+ set = make(map[uint]bool)
+ for _, item := range b {
+ set[item] = true
+ }
+ for _, item := range a {
+ if !set[item] {
+ different = append(different, item)
+ }
+ }
+
+ return different
+}
diff --git a/util/common/err.go b/util/common/err.go
new file mode 100644
index 0000000..9edba83
--- /dev/null
+++ b/util/common/err.go
@@ -0,0 +1,28 @@
+package common
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/alireza0/s-ui/logger"
+)
+
+func NewErrorf(format string, a ...interface{}) error {
+ msg := fmt.Sprintf(format, a...)
+ return errors.New(msg)
+}
+
+func NewError(a ...interface{}) error {
+ msg := fmt.Sprintln(a...)
+ return errors.New(msg)
+}
+
+func Recover(msg string) interface{} {
+ panicErr := recover()
+ if panicErr != nil {
+ if msg != "" {
+ logger.Error(msg, "panic:", panicErr)
+ }
+ }
+ return panicErr
+}
diff --git a/util/common/random.go b/util/common/random.go
new file mode 100644
index 0000000..55145fd
--- /dev/null
+++ b/util/common/random.go
@@ -0,0 +1,55 @@
+package common
+
+import (
+ crand "crypto/rand"
+ "math/big"
+ mrand "math/rand"
+ "sync"
+ "time"
+)
+
+var (
+ allSeq []rune = []rune{
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
+ }
+
+ fallbackRand = mrand.New(mrand.NewSource(time.Now().UnixNano()))
+ fallbackMu = sync.Mutex{}
+)
+
+func Random(n int) string {
+ if n <= 0 || len(allSeq) == 0 {
+ return ""
+ }
+ result := make([]rune, n)
+ maxBig := big.NewInt(int64(len(allSeq)))
+ for i := 0; i < n; i++ {
+ num, err := crand.Int(crand.Reader, maxBig)
+ if err != nil {
+ // fallback
+ fallbackMu.Lock()
+ result[i] = allSeq[fallbackRand.Intn(len(allSeq))]
+ fallbackMu.Unlock()
+ continue
+ }
+ result[i] = allSeq[int(num.Int64())]
+ }
+ return string(result)
+}
+
+func RandomInt(n int) int {
+ if n <= 0 {
+ return 0
+ }
+ max := big.NewInt(int64(n))
+ result, err := crand.Int(crand.Reader, max)
+ if err != nil {
+ // fallback
+ fallbackMu.Lock()
+ defer fallbackMu.Unlock()
+ return fallbackRand.Intn(n)
+ }
+ return int(result.Int64())
+}
diff --git a/util/genLink.go b/util/genLink.go
new file mode 100644
index 0000000..6187372
--- /dev/null
+++ b/util/genLink.go
@@ -0,0 +1,615 @@
+package util
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/alireza0/s-ui/database/model"
+ "github.com/alireza0/s-ui/util/common"
+)
+
+var InboundTypeWithLink = []string{"socks", "http", "mixed", "shadowsocks", "naive", "hysteria", "hysteria2", "anytls", "tuic", "vless", "trojan", "vmess"}
+
+type LinkParam struct {
+ Key string
+ Value string
+}
+
+func LinkGenerator(clientConfig json.RawMessage, i *model.Inbound, hostname string) []string {
+ inbound, err := i.MarshalFull()
+ if err != nil {
+ return []string{}
+ }
+
+ var tls map[string]interface{}
+ if i.TlsId > 0 {
+ tls = prepareTls(i.Tls)
+ }
+
+ var userConfig map[string]map[string]interface{}
+ if err := json.Unmarshal(clientConfig, &userConfig); err != nil {
+ return []string{}
+ }
+
+ var Addrs []map[string]interface{}
+ if err := json.Unmarshal(i.Addrs, &Addrs); err != nil {
+ return []string{}
+ }
+ if len(Addrs) == 0 {
+ Addrs = append(Addrs, map[string]interface{}{
+ "server": hostname,
+ "server_port": (*inbound)["listen_port"],
+ "remark": i.Tag,
+ })
+ if i.TlsId > 0 {
+ Addrs[0]["tls"] = tls
+ }
+ } else {
+ for index, addr := range Addrs {
+ addrRemark, _ := addr["remark"].(string)
+ Addrs[index]["remark"] = i.Tag + addrRemark
+ if i.TlsId > 0 {
+ newTls := map[string]interface{}{}
+ for k, v := range tls {
+ newTls[k] = v
+ }
+
+ // Override tls
+ if addrTls, ok := addr["tls"].(map[string]interface{}); ok {
+ for k, v := range addrTls {
+ newTls[k] = v
+ }
+ }
+ Addrs[index]["tls"] = newTls
+ }
+ }
+ }
+
+ switch i.Type {
+ case "socks":
+ return socksLink(userConfig["socks"], Addrs)
+ case "http":
+ return httpLink(userConfig["http"], Addrs)
+ case "mixed":
+ return append(
+ socksLink(userConfig["socks"], Addrs),
+ httpLink(userConfig["http"], Addrs)...,
+ )
+ case "shadowsocks":
+ return shadowsocksLink(userConfig, *inbound, Addrs)
+ case "naive":
+ return naiveLink(userConfig["naive"], *inbound, Addrs)
+ case "hysteria":
+ return hysteriaLink(userConfig["hysteria"], *inbound, Addrs)
+ case "hysteria2":
+ return hysteria2Link(userConfig["hysteria2"], *inbound, Addrs)
+ case "tuic":
+ return tuicLink(userConfig["tuic"], *inbound, Addrs)
+ case "vless":
+ return vlessLink(userConfig["vless"], *inbound, Addrs)
+ case "anytls":
+ return anytlsLink(userConfig["anytls"], Addrs)
+ case "trojan":
+ return trojanLink(userConfig["trojan"], *inbound, Addrs)
+ case "vmess":
+ return vmessLink(userConfig["vmess"], *inbound, Addrs)
+ }
+
+ return []string{}
+}
+
+func prepareTls(t *model.Tls) map[string]interface{} {
+ var iTls, oTls map[string]interface{}
+ if err := json.Unmarshal(t.Client, &oTls); err != nil {
+ return nil
+ }
+ if err := json.Unmarshal(t.Server, &iTls); err != nil {
+ return nil
+ }
+
+ for k, v := range iTls {
+ switch k {
+ case "enabled", "server_name", "alpn":
+ oTls[k] = v
+ case "reality":
+ reality := v.(map[string]interface{})
+ clientReality := oTls["reality"].(map[string]interface{})
+ clientReality["enabled"] = reality["enabled"]
+ if shortIDs, hasSIds := reality["short_id"].([]interface{}); hasSIds && len(shortIDs) > 0 {
+ clientReality["short_id"] = shortIDs[common.RandomInt(len(shortIDs))]
+ }
+ oTls["reality"] = clientReality
+ }
+ }
+ return oTls
+}
+
+func socksLink(userConfig map[string]interface{}, addrs []map[string]interface{}) []string {
+ var links []string
+ for _, addr := range addrs {
+ links = append(links, fmt.Sprintf("socks5://%s:%s@%s:%d", userConfig["username"], userConfig["password"], addr["server"].(string), uint(addr["server_port"].(float64))))
+ }
+ return links
+}
+
+func httpLink(userConfig map[string]interface{}, addrs []map[string]interface{}) []string {
+ var links []string
+ protocol := "http"
+ for _, addr := range addrs {
+ if addr["tls"] != nil {
+ protocol = "https"
+ }
+ links = append(links, fmt.Sprintf("%s://%s:%s@%s:%d", protocol, userConfig["username"], userConfig["password"], addr["server"].(string), uint(addr["server_port"].(float64))))
+ }
+ return links
+}
+
+func shadowsocksLink(
+ userConfig map[string]map[string]interface{},
+ inbound map[string]interface{},
+ addrs []map[string]interface{}) []string {
+
+ var userPass []string
+ method, _ := inbound["method"].(string)
+ if strings.HasPrefix(method, "2022") {
+ inbPass, _ := inbound["password"].(string)
+ userPass = append(userPass, inbPass)
+ }
+ var pass string
+ if method == "2022-blake3-aes-128-gcm" {
+ pass, _ = userConfig["shadowsocks16"]["password"].(string)
+ } else {
+ pass, _ = userConfig["shadowsocks"]["password"].(string)
+ }
+ userPass = append(userPass, pass)
+
+ uriBase := fmt.Sprintf("ss://%s", toBase64([]byte(fmt.Sprintf("%s:%s", method, strings.Join(userPass, ":")))))
+
+ var links []string
+ for _, addr := range addrs {
+ port, _ := addr["server_port"].(float64)
+ links = append(links, fmt.Sprintf("%s@%s:%.0f#%s", uriBase, addr["server"].(string), port, addr["remark"].(string)))
+ }
+ return links
+}
+
+func naiveLink(
+ userConfig map[string]interface{},
+ inbound map[string]interface{},
+ addrs []map[string]interface{}) []string {
+
+ password, _ := userConfig["password"].(string)
+ username, _ := userConfig["username"].(string)
+
+ baseUri := "http2://"
+ var links []string
+
+ for _, addr := range addrs {
+ var params []LinkParam
+ params = append(params, LinkParam{"padding", "1"})
+ if tls, ok := addr["tls"].(map[string]interface{}); ok {
+ if sni, ok := tls["server_name"].(string); ok {
+ params = append(params, LinkParam{"peer", sni})
+ }
+ if alpn, ok := tls["alpn"].([]interface{}); ok {
+ alpnList := make([]string, len(alpn))
+ for i, v := range alpn {
+ alpnList[i] = v.(string)
+ }
+ params = append(params, LinkParam{"alpn", strings.Join(alpnList, ",")})
+ }
+ if insecure, ok := tls["insecure"].(bool); ok && insecure {
+ params = append(params, LinkParam{"insecure", "1"})
+ }
+ }
+ if tfo, ok := inbound["tcp_fast_open"].(bool); ok && tfo {
+ params = append(params, LinkParam{"tfo", "1"})
+ } else {
+ params = append(params, LinkParam{"tfo", "0"})
+ }
+
+ port, _ := addr["server_port"].(float64)
+ uri := baseUri + toBase64([]byte(fmt.Sprintf("%s:%s@%s:%.0f", username, password, addr["server"].(string), port)))
+ links = append(links, addParams(uri, params, addr["remark"].(string)))
+ }
+ return links
+}
+
+func hysteriaLink(
+ userConfig map[string]interface{},
+ inbound map[string]interface{},
+ addrs []map[string]interface{}) []string {
+
+ baseUri := "hysteria://"
+ var links []string
+
+ for _, addr := range addrs {
+ var params []LinkParam
+ if upmbps, ok := inbound["up_mbps"].(float64); ok {
+ params = append(params, LinkParam{"downmbps", fmt.Sprintf("%.0f", upmbps)})
+ }
+ if downmbps, ok := inbound["down_mbps"].(float64); ok {
+ params = append(params, LinkParam{"upmbps", fmt.Sprintf("%.0f", downmbps)})
+ }
+ if auth, ok := userConfig["auth_str"].(string); ok {
+ params = append(params, LinkParam{"auth", auth})
+ }
+ if tls, ok := addr["tls"].(map[string]interface{}); ok {
+ getTlsParams(¶ms, tls, "insecure")
+ }
+ if obfs, ok := inbound["obfs"].(string); ok {
+ params = append(params, LinkParam{"obfs", obfs})
+ }
+ if tfo, ok := inbound["tcp_fast_open"].(bool); ok && tfo {
+ params = append(params, LinkParam{"fastopen", "1"})
+ } else {
+ params = append(params, LinkParam{"fastopen", "0"})
+ }
+ var outJson map[string]interface{}
+ if err := json.Unmarshal(inbound["out_json"].(json.RawMessage), &outJson); err != nil {
+ return []string{} // Handle error
+ }
+ if mport, ok := outJson["server_ports"].([]interface{}); ok {
+ mportList := make([]string, len(mport))
+ for i, v := range mport {
+ mportList[i] = v.(string)
+ }
+ params = append(params, LinkParam{"mport", strings.Join(mportList, ",")})
+ }
+
+ port, _ := addr["server_port"].(float64)
+ uri := fmt.Sprintf("%s%s:%.0f", baseUri, addr["server"].(string), port)
+ links = append(links, addParams(uri, params, addr["remark"].(string)))
+ }
+
+ return links
+}
+
+func hysteria2Link(
+ userConfig map[string]interface{},
+ inbound map[string]interface{},
+ addrs []map[string]interface{}) []string {
+
+ password, _ := userConfig["password"].(string)
+ baseUri := fmt.Sprintf("%s%s@", "hysteria2://", password)
+ var links []string
+
+ for _, addr := range addrs {
+ var params []LinkParam
+ if upmbps, ok := inbound["up_mbps"].(float64); ok {
+ params = append(params, LinkParam{"downmbps", fmt.Sprintf("%.0f", upmbps)})
+ }
+ if downmbps, ok := inbound["down_mbps"].(float64); ok {
+ params = append(params, LinkParam{"upmbps", fmt.Sprintf("%.0f", downmbps)})
+ }
+ if tls, ok := addr["tls"].(map[string]interface{}); ok {
+ getTlsParams(¶ms, tls, "insecure")
+ }
+ if obfs, ok := inbound["obfs"].(map[string]interface{}); ok {
+ if obfsType, ok := obfs["type"].(string); ok {
+ params = append(params, LinkParam{"obfs", obfsType})
+ }
+ if obfsPassword, ok := obfs["password"].(string); ok {
+ params = append(params, LinkParam{"obfs-password", obfsPassword})
+ }
+ }
+ if tfo, ok := inbound["tcp_fast_open"].(bool); ok && tfo {
+ params = append(params, LinkParam{"fastopen", "1"})
+ } else {
+ params = append(params, LinkParam{"fastopen", "0"})
+ }
+ var outJson map[string]interface{}
+ if err := json.Unmarshal(inbound["out_json"].(json.RawMessage), &outJson); err != nil {
+ return []string{} // Handle error
+ }
+ if mport, ok := outJson["server_ports"].([]interface{}); ok {
+ mportList := make([]string, len(mport))
+ for i, v := range mport {
+ mportList[i] = v.(string)
+ }
+ params = append(params, LinkParam{"mport", strings.Join(mportList, ",")})
+ }
+
+ port, _ := addr["server_port"].(float64)
+ uri := fmt.Sprintf("%s%s:%.0f", baseUri, addr["server"].(string), port)
+ links = append(links, addParams(uri, params, addr["remark"].(string)))
+ }
+
+ return links
+}
+
+func anytlsLink(
+ userConfig map[string]interface{},
+ addrs []map[string]interface{}) []string {
+
+ password, _ := userConfig["password"].(string)
+ baseUri := fmt.Sprintf("%s%s@", "anytls://", password)
+ var links []string
+
+ for _, addr := range addrs {
+ var params []LinkParam
+ if tls, ok := addr["tls"].(map[string]interface{}); ok {
+ getTlsParams(¶ms, tls, "insecure")
+ }
+
+ port, _ := addr["server_port"].(float64)
+ uri := fmt.Sprintf("%s%s:%.0f", baseUri, addr["server"].(string), port)
+ links = append(links, addParams(uri, params, addr["remark"].(string)))
+ }
+
+ return links
+}
+
+func tuicLink(
+ userConfig map[string]interface{},
+ inbound map[string]interface{},
+ addrs []map[string]interface{}) []string {
+
+ password, _ := userConfig["password"].(string)
+ uuid, _ := userConfig["uuid"].(string)
+ baseUri := fmt.Sprintf("%s%s:%s@", "tuic://", uuid, password)
+ var links []string
+
+ for _, addr := range addrs {
+ var params []LinkParam
+ if tls, ok := addr["tls"].(map[string]interface{}); ok {
+ getTlsParams(¶ms, tls, "insecure")
+ }
+ if congestionControl, ok := inbound["congestion_control"].(string); ok {
+ params = append(params, LinkParam{"congestion_control", congestionControl})
+ }
+
+ port, _ := addr["server_port"].(float64)
+ uri := fmt.Sprintf("%s%s:%.0f", baseUri, addr["server"].(string), port)
+ links = append(links, addParams(uri, params, addr["remark"].(string)))
+ }
+
+ return links
+}
+
+func vlessLink(
+ userConfig map[string]interface{},
+ inbound map[string]interface{},
+ addrs []map[string]interface{}) []string {
+
+ uuid, _ := userConfig["uuid"].(string)
+ baseParams := getTransportParams(inbound["transport"])
+ var links []string
+
+ for _, addr := range addrs {
+ params := make([]LinkParam, len(baseParams))
+ copy(params, baseParams)
+ if tls, ok := addr["tls"].(map[string]interface{}); ok && tls["enabled"].(bool) {
+ getTlsParams(¶ms, tls, "allowInsecure")
+ if flow, ok := userConfig["flow"].(string); ok {
+ params = append(params, LinkParam{"flow", flow})
+ }
+ }
+ port, _ := addr["server_port"].(float64)
+ uri := fmt.Sprintf("vless://%s@%s:%.0f", uuid, addr["server"].(string), port)
+ uri = addParams(uri, params, addr["remark"].(string))
+ links = append(links, uri)
+ }
+
+ return links
+}
+
+func trojanLink(
+ userConfig map[string]interface{},
+ inbound map[string]interface{},
+ addrs []map[string]interface{}) []string {
+ password, _ := userConfig["password"].(string)
+ baseParams := getTransportParams(inbound["transport"])
+ var links []string
+
+ for _, addr := range addrs {
+ params := make([]LinkParam, len(baseParams))
+ copy(params, baseParams)
+ if tls, ok := addr["tls"].(map[string]interface{}); ok && tls["enabled"].(bool) {
+ getTlsParams(¶ms, tls, "allowInsecure")
+ }
+ port, _ := addr["server_port"].(float64)
+ uri := fmt.Sprintf("trojan://%s@%s:%.0f", password, addr["server"].(string), port)
+ uri = addParams(uri, params, addr["remark"].(string))
+ links = append(links, uri)
+ }
+
+ return links
+}
+
+func vmessLink(
+ userConfig map[string]interface{},
+ inbound map[string]interface{},
+ addrs []map[string]interface{}) []string {
+
+ uuid, _ := userConfig["uuid"].(string)
+ transportParams := getTransportParams(inbound["transport"])
+ var links []string
+
+ baseParams := map[string]interface{}{
+ "v": "2",
+ "id": uuid,
+ "aid": 0,
+ }
+
+ var net, typ, host, path string
+ for _, p := range transportParams {
+ switch p.Key {
+ case "type":
+ net = p.Value
+ case "host":
+ host = p.Value
+ case "path":
+ path = p.Value
+ }
+ }
+
+ if net == "http" || net == "tcp" {
+ baseParams["net"] = "tcp"
+ if net == "http" {
+ typ = "http"
+ }
+ } else {
+ baseParams["net"] = net
+ }
+
+ for _, addr := range addrs {
+ obj := make(map[string]interface{})
+ for k, v := range baseParams {
+ obj[k] = v
+ }
+
+ obj["add"], _ = addr["server"].(string)
+ port, _ := addr["server_port"].(float64)
+ obj["port"] = fmt.Sprintf("%.0f", port)
+ obj["ps"], _ = addr["remark"].(string)
+ if typ != "" {
+ obj["type"] = typ
+ }
+ if host != "" {
+ obj["host"] = host
+ }
+ if path != "" {
+ obj["path"] = path
+ }
+ populateVmessTlsParams(obj, addr["tls"])
+
+ jsonStr, _ := json.Marshal(obj)
+
+ uri := fmt.Sprintf("vmess://%s", toBase64(jsonStr))
+ links = append(links, uri)
+ }
+ return links
+}
+
+func populateVmessTlsParams(obj map[string]interface{}, tlsConfig interface{}) {
+ if tlsMap, ok := tlsConfig.(map[string]interface{}); ok && tlsMap["enabled"].(bool) {
+ obj["tls"] = "tls"
+ var tlsParams []LinkParam
+ getTlsParams(&tlsParams, tlsMap, "allowInsecure")
+ for _, p := range tlsParams {
+ switch p.Key {
+ case "security":
+ // ignore, as "tls" is already set
+ case "allowInsecure":
+ obj["allowInsecure"] = 1
+ case "sni":
+ obj["sni"] = p.Value
+ case "fp":
+ obj["fp"] = p.Value
+ case "alpn":
+ obj["alpn"] = p.Value
+ }
+ }
+ } else {
+ obj["tls"] = "none"
+ }
+}
+
+func toBase64(d []byte) string {
+ return base64.StdEncoding.EncodeToString(d)
+}
+
+func addParams(uri string, params []LinkParam, remark string) string {
+ URL, _ := url.Parse(uri)
+ var q []string
+ for _, p := range params {
+ switch p.Key {
+ case "mport", "alpn":
+ q = append(q, fmt.Sprintf("%s=%s", p.Key, p.Value))
+ default:
+ q = append(q, fmt.Sprintf("%s=%s", p.Key, url.QueryEscape(p.Value)))
+ }
+ }
+ URL.RawQuery = strings.Join(q, "&")
+ URL.Fragment = remark
+ return URL.String()
+}
+
+func getTransportParams(t interface{}) []LinkParam {
+ var params []LinkParam
+ trasport, _ := t.(map[string]interface{})
+ var transportType string
+ if tt, ok := trasport["type"].(string); ok {
+ transportType = tt
+ } else {
+ transportType = "tcp"
+ }
+ params = append(params, LinkParam{"type", transportType})
+ if transportType == "tcp" {
+ return params
+ }
+
+ switch transportType {
+ case "http":
+ if host, ok := trasport["host"].([]interface{}); ok {
+ var hosts []string
+ for _, v := range host {
+ hosts = append(hosts, v.(string))
+ }
+ params = append(params, LinkParam{"host", strings.Join(hosts, ",")})
+ }
+ if path, ok := trasport["path"].(string); ok {
+ params = append(params, LinkParam{"path", path})
+ }
+ case "ws":
+ if path, ok := trasport["path"].(string); ok {
+ params = append(params, LinkParam{"path", path})
+ }
+ if headers, ok := trasport["headers"].(map[string]interface{}); ok {
+ if host, ok := headers["Host"].(string); ok {
+ params = append(params, LinkParam{"host", host})
+ }
+ }
+ case "grpc":
+ if serviceName, ok := trasport["service_name"].(string); ok {
+ params = append(params, LinkParam{"serviceName", serviceName})
+ }
+ case "httpupgrade":
+ if host, ok := trasport["host"].(string); ok {
+ params = append(params, LinkParam{"host", host})
+ }
+ if path, ok := trasport["path"].(string); ok {
+ params = append(params, LinkParam{"path", path})
+ }
+ }
+ return params
+}
+
+func getTlsParams(params *[]LinkParam, tls map[string]interface{}, insecureKey string) {
+ if reality, ok := tls["reality"].(map[string]interface{}); ok && reality["enabled"].(bool) {
+ *params = append(*params, LinkParam{"security", "reality"})
+ if pbk, ok := reality["public_key"].(string); ok {
+ *params = append(*params, LinkParam{"pbk", pbk})
+ }
+ if sid, ok := reality["short_id"].(string); ok {
+ *params = append(*params, LinkParam{"sid", sid})
+ }
+ } else {
+ *params = append(*params, LinkParam{"security", "tls"})
+ if insecure, ok := tls["insecure"].(bool); ok && insecure {
+ *params = append(*params, LinkParam{insecureKey, "1"})
+ }
+ if disableSni, ok := tls["disable_sni"].(bool); ok && disableSni {
+ *params = append(*params, LinkParam{"disable_sni", "1"})
+ }
+ }
+ if utls, ok := tls["utls"].(map[string]interface{}); ok {
+ if fingerprint, ok := utls["fingerprint"].(string); ok {
+ *params = append(*params, LinkParam{"fp", fingerprint})
+ }
+ }
+ if sni, ok := tls["server_name"].(string); ok {
+ *params = append(*params, LinkParam{"sni", sni})
+ }
+ if alpn, ok := tls["alpn"].([]interface{}); ok {
+ alpnList := make([]string, len(alpn))
+ for i, v := range alpn {
+ alpnList[i] = v.(string)
+ }
+ *params = append(*params, LinkParam{"alpn", strings.Join(alpnList, ",")})
+ }
+}
diff --git a/util/linkToJson.go b/util/linkToJson.go
new file mode 100644
index 0000000..068b540
--- /dev/null
+++ b/util/linkToJson.go
@@ -0,0 +1,581 @@
+package util
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/alireza0/s-ui/util/common"
+)
+
+func GetOutbound(uri string, i int) (*map[string]interface{}, string, error) {
+ u, err := url.Parse(uri)
+ if err == nil {
+ switch u.Scheme {
+ case "vmess":
+ return vmess(u.Host, i)
+ case "vless":
+ return vless(u, i)
+ case "trojan":
+ return trojan(u, i)
+ case "hy", "hysteria":
+ return hy(u, i)
+ case "hy2", "hysteria2":
+ return hy2(u, i)
+ case "anytls":
+ return anytls(u, i)
+ case "tuic":
+ return tuic(u, i)
+ case "ss", "shadowsocks":
+ return ss(u, i)
+ case "naive+https", "naive+quic", "http2":
+ return parseNaiveLink(u, i)
+ }
+ }
+ return nil, "", common.NewError("Unsupported link format")
+}
+
+func vmess(data string, i int) (*map[string]interface{}, string, error) {
+ dataByte, err := B64StrToByte(data)
+ if err != nil {
+ return nil, "", err
+ }
+ var dataJson map[string]interface{}
+ err = json.Unmarshal(dataByte, &dataJson)
+ if err != nil {
+ return nil, "", err
+ }
+ transport := map[string]interface{}{}
+ tp_net, _ := dataJson["net"].(string)
+ tp_type, _ := dataJson["type"].(string)
+ tp_host, _ := dataJson["host"].(string)
+ tp_path, _ := dataJson["path"].(string)
+ switch strings.ToLower(tp_net) {
+ case "tcp", "":
+ if tp_type == "http" {
+ transport["type"] = tp_type
+ if len(tp_host) > 0 {
+ transport["host"] = strings.Split(tp_host, ",")
+ }
+ transport["path"] = tp_path
+ }
+ case "http", "h2":
+ transport["type"] = "http"
+ if len(tp_host) > 0 {
+ transport["host"] = strings.Split(tp_host, ",")
+ }
+ transport["path"] = tp_path
+ case "ws":
+ transport["type"] = tp_net
+ transport["path"] = tp_path
+ transport["early_data_header_name"] = "Sec-WebSocket-Protocol"
+ if len(tp_host) > 0 {
+ transport["headers"] = map[string]interface{}{
+ "Host": tp_host,
+ }
+ }
+ case "quic":
+ transport["type"] = tp_net
+ case "grpc":
+ transport["type"] = tp_net
+ transport["service_name"] = tp_path
+ case "httpupgrade":
+ transport["type"] = tp_net
+ transport["path"] = tp_path
+ transport["host"] = tp_host
+ default:
+ return nil, "", common.NewError("Invalid vmess")
+ }
+ tls := map[string]interface{}{}
+ vmess_tls, _ := dataJson["tls"].(string)
+ if vmess_tls == "tls" {
+ tls["enabled"] = true
+ tls_sni, _ := dataJson["sni"].(string)
+ tls_alpn, _ := dataJson["alpn"].(string)
+ _, tls_insecure := dataJson["allowInsecure"]
+ tls_fp, _ := dataJson["fp"].(string)
+ if len(tls_sni) > 0 {
+ tls["server_name"] = tls_sni
+ }
+ if len(tls_alpn) > 0 {
+ tls["alpn"] = strings.Split(tls_alpn, ",")
+ }
+ if tls_insecure {
+ tls["insecure"] = true
+ }
+ if len(tls_fp) > 0 {
+ tls["utls"] = map[string]interface{}{
+ "enabled": true,
+ "fingerprint": tls_fp,
+ }
+ }
+ }
+ tag, _ := dataJson["ps"].(string)
+ if i > 0 {
+ tag = fmt.Sprintf("%d.%s", i, tag)
+ }
+ alter_id := 0
+ if aid, ok := dataJson["aid"].(float64); ok {
+ alter_id = int(aid)
+ }
+ vmess := map[string]interface{}{
+ "type": "vmess",
+ "tag": tag,
+ "server": dataJson["add"],
+ "server_port": dataJson["port"],
+ "uuid": dataJson["id"],
+ "security": "auto",
+ "alter_id": alter_id,
+ "tls": tls,
+ "transport": transport,
+ }
+ return &vmess, tag, err
+}
+
+func vless(u *url.URL, i int) (*map[string]interface{}, string, error) {
+ query, _ := url.ParseQuery(u.RawQuery)
+ security := query.Get("security")
+ host, portStr, _ := net.SplitHostPort(u.Host)
+ port := 80
+ if len(portStr) > 0 {
+ port, _ = strconv.Atoi(portStr)
+ } else {
+ if security == "tls" || security == "reality" {
+ port = 443
+ }
+ }
+ tp_type := query.Get("type")
+ tag := u.Fragment
+ if i > 0 {
+ tag = fmt.Sprintf("%d.%s", i, u.Fragment)
+ }
+ vless := map[string]interface{}{
+ "type": "vless",
+ "tag": tag,
+ "server": host,
+ "server_port": port,
+ "uuid": u.User.Username(),
+ "flow": query.Get("flow"),
+ "tls": getTls(security, &query),
+ "transport": getTransport(tp_type, &query),
+ }
+ return &vless, tag, nil
+}
+
+func trojan(u *url.URL, i int) (*map[string]interface{}, string, error) {
+ query, _ := url.ParseQuery(u.RawQuery)
+ security := query.Get("security")
+ host, portStr, _ := net.SplitHostPort(u.Host)
+ port := 80
+ if len(portStr) > 0 {
+ port, _ = strconv.Atoi(portStr)
+ } else {
+ if security == "tls" || security == "reality" {
+ port = 443
+ }
+ }
+ tp_type := query.Get("type")
+ tag := u.Fragment
+ if i > 0 {
+ tag = fmt.Sprintf("%d.%s", i, u.Fragment)
+ }
+ trojan := map[string]interface{}{
+ "type": "trojan",
+ "tag": tag,
+ "server": host,
+ "server_port": port,
+ "password": u.User.Username(),
+ "tls": getTls(security, &query),
+ "transport": getTransport(tp_type, &query),
+ }
+ return &trojan, tag, nil
+}
+
+func hy(u *url.URL, i int) (*map[string]interface{}, string, error) {
+ query, _ := url.ParseQuery(u.RawQuery)
+ host, portStr, _ := net.SplitHostPort(u.Host)
+ port := 443
+ if len(portStr) > 0 {
+ port, _ = strconv.Atoi(portStr)
+ }
+
+ security := query.Get("security")
+ if len(security) == 0 {
+ security = "tls"
+ }
+
+ tag := u.Fragment
+ if i > 0 {
+ tag = fmt.Sprintf("%d.%s", i, u.Fragment)
+ }
+ hy := map[string]interface{}{
+ "type": "hysteria",
+ "tag": tag,
+ "server": host,
+ "server_port": port,
+ "obfs": query.Get("obfsParam"),
+ "auth_str": query.Get("auth"),
+ "tls": getTls(security, &query),
+ }
+ down, _ := strconv.Atoi(query.Get("downmbps"))
+ up, _ := strconv.Atoi(query.Get("upmbps"))
+ recv_window_conn, _ := strconv.Atoi(query.Get("recv_window_conn"))
+ recv_window, _ := strconv.Atoi(query.Get("recv_window"))
+ if down > 0 {
+ hy["down_mbps"] = down
+ }
+ if up > 0 {
+ hy["up_mbps"] = up
+ }
+ if recv_window_conn > 0 {
+ hy["recv_window_conn"] = recv_window_conn
+ }
+ if recv_window > 0 {
+ hy["recv_window"] = recv_window
+ }
+ return &hy, tag, nil
+}
+
+func hy2(u *url.URL, i int) (*map[string]interface{}, string, error) {
+ query, _ := url.ParseQuery(u.RawQuery)
+ host, portStr, _ := net.SplitHostPort(u.Host)
+ port := 443
+ if len(portStr) > 0 {
+ port, _ = strconv.Atoi(portStr)
+ }
+
+ security := query.Get("security")
+ if len(security) == 0 {
+ security = "tls"
+ }
+
+ tag := u.Fragment
+ if i > 0 {
+ tag = fmt.Sprintf("%d.%s", i, u.Fragment)
+ }
+ hy2 := map[string]interface{}{
+ "type": "hysteria2",
+ "tag": tag,
+ "server": host,
+ "server_port": port,
+ "password": u.User.Username(),
+ "tls": getTls(security, &query),
+ }
+ down, _ := strconv.Atoi(query.Get("downmbps"))
+ up, _ := strconv.Atoi(query.Get("upmbps"))
+ obfs := query.Get("obfs")
+ mport := strings.ReplaceAll(query.Get("mport"), "-", ":")
+ fastopen := query.Get("fastopen")
+ if down > 0 {
+ hy2["down_mbps"] = down
+ }
+ if up > 0 {
+ hy2["up_mbps"] = up
+ }
+ if obfs == "salamander" {
+ hy2["obfs"] = map[string]interface{}{
+ "type": "salamander",
+ "password": query.Get("obfs-password"),
+ }
+ }
+ if len(mport) > 0 {
+ hy2["server_ports"] = strings.Split(mport, ",")
+ }
+ if fastopen == "1" || fastopen == "true" {
+ hy2["fastopen"] = true
+ }
+ return &hy2, tag, nil
+}
+
+func anytls(u *url.URL, i int) (*map[string]interface{}, string, error) {
+ query, _ := url.ParseQuery(u.RawQuery)
+ host, portStr, _ := net.SplitHostPort(u.Host)
+ port := 443
+ if len(portStr) > 0 {
+ port, _ = strconv.Atoi(portStr)
+ }
+
+ security := query.Get("security")
+ if len(security) == 0 {
+ security = "tls"
+ }
+
+ tag := u.Fragment
+ if i > 0 {
+ tag = fmt.Sprintf("%d.%s", i, u.Fragment)
+ }
+ anytls := map[string]interface{}{
+ "type": "anytls",
+ "tag": tag,
+ "server": host,
+ "server_port": port,
+ "password": u.User.Username(),
+ "tls": getTls(security, &query),
+ }
+ return &anytls, tag, nil
+}
+
+func tuic(u *url.URL, i int) (*map[string]interface{}, string, error) {
+ query, _ := url.ParseQuery(u.RawQuery)
+ host, portStr, _ := net.SplitHostPort(u.Host)
+ port := 443
+ if len(portStr) > 0 {
+ port, _ = strconv.Atoi(portStr)
+ }
+
+ security := query.Get("security")
+ if len(security) == 0 {
+ security = "tls"
+ }
+
+ tag := u.Fragment
+ if i > 0 {
+ tag = fmt.Sprintf("%d.%s", i, u.Fragment)
+ }
+ password, _ := u.User.Password()
+ tuic := map[string]interface{}{
+ "type": "tuic",
+ "tag": tag,
+ "server": host,
+ "server_port": port,
+ "uuid": u.User.Username(),
+ "password": password,
+ "congestion_control": query.Get("congestion_control"),
+ "udp_relay_mode": query.Get("udp_relay_mode"),
+ "tls": getTls(security, &query),
+ }
+ return &tuic, tag, nil
+}
+
+func ss(u *url.URL, i int) (*map[string]interface{}, string, error) {
+ query, _ := url.ParseQuery(u.RawQuery)
+ host, portStr, _ := net.SplitHostPort(u.Host)
+ port := 443
+ if len(portStr) > 0 {
+ port, _ = strconv.Atoi(portStr)
+ }
+ method := u.User.Username()
+ password, ok := u.User.Password()
+ if !ok {
+ decrypted := StrOrBase64Encoded(method)
+ decrypted_arr := strings.Split(decrypted, ":")
+ if len(decrypted_arr) > 1 {
+ method = decrypted_arr[0]
+ password = strings.Join(decrypted_arr[1:], ":")
+ } else {
+ return nil, "", common.NewError("Unsupported shadowsocks")
+ }
+ }
+
+ tag := u.Fragment
+ if i > 0 {
+ tag = fmt.Sprintf("%d.%s", i, u.Fragment)
+ }
+ ss := map[string]interface{}{
+ "type": "shadowsocks",
+ "tag": tag,
+ "server": host,
+ "server_port": port,
+ "method": method,
+ "password": password,
+ }
+
+ v2ray_type := query.Get("type")
+ if len(v2ray_type) > 0 {
+ pl_arr := []string{}
+ host_header := query.Get("host")
+ if query.Get("security") == "tls" {
+ pl_arr = append(pl_arr, "tls")
+ }
+ if v2ray_type == "quic" {
+ pl_arr = append(pl_arr, "mode=quic")
+ }
+ if len(host_header) > 0 {
+ pl_arr = append(pl_arr, "host="+host_header)
+ }
+ ss["plugin"] = "v2ray-plugin"
+ ss["plugin_opts"] = strings.Join(pl_arr, ";")
+ }
+ plugin := query.Get("plugin")
+ if len(plugin) > 0 {
+ pl_arr := strings.Split(plugin, ";")
+ if len(pl_arr) > 0 {
+ ss["plugin"] = pl_arr[0]
+ ss["plugin_opts"] = strings.Join(pl_arr[1:], ";")
+ }
+ }
+ return &ss, tag, nil
+}
+
+func parseNaiveLink(u *url.URL, i int) (*map[string]interface{}, string, error) {
+ var host, portStr, username, password string
+ var port int
+
+ switch u.Scheme {
+ case "http2":
+ decoded := StrOrBase64Encoded(u.Hostname())
+ if idx := strings.Index(decoded, "@"); idx != -1 {
+ userInfo := decoded[:idx]
+ hostPort := decoded[idx+1:]
+ if idx2 := strings.Index(userInfo, ":"); idx2 != -1 {
+ username = userInfo[:idx2]
+ password = userInfo[idx2+1:]
+ } else {
+ username = userInfo
+ }
+ host, portStr, _ = net.SplitHostPort(hostPort)
+ if portStr != "" {
+ port, _ = strconv.Atoi(portStr)
+ } else {
+ port = 443
+ }
+ } else {
+ return nil, "", common.NewError("Invalid naive link (http2)")
+ }
+ case "naive+https", "naive+quic":
+ host, portStr, _ = net.SplitHostPort(u.Host)
+ if portStr != "" {
+ port, _ = strconv.Atoi(portStr)
+ } else {
+ port = 443
+ }
+ if u.User != nil {
+ username = u.User.Username()
+ password, _ = u.User.Password()
+ }
+ default:
+ return nil, "", common.NewError("Unsupported naive scheme")
+ }
+
+ tag := u.Fragment
+ if i > 0 {
+ tag = fmt.Sprintf("%d.%s", i, u.Fragment)
+ }
+ if tag == "" {
+ tag = fmt.Sprintf("naive-%d", i)
+ }
+
+ naive := map[string]interface{}{
+ "type": "naive",
+ "tag": tag,
+ "server": host,
+ "server_port": port,
+ "username": username,
+ "password": password,
+ "tls": map[string]interface{}{"enabled": true},
+ }
+
+ query := u.Query()
+ if peer := query.Get("peer"); peer != "" {
+ if tls, ok := naive["tls"].(map[string]interface{}); ok {
+ tls["server_name"] = peer
+ }
+ }
+ if insecure := query.Get("insecure"); insecure == "1" || insecure == "true" {
+ if tls, ok := naive["tls"].(map[string]interface{}); ok {
+ tls["insecure"] = true
+ }
+ }
+ if alpn := query.Get("alpn"); alpn != "" {
+ if tls, ok := naive["tls"].(map[string]interface{}); ok {
+ tls["alpn"] = strings.Split(alpn, ",")
+ }
+ }
+ if u.Scheme == "naive+quic" {
+ naive["quic"] = true
+ }
+
+ return &naive, tag, nil
+}
+
+func getTransport(tp_type string, q *url.Values) map[string]interface{} {
+ transport := map[string]interface{}{}
+ tp_host := q.Get("host")
+ tp_path := q.Get("path")
+ switch strings.ToLower(tp_type) {
+ case "tcp", "":
+ if q.Get("headerType") == "http" {
+ transport["type"] = "http"
+ if len(tp_host) > 0 {
+ transport["host"] = strings.Split(tp_host, ",")
+ }
+ transport["path"] = tp_path
+ }
+ case "http", "h2":
+ transport["type"] = "http"
+ if len(tp_host) > 0 {
+ transport["host"] = strings.Split(tp_host, ",")
+ }
+ transport["path"] = tp_path
+ case "ws":
+ transport["type"] = "ws"
+ transport["path"] = tp_path
+ if len(tp_host) > 0 {
+ transport["headers"] = map[string]interface{}{
+ "Host": tp_host,
+ }
+ }
+ case "quic":
+ transport["type"] = "quic"
+ case "grpc":
+ transport["type"] = "grpc"
+ transport["service_name"] = q.Get("serviceName")
+ case "httpupgrade":
+ transport["type"] = "httpupgrade"
+ transport["path"] = tp_path
+ transport["host"] = tp_host
+ }
+ return transport
+}
+
+func getTls(security string, q *url.Values) map[string]interface{} {
+ tls := map[string]interface{}{}
+ tls_fp := q.Get("fp")
+ tls_sni := q.Get("sni")
+ tls_allow_insecure := q.Get("allowInsecure")
+ tls_insecure := q.Get("insecure")
+ tls_alpn := q.Get("alpn")
+ tls_ech := q.Get("ech")
+ disable_sni := q.Get("disable_sni")
+ switch security {
+ case "tls":
+ tls["enabled"] = true
+ case "reality":
+ tls["enabled"] = true
+ tls["reality"] = map[string]interface{}{
+ "enabled": true,
+ "public_key": q.Get("pbk"),
+ "short_id": q.Get("sid"),
+ }
+ }
+ if len(tls_sni) > 0 {
+ tls["server_name"] = tls_sni
+ }
+ if len(tls_alpn) > 0 {
+ tls["alpn"] = strings.Split(tls_alpn, ",")
+ }
+ if tls_insecure == "1" || tls_insecure == "true" || tls_allow_insecure == "1" || tls_allow_insecure == "true" {
+ tls["insecure"] = true
+ }
+ if len(tls_fp) > 0 {
+ tls["utls"] = map[string]interface{}{
+ "enabled": true,
+ "fingerprint": tls_fp,
+ }
+ }
+ if len(tls_ech) > 0 {
+ tls["ech"] = map[string]interface{}{
+ "enabled": true,
+ "config": []string{
+ tls_ech,
+ },
+ }
+ }
+ if disable_sni == "1" || disable_sni == "true" {
+ tls["disable_sni"] = true
+ }
+ return tls
+}
diff --git a/util/outJson.go b/util/outJson.go
new file mode 100644
index 0000000..9658f93
--- /dev/null
+++ b/util/outJson.go
@@ -0,0 +1,237 @@
+package util
+
+import (
+ "encoding/json"
+
+ "github.com/alireza0/s-ui/util/common"
+
+ "github.com/alireza0/s-ui/database/model"
+)
+
+// Fill Inbound's out_json
+func FillOutJson(i *model.Inbound, hostname string) error {
+ switch i.Type {
+ case "direct", "tun", "redirect", "tproxy":
+ return nil
+ }
+ var outJson map[string]interface{}
+ err := json.Unmarshal(i.OutJson, &outJson)
+ if err != nil {
+ return err
+ }
+
+ if outJson == nil {
+ outJson = make(map[string]interface{})
+ }
+
+ if i.TlsId > 0 {
+ addTls(&outJson, i.Tls)
+ } else {
+ delete(outJson, "tls")
+ }
+
+ inbound, err := i.MarshalFull()
+ if err != nil {
+ return err
+ }
+
+ outJson["type"] = i.Type
+ outJson["tag"] = i.Tag
+ outJson["server"] = hostname
+ outJson["server_port"] = (*inbound)["listen_port"]
+
+ switch i.Type {
+ case "http", "socks", "mixed", "anytls":
+ case "naive":
+ naiveOut(&outJson, *inbound)
+ case "shadowsocks":
+ shadowsocksOut(&outJson, *inbound)
+ case "shadowtls":
+ shadowTlsOut(&outJson, *inbound)
+ case "hysteria":
+ hysteriaOut(&outJson, *inbound)
+ case "hysteria2":
+ hysteria2Out(&outJson, *inbound)
+ case "tuic":
+ tuicOut(&outJson, *inbound)
+ case "vless":
+ vlessOut(&outJson, *inbound)
+ case "trojan":
+ trojanOut(&outJson, *inbound)
+ case "vmess":
+ vmessOut(&outJson, *inbound)
+ default:
+ for key := range outJson {
+ delete(outJson, key)
+ }
+ }
+
+ i.OutJson, err = json.MarshalIndent(outJson, "", " ")
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// addTls function
+func addTls(out *map[string]interface{}, tls *model.Tls) {
+ var tlsServer, tlsConfig map[string]interface{}
+ err := json.Unmarshal(tls.Server, &tlsServer)
+ if err != nil {
+ return
+ }
+ err = json.Unmarshal(tls.Client, &tlsConfig)
+ if err != nil {
+ return
+ }
+
+ if enabled, ok := tlsServer["enabled"]; ok {
+ tlsConfig["enabled"] = enabled
+ }
+ if serverName, ok := tlsServer["server_name"]; ok {
+ tlsConfig["server_name"] = serverName
+ }
+ if alpn, ok := tlsServer["alpn"]; ok {
+ tlsConfig["alpn"] = alpn
+ }
+ if minVersion, ok := tlsServer["min_version"]; ok {
+ tlsConfig["min_version"] = minVersion
+ }
+ if maxVersion, ok := tlsServer["max_version"]; ok {
+ tlsConfig["max_version"] = maxVersion
+ }
+ if certificate, ok := tlsServer["certificate"]; ok {
+ tlsConfig["certificate"] = certificate
+ }
+ if cipherSuites, ok := tlsServer["cipher_suites"]; ok {
+ tlsConfig["cipher_suites"] = cipherSuites
+ }
+ if reality, ok := tlsServer["reality"].(map[string]interface{}); ok && reality["enabled"].(bool) {
+ realityConfig := tlsConfig["reality"].(map[string]interface{})
+ realityConfig["enabled"] = true
+ if shortIDs, ok := reality["short_id"].([]interface{}); ok && len(shortIDs) > 0 {
+ realityConfig["short_id"] = shortIDs[common.RandomInt(len(shortIDs))]
+ }
+ tlsConfig["reality"] = realityConfig
+ }
+ if ech, ok := tlsServer["ech"].(map[string]interface{}); ok && ech["enabled"].(bool) {
+ echConfig := tlsConfig["ech"].(map[string]interface{})
+ echConfig["enabled"] = true
+ echConfig["pq_signature_schemes_enabled"] = ech["pq_signature_schemes_enabled"]
+ echConfig["dynamic_record_sizing_disabled"] = ech["dynamic_record_sizing_disabled"]
+ tlsConfig["ech"] = echConfig
+ }
+
+ (*out)["tls"] = tlsConfig
+}
+
+func naiveOut(out *map[string]interface{}, inbound map[string]interface{}) {
+ if quic_congestion_control, ok := inbound["quic_congestion_control"].(string); ok {
+ (*out)["quic"] = true
+ switch quic_congestion_control {
+ case "bbr_standard":
+ (*out)["quic_congestion_control"] = "bbr"
+ case "bbr2_variant":
+ (*out)["quic_congestion_control"] = "bbr2"
+ default:
+ (*out)["quic_congestion_control"] = quic_congestion_control
+ }
+ }
+
+}
+
+func shadowsocksOut(out *map[string]interface{}, inbound map[string]interface{}) {
+ if method, ok := inbound["method"].(string); ok {
+ (*out)["method"] = method
+ }
+}
+
+func shadowTlsOut(out *map[string]interface{}, inbound map[string]interface{}) {
+ if version, ok := inbound["version"].(float64); ok && int(version) == 3 {
+ (*out)["version"] = 3
+ } else {
+ for key := range *out {
+ delete(*out, key)
+ }
+ }
+ (*out)["tls"] = map[string]interface{}{"enabled": true}
+}
+
+func hysteriaOut(out *map[string]interface{}, inbound map[string]interface{}) {
+ delete(*out, "down_mbps")
+ delete(*out, "up_mbps")
+ delete(*out, "obfs")
+ delete(*out, "recv_window_conn")
+ delete(*out, "disable_mtu_discovery")
+
+ if upMbps, ok := inbound["down_mbps"]; ok {
+ (*out)["up_mbps"] = upMbps
+ }
+ if downMbps, ok := inbound["up_mbps"]; ok {
+ (*out)["down_mbps"] = downMbps
+ }
+ if obfs, ok := inbound["obfs"]; ok {
+ (*out)["obfs"] = obfs
+ }
+ if recvWindow, ok := inbound["recv_window_conn"]; ok {
+ (*out)["recv_window_conn"] = recvWindow
+ }
+ if disableMTU, ok := inbound["disable_mtu_discovery"]; ok {
+ (*out)["disable_mtu_discovery"] = disableMTU
+ }
+}
+
+func hysteria2Out(out *map[string]interface{}, inbound map[string]interface{}) {
+ delete(*out, "down_mbps")
+ delete(*out, "up_mbps")
+ delete(*out, "obfs")
+
+ if upMbps, ok := inbound["down_mbps"]; ok {
+ (*out)["up_mbps"] = upMbps
+ }
+ if downMbps, ok := inbound["up_mbps"]; ok {
+ (*out)["down_mbps"] = downMbps
+ }
+ if obfs, ok := inbound["obfs"]; ok {
+ (*out)["obfs"] = obfs
+ }
+}
+
+func tuicOut(out *map[string]interface{}, inbound map[string]interface{}) {
+ delete(*out, "zero_rtt_handshake")
+ delete(*out, "heartbeat")
+ if congestionControl, ok := inbound["congestion_control"].(string); ok {
+ (*out)["congestion_control"] = congestionControl
+ } else {
+ (*out)["congestion_control"] = "cubic"
+ }
+ if zeroRTT, ok := inbound["zero_rtt_handshake"].(bool); ok {
+ (*out)["zero_rtt_handshake"] = zeroRTT
+ }
+ if heartbeat, ok := inbound["heartbeat"]; ok {
+ (*out)["heartbeat"] = heartbeat
+ }
+}
+
+func vlessOut(out *map[string]interface{}, inbound map[string]interface{}) {
+ delete(*out, "transport")
+ if transport, ok := inbound["transport"]; ok {
+ (*out)["transport"] = transport
+ }
+}
+
+func trojanOut(out *map[string]interface{}, inbound map[string]interface{}) {
+ delete(*out, "transport")
+ if transport, ok := inbound["transport"]; ok {
+ (*out)["transport"] = transport
+ }
+}
+
+func vmessOut(out *map[string]interface{}, inbound map[string]interface{}) {
+ (*out)["alter_id"] = 0
+ delete(*out, "transport")
+ if transport, ok := inbound["transport"]; ok {
+ (*out)["transport"] = transport
+ }
+}
diff --git a/util/subInfo.go b/util/subInfo.go
new file mode 100644
index 0000000..25bf3ca
--- /dev/null
+++ b/util/subInfo.go
@@ -0,0 +1,15 @@
+package util
+
+import (
+ "fmt"
+
+ "github.com/alireza0/s-ui/database/model"
+)
+
+func GetHeaders(client *model.Client, updateInterval int) []string {
+ var headers []string
+ headers = append(headers, fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", client.Up, client.Down, client.Volume, client.Expiry))
+ headers = append(headers, fmt.Sprintf("%d", updateInterval))
+ headers = append(headers, client.Name)
+ return headers
+}
diff --git a/util/subToJson.go b/util/subToJson.go
new file mode 100644
index 0000000..d6df648
--- /dev/null
+++ b/util/subToJson.go
@@ -0,0 +1,97 @@
+package util
+
+import (
+ "crypto/tls"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/alireza0/s-ui/logger"
+ "github.com/alireza0/s-ui/util/common"
+)
+
+func GetExternalLink(url string) string {
+ tr := &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ }
+
+ client := &http.Client{Transport: tr}
+
+ response, err := client.Get(url)
+ if err != nil {
+ logger.Warning("sub: Error making HTTP request:", err)
+ return ""
+ }
+ defer response.Body.Close()
+
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ logger.Warning("sub: Error reading response body:", err)
+ return ""
+ }
+
+ data := StrOrBase64Encoded(string(body))
+ return data
+}
+
+func GetExternalSub(url string) ([]map[string]interface{}, error) {
+ var err error
+ var result []map[string]interface{}
+
+ if len(url) == 0 {
+ return nil, common.NewError("no url")
+ }
+
+ data := GetExternalLink(url)
+ if len(data) == 0 {
+ return nil, common.NewError("no result")
+ }
+
+ // if the data is a JSON object
+ if strings.HasPrefix(data, "{") && strings.HasSuffix(data, "}") {
+ var jsonData map[string]interface{}
+ err = json.Unmarshal([]byte(data), &jsonData)
+ if err != nil {
+ logger.Warning("sub: Error unmarshalling JSON:", err)
+ return nil, err
+ }
+ outbounds, ok := jsonData["outbounds"].([]any)
+ if !ok {
+ logger.Warning("sub: Error getting outbounds:", err)
+ return nil, err
+ }
+ for _, outbound := range outbounds {
+ outboundMap, ok := outbound.(map[string]interface{})
+ if ok && len(outboundMap) > 0 {
+ oType, _ := outboundMap["type"].(string)
+ switch oType {
+ case "urltest":
+ case "direct":
+ case "selector":
+ case "block":
+ continue
+ default:
+ result = append(result, outboundMap)
+ }
+ }
+ }
+ if len(result) == 0 {
+ return nil, common.NewError("no result")
+ }
+ return result, nil
+ } else {
+ // if data is a text
+ links := strings.Split(data, "\n")
+ for _, link := range links {
+ linkToJson, _, err := GetOutbound(link, 0)
+ if err == nil {
+ result = append(result, *linkToJson)
+ }
+ }
+ }
+ if len(result) == 0 {
+ return nil, common.NewError("no result")
+ }
+ return result, nil
+}
diff --git a/web/web.go b/web/web.go
new file mode 100644
index 0000000..24959d9
--- /dev/null
+++ b/web/web.go
@@ -0,0 +1,229 @@
+package web
+
+import (
+ "context"
+ "crypto/tls"
+ "embed"
+ "html/template"
+ "io"
+ "io/fs"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/alireza0/s-ui/api"
+ "github.com/alireza0/s-ui/config"
+ "github.com/alireza0/s-ui/logger"
+ "github.com/alireza0/s-ui/middleware"
+ "github.com/alireza0/s-ui/network"
+ "github.com/alireza0/s-ui/service"
+
+ "github.com/gin-contrib/gzip"
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-contrib/sessions/cookie"
+ "github.com/gin-gonic/gin"
+)
+
+//go:embed *
+var content embed.FS
+
+type Server struct {
+ httpServer *http.Server
+ listener net.Listener
+ ctx context.Context
+ cancel context.CancelFunc
+ settingService service.SettingService
+}
+
+func NewServer() *Server {
+ ctx, cancel := context.WithCancel(context.Background())
+ return &Server{
+ ctx: ctx,
+ cancel: cancel,
+ }
+}
+
+func (s *Server) initRouter() (*gin.Engine, error) {
+ if config.IsDebug() {
+ gin.SetMode(gin.DebugMode)
+ } else {
+ gin.DefaultWriter = io.Discard
+ gin.DefaultErrorWriter = io.Discard
+ gin.SetMode(gin.ReleaseMode)
+ }
+
+ engine := gin.Default()
+
+ // Load the HTML template
+ t := template.New("").Funcs(engine.FuncMap)
+ template, err := t.ParseFS(content, "html/index.html")
+ if err != nil {
+ return nil, err
+ }
+ engine.SetHTMLTemplate(template)
+
+ base_url, err := s.settingService.GetWebPath()
+ if err != nil {
+ return nil, err
+ }
+
+ webDomain, err := s.settingService.GetWebDomain()
+ if err != nil {
+ return nil, err
+ }
+
+ if webDomain != "" {
+ engine.Use(middleware.DomainValidator(webDomain))
+ }
+
+ secret, err := s.settingService.GetSecret()
+ if err != nil {
+ return nil, err
+ }
+
+ engine.Use(gzip.Gzip(gzip.DefaultCompression))
+ assetsBasePath := base_url + "assets/"
+
+ store := cookie.NewStore(secret)
+ engine.Use(sessions.Sessions("s-ui", store))
+
+ engine.Use(func(c *gin.Context) {
+ uri := c.Request.RequestURI
+ if strings.HasPrefix(uri, assetsBasePath) {
+ c.Header("Cache-Control", "max-age=31536000")
+ }
+ })
+
+ // Serve the assets folder
+ assetsFS, err := fs.Sub(content, "html/assets")
+ if err != nil {
+ panic(err)
+ }
+
+ engine.StaticFS(assetsBasePath, http.FS(assetsFS))
+
+ group_apiv2 := engine.Group(base_url + "apiv2")
+ apiv2 := api.NewAPIv2Handler(group_apiv2)
+
+ group_api := engine.Group(base_url + "api")
+ api.NewAPIHandler(group_api, apiv2)
+
+ // Serve index.html as the entry point
+ // Handle all other routes by serving index.html
+ engine.NoRoute(func(c *gin.Context) {
+ if c.Request.URL.Path == strings.TrimSuffix(base_url, "/") {
+ c.Redirect(http.StatusTemporaryRedirect, base_url)
+ return
+ }
+ if !strings.HasPrefix(c.Request.URL.Path, base_url) {
+ c.String(404, "")
+ return
+ }
+ if c.Request.URL.Path != base_url+"login" && !api.IsLogin(c) {
+ c.Redirect(http.StatusTemporaryRedirect, base_url+"login")
+ return
+ }
+ if c.Request.URL.Path == base_url+"login" && api.IsLogin(c) {
+ c.Redirect(http.StatusTemporaryRedirect, base_url)
+ return
+ }
+ c.HTML(http.StatusOK, "index.html", gin.H{"BASE_URL": base_url})
+ })
+
+ return engine, nil
+}
+
+func (s *Server) Start() (err error) {
+ //This is an anonymous function, no function name
+ defer func() {
+ if err != nil {
+ s.Stop()
+ }
+ }()
+
+ engine, err := s.initRouter()
+ if err != nil {
+ return err
+ }
+
+ certFile, err := s.settingService.GetCertFile()
+ if err != nil {
+ return err
+ }
+ keyFile, err := s.settingService.GetKeyFile()
+ if err != nil {
+ return err
+ }
+ listen, err := s.settingService.GetListen()
+ if err != nil {
+ return err
+ }
+ port, err := s.settingService.GetPort()
+ if err != nil {
+ return err
+ }
+ listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
+ listener, err := net.Listen("tcp", listenAddr)
+ if err != nil {
+ return err
+ }
+ if certFile != "" || keyFile != "" {
+ cert, err := tls.LoadX509KeyPair(certFile, keyFile)
+ if err != nil {
+ listener.Close()
+ return err
+ }
+ c := &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ }
+ listener = network.NewAutoHttpsListener(listener)
+ listener = tls.NewListener(listener, c)
+ }
+
+ if certFile != "" || keyFile != "" {
+ logger.Info("web server run https on", listener.Addr())
+ } else {
+ logger.Info("web server run http on", listener.Addr())
+ }
+ s.listener = listener
+
+ s.httpServer = &http.Server{
+ Handler: engine,
+ }
+
+ go func() {
+ s.httpServer.Serve(listener)
+ }()
+
+ return nil
+}
+
+func (s *Server) Stop() error {
+ var err error
+ if s.httpServer != nil {
+ shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 30*time.Second)
+ err = s.httpServer.Shutdown(shutdownCtx)
+ cancelShutdown()
+ if err != nil {
+ s.cancel()
+ if s.listener != nil {
+ _ = s.listener.Close()
+ }
+ return err
+ }
+ } else if s.listener != nil {
+ err = s.listener.Close()
+ if err != nil {
+ s.cancel()
+ return err
+ }
+ }
+ s.cancel()
+ return nil
+}
+
+func (s *Server) GetCtx() context.Context {
+ return s.ctx
+}
diff --git a/windows/README.md b/windows/README.md
new file mode 100644
index 0000000..ab9ffba
--- /dev/null
+++ b/windows/README.md
@@ -0,0 +1,23 @@
+# Windows Files
+
+This directory contains all Windows-specific files for S-UI.
+
+## Available Files:
+
+- **s-ui-windows.xml**: Windows Service configuration
+- **install-windows.bat**: Installation script
+- **s-ui-windows.bat**: Control panel
+- **uninstall-windows.bat**: Uninstallation script
+- **build-windows.bat**: Simple build script for CMD
+- **build-windows.ps1**: Advanced build script for PowerShell
+
+## Usage:
+
+To install S-UI on Windows:
+1. Run `install-windows.bat` as Administrator
+2. Follow the installation wizard
+3. Use `s-ui-windows.bat` for management
+
+To build from source:
+- With CMD: `build-windows.bat`
+- With PowerShell: `.\build-windows.ps1`
diff --git a/windows/build-windows.bat b/windows/build-windows.bat
new file mode 100644
index 0000000..9b3445d
--- /dev/null
+++ b/windows/build-windows.bat
@@ -0,0 +1,73 @@
+@echo off
+setlocal enabledelayedexpansion
+
+echo Building S-UI for Windows...
+
+cd /d "%~dp0"
+
+REM Check if Go is installed
+go version >nul 2>&1
+if errorlevel 1 (
+ echo Error: Go is not installed or not in PATH
+ echo Please install Go from https://golang.org/dl/
+ pause
+ exit /b 1
+)
+
+REM Check if Node.js is installed
+node --version >nul 2>&1
+if errorlevel 1 (
+ echo Error: Node.js is not installed or not in PATH
+ echo Please install Node.js from https://nodejs.org/
+ pause
+ exit /b 1
+)
+
+echo Building frontend...
+cd frontend
+call npm install
+if errorlevel 1 (
+ echo Error: Failed to install frontend dependencies
+ pause
+ exit /b 1
+)
+
+call npm run build
+if errorlevel 1 (
+ echo Error: Failed to build frontend
+ pause
+ exit /b 1
+)
+
+cd ..
+
+echo Creating web/html directory...
+if not exist "web\html" mkdir "web\html"
+
+echo Copying frontend build files...
+xcopy "frontend\dist\*" "web\html\" /E /Y /Q
+
+echo Building backend...
+set CGO_ENABLED=1
+set GOOS=windows
+set GOARCH=amd64
+
+REM Try to build with CGO first
+go build -ldflags "-w -s" -tags "with_quic,with_grpc,with_utls,with_acme,with_gvisor,with_tailscale" -o sui.exe main.go
+if errorlevel 1 (
+ echo Warning: CGO build failed, trying without CGO...
+ set CGO_ENABLED=0
+ go build -ldflags "-w -s" -tags "with_quic,with_grpc,with_utls,with_acme,with_gvisor,with_tailscale" -o sui.exe main.go
+ if errorlevel 1 (
+ echo Error: Failed to build backend
+ pause
+ exit /b 1
+ )
+ echo Built without CGO (some features may be limited)
+) else (
+ echo Built with CGO
+)
+
+echo Build completed successfully!
+echo Output: sui.exe
+pause
diff --git a/windows/build-windows.ps1 b/windows/build-windows.ps1
new file mode 100644
index 0000000..f50fb55
--- /dev/null
+++ b/windows/build-windows.ps1
@@ -0,0 +1,138 @@
+# PowerShell script for building S-UI on Windows
+param(
+ [string]$Architecture = "amd64",
+ [switch]$NoCGO,
+ [switch]$Help
+)
+
+if ($Help) {
+ Write-Host "Usage: .\build-windows.ps1 [-Architecture ] [-NoCGO] [-Help]"
+ Write-Host "Architectures: amd64, 386, arm64"
+ Write-Host "Examples:"
+ Write-Host " .\build-windows.ps1 # Build for amd64 with CGO"
+ Write-Host " .\build-windows.ps1 -Architecture 386 # Build for 32-bit Windows"
+ Write-Host " .\build-windows.ps1 -NoCGO # Build without CGO"
+ exit 0
+}
+
+Write-Host "Building S-UI for Windows ($Architecture)..." -ForegroundColor Green
+
+# Check if Go is installed
+try {
+ $goVersion = go version 2>$null
+ if ($LASTEXITCODE -ne 0) {
+ throw "Go not found"
+ }
+ Write-Host "Go version: $goVersion" -ForegroundColor Green
+} catch {
+ Write-Host "Error: Go is not installed or not in PATH" -ForegroundColor Red
+ Write-Host "Please install Go from https://golang.org/dl/" -ForegroundColor Yellow
+ Read-Host "Press Enter to exit"
+ exit 1
+}
+
+# Check if Node.js is installed
+try {
+ $nodeVersion = node --version 2>$null
+ if ($LASTEXITCODE -ne 0) {
+ throw "Node.js not found"
+ }
+ Write-Host "Node.js version: $nodeVersion" -ForegroundColor Green
+} catch {
+ Write-Host "Error: Node.js is not installed or not in PATH" -ForegroundColor Red
+ Write-Host "Please install Node.js from https://nodejs.org/" -ForegroundColor Yellow
+ Read-Host "Press Enter to exit"
+ exit 1
+}
+
+# Build frontend
+Write-Host "Building frontend..." -ForegroundColor Yellow
+Push-Location frontend
+
+try {
+ Write-Host "Installing dependencies..." -ForegroundColor Cyan
+ npm install
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to install frontend dependencies"
+ }
+
+ Write-Host "Building frontend..." -ForegroundColor Cyan
+ npm run build
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to build frontend"
+ }
+} catch {
+ Write-Host "Error: $_" -ForegroundColor Red
+ Pop-Location
+ Read-Host "Press Enter to exit"
+ exit 1
+}
+
+Pop-Location
+
+# Create web/html directory
+Write-Host "Creating web/html directory..." -ForegroundColor Yellow
+if (!(Test-Path "web\html")) {
+ New-Item -ItemType Directory -Path "web\html" -Force | Out-Null
+}
+
+# Copy frontend build files
+Write-Host "Copying frontend build files..." -ForegroundColor Yellow
+Copy-Item "frontend\dist\*" "web\html\" -Recurse -Force
+
+# Build backend
+Write-Host "Building backend..." -ForegroundColor Yellow
+
+# Set environment variables
+$env:GOOS = "windows"
+$env:GOARCH = $Architecture
+
+if ($NoCGO) {
+ $env:CGO_ENABLED = "0"
+ Write-Host "Building without CGO..." -ForegroundColor Yellow
+} else {
+ $env:CGO_ENABLED = "1"
+ Write-Host "Building with CGO..." -ForegroundColor Yellow
+}
+
+# Build command
+$buildCmd = "go build -ldflags `"-w -s`" -tags `"with_quic,with_grpc,with_utls,with_acme,with_gvisor,with_tailscale`" -o sui.exe main.go"
+
+try {
+ Invoke-Expression $buildCmd
+ if ($LASTEXITCODE -ne 0) {
+ if (!$NoCGO) {
+ Write-Host "CGO build failed, trying without CGO..." -ForegroundColor Yellow
+ $env:CGO_ENABLED = "0"
+ Invoke-Expression $buildCmd
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to build backend even without CGO"
+ }
+ Write-Host "Built without CGO (some features may be limited)" -ForegroundColor Yellow
+ } else {
+ throw "Failed to build backend"
+ }
+ } else {
+ if ($env:CGO_ENABLED -eq "1") {
+ Write-Host "Built successfully with CGO" -ForegroundColor Green
+ } else {
+ Write-Host "Built successfully without CGO" -ForegroundColor Green
+ }
+ }
+} catch {
+ Write-Host "Error: $_" -ForegroundColor Red
+ Read-Host "Press Enter to exit"
+ exit 1
+}
+
+Write-Host "Build completed successfully!" -ForegroundColor Green
+Write-Host "Output: sui.exe" -ForegroundColor Green
+
+# Show file info
+if (Test-Path "sui.exe") {
+ $fileInfo = Get-Item "sui.exe"
+ Write-Host "File size: $([math]::Round($fileInfo.Length / 1MB, 2)) MB" -ForegroundColor Cyan
+ Write-Host "Created: $($fileInfo.CreationTime)" -ForegroundColor Cyan
+}
+
+Read-Host "Press Enter to exit"
diff --git a/windows/install-windows.bat b/windows/install-windows.bat
new file mode 100644
index 0000000..32e7b98
--- /dev/null
+++ b/windows/install-windows.bat
@@ -0,0 +1,195 @@
+@echo off
+setlocal enabledelayedexpansion
+
+echo ========================================
+echo S-UI Windows Installer
+echo ========================================
+
+REM Check if running as Administrator
+net session >nul 2>&1
+if %errorLevel% neq 0 (
+ echo Error: This script must be run as Administrator
+ echo Right-click on this file and select "Run as administrator"
+ pause
+ exit /b 1
+)
+
+cd /d "%~dp0"
+REM Set installation directory
+set "INSTALL_DIR=C:\Program Files\s-ui"
+set "SERVICE_NAME=s-ui"
+
+echo Installing S-UI to: %INSTALL_DIR%
+
+REM Create installation directory
+if not exist "%INSTALL_DIR%" mkdir "%INSTALL_DIR%"
+if not exist "%INSTALL_DIR%\db" mkdir "%INSTALL_DIR%\db"
+if not exist "%INSTALL_DIR%\logs" mkdir "%INSTALL_DIR%\logs"
+if not exist "%INSTALL_DIR%\cert" mkdir "%INSTALL_DIR%\cert"
+
+REM Copy files
+echo Copying files...
+copy "sui.exe" "%INSTALL_DIR%\" >nul
+copy "s-ui-windows.xml" "%INSTALL_DIR%\" >nul
+copy "s-ui-windows.bat" "%INSTALL_DIR%\" >nul
+
+REM Check if WinSW is available
+set "WINSW_PATH=%INSTALL_DIR%\winsw.exe"
+if not exist "%WINSW_PATH%" (
+ echo Downloading WinSW...
+ powershell -Command "& {Invoke-WebRequest -Uri 'https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe' -OutFile '%WINSW_PATH%'}"
+ if exist "%WINSW_PATH%" (
+ echo WinSW downloaded successfully
+ ) else (
+ echo Warning: Failed to download WinSW. Service installation will be skipped.
+ echo You can manually download WinSW from: https://github.com/winsw/winsw/releases
+ )
+)
+
+REM Install Windows Service
+if exist "%WINSW_PATH%" (
+ echo Installing Windows Service...
+ cd /d "%INSTALL_DIR%"
+ copy "winsw.exe" "s-ui-service.exe" >nul
+ copy "s-ui-windows.xml" "s-ui-service.xml" >nul
+
+ REM Install service
+ s-ui-service.exe install
+ if %errorLevel% equ 0 (
+ echo Service installed successfully
+ ) else (
+ echo Warning: Failed to install service. You can install it manually later.
+ )
+)
+
+REM Run migration
+echo Running database migration...
+cd /d "%INSTALL_DIR%"
+sui.exe migrate
+if %errorLevel% equ 0 (
+ echo Migration completed successfully
+) else (
+ echo Warning: Migration failed or database is new
+)
+
+REM Get network configuration
+echo.
+echo ========================================
+echo Network Configuration
+echo ========================================
+
+REM Get local IP addresses
+echo Available IP addresses:
+for /f "tokens=2 delims=:" %%i in ('ipconfig ^| findstr /i "IPv4"') do (
+ echo %%i
+)
+
+REM Get panel configuration
+echo.
+set /p panel_port="Enter panel port (default: 2095): "
+if "%panel_port%"=="" set "panel_port=2095"
+
+set /p panel_path="Enter panel path (default: /app/): "
+if "%panel_path%"=="" set "panel_path=/app/"
+
+set /p sub_port="Enter subscription port (default: 2096): "
+if "%sub_port%"=="" set "sub_port=2096"
+
+set /p sub_path="Enter subscription path (default: /sub/): "
+if "%sub_path%"=="" set "sub_path=/sub/"
+
+REM Apply settings
+echo.
+echo Applying settings...
+cd /d "%INSTALL_DIR%"
+sui.exe setting -port %panel_port% -path "%panel_path%" -subPort %sub_port% -subPath "%sub_path%"
+
+REM Get admin credentials
+echo.
+echo ========================================
+echo Admin Configuration
+echo ========================================
+
+set /p admin_username="Enter admin username (default: admin): "
+if "%admin_username%"=="" set "admin_username=admin"
+
+set /p admin_password="Enter admin password: "
+if "%admin_password%"=="" (
+ echo Error: Password cannot be empty
+ pause
+ exit /b 1
+)
+
+REM Set admin credentials
+echo Setting admin credentials...
+sui.exe admin -username "%admin_username%" -password "%admin_password%"
+
+REM Start service
+echo Starting S-UI service...
+net start %SERVICE_NAME%
+if %errorLevel% equ 0 (
+ echo Service started successfully
+) else (
+ echo Warning: Failed to start service. You can start it manually later.
+)
+
+REM Create desktop shortcut
+echo Creating desktop shortcut...
+set "DESKTOP=%USERPROFILE%\Desktop"
+if exist "%DESKTOP%" (
+ powershell -Command "& {$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%DESKTOP%\S-UI.lnk'); $Shortcut.TargetPath = '%INSTALL_DIR%\s-ui-windows.bat'; $Shortcut.WorkingDirectory = '%INSTALL_DIR%'; $Shortcut.Description = 'S-UI Control Panel'; $Shortcut.Save()}"
+ echo Desktop shortcut created
+)
+
+REM Create Start Menu shortcut
+echo Creating Start Menu shortcut...
+set "START_MENU=%APPDATA%\Microsoft\Windows\Start Menu\Programs"
+if exist "%START_MENU%" (
+ if not exist "%START_MENU%\S-UI" mkdir "%START_MENU%\S-UI"
+ powershell -Command "& {$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%START_MENU%\S-UI\S-UI Control Panel.lnk'); $Shortcut.TargetPath = '%INSTALL_DIR%\s-ui-windows.bat'; $Shortcut.WorkingDirectory = '%INSTALL_DIR%'; $Shortcut.Description = 'S-UI Control Panel'; $Shortcut.Save()}"
+ echo Start Menu shortcut created
+)
+
+REM Set permissions
+echo Setting permissions...
+icacls "%INSTALL_DIR%" /grant "Users:(OI)(CI)RX" /T >nul
+icacls "%INSTALL_DIR%\db" /grant "Users:(OI)(CI)F" /T >nul
+icacls "%INSTALL_DIR%\logs" /grant "Users:(OI)(CI)F" /T >nul
+
+REM Create environment variable
+echo Setting environment variable...
+setx SUI_HOME "%INSTALL_DIR%" /M >nul
+
+REM Show final configuration
+echo.
+echo ========================================
+echo Installation completed successfully!
+echo ========================================
+echo.
+echo S-UI has been installed to: %INSTALL_DIR%
+echo.
+echo Configuration:
+echo Panel Port: %panel_port%
+echo Panel Path: %panel_path%
+echo Subscription Port: %sub_port%
+echo Subscription Path: %sub_path%
+echo Admin Username: %admin_username%
+echo.
+echo Access URLs:
+for /f "tokens=2 delims=:" %%i in ('ipconfig ^| findstr /i "IPv4"') do (
+ set "ip=%%i"
+ set "ip=!ip: =!"
+ echo Panel: http://!ip!:%panel_port%%panel_path%
+ echo Subscription: http://!ip!:%sub_port%%sub_path%
+)
+echo.
+echo Service name: %SERVICE_NAME%
+echo.
+echo Useful commands:
+echo net start %SERVICE_NAME% - Start the service
+echo net stop %SERVICE_NAME% - Stop the service
+echo sc query %SERVICE_NAME% - Check service status
+echo.
+echo You can also use the desktop shortcut or Start Menu item.
+echo.
+pause
diff --git a/windows/s-ui-windows.bat b/windows/s-ui-windows.bat
new file mode 100644
index 0000000..bf1065e
--- /dev/null
+++ b/windows/s-ui-windows.bat
@@ -0,0 +1,237 @@
+@echo off
+setlocal enabledelayedexpansion
+
+REM S-UI Windows Control Script
+REM This script provides a menu-driven interface for managing S-UI on Windows
+
+cd /d "%~dp0"
+set "SERVICE_NAME=s-ui"
+set "INSTALL_DIR=%SUI_HOME%"
+if "%INSTALL_DIR%"=="" set "INSTALL_DIR=C:\Program Files\s-ui"
+
+:menu
+cls
+echo ========================================
+echo S-UI Windows Control Panel
+echo ========================================
+echo.
+echo Current directory: %INSTALL_DIR%
+echo.
+echo 1. Start S-UI Service
+echo 2. Stop S-UI Service
+echo 3. Restart S-UI Service
+echo 4. Check Service Status
+echo 5. View Service Logs
+echo 6. Open Panel in Browser
+echo 7. Run S-UI Manually
+echo 8. Install/Uninstall Service
+echo 9. Open Installation Directory
+echo 10. Show Configuration
+echo 11. Show Access URLs
+echo 0. Exit
+echo.
+echo ========================================
+
+set /p choice="Please select an option [0-11]: "
+
+if "%choice%"=="1" goto start_service
+if "%choice%"=="2" goto stop_service
+if "%choice%"=="3" goto restart_service
+if "%choice%"=="4" goto check_status
+if "%choice%"=="5" goto view_logs
+if "%choice%"=="6" goto open_panel
+if "%choice%"=="7" goto run_manual
+if "%choice%"=="8" goto service_management
+if "%choice%"=="9" goto open_directory
+if "%choice%"=="10" goto show_config
+if "%choice%"=="11" goto show_urls
+if "%choice%"=="0" goto exit
+goto invalid_choice
+
+:start_service
+echo Starting S-UI service...
+net start %SERVICE_NAME%
+if %errorLevel% equ 0 (
+ echo Service started successfully!
+) else (
+ echo Failed to start service. Error code: %errorLevel%
+)
+pause
+goto menu
+
+:stop_service
+echo Stopping S-UI service...
+net stop %SERVICE_NAME%
+if %errorLevel% equ 0 (
+ echo Service stopped successfully!
+) else (
+ echo Failed to stop service. Error code: %errorLevel%
+)
+pause
+goto menu
+
+:restart_service
+echo Restarting S-UI service...
+net stop %SERVICE_NAME% >nul 2>&1
+timeout /t 2 /nobreak >nul
+net start %SERVICE_NAME%
+if %errorLevel% equ 0 (
+ echo Service restarted successfully!
+) else (
+ echo Failed to restart service. Error code: %errorLevel%
+)
+pause
+goto menu
+
+:check_status
+echo Checking S-UI service status...
+sc query %SERVICE_NAME%
+echo.
+echo Service status details:
+for /f "tokens=3 delims=: " %%i in ('sc query %SERVICE_NAME% ^| find "STATE"') do (
+ echo Current state: %%i
+)
+pause
+goto menu
+
+:view_logs
+echo Opening S-UI logs...
+if exist "%INSTALL_DIR%\logs" (
+ start "" "%INSTALL_DIR%\logs"
+) else (
+ echo Logs directory not found: %INSTALL_DIR%\logs
+)
+pause
+goto menu
+
+:open_panel
+echo Opening S-UI panel in browser...
+start http://localhost:2095
+echo Panel opened in default browser.
+pause
+goto menu
+
+:run_manual
+echo Running S-UI manually...
+if exist "%INSTALL_DIR%\sui.exe" (
+ cd /d "%INSTALL_DIR%"
+ echo Starting S-UI in current window...
+ echo Press Ctrl+C to stop
+ echo.
+ sui.exe
+) else (
+ echo S-UI executable not found: %INSTALL_DIR%\sui.exe
+ echo Please run the installer first.
+)
+pause
+goto menu
+
+:service_management
+cls
+echo ========================================
+echo Service Management
+echo ========================================
+echo.
+echo 1. Install Windows Service
+echo 2. Uninstall Windows Service
+echo 3. Back to Main Menu
+echo.
+set /p service_choice="Select option [1-3]: "
+
+if "%service_choice%"=="1" goto install_service
+if "%service_choice%"=="2" goto uninstall_service
+if "%service_choice%"=="3" goto menu
+goto invalid_choice
+
+:install_service
+echo Installing Windows Service...
+if exist "%INSTALL_DIR%\s-ui-service.exe" (
+ cd /d "%INSTALL_DIR%"
+ s-ui-service.exe install
+ if %errorLevel% equ 0 (
+ echo Service installed successfully!
+ echo Starting service...
+ net start %SERVICE_NAME%
+ ) else (
+ echo Failed to install service. Error code: %errorLevel%
+ )
+) else (
+ echo Service wrapper not found. Please run the installer first.
+)
+pause
+goto service_management
+
+:uninstall_service
+echo Uninstalling Windows Service...
+if exist "%INSTALL_DIR%\s-ui-service.exe" (
+ cd /d "%INSTALL_DIR%"
+ net stop %SERVICE_NAME% >nul 2>&1
+ s-ui-service.exe uninstall
+ if %errorLevel% equ 0 (
+ echo Service uninstalled successfully!
+ ) else (
+ echo Failed to uninstall service. Error code: %errorLevel%
+ )
+) else (
+ echo Service wrapper not found.
+)
+pause
+goto service_management
+
+:open_directory
+echo Opening installation directory...
+if exist "%INSTALL_DIR%" (
+ start "" "%INSTALL_DIR%"
+) else (
+ echo Installation directory not found: %INSTALL_DIR%
+)
+pause
+goto menu
+
+:show_config
+echo.
+echo ========================================
+echo S-UI Configuration
+echo ========================================
+if exist "%INSTALL_DIR%\sui.exe" (
+ cd /d "%INSTALL_DIR%"
+ echo Current settings:
+ sui.exe setting -show
+ echo.
+ echo Admin credentials:
+ sui.exe admin -show
+) else (
+ echo S-UI executable not found. Please run the installer first.
+)
+pause
+goto menu
+
+:show_urls
+echo.
+echo ========================================
+echo Access URLs
+echo ========================================
+echo.
+echo Local access:
+echo Panel: http://localhost:2095
+echo Subscription: http://localhost:2096
+echo.
+echo Network access:
+for /f "tokens=2 delims=:" %%i in ('ipconfig ^| findstr /i "IPv4"') do (
+ set "ip=%%i"
+ set "ip=!ip: =!"
+ echo Panel: http://!ip!:2095
+ echo Subscription: http://!ip!:2096
+)
+echo.
+pause
+goto menu
+
+:invalid_choice
+echo Invalid choice. Please select a valid option.
+pause
+goto menu
+
+:exit
+echo Thank you for using S-UI Windows Control Panel!
+exit /b 0
diff --git a/windows/s-ui-windows.xml b/windows/s-ui-windows.xml
new file mode 100644
index 0000000..e526d93
--- /dev/null
+++ b/windows/s-ui-windows.xml
@@ -0,0 +1,22 @@
+
+
+ s-ui
+ S-UI Proxy Panel
+ S-UI is a proxy panel for managing proxy services
+ %BASE%\sui.exe
+
+ rotate
+ %BASE%\logs
+
+
+ %BASE%
+
+
+
+
+
+ 1 hour
+ Automatic
+ tcpip
+ netman
+
diff --git a/windows/uninstall-windows.bat b/windows/uninstall-windows.bat
new file mode 100644
index 0000000..c620001
--- /dev/null
+++ b/windows/uninstall-windows.bat
@@ -0,0 +1,102 @@
+@echo off
+setlocal enabledelayedexpansion
+
+echo ========================================
+echo S-UI Windows Uninstaller
+echo ========================================
+
+REM Check if running as Administrator
+net session >nul 2>&1
+if %errorLevel% neq 0 (
+ echo Error: This script must be run as Administrator
+ echo Right-click on this file and select "Run as administrator"
+ pause
+ exit /b 1
+)
+
+REM Set installation directory
+set "INSTALL_DIR=C:\Program Files\s-ui"
+set "SERVICE_NAME=s-ui"
+
+echo Uninstalling S-UI from: %INSTALL_DIR%
+
+REM Stop and remove Windows Service
+if exist "%INSTALL_DIR%\s-ui-service.exe" (
+ echo Stopping and removing Windows Service...
+ net stop %SERVICE_NAME% >nul 2>&1
+ cd /d "%INSTALL_DIR%"
+ s-ui-service.exe uninstall >nul 2>&1
+ if %errorLevel% equ 0 (
+ echo Service removed successfully
+ ) else (
+ echo Warning: Failed to remove service or service was not installed
+ )
+)
+
+REM Remove desktop shortcut
+echo Removing desktop shortcut...
+set "DESKTOP=%USERPROFILE%\Desktop"
+if exist "%DESKTOP%\S-UI.lnk" (
+ del "%DESKTOP%\S-UI.lnk" >nul 2>&1
+ echo Desktop shortcut removed
+)
+
+REM Remove Start Menu shortcut
+echo Removing Start Menu shortcut...
+set "START_MENU=%APPDATA%\Microsoft\Windows\Start Menu\Programs\S-UI"
+if exist "%START_MENU%" (
+ rmdir /s /q "%START_MENU%" >nul 2>&1
+ echo Start Menu shortcut removed
+)
+
+REM Remove environment variable
+echo Removing environment variable...
+reg delete "HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" /v SUI_HOME /f >nul 2>&1
+
+REM Ask user if they want to keep data
+echo.
+set /p keep_data="Do you want to keep your data (database, logs, certificates)? [y/n]: "
+if /i "%keep_data%"=="y" (
+ echo Keeping data files...
+ REM Remove only executable and service files
+ if exist "%INSTALL_DIR%\sui.exe" del "%INSTALL_DIR%\sui.exe" >nul 2>&1
+ if exist "%INSTALL_DIR%\s-ui-service.exe" del "%INSTALL_DIR%\s-ui-service.exe" >nul 2>&1
+ if exist "%INSTALL_DIR%\s-ui-service.xml" del "%INSTALL_DIR%\s-ui-service.xml" >nul 2>&1
+ if exist "%INSTALL_DIR%\winsw.exe" del "%INSTALL_DIR%\winsw.exe" >nul 2>&1
+ if exist "%INSTALL_DIR%\*.bat" del "%INSTALL_DIR%\*.bat" >nul 2>&1
+ if exist "%INSTALL_DIR%\*.xml" del "%INSTALL_DIR%\*.xml" >nul 2>&1
+ if exist "%INSTALL_DIR%\*.md" del "%INSTALL_DIR%\*.md" >nul 2>&1
+ echo Data files preserved in: %INSTALL_DIR%
+) else (
+ echo Removing all files...
+ REM Remove entire installation directory
+ if exist "%INSTALL_DIR%" (
+ rmdir /s /q "%INSTALL_DIR%" >nul 2>&1
+ if exist "%INSTALL_DIR%" (
+ echo Warning: Some files could not be removed. Please manually delete: %INSTALL_DIR%
+ ) else (
+ echo All files removed successfully
+ )
+ )
+)
+
+REM Remove firewall rules
+echo Removing firewall rules...
+netsh advfirewall firewall delete rule name="S-UI Panel" >nul 2>&1
+netsh advfirewall firewall delete rule name="S-UI Subscription" >nul 2>&1
+
+echo.
+echo ========================================
+echo Uninstallation completed!
+echo ========================================
+echo.
+echo S-UI has been uninstalled from your system.
+echo.
+if /i "%keep_data%"=="y" (
+ echo Your data has been preserved in: %INSTALL_DIR%
+ echo You can safely delete this directory if you no longer need the data.
+)
+echo.
+echo Thank you for using S-UI!
+echo.
+pause