commit 4baa10ce06da178e90e31f1dcde0138237fdf81c Author: SamNet-dev Date: Tue Feb 24 01:38:26 2026 -0600 feat: migrate tunnelforge to Gitea — update all URLs and self-update mechanism diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e336b82 --- /dev/null +++ b/LICENSE @@ -0,0 +1,677 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + TunnelForge — SSH Tunnel Manager + Copyright (C) 2026 SamNet Technologies, LLC + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d7518f --- /dev/null +++ b/README.md @@ -0,0 +1,2677 @@ +

+

+  ▀▀█▀▀ █  █ █▄ █ █▄ █ █▀▀ █   █▀▀ █▀█ █▀█ █▀▀ █▀▀
+    █   █  █ █ ▀█ █ ▀█ █▀▀ █   █▀  █ █ █▀█ █ █ █▀▀
+    █    ▀▀  █  █ █  █ ▀▀▀ ▀▀▀ █   ▀▀▀ █ █ ▀▀▀ ▀▀▀
+  
+

+ +

The Ultimate Single-File SSH Tunnel Manager

+ +

+ License: GPL v3 + Bash 4.3+ + Platform: Linux + Version 1.0.0 + Zero Dependencies +

+ +

+ One script. Every tunnel type. Full TUI. Live dashboard.
+ SOCKS5 • Local Forward • Remote Forward • Jump Hosts • TLS Obfuscation • Telegram Bot • Kill Switch • DNS Leak Protection +

+ +--- + +## Quick Start + +**One-line install:** +```bash +curl -fsSL https://git.samnet.dev/SamNet-dev/tunnelforge/raw/branch/main/tunnelforge.sh -o tunnelforge.sh && sudo bash tunnelforge.sh install +``` + +**Or clone the repo:** +```bash +git clone https://git.samnet.dev/SamNet-dev/tunnelforge.git +cd tunnelforge +sudo bash tunnelforge.sh install +``` + +**Launch the interactive menu:** +```bash +tunnelforge menu +``` + +**Or use CLI directly:** +```bash +tunnelforge create # Create your first tunnel +tunnelforge start my-tunnel # Start it +tunnelforge dashboard # Watch it live +``` + +

+ TunnelForge Main Menu +
+ Interactive TUI — manage tunnels, security, services, and more from a single menu +

+ +--- + +## Why TunnelForge? + +Most SSH tunnel tools are either too simple (just a wrapper around `ssh -D`) or too complex (requiring Docker, Go, Python, or a dozen config files). TunnelForge is different: + +| | TunnelForge | Others | +|---|---|---| +| **Installation** | Single bash file, zero dependencies | Python/Go/Node.js + package managers | +| **Interface** | Full TUI menu + CLI + live dashboard | CLI-only or web UI requiring a browser | +| **Security** | DNS leak protection, kill switch, audit scoring | Basic SSH only | +| **Obfuscation** | TLS wrapping + PSK to bypass DPI/censorship | Not available | +| **Monitoring** | Real-time sparkline bandwidth graphs | Log files | +| **Notifications** | Telegram bot with remote commands | None | +| **Persistence** | Auto-generated systemd services | Manual configuration | +| **Education** | Built-in Learn menu with diagrams | External docs | +| **Client Sharing** | One-click generated scripts (Linux + Windows) | Manual setup | + +**TunnelForge is the most complete SSH tunnel manager available as a single file.** + +--- + +
+

Features Overview

+ +### Tunnel Management +- **SOCKS5 Dynamic Proxy** (`-D`) — Route all traffic through SSH server +- **Local Port Forwarding** (`-L`) — Access remote services locally +- **Remote/Reverse Forwarding** (`-R`) — Expose local services remotely +- **Jump Host / Multi-Hop** (`-J`) — Chain through bastion servers +- **AutoSSH Integration** — Automatic reconnection on drop +- **ControlMaster Multiplexing** — Reuse SSH connections +- **Multi-tunnel Support** — Run dozens of tunnels simultaneously + +### Security +- **DNS Leak Protection** — Rewrites resolv.conf + locks with `chattr` +- **Kill Switch** — iptables rules block all traffic if tunnel drops (IPv4 + IPv6) +- **6-Point Security Audit** — Scored assessment of your tunnel security posture +- **SSH Key Generation** — Create ed25519, RSA, or ECDSA keys +- **SSH Key Deployment** — One-command deploy to remote servers +- **Host Fingerprint Verification** — Verify server identity before connecting +- **Server Hardening** — Automated sshd, firewall, fail2ban, sysctl configuration + +### Monitoring & Dashboard +- **Live TUI Dashboard** — Real-time tunnel status with auto-refresh +- **Sparkline Bandwidth Graphs** — ASCII sparklines (▁▂▃▄▅▆▇█) for RX/TX +- **Traffic Counters** — Total bytes transferred per tunnel +- **Uptime Tracking** — Per-tunnel uptime display +- **Connection Quality** — Latency measurement with color-coded indicators +- **Speed Test** — Built-in download speed test through tunnel +- **Pagination** — Navigate across pages when running many tunnels + +### Telegram Bot +- **Real-time Alerts** — Start, stop, fail, reconnect notifications +- **Remote Commands** — `/tf_status`, `/tf_list`, `/tf_ip`, `/tf_config` and more +- **Periodic Reports** — Scheduled status reports to your phone +- **Client Sharing** — Send connection scripts + PSK via Telegram +- **SOCKS5 Routing** — Bot works through your tunnel when Telegram is blocked + +### TLS Obfuscation (Anti-Censorship) +- **Outbound TLS Wrapping** — SSH traffic disguised as HTTPS via stunnel +- **Inbound PSK Protection** — SOCKS5 listener secured with Pre-Shared Key +- **Client Script Generator** — Auto-generate connect scripts for Linux + Windows +- **One-Click Server Setup** — Automated stunnel installation and configuration +- **DPI Bypass** — Traffic appears as normal HTTPS on port 443 + +### System Integration +- **Systemd Service Generator** — Auto-create hardened unit files +- **Backup & Restore** — Full configuration backup with rotation +- **Server Setup Wizard** — Harden a fresh server for receiving tunnels +- **Profile Management** — Create, edit, delete, import tunnel profiles +- **Log Management** — Per-tunnel logs with rotation +- **Clean Uninstall** — Complete removal of all files, services, and configs + +### Built-in Education +- **Learn Menu** — 9 interactive lessons on SSH tunneling concepts +- **Scenario Examples** — Step-by-step real-world use cases with diagrams +- **ASCII Diagrams** — Visual traffic flow for every tunnel type + +
+ +--- + +
+

Tunnel Types Explained

+ +### SOCKS5 Dynamic Proxy (`-D`) + +Route **all** your TCP traffic through a remote SSH server. Your traffic appears to originate from the server's IP address. + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Client │ SSH │ SSH │ Direct │ Target │ +│ ├─────────►│ Server ├─────────►│ Website │ +│ :1080 │ Encrypted│ │ │ │ +└──────────┘ └──────────┘ └──────────┘ + ▲ + Browser + SOCKS5 +``` + +**Use cases:** Private browsing, bypass geo-restrictions, hide your IP, route traffic through a different country. + +```bash +tunnelforge create # Select "SOCKS5 Proxy" → set port 1080 +tunnelforge start my-proxy +# Configure browser: SOCKS5 proxy → 127.0.0.1:1080 +curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me +``` + +--- + +### Local Port Forwarding (`-L`) + +Access a **remote service** as if it were running locally. Map a local port to a service behind the SSH server. + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Client │ SSH │ SSH │ Local │ MySQL │ +│ ├─────────►│ Server ├─────────►│ :3306 │ +│ :3306 │ Encrypted│ │ Network │ │ +└──────────┘ └──────────┘ └──────────┘ + ▲ + mysql -h + 127.0.0.1 +``` + +**Use cases:** Access remote databases, internal web apps, admin panels behind firewalls. + +```bash +tunnelforge create # Select "Local Forward" → local 3306 → remote db:3306 +tunnelforge start db-tunnel +mysql -h 127.0.0.1 -P 3306 -u admin -p +``` + +--- + +### Remote/Reverse Forwarding (`-R`) + +Expose a **local service** to the outside world through a remote SSH server. Users connect to the server and reach your local machine. + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Local │ SSH │ SSH │ Public │ Users │ +│ Dev App │◄────────►│ Server │◄─────────│ on Web │ +│ :3000 │ Encrypted│ :9090 │ │ │ +└──────────┘ └──────────┘ └──────────┘ +``` + +**Use cases:** Webhook development, share local app for testing, NAT traversal, demo to clients. + +```bash +tunnelforge create # Select "Remote Forward" → remote 9090 ← local 3000 +tunnelforge start dev-share +# Others access: http://your-server:9090 +``` + +--- + +### Jump Hosts / Multi-Hop (`-J`) + +Reach servers behind **multiple layers** of firewalls by chaining through intermediate SSH servers. + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Client │────►│ Bastion │────►│ Jump 2 │────►│ Target │ +│ │ SSH │ Server │ SSH │ Server │ SSH │ Server │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ +``` + +**Use cases:** Corporate networks, multi-tier architectures, isolated environments, high-security zones. + +```bash +tunnelforge create # Select "Jump Host" → jumps: admin@bastion:22 +tunnelforge start corp-tunnel +``` + +
+ +--- + +
+

Installation

+ +### Requirements + +| Requirement | Details | +|---|---| +| **OS** | Linux (Ubuntu, Debian, CentOS, Fedora, Arch, Alpine) | +| **Bash** | 4.3 or higher | +| **Privileges** | Root access for installation | +| **SSH** | OpenSSH client (pre-installed on most systems) | +| **Optional** | `autossh` (auto-reconnect), `stunnel` (TLS obfuscation), `sshpass` (password auth) | + +### Install + +```bash +# Option 1: Git clone +git clone https://git.samnet.dev/SamNet-dev/tunnelforge.git +cd tunnelforge +sudo bash tunnelforge.sh install + +# Option 2: Direct download +curl -fsSL https://git.samnet.dev/SamNet-dev/tunnelforge/raw/branch/main/tunnelforge.sh -o tunnelforge.sh +sudo bash tunnelforge.sh install +``` + +### Verify Installation + +```bash +tunnelforge version +# TunnelForge v1.0.0 +``` + +### Directory Structure + +After installation, TunnelForge creates: + +``` +/opt/tunnelforge/ +├── tunnelforge.sh # Main script +├── config/ +│ └── tunnelforge.conf # Global configuration +├── profiles/ +│ └── *.conf # Tunnel profiles +├── pids/ # Running tunnel PIDs +├── logs/ # Per-tunnel log files +├── backups/ # Configuration backups +├── data/ +│ ├── bandwidth/ # Bandwidth history (sparklines) +│ └── reconnects/ # Reconnect statistics +└── sockets/ # SSH ControlMaster sockets +``` + +A symlink is created at `/usr/local/bin/tunnelforge` for global access. + +
+ +--- + +
+

Quick Examples

+ +### Browse Privately via SOCKS5 + +```bash +tunnelforge create +# → Name: private-proxy +# → Type: SOCKS5 +# → Server: user@myserver.com +# → Port: 1080 + +tunnelforge start private-proxy + +# Test it +curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me + +# Configure Firefox: Settings → Network → Manual Proxy → SOCKS5: 127.0.0.1:1080 +``` + +### Access a Remote Database + +```bash +tunnelforge create +# → Name: prod-db +# → Type: Local Forward +# → Server: admin@db-server.internal +# → Local port: 3306 → Remote: localhost:3306 + +tunnelforge start prod-db +mysql -h 127.0.0.1 -P 3306 -u dbuser -p +``` + +### Share Your Local Dev Server + +```bash +tunnelforge create +# → Name: demo-share +# → Type: Remote Forward +# → Server: user@public-vps.com +# → Remote port: 8080 ← Local: localhost:3000 + +tunnelforge start demo-share +# Share with anyone: http://public-vps.com:8080 +``` + +### Chain Through Jump Hosts + +```bash +tunnelforge create +# → Name: corp-access +# → Type: Jump Host +# → Jump: admin@bastion.corp.com:22 +# → Target: user@internal-server:22 +# → SOCKS5 port: 1080 + +tunnelforge start corp-access +``` + +### Bypass Censorship with TLS Obfuscation + +```bash +# First, set up stunnel on your VPS +tunnelforge obfs-setup my-tunnel + +# Now SSH traffic is wrapped in TLS on port 443 +# DPI sees: normal HTTPS traffic +# Reality: SSH tunnel inside TLS +tunnelforge start my-tunnel + +# Share with others +tunnelforge client-script my-tunnel # Generates connect scripts +tunnelforge telegram share my-tunnel # Send scripts via Telegram +``` + +
+ +--- + +
+

CLI Reference

+ +### Tunnel Commands + +| Command | Description | +|---|---| +| `tunnelforge start ` | Start a tunnel | +| `tunnelforge stop ` | Stop a tunnel | +| `tunnelforge restart ` | Restart a tunnel | +| `tunnelforge start-all` | Start all autostart tunnels | +| `tunnelforge stop-all` | Stop all running tunnels | +| `tunnelforge status` | Show all tunnel statuses | +| `tunnelforge dashboard` | Launch live TUI dashboard | +| `tunnelforge logs [name]` | Tail tunnel logs | + +### Profile Commands + +| Command | Description | +|---|---| +| `tunnelforge create` | Create a new tunnel profile (wizard) | +| `tunnelforge list` | List all profiles | +| `tunnelforge delete ` | Delete a profile | + +### Security Commands + +| Command | Description | +|---|---| +| `tunnelforge audit` | Run 6-point security audit | +| `tunnelforge key-gen [type]` | Generate SSH key (ed25519/rsa/ecdsa) | +| `tunnelforge key-deploy ` | Deploy SSH key to server | +| `tunnelforge fingerprint [port]` | Verify SSH host fingerprint | + +### Telegram Commands + +| Command | Description | +|---|---| +| `tunnelforge telegram setup` | Configure Telegram bot | +| `tunnelforge telegram test` | Send test notification | +| `tunnelforge telegram status` | Show notification config | +| `tunnelforge telegram send ` | Send a message | +| `tunnelforge telegram report` | Send status report | +| `tunnelforge telegram share [name]` | Share client scripts via Telegram | + +### Service Commands + +| Command | Description | +|---|---| +| `tunnelforge service ` | Generate systemd service | +| `tunnelforge service enable` | Enable and start service | +| `tunnelforge service disable` | Disable and stop service | +| `tunnelforge service status` | Show service status | +| `tunnelforge service remove` | Remove service file | + +### System Commands + +| Command | Description | +|---|---| +| `tunnelforge menu` | Launch interactive TUI | +| `tunnelforge install` | Install TunnelForge | +| `tunnelforge server-setup` | Harden server for tunnels | +| `tunnelforge obfs-setup ` | Set up TLS obfuscation | +| `tunnelforge client-config ` | Show client connection config | +| `tunnelforge client-script ` | Generate client scripts | +| `tunnelforge backup` | Backup all configs | +| `tunnelforge restore [file]` | Restore from backup | +| `tunnelforge uninstall` | Remove everything | +| `tunnelforge version` | Show version | +| `tunnelforge help` | Show help | + +
+ +--- + +
+

Interactive TUI Menu

+ +Launch with `tunnelforge menu` or just `tunnelforge` (no arguments): + +``` +╔══════════════════════════════════════════════════════════════╗ +║ TunnelForge — Main Menu ║ +╚══════════════════════════════════════════════════════════════╝ + + ── Tunnels ── + 1) Create new tunnel Setup wizard + 2) Start a tunnel Launch SSH tunnel + 3) Stop a tunnel Terminate tunnel + 4) Start All tunnels Launch autostart tunnels + 5) Stop All tunnels Terminate all + + ── Monitoring ── + 6) Status Show tunnel statuses + 7) Dashboard Live TUI dashboard + + ── Management ── + 8) Profiles Manage tunnel profiles + 9) Settings Configure defaults + s) Services Systemd service manager + b) Backup / Restore Manage backups + + ── Security ── + x) Security Audit Check security posture + k) SSH Key Management Generate & deploy keys + f) Fingerprint Check Verify host fingerprints + + ── Extras ── + t) Telegram Notification settings + c) Client Configs TLS+PSK connection info + e) Examples Real-world scenarios + l) Learn SSH tunnel concepts + a) About Version & info + + u) Uninstall + q) Quit + + Select: +``` + +Navigate by pressing a single key. No Enter needed. Every menu is keyboard-driven. + +
+ +--- + +
+

Live Dashboard

+ +Launch with `tunnelforge dashboard` or press `7` in the main menu: + +

+ TunnelForge Live Dashboard +
+ Live dashboard with sparkline bandwidth graphs, active connections, reconnect log, and system resources +

+ +``` +╔══════════════════════════════════════════════════════════════╗ +║ TunnelForge Dashboard v1.0.0 ║ +║ Page 1/2 │ 2026-02-07 14:32:15 ║ +╚══════════════════════════════════════════════════════════════╝ + + NAME TYPE STATUS LOCAL UPTIME + ──────────────────────────────────────────────────────────── + iran-proxy SOCKS5 ● ALIVE 127.0.0.1:1080 2h 8m + RX ▁▂▃▅▇█▇▅▃▂ 412.3 MB TX ▁▁▂▃▅▇▅▃ 82.1 MB + + t1-socks5 SOCKS5 ● ALIVE 127.0.0.1:4001 2h 9m + RX ▁▁▁▂▂▃▂▁▁▁ 96.7 KB TX ▁▁▁▁▂▂▁▁ 12.3 KB + + db-tunnel LOCAL ■ STOPPED 127.0.0.1:3306 — + + dev-share REMOTE ● ALIVE 0.0.0.0:9090 45m + RX ▁▂▂▃▃▂▁▁▁▁ 1.2 MB TX ▁▁▁▂▃▅▃▂ 890 KB + + ── Active Connections ── + iran-proxy : 3 connections + t1-socks5 : 1 connection + + ── Recent Log ── + 14:32:10 [info] Tunnel 'iran-proxy' reconnected (AutoSSH) + 14:30:05 [info] Speed test: 12.4 Mbps via iran-proxy + + s=start t=stop r=restart c=create p=speed g=qlty q=quit [] +``` + +### Dashboard Keyboard Controls + +| Key | Action | +|---|---| +| `s` | Start a tunnel | +| `t` | Stop a tunnel | +| `r` | Restart all running tunnels | +| `c` | Create a new profile | +| `p` | Run speed test | +| `g` | Check connection quality | +| `[` / `]` | Previous / next page | +| `q` | Exit dashboard | + +### What It Shows +- **Status** — Live running/stopped state per tunnel +- **Uptime** — How long each tunnel has been connected +- **Sparkline Graphs** — Real-time bandwidth visualization using 8 levels (▁▂▃▄▅▆▇█) +- **Traffic Totals** — Cumulative RX/TX bytes per tunnel +- **Active Connections** — Number of established TCP connections through each tunnel +- **Recent Logs** — Last 4 log entries across all tunnels +- **Pagination** — Automatically pages when running 5+ tunnels + +
+ +--- + +
+

Security Features

+ +### DNS Leak Protection + +Prevents DNS queries from bypassing the tunnel and revealing your real location. + +**How it works:** +1. Backs up your current `/etc/resolv.conf` +2. Rewrites it to use only tunnel-safe DNS servers (default: 8.8.8.8, 8.8.4.4) +3. Locks the file with `chattr +i` (immutable) to prevent system overrides +4. Automatically restores original DNS when tunnel stops + +```bash +# Enable per-profile in the wizard or editor +# Verify with: +tunnelforge audit # Check DNS leak protection status +``` + +### Kill Switch + +If the tunnel drops, **all internet traffic is blocked** to prevent data leaks. + +**How it works:** +1. Creates a custom `TUNNELFORGE` iptables chain +2. Allows only SSH tunnel traffic + loopback +3. Blocks everything else (IPv4 + IPv6) +4. Automatically removes rules when tunnel stops or is manually disabled + +```bash +# Enable per-profile in the wizard or editor +# Verify with: +tunnelforge audit # Check kill switch status +``` + +### SSH Key Management + +```bash +# Generate a new key +tunnelforge key-gen ed25519 # Recommended (fast, secure) +tunnelforge key-gen rsa # 4096-bit RSA (broad compatibility) +tunnelforge key-gen ecdsa # ECDSA alternative + +# Deploy to a tunnel's server +tunnelforge key-deploy my-tunnel +``` + +### Host Fingerprint Verification + +Verify a server's SSH fingerprint before trusting it: + +```bash +tunnelforge fingerprint myserver.com 22 +# Displays SHA256 and MD5 fingerprints +# Compare against your server's known fingerprint +``` + +### 6-Point Security Audit + +Scores your security posture out of 100 points: + +```bash +tunnelforge audit +``` + +| Check | What It Verifies | +|---|---| +| SSH Key Permissions | Private keys are 600 or 400 | +| SSH Directory | `~/.ssh` is 700 | +| DNS Leak Protection | Active DNS protection on running tunnels | +| Kill Switch | iptables chain is active | +| Tunnel Integrity | PIDs are valid, processes running | +| System Packages | Required security tools installed | + +### Server Hardening + +Prepare a fresh server to receive SSH tunnels securely: + +```bash +tunnelforge server-setup +``` + +**What it configures:** +- **SSH daemon** — Disable password auth, disable root login, custom port +- **Firewall** — UFW/iptables rules for SSH + tunnel ports only +- **Fail2ban** — Auto-ban after failed login attempts +- **Kernel** — Sysctl hardening (SYN flood protection, ICMP redirects, IP forwarding) + +
+ +--- + +
+

TLS Obfuscation (Anti-Censorship)

+ +In networks with Deep Packet Inspection (DPI), SSH traffic can be detected and blocked. TunnelForge wraps your SSH connection inside a TLS layer, making it look like normal HTTPS traffic. + +### How It Works + +``` +Without TLS Obfuscation: +Client ──── SSH (detectable) ────► VPS ← DPI can block this + +With TLS Obfuscation: +Client ──── TLS/443 (looks like HTTPS) ────► VPS ──── SSH ────► Internet + ▲ + stunnel + unwraps TLS +``` + +### Setup (Server Side) + +```bash +# Automatically installs stunnel, generates TLS cert, configures port mapping +tunnelforge obfs-setup my-tunnel +``` + +This will: +1. Install `stunnel` on the remote server +2. Generate a self-signed TLS certificate +3. Map port 443 (TLS) → port 22 (SSH) +4. Enable as a systemd service +5. Open the firewall port + +### Inbound PSK Protection + +Protect your local SOCKS5 listener with a Pre-Shared Key. Without the PSK, nobody can connect to your tunnel — even if they find the port. + +``` +Client with PSK ──── TLS+PSK ────► Your Machine ──── SOCKS5 ────► Tunnel +Unauthorized ──── TLS ────► Your Machine ──── REJECTED +``` + +### Share With Others + +Generate standalone connect scripts that anyone can use: + +```bash +# Generate Linux + Windows scripts +tunnelforge client-script my-tunnel + +# Share via Telegram (sends scripts + PSK + instructions) +tunnelforge telegram share my-tunnel +``` + +**Generated scripts include:** +- `tunnelforge-connect.sh` — Linux bash script (`./connect.sh start|stop|status`) +- `tunnelforge-connect.ps1` — Windows PowerShell script + +Both scripts auto-configure stunnel, create PSK files, and manage the connection lifecycle. + +### Standalone Windows Client + +TunnelForge also ships with **[`windows-client/tunnelforge-client.bat`](windows-client/tunnelforge-client.bat)** — a ready-to-distribute Windows batch client. Users just double-click it, paste their connection details, and they're connected. No PowerShell required. + +**Features:** +- Interactive setup — prompts for server, port, and PSK +- Auto-installs stunnel (via winget, Chocolatey, or manual download link) +- Saves connection for instant reconnect +- Commands: `tunnelforge-client.bat stop` / `status` +- Prints browser proxy setup instructions (Firefox + Chrome) + +``` +How to distribute: +1. Send windows-client/tunnelforge-client.bat to the user +2. Give them the PSK + server info (from: tunnelforge client-config ) +3. User double-clicks the .bat → enters details → connected +``` + +
+ +--- + +
+

Telegram Bot

+ +Get real-time tunnel notifications on your phone and control tunnels remotely. + +### Setup + +```bash +tunnelforge telegram setup +``` + +1. **Create a bot** — Talk to [@BotFather](https://t.me/BotFather) on Telegram, send `/newbot` +2. **Enter your token** — Paste the bot token into TunnelForge +3. **Get your Chat ID** — Send `/start` to your bot, TunnelForge auto-detects your ID +4. **Done** — You'll receive a test message confirming setup + +### Notifications You'll Receive + +| Event | Message | +|---|---| +| Tunnel started | Profile name, PID, tunnel type | +| Tunnel stopped | Profile name | +| Tunnel failed | Error details | +| Tunnel reconnected | AutoSSH auto-recovery | +| Security alert | DNS leak or kill switch event | +| Status report | All tunnels overview (periodic) | + +### Bot Commands + +Send these commands to your bot from Telegram: + +| Command | Response | +|---|---| +| `/tf_status` | All tunnel statuses | +| `/tf_list` | List all profiles | +| `/tf_ip` | Server's public IP | +| `/tf_config` | Client connection configs (PSK info) | +| `/tf_uptime` | Server uptime | +| `/tf_report` | Full status report | +| `/tf_help` | Available commands | + +### Configuration + +```bash +tunnelforge telegram status # View current config +tunnelforge telegram test # Send test message +tunnelforge telegram send "Hello from TunnelForge!" +``` + +Enable periodic reports (e.g., every hour): +``` +Settings → Telegram → Toggle Status Reports → Set Interval +``` + +
+ +--- + +
+

Systemd Services

+ +Make your tunnels survive reboots with auto-generated systemd service files. + +### Usage + +```bash +# Generate a service file +tunnelforge service my-tunnel + +# Enable and start (survives reboot) +tunnelforge service my-tunnel enable + +# Check status +tunnelforge service my-tunnel status + +# Disable +tunnelforge service my-tunnel disable + +# Remove service file +tunnelforge service my-tunnel remove +``` + +### Generated Service Features + +The auto-generated unit file includes security hardening: + +- `ProtectSystem=strict` — Read-only filesystem +- `ProtectHome=tmpfs` — Isolated home directory +- `PrivateTmp=true` — Private temp directory +- `CAP_NET_ADMIN` — Only if kill switch is enabled +- `CAP_LINUX_IMMUTABLE` — Only if DNS leak protection is enabled +- Automatic restart policies +- Proper start/stop timeouts + +
+ +--- + +
+

Backup & Restore

+ +### Backup + +```bash +tunnelforge backup +``` + +Creates a timestamped `.tar.gz` archive containing: +- All tunnel profiles +- Global configuration +- SSH keys (if stored in TunnelForge directory) +- Systemd service files + +Backups are stored in `/opt/tunnelforge/backups/` with automatic rotation. + +### Restore + +```bash +# Restore from latest backup +tunnelforge restore + +# Restore from specific file +tunnelforge restore /path/to/backup.tar.gz +``` + +
+ +--- + +
+

Configuration Reference

+ +### Global Config (`/opt/tunnelforge/config/tunnelforge.conf`) + +| Setting | Default | Description | +|---|---|---| +| `SSH_DEFAULT_USER` | `root` | Default SSH username | +| `SSH_DEFAULT_PORT` | `22` | Default SSH port | +| `SSH_DEFAULT_KEY` | — | Default identity key path | +| `SSH_CONNECT_TIMEOUT` | `10` | Connection timeout (seconds) | +| `AUTOSSH_ENABLED` | `true` | Enable AutoSSH by default | +| `AUTOSSH_POLL` | `30` | AutoSSH poll interval (seconds) | +| `CONTROLMASTER_ENABLED` | `false` | SSH connection multiplexing | +| `LOG_LEVEL` | `info` | Logging verbosity (debug/info/warn/error) | +| `DASHBOARD_REFRESH` | `3` | Dashboard refresh rate (seconds) | +| `DNS_LEAK_PROTECTION` | `false` | DNS leak protection default | +| `KILL_SWITCH` | `false` | Kill switch default | +| `TELEGRAM_ENABLED` | `false` | Enable Telegram notifications | +| `TELEGRAM_BOT_TOKEN` | — | Bot token from @BotFather | +| `TELEGRAM_CHAT_ID` | — | Your Telegram chat ID | +| `TELEGRAM_ALERTS` | `true` | Send tunnel event alerts | +| `TELEGRAM_PERIODIC_STATUS` | `false` | Send periodic reports | +| `TELEGRAM_STATUS_INTERVAL` | `3600` | Report interval (seconds) | + +### Profile Fields + +Each tunnel profile (`/opt/tunnelforge/profiles/.conf`) contains: + +| Field | Description | +|---|---| +| `TUNNEL_TYPE` | `socks5`, `local`, `remote`, or `jump` | +| `SSH_HOST` | Server hostname or IP | +| `SSH_PORT` | SSH port (default 22) | +| `SSH_USER` | SSH username | +| `SSH_PASSWORD` | SSH password (optional) | +| `IDENTITY_KEY` | Path to SSH private key | +| `LOCAL_BIND_ADDR` | Local bind address | +| `LOCAL_PORT` | Local port number | +| `REMOTE_HOST` | Remote target host | +| `REMOTE_PORT` | Remote target port | +| `JUMP_HOSTS` | Comma-separated jump hosts | +| `SSH_OPTIONS` | Extra SSH flags | +| `AUTOSSH_ENABLED` | AutoSSH toggle | +| `DNS_LEAK_PROTECTION` | DNS protection toggle | +| `KILL_SWITCH` | Kill switch toggle | +| `AUTOSTART` | Start on boot | +| `OBFS_MODE` | `none` or `stunnel` | +| `OBFS_PORT` | TLS obfuscation port | +| `OBFS_LOCAL_PORT` | Inbound TLS+PSK port | +| `OBFS_PSK` | Pre-Shared Key (hex) | +| `DESCRIPTION` | Profile description | + +
+ +--- + +
+

Real-World Scenarios

+ +Each scenario below is expandable and shows the **exact wizard steps** you'll follow. Both SSH-only and TLS-encrypted variants are covered where applicable. + +
+

Scenario 1: Private Browsing via SOCKS5

+ +**Goal:** Route all your browser traffic through a VPS so websites see the VPS IP instead of yours. + +``` +Your PC :1080 ──── SSH (Encrypted) ────► VPS ──── Direct ────► Internet + ▲ + Websites see + the VPS IP +``` + +**What you need:** A VPS or remote server with SSH access. + +#### Wizard Steps (SSH-only) + +``` +tunnelforge create +``` + +| Step | Prompt | What to enter | +|---|---|---| +| 1 | Profile name | `private-proxy` | +| 2 | Tunnel type | `1` — SOCKS5 Proxy | +| 3 | SSH host | Your VPS IP (e.g. `45.33.32.10`) | +| 4 | SSH port | `22` (default) | +| 5 | SSH user | `root` (or your username) | +| 6 | SSH password | Your password (or Enter to skip for key auth) | +| 7 | Identity key | `~/.ssh/id_ed25519` (or Enter to skip) | +| 8 | Auth test | Automatic — verifies connection | +| 9a | Bind address | `127.0.0.1` (local only) or `0.0.0.0` (share with LAN) | +| 9b | SOCKS5 port | `1080` | +| 10 | Connection mode | `1` — Regular SSH | +| 11 | Inbound protection | `1` — None | +| 12 | AutoSSH | `y` (recommended) | +| 13 | Save & start | `y` then `y` | + +#### Wizard Steps (TLS-encrypted — for censored networks) + +Same as above, but at step 10: + +| Step | Prompt | What to enter | +|---|---|---| +| 10 | Connection mode | `2` — TLS Encrypted | +| 10a | TLS port | `443` (looks like HTTPS) | +| 10b | Setup stunnel now? | `y` (auto-installs on server) | +| 11 | Inbound protection | `1` — None (you're the only user) | + +#### After Starting + +**Firefox:** +1. Settings → search "proxy" → Manual proxy configuration +2. SOCKS Host: `127.0.0.1` — Port: `1080` +3. Select "SOCKS v5" +4. Check "Proxy DNS when using SOCKS v5" + +**Chrome:** +```bash +google-chrome --proxy-server="socks5://127.0.0.1:1080" +``` + +**Test it works:** +```bash +curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me +# Should show your VPS IP, not your real IP +``` + +
+ +
+

Scenario 2: Access a Remote Database

+ +**Goal:** Access MySQL/PostgreSQL running on your VPS as if it were on your local machine. + +``` +Your PC :3306 ──── SSH (Encrypted) ────► VPS ──── Local ────► MySQL :3306 +``` + +**What you need:** A VPS with a database running (e.g. MySQL on port 3306). + +#### Wizard Steps + +``` +tunnelforge create +``` + +| Step | Prompt | What to enter | +|---|---|---| +| 1 | Profile name | `prod-db` | +| 2 | Tunnel type | `2` — Local Port Forward | +| 3 | SSH host | Your VPS IP (e.g. `45.33.32.10`) | +| 4 | SSH port | `22` | +| 5 | SSH user | `root` | +| 6 | SSH password | Your password (or Enter to skip) | +| 7 | Identity key | Enter to skip (or path to key) | +| 8 | Auth test | Automatic | +| 9a | Bind address | `127.0.0.1` | +| 9b | Local port | `3306` (port on YOUR machine) | +| 9c | Remote host | `127.0.0.1` (means "on the VPS itself") | +| 9d | Remote port | `3306` (MySQL port on VPS) | +| 10 | Connection mode | `1` — Regular SSH | +| 11 | Inbound protection | `1` — None | +| 12 | AutoSSH | `y` | +| 13 | Save & start | `y` then `y` | + +#### After Starting + +```bash +# MySQL +mysql -h 127.0.0.1 -P 3306 -u dbuser -p + +# PostgreSQL (change ports to 5432) +psql -h 127.0.0.1 -p 5432 -U postgres + +# Redis (change ports to 6379) +redis-cli -h 127.0.0.1 -p 6379 + +# Web admin panel (change ports to 8080) +# Open browser: http://127.0.0.1:8080 +``` + +#### Common Variations + +| Service | Local Port | Remote Port | +|---|---|---| +| MySQL | 3306 | 3306 | +| PostgreSQL | 5432 | 5432 | +| Redis | 6379 | 6379 | +| Web panel | 8080 | 8080 | +| MongoDB | 27017 | 27017 | + +> **Tip:** Set Remote host to another IP on the VPS network (e.g. `10.0.0.5`) to reach a database on a private subnet that only the VPS can access. + +
+ +
+

Scenario 3: Share Your Local Dev Server

+ +**Goal:** You have a website running locally (e.g. on port 3000) and want others on the internet to access it through your VPS. + +``` +Local App :3000 ◄──── SSH (Encrypted) ────► VPS :9090 ◄──── Users on Web +``` + +**What you need:** A local service running + a VPS with a public IP. + +#### Wizard Steps + +``` +tunnelforge create +``` + +| Step | Prompt | What to enter | +|---|---|---| +| 1 | Profile name | `dev-share` | +| 2 | Tunnel type | `3` — Remote/Reverse Forward | +| 3 | SSH host | Your VPS IP (e.g. `45.33.32.10`) | +| 4 | SSH port | `22` | +| 5 | SSH user | `root` | +| 6 | SSH password | Your password | +| 7 | Identity key | Enter to skip | +| 8 | Auth test | Automatic | +| 9a | Remote bind | `0.0.0.0` (public access — requires `GatewayPorts yes` in sshd) | +| 9b | Remote port | `9090` (port on VPS others connect to) | +| 9c | Local host | `127.0.0.1` (this machine) | +| 9d | Local port | `3000` (your running app) | +| 10 | Connection mode | `1` — Regular SSH | +| 11 | Inbound protection | `1` — None | +| 12 | AutoSSH | `y` | +| 13 | Save & start | `y` then `y` | + +#### Important: Enable GatewayPorts + +If using `0.0.0.0` bind (public access), your VPS sshd needs: + +```bash +# On VPS: edit /etc/ssh/sshd_config +GatewayPorts yes + +# Then restart sshd +sudo systemctl restart sshd +``` + +If using `127.0.0.1` bind, the port is only accessible from the VPS itself. + +#### After Starting + +```bash +# Make sure your local service is running first: +python3 -m http.server 3000 # or: npm start, etc. + +# Test from VPS (SSH into it): +curl http://localhost:9090 + +# Test from anywhere (if bind is 0.0.0.0): +curl http://45.33.32.10:9090 + +# Share with clients: +# "Visit http://45.33.32.10:9090 to see the demo" +``` + +#### Common Use Cases + +| Use Case | Local Port | What's Exposed | +|---|---|---| +| Node.js dev server | 3000 | Web app | +| React/Vue dev server | 5173 | Frontend | +| Webhook receiver | 8080 | API endpoint | +| SSH to home PC | 22 | SSH access | + +
+ +
+

Scenario 4: Multi-Hop Through Bastion / Jump Hosts

+ +**Goal:** Reach a server that is NOT directly accessible from the internet. You hop through one or more intermediate servers. + +``` +Your PC ── SSH ──► Bastion (public) ── SSH ──► Target (hidden) +``` + +**What you need:** SSH access to the jump/bastion server + the target server. + +#### Wizard Steps (SOCKS5 at target) + +``` +tunnelforge create +``` + +| Step | Prompt | What to enter | +|---|---|---| +| 1 | Profile name | `corp-access` | +| 2 | Tunnel type | `4` — Jump Host | +| 3 | SSH host | **Target** IP (e.g. `10.0.50.100`) | +| 4 | SSH port | `22` | +| 5 | SSH user | `admin` (user on **target**) | +| 6 | SSH password | Password for **target** | +| 7 | Identity key | Key for **target** | +| 8 | Auth test | May fail (target not reachable directly) — continue anyway | +| 9a | Jump hosts | `root@bastion.example.com:22` | +| 9b | Tunnel type at destination | `1` — SOCKS5 Proxy | +| 9c | Bind address | `127.0.0.1` | +| 9d | SOCKS5 port | `1080` | +| 10 | Connection mode | `1` — Regular SSH | +| 11 | Inbound protection | `1` — None | +| 12 | AutoSSH | `y` | +| 13 | Save & start | `y` then `y` | + +> **Important:** Step 3-7 are for the **TARGET** server. Jump host credentials go in step 9a using the format `user@host:port`. + +#### Wizard Steps (Local Forward at target) + +Same as above, but at step 9: + +| Step | Prompt | What to enter | +|---|---|---| +| 9a | Jump hosts | `root@bastion.example.com:22` | +| 9b | Tunnel type at destination | `2` — Local Port Forward | +| 9c | Bind address | `127.0.0.1` | +| 9d | Local port | `8080` | +| 9e | Remote host | `127.0.0.1` (on the target) | +| 9f | Remote port | `80` (web server on target) | + +#### Multiple Jump Hosts + +Chain through several servers by comma-separating them: + +``` +Jump hosts: user1@hop1.com:22,user2@hop2.com:22 +``` + +``` +Your PC ──► hop1.com ──► hop2.com ──► Target +``` + +#### After Starting + +```bash +# SOCKS5 mode — set browser proxy: +# 127.0.0.1:1080 + +# Local Forward mode — open in browser: +# http://127.0.0.1:8080 → shows target's web server +``` + +
+ +
+

Scenario 5: Bypass DPI Censorship (Single VPS)

+ +**Goal:** Your ISP uses Deep Packet Inspection (DPI) to detect and block SSH traffic. Wrap SSH in TLS so it looks like normal HTTPS. + +``` +Without TLS: Your PC ──── SSH (blocked by DPI) ────► VPS +With TLS: Your PC ──── TLS/443 (looks like HTTPS) ────► VPS ──► Internet +``` + +**What you need:** 1 VPS outside the censored network. + +#### Wizard Steps + +``` +tunnelforge create +``` + +| Step | Prompt | What to enter | +|---|---|---| +| 1 | Profile name | `bypass-proxy` | +| 2 | Tunnel type | `1` — SOCKS5 Proxy | +| 3 | SSH host | Your VPS IP (e.g. `45.33.32.10`) | +| 4 | SSH port | `22` | +| 5 | SSH user | `root` | +| 6 | SSH password | Your password | +| 7 | Identity key | Enter to skip | +| 8 | Auth test | Automatic | +| 9a | Bind address | `127.0.0.1` | +| 9b | SOCKS5 port | `1080` | +| **10** | **Connection mode** | **`2` — TLS Encrypted** | +| 10a | TLS port | `443` (mimics HTTPS — most effective) | +| 10b | Setup stunnel on server? | `y` (auto-installs stunnel on VPS) | +| 11 | Inbound protection | `1` — None (you're connecting directly) | +| 12 | AutoSSH | `y` | +| 13 | Save & start | `y` then `y` | + +#### What Happens Behind the Scenes + +1. TunnelForge installs `stunnel` on your VPS +2. Stunnel listens on port 443 (TLS) and forwards to SSH port 22 +3. Your local SSH client connects through stunnel via `openssl s_client` +4. DPI only sees a TLS handshake on port 443 — indistinguishable from HTTPS + +#### After Starting + +```bash +# Set browser SOCKS5 proxy: 127.0.0.1:1080 +curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me +# Shows VPS IP — you're browsing through TLS-wrapped SSH +``` + +#### What DPI Sees + +``` +Your PC ──── HTTPS traffic on port 443 ────► VPS IP +Verdict: Normal website browsing. Allowed. +``` + +
+ +
+

Scenario 6: Double-Hop TLS Chain (Two VPS)

+ +**Goal:** Run a shared proxy for multiple users. VPS-A is the entry point (relay). VPS-B is the exit point. Both legs are TLS-encrypted. Users connect with a PSK. + +``` +Users ── TLS+PSK:1443 ──► VPS-A (relay) ── TLS:443 ──► VPS-B (exit) ──► Internet + stunnel+PSK stunnel + SOCKS5:1080 SSH:22 +``` + +**What you need:** 2 VPS servers. VPS-A = relay (can be in censored country). VPS-B = exit (outside). + +#### Wizard Steps (run on VPS-A) + +Install TunnelForge on VPS-A, then: + +``` +tunnelforge create +``` + +| Step | Prompt | What to enter | +|---|---|---| +| 1 | Profile name | `double-hop` | +| 2 | Tunnel type | `1` — SOCKS5 Proxy | +| 3 | SSH host | **VPS-B** IP (e.g. `203.0.113.50`) | +| 4 | SSH port | `22` | +| 5 | SSH user | `root` | +| 6 | SSH password | VPS-B password | +| 7 | Identity key | Enter to skip | +| 8 | Auth test | Automatic | +| 9a | Bind address | `127.0.0.1` (stunnel handles external) | +| 9b | SOCKS5 port | `1080` | +| **10** | **Connection mode** | **`2` — TLS Encrypted** | +| 10a | TLS port | `443` | +| 10b | Setup stunnel on VPS-B? | `y` | +| **11** | **Inbound protection** | **`2` — TLS + PSK** | +| 11a | Inbound TLS port | `1443` (users connect here) | +| 11b | PSK | Auto-generated (saved in profile) | +| 12 | AutoSSH | `y` | +| 13 | Save & start | `y` then `y` | + +#### Generate Client Scripts for Users + +```bash +# On VPS-A after tunnel is running: +tunnelforge client-script double-hop + +# Creates: +# tunnelforge-connect.sh (Linux) +# tunnelforge-connect.ps1 (Windows) + +# Or send via Telegram: +tunnelforge telegram share double-hop +``` + +#### What Users Do + +**Linux:** +```bash +chmod +x tunnelforge-connect.sh +./tunnelforge-connect.sh # Connect +./tunnelforge-connect.sh stop # Disconnect +./tunnelforge-connect.sh status # Check +# Set browser proxy: 127.0.0.1:1080 +``` + +**Windows PowerShell:** +```powershell +powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 # Connect +powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 stop # Disconnect +powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 status # Check +# Set browser proxy: 127.0.0.1:1080 +``` + +**Windows Batch (standalone client):** +``` +tunnelforge-client.bat # Double-click, enter server/port/PSK +tunnelforge-client.bat stop # Disconnect +tunnelforge-client.bat status # Check +# Set browser proxy: 127.0.0.1:1080 +``` + +#### What DPI Sees + +``` +User PC ──── HTTPS:1443 ──► VPS-A IP (normal TLS) +VPS-A ──── HTTPS:443 ──► VPS-B IP (normal TLS) +No SSH protocol visible anywhere in the chain. +``` + +
+ +
+

Scenario 7: Share Your Tunnel With Others

+ +**Goal:** You have a working tunnel with TLS+PSK protection and want to give others access. TunnelForge generates a standalone script they just run. + +**What you need:** A running tunnel with Inbound TLS+PSK enabled (see Scenario 5 or 6). + +#### Step 1 — Generate Client Scripts + +```bash +tunnelforge client-script my-tunnel +``` + +This creates two files: +- `tunnelforge-connect.sh` — for Linux/Mac +- `tunnelforge-connect.ps1` — for Windows PowerShell + +Each script contains your server address, port, and PSK — everything needed to connect. + +**Or use the standalone Windows client:** Send users `windows-client/tunnelforge-client.bat` from the repo — they double-click it, enter connection details, and they're connected. No scripts to generate. + +#### Step 2 — Send to Users + +Share via: +- Telegram: `tunnelforge telegram share my-tunnel` +- WhatsApp, email, USB drive, or any file transfer method +- For Windows users: also send `windows-client/tunnelforge-client.bat` (or just send the .bat alone with PSK info) + +#### Step 3 — User Runs the Script + +**Linux:** +```bash +chmod +x tunnelforge-connect.sh +./tunnelforge-connect.sh # Connect (auto-installs stunnel if needed) +./tunnelforge-connect.sh stop # Disconnect +./tunnelforge-connect.sh status # Check connection +``` + +**Windows (PowerShell — generated script):** +```powershell +powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 +powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 stop +powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 status +``` + +**Windows (Batch — standalone client):** +``` +tunnelforge-client.bat # Double-click or run — enter server/port/PSK when prompted +tunnelforge-client.bat stop # Disconnect +tunnelforge-client.bat status # Check connection +``` + +All clients automatically: +1. Install stunnel if not present (winget/Chocolatey/apt) +2. Write config files locally +3. Start stunnel and create a local SOCKS5 proxy +4. Print browser setup instructions (Firefox + Chrome) + +#### After Connecting + +``` +Browser proxy: 127.0.0.1:1080 +All traffic routes through the tunnel. +``` + +#### View Connection Info Without Scripts + +```bash +# Show PSK + server + port info (for manual setup): +tunnelforge client-config my-tunnel +``` + +#### Revoking Access + +To revoke a user's access: +1. Edit the profile and regenerate the PSK +2. Regenerate client scripts: `tunnelforge client-script my-tunnel` +3. Restart the tunnel: `tunnelforge restart my-tunnel` +4. Old scripts will no longer work — distribute the new ones to authorized users only + +
+ +
+ +--- + +
+

Learn Menu (Built-in Education)

+ +TunnelForge includes an interactive learning system accessible from the main menu (`l` key) or CLI. Each topic includes explanations, ASCII diagrams, and practical examples. + +| # | Topic | Description | +|---|---|---| +| 1 | What is an SSH Tunnel? | Encrypted channel fundamentals | +| 2 | SOCKS5 Dynamic Proxy | Route all traffic through `-D` flag | +| 3 | Local Port Forwarding | Access remote services via `-L` flag | +| 4 | Remote/Reverse Forwarding | Expose local services via `-R` flag | +| 5 | Jump Hosts & Multi-hop | Chain through bastions via `-J` flag | +| 6 | ControlMaster Multiplexing | Reuse SSH connections for speed | +| 7 | AutoSSH & Reconnection | Automatic tunnel recovery | +| 8 | What is TLS Obfuscation? | Wrap SSH in TLS to bypass DPI | +| 9 | PSK Authentication | Pre-Shared Key for tunnel security | + +
+ +--- + +## License & Author + +**TunnelForge** is licensed under the [GNU General Public License v3.0](LICENSE). + +Copyright (C) 2026 **SamNet Technologies, LLC** + +``` +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. +``` + +--- + +
+ +
+

+

+  ▀▀█▀▀ █  █ █▄ █ █▄ █ █▀▀ █   █▀▀ █▀█ █▀█ █▀▀ █▀▀
+    █   █  █ █ ▀█ █ ▀█ █▀▀ █   █▀  █ █ █▀█ █ █ █▀▀
+    █    ▀▀  █  █ █  █ ▀▀▀ ▀▀▀ █   ▀▀▀ █ █ ▀▀▀ ▀▀▀
+  
+

+
+ +

کامل‌ترین مدیر تانل SSH در یک فایل

+ +

+ License: GPL v3 + Bash 4.3+ + Platform: Linux + Version 1.0.0 + بدون وابستگی +

+ +

+ یک اسکریپت. همه انواع تانل. رابط کاربری کامل. داشبورد زنده..
+ SOCKS5 • Local Forward • Remote Forward • Jump Host • رمزنگاری TLS • ربات تلگرام • Kill Switch • محافظت DNS +

+ +--- + +## شروع سریع + +**نصب با یک دستور:** +
+ +```bash +curl -fsSL https://git.samnet.dev/SamNet-dev/tunnelforge/raw/branch/main/tunnelforge.sh -o tunnelforge.sh && sudo bash tunnelforge.sh install +``` + +
+ +**یا کلون ریپو:** +
+ +```bash +git clone https://git.samnet.dev/SamNet-dev/tunnelforge.git +cd tunnelforge +sudo bash tunnelforge.sh install +``` + +
+ +**اجرای منوی تعاملی:** +
+ +```bash +tunnelforge menu +``` + +
+ +**یا استفاده مستقیم از CLI:** +
+ +```bash +tunnelforge create # ساخت اولین تانل +tunnelforge start my-tunnel # شروع تانل +tunnelforge dashboard # داشبورد زنده +``` + +
+ +

+ منوی اصلی TunnelForge +
+ رابط کاربری تعاملی — مدیریت تانل‌ها، امنیت، سرویس‌ها و بیشتر از یک منو +

+ +--- + +## چرا TunnelForge؟ + +بیشتر ابزارهای مدیریت تانل SSH یا خیلی ساده هستند (فقط یک wrapper روی `ssh -D`) یا خیلی پیچیده (نیاز به Docker، Go، Python یا دهها فایل تنظیمات). TunnelForge متفاوت است: + +| | TunnelForge | سایر ابزارها | +|---|---|---| +| **نصب** | یک فایل bash، بدون وابستگی | Python/Go/Node.js + package manager | +| **رابط کاربری** | منوی TUI کامل + CLI + داشبورد زنده | فقط CLI یا Web UI | +| **امنیت** | محافظت DNS، کیل سوییچ، امتیازدهی امنیتی | فقط SSH ساده | +| **رمزنگاری** | پوشش TLS + PSK برای عبور از DPI | ندارد | +| **مانیتورینگ** | نمودار پهنای باند لحظه‌ای | فایل‌های لاگ | +| **اطلاع‌رسانی** | ربات تلگرام با دستورات ریموت | ندارد | +| **پایداری** | سرویس systemd خودکار | تنظیم دستی | +| **آموزش** | منوی یادگیری داخلی با دیاگرام | مستندات خارجی | +| **اشتراک‌گذاری** | تولید اسکریپت اتصال (Linux + Windows) | تنظیم دستی | + +**TunnelForge کامل‌ترین مدیر تانل SSH موجود در یک فایل است.** + +--- + +
+

مروری بر امکانات

+ +### مدیریت تانل +- **پراکسی SOCKS5 داینامیک** (`-D`) — مسیریابی تمام ترافیک از طریق سرور SSH +- **Port Forwarding محلی** (`-L`) — دسترسی محلی به سرویس‌های ریموت +- **Port Forwarding معکوس** (`-R`) — نمایش سرویس‌های محلی به بیرون +- **Jump Host / چند مرحله‌ای** (`-J`) — عبور از سرورهای واسط +- **AutoSSH** — اتصال مجدد خودکار +- **ControlMaster** — استفاده مجدد از اتصالات SSH +- **چند تانل همزمان** — اجرای دهها تانل به صورت موازی + +### امنیت +- **محافظت از نشت DNS** — بازنویسی resolv.conf + قفل با `chattr` +- **کیل سوییچ** — مسدود کردن تمام ترافیک در صورت قطع تانل (IPv4 + IPv6) +- **ممیزی امنیتی ۶ نقطه‌ای** — ارزیابی امتیازی وضعیت امنیتی +- **تولید کلید SSH** — ساخت کلیدهای ed25519، RSA یا ECDSA +- **استقرار کلید SSH** — ارسال کلید به سرور با یک دستور +- **تأیید اثرانگشت سرور** — بررسی هویت سرور قبل از اتصال +- **سخت‌سازی سرور** — تنظیم خودکار sshd، فایروال، fail2ban و sysctl + +### مانیتورینگ و داشبورد +- **داشبورد TUI زنده** — وضعیت لحظه‌ای تانل‌ها با رفرش خودکار +- **نمودار پهنای باند Sparkline** — نمودارهای ASCII (▁▂▃▄▅▆▇█) برای RX/TX +- **شمارنده ترافیک** — کل بایت‌های ارسال و دریافت هر تانل +- **ردیابی Uptime** — نمایش زمان اتصال هر تانل +- **کیفیت اتصال** — اندازه‌گیری تأخیر با نشانگر رنگی +- **تست سرعت** — تست سرعت دانلود داخلی از طریق تانل +- **صفحه‌بندی** — ناوبری در صفحات مختلف برای تانل‌های زیاد + +

+ داشبورد زنده TunnelForge +
+ داشبورد زنده با نمودار پهنای باند، اتصالات فعال، لاگ اتصال مجدد و منابع سیستم +

+ +### ربات تلگرام +- **اعلان‌های لحظه‌ای** — شروع، توقف، خطا، اتصال مجدد +- **دستورات ریموت** — `/tf_status`، `/tf_list`، `/tf_ip`، `/tf_config` و بیشتر +- **گزارش‌های دوره‌ای** — ارسال زمان‌بندی شده وضعیت به گوشی +- **اشتراک‌گذاری** — ارسال اسکریپت اتصال + PSK از طریق تلگرام +- **مسیریابی SOCKS5** — ربات از طریق تانل کار می‌کند وقتی تلگرام مسدود است + +### رمزنگاری TLS (ضد سانسور) +- **پوشش TLS خروجی** — ترافیک SSH به شکل HTTPS از طریق stunnel +- **محافظت PSK ورودی** — لیسنر SOCKS5 با کلید پیش‌اشتراکی +- **تولید اسکریپت کلاینت** — ساخت خودکار اسکریپت اتصال برای Linux + Windows +- **راه‌اندازی خودکار سرور** — نصب و پیکربندی stunnel با یک دستور +- **عبور از DPI** — ترافیک مانند HTTPS معمولی روی پورت 443 + +### یکپارچگی سیستم +- **تولیدکننده سرویس Systemd** — ساخت خودکار فایل‌های unit امن +- **پشتیبان‌گیری و بازیابی** — بکاپ کامل تنظیمات با چرخش +- **ویزارد سخت‌سازی سرور** — آماده‌سازی سرور تازه برای دریافت تانل +- **مدیریت پروفایل** — ساخت، ویرایش، حذف، وارد کردن پروفایل +- **مدیریت لاگ** — لاگ مجزا برای هر تانل با چرخش +- **حذف کامل** — پاکسازی تمام فایل‌ها، سرویس‌ها و تنظیمات + +### آموزش داخلی +- **منوی یادگیری** — ۹ درس تعاملی درباره مفاهیم تانل SSH +- **سناریوهای واقعی** — راهنمای گام به گام با دیاگرام +- **دیاگرام‌های ASCII** — نمایش بصری جریان ترافیک + +
+ +--- + +
+

انواع تانل

+ +### پراکسی SOCKS5 داینامیک (`-D`) + +**تمام** ترافیک TCP خود را از طریق سرور SSH مسیریابی کنید. ترافیک شما از IP سرور ارسال می‌شود. + +
+ +``` +Client :1080 ──── SSH (Encrypted) ────► Server ──── Direct ────► Internet +``` + +
+ +**کاربردها:** مرور خصوصی، عبور از محدودیت‌های جغرافیایی، مخفی کردن IP، مسیریابی ترافیک. + +
+ +```bash +tunnelforge create # انتخاب "SOCKS5 Proxy" → پورت 1080 +tunnelforge start my-proxy +curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me +``` + +
+ +--- + +### Port Forwarding محلی (`-L`) + +به یک **سرویس ریموت** دسترسی پیدا کنید انگار روی سیستم خودتان اجرا می‌شود. + +
+ +``` +Client :3306 ──── SSH (Encrypted) ────► Server ──── Local ────► MySQL :3306 +``` + +
+ +**کاربردها:** دسترسی به دیتابیس ریموت، وب اپلیکیشن‌های داخلی، پنل‌های مدیریتی پشت فایروال. + +
+ +```bash +tunnelforge create # انتخاب "Local Forward" → محلی 3306 → ریموت db:3306 +tunnelforge start db-tunnel +mysql -h 127.0.0.1 -P 3306 -u admin -p +``` + +
+ +--- + +### Port Forwarding معکوس (`-R`) + +یک **سرویس محلی** را از طریق سرور SSH در دسترس دنیای بیرون قرار دهید. + +
+ +``` +Local App :3000 ◄──── SSH (Encrypted) ────► Server :9090 ◄──── Users +``` + +
+ +**کاربردها:** توسعه webhook، اشتراک‌گذاری اپ برای تست، عبور از NAT، دمو به مشتری. + +
+ +```bash +tunnelforge create # انتخاب "Remote Forward" → ریموت 9090 ← محلی 3000 +tunnelforge start dev-share +``` + +
+ +--- + +### Jump Host / چند مرحله‌ای (`-J`) + +به سرورهایی که پشت **چندین لایه** فایروال هستند از طریق سرورهای واسط دسترسی پیدا کنید. + +
+ +``` +Client ── SSH ──► Jump 1 ── SSH ──► Jump 2 ── SSH ──► Target +``` + +
+ +**کاربردها:** شبکه‌های سازمانی، معماری چند لایه، محیط‌های ایزوله، مناطق امنیتی. + +
+ +--- + +
+

نصب

+ +### پیش‌نیازها + +| پیش‌نیاز | جزئیات | +|---|---| +| **سیستم عامل** | Linux (Ubuntu، Debian، CentOS، Fedora، Arch، Alpine) | +| **Bash** | نسخه ۴.۳ یا بالاتر | +| **دسترسی** | دسترسی root برای نصب | +| **SSH** | کلاینت OpenSSH (در اکثر سیستم‌ها نصب است) | +| **اختیاری** | `autossh` (اتصال مجدد خودکار)، `stunnel` (رمزنگاری TLS)، `sshpass` (احراز هویت با رمز) | + +### نصب + +
+ +```bash +# روش ۱: کلون از Git +git clone https://git.samnet.dev/SamNet-dev/tunnelforge.git +cd tunnelforge +sudo bash tunnelforge.sh install + +# روش ۲: دانلود مستقیم +curl -fsSL https://git.samnet.dev/SamNet-dev/tunnelforge/raw/branch/main/tunnelforge.sh -o tunnelforge.sh +sudo bash tunnelforge.sh install +``` + +
+ +### تأیید نصب + +
+ +```bash +tunnelforge version +# TunnelForge v1.0.0 +``` + +
+ +### ساختار دایرکتوری + +
+ +``` +/opt/tunnelforge/ +├── tunnelforge.sh # اسکریپت اصلی +├── config/ +│ └── tunnelforge.conf # تنظیمات کلی +├── profiles/ +│ └── *.conf # پروفایل تانل‌ها +├── pids/ # فایل‌های PID تانل‌های فعال +├── logs/ # لاگ هر تانل +├── backups/ # فایل‌های پشتیبان +├── data/ +│ ├── bandwidth/ # تاریخچه پهنای باند +│ └── reconnects/ # آمار اتصال مجدد +└── sockets/ # سوکت‌های ControlMaster +``` + +
+ +
+ +--- + +
+

مثال‌های سریع

+ +### مرور خصوصی با SOCKS5 + +
+ +```bash +tunnelforge create +# → نام: private-proxy +# → نوع: SOCKS5 +# → سرور: user@myserver.com +# → پورت: 1080 + +tunnelforge start private-proxy +curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me + +# تنظیم فایرفاکس: Settings → Network → Manual Proxy → SOCKS5: 127.0.0.1:1080 +``` + +
+ +### دسترسی به دیتابیس ریموت + +
+ +```bash +tunnelforge create +# → نام: prod-db +# → نوع: Local Forward +# → سرور: admin@db-server.internal +# → پورت محلی: 3306 → ریموت: localhost:3306 + +tunnelforge start prod-db +mysql -h 127.0.0.1 -P 3306 -u dbuser -p +``` + +
+ +### اشتراک‌گذاری سرور توسعه + +
+ +```bash +tunnelforge create +# → نام: demo-share +# → نوع: Remote Forward +# → سرور: user@public-vps.com +# → پورت ریموت: 8080 ← محلی: localhost:3000 + +tunnelforge start demo-share +# لینک اشتراک: http://public-vps.com:8080 +``` + +
+ +### عبور از سانسور با رمزنگاری TLS + +
+ +```bash +# ۱. راه‌اندازی stunnel روی VPS +tunnelforge obfs-setup my-tunnel + +# ۲. شروع تانل — SSH حالا در TLS پورت 443 پوشش داده شده +tunnelforge start my-tunnel + +# DPI می‌بیند: ترافیک HTTPS عادی +# واقعیت: تانل SSH داخل TLS + +# ۳. اشتراک با دیگران +tunnelforge client-script my-tunnel # تولید اسکریپت اتصال +tunnelforge telegram share my-tunnel # ارسال از طریق تلگرام +``` + +
+ +
+ +--- + +
+

مرجع دستورات CLI

+ +### دستورات تانل + +| دستور | توضیح | +|---|---| +| `tunnelforge start ` | شروع تانل | +| `tunnelforge stop ` | توقف تانل | +| `tunnelforge restart ` | راه‌اندازی مجدد | +| `tunnelforge start-all` | شروع همه تانل‌های خودکار | +| `tunnelforge stop-all` | توقف همه تانل‌ها | +| `tunnelforge status` | نمایش وضعیت | +| `tunnelforge dashboard` | داشبورد زنده | +| `tunnelforge logs [name]` | مشاهده لاگ | + +### دستورات پروفایل + +| دستور | توضیح | +|---|---| +| `tunnelforge create` | ساخت پروفایل جدید (ویزارد) | +| `tunnelforge list` | لیست پروفایل‌ها | +| `tunnelforge delete ` | حذف پروفایل | + +### دستورات امنیتی + +| دستور | توضیح | +|---|---| +| `tunnelforge audit` | ممیزی امنیتی ۶ نقطه‌ای | +| `tunnelforge key-gen [type]` | تولید کلید SSH | +| `tunnelforge key-deploy ` | استقرار کلید روی سرور | +| `tunnelforge fingerprint ` | بررسی اثرانگشت سرور | + +### دستورات تلگرام + +| دستور | توضیح | +|---|---| +| `tunnelforge telegram setup` | پیکربندی ربات | +| `tunnelforge telegram test` | ارسال پیام آزمایشی | +| `tunnelforge telegram status` | نمایش تنظیمات | +| `tunnelforge telegram send ` | ارسال پیام | +| `tunnelforge telegram report` | ارسال گزارش وضعیت | +| `tunnelforge telegram share [name]` | اشتراک اسکریپت‌ها | + +### دستورات سرویس + +| دستور | توضیح | +|---|---| +| `tunnelforge service ` | تولید سرویس systemd | +| `tunnelforge service enable` | فعال‌سازی سرویس | +| `tunnelforge service disable` | غیرفعال‌سازی | +| `tunnelforge service status` | وضعیت سرویس | +| `tunnelforge service remove` | حذف سرویس | + +### دستورات سیستم + +| دستور | توضیح | +|---|---| +| `tunnelforge menu` | منوی تعاملی | +| `tunnelforge install` | نصب TunnelForge | +| `tunnelforge server-setup` | سخت‌سازی سرور | +| `tunnelforge obfs-setup ` | راه‌اندازی رمزنگاری TLS | +| `tunnelforge client-config ` | نمایش تنظیمات کلاینت | +| `tunnelforge client-script ` | تولید اسکریپت کلاینت | +| `tunnelforge backup` | پشتیبان‌گیری | +| `tunnelforge restore [file]` | بازیابی | +| `tunnelforge uninstall` | حذف کامل | +| `tunnelforge version` | نمایش نسخه | +| `tunnelforge help` | راهنما | + +
+ +--- + +
+

امنیت

+ +### محافظت از نشت DNS + +از ارسال درخواست‌های DNS خارج از تانل جلوگیری می‌کند. + +**نحوه کار:** +1. از `/etc/resolv.conf` فعلی بکاپ می‌گیرد +2. آن را با سرورهای DNS امن بازنویسی می‌کند (پیش‌فرض: 8.8.8.8) +3. فایل را با `chattr +i` قفل می‌کند تا سیستم نتواند آن را تغییر دهد +4. هنگام توقف تانل، DNS اصلی بازیابی می‌شود + +### کیل سوییچ + +اگر تانل قطع شود، **تمام ترافیک اینترنت مسدود می‌شود**. + +**نحوه کار:** +1. زنجیره iptables سفارشی `TUNNELFORGE` ایجاد می‌کند +2. فقط ترافیک تانل SSH + loopback مجاز است +3. بقیه ترافیک مسدود (IPv4 + IPv6) +4. هنگام توقف تانل، قوانین حذف می‌شوند + +### ممیزی امنیتی ۶ نقطه‌ای + +
+ +```bash +tunnelforge audit +``` + +
+ +| بررسی | توضیح | +|---|---| +| مجوزهای کلید SSH | بررسی 600 یا 400 بودن | +| دایرکتوری SSH | تأیید 700 بودن `~/.ssh` | +| محافظت DNS | وضعیت محافظت فعال | +| کیل سوییچ | فعال بودن زنجیره iptables | +| سلامت تانل | بررسی اعتبار PID‌ها | +| بسته‌های سیستم | ابزارهای امنیتی نصب شده | + +### سخت‌سازی سرور + +
+ +```bash +tunnelforge server-setup +``` + +
+ +**تنظیمات:** +- **SSH** — غیرفعال‌سازی احراز هویت رمز عبور، غیرفعال‌سازی root login +- **فایروال** — قوانین UFW/iptables فقط برای SSH + پورت‌های تانل +- **Fail2ban** — مسدودسازی خودکار بعد از تلاش‌های ناموفق +- **Kernel** — سخت‌سازی sysctl (محافظت SYN flood، ICMP) + +
+ +--- + +
+

رمزنگاری TLS (ضد سانسور)

+ +در شبکه‌هایی که بازرسی عمیق بسته (DPI) دارند، ترافیک SSH قابل شناسایی و مسدودسازی است. TunnelForge اتصال SSH شما را داخل یک لایه TLS می‌پیچد تا مانند ترافیک HTTPS عادی به نظر برسد. + +### نحوه کار + +
+ +``` +بدون رمزنگاری TLS: +Client ──── SSH (قابل شناسایی) ────► VPS ← DPI می‌تواند مسدود کند + +با رمزنگاری TLS: +Client ──── TLS/443 (شبیه HTTPS) ────► VPS ──── SSH ────► اینترنت +``` + +
+ +### راه‌اندازی سمت سرور + +
+ +```bash +tunnelforge obfs-setup my-tunnel +``` + +
+ +این دستور به صورت خودکار: +1. `stunnel` را روی سرور ریموت نصب می‌کند +2. گواهی TLS خودامضا تولید می‌کند +3. پورت 443 (TLS) را به پورت 22 (SSH) مپ می‌کند +4. سرویس systemd فعال می‌کند +5. پورت فایروال باز می‌کند + +### اشتراک‌گذاری با دیگران + +
+ +```bash +# تولید اسکریپت برای Linux + Windows +tunnelforge client-script my-tunnel + +# ارسال از طریق تلگرام +tunnelforge telegram share my-tunnel +``` + +
+ +اسکریپت‌های تولید شده شامل تنظیمات stunnel، فایل PSK و مدیریت کامل اتصال هستند. + +### کلاینت مستقل ویندوز + +فایل **[`windows-client/tunnelforge-client.bat`](windows-client/tunnelforge-client.bat)** یک کلاینت مستقل ویندوز است که در ریپو موجود است. کاربران فقط دابل‌کلیک می‌کنند، اطلاعات اتصال را وارد می‌کنند و متصل می‌شوند. نیازی به PowerShell نیست. + +**امکانات:** +- تنظیم تعاملی — سرور، پورت و PSK را می‌پرسد +- نصب خودکار stunnel (از طریق winget، Chocolatey یا لینک دانلود) +- ذخیره اتصال برای اتصال مجدد فوری +- دستورات: `tunnelforge-client.bat stop` / `status` +- نمایش تنظیمات پراکسی مرورگر (Firefox + Chrome) + +
+ +``` +توزیع: +1. فایل windows-client/tunnelforge-client.bat را به کاربر بدهید +2. اطلاعات PSK + سرور را بدهید (از: tunnelforge client-config ) +3. کاربر دابل‌کلیک → اطلاعات وارد → متصل +``` + +
+ +
+ +--- + +
+

ربات تلگرام

+ +اطلاع‌رسانی لحظه‌ای تانل‌ها روی گوشی و کنترل ریموت. + +### راه‌اندازی + +
+ +```bash +tunnelforge telegram setup +``` + +
+ +1. **ساخت ربات** — به [@BotFather](https://t.me/BotFather) پیام دهید و `/newbot` ارسال کنید +2. **وارد کردن توکن** — توکن ربات را در TunnelForge وارد کنید +3. **دریافت Chat ID** — به ربات `/start` ارسال کنید، TunnelForge خودکار شناسایی می‌کند +4. **تمام** — پیام آزمایشی تأیید ارسال می‌شود + +### اعلان‌ها + +| رویداد | پیام | +|---|---| +| شروع تانل | نام، PID، نوع تانل | +| توقف تانل | نام پروفایل | +| خطای تانل | جزئیات خطا | +| اتصال مجدد | بازیابی خودکار AutoSSH | +| هشدار امنیتی | نشت DNS یا کیل سوییچ | +| گزارش وضعیت | مرور کلی تمام تانل‌ها | + +### دستورات ربات + +| دستور | پاسخ | +|---|---| +| `/tf_status` | وضعیت تمام تانل‌ها | +| `/tf_list` | لیست پروفایل‌ها | +| `/tf_ip` | IP عمومی سرور | +| `/tf_config` | تنظیمات اتصال کلاینت | +| `/tf_uptime` | مدت فعالیت سرور | +| `/tf_report` | گزارش کامل وضعیت | +| `/tf_help` | لیست دستورات | + +
+ +--- + +
+

سرویس Systemd

+ +تانل‌ها را مقاوم در برابر ریبوت کنید: + +
+ +```bash +tunnelforge service my-tunnel # تولید فایل سرویس +tunnelforge service my-tunnel enable # فعال‌سازی و شروع +tunnelforge service my-tunnel status # بررسی وضعیت +tunnelforge service my-tunnel disable # غیرفعال‌سازی +tunnelforge service my-tunnel remove # حذف سرویس +``` + +
+ +فایل سرویس تولید شده شامل سخت‌سازی امنیتی (`ProtectSystem`، `PrivateTmp`، قابلیت‌های محدود) است. + +
+ +--- + +
+

پشتیبان‌گیری و بازیابی

+ +
+ +```bash +# پشتیبان‌گیری +tunnelforge backup + +# بازیابی +tunnelforge restore +tunnelforge restore /path/to/backup.tar.gz +``` + +
+ +شامل: پروفایل‌ها، تنظیمات، کلیدهای SSH و فایل‌های سرویس systemd. + +
+ +--- + +
+

سناریوهای واقعی

+ +هر سناریو قابل باز شدن است و **مراحل دقیق ویزارد** را نشان می‌دهد. هم نسخه SSH معمولی و هم TLS رمزنگاری شده پوشش داده شده. + +
+

سناریو ۱: مرور خصوصی با SOCKS5

+ +**هدف:** ترافیک مرورگر را از طریق VPS مسیریابی کنید تا سایت‌ها IP سرور را ببینند نه IP واقعی شما. + +**پیش‌نیاز:** یک سرور VPS با دسترسی SSH. + +#### مراحل ویزارد (SSH معمولی) + +
+ +``` +tunnelforge create +``` + +| مرحله | سوال | چه وارد کنید | +|---|---|---| +| 1 | Profile name | `private-proxy` | +| 2 | Tunnel type | `1` — SOCKS5 Proxy | +| 3 | SSH host | IP سرور (مثلاً `45.33.32.10`) | +| 4 | SSH port | `22` | +| 5 | SSH user | `root` | +| 6 | SSH password | رمز عبور (یا Enter برای کلید SSH) | +| 7 | Identity key | `~/.ssh/id_ed25519` (یا Enter) | +| 8 | Auth test | خودکار | +| 9a | Bind address | `127.0.0.1` | +| 9b | SOCKS5 port | `1080` | +| 10 | Connection mode | `1` — Regular SSH | +| 11 | Inbound protection | `1` — None | +| 12 | AutoSSH | `y` | +| 13 | Save & start | `y` سپس `y` | + +
+ +#### مراحل ویزارد (TLS رمزنگاری — برای شبکه سانسور شده) + +مانند بالا، اما در مرحله ۱۰: + +
+ +| مرحله | سوال | چه وارد کنید | +|---|---|---| +| 10 | Connection mode | `2` — TLS Encrypted | +| 10a | TLS port | `443` (شبیه HTTPS) | +| 10b | Setup stunnel now? | `y` (نصب خودکار روی سرور) | + +
+ +#### بعد از شروع + +
+ +**Firefox:** Settings → proxy → SOCKS Host: `127.0.0.1` Port: `1080` + +**Chrome:** +```bash +google-chrome --proxy-server="socks5://127.0.0.1:1080" +``` + +**تست:** +```bash +curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me +``` + +
+ +
+ +
+

سناریو ۲: دسترسی به دیتابیس ریموت

+ +**هدف:** به MySQL/PostgreSQL روی VPS دسترسی پیدا کنید انگار روی سیستم خودتان اجرا می‌شود. + +**پیش‌نیاز:** یک VPS با دیتابیس فعال. + +#### مراحل ویزارد + +
+ +``` +tunnelforge create +``` + +| مرحله | سوال | چه وارد کنید | +|---|---|---| +| 1 | Profile name | `prod-db` | +| 2 | Tunnel type | `2` — Local Port Forward | +| 3 | SSH host | IP سرور (مثلاً `45.33.32.10`) | +| 4 | SSH port | `22` | +| 5 | SSH user | `root` | +| 6 | SSH password | رمز عبور | +| 7 | Identity key | Enter | +| 8 | Auth test | خودکار | +| 9a | Bind address | `127.0.0.1` | +| 9b | Local port | `3306` (پورت روی سیستم شما) | +| 9c | Remote host | `127.0.0.1` (یعنی "روی خود VPS") | +| 9d | Remote port | `3306` (پورت MySQL روی VPS) | +| 10 | Connection mode | `1` — Regular SSH | +| 11 | Inbound protection | `1` — None | +| 12 | AutoSSH | `y` | +| 13 | Save & start | `y` سپس `y` | + +
+ +#### بعد از شروع + +
+ +```bash +mysql -h 127.0.0.1 -P 3306 -u dbuser -p # MySQL +psql -h 127.0.0.1 -p 5432 -U postgres # PostgreSQL +redis-cli -h 127.0.0.1 -p 6379 # Redis +``` + +
+ +| سرویس | پورت محلی | پورت ریموت | +|---|---|---| +| MySQL | 3306 | 3306 | +| PostgreSQL | 5432 | 5432 | +| Redis | 6379 | 6379 | +| پنل وب | 8080 | 8080 | + +
+ +
+

سناریو ۳: اشتراک‌گذاری سرور توسعه

+ +**هدف:** وب‌سایت محلی (مثلاً پورت 3000) را از طریق VPS در دسترس اینترنت قرار دهید. + +**پیش‌نیاز:** یک سرویس محلی فعال + VPS با IP عمومی. + +#### مراحل ویزارد + +
+ +``` +tunnelforge create +``` + +| مرحله | سوال | چه وارد کنید | +|---|---|---| +| 1 | Profile name | `dev-share` | +| 2 | Tunnel type | `3` — Remote/Reverse Forward | +| 3 | SSH host | IP سرور (مثلاً `45.33.32.10`) | +| 4-8 | SSH credentials | مانند سناریوهای قبل | +| 9a | Remote bind | `0.0.0.0` (دسترسی عمومی) | +| 9b | Remote port | `9090` (پورت روی VPS) | +| 9c | Local host | `127.0.0.1` | +| 9d | Local port | `3000` (اپ شما) | +| 10 | Connection mode | `1` — Regular SSH | +| 12 | AutoSSH | `y` | +| 13 | Save & start | `y` سپس `y` | + +
+ +**مهم:** اگر bind روی `0.0.0.0` است، باید `GatewayPorts yes` در sshd سرور فعال باشد. + +#### بعد از شروع + +
+ +```bash +# تست از هرجا: +curl http://45.33.32.10:9090 + +# لینک اشتراک: http://45.33.32.10:9090 +``` + +
+ +
+ +
+

سناریو ۴: عبور از چند فایروال (Jump Host)

+ +**هدف:** به سروری که مستقیماً از اینترنت قابل دسترسی نیست از طریق سرورهای واسط (bastion) دسترسی پیدا کنید. + +#### مراحل ویزارد (SOCKS5 در مقصد) + +
+ +``` +tunnelforge create +``` + +| مرحله | سوال | چه وارد کنید | +|---|---|---| +| 1 | Profile name | `corp-access` | +| 2 | Tunnel type | `4` — Jump Host | +| 3 | SSH host | IP **مقصد** (مثلاً `10.0.50.100`) | +| 4 | SSH port | `22` | +| 5 | SSH user | `admin` (کاربر روی **مقصد**) | +| 6 | SSH password | رمز **مقصد** | +| 8 | Auth test | ممکن است خطا بدهد — ادامه دهید | +| 9a | Jump hosts | `root@bastion.example.com:22` | +| 9b | Tunnel type at destination | `1` — SOCKS5 | +| 9c | Bind address | `127.0.0.1` | +| 9d | SOCKS5 port | `1080` | +| 10 | Connection mode | `1` — Regular SSH | +| 13 | Save & start | `y` سپس `y` | + +
+ +> **مهم:** مراحل ۳ تا ۷ برای سرور **مقصد** هستند. اطلاعات سرور واسط در مرحله 9a وارد می‌شود. + +#### چند سرور واسط + +
+ +``` +Jump hosts: user1@hop1.com:22,user2@hop2.com:22 +``` + +
+ +
+ +
+

سناریو ۵: عبور از سانسور DPI (یک VPS)

+ +**هدف:** ISP شما SSH را شناسایی و مسدود می‌کند. SSH را در TLS بپیچید تا مانند HTTPS عادی به نظر برسد. + +
+ +``` +بدون TLS: PC ──── SSH (مسدود توسط DPI) ────► VPS +با TLS: PC ──── TLS/443 (شبیه HTTPS) ────► VPS ──► Internet +``` + +
+ +#### مراحل ویزارد + +
+ +``` +tunnelforge create +``` + +| مرحله | سوال | چه وارد کنید | +|---|---|---| +| 1 | Profile name | `bypass-proxy` | +| 2 | Tunnel type | `1` — SOCKS5 Proxy | +| 3 | SSH host | IP سرور خارج از کشور | +| 4-8 | SSH credentials | مانند سناریوهای قبل | +| 9a | Bind address | `127.0.0.1` | +| 9b | SOCKS5 port | `1080` | +| **10** | **Connection mode** | **`2` — TLS Encrypted** | +| 10a | TLS port | `443` (شبیه HTTPS) | +| 10b | Setup stunnel? | `y` (نصب خودکار) | +| 11 | Inbound protection | `1` — None | +| 12 | AutoSSH | `y` | +| 13 | Save & start | `y` سپس `y` | + +
+ +#### DPI چه می‌بیند + +
+ +``` +PC ──── HTTPS:443 ──► VPS IP +نتیجه: ترافیک عادی وب. مجاز. +``` + +
+ +
+ +
+

سناریو ۶: زنجیره دو سرور TLS

+ +**هدف:** پراکسی اشتراکی برای چند کاربر. VPS-A = ورودی (relay). VPS-B = خروجی. هر دو مسیر TLS رمزنگاری شده. + +
+ +``` +Users ── TLS+PSK:1443 ──► VPS-A ── TLS:443 ──► VPS-B ──► Internet +``` + +
+ +#### مراحل ویزارد (اجرا روی VPS-A) + +
+ +``` +tunnelforge create +``` + +| مرحله | سوال | چه وارد کنید | +|---|---|---| +| 1 | Profile name | `double-hop` | +| 2 | Tunnel type | `1` — SOCKS5 | +| 3 | SSH host | IP **VPS-B** | +| 4-8 | SSH credentials | اطلاعات VPS-B | +| 9a | Bind address | `127.0.0.1` | +| 9b | SOCKS5 port | `1080` | +| **10** | **Connection mode** | **`2` — TLS Encrypted** | +| 10a | TLS port | `443` | +| 10b | Setup stunnel on VPS-B? | `y` | +| **11** | **Inbound protection** | **`2` — TLS + PSK** | +| 11a | Inbound TLS port | `1443` | +| 11b | PSK | خودکار تولید می‌شود | +| 12 | AutoSSH | `y` | +| 13 | Save & start | `y` سپس `y` | + +
+ +#### تولید اسکریپت برای کاربران + +
+ +```bash +tunnelforge client-script double-hop # تولید اسکریپت +tunnelforge telegram share double-hop # ارسال با تلگرام +``` + +
+ +#### کاربران چه می‌کنند + +
+ +```bash +# Linux: +./tunnelforge-connect.sh # اتصال +./tunnelforge-connect.sh stop # قطع +# پراکسی مرورگر: 127.0.0.1:1080 +``` + +```powershell +# Windows PowerShell: +powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 +# پراکسی مرورگر: 127.0.0.1:1080 +``` + +``` +# Windows Batch (کلاینت مستقل): +tunnelforge-client.bat # دابل‌کلیک، سرور/پورت/PSK وارد کنید +tunnelforge-client.bat stop # قطع +tunnelforge-client.bat status # بررسی +# پراکسی مرورگر: 127.0.0.1:1080 +``` + +
+ +
+ +
+

سناریو ۷: اشتراک تانل با دیگران

+ +**هدف:** تانل TLS+PSK فعال دارید و می‌خواهید دیگران هم استفاده کنند. TunnelForge یک اسکریپت مستقل تولید می‌کند. + +**پیش‌نیاز:** تانل فعال با Inbound TLS+PSK (سناریو ۵ یا ۶). + +#### مرحله ۱ — تولید اسکریپت + +
+ +```bash +tunnelforge client-script my-tunnel +``` + +
+ +دو فایل تولید می‌شود: +- `tunnelforge-connect.sh` — برای Linux/Mac +- `tunnelforge-connect.ps1` — برای Windows PowerShell + +**یا از کلاینت مستقل ویندوز استفاده کنید:** فایل `windows-client/tunnelforge-client.bat` را از ریپو به کاربر بدهید — فقط دابل‌کلیک، اطلاعات اتصال وارد، متصل. نیازی به تولید اسکریپت نیست. + +#### مرحله ۲ — ارسال به کاربران + +
+ +```bash +tunnelforge telegram share my-tunnel # ارسال با تلگرام +``` + +
+ +یا از طریق واتس‌اپ، ایمیل، فلش و غیره. + +برای کاربران ویندوز: فایل `windows-client/tunnelforge-client.bat` را هم بفرستید (یا فقط فایل bat با اطلاعات PSK کافیست). + +#### مرحله ۳ — اجرا توسط کاربر + +
+ +```bash +# Linux: +chmod +x tunnelforge-connect.sh +./tunnelforge-connect.sh # اتصال (stunnel خودکار نصب می‌شود) +./tunnelforge-connect.sh stop # قطع +./tunnelforge-connect.sh status # بررسی وضعیت +# پراکسی مرورگر: 127.0.0.1:1080 +``` + +```powershell +# Windows PowerShell: +powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 +powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 stop +powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 status +# پراکسی مرورگر: 127.0.0.1:1080 +``` + +``` +# Windows Batch (کلاینت مستقل): +tunnelforge-client.bat # دابل‌کلیک، سرور/پورت/PSK وارد کنید +tunnelforge-client.bat stop # قطع +tunnelforge-client.bat status # بررسی +# پراکسی مرورگر: 127.0.0.1:1080 +``` + +
+ +#### لغو دسترسی کاربر + +1. PSK را در پروفایل تغییر دهید +2. اسکریپت جدید تولید کنید: `tunnelforge client-script my-tunnel` +3. تانل را ریستارت کنید: `tunnelforge restart my-tunnel` +4. اسکریپت‌های قدیمی دیگر کار نمی‌کنند + +
+ +
+ +--- + +
+

منوی یادگیری

+ +TunnelForge شامل سیستم آموزشی تعاملی است (کلید `l` در منوی اصلی): + +| # | موضوع | توضیح | +|---|---|---| +| ۱ | تانل SSH چیست؟ | مبانی کانال رمزنگاری شده | +| ۲ | پراکسی SOCKS5 | مسیریابی ترافیک با `-D` | +| ۳ | Port Forwarding محلی | دسترسی به سرویس ریموت با `-L` | +| ۴ | Port Forwarding معکوس | نمایش سرویس محلی با `-R` | +| ۵ | Jump Host | عبور از سرورهای واسط با `-J` | +| ۶ | ControlMaster | استفاده مجدد از اتصالات | +| ۷ | AutoSSH | بازیابی خودکار تانل | +| ۸ | رمزنگاری TLS | پوشش SSH در TLS | +| ۹ | احراز هویت PSK | کلید پیش‌اشتراکی | + +
+ +--- + +## مجوز و نویسنده + +**TunnelForge** تحت مجوز [GNU General Public License v3.0](LICENSE) منتشر شده است. + +حق نشر (C) ۲۰۲۶ **SamNet Technologies, LLC** + +
diff --git a/screenshots/dashboard.png b/screenshots/dashboard.png new file mode 100644 index 0000000..3a0900e Binary files /dev/null and b/screenshots/dashboard.png differ diff --git a/screenshots/tunnelforge-main.png b/screenshots/tunnelforge-main.png new file mode 100644 index 0000000..391a4cf Binary files /dev/null and b/screenshots/tunnelforge-main.png differ diff --git a/tunnelforge.sh b/tunnelforge.sh new file mode 100644 index 0000000..2b087c5 --- /dev/null +++ b/tunnelforge.sh @@ -0,0 +1,10354 @@ +#!/usr/bin/env bash +# ╔════════════════════════════════════════════════════════════════════╗ +# ║ ▀▀█▀▀ █ █ █▄ █ █▄ █ █▀▀ █ █▀▀ █▀█ █▀█ █▀▀ █▀▀ ║ +# ║ █ █ █ █ ▀█ █ ▀█ █▀▀ █ █▀ █ █ █▀█ █ █ █▀▀ ║ +# ║ █ ▀▀ █ █ █ █ ▀▀▀ ▀▀▀ █ ▀▀▀ █ █ ▀▀▀ ▀▀▀ ║ +# ╚════════════════════════════════════════════════════════════════════╝ +# +# TunnelForge — SSH Tunnel Manager +# Copyright (C) 2026 SamNet Technologies, LLC +# +# Single-file bash tool with TUI menu, live dashboard, +# DNS leak protection, kill switch, server hardening, and Telegram bot. +# +# Version : 1.0.0 +# Author : SamNet Technologies, LLC +# License : GNU General Public License v3.0 +# Repo : git.samnet.dev/SamNet-dev/tunnelforge +# Usage : tunnelforge [command] [options] +# Run 'tunnelforge help' for full command reference. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# ============================================================================ +# STRICT MODE & BASH VERSION CHECK +# ============================================================================ + +set -eo pipefail + +MIN_BASH_VERSION="4.3" + +check_bash_version() { + local major="${BASH_VERSINFO[0]}" + local minor="${BASH_VERSINFO[1]}" + local req_major="${MIN_BASH_VERSION%%.*}" + local req_minor="${MIN_BASH_VERSION##*.}" + + if (( major < req_major )) || \ + (( major == req_major && minor < req_minor )); then + echo "ERROR: TunnelForge requires bash ${MIN_BASH_VERSION}+" \ + "(found ${BASH_VERSION})" >&2 + exit 1 + fi +} +check_bash_version + +# ============================================================================ +# VERSION & GLOBAL CONSTANTS +# ============================================================================ + +readonly VERSION="1.0.0" +readonly GITHUB_REPO="SamNet-dev/tunnelforge" +readonly GITEA_RAW="https://git.samnet.dev/SamNet-dev/tunnelforge/raw/branch/main" +readonly APP_NAME="TunnelForge" +readonly APP_NAME_LOWER="tunnelforge" + +# Installation directories +readonly INSTALL_DIR="/opt/tunnelforge" +readonly CONFIG_DIR="${INSTALL_DIR}/config" +readonly PROFILES_DIR="${INSTALL_DIR}/profiles" +readonly PID_DIR="${INSTALL_DIR}/pids" +readonly LOG_DIR="${INSTALL_DIR}/logs" +readonly BACKUP_DIR="${INSTALL_DIR}/backups" +readonly DATA_DIR="${INSTALL_DIR}/data" +readonly BIN_LINK="/usr/local/bin/tunnelforge" + +# Config files +readonly MAIN_CONFIG="${CONFIG_DIR}/tunnelforge.conf" + +# Temp / lock +TMP_DIR="" +# Background Telegram PIDs for cleanup +declare -a _TG_BG_PIDS=() +# SSH ControlMaster +readonly SSH_CONTROL_DIR="${INSTALL_DIR}/sockets" + +# Bandwidth & reconnect history +readonly BW_HISTORY_DIR="${DATA_DIR}/bandwidth" +readonly RECONNECT_LOG_DIR="${DATA_DIR}/reconnects" + +# ============================================================================ +# TEMP FILE CLEANUP TRAP +# ============================================================================ + +cleanup() { + local exit_code=$? + tput cnorm 2>/dev/null || true + tput rmcup 2>/dev/null || true + # Reap background Telegram sends + local _tg_p + for _tg_p in "${_TG_BG_PIDS[@]}"; do + kill "$_tg_p" 2>/dev/null || true + wait "$_tg_p" 2>/dev/null || true + done + rm -rf "${TMP_DIR}" 2>/dev/null + rm -f "${PID_DIR}"/*.lock "${CONFIG_DIR}"/*.lock "${PROFILES_DIR}"/*.lock 2>/dev/null + # Clean stale SSH ControlMaster sockets + find "${SSH_CONTROL_DIR}" -type s -delete 2>/dev/null || true + # Clean up our own mkdir-based locks (stale after SIGKILL) + local _cleanup_lck _cleanup_pid + for _cleanup_lck in "${CONFIG_DIR}"/*.lck "${PROFILES_DIR}"/*.lck "${PID_DIR}"/*.lck "${BW_HISTORY_DIR}"/*.lck; do + if [[ -d "$_cleanup_lck" ]]; then + _cleanup_pid=$(cat "${_cleanup_lck}/pid" 2>/dev/null) || true + if [[ "${_cleanup_pid}" == "$$" ]] || [[ -z "$_cleanup_pid" ]]; then + rm -f "${_cleanup_lck}/pid" 2>/dev/null || true + rmdir "$_cleanup_lck" 2>/dev/null || true + fi + fi + done + exit "${exit_code}" +} +trap cleanup EXIT INT TERM HUP QUIT + +TMP_DIR=$(mktemp -d "/tmp/tunnelforge.XXXXXX" 2>/dev/null) || { + echo "FATAL: Cannot create secure temporary directory" >&2 + exit 1 +} +chmod 700 "${TMP_DIR}" 2>/dev/null || true +readonly TMP_DIR + +# ============================================================================ +# CONFIGURATION DEFAULTS (declare -gA CONFIG) +# ============================================================================ + +declare -gA CONFIG=( + # SSH defaults + [SSH_DEFAULT_USER]="root" + [SSH_DEFAULT_PORT]="22" + [SSH_DEFAULT_KEY]="" + [SSH_CONNECT_TIMEOUT]="10" + [SSH_SERVER_ALIVE_INTERVAL]="30" + [SSH_SERVER_ALIVE_COUNT_MAX]="3" + [SSH_STRICT_HOST_KEY]="yes" + + # AutoSSH + [AUTOSSH_ENABLED]="true" + [AUTOSSH_POLL]="30" + [AUTOSSH_FIRST_POLL]="30" + [AUTOSSH_GATETIME]="30" + + [AUTOSSH_MONITOR_PORT]="0" + [AUTOSSH_LOG_LEVEL]="1" + + # ControlMaster + [CONTROLMASTER_ENABLED]="false" + [CONTROLMASTER_PERSIST]="600" + + # Security + [DNS_LEAK_PROTECTION]="false" + [DNS_SERVER_1]="1.1.1.1" + [DNS_SERVER_2]="1.0.0.1" + [KILL_SWITCH]="false" + + # Telegram + [TELEGRAM_ENABLED]="false" + [TELEGRAM_BOT_TOKEN]="" + [TELEGRAM_CHAT_ID]="" + [TELEGRAM_ALERTS]="true" + [TELEGRAM_PERIODIC_STATUS]="false" + [TELEGRAM_STATUS_INTERVAL]="3600" + + # Dashboard + [DASHBOARD_REFRESH]="5" + [DASHBOARD_THEME]="retro" + + # Logging + [LOG_LEVEL]="info" + [LOG_JSON]="false" + [LOG_MAX_SIZE]="10485760" + [LOG_ROTATE_COUNT]="5" + + # General + [AUTO_UPDATE_CHECK]="false" +) + +config_get() { + local key="$1" + local default="${2:-}" + echo "${CONFIG[$key]:-$default}" +} + +config_set() { + local key="$1" + local value="$2" + CONFIG["$key"]="$value" +} + +# ============================================================================ +# COLORS & TERMINAL DETECTION +# ============================================================================ + +if [[ -t 1 ]] && [[ -t 2 ]]; then + IS_TTY=true +else + IS_TTY=false +fi + +if [[ "${IS_TTY}" == true ]] && [[ "${TERM:-dumb}" != "dumb" ]] && [[ -z "${NO_COLOR:-}" ]]; then + RED=$'\033[0;31m' + GREEN=$'\033[0;32m' + YELLOW=$'\033[0;33m' + BLUE=$'\033[0;34m' + MAGENTA=$'\033[0;35m' + CYAN=$'\033[0;36m' + WHITE=$'\033[0;37m' + + BOLD=$'\033[1m' + BOLD_RED=$'\033[1;31m' + BOLD_GREEN=$'\033[1;32m' + BOLD_YELLOW=$'\033[1;33m' + BOLD_BLUE=$'\033[1;34m' + BOLD_MAGENTA=$'\033[1;35m' + BOLD_CYAN=$'\033[1;36m' + BOLD_WHITE=$'\033[1;37m' + + BG_RED=$'\033[41m' + BG_GREEN=$'\033[42m' + BG_YELLOW=$'\033[43m' + BG_BLUE=$'\033[44m' + + DIM=$'\033[2m' + UNDERLINE=$'\033[4m' + REVERSE=$'\033[7m' + RESET=$'\033[0m' + + # Status indicators (Unicode + color) + readonly STATUS_OK="${GREEN}●${RESET}" + readonly STATUS_FAIL="${RED}✗${RESET}" + readonly STATUS_WARN="${YELLOW}▲${RESET}" + readonly STATUS_STOP="${DIM}■${RESET}" + readonly STATUS_SPIN="${CYAN}◆${RESET}" +else + RED='' GREEN='' YELLOW='' BLUE='' MAGENTA='' CYAN='' WHITE='' + BOLD='' BOLD_RED='' BOLD_GREEN='' BOLD_YELLOW='' BOLD_BLUE='' + BOLD_MAGENTA='' BOLD_CYAN='' BOLD_WHITE='' + BG_RED='' BG_GREEN='' BG_YELLOW='' BG_BLUE='' + DIM='' UNDERLINE='' REVERSE='' RESET='' + IS_TTY=false + + # Status indicators (ASCII fallback for dumb/pipe/NO_COLOR) + readonly STATUS_OK="*" + readonly STATUS_FAIL="x" + readonly STATUS_WARN="!" + readonly STATUS_STOP="-" + readonly STATUS_SPIN="+" +fi + +# ============================================================================ +# LOGGING +# ============================================================================ + +declare -gA LOG_LEVELS=( [debug]=0 [info]=1 [success]=2 [warn]=3 [error]=4 ) + +_get_log_level_num() { echo "${LOG_LEVELS[${1:-info}]:-1}"; } + +_should_log() { + local msg_level="$1" + local configured_level + configured_level=$(config_get "LOG_LEVEL" "info") + local msg_num configured_num + msg_num=$(_get_log_level_num "$msg_level") + configured_num=$(_get_log_level_num "$configured_level") + if (( msg_num >= configured_num )); then return 0; fi + return 1 +} + +log_json() { + local level="$1" message="$2" + local ts + ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + # Escape JSON special characters: backslash first, then quotes, then control chars + message="${message//\\/\\\\}" + message="${message//\"/\\\"}" + message="${message//$'\n'/\\n}" + message="${message//$'\t'/\\t}" + message="${message//$'\r'/\\r}" + printf '{"timestamp":"%s","level":"%s","app":"%s","message":"%s"}\n' \ + "$ts" "$level" "$APP_NAME_LOWER" "$message" +} + +_log() { + local level="$1" color="$2" prefix="$3" message="$4" + _should_log "$level" || return 0 + + if [[ "$(config_get LOG_JSON false)" == "true" ]]; then + log_json "$level" "$message" \ + >> "${LOG_DIR}/${APP_NAME_LOWER}.log" 2>/dev/null || true + fi + + local ts + ts=$(date '+%H:%M:%S') + if [[ "${IS_TTY}" == true ]]; then + printf "${DIM}[%s]${RESET} ${color}${prefix}${RESET} %s\n" \ + "$ts" "$message" >&2 + else + printf "[%s] %s %s\n" "$ts" "$prefix" "$message" >&2 + fi +} + +log_debug() { _log "debug" "${DIM}" "[DEBUG]" "$1"; } +log_info() { _log "info" "${CYAN}" "[INFO] " "$1"; } +log_success() { _log "success" "${GREEN}" "[ OK ]" "$1"; } +log_warn() { _log "warn" "${YELLOW}" "[WARN] " "$1"; } +log_error() { _log "error" "${RED}" "[ERROR]" "$1"; } + +log_file() { + local level="$1" message="$2" + if [[ "$(config_get LOG_JSON false)" == "true" ]]; then + log_json "$level" "$message" \ + >> "${LOG_DIR}/${APP_NAME_LOWER}.log" 2>/dev/null || true + else + local ts + ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + printf "[%s] [%s] %s\n" "$ts" "$level" "$message" \ + >> "${LOG_DIR}/${APP_NAME_LOWER}.log" 2>/dev/null || true + fi +} + +# ============================================================================ +# UTILITY FUNCTIONS +# ============================================================================ + +# Drain trailing bytes from multi-byte escape sequences (arrow keys, etc.) +# Call after read -rsn1: if key is ESC, consume the rest and blank the var. +# Usage: _drain_esc varname +_drain_esc() { + local -n _de_ref="$1" + if [[ "${_de_ref}" == $'\033' ]]; then + local _de_trash + read -rsn2 -t 0.1 _de_trash /dev/null || true + _de_ref="" + fi +} + +validate_port() { + local port="$1" + if [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )); then + return 0 + fi + return 1 +} + +# Check if a local port is already in use or assigned +# Returns 0 if free, 1 if busy. Prints suggestion on conflict. +_check_port_conflict() { + local _cp_port="$1" _cp_type="${2:-local}" + local _cp_busy=false _cp_who="" + + # Check active listeners via ss + if command -v ss &>/dev/null; then + if ss -tln 2>/dev/null | tail -n +2 | grep -qE "[:.]${_cp_port}[[:space:]]"; then + _cp_busy=true + _cp_who="system process" + fi + fi + + # Check other TunnelForge profiles + if [[ -d "$PROFILES_DIR" ]]; then + local _cp_f _cp_pname + for _cp_f in "$PROFILES_DIR"/*.conf; do + [[ -f "$_cp_f" ]] || continue + _cp_pname=$(basename "$_cp_f" .conf) + local _cp_pport="" + _cp_pport=$(grep -oE "^LOCAL_PORT='[0-9]+'" "$_cp_f" 2>/dev/null | cut -d"'" -f2) || true + if [[ "$_cp_pport" == "$_cp_port" ]]; then + _cp_busy=true + _cp_who="profile '${_cp_pname}'" + break + fi + done + fi + + if [[ "$_cp_busy" == true ]]; then + printf " ${YELLOW}! Port %s is used by %s${RESET}\n" "$_cp_port" "$_cp_who" >/dev/tty + # Suggest next free port + local _cp_try=$(( _cp_port + 1 )) + local _cp_max=$(( _cp_port + 20 )) + while (( _cp_try <= _cp_max && _cp_try <= 65535 )); do + local _cp_free=true + if command -v ss &>/dev/null; then + if ss -tln 2>/dev/null | tail -n +2 | grep -qE "[:.]${_cp_try}[[:space:]]"; then + _cp_free=false + fi + fi + if [[ "$_cp_free" == true ]] && [[ -d "$PROFILES_DIR" ]]; then + for _cp_f in "$PROFILES_DIR"/*.conf; do + [[ -f "$_cp_f" ]] || continue + local _cp_pp="" + _cp_pp=$(grep -oE "^LOCAL_PORT='[0-9]+'" "$_cp_f" 2>/dev/null | cut -d"'" -f2) || true + if [[ "$_cp_pp" == "$_cp_try" ]]; then + _cp_free=false; break + fi + done + fi + if [[ "$_cp_free" == true ]]; then + printf " ${DIM}Suggested: %s${RESET}\n" "$_cp_try" >/dev/tty + return 1 + fi + (( ++_cp_try )) + done + return 1 + fi + return 0 +} + +validate_ip() { + local ip="$1" + if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + local IFS='.' + read -ra octets <<< "$ip" + for octet in "${octets[@]}"; do + # Reject leading zeros (octal ambiguity with iptables/ssh) + if [[ "$octet" =~ ^0[0-9] ]]; then return 1; fi + if (( 10#$octet > 255 )); then return 1; fi + done + return 0 + fi + return 1 +} + +validate_ip6() { + local ip="$1" + # Accept bracketed form [::1] + if [[ "$ip" =~ ^\[([0-9a-fA-F:]+)\]$ ]]; then + ip="${BASH_REMATCH[1]}" + fi + # Basic IPv6: must contain at least one colon, only hex digits and colons + if [[ "$ip" =~ ^[0-9a-fA-F]*:[0-9a-fA-F:]*$ ]]; then + # Reject more than one :: (invalid shorthand) + local _dc="${ip//[^:]/}" + if [[ "${ip}" == *"::"*"::"* ]]; then return 1; fi + return 0 + fi + return 1 +} + +validate_hostname() { + local host="$1" + validate_ip "$host" && return 0 + # Accept bracket-wrapped IPv6 (e.g., [::1], [2001:db8::1]) + if [[ "$host" =~ ^\[[0-9a-fA-F:]+\]$ ]]; then return 0; fi + # Accept bare IPv6 (e.g., ::1, 2001:db8::1) + if [[ "$host" =~ ^[0-9a-fA-F]*:[0-9a-fA-F:]+$ ]]; then return 0; fi + [[ "$host" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$ ]] +} + +validate_profile_name() { + local name="$1" + [[ "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ ]] +} + +format_bytes() { + local bytes="${1:-0}" + [[ "$bytes" =~ ^[0-9]+$ ]] || bytes=0 + if (( bytes < 1024 )); then + printf "%d B" "$bytes" + elif (( bytes < 1048576 )); then + printf "%d.%d KB" "$(( bytes / 1024 ))" "$(( (bytes % 1024) * 10 / 1024 ))" + elif (( bytes < 1073741824 )); then + printf "%d.%d MB" "$(( bytes / 1048576 ))" "$(( (bytes % 1048576) * 10 / 1048576 ))" + else + printf "%d.%02d GB" "$(( bytes / 1073741824 ))" "$(( (bytes % 1073741824) * 100 / 1073741824 ))" + fi +} + +format_duration() { + local seconds="${1:-0}" + [[ "$seconds" =~ ^[0-9]+$ ]] || seconds=0 + local d=$(( seconds / 86400 )) + local h=$(( (seconds % 86400) / 3600 )) + local m=$(( (seconds % 3600) / 60 )) + local s=$(( seconds % 60 )) + + if (( d > 0 )); then + printf "%dd %dh %dm" "$d" "$h" "$m" + elif (( h > 0 )); then + printf "%dh %dm %ds" "$h" "$m" "$s" + elif (( m > 0 )); then + printf "%dm %ds" "$m" "$s" + else + printf "%ds" "$s" + fi +} + +get_public_ip() { + local ip="" svc + local services=( + "https://api.ipify.org" + "https://ifconfig.me/ip" + "https://icanhazip.com" + "https://ipecho.net/plain" + ) + for svc in "${services[@]}"; do + ip=$(curl -s --max-time 5 "$svc" 2>/dev/null | tr -d '[:space:]' || true) + if validate_ip "$ip" || validate_ip6 "$ip"; then + echo "$ip"; return 0 + fi + done + echo "unknown"; return 1 +} + +check_root() { + local hint="${1:-}" + if [[ $EUID -ne 0 ]]; then + log_error "This operation requires root privileges" + log_info "Run with: sudo tunnelforge ${hint}" + return 1 + fi +} + +confirm_action() { + local message="${1:-Are you sure?}" + local default="${2:-n}" + local prompt + if [[ "$default" == "y" ]]; then + prompt="${message} [Y/n]: " + else + prompt="${message} [y/N]: " + fi + printf "${BOLD}%s${RESET}" "$prompt" >/dev/tty + local answer + read -r answer /dev/null; do + printf "\r${CYAN}%s${RESET} %s" "${chars[$i]}" "$message" >&2 + i=$(( (i + 1) % ${#chars[@]} )) + sleep 0.1 + done + printf "\r%*s\r" $(( ${#message} + 3 )) "" >&2 +} + +is_port_in_use() { + local port="$1" bind_addr="${2:-127.0.0.1}" + if command -v ss &>/dev/null; then + local _ss_pattern + if [[ "$bind_addr" == "0.0.0.0" ]] || [[ "$bind_addr" == "::" ]] || [[ "$bind_addr" == "[::]" ]]; then + _ss_pattern="\\*" + elif [[ "$bind_addr" =~ : ]]; then + # IPv6 — ss shows as [addr]:port; escape brackets for grep + local _stripped="${bind_addr#\[}" + _stripped="${_stripped%\]}" + _ss_pattern="\\[${_stripped}\\]" + else + _ss_pattern="${bind_addr//./\\.}" + fi + ss -tln sport = :"${port}" 2>/dev/null | tail -n +2 | grep -qE "(${_ss_pattern}|\\*):" 2>/dev/null + elif command -v netstat &>/dev/null; then + netstat -tln 2>/dev/null | grep -qE "(${bind_addr//./\\.}|0\\.0\\.0\\.0):${port}([[:space:]]|$)" + else + return 1 + fi +} + +get_term_width() { + tput cols 2>/dev/null || echo 80 +} + +# ============================================================================ +# OS DETECTION & PACKAGE MANAGEMENT +# ============================================================================ + +declare -g OS_ID="" +declare -g OS_VERSION="" +declare -g OS_FAMILY="" +declare -g PKG_MANAGER="" +declare -g PKG_INSTALL="" +declare -g PKG_UPDATE="" +declare -g INIT_SYSTEM="" + +detect_os() { + if [[ -f /etc/os-release ]]; then + # Parse directly — sourcing collides with our readonly VERSION + OS_ID=$(grep -m1 '^ID=' /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"') || true + OS_VERSION=$(grep -m1 '^VERSION_ID=' /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"') || true + : "${OS_ID:=unknown}" "${OS_VERSION:=unknown}" + elif [[ -f /etc/redhat-release ]]; then + OS_ID="rhel" + OS_VERSION=$(grep -oE '[0-9]+\.[0-9]+' /etc/redhat-release | head -1 || true) + else + OS_ID="unknown" + OS_VERSION="unknown" + fi + + case "${OS_ID}" in + ubuntu|debian|raspbian|linuxmint|pop|kali|parrot) + OS_FAMILY="debian" + PKG_MANAGER="apt-get" + PKG_INSTALL="apt-get install -y" + PKG_UPDATE="apt-get update" + ;; + fedora|centos|rhel|rocky|almalinux|ol) + OS_FAMILY="rhel" + if command -v dnf &>/dev/null; then + PKG_MANAGER="dnf" + PKG_INSTALL="dnf install -y" + PKG_UPDATE="dnf check-update" + else + PKG_MANAGER="yum" + PKG_INSTALL="yum install -y" + PKG_UPDATE="yum check-update" + fi + ;; + arch|manjaro|endeavouros) + OS_FAMILY="arch" + PKG_MANAGER="pacman" + PKG_INSTALL="pacman -S --noconfirm" + PKG_UPDATE="pacman -Sy" + ;; + alpine) + OS_FAMILY="alpine" + PKG_MANAGER="apk" + PKG_INSTALL="apk add" + PKG_UPDATE="apk update" + ;; + opensuse*|sles) + OS_FAMILY="suse" + PKG_MANAGER="zypper" + PKG_INSTALL="zypper install -y" + PKG_UPDATE="zypper refresh" + ;; + *) + OS_FAMILY="unknown" + log_warn "Unknown OS: ${OS_ID}. Some features may not work." + ;; + esac + + # Detect init system + if command -v systemctl &>/dev/null && [[ -d /run/systemd/system ]]; then + INIT_SYSTEM="systemd" + elif command -v rc-service &>/dev/null; then + INIT_SYSTEM="openrc" + elif [[ -d /etc/init.d ]]; then + INIT_SYSTEM="sysvinit" + else + INIT_SYSTEM="unknown" + fi + + log_debug "Detected OS: ${OS_ID} ${OS_VERSION} (${OS_FAMILY}), init: ${INIT_SYSTEM}" +} + +install_package() { + local pkg="$1" + local pkg_name="$pkg" + + case "${OS_FAMILY}" in + debian) + case "$pkg" in + openssh-client) pkg_name="openssh-client" ;; + ncurses) pkg_name="ncurses-bin" ;; + esac ;; + rhel) + case "$pkg" in + openssh-client) pkg_name="openssh-clients" ;; + ncurses) pkg_name="ncurses" ;; + iproute2) pkg_name="iproute" ;; + esac ;; + arch) + case "$pkg" in + openssh-client) pkg_name="openssh" ;; + ncurses) pkg_name="ncurses" ;; + iproute2) pkg_name="iproute2" ;; + esac ;; + alpine) + case "$pkg" in + openssh-client) pkg_name="openssh-client" ;; + ncurses) pkg_name="ncurses" ;; + iproute2) pkg_name="iproute2" ;; + esac ;; + suse) + case "$pkg" in + openssh-client) pkg_name="openssh" ;; + ncurses) pkg_name="ncurses-utils" ;; + iproute2) pkg_name="iproute2" ;; + esac ;; + esac + + if [[ -z "$PKG_INSTALL" ]]; then + log_error "No package manager configured for this OS" + return 1 + fi + log_info "Installing ${pkg_name}..." + if ${PKG_INSTALL} "${pkg_name}" &>/dev/null; then + log_success "Installed ${pkg_name}" + return 0 + else + log_error "Failed to install ${pkg_name}" + return 1 + fi +} + +check_dependencies() { + local missing=() + + local -A deps=( + [ssh]="openssh-client" + [autossh]="autossh" + [sshpass]="sshpass" + [iptables]="iptables" + [curl]="curl" + [ip]="iproute2" + [tput]="ncurses" + [bc]="bc" + [jq]="jq" + ) + + log_info "Checking dependencies..." + for cmd in "${!deps[@]}"; do + if ! command -v "$cmd" &>/dev/null; then + missing+=("${deps[$cmd]}") + log_warn "Missing: ${cmd} (package: ${deps[$cmd]})" + else + log_debug "Found: ${cmd}" + fi + done + + if [[ ${#missing[@]} -eq 0 ]]; then + log_success "All dependencies satisfied" + return 0 + fi + + log_info "Missing ${#missing[@]} package(s): ${missing[*]}" + + if ! check_root; then + log_error "Root access needed to install missing packages" + return 1 + fi + + log_info "Updating package cache..." + ${PKG_UPDATE} &>/dev/null || true + + local failed=0 + for pkg in "${missing[@]}"; do + install_package "$pkg" || ((++failed)) + done + + if (( failed > 0 )); then + log_error "${failed} package(s) failed to install" + return 1 + fi + + log_success "All dependencies installed" + return 0 +} + +# ============================================================================ +# SETTINGS LOAD / SAVE (safe whitelist-validated parser) +# ============================================================================ + +readonly CONFIG_WHITELIST=( + SSH_DEFAULT_USER SSH_DEFAULT_PORT SSH_DEFAULT_KEY + SSH_CONNECT_TIMEOUT SSH_SERVER_ALIVE_INTERVAL SSH_SERVER_ALIVE_COUNT_MAX + SSH_STRICT_HOST_KEY + AUTOSSH_ENABLED AUTOSSH_POLL AUTOSSH_GATETIME AUTOSSH_MONITOR_PORT + AUTOSSH_FIRST_POLL AUTOSSH_LOG_LEVEL + CONTROLMASTER_ENABLED CONTROLMASTER_PERSIST + DNS_LEAK_PROTECTION DNS_SERVER_1 DNS_SERVER_2 KILL_SWITCH + TELEGRAM_ENABLED TELEGRAM_BOT_TOKEN TELEGRAM_CHAT_ID + TELEGRAM_ALERTS TELEGRAM_PERIODIC_STATUS TELEGRAM_STATUS_INTERVAL + DASHBOARD_REFRESH DASHBOARD_THEME + LOG_LEVEL LOG_JSON LOG_MAX_SIZE LOG_ROTATE_COUNT + AUTO_UPDATE_CHECK +) + +_is_whitelisted() { + local key="$1" k + for k in "${CONFIG_WHITELIST[@]}"; do + if [[ "$k" == "$key" ]]; then return 0; fi + done + return 1 +} + +load_settings() { + local config_file="${1:-$MAIN_CONFIG}" + [[ -f "$config_file" ]] || return 0 + + log_debug "Loading settings from: ${config_file}" + + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ "$line" =~ ^[[:space:]]*$ ]] && continue + + if [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then + local key="${BASH_REMATCH[1]}" + local value="${BASH_REMATCH[2]}" + # Strip matched outer quote pairs, then un-escape + if [[ "$value" == \'*\' ]]; then + value="${value#\'}"; value="${value%\'}" + value="${value//\'\\\'\'/\'}" + elif [[ "$value" == \"*\" ]]; then + value="${value#\"}"; value="${value%\"}" + fi + + if _is_whitelisted "$key"; then + CONFIG["$key"]="$value" + if [[ "$key" == "TELEGRAM_BOT_TOKEN" ]] || [[ "$key" == "TELEGRAM_CHAT_ID" ]]; then + log_debug "Loaded: ${key}=****" + else + log_debug "Loaded: ${key}=${value}" + fi + else + log_warn "Ignoring unknown config key: ${key}" + fi + fi + done < "$config_file" + + log_debug "Settings loaded" +} + +save_settings() { + local config_file="${1:-$MAIN_CONFIG}" + + # Acquire file-level lock to prevent concurrent writer races + local _ss_lock_fd="" _ss_lock_dir="" + _ss_unlock() { + if [[ -n "${_ss_lock_fd:-}" ]]; then exec {_ss_lock_fd}>&- 2>/dev/null || true; fi + if [[ -n "${_ss_lock_dir:-}" ]]; then + rm -f "${_ss_lock_dir}/pid" 2>/dev/null || true + rmdir "${_ss_lock_dir}" 2>/dev/null || true + _ss_lock_dir="" + fi + } + if command -v flock &>/dev/null; then + exec {_ss_lock_fd}>"${config_file}.lock" + flock -w 5 "$_ss_lock_fd" 2>/dev/null || { log_warn "Could not acquire settings lock"; _ss_unlock; return 1; } + else + _ss_lock_dir="${config_file}.lck" + local _ss_try=0 + while ! mkdir "$_ss_lock_dir" 2>/dev/null; do + local _ss_stale_pid="" + _ss_stale_pid=$(cat "${_ss_lock_dir}/pid" 2>/dev/null) || true + if [[ -n "$_ss_stale_pid" ]] && ! kill -0 "$_ss_stale_pid" 2>/dev/null; then + rm -f "${_ss_lock_dir}/pid" 2>/dev/null || true + rmdir "$_ss_lock_dir" 2>/dev/null || true + continue + fi + if (( ++_ss_try >= 10 )); then log_warn "Could not acquire settings lock"; return 1; fi + sleep 0.5 + done + printf '%s' "$$" > "${_ss_lock_dir}/pid" 2>/dev/null || true + fi + + local tmp_file + tmp_file=$(mktemp "${TMP_DIR}/config.XXXXXX") + + mkdir -p "$(dirname "$config_file")" 2>/dev/null || true + + cat > "$tmp_file" <<'HEADER' +# ============================================================================ +# TunnelForge Configuration +# Generated automatically — edit with care +# ============================================================================ +HEADER + + local key _sv + { + for key in "${CONFIG_WHITELIST[@]}"; do + if [[ -n "${CONFIG[$key]+x}" ]]; then + _sv="${CONFIG[$key]//$'\n'/}" + _sv="${_sv//\'/\'\\\'\'}" + printf "%s='%s'\n" "$key" "$_sv" + fi + done + } >> "$tmp_file" + + # Set permissions before mv so there's no window of insecure perms + chmod 600 "$tmp_file" 2>/dev/null || true + # mv fails across filesystems (/tmp → /etc), fall back to cp to same-dir temp then mv + if mv "$tmp_file" "$config_file" 2>/dev/null || \ + { cp "$tmp_file" "${config_file}.tmp.$$" 2>/dev/null && \ + mv "${config_file}.tmp.$$" "$config_file" 2>/dev/null && \ + rm -f "$tmp_file" 2>/dev/null; }; then + log_debug "Settings saved to: ${config_file}" + _ss_unlock + return 0 + fi + rm -f "${config_file}.tmp.$$" 2>/dev/null + rm -f "$tmp_file" 2>/dev/null + log_error "Failed to save settings to: ${config_file}" + _ss_unlock + return 1 +} + +# ============================================================================ +# DIRECTORY INITIALIZATION +# ============================================================================ + +init_directories() { + local dirs=( + "$INSTALL_DIR" "$CONFIG_DIR" "$PROFILES_DIR" + "$PID_DIR" "$LOG_DIR" "$BACKUP_DIR" + "$DATA_DIR" "$SSH_CONTROL_DIR" + "$BW_HISTORY_DIR" "$RECONNECT_LOG_DIR" + ) + for dir in "${dirs[@]}"; do + if [[ ! -d "$dir" ]]; then + mkdir -p "$dir" 2>/dev/null || { + log_error "Failed to create directory: ${dir}" + return 1 + } + fi + done + chmod 700 "$CONFIG_DIR" 2>/dev/null || true + chmod 700 "$SSH_CONTROL_DIR" 2>/dev/null || true + chmod 700 "$LOG_DIR" 2>/dev/null || true + chmod 700 "$PROFILES_DIR" 2>/dev/null || true + chmod 700 "$PID_DIR" 2>/dev/null || true + chmod 700 "$BACKUP_DIR" 2>/dev/null || true + chmod 700 "$DATA_DIR" 2>/dev/null || true + chmod 700 "$BW_HISTORY_DIR" 2>/dev/null || true + chmod 700 "$RECONNECT_LOG_DIR" 2>/dev/null || true + chmod 755 "$INSTALL_DIR" 2>/dev/null || true + log_debug "Directories initialized" +} + +# ============================================================================ +# PROFILE MANAGEMENT +# ============================================================================ + +readonly PROFILE_FIELDS=( + PROFILE_NAME TUNNEL_TYPE + SSH_HOST SSH_PORT SSH_USER SSH_PASSWORD IDENTITY_KEY + LOCAL_BIND_ADDR LOCAL_PORT REMOTE_HOST REMOTE_PORT + JUMP_HOSTS SSH_OPTIONS + AUTOSSH_ENABLED AUTOSSH_MONITOR_PORT + DNS_LEAK_PROTECTION KILL_SWITCH AUTOSTART + OBFS_MODE OBFS_PORT OBFS_LOCAL_PORT OBFS_PSK + DESCRIPTION +) + +_profile_path() { echo "${PROFILES_DIR}/${1}.conf"; } + +create_profile() { + local name="$1" + + if ! validate_profile_name "$name"; then + log_error "Invalid profile name: '${name}'" + log_info "Use letters, numbers, hyphens, underscores (max 64 chars)" + return 1 + fi + + local profile_file + profile_file=$(_profile_path "$name") + if [[ -f "$profile_file" ]]; then + log_error "Profile '${name}' already exists" + return 1 + fi + + local -A profile=( + [PROFILE_NAME]="$name" + [TUNNEL_TYPE]="socks5" + [SSH_HOST]="" + [SSH_PORT]="$(config_get SSH_DEFAULT_PORT 22)" + [SSH_USER]="$(config_get SSH_DEFAULT_USER root)" + [IDENTITY_KEY]="$(config_get SSH_DEFAULT_KEY)" + [LOCAL_BIND_ADDR]="127.0.0.1" + [LOCAL_PORT]="1080" + [REMOTE_HOST]="" + [REMOTE_PORT]="" + [JUMP_HOSTS]="" + [SSH_OPTIONS]="" + [AUTOSSH_ENABLED]="$(config_get AUTOSSH_ENABLED true)" + [AUTOSSH_MONITOR_PORT]="0" + [DNS_LEAK_PROTECTION]="false" + [KILL_SWITCH]="false" + [AUTOSTART]="false" + [DESCRIPTION]="" + ) + + _save_profile_data "$profile_file" profile + log_success "Profile '${name}' created" +} + +load_profile() { + local name="$1" + local -n _profile_ref="$2" + + local profile_file + profile_file=$(_profile_path "$name") + if [[ ! -f "$profile_file" ]]; then + log_error "Profile '${name}' not found" + return 1 + fi + + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ "$line" =~ ^[[:space:]]*$ ]] && continue + + if [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then + local key="${BASH_REMATCH[1]}" + local value="${BASH_REMATCH[2]}" + # Strip matched outer quote pairs, then un-escape + if [[ "$value" == \'*\' ]]; then + value="${value#\'}"; value="${value%\'}" + value="${value//\'\\\'\'/\'}" + elif [[ "$value" == \"*\" ]]; then + value="${value#\"}"; value="${value%\"}" + fi + + local valid=false field + for field in "${PROFILE_FIELDS[@]}"; do + [[ "$field" == "$key" ]] && { valid=true; break; } + done + if [[ "$valid" == true ]]; then _profile_ref["$key"]="$value"; fi + fi + done < "$profile_file" + + # Validate critical fields against injection + local _fld _fval + for _fld in SSH_USER SSH_HOST REMOTE_HOST; do + _fval="${_profile_ref[$_fld]:-}" + [[ -z "$_fval" ]] && continue + if [[ "$_fval" =~ [[:cntrl:]] ]]; then + log_error "Profile '${name}': ${_fld} contains control characters" + return 1 + fi + done + if [[ -n "${_profile_ref[SSH_USER]:-}" ]] && \ + ! [[ "${_profile_ref[SSH_USER]}" =~ ^[a-zA-Z0-9._@-]+$ ]]; then + log_error "Profile '${name}': SSH_USER contains invalid characters" + return 1 + fi + if [[ -n "${_profile_ref[SSH_HOST]:-}" ]] && \ + ! [[ "${_profile_ref[SSH_HOST]}" =~ ^[a-zA-Z0-9._:%-]+$|^\[[0-9a-fA-F:]+\]$ ]]; then + log_error "Profile '${name}': SSH_HOST contains invalid characters" + return 1 + fi + if [[ -n "${_profile_ref[REMOTE_HOST]:-}" ]] && \ + ! [[ "${_profile_ref[REMOTE_HOST]}" =~ ^[a-zA-Z0-9._:%-]+$|^\[[0-9a-fA-F:]+\]$ ]]; then + log_error "Profile '${name}': REMOTE_HOST contains invalid characters" + return 1 + fi + # Validate LOCAL_BIND_ADDR (used directly in SSH -D/-L/-R commands) + if [[ -n "${_profile_ref[LOCAL_BIND_ADDR]:-}" ]] && \ + ! { validate_ip "${_profile_ref[LOCAL_BIND_ADDR]}" || \ + validate_ip6 "${_profile_ref[LOCAL_BIND_ADDR]}" || \ + [[ "${_profile_ref[LOCAL_BIND_ADDR]}" == "localhost" ]] || \ + [[ "${_profile_ref[LOCAL_BIND_ADDR]}" == "*" ]] || \ + [[ "${_profile_ref[LOCAL_BIND_ADDR]}" == "0.0.0.0" ]]; }; then + log_error "Profile '${name}': LOCAL_BIND_ADDR is not a valid address" + return 1 + fi + # Validate OBFS_MODE enum + if [[ -n "${_profile_ref[OBFS_MODE]:-}" ]] && \ + ! [[ "${_profile_ref[OBFS_MODE]}" =~ ^(none|stunnel)$ ]]; then + log_error "Profile '${name}': OBFS_MODE must be 'none' or 'stunnel'" + return 1 + fi + + log_debug "Profile '${name}' loaded" +} + +_save_profile_data() { + local file="$1" + local -n _data_ref="$2" + mkdir -p "$(dirname "$file")" 2>/dev/null || true + + # Acquire file-level lock to prevent concurrent writer races + local _spd_lock_fd="" _spd_lock_dir="" + _spd_unlock() { + if [[ -n "${_spd_lock_fd:-}" ]]; then exec {_spd_lock_fd}>&- 2>/dev/null || true; fi + if [[ -n "${_spd_lock_dir:-}" ]]; then + rm -f "${_spd_lock_dir}/pid" 2>/dev/null || true + rmdir "${_spd_lock_dir}" 2>/dev/null || true + _spd_lock_dir="" + fi + } + if command -v flock &>/dev/null; then + exec {_spd_lock_fd}>"${file}.lock" + flock -w 5 "$_spd_lock_fd" 2>/dev/null || { log_warn "Could not acquire profile lock"; _spd_unlock; return 1; } + else + _spd_lock_dir="${file}.lck" + local _spd_try=0 + while ! mkdir "$_spd_lock_dir" 2>/dev/null; do + local _spd_stale_pid="" + _spd_stale_pid=$(cat "${_spd_lock_dir}/pid" 2>/dev/null) || true + if [[ -n "$_spd_stale_pid" ]] && ! kill -0 "$_spd_stale_pid" 2>/dev/null; then + rm -f "${_spd_lock_dir}/pid" 2>/dev/null || true + rmdir "$_spd_lock_dir" 2>/dev/null || true + continue + fi + if (( ++_spd_try >= 10 )); then log_warn "Could not acquire profile lock"; return 1; fi + sleep 0.5 + done + printf '%s' "$$" > "${_spd_lock_dir}/pid" 2>/dev/null || true + fi + + local tmp_file + tmp_file=$(mktemp "${TMP_DIR}/profile.XXXXXX") + + { + printf "# TunnelForge Profile\n" + printf "# Generated: %s\n\n" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + local _sv field + for field in "${PROFILE_FIELDS[@]}"; do + if [[ -n "${_data_ref[$field]+x}" ]]; then + _sv="${_data_ref[$field]//$'\n'/}" + _sv="${_sv//\'/\'\\\'\'}" + printf "%s='%s'\n" "$field" "$_sv" + fi + done + } > "$tmp_file" + + # Set permissions before mv so there's no window of insecure perms + chmod 600 "$tmp_file" 2>/dev/null || true + # mv fails across filesystems (/tmp → /opt), fall back to cp to same-dir temp then mv + if ! { mv "$tmp_file" "$file" 2>/dev/null || \ + { cp "$tmp_file" "${file}.tmp.$$" 2>/dev/null && \ + mv "${file}.tmp.$$" "$file" 2>/dev/null && \ + rm -f "$tmp_file" 2>/dev/null; }; }; then + rm -f "$tmp_file" "${file}.tmp.$$" 2>/dev/null + log_error "Failed to save profile: ${file}" + _spd_unlock + return 1 + fi + _spd_unlock +} + +save_profile() { + local name="$1" + local -n _prof_ref="$2" + _save_profile_data "$(_profile_path "$name")" _prof_ref || { log_error "Failed to save profile '${name}'"; return 1; } + log_debug "Profile '${name}' saved" +} + +delete_profile() { + local name="$1" + local profile_file + profile_file=$(_profile_path "$name") + + if [[ ! -f "$profile_file" ]]; then + log_error "Profile '${name}' not found" + return 1 + fi + + # Stop tunnel if running + if is_tunnel_running "$name"; then + log_info "Stopping running tunnel '${name}'..." + stop_tunnel "$name" || log_warn "Could not stop tunnel '${name}'" + fi + + rm -f "$profile_file" 2>/dev/null || true + rm -f "${PID_DIR}/${name}.pid" 2>/dev/null || true + rm -f "${BW_HISTORY_DIR}/${name}.dat" 2>/dev/null || true + rm -f "${RECONNECT_LOG_DIR}/${name}.log" 2>/dev/null || true + log_success "Profile '${name}' deleted" +} + +list_profiles() { + [[ -d "$PROFILES_DIR" ]] || return 0 + local f _base + for f in "${PROFILES_DIR}"/*.conf; do + [[ -f "$f" ]] || continue + # Extract profile name without forking basename + _base="${f##*/}" # strip directory + _base="${_base%.conf}" # strip .conf extension + printf '%s\n' "$_base" + done +} + +get_profile_field() { + local name="$1" field="$2" + local -A _gpf + if load_profile "$name" _gpf; then + echo "${_gpf[$field]:-}" + else + return 1 + fi +} + +update_profile_field() { + local name="$1" field="$2" value="$3" + local -A _upf + load_profile "$name" _upf || return 1 + _upf["$field"]="$value" + save_profile "$name" _upf +} + +# ============================================================================ +# SSH COMMAND BUILDERS +# ============================================================================ + +_validate_jump_hosts() { + local hosts="$1" + [[ -z "$hosts" ]] && return 0 + if [[ ! "$hosts" =~ ^([a-zA-Z0-9._@:%-]+,)*[a-zA-Z0-9._@:%-]+$ ]]; then + log_error "Invalid JUMP_HOSTS format: ${hosts}" + return 1 + fi + return 0 +} + +get_ssh_base_opts() { + local -n _opts_profile="$1" + local opts=() + + opts+=(-o "ConnectTimeout=$(config_get SSH_CONNECT_TIMEOUT 10)") + opts+=(-o "ServerAliveInterval=$(config_get SSH_SERVER_ALIVE_INTERVAL 30)") + opts+=(-o "ServerAliveCountMax=$(config_get SSH_SERVER_ALIVE_COUNT_MAX 3)") + + local strict + strict=$(config_get SSH_STRICT_HOST_KEY "yes") + opts+=(-o "StrictHostKeyChecking=${strict}") + + opts+=(-N) # no remote command + opts+=(-T) # no pseudo-TTY + opts+=(-o "ExitOnForwardFailure=yes") + + # Identity key + local key="${_opts_profile[IDENTITY_KEY]:-}" + if [[ -n "$key" ]] && [[ -f "$key" ]]; then opts+=(-i "$key"); fi + + # Port + local _ssh_port="${_opts_profile[SSH_PORT]:-22}" + if ! validate_port "$_ssh_port"; then + log_error "Invalid SSH port: ${_ssh_port}" + return 1 + fi + opts+=(-p "$_ssh_port") + + # Extra SSH options (allowlist — only known-safe options accepted) + local extra="${_opts_profile[SSH_OPTIONS]:-}" + if [[ -n "$extra" ]]; then + local -a _extra_arr + read -ra _extra_arr <<< "$extra" || true + local -a _validated_opts=() + local _opt _opt_name _skip_next=false + for _opt in "${_extra_arr[@]}"; do + if [[ "$_skip_next" == true ]]; then + _skip_next=false + _opt_name="${_opt%%=*}" + if ! printf '%s' "$_opt_name" | grep -qiE '^(Compression|TCPKeepAlive|IPQoS|RekeyLimit|Ciphers|MACs|KexAlgorithms|HostKeyAlgorithms|PubkeyAcceptedAlgorithms|PubkeyAcceptedKeyTypes|ConnectionAttempts|ConnectTimeout|NumberOfPasswordPrompts|PreferredAuthentications|AddressFamily|BatchMode|CheckHostIP|HashKnownHosts|NoHostAuthenticationForLocalhost|PasswordAuthentication|StrictHostKeyChecking|UpdateHostKeys|VerifyHostKeyDNS|VisualHostKey|LogLevel|ServerAliveInterval|ServerAliveCountMax|GSSAPIAuthentication|GSSAPIDelegateCredentials)$'; then + log_error "SSH option not in allowlist: ${_opt}" + return 1 + fi + _validated_opts+=(-o "$_opt") + continue + fi + if [[ "$_opt" == "-o" ]]; then + _skip_next=true; continue + fi + _opt_name="${_opt%%=*}" + if ! printf '%s' "$_opt_name" | grep -qiE '^(Compression|TCPKeepAlive|IPQoS|RekeyLimit|Ciphers|MACs|KexAlgorithms|HostKeyAlgorithms|PubkeyAcceptedAlgorithms|PubkeyAcceptedKeyTypes|ConnectionAttempts|ConnectTimeout|NumberOfPasswordPrompts|PreferredAuthentications|AddressFamily|BatchMode|CheckHostIP|HashKnownHosts|NoHostAuthenticationForLocalhost|PasswordAuthentication|StrictHostKeyChecking|UpdateHostKeys|VerifyHostKeyDNS|VisualHostKey|LogLevel|ServerAliveInterval|ServerAliveCountMax|GSSAPIAuthentication|GSSAPIDelegateCredentials)$'; then + log_error "SSH option not in allowlist: ${_opt}" + return 1 + fi + _validated_opts+=(-o "$_opt") + done + if [[ "$_skip_next" == true ]]; then + log_error "SSH option -o without value" + return 1 + fi + opts+=("${_validated_opts[@]}") + fi + + # ControlMaster + if [[ "$(config_get CONTROLMASTER_ENABLED false)" == "true" ]]; then + opts+=(-o "ControlMaster=auto") + opts+=(-o "ControlPath=${SSH_CONTROL_DIR}/%C") + opts+=(-o "ControlPersist=$(config_get CONTROLMASTER_PERSIST 600)") + fi + + printf '%s\n' "${opts[@]}" +} + +# Wrap bare IPv6 addresses in brackets for SSH forwarding specs +_bracket_ipv6() { + local addr="$1" + if [[ "$addr" =~ : ]] && [[ "$addr" != \[* ]]; then + printf '[%s]' "$addr" + else + printf '%s' "$addr" + fi +} + +_unbracket_ipv6() { + local addr="$1" + addr="${addr#\[}" + addr="${addr%\]}" + printf '%s' "$addr" +} + +# ── Obfuscation-aware jump/proxy options builder ── +# Appends the correct jump/proxy flags based on OBFS_MODE. +# When OBFS_MODE=stunnel: uses ProxyCommand with openssl s_client. +# When no obfuscation: uses standard -J for jump hosts. +# Args: profile_nameref cmd_array_nameref +_build_obfs_proxy_or_jump() { + local -n _ob_prof="$1" + local -n _ob_cmd="$2" + + local _ob_mode="${_ob_prof[OBFS_MODE]:-none}" + local _ob_port="${_ob_prof[OBFS_PORT]:-443}" + local _ob_jump="${_ob_prof[JUMP_HOSTS]:-}" + + # Validate port is numeric to prevent injection in ProxyCommand + if [[ -n "$_ob_port" ]] && ! [[ "$_ob_port" =~ ^[0-9]+$ ]]; then + log_error "OBFS_PORT must be numeric"; return 1 + fi + + if [[ "$_ob_mode" == "stunnel" ]]; then + if [[ -n "$_ob_jump" ]]; then + # Jump host + stunnel: wrap the jump connection in TLS + _validate_jump_hosts "$_ob_jump" || return 1 + # Parse first jump host: user@host:port + local _jh="${_ob_jump%%,*}" + local _juser="" _jhost="" _jport="22" + if [[ "$_jh" == *@* ]]; then + _juser="${_jh%%@*}" + _jh="${_jh#*@}" + fi + if [[ "$_jh" == *:* ]]; then + _jport="${_jh##*:}" + _jhost="${_jh%%:*}" + else + _jhost="$_jh" + fi + # Validate parsed jump host/port to prevent ProxyCommand injection + if ! [[ "$_jport" =~ ^[0-9]+$ ]]; then + log_error "Jump host port must be numeric"; return 1 + fi + if ! [[ "$_jhost" =~ ^[a-zA-Z0-9._:-]+$ ]]; then + log_error "Jump host contains invalid characters"; return 1 + fi + local _jdest="${_juser:+${_juser}@}${_jhost}" + # Nested ProxyCommand: connect to jump via stunnel, then -W to target + local _inner_pc="openssl s_client -connect ${_jhost}:${_ob_port} -quiet 2>/dev/null" + _ob_cmd+=(-o "ProxyCommand=ssh -o 'ProxyCommand=${_inner_pc}' -p ${_jport} -W %h:%p ${_jdest}") + else + # Direct tunnel + stunnel: openssl s_client as ProxyCommand + _ob_cmd+=(-o "ProxyCommand=openssl s_client -connect %h:${_ob_port} -quiet 2>/dev/null") + fi + else + # No obfuscation: standard -J for jump hosts + if [[ -n "$_ob_jump" ]]; then + _validate_jump_hosts "$_ob_jump" || return 1 + _ob_cmd+=(-J "$_ob_jump") + fi + fi + return 0 +} + +build_socks5_cmd() { + local -n _s5="$1" + local cmd=() base_opts _base_output + _base_output=$(get_ssh_base_opts _s5) || return 1 + mapfile -t base_opts <<< "$_base_output" + cmd+=(ssh "${base_opts[@]}") + + local bind + bind=$(_bracket_ipv6 "${_s5[LOCAL_BIND_ADDR]:-127.0.0.1}") + local port="${_s5[LOCAL_PORT]:-1080}" + cmd+=(-D "${bind}:${port}") + + _build_obfs_proxy_or_jump _s5 cmd || return 1 + + local user="${_s5[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}" + local _ssh_dest + _ssh_dest=$(_unbracket_ipv6 "${_s5[SSH_HOST]}") + cmd+=("${user}@${_ssh_dest}") + + printf '%s\n' "${cmd[@]}" +} + +build_local_forward_cmd() { + local -n _lf="$1" + local cmd=() base_opts _base_output + _base_output=$(get_ssh_base_opts _lf) || return 1 + mapfile -t base_opts <<< "$_base_output" + cmd+=(ssh "${base_opts[@]}") + + local bind rhost + bind=$(_bracket_ipv6 "${_lf[LOCAL_BIND_ADDR]:-127.0.0.1}") + rhost=$(_bracket_ipv6 "${_lf[REMOTE_HOST]}") + cmd+=(-L "${bind}:${_lf[LOCAL_PORT]}:${rhost}:${_lf[REMOTE_PORT]}") + + _build_obfs_proxy_or_jump _lf cmd || return 1 + + local user="${_lf[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}" + local _ssh_dest + _ssh_dest=$(_unbracket_ipv6 "${_lf[SSH_HOST]}") + cmd+=("${user}@${_ssh_dest}") + + printf '%s\n' "${cmd[@]}" +} + +build_remote_forward_cmd() { + local -n _rf="$1" + local cmd=() base_opts _base_output + _base_output=$(get_ssh_base_opts _rf) || return 1 + mapfile -t base_opts <<< "$_base_output" + cmd+=(ssh "${base_opts[@]}") + + local rhost rbind + rhost=$(_bracket_ipv6 "${_rf[REMOTE_HOST]:-localhost}") + rbind="${_rf[LOCAL_BIND_ADDR]:-}" + if [[ -n "$rbind" ]]; then + rbind=$(_bracket_ipv6 "$rbind") + cmd+=(-R "${rbind}:${_rf[REMOTE_PORT]}:${rhost}:${_rf[LOCAL_PORT]}") + else + cmd+=(-R "${_rf[REMOTE_PORT]}:${rhost}:${_rf[LOCAL_PORT]}") + fi + + _build_obfs_proxy_or_jump _rf cmd || return 1 + + local user="${_rf[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}" + local _ssh_dest + _ssh_dest=$(_unbracket_ipv6 "${_rf[SSH_HOST]}") + cmd+=("${user}@${_ssh_dest}") + + printf '%s\n' "${cmd[@]}" +} + +build_tunnel_cmd() { + local name="$1" + local -A _bt + load_profile "$name" _bt || return 1 + + case "${_bt[TUNNEL_TYPE]:-socks5}" in + socks5) build_socks5_cmd _bt ;; + local) build_local_forward_cmd _bt ;; + remote) build_remote_forward_cmd _bt ;; + jump) # Legacy: dispatch based on whether remote target is set + if [[ -n "${_bt[REMOTE_HOST]:-}" ]] && [[ -n "${_bt[REMOTE_PORT]:-}" ]]; then + build_local_forward_cmd _bt + else + build_socks5_cmd _bt + fi ;; + *) + log_error "Unknown tunnel type: ${_bt[TUNNEL_TYPE]}" + return 1 ;; + esac +} + +# ============================================================================ +# TUNNEL LIFECYCLE +# ============================================================================ + +_pid_file() { echo "${PID_DIR}/${1}.pid"; } +_log_file() { echo "${LOG_DIR}/${1}.log"; } + +is_tunnel_running() { + local name="$1" + local pid_file="${PID_DIR}/${name}.pid" + [[ -f "$pid_file" ]] || return 1 + + local pid="" + read -r pid < "$pid_file" 2>/dev/null || true + if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then + return 1 + fi + return 0 +} + +_clean_stale_pid() { + local name="$1" + local pid_file="${PID_DIR}/${name}.pid" + [[ -f "$pid_file" ]] || return 0 + local pid="" + read -r pid < "$pid_file" 2>/dev/null || true + if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then + rm -f "$pid_file" "${pid_file}.autossh" "${PID_DIR}/${name}.stunnel" \ + "${PID_DIR}/${name}.started" "${PID_DIR}/${name}.askpass" "${PID_DIR}/${name}.pass" 2>/dev/null + fi + return 0 +} + +get_tunnel_pid() { + local pid_file="${PID_DIR}/${1}.pid" + if [[ -f "$pid_file" ]]; then + local _pid="" + read -r _pid < "$pid_file" 2>/dev/null || true + printf '%s' "$_pid" + fi + return 0 +} + +_record_reconnect() { + local name="$1" reason="${2:-unknown}" + printf "%s|%s\n" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$reason" \ + >> "${RECONNECT_LOG_DIR}/${name}.log" 2>/dev/null || true + _notify_reconnect "$name" "$reason" + return 0 +} + +start_tunnel() { + local name="$1" + + # Per-profile lock to prevent concurrent start/stop races + local _st_lock_fd="" _st_lock_dir="" + _st_unlock() { + if [[ -n "${_st_lock_fd:-}" ]]; then exec {_st_lock_fd}>&- 2>/dev/null || true; fi + if [[ -n "${_st_lock_dir:-}" ]]; then + rm -f "${_st_lock_dir}/pid" 2>/dev/null || true + rmdir "${_st_lock_dir}" 2>/dev/null || true + _st_lock_dir="" + fi + rm -f "${PID_DIR}/${name}.lock" 2>/dev/null || true + } + if command -v flock &>/dev/null; then + exec {_st_lock_fd}>"${PID_DIR}/${name}.lock" 2>/dev/null || { log_error "Could not open lock file for '${name}'"; return 1; } + flock -w 10 "$_st_lock_fd" 2>/dev/null || { log_error "Could not acquire lock for '${name}'"; _st_unlock; return 1; } + else + _st_lock_dir="${PID_DIR}/${name}.lck" + local _st_try=0 + while ! mkdir "$_st_lock_dir" 2>/dev/null; do + local _st_stale_pid="" + _st_stale_pid=$(cat "${_st_lock_dir}/pid" 2>/dev/null) || true + if [[ -n "$_st_stale_pid" ]] && ! kill -0 "$_st_stale_pid" 2>/dev/null; then + rm -f "${_st_lock_dir}/pid" 2>/dev/null || true + rmdir "$_st_lock_dir" 2>/dev/null || true + continue + fi + if (( ++_st_try >= 20 )); then log_error "Could not acquire lock for '${name}'"; _st_lock_dir=""; return 1; fi + sleep 0.5 + done + printf '%s' "$$" > "${_st_lock_dir}/pid" 2>/dev/null || true + fi + + # Rotate logs if needed + rotate_logs 2>/dev/null || true + + local profile_file + profile_file=$(_profile_path "$name") + if [[ ! -f "$profile_file" ]]; then + log_error "Profile '${name}' not found" + _st_unlock; return 1 + fi + + if is_tunnel_running "$name"; then + log_warn "Tunnel '${name}' is already running (PID: $(get_tunnel_pid "$name"))" + _st_unlock; return 2 + fi + + local -A _sp + load_profile "$name" _sp || { _st_unlock; return 1; } + + local tunnel_type="${_sp[TUNNEL_TYPE]:-socks5}" + local ssh_host="${_sp[SSH_HOST]}" + local local_port="${_sp[LOCAL_PORT]:-}" + local bind_addr="${_sp[LOCAL_BIND_ADDR]:-127.0.0.1}" + + # Force 127.0.0.1 binding when inbound TLS is active (stunnel wraps the port) + if [[ -n "${_sp[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_sp[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then + if [[ "$bind_addr" != "127.0.0.1" ]]; then + log_info "Inbound TLS active — forcing bind to 127.0.0.1 (stunnel handles external access)" + bind_addr="127.0.0.1" + _sp[LOCAL_BIND_ADDR]="127.0.0.1" + fi + fi + + # Validate port numbers + if [[ -n "$local_port" ]] && ! validate_port "$local_port"; then + log_error "Invalid LOCAL_PORT '${local_port}' in profile '${name}'" + _st_unlock; return 1 + fi + if [[ -n "${_sp[REMOTE_PORT]:-}" ]] && ! validate_port "${_sp[REMOTE_PORT]}"; then + log_error "Invalid REMOTE_PORT '${_sp[REMOTE_PORT]}' in profile '${name}'" + _st_unlock; return 1 + fi + + if [[ -z "$ssh_host" ]]; then + log_error "SSH host not configured for profile '${name}'" + _st_unlock; return 1 + fi + + # Validate openssl is available for TLS obfuscation + if [[ "${_sp[OBFS_MODE]:-none}" == "stunnel" ]]; then + if ! command -v openssl &>/dev/null; then + log_error "openssl required for TLS obfuscation but not found" + _st_unlock; return 1 + fi + log_debug "TLS obfuscation enabled (port ${_sp[OBFS_PORT]:-443})" + fi + + # Validate required fields for forward tunnels + if [[ "$tunnel_type" == "local" ]]; then + if [[ -z "${_sp[REMOTE_HOST]:-}" ]] || [[ -z "${_sp[REMOTE_PORT]:-}" ]]; then + log_error "Local forward requires REMOTE_HOST and REMOTE_PORT" + _st_unlock; return 1 + fi + if [[ -z "${_sp[LOCAL_PORT]:-}" ]]; then + log_error "Local forward requires LOCAL_PORT" + _st_unlock; return 1 + fi + elif [[ "$tunnel_type" == "remote" ]]; then + if [[ -z "${_sp[REMOTE_PORT]:-}" ]] || [[ -z "${_sp[LOCAL_PORT]:-}" ]]; then + log_error "Remote forward requires REMOTE_PORT and LOCAL_PORT" + _st_unlock; return 1 + fi + fi + + # Check port collision (non-remote tunnels) + if [[ "$tunnel_type" != "remote" ]] && [[ -n "$local_port" ]]; then + if is_port_in_use "$local_port" "$bind_addr"; then + log_error "Port ${local_port} is already in use" + _st_unlock; return 1 + fi + fi + + # Build SSH command + local -a ssh_cmd + local _build_output + _build_output=$(build_tunnel_cmd "$name") || { log_error "Failed to build SSH command for '${name}'"; _st_unlock; return 1; } + mapfile -t ssh_cmd <<< "$_build_output" + if [[ ${#ssh_cmd[@]} -eq 0 ]]; then + log_error "Failed to build SSH command for '${name}'" + _st_unlock; return 1 + fi + + local tunnel_log tunnel_pid_file + tunnel_log=$(_log_file "$name") + tunnel_pid_file=$(_pid_file "$name") + + log_info "Starting tunnel '${name}' (${tunnel_type})..." + log_debug "Command: ${ssh_cmd[*]}" + + # Password-based auth via sshpass or SSH_ASKPASS + local _st_password="${_sp[SSH_PASSWORD]:-}" + local _st_use_sshpass=false + local _st_use_askpass=false + local _st_saved_display="${DISPLAY:-}" + local _st_had_display=false + if [[ -n "${DISPLAY+x}" ]]; then _st_had_display=true; fi + + # Auth cleanup helper — unset env vars, restore DISPLAY, remove askpass files + _st_cleanup_auth() { + if [[ "${_st_use_sshpass:-}" == true ]]; then unset SSHPASS 2>/dev/null || true; fi + if [[ "${_st_use_askpass:-}" == true ]]; then + unset SSH_ASKPASS SSH_ASKPASS_REQUIRE 2>/dev/null || true + if [[ "${_st_had_display:-}" == true ]]; then + export DISPLAY="$_st_saved_display" + else + unset DISPLAY 2>/dev/null || true + fi + rm -f "${PID_DIR}/${name}.askpass" "${PID_DIR}/${name}.pass" 2>/dev/null || true + fi + } + + if [[ -n "$_st_password" ]]; then + if [[ -n "${_sp[JUMP_HOSTS]:-}" ]]; then + # Jump host tunnels need multiple password prompts. + # sshpass only handles one, so use SSH_ASKPASS instead. + local _askpass_file="${PID_DIR}/${name}.askpass" + local _passfile="${PID_DIR}/${name}.pass" + printf '%s\n' "$_st_password" > "$_passfile" + chmod 600 "$_passfile" + printf '#!/bin/bash\ncat "%s"\n' "$_passfile" > "$_askpass_file" + chmod 700 "$_askpass_file" + export DISPLAY="${DISPLAY:-:0}" + export SSH_ASKPASS="$_askpass_file" + export SSH_ASKPASS_REQUIRE="force" + _st_use_askpass=true + else + if ! command -v sshpass &>/dev/null; then + log_info "Installing sshpass for password authentication..." + if [[ -n "${PKG_UPDATE:-}" ]]; then ${PKG_UPDATE} &>/dev/null || true; fi + install_package "sshpass" || log_warn "Failed to install sshpass" + fi + if command -v sshpass &>/dev/null; then + _st_use_sshpass=true + export SSHPASS="$_st_password" + else + log_warn "sshpass unavailable — SSH will prompt for password interactively" + fi + fi + fi + + local use_autossh="${_sp[AUTOSSH_ENABLED]:-$(config_get AUTOSSH_ENABLED true)}" + + # autossh cannot handle -J (ProxyJump) — it mangles the argument parsing. + # Fall back to plain SSH for jump host tunnels. + if [[ -n "${_sp[JUMP_HOSTS]:-}" ]] && [[ "$use_autossh" == "true" ]]; then + log_debug "Jump host tunnel — skipping autossh (incompatible with -J)" + use_autossh="false" + fi + + if [[ "$use_autossh" == "true" ]] && command -v autossh &>/dev/null; then + # ── AutoSSH mode ── + local monitor_port="${_sp[AUTOSSH_MONITOR_PORT]:-$(config_get AUTOSSH_MONITOR_PORT 0)}" + + export AUTOSSH_PIDFILE="$tunnel_pid_file" + export AUTOSSH_LOGFILE="$tunnel_log" + export AUTOSSH_POLL="$(config_get AUTOSSH_POLL 30)" + export AUTOSSH_GATETIME="$(config_get AUTOSSH_GATETIME 30)" + export AUTOSSH_FIRST_POLL="$(config_get AUTOSSH_FIRST_POLL 30)" + export AUTOSSH_LOGLEVEL="$(config_get AUTOSSH_LOG_LEVEL 1)" + + ssh_cmd[0]="autossh" + local -a autossh_cmd=("${ssh_cmd[0]}" "-M" "$monitor_port" "${ssh_cmd[@]:1}") + + if [[ "$_st_use_sshpass" == true ]]; then + local -a _sshpass_autossh=(sshpass -e "${autossh_cmd[@]}") + "${_sshpass_autossh[@]}" >> "$tunnel_log" 2>&1 & + else + "${autossh_cmd[@]}" >> "$tunnel_log" 2>&1 & + fi + local bg_pid=$! + disown "$bg_pid" 2>/dev/null || true + # Always record autossh parent PID for reliable kill + printf '%s\n' "$bg_pid" > "${tunnel_pid_file}.autossh" 2>/dev/null || true + + # Wait for autossh to write its PID (AUTOSSH_PIDFILE) + local _as_wait + for _as_wait in 1 2 3; do + if [[ -f "$tunnel_pid_file" ]]; then break; fi + sleep 1 + done + # Fallback: if autossh didn't write PID, use background job PID + if [[ ! -f "$tunnel_pid_file" ]]; then + local _pid_tmp + _pid_tmp=$(mktemp "${tunnel_pid_file}.XXXXXX") || { + log_error "Cannot create PID temp file" + unset AUTOSSH_PIDFILE AUTOSSH_LOGFILE AUTOSSH_POLL AUTOSSH_GATETIME AUTOSSH_FIRST_POLL AUTOSSH_LOGLEVEL + _st_cleanup_auth; _st_unlock; return 1 + } + printf '%s\n' "$bg_pid" > "$_pid_tmp" && mv -f "$_pid_tmp" "$tunnel_pid_file" || { + rm -f "$_pid_tmp" 2>/dev/null + log_error "Failed to write PID file for '${name}'" + unset AUTOSSH_PIDFILE AUTOSSH_LOGFILE AUTOSSH_POLL AUTOSSH_GATETIME AUTOSSH_FIRST_POLL AUTOSSH_LOGLEVEL + _st_cleanup_auth; _st_unlock; return 1 + } + fi + + unset AUTOSSH_PIDFILE AUTOSSH_LOGFILE AUTOSSH_POLL + unset AUTOSSH_GATETIME AUTOSSH_FIRST_POLL AUTOSSH_LOGLEVEL + else + # ── Plain SSH mode ── + if [[ "$_st_use_sshpass" == true ]] || [[ "$_st_use_askpass" == true ]]; then + # sshpass/askpass and ssh -f are incompatible (-f breaks pty). + # Background the whole command ourselves instead. + if [[ "$_st_use_sshpass" == true ]]; then + sshpass -e "${ssh_cmd[@]}" >> "$tunnel_log" 2>&1 & + else + # > "$tunnel_log" 2>&1 & + fi + local bg_pid=$! + disown "$bg_pid" 2>/dev/null || true + + # Wait for SSH to authenticate and establish the tunnel + sleep 3 + if ! kill -0 "$bg_pid" 2>/dev/null; then + log_error "SSH connection failed for '${name}'" + _st_cleanup_auth; _st_unlock; return 1 + fi + else + # Use -f so SSH authenticates in foreground (password prompt works) + # then forks to background after successful auth + local _ssh_rc=0 + "${ssh_cmd[@]}" -f >> "$tunnel_log" 2>&1 /dev/null | head -1) || true + fi + # Fallback: search for the SSH process by command line + if [[ -z "$bg_pid" ]]; then + bg_pid=$(pgrep -n -f "ssh.*-[DJ].*${ssh_host}" 2>/dev/null) || true + fi + if [[ -z "$bg_pid" ]]; then + bg_pid=$(pgrep -n -f "ssh.*${ssh_host}" 2>/dev/null) || true + fi + [[ -n "$bg_pid" ]] && break + sleep 1 + done + + if [[ -z "$bg_pid" ]]; then + log_error "Could not find SSH process for '${name}'" + _st_cleanup_auth; _st_unlock; return 1 + fi + fi + + local _pid_tmp + _pid_tmp=$(mktemp "${tunnel_pid_file}.XXXXXX") || { log_error "Cannot create PID temp file"; _st_cleanup_auth; _st_unlock; return 1; } + printf '%s\n' "$bg_pid" > "$_pid_tmp" && mv -f "$_pid_tmp" "$tunnel_pid_file" || { + rm -f "$_pid_tmp" 2>/dev/null + log_error "Failed to write PID file for '${name}'" + _st_cleanup_auth; _st_unlock; return 1 + } + fi + + # Clean up auth env vars + restore DISPLAY (keep askpass files for autossh reconnection) + if [[ "$_st_use_sshpass" == true ]]; then unset SSHPASS 2>/dev/null || true; fi + if [[ "$_st_use_askpass" == true ]]; then + unset SSH_ASKPASS SSH_ASKPASS_REQUIRE 2>/dev/null || true + if [[ "$_st_had_display" == true ]]; then + export DISPLAY="$_st_saved_display" + else + unset DISPLAY 2>/dev/null || true + fi + fi + + sleep 1 + if is_tunnel_running "$name"; then + local pid + pid=$(get_tunnel_pid "$name") + # Write startup timestamp for reliable uptime tracking + date +%s > "${PID_DIR}/${name}.started" 2>/dev/null || true + log_success "Tunnel '${name}' started (PID: ${pid})" + log_file "info" "Tunnel '${name}' started (PID: ${pid}, type: ${tunnel_type})" || true + _notify_tunnel_start "$name" "$tunnel_type" "$pid" || true + + # Enable security features if configured + if [[ "${_sp[DNS_LEAK_PROTECTION]:-}" == "true" ]]; then + log_info "Enabling DNS leak protection..." + if ! enable_dns_leak_protection; then + log_warn "DNS leak protection FAILED — tunnel running WITHOUT DNS protection" + fi + fi + if [[ "${_sp[KILL_SWITCH]:-}" == "true" ]]; then + log_info "Enabling kill switch..." + if ! enable_kill_switch "$name"; then + log_warn "Kill switch FAILED — tunnel running WITHOUT kill switch" + fi + fi + + # Start local stunnel for inbound TLS+PSK if configured + if [[ -n "${_sp[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_sp[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then + log_info "Starting inbound TLS wrapper (stunnel + PSK)..." + if _obfs_start_local_stunnel "$name" _sp; then + _obfs_show_client_config "$name" _sp + else + log_warn "Inbound TLS wrapper failed — tunnel running WITHOUT inbound protection" + fi + fi + _st_unlock; return 0 + else + log_error "Tunnel '${name}' failed to start" + log_info "Check logs: ${tunnel_log}" + _notify_tunnel_fail "$name" || true + _st_cleanup_auth + rm -f "$tunnel_pid_file" 2>/dev/null || true + _st_unlock; return 1 + fi +} + +stop_tunnel() { + local name="$1" + + # Per-profile lock to prevent concurrent start/stop races + local _stp_lock_fd="" _stp_lock_dir="" + _stp_unlock() { + if [[ -n "${_stp_lock_fd:-}" ]]; then exec {_stp_lock_fd}>&- 2>/dev/null || true; fi + if [[ -n "${_stp_lock_dir:-}" ]]; then + rm -f "${_stp_lock_dir}/pid" 2>/dev/null || true + rmdir "${_stp_lock_dir}" 2>/dev/null || true + _stp_lock_dir="" + fi + rm -f "${PID_DIR}/${name}.lock" 2>/dev/null || true + } + if command -v flock &>/dev/null; then + exec {_stp_lock_fd}>"${PID_DIR}/${name}.lock" 2>/dev/null || { log_error "Could not open lock file for '${name}'"; return 1; } + flock -w 10 "$_stp_lock_fd" 2>/dev/null || { log_error "Could not acquire lock for '${name}'"; _stp_unlock; return 1; } + else + _stp_lock_dir="${PID_DIR}/${name}.lck" + local _stp_try=0 + while ! mkdir "$_stp_lock_dir" 2>/dev/null; do + local _stp_stale_pid="" + _stp_stale_pid=$(cat "${_stp_lock_dir}/pid" 2>/dev/null) || true + if [[ -n "$_stp_stale_pid" ]] && ! kill -0 "$_stp_stale_pid" 2>/dev/null; then + rm -f "${_stp_lock_dir}/pid" 2>/dev/null || true + rmdir "$_stp_lock_dir" 2>/dev/null || true + continue + fi + if (( ++_stp_try >= 20 )); then log_error "Could not acquire lock for '${name}'"; return 1; fi + sleep 0.5 + done + printf '%s' "$$" > "${_stp_lock_dir}/pid" 2>/dev/null || true + fi + + if ! is_tunnel_running "$name"; then + log_warn "Tunnel '${name}' is not running" + # Still clean up stale PID and security features + _clean_stale_pid "$name" + local -A _stp_stale + if load_profile "$name" _stp_stale 2>/dev/null; then + if [[ "${_stp_stale[KILL_SWITCH]:-}" == "true" ]]; then + disable_kill_switch "$name" || log_warn "Kill switch disable failed for stale tunnel" + fi + if [[ "${_stp_stale[DNS_LEAK_PROTECTION]:-}" == "true" ]]; then + disable_dns_leak_protection || log_warn "DNS leak protection disable failed for stale tunnel" + fi + fi + _stp_unlock; return 0 + fi + + local tunnel_pid tunnel_pid_file + tunnel_pid=$(get_tunnel_pid "$name") + tunnel_pid_file=$(_pid_file "$name") + + log_info "Stopping tunnel '${name}' (PID: ${tunnel_pid})..." + + # Load profile for security cleanup + local -A _stp + load_profile "$name" _stp 2>/dev/null || true + + if [[ "${_stp[KILL_SWITCH]:-}" == "true" ]]; then + log_info "Disabling kill switch..." + disable_kill_switch "$name" || log_warn "Kill switch disable failed" + fi + if [[ "${_stp[DNS_LEAK_PROTECTION]:-}" == "true" ]]; then + log_info "Disabling DNS leak protection..." + disable_dns_leak_protection || log_warn "DNS leak protection disable failed" + fi + + # Stop local stunnel (inbound TLS+PSK) if running + _obfs_stop_local_stunnel "$name" || true + + # Kill autossh parent first (if present) — it handles SSH child cleanup + local _autossh_parent_file="${tunnel_pid_file}.autossh" + if [[ -f "$_autossh_parent_file" ]]; then + local _as_parent_pid="" + read -r _as_parent_pid < "$_autossh_parent_file" 2>/dev/null || true + if [[ -n "$_as_parent_pid" ]] && kill -0 "$_as_parent_pid" 2>/dev/null; then + kill "$_as_parent_pid" 2>/dev/null || true + fi + rm -f "$_autossh_parent_file" 2>/dev/null || true + fi + + # Graceful SIGTERM → wait → SIGKILL + kill "$tunnel_pid" 2>/dev/null || true + local waited=0 + while (( waited < 5 )) && kill -0 "$tunnel_pid" 2>/dev/null; do + sleep 1; ((++waited)) + done + if kill -0 "$tunnel_pid" 2>/dev/null; then + log_warn "Force killing tunnel '${name}'..." + kill -9 "$tunnel_pid" 2>/dev/null || true + sleep 1 + fi + + # Clean up SSH control sockets (stale sockets from %C hash naming) + # Use timeout to prevent hanging if remote host is unreachable + find "${SSH_CONTROL_DIR}" -maxdepth 1 -type s ! -name '.' -exec \ + sh -c 'timeout 3 ssh -O check -o "ControlPath=$1" dummy 2>/dev/null || rm -f "$1"' _ {} \; 2>/dev/null || true + + if ! kill -0 "$tunnel_pid" 2>/dev/null; then + rm -f "$tunnel_pid_file" "${tunnel_pid_file}.autossh" "${PID_DIR}/${name}.stunnel" \ + "${PID_DIR}/${name}.started" "${PID_DIR}/${name}.askpass" "${PID_DIR}/${name}.pass" 2>/dev/null || true + unset '_SSH_PID_CACHE[$name]' 2>/dev/null || true + log_success "Tunnel '${name}' stopped" + log_file "info" "Tunnel '${name}' stopped" || true + _notify_tunnel_stop "$name" || true + _stp_unlock; return 0 + else + log_error "Failed to stop tunnel '${name}'" + _stp_unlock; return 1 + fi +} + +restart_tunnel() { + local name="$1" + + # Hold a restart lock across the entire stop+start sequence + # to prevent another process from starting during the gap + local _rt_lock_fd="" _rt_lock_dir="" + _rt_unlock() { + if [[ -n "${_rt_lock_fd:-}" ]]; then exec {_rt_lock_fd}>&- 2>/dev/null || true; fi + if [[ -n "${_rt_lock_dir:-}" ]]; then + rm -f "${_rt_lock_dir}/pid" 2>/dev/null || true + rmdir "${_rt_lock_dir}" 2>/dev/null || true + _rt_lock_dir="" + fi + rm -f "${PID_DIR}/${name}.restart.lock" 2>/dev/null || true + } + if command -v flock &>/dev/null; then + exec {_rt_lock_fd}>"${PID_DIR}/${name}.restart.lock" 2>/dev/null || { log_error "Could not open restart lock file for '${name}'"; return 1; } + flock -w 10 "$_rt_lock_fd" 2>/dev/null || { log_error "Could not acquire restart lock for '${name}'"; _rt_unlock; return 1; } + else + _rt_lock_dir="${PID_DIR}/${name}.restart.lck" + local _rt_try=0 + while ! mkdir "$_rt_lock_dir" 2>/dev/null; do + local _rt_stale_pid="" + _rt_stale_pid=$(cat "${_rt_lock_dir}/pid" 2>/dev/null) || true + if [[ -n "$_rt_stale_pid" ]] && ! kill -0 "$_rt_stale_pid" 2>/dev/null; then + rm -f "${_rt_lock_dir}/pid" 2>/dev/null || true + rmdir "$_rt_lock_dir" 2>/dev/null || true + continue + fi + if (( ++_rt_try >= 20 )); then log_error "Could not acquire restart lock for '${name}'"; return 1; fi + sleep 0.5 + done + printf '%s' "$$" > "${_rt_lock_dir}/pid" 2>/dev/null || true + fi + + log_info "Restarting tunnel '${name}'..." + _record_reconnect "$name" "manual_restart" + stop_tunnel "$name" || true + sleep 1 + start_tunnel "$name" || { log_error "Failed to restart tunnel '${name}'"; _rt_unlock; return 1; } + _rt_unlock +} + +start_all_tunnels() { + local started=0 failed=0 name + while IFS= read -r name; do + [[ -z "$name" ]] && continue + local autostart + autostart=$(get_profile_field "$name" "AUTOSTART") || true + if [[ "$autostart" == "true" ]]; then + local _st_rc=0 + start_tunnel "$name" || _st_rc=$? + if (( _st_rc == 0 )); then + ((++started)) + elif (( _st_rc == 2 )); then + : # already running — skip counting + else + ((++failed)) + fi + fi + done < <(list_profiles) + log_info "Started ${started} tunnel(s), ${failed} failed" + if (( failed > 0 )); then return 1; fi + return 0 +} + +stop_all_tunnels() { + local stopped=0 failed=0 name + while IFS= read -r name; do + [[ -z "$name" ]] && continue + if is_tunnel_running "$name"; then + if stop_tunnel "$name"; then + ((++stopped)) + else + ((++failed)) + fi + fi + done < <(list_profiles) + if (( failed > 0 )); then + log_info "Stopped ${stopped} tunnel(s), ${failed} failed" + return 1 + else + log_info "Stopped ${stopped} tunnel(s)" + fi + return 0 +} + +# ── Traffic & uptime helpers ── + +get_tunnel_uptime() { + local name="$1" + local pid + pid=$(get_tunnel_pid "$name") + [[ -z "$pid" ]] && { echo 0; return 0; } + kill -0 "$pid" 2>/dev/null || { echo 0; return 0; } + + local _now + printf -v _now '%(%s)T' -1 + + # Primary: startup timestamp file (most reliable, survives across invocations) + local _started_file="${PID_DIR}/${name}.started" + if [[ -f "$_started_file" ]]; then + local _st_epoch="" + read -r _st_epoch < "$_started_file" 2>/dev/null || true + if [[ "$_st_epoch" =~ ^[0-9]+$ ]]; then + echo $(( _now - _st_epoch )) + return 0 + fi + fi + + # Fallback 1: /proc/PID/stat (Linux only, pure bash — no awk forks) + if [[ -f "/proc/${pid}/stat" ]]; then + local _stat_line="" + read -r _stat_line < "/proc/${pid}/stat" 2>/dev/null || true + # Field 22 is starttime (in clock ticks). Fields are space-delimited + # but field 2 (comm) can contain spaces in parens. Strip it first. + _stat_line="${_stat_line#*(}" # remove up to first ( + _stat_line="${_stat_line#*) }" # remove up to ") " + # Now field 1=state, ... field 20=starttime (22 - 2 comm fields) + local -a _sf + read -ra _sf <<< "$_stat_line" + local start_ticks="${_sf[19]:-}" + local clk_tck + clk_tck=$(getconf CLK_TCK 2>/dev/null || echo 100) + [[ "$clk_tck" =~ ^[0-9]+$ ]] || clk_tck=100 + + local boot_time="" + local _bkey _bval + while read -r _bkey _bval; do + [[ "$_bkey" == "btime" ]] && { boot_time="$_bval"; break; } + done < /proc/stat 2>/dev/null + + if [[ "$start_ticks" =~ ^[0-9]+$ ]] && [[ "$boot_time" =~ ^[0-9]+$ ]]; then + local start_sec=$(( boot_time + start_ticks / clk_tck )) + echo $(( _now - start_sec )) + return 0 + fi + fi + + # Fallback 2: PID file mtime + local pf="${PID_DIR}/${name}.pid" + if [[ -f "$pf" ]]; then + local ft + ft=$(stat -c %Y "$pf" 2>/dev/null || stat -f %m "$pf" 2>/dev/null) || true + if [[ -n "$ft" ]]; then + echo $(( _now - ft )); return 0 + fi + fi + echo 0; return 0 +} + +# Cache: tunnel name → resolved SSH child PID (avoids re-walking tree every frame) +declare -gA _SSH_PID_CACHE=() + +get_tunnel_traffic() { + local _name="$1" + local pid + pid=$(get_tunnel_pid "$_name") + if [[ -z "$pid" ]] || [[ ! -d "/proc/${pid}" ]]; then + unset '_SSH_PID_CACHE[$_name]' 2>/dev/null || true + echo "0 0"; return 0 + fi + + # Check cached SSH child PID first + local _target="${_SSH_PID_CACHE[$_name]:-}" + if [[ -n "$_target" ]] && [[ -d "/proc/${_target}" ]]; then + # Cache hit — verify it's still alive + : + else + # Walk process tree to find actual ssh process (autossh/sshpass are wrappers) + # Uses /proc/PID/task/*/children (no fork) instead of pgrep -P + _target="$pid" + local _try _comm="" + for _try in 1 2 3; do + _comm="" + read -r _comm < "/proc/${_target}/comm" 2>/dev/null || true + [[ "$_comm" == "ssh" ]] && break + # Read first child PID from /proc without forking pgrep + local _next="" + if [[ -f "/proc/${_target}/task/${_target}/children" ]]; then + read -r _next _ < "/proc/${_target}/task/${_target}/children" 2>/dev/null || true + fi + [[ -z "$_next" ]] && break + _target="$_next" + done + _SSH_PID_CACHE["$_name"]="$_target" + fi + + if [[ ! -f "/proc/${_target}/io" ]]; then + echo "0 0"; return 0 + fi + # Read rchar/wchar with a single while-read loop (no awk forks) + local _key _val rchar=0 wchar=0 + while read -r _key _val; do + case "$_key" in + rchar:) rchar="$_val" ;; + wchar:) wchar="$_val"; break ;; # wchar comes after rchar, stop early + esac + done < "/proc/${_target}/io" 2>/dev/null + echo "${rchar} ${wchar}" +} + +# Count ESTABLISHED connections on a port from ss data (pure bash, no grep fork) +# Usage: _count_port_conns PORT [ss_data_var] +_count_port_conns() { + local _cpc_port="$1" _cpc_data="${2:-${_DASH_SS_CACHE:-}}" + if [[ -z "$_cpc_data" ]]; then + _cpc_data=$(ss -tn 2>/dev/null) || true + fi + local _cpc_count=0 _cpc_line + while IFS= read -r _cpc_line; do + [[ "$_cpc_line" == *ESTAB* ]] || continue + [[ "$_cpc_line" == *":${_cpc_port} "* ]] || [[ "$_cpc_line" == *":${_cpc_port} "* ]] || continue + (( ++_cpc_count )) || true + done <<< "$_cpc_data" + echo "$_cpc_count" +} + +get_tunnel_connections() { + local name="$1" + local -A _cc + load_profile "$name" _cc 2>/dev/null || { echo 0; return 0; } + local port="${_cc[LOCAL_PORT]:-}" + [[ -z "$port" ]] && { echo 0; return 0; } + [[ "$port" =~ ^[0-9]+$ ]] || { echo 0; return 0; } + + # When inbound TLS is active, count only the stunnel port + local _olp="${_cc[OBFS_LOCAL_PORT]:-0}" + if [[ "$_olp" =~ ^[0-9]+$ ]] && (( _olp > 0 )); then + _count_port_conns "$_olp" + else + _count_port_conns "$port" + fi +} + +# ============================================================================ +# SECURITY FEATURES (Phase 4) +# ============================================================================ + +readonly _RESOLV_CONF="/etc/resolv.conf" +readonly _RESOLV_BACKUP="${BACKUP_DIR}/resolv.conf.bak" +readonly _IPTABLES_BACKUP_DIR="${BACKUP_DIR}/iptables" +readonly _TF_CHAIN="TUNNELFORGE" + +# ── DNS Leak Protection ── +# Forces DNS through specified servers by rewriting /etc/resolv.conf + +enable_dns_leak_protection() { + if [[ $EUID -ne 0 ]]; then + log_error "DNS leak protection requires root privileges" + return 1 + fi + + mkdir -p "$BACKUP_DIR" 2>/dev/null || true + + # Backup current resolv.conf if not already backed up + if [[ ! -f "$_RESOLV_BACKUP" ]] && [[ -f "$_RESOLV_CONF" ]]; then + if ! cp "$_RESOLV_CONF" "$_RESOLV_BACKUP" 2>/dev/null; then + log_error "Failed to backup resolv.conf" + return 1 + fi + log_debug "Backed up resolv.conf" + fi + + # Remove immutable flag if set from previous run + if command -v chattr &>/dev/null; then + chattr -i "$_RESOLV_CONF" 2>/dev/null || true + fi + + # Write new resolv.conf with secure DNS + local dns1 dns2 _dns_val + dns1=$(config_get DNS_SERVER_1 "1.1.1.1") + dns2=$(config_get DNS_SERVER_2 "1.0.0.1") + + # Validate DNS server values are valid IPv4 or IPv6 addresses + local _dns_ip_re='^([0-9]{1,3}\.){3}[0-9]{1,3}$' + local _dns_ip6_re='^[0-9a-fA-F]*:[0-9a-fA-F:]*$' + for _dns_val in "$dns1" "$dns2"; do + if ! [[ "$_dns_val" =~ $_dns_ip_re ]] && ! [[ "$_dns_val" =~ $_dns_ip6_re ]]; then + log_error "Invalid DNS server address: ${_dns_val}" + return 1 + fi + done + + # Abort if resolv.conf is a symlink (systemd-resolved, etc.) + if [[ -L "$_RESOLV_CONF" ]]; then + log_error "resolv.conf is a symlink ($(readlink "$_RESOLV_CONF" 2>/dev/null || true)) — cannot safely rewrite; disable systemd-resolved first" + return 1 + fi + + # Atomic write via temp file + mv + local _resolv_tmp + _resolv_tmp=$(mktemp "${_RESOLV_CONF}.tf_tmp.XXXXXX") || { log_error "Failed to create temp file"; return 1; } + { + printf "# TunnelForge DNS Leak Protection — do not edit\n" + printf "# Original backed up to: %s\n" "$_RESOLV_BACKUP" + printf "nameserver %s\n" "$dns1" + printf "nameserver %s\n" "$dns2" + printf "options edns0\n" + } > "$_resolv_tmp" 2>/dev/null || { + log_error "Failed to write resolv.conf temp file" + rm -f "$_resolv_tmp" 2>/dev/null + return 1 + } + if ! mv -f "$_resolv_tmp" "$_RESOLV_CONF" 2>/dev/null; then + log_error "Failed to install resolv.conf (mv failed)" + rm -f "$_resolv_tmp" 2>/dev/null + return 1 + fi + + # Make immutable to prevent overwriting by system services + if command -v chattr &>/dev/null; then + if ! chattr +i "$_RESOLV_CONF" 2>/dev/null; then + log_warn "Could not make resolv.conf immutable (chattr +i failed)" + fi + fi + + log_success "DNS leak protection enabled (${dns1}, ${dns2})" + return 0 +} + +disable_dns_leak_protection() { + if [[ $EUID -ne 0 ]]; then + log_error "DNS leak protection requires root privileges" + return 1 + fi + + # Remove immutable flag + if command -v chattr &>/dev/null; then + chattr -i "$_RESOLV_CONF" 2>/dev/null || true + fi + + # Restore backup (atomic: copy to temp then mv) + if [[ -f "$_RESOLV_BACKUP" ]]; then + local _restore_tmp + _restore_tmp=$(mktemp "${_RESOLV_CONF}.tf_restore.XXXXXX") || { log_error "Failed to create temp file"; return 1; } + if ! cp "$_RESOLV_BACKUP" "$_restore_tmp" 2>/dev/null; then + log_error "Failed to copy resolv.conf backup to temp file" + rm -f "$_restore_tmp" 2>/dev/null + return 1 + fi + if ! mv -f "$_restore_tmp" "$_RESOLV_CONF" 2>/dev/null; then + log_error "Failed to restore resolv.conf (mv failed)" + rm -f "$_restore_tmp" 2>/dev/null + return 1 + fi + rm -f "$_RESOLV_BACKUP" 2>/dev/null + log_success "DNS leak protection disabled (resolv.conf restored)" + else + log_warn "No resolv.conf backup found; writing sane defaults" + local _defaults_tmp + _defaults_tmp=$(mktemp "${_RESOLV_CONF}.tf_defaults.XXXXXX") || { log_error "Failed to create temp file"; return 1; } + if ! { printf "nameserver 8.8.8.8\n"; printf "nameserver 8.8.4.4\n"; } > "$_defaults_tmp" 2>/dev/null; then + log_error "Failed to write sane defaults to temp file" + rm -f "$_defaults_tmp" 2>/dev/null + return 1 + fi + if ! mv -f "$_defaults_tmp" "$_RESOLV_CONF" 2>/dev/null; then + log_error "Failed to apply sane defaults (mv failed)" + rm -f "$_defaults_tmp" 2>/dev/null + return 1 + fi + fi + return 0 +} + +is_dns_leak_protected() { + if [[ -f "$_RESOLV_CONF" ]] && grep -qF "TunnelForge DNS" "$_RESOLV_CONF" 2>/dev/null; then + return 0 + fi + return 1 +} + +# ── Kill Switch (iptables firewall) ── +# Blocks all non-tunnel traffic to prevent data leaks if tunnel drops + +enable_kill_switch() { + local name="$1" + + if [[ $EUID -ne 0 ]]; then + log_error "Kill switch requires root privileges" + return 1 + fi + if ! command -v iptables &>/dev/null; then + log_error "iptables is required for kill switch" + return 1 + fi + + local _has_ip6tables=false + if command -v ip6tables &>/dev/null; then _has_ip6tables=true; fi + + local -A _ks_prof + load_profile "$name" _ks_prof 2>/dev/null || { + log_error "Cannot load profile '${name}' for kill switch" + return 1 + } + + local ssh_host="${_ks_prof[SSH_HOST]:-}" + local ssh_port="${_ks_prof[SSH_PORT]:-22}" + + if [[ -z "$ssh_host" ]]; then + log_error "No SSH host configured for kill switch" + return 1 + fi + + # Resolve hostname to IPv4 and IPv6 (with fallback chain for portability) + local ssh_ip ssh_ip6="" + ssh_ip=$(getent ahostsv4 "$ssh_host" 2>/dev/null | awk '{print $1; exit}') || true + if [[ -z "$ssh_ip" ]]; then + ssh_ip=$(dig +short A "$ssh_host" 2>/dev/null | head -1) || true + fi + if [[ -z "$ssh_ip" ]]; then + ssh_ip=$(host "$ssh_host" 2>/dev/null | awk '/has address/{print $NF; exit}') || true + fi + if [[ -z "$ssh_ip" ]]; then + ssh_ip=$(nslookup "$ssh_host" 2>/dev/null \ + | awk '/^Address/ && !/127\.0\.0/ && NR>2 {print $NF; exit}') || true + fi + if [[ -z "$ssh_ip" ]]; then + ssh_ip="$ssh_host" # Assume already an IP + fi + if ! validate_ip "$ssh_ip" && ! validate_ip6 "$ssh_ip"; then + log_error "Could not resolve SSH host '${ssh_host}' to a valid IP — kill switch aborted" + return 1 + fi + if [[ "$_has_ip6tables" == true ]]; then + ssh_ip6=$(getent ahostsv6 "$ssh_host" 2>/dev/null | awk '{print $1; exit}') || true + fi + + mkdir -p "$_IPTABLES_BACKUP_DIR" 2>/dev/null || true + + # Create chain if it doesn't exist + iptables -N "$_TF_CHAIN" 2>/dev/null || true + if [[ "$_has_ip6tables" == true ]]; then + ip6tables -N "$_TF_CHAIN" 2>/dev/null || true + fi + + # Check if chain already has rules (multi-tunnel support) + if iptables -S "$_TF_CHAIN" 2>/dev/null | grep -q -- "-j DROP"; then + # Chain active — remove old DROP, add this tunnel's SSH host, re-add DROP + iptables -D "$_TF_CHAIN" -j DROP 2>/dev/null || true + iptables -A "$_TF_CHAIN" -d "$ssh_ip" -p tcp --dport "$ssh_port" -m comment --comment "tf:${name}" -j ACCEPT 2>/dev/null || true + iptables -A "$_TF_CHAIN" -j DROP 2>/dev/null || true + if [[ "$_has_ip6tables" == true ]]; then + ip6tables -D "$_TF_CHAIN" -j DROP 2>/dev/null || true + if [[ -n "$ssh_ip6" ]]; then + ip6tables -A "$_TF_CHAIN" -d "$ssh_ip6" -p tcp --dport "$ssh_port" -m comment --comment "tf:${name}" -j ACCEPT 2>/dev/null || true + fi + ip6tables -A "$_TF_CHAIN" -j DROP 2>/dev/null || true + fi + else + # Fresh chain — build with all standard rules + iptables -F "$_TF_CHAIN" 2>/dev/null || true + # Allow loopback + iptables -A "$_TF_CHAIN" -o lo -j ACCEPT 2>/dev/null || true + # Allow established/related + iptables -A "$_TF_CHAIN" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true + # Allow SSH to tunnel server + iptables -A "$_TF_CHAIN" -d "$ssh_ip" -p tcp --dport "$ssh_port" -m comment --comment "tf:${name}" -j ACCEPT 2>/dev/null || true + # Allow local DNS + iptables -A "$_TF_CHAIN" -d 127.0.0.0/8 -p udp --dport 53 -j ACCEPT 2>/dev/null || true + iptables -A "$_TF_CHAIN" -d 127.0.0.0/8 -p tcp --dport 53 -j ACCEPT 2>/dev/null || true + # Allow configured DNS servers (for DNS leak protection compatibility) + local _dns1 _dns2 + _dns1=$(config_get DNS_SERVER_1 "1.1.1.1") + _dns2=$(config_get DNS_SERVER_2 "1.0.0.1") + if validate_ip "$_dns1" || validate_ip6 "$_dns1"; then + iptables -A "$_TF_CHAIN" -d "$_dns1" -p udp --dport 53 -j ACCEPT 2>/dev/null || true + iptables -A "$_TF_CHAIN" -d "$_dns1" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true + fi + if validate_ip "$_dns2" || validate_ip6 "$_dns2"; then + iptables -A "$_TF_CHAIN" -d "$_dns2" -p udp --dport 53 -j ACCEPT 2>/dev/null || true + iptables -A "$_TF_CHAIN" -d "$_dns2" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true + fi + # Allow DHCP + iptables -A "$_TF_CHAIN" -p udp --dport 67:68 -j ACCEPT 2>/dev/null || true + # Drop everything else + iptables -A "$_TF_CHAIN" -j DROP 2>/dev/null || true + + # IPv6 mirror + if [[ "$_has_ip6tables" == true ]]; then + ip6tables -F "$_TF_CHAIN" 2>/dev/null || true + ip6tables -A "$_TF_CHAIN" -o lo -j ACCEPT 2>/dev/null || true + ip6tables -A "$_TF_CHAIN" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true + if [[ -n "$ssh_ip6" ]]; then + ip6tables -A "$_TF_CHAIN" -d "$ssh_ip6" -p tcp --dport "$ssh_port" -m comment --comment "tf:${name}" -j ACCEPT 2>/dev/null || true + fi + ip6tables -A "$_TF_CHAIN" -d ::1 -p udp --dport 53 -j ACCEPT 2>/dev/null || true + ip6tables -A "$_TF_CHAIN" -d ::1 -p tcp --dport 53 -j ACCEPT 2>/dev/null || true + # Allow configured DNS servers if they are IPv6 + local _dns_v + for _dns_v in "$_dns1" "$_dns2"; do + if [[ "$_dns_v" =~ : ]]; then + ip6tables -A "$_TF_CHAIN" -d "$_dns_v" -p udp --dport 53 -j ACCEPT 2>/dev/null || true + ip6tables -A "$_TF_CHAIN" -d "$_dns_v" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true + fi + done + ip6tables -A "$_TF_CHAIN" -p udp --dport 546:547 -j ACCEPT 2>/dev/null || true + ip6tables -A "$_TF_CHAIN" -p ipv6-icmp -j ACCEPT 2>/dev/null || true + ip6tables -A "$_TF_CHAIN" -j DROP 2>/dev/null || true + fi + fi + + # Insert jump to chain (avoid duplicates) + if ! iptables -C OUTPUT -j "$_TF_CHAIN" 2>/dev/null; then + iptables -I OUTPUT 1 -j "$_TF_CHAIN" 2>/dev/null || true + fi + if ! iptables -C FORWARD -j "$_TF_CHAIN" 2>/dev/null; then + iptables -I FORWARD 1 -j "$_TF_CHAIN" 2>/dev/null || true + fi + if [[ "$_has_ip6tables" == true ]]; then + if ! ip6tables -C OUTPUT -j "$_TF_CHAIN" 2>/dev/null; then + ip6tables -I OUTPUT 1 -j "$_TF_CHAIN" 2>/dev/null || true + fi + if ! ip6tables -C FORWARD -j "$_TF_CHAIN" 2>/dev/null; then + ip6tables -I FORWARD 1 -j "$_TF_CHAIN" 2>/dev/null || true + fi + fi + + log_success "Kill switch enabled for '${name}' (allow ${ssh_ip}:${ssh_port})" + return 0 +} + +disable_kill_switch() { + local name="$1" + + if [[ $EUID -ne 0 ]]; then + log_error "Kill switch requires root privileges" + return 1 + fi + if ! command -v iptables &>/dev/null; then + return 0 + fi + + local _has_ip6tables=false + if command -v ip6tables &>/dev/null; then _has_ip6tables=true; fi + + # Remove only this tunnel's SSH ACCEPT rule (multi-tunnel safe) + # Load profile to get SSH host/port for exact rule match + local -A _dks_prof + load_profile "$name" _dks_prof 2>/dev/null || true + local _dks_host="${_dks_prof[SSH_HOST]:-}" + local _dks_port="${_dks_prof[SSH_PORT]:-22}" + local _dks_ip + _dks_ip=$(getent ahostsv4 "$_dks_host" 2>/dev/null | awk '{print $1; exit}') || true + if [[ -z "$_dks_ip" ]]; then + _dks_ip=$(dig +short A "$_dks_host" 2>/dev/null | head -1) || true + fi + if [[ -z "$_dks_ip" ]]; then + _dks_ip=$(host "$_dks_host" 2>/dev/null | awk '/has address/{print $NF; exit}') || true + fi + : "${_dks_ip:=$_dks_host}" + if [[ -n "$_dks_ip" ]]; then + iptables -D "$_TF_CHAIN" -d "$_dks_ip" -p tcp --dport "$_dks_port" -m comment --comment "tf:${name}" -j ACCEPT 2>/dev/null || true + fi + # IPv6: remove tunnel rule + if [[ "$_has_ip6tables" == true ]]; then + local _dks_ip6 + _dks_ip6=$(getent ahostsv6 "$_dks_host" 2>/dev/null | awk '{print $1; exit}') || true + if [[ -n "$_dks_ip6" ]]; then + ip6tables -D "$_TF_CHAIN" -d "$_dks_ip6" -p tcp --dport "$_dks_port" -m comment --comment "tf:${name}" -j ACCEPT 2>/dev/null || true + fi + fi + # Fallback: find exact rule by comment using -S output and delete it + local _fb_rule + _fb_rule=$(iptables -S "$_TF_CHAIN" 2>/dev/null | grep -F "tf:${name}" | head -1) || true + if [[ -n "$_fb_rule" ]]; then + _fb_rule="${_fb_rule/-A $_TF_CHAIN/-D $_TF_CHAIN}" + _fb_rule="${_fb_rule//\"/}" + local -a _fb_arr + read -ra _fb_arr <<< "$_fb_rule" + iptables "${_fb_arr[@]}" 2>/dev/null || true + fi + # IPv6 fallback + if [[ "$_has_ip6tables" == true ]]; then + local _fb6_rule + _fb6_rule=$(ip6tables -S "$_TF_CHAIN" 2>/dev/null | grep -F "tf:${name}" | head -1) || true + if [[ -n "$_fb6_rule" ]]; then + _fb6_rule="${_fb6_rule/-A $_TF_CHAIN/-D $_TF_CHAIN}" + _fb6_rule="${_fb6_rule//\"/}" + local -a _fb6_arr + read -ra _fb6_arr <<< "$_fb6_rule" + ip6tables "${_fb6_arr[@]}" 2>/dev/null || true + fi + fi + + # Check if any other tunnel rules remain + local _remaining + _remaining=$(iptables -S "$_TF_CHAIN" 2>/dev/null | grep -c 'tf:' || true) + : "${_remaining:=0}" + + if (( _remaining == 0 )); then + # Last tunnel — tear down the entire chain + iptables -D OUTPUT -j "$_TF_CHAIN" 2>/dev/null || true + iptables -D FORWARD -j "$_TF_CHAIN" 2>/dev/null || true + iptables -F "$_TF_CHAIN" 2>/dev/null || true + iptables -X "$_TF_CHAIN" 2>/dev/null || true + if [[ "$_has_ip6tables" == true ]]; then + ip6tables -D OUTPUT -j "$_TF_CHAIN" 2>/dev/null || true + ip6tables -D FORWARD -j "$_TF_CHAIN" 2>/dev/null || true + ip6tables -F "$_TF_CHAIN" 2>/dev/null || true + ip6tables -X "$_TF_CHAIN" 2>/dev/null || true + fi + fi + + # Clean up stale pristine backup files (no longer used — chain flush+delete is sufficient) + if (( _remaining == 0 )); then + rm -f "${_IPTABLES_BACKUP_DIR}/pristine.rules" 2>/dev/null + rm -f "${_IPTABLES_BACKUP_DIR}/pristine6.rules" 2>/dev/null + fi + + log_success "Kill switch disabled for '${name}'" + return 0 +} + +is_kill_switch_active() { + command -v iptables &>/dev/null || return 1 + if iptables -C OUTPUT -j "$_TF_CHAIN" 2>/dev/null; then + return 0 + fi + return 1 +} + +# ── SSH Key Management ── + +generate_ssh_key() { + local key_type="${1:-ed25519}" + local key_path="${2:-${HOME}/.ssh/id_${key_type}}" + local comment="${3:-tunnelforge@$(hostname 2>/dev/null || echo localhost)}" + + if [[ -f "$key_path" ]]; then + log_warn "Key already exists: ${key_path}" + return 0 + fi + + local key_dir + key_dir=$(dirname "$key_path") + mkdir -p "$key_dir" 2>/dev/null || true + chmod 700 "$key_dir" 2>/dev/null || true + + log_info "Generating ${key_type} SSH key..." + + if ssh-keygen -t "$key_type" -f "$key_path" -C "$comment" -N "" 2>/dev/null; then + chmod 600 "$key_path" 2>/dev/null || true + chmod 644 "${key_path}.pub" 2>/dev/null || true + log_success "SSH key generated: ${key_path}" + log_info "Public key:" + printf " %s\n" "$(cat "${key_path}.pub" 2>/dev/null)" + return 0 + fi + log_error "Failed to generate SSH key" + return 1 +} + +deploy_ssh_key() { + local name="$1" + + local -A _dk_prof + load_profile "$name" _dk_prof 2>/dev/null || { + log_error "Cannot load profile '${name}'" + return 1 + } + + local ssh_host="${_dk_prof[SSH_HOST]:-}" + local ssh_port="${_dk_prof[SSH_PORT]:-22}" + local ssh_user="${_dk_prof[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}" + local key="${_dk_prof[IDENTITY_KEY]:-}" + + if [[ -z "$ssh_host" ]]; then + log_error "No SSH host in profile '${name}'" + return 1 + fi + + # Find public key + local pub_key="" + if [[ -n "$key" ]] && [[ -f "${key}.pub" ]]; then + pub_key="${key}.pub" + elif [[ -f "${HOME}/.ssh/id_ed25519.pub" ]]; then + pub_key="${HOME}/.ssh/id_ed25519.pub" + elif [[ -f "${HOME}/.ssh/id_rsa.pub" ]]; then + pub_key="${HOME}/.ssh/id_rsa.pub" + else + log_error "No public key found to deploy" + return 1 + fi + + log_info "Deploying key to ${ssh_user}@${ssh_host}:${ssh_port}..." + + local _dk_pass="${_dk_prof[SSH_PASSWORD]:-}" + local -a _dk_sshpass_prefix=() + if [[ -n "$_dk_pass" ]] && command -v sshpass &>/dev/null; then + _dk_sshpass_prefix=(env "SSHPASS=${_dk_pass}" sshpass -e) + fi + + if command -v ssh-copy-id &>/dev/null; then + local priv_key="${pub_key%.pub}" + local -a _dk_opts=(-i "$priv_key" -p "$ssh_port" -o "StrictHostKeyChecking=accept-new") + if [[ -n "$key" ]] && [[ -f "$key" ]]; then _dk_opts+=(-o "IdentityFile=${key}"); fi + if "${_dk_sshpass_prefix[@]}" ssh-copy-id "${_dk_opts[@]}" "${ssh_user}@${ssh_host}" 2>/dev/null; then + log_success "Key deployed to ${ssh_user}@${ssh_host}" + return 0 + fi + fi + + # Manual fallback (always attempted if ssh-copy-id missing or failed) + local -a _dk_ssh_opts=(-p "$ssh_port" -o "StrictHostKeyChecking=accept-new") + if [[ -n "$key" ]] && [[ -f "$key" ]]; then _dk_ssh_opts+=(-o "IdentityFile=${key}"); fi + if "${_dk_sshpass_prefix[@]}" ssh "${_dk_ssh_opts[@]}" "${ssh_user}@${ssh_host}" \ + 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys' \ + < "$pub_key" 2>/dev/null; then + log_success "Key deployed to ${ssh_user}@${ssh_host}" + return 0 + fi + + log_error "Failed to deploy key" + return 1 +} + +check_key_permissions() { + local key_path="$1" + local issues=0 + + if [[ ! -f "$key_path" ]]; then + log_warn "Key not found: ${key_path}" + return 1 + fi + + local perms + perms=$(stat -c "%a" "$key_path" 2>/dev/null || stat -f "%Lp" "$key_path" 2>/dev/null) || true + if [[ -n "$perms" ]]; then + case "$perms" in + 600|400) ;; + *) log_warn "Insecure permissions on ${key_path}: ${perms} (should be 600)" + ((++issues)) ;; + esac + fi + + local owner + owner=$(stat -c "%U" "$key_path" 2>/dev/null || stat -f "%Su" "$key_path" 2>/dev/null) || true + if [[ -n "$owner" ]] && [[ "$owner" != "$(whoami 2>/dev/null || echo root)" ]]; then + log_warn "Key ${key_path} owned by ${owner}" + ((++issues)) + fi + + if (( issues == 0 )); then + log_debug "Key permissions OK: ${key_path}" + return 0 + fi + return 1 +} + +# ── Verify SSH Host Fingerprint ── + +verify_host_fingerprint() { + local host="$1" port="${2:-22}" + + if [[ -z "$host" ]]; then + log_error "Usage: verify_host_fingerprint [port]" + return 1 + fi + + # Validate hostname — reject option injection and shell metacharacters + if [[ "$host" == -* ]] || [[ "$host" =~ [^a-zA-Z0-9._:@%\[\]-] ]]; then + log_error "Invalid hostname: ${host}" + return 1 + fi + + printf "\n${BOLD}SSH Host Fingerprints for %s:%s${RESET}\n\n" "$host" "$port" + + if ! command -v ssh-keyscan &>/dev/null; then + log_error "ssh-keyscan is required" + return 1 + fi + + local keys + keys=$(ssh-keyscan -p "$port" -- "$host" 2>/dev/null) || true + if [[ -z "$keys" ]]; then + log_error "Could not retrieve host keys from ${host}:${port}" + return 1 + fi + + while IFS= read -r _fline || [[ -n "$_fline" ]]; do + [[ -z "$_fline" ]] && continue + [[ "$_fline" == \#* ]] && continue + local _fp + _fp=$(echo "$_fline" | ssh-keygen -lf - 2>/dev/null) || true + if [[ -n "$_fp" ]]; then + printf " ${CYAN}●${RESET} %s\n" "$_fp" + fi + done <<< "$keys" + + printf "\n${DIM}Known hosts status:${RESET}\n" + if [[ -f "${HOME}/.ssh/known_hosts" ]]; then + local _kh_lookup="$host" + if [[ "$port" != "22" ]]; then _kh_lookup="[${host}]:${port}"; fi + if ssh-keygen -F "$_kh_lookup" -f "${HOME}/.ssh/known_hosts" &>/dev/null; then + printf " ${GREEN}●${RESET} Host is in known_hosts\n" + else + printf " ${YELLOW}▲${RESET} Host is NOT in known_hosts\n" + fi + else + printf " ${YELLOW}▲${RESET} No known_hosts file found\n" + fi + printf "\n" + return 0 +} + +# ── Security Audit ── + +security_audit() { + local score=100 issues=0 warnings=0 + + printf "\n${BOLD_CYAN}═══ TunnelForge Security Audit ═══${RESET}\n\n" + + # 1. SSH key permissions + printf "${BOLD}[1/6] SSH Key Permissions${RESET}\n" + local _found_keys=false _key_f _key_penalty=0 + for _key_f in "${HOME}/.ssh/"*; do + [[ -f "$_key_f" ]] || continue + [[ "$_key_f" == *.pub ]] && continue + [[ "$_key_f" == */known_hosts* ]] && continue + [[ "$_key_f" == */authorized_keys* ]] && continue + [[ "$_key_f" == */config ]] && continue + # Only check files that look like private keys (contain BEGIN marker) + head -1 "$_key_f" 2>/dev/null | grep -q "PRIVATE KEY" || continue + _found_keys=true + local _kp + _kp=$(stat -c "%a" "$_key_f" 2>/dev/null || stat -f "%Lp" "$_key_f" 2>/dev/null) || true + if [[ "$_kp" == "600" ]] || [[ "$_kp" == "400" ]]; then + printf " ${GREEN}●${RESET} %s (%s) OK\n" "$(basename "$_key_f")" "$_kp" + else + printf " ${RED}✗${RESET} %s (%s) — should be 600\n" "$(basename "$_key_f")" "${_kp:-?}" + ((++issues)) + if (( _key_penalty < 20 )); then + score=$(( score - 10 )) + (( _key_penalty += 10 )) + fi + fi + done + if [[ "$_found_keys" != true ]]; then + printf " ${YELLOW}▲${RESET} No SSH keys found in ~/.ssh/\n" + ((++warnings)) + fi + + # 2. SSH directory permissions + printf "\n${BOLD}[2/6] SSH Directory${RESET}\n" + if [[ -d "${HOME}/.ssh" ]]; then + local _ssh_perms + _ssh_perms=$(stat -c "%a" "${HOME}/.ssh" 2>/dev/null || stat -f "%Lp" "${HOME}/.ssh" 2>/dev/null) || true + if [[ "$_ssh_perms" == "700" ]]; then + printf " ${GREEN}●${RESET} ~/.ssh permissions: %s OK\n" "$_ssh_perms" + else + printf " ${RED}✗${RESET} ~/.ssh permissions: %s — should be 700\n" "${_ssh_perms:-?}" + ((++issues)); score=$(( score - 5 )) + fi + else + printf " ${YELLOW}▲${RESET} ~/.ssh directory does not exist\n" + ((++warnings)) + fi + + # 3. DNS leak protection status + printf "\n${BOLD}[3/6] DNS Leak Protection${RESET}\n" + if is_dns_leak_protected; then + printf " ${GREEN}●${RESET} DNS leak protection is ACTIVE\n" + else + printf " ${DIM}■${RESET} DNS leak protection is not active\n" + ((++warnings)) + fi + + # 4. Kill switch status + printf "\n${BOLD}[4/6] Kill Switch${RESET}\n" + if [[ $EUID -ne 0 ]]; then + printf " ${YELLOW}▲${RESET} Skipped — requires root to inspect iptables\n" + ((++warnings)) + elif is_kill_switch_active; then + printf " ${GREEN}●${RESET} Kill switch is ACTIVE (IPv4)\n" + if command -v ip6tables &>/dev/null && ip6tables -C OUTPUT -j "$_TF_CHAIN" 2>/dev/null; then + printf " ${GREEN}●${RESET} Kill switch is ACTIVE (IPv6)\n" + else + printf " ${YELLOW}▲${RESET} IPv6 kill switch not active\n" + ((++warnings)) + fi + else + printf " ${DIM}■${RESET} Kill switch is not active\n" + ((++warnings)) + fi + + # 5. Tunnel PID integrity + printf "\n${BOLD}[5/6] Tunnel Integrity${RESET}\n" + local _audit_profiles + _audit_profiles=$(list_profiles) + if [[ -n "$_audit_profiles" ]]; then + while IFS= read -r _aname; do + [[ -z "$_aname" ]] && continue + local _apid + _apid=$(get_tunnel_pid "$_aname" 2>/dev/null) + if [[ -n "$_apid" ]]; then + if kill -0 "$_apid" 2>/dev/null; then + printf " ${GREEN}●${RESET} %s (PID %s) — running\n" "$_aname" "$_apid" + else + printf " ${RED}✗${RESET} %s (PID %s) — stale PID file\n" "$_aname" "$_apid" + ((++issues)); score=$(( score - 5 )) + fi + else + printf " ${DIM}■${RESET} %s — not running\n" "$_aname" + fi + done <<< "$_audit_profiles" + else + printf " ${DIM}■${RESET} No profiles configured\n" + fi + + # 6. Listening ports check + printf "\n${BOLD}[6/6] Listening Ports${RESET}\n" + if command -v ss &>/dev/null; then + local _port_count + _port_count=$(ss -tln 2>/dev/null | tail -n +2 | wc -l) || true + printf " ${DIM}■${RESET} %s TCP ports listening\n" "${_port_count:-0}" + elif command -v netstat &>/dev/null; then + local _port_count + _port_count=$(netstat -tln 2>/dev/null | tail -n +3 | wc -l) || true + printf " ${DIM}■${RESET} %s TCP ports listening\n" "${_port_count:-0}" + else + printf " ${YELLOW}▲${RESET} Cannot check ports (ss/netstat not found)\n" + fi + + # Summary + if (( score < 0 )); then score=0; fi + printf "\n${BOLD}──────────────────────────────────${RESET}\n" + local _sc_color="${GREEN}" + if (( score < 70 )); then _sc_color="${RED}" + elif (( score < 90 )); then _sc_color="${YELLOW}"; fi + printf "${BOLD}Security Score: ${_sc_color}%d/100${RESET}\n" "$score" + printf "${DIM}Issues: %d | Warnings: %d${RESET}\n\n" "$issues" "$warnings" + return 0 +} + +# ============================================================================ +# SERVER SETUP & SYSTEMD (Phase 5) +# ============================================================================ + +readonly _SYSTEMD_DIR="/etc/systemd/system" +readonly _SSHD_CONFIG="/etc/ssh/sshd_config" +readonly _SSHD_BACKUP="${BACKUP_DIR}/sshd_config.bak" + +# ── Server Hardening ── +# Hardens a remote server's SSH config for receiving tunnel connections + +_server_harden_sshd() { + printf "\n${BOLD}[1/4] Hardening SSH daemon${RESET}\n" + + if [[ ! -f "$_SSHD_CONFIG" ]]; then + log_error "sshd_config not found at ${_SSHD_CONFIG}" + return 1 + fi + + # Backup original (canonical backup kept for first-run restore) + if [[ ! -f "$_SSHD_BACKUP" ]]; then + cp "$_SSHD_CONFIG" "$_SSHD_BACKUP" 2>/dev/null || { + log_error "Failed to backup sshd_config" + return 1 + } + log_success "Backed up sshd_config (canonical)" + fi + # Always create a timestamped backup before each modification + local _ts_backup="${BACKUP_DIR}/sshd_config.$(date -u '+%Y%m%d%H%M%S').bak" + cp "$_SSHD_CONFIG" "$_ts_backup" 2>/dev/null || true + + # Check if any user has authorized_keys before disabling password auth + local _pw_auth="no" + local _has_keys=false + local _huser + for _huser in /root /home/*; do + if [[ -f "${_huser}/.ssh/authorized_keys" ]] && [[ -s "${_huser}/.ssh/authorized_keys" ]]; then + _has_keys=true + break + fi + done + if [[ "$_has_keys" != true ]]; then + _pw_auth="yes" + log_warn "No authorized_keys found — keeping PasswordAuthentication=yes to prevent lockout" + fi + + # Apply hardening settings + local -A _harden=( + [PermitRootLogin]="prohibit-password" + [PasswordAuthentication]="$_pw_auth" + [PubkeyAuthentication]="yes" + [X11Forwarding]="no" + [AllowTcpForwarding]="yes" + [GatewayPorts]="clientspecified" + [MaxAuthTries]="3" + [LoginGraceTime]="30" + [ClientAliveInterval]="60" + [ClientAliveCountMax]="3" + [PermitEmptyPasswords]="no" + ) + + local _hk _hv _changed=false + for _hk in "${!_harden[@]}"; do + _hv="${_harden[$_hk]}" + if grep -qE "^[[:space:]]*${_hk}[[:space:]]" "$_SSHD_CONFIG" 2>/dev/null; then + # Setting exists — update it + local _cur + _cur=$(grep -E "^[[:space:]]*${_hk}[[:space:]]" "$_SSHD_CONFIG" 2>/dev/null | awk '{print $2}' | head -1) || true + if [[ "$_cur" != "$_hv" ]]; then + # First-match only to preserve Match block overrides + local _ln + _ln=$(grep -n "^[[:space:]]*${_hk}[[:space:]]" "$_SSHD_CONFIG" 2>/dev/null | head -1 | cut -d: -f1) || true + if [[ -n "$_ln" ]]; then + local _sed_tmp + _sed_tmp=$(mktemp "${_SSHD_CONFIG}.tf_sed.XXXXXX") || continue + sed "${_ln}s/.*/${_hk} ${_hv}/" "$_SSHD_CONFIG" > "$_sed_tmp" 2>/dev/null && mv -f "$_sed_tmp" "$_SSHD_CONFIG" 2>/dev/null || true + rm -f "$_sed_tmp" 2>/dev/null || true + fi + printf " ${CYAN}~${RESET} %s: %s → %s\n" "$_hk" "${_cur:-?}" "$_hv" + _changed=true + else + printf " ${GREEN}●${RESET} %s: %s (already set)\n" "$_hk" "$_hv" + fi + elif grep -qE "^[[:space:]]*#[[:space:]]*${_hk}[[:space:]]" "$_SSHD_CONFIG" 2>/dev/null; then + # Commented out — uncomment and set (first match only) + local _cln + _cln=$(grep -n "^[[:space:]]*#[[:space:]]*${_hk}[[:space:]]" "$_SSHD_CONFIG" 2>/dev/null | head -1 | cut -d: -f1) || true + if [[ -n "$_cln" ]]; then + local _sed_tmp + _sed_tmp=$(mktemp "${_SSHD_CONFIG}.tf_sed.XXXXXX") || continue + sed "${_cln}s/.*/${_hk} ${_hv}/" "$_SSHD_CONFIG" > "$_sed_tmp" 2>/dev/null && mv -f "$_sed_tmp" "$_SSHD_CONFIG" 2>/dev/null || true + rm -f "$_sed_tmp" 2>/dev/null || true + fi + printf " ${CYAN}+${RESET} %s: %s (uncommented)\n" "$_hk" "$_hv" + _changed=true + else + # Not present — insert before first Match block (or append if none) + local _match_ln + _match_ln=$(grep -n "^[[:space:]]*Match[[:space:]]" "$_SSHD_CONFIG" 2>/dev/null | head -1 | cut -d: -f1) || true + if [[ -n "$_match_ln" ]]; then + local _sed_tmp + _sed_tmp=$(mktemp "${_SSHD_CONFIG}.tf_sed.XXXXXX") || continue + { head -n "$((_match_ln - 1))" "$_SSHD_CONFIG"; printf "%s %s\n" "$_hk" "$_hv"; tail -n "+${_match_ln}" "$_SSHD_CONFIG"; } > "$_sed_tmp" 2>/dev/null && mv -f "$_sed_tmp" "$_SSHD_CONFIG" 2>/dev/null || true + rm -f "$_sed_tmp" 2>/dev/null || true + else + if [[ -s "$_SSHD_CONFIG" ]] && [[ -n "$(tail -c1 "$_SSHD_CONFIG" 2>/dev/null)" ]]; then + echo "" >> "$_SSHD_CONFIG" 2>/dev/null || true + fi + printf "%s %s\n" "$_hk" "$_hv" >> "$_SSHD_CONFIG" 2>/dev/null || true + fi + printf " ${CYAN}+${RESET} %s: %s (added)\n" "$_hk" "$_hv" + _changed=true + fi + done + + if [[ "$_changed" == true ]]; then + # Test config before reload + if sshd -t 2>/dev/null; then + log_success "sshd_config syntax OK" + local _reload_ok=false + if [[ "$INIT_SYSTEM" == "systemd" ]]; then + if systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null; then + _reload_ok=true + fi + else + if service sshd reload 2>/dev/null || service ssh reload 2>/dev/null; then + _reload_ok=true + fi + fi + if [[ "$_reload_ok" == true ]]; then + log_success "SSH daemon reloaded" + else + log_warn "Could not reload sshd (reload manually)" + fi + else + log_error "sshd_config syntax error — restoring backup" + if [[ -f "$_ts_backup" ]] && cp "$_ts_backup" "$_SSHD_CONFIG" 2>/dev/null; then + log_info "Restored from timestamped backup" + elif [[ -f "$_SSHD_BACKUP" ]] && cp "$_SSHD_BACKUP" "$_SSHD_CONFIG" 2>/dev/null; then + log_warn "Timestamped backup unavailable, restored from canonical backup" + else + log_error "CRITICAL: No backup available — sshd_config may be broken!" + fi + return 1 + fi + else + log_info "No changes needed" + fi + return 0 +} + +_server_setup_firewall() { + printf "\n${BOLD}[2/4] Configuring firewall${RESET}\n" + + # Detect SSH port from sshd_config (used by all firewall paths) + local _fw_ssh_port=22 + if [[ -f "$_SSHD_CONFIG" ]]; then + local _detected_port + _detected_port=$(grep -E "^[[:space:]]*Port[[:space:]]+" "$_SSHD_CONFIG" 2>/dev/null | awk '{print $2}' | head -1) || true + if [[ -n "$_detected_port" ]] && [[ "$_detected_port" =~ ^[0-9]+$ ]]; then + _fw_ssh_port="$_detected_port" + fi + fi + + if command -v ufw &>/dev/null; then + ufw default deny incoming 2>/dev/null || true + ufw allow "$_fw_ssh_port/tcp" 2>/dev/null || true + ufw --force enable 2>/dev/null || true + printf " ${GREEN}●${RESET} UFW enabled (default deny + SSH port %s allowed)\n" "$_fw_ssh_port" + log_success "UFW configured with default deny" + elif command -v firewall-cmd &>/dev/null; then + firewall-cmd --permanent --add-port="${_fw_ssh_port}/tcp" 2>/dev/null || true + firewall-cmd --reload 2>/dev/null || true + printf " ${GREEN}●${RESET} firewalld: SSH port %s allowed\n" "$_fw_ssh_port" + log_success "firewalld configured" + elif command -v iptables &>/dev/null; then + # IPv4 rules (conntrack instead of deprecated state module) + if ! iptables -C INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null; then + iptables -I INPUT 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true + fi + if ! iptables -C INPUT -i lo -j ACCEPT 2>/dev/null; then + iptables -I INPUT 2 -i lo -j ACCEPT 2>/dev/null || true + fi + if ! iptables -C INPUT -p tcp --dport "$_fw_ssh_port" -j ACCEPT 2>/dev/null; then + iptables -A INPUT -p tcp --dport "$_fw_ssh_port" -j ACCEPT 2>/dev/null || true + fi + iptables -P INPUT DROP 2>/dev/null || true + iptables -P FORWARD DROP 2>/dev/null || true + printf " ${GREEN}●${RESET} iptables: SSH (port %s) allowed, default deny\n" "$_fw_ssh_port" + # IPv6 mirror rules + if command -v ip6tables &>/dev/null; then + if ! ip6tables -C INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null; then + ip6tables -I INPUT 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true + fi + if ! ip6tables -C INPUT -i lo -j ACCEPT 2>/dev/null; then + ip6tables -I INPUT 2 -i lo -j ACCEPT 2>/dev/null || true + fi + if ! ip6tables -C INPUT -p tcp --dport "$_fw_ssh_port" -j ACCEPT 2>/dev/null; then + ip6tables -A INPUT -p tcp --dport "$_fw_ssh_port" -j ACCEPT 2>/dev/null || true + fi + ip6tables -P INPUT DROP 2>/dev/null || true + ip6tables -P FORWARD DROP 2>/dev/null || true + printf " ${GREEN}●${RESET} ip6tables: SSH (port %s) allowed, default deny\n" "$_fw_ssh_port" + fi + # Persist iptables rules + if [[ -d "/etc/iptables" ]]; then + iptables-save > /etc/iptables/rules.v4 2>/dev/null || true + if command -v ip6tables-save &>/dev/null; then + ip6tables-save > /etc/iptables/rules.v6 2>/dev/null || true + fi + printf " ${GREEN}●${RESET} iptables rules persisted to /etc/iptables/\n" + elif command -v netfilter-persistent &>/dev/null; then + netfilter-persistent save 2>/dev/null || true + printf " ${GREEN}●${RESET} iptables rules persisted via netfilter-persistent\n" + else + log_warn "Install iptables-persistent to survive reboots" + fi + else + printf " ${YELLOW}▲${RESET} No firewall tool found (ufw/firewalld/iptables)\n" + fi + return 0 +} + +_server_setup_fail2ban() { + printf "\n${BOLD}[3/4] Setting up fail2ban${RESET}\n" + + if ! command -v fail2ban-client &>/dev/null; then + log_info "Installing fail2ban..." + if [[ -n "${PKG_INSTALL:-}" ]]; then + ${PKG_INSTALL} fail2ban 2>/dev/null || { + log_warn "Could not install fail2ban" + return 0 + } + else + printf " ${YELLOW}▲${RESET} Cannot install fail2ban (unknown package manager)\n" + return 0 + fi + fi + + # Detect SSH port + local _f2b_ssh_port=22 + if [[ -f "$_SSHD_CONFIG" ]]; then + local _f2b_dp + _f2b_dp=$(grep -E "^[[:space:]]*Port[[:space:]]+" "$_SSHD_CONFIG" 2>/dev/null | awk '{print $2}' | head -1) || true + if [[ -n "$_f2b_dp" ]] && [[ "$_f2b_dp" =~ ^[0-9]+$ ]]; then + _f2b_ssh_port="$_f2b_dp" + fi + fi + + local jail_dir="/etc/fail2ban/jail.d" + mkdir -p "$jail_dir" 2>/dev/null || true + + local jail_file="${jail_dir}/tunnelforge-sshd.conf" + if [[ ! -f "$jail_file" ]]; then + { + printf "[sshd]\n" + printf "enabled = true\n" + printf "port = %s\n" "$_f2b_ssh_port" + printf "filter = sshd\n" + printf "maxretry = 5\n" + printf "findtime = 600\n" + printf "bantime = 3600\n" + local _f2b_backend="auto" + if [[ "$INIT_SYSTEM" == "systemd" ]]; then _f2b_backend="systemd"; fi + printf "backend = %s\n" "$_f2b_backend" + } > "$jail_file" 2>/dev/null || true + printf " ${GREEN}●${RESET} Created fail2ban SSH jail\n" + else + printf " ${GREEN}●${RESET} fail2ban SSH jail already exists\n" + fi + + if [[ "$INIT_SYSTEM" == "systemd" ]]; then + systemctl enable fail2ban 2>/dev/null || true + systemctl restart fail2ban 2>/dev/null || true + fi + log_success "fail2ban configured" + return 0 +} + +_server_setup_sysctl() { + printf "\n${BOLD}[4/4] Kernel hardening${RESET}\n" + + local sysctl_file="/etc/sysctl.d/99-tunnelforge.conf" + if [[ ! -f "$sysctl_file" ]]; then + { + printf "# TunnelForge kernel hardening\n" + printf "net.ipv4.tcp_syncookies = 1\n" + printf "net.ipv4.conf.all.rp_filter = 1\n" + printf "net.ipv4.conf.default.rp_filter = 1\n" + printf "net.ipv4.icmp_echo_ignore_broadcasts = 1\n" + printf "net.ipv4.conf.all.accept_redirects = 0\n" + printf "net.ipv4.conf.default.accept_redirects = 0\n" + printf "net.ipv4.conf.all.send_redirects = 0\n" + printf "net.ipv4.conf.default.send_redirects = 0\n" + printf "net.ipv4.ip_forward = 1\n" + } > "$sysctl_file" 2>/dev/null || true + sysctl -p "$sysctl_file" 2>/dev/null || true + printf " ${GREEN}●${RESET} Kernel parameters hardened\n" + log_success "sysctl hardening applied" + else + printf " ${GREEN}●${RESET} Kernel hardening already applied\n" + fi + return 0 +} + +server_setup() { + local _profile_name="${1:-}" + + # If a profile name is given, harden the REMOTE server via SSH + if [[ -n "$_profile_name" ]]; then + _server_setup_remote "$_profile_name" + return $? + fi + + # Otherwise, harden THIS (local) server + if [[ $EUID -ne 0 ]]; then + log_error "Server setup requires root privileges" + return 1 + fi + + printf "\n${BOLD_CYAN}═══ TunnelForge Server Setup ═══${RESET}\n" + printf "${DIM}Hardening this server for receiving SSH tunnel connections${RESET}\n" + + printf "\nThis will:\n" + printf " 1. Harden SSH daemon configuration\n" + printf " 2. Configure firewall rules\n" + printf " 3. Set up fail2ban intrusion prevention\n" + printf " 4. Apply kernel network hardening\n\n" + + if ! confirm_action "Proceed with server hardening?"; then + log_info "Server setup cancelled" + return 0 + fi + + _server_harden_sshd || true + _server_setup_firewall || true + _server_setup_fail2ban || true + _server_setup_sysctl || true + + printf "\n${BOLD_GREEN}═══ Server Setup Complete ═══${RESET}\n" + printf "${DIM}Your server is now hardened for SSH tunnel connections.${RESET}\n" + printf "${DIM}Run 'tunnelforge audit' to verify security posture.${RESET}\n\n" + return 0 +} + +# ── Remote Server Setup ── +# SSHes into a profile's target server and enables essential tunnel settings + +_server_setup_remote() { + local name="$1" + local -A _rss_prof + if ! load_profile "$name" _rss_prof; then + log_error "Profile '${name}' not found" + return 1 + fi + + local host="${_rss_prof[SSH_HOST]:-}" + local user="${_rss_prof[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}" + if [[ -z "$host" ]]; then + log_error "Profile '${name}' has no SSH_HOST" + return 1 + fi + + printf "\n${BOLD_CYAN}═══ Remote Server Setup ═══${RESET}\n" + printf "${DIM}Target: %s@%s${RESET}\n\n" "$user" "$host" + printf "This will enable on the remote server:\n" + printf " - AllowTcpForwarding yes ${DIM}(required for -D/-L/-R)${RESET}\n" + printf " - GatewayPorts clientspecified ${DIM}(for -R public bind)${RESET}\n" + printf " - PermitTunnel yes ${DIM}(for TUN/TAP forwarding)${RESET}\n\n" + + if ! confirm_action "SSH into ${host} and apply settings?"; then + log_info "Remote setup cancelled" + return 0 + fi + + _obfs_remote_ssh _rss_prof + local _rss_rc=0 + + "${_OBFS_SSH_CMD[@]}" "bash -s" <<'REMOTE_SSHD_SCRIPT' || _rss_rc=$? +set -e + +# Use sudo if not root +SUDO="" +if [ "$(id -u)" -ne 0 ]; then + if command -v sudo >/dev/null 2>&1; then + if sudo -n true 2>/dev/null; then + SUDO="sudo" + else + echo "ERROR: Not root and sudo requires a password" + echo " Either SSH as root, or add NOPASSWD for this user" + exit 1 + fi + else + echo "ERROR: Not running as root and sudo not available" + exit 1 + fi +fi + +SSHD_CONFIG="/etc/ssh/sshd_config" +if [ ! -f "$SSHD_CONFIG" ]; then + echo "ERROR: sshd_config not found at $SSHD_CONFIG" + exit 1 +fi + +# Backup before changes +$SUDO cp "$SSHD_CONFIG" "${SSHD_CONFIG}.bak.$(date +%s)" 2>/dev/null || true + +CHANGED=false + +apply_setting() { + local key="$1" val="$2" + if grep -qE "^[[:space:]]*${key}[[:space:]]" "$SSHD_CONFIG" 2>/dev/null; then + CUR=$(grep -E "^[[:space:]]*${key}[[:space:]]" "$SSHD_CONFIG" | awk '{print $2}' | head -1) + if [ "$CUR" != "$val" ]; then + LN=$(grep -n "^[[:space:]]*${key}[[:space:]]" "$SSHD_CONFIG" | head -1 | cut -d: -f1) + $SUDO sed -i "${LN}s/.*/${key} ${val}/" "$SSHD_CONFIG" + echo " ~ ${key}: ${CUR} -> ${val}" + CHANGED=true + else + echo " OK ${key}: ${val} (already set)" + fi + elif grep -qE "^[[:space:]]*#[[:space:]]*${key}" "$SSHD_CONFIG" 2>/dev/null; then + LN=$(grep -n "^[[:space:]]*#[[:space:]]*${key}" "$SSHD_CONFIG" | head -1 | cut -d: -f1) + $SUDO sed -i "${LN}s/.*/${key} ${val}/" "$SSHD_CONFIG" + echo " + ${key}: ${val} (uncommented)" + CHANGED=true + else + echo "${key} ${val}" | $SUDO tee -a "$SSHD_CONFIG" >/dev/null + echo " + ${key}: ${val} (added)" + CHANGED=true + fi +} + +echo "Checking sshd settings..." +apply_setting "AllowTcpForwarding" "yes" +apply_setting "GatewayPorts" "clientspecified" +apply_setting "PermitTunnel" "yes" + +if [ "$CHANGED" = true ]; then + if $SUDO sshd -t 2>/dev/null; then + echo "sshd_config syntax OK" + if command -v systemctl >/dev/null 2>&1; then + $SUDO systemctl reload sshd 2>/dev/null || $SUDO systemctl reload ssh 2>/dev/null || true + else + $SUDO service sshd reload 2>/dev/null || $SUDO service ssh reload 2>/dev/null || true + fi + echo "SUCCESS: SSH daemon reloaded with new settings" + else + echo "ERROR: sshd_config syntax error — restoring backup" + LATEST_BAK=$(ls -t "${SSHD_CONFIG}.bak."* 2>/dev/null | head -1) + if [ -n "$LATEST_BAK" ]; then + $SUDO cp "$LATEST_BAK" "$SSHD_CONFIG" 2>/dev/null || true + echo "Restored from backup" + fi + exit 1 + fi +else + echo "All settings already correct — no changes needed" +fi +REMOTE_SSHD_SCRIPT + + unset SSHPASS 2>/dev/null || true + + if (( _rss_rc == 0 )); then + printf "\n" + log_success "Remote server configured for tunnel forwarding" + else + printf "\n" + log_error "Remote setup failed (exit code: ${_rss_rc})" + fi + return "$_rss_rc" +} + +# ── TLS Obfuscation (stunnel) Setup ── +# Remotely installs and configures stunnel on a profile's server + +# Build SSH command for remote execution on profile's server. +# Populates global _OBFS_SSH_CMD array. Caller must unset SSHPASS. +_obfs_remote_ssh() { + local -n _ors_prof="$1" + local host="${_ors_prof[SSH_HOST]:-}" + local port="${_ors_prof[SSH_PORT]:-22}" + local user="${_ors_prof[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}" + local key="${_ors_prof[IDENTITY_KEY]:-}" + local password="${_ors_prof[SSH_PASSWORD]:-}" + + _OBFS_SSH_CMD=() + local -a ssh_opts=(-p "$port" -o "ConnectTimeout=15" -o "StrictHostKeyChecking=accept-new") + if [[ -n "$key" ]] && [[ -f "$key" ]]; then ssh_opts+=(-i "$key"); fi + + if [[ -n "$password" ]]; then + if command -v sshpass &>/dev/null; then + export SSHPASS="$password" + _OBFS_SSH_CMD=(sshpass -e ssh "${ssh_opts[@]}" "${user}@${host}") + else + ssh_opts+=(-o "BatchMode=no") + _OBFS_SSH_CMD=(ssh "${ssh_opts[@]}" "${user}@${host}") + fi + else + _OBFS_SSH_CMD=(ssh "${ssh_opts[@]}" "${user}@${host}") + fi + return 0 +} + +# Core remote setup script — shared by CLI and wizard. +# Args: obfs_port ssh_port +# Requires _OBFS_SSH_CMD to be populated by _obfs_remote_ssh(). +_obfs_run_remote_setup() { + local obfs_port="$1" ssh_port="$2" + # Validate ports are numeric before interpolation into remote script + if ! [[ "$obfs_port" =~ ^[0-9]+$ ]] || ! [[ "$ssh_port" =~ ^[0-9]+$ ]]; then + log_error "Port values must be numeric"; return 1 + fi + local _setup_rc=0 + + "${_OBFS_SSH_CMD[@]}" "bash -s" </dev/null 2>&1; then + # Verify sudo works without password (non-interactive session has no TTY) + if sudo -n true 2>/dev/null; then + SUDO="sudo" + else + echo "ERROR: Not root and sudo requires a password" + echo " Either SSH as root, or add NOPASSWD for this user in /etc/sudoers" + exit 1 + fi + else + echo "ERROR: Not running as root and sudo not available" + exit 1 + fi +fi + +# Detect package manager +if command -v apt-get >/dev/null 2>&1; then + PKG_INSTALL="\$SUDO apt-get install -y -qq" + PKG_UPDATE="\$SUDO apt-get update -qq" +elif command -v dnf >/dev/null 2>&1; then + PKG_INSTALL="\$SUDO dnf install -y -q" + PKG_UPDATE="true" +elif command -v yum >/dev/null 2>&1; then + PKG_INSTALL="\$SUDO yum install -y -q" + PKG_UPDATE="true" +elif command -v apk >/dev/null 2>&1; then + PKG_INSTALL="\$SUDO apk add --quiet" + PKG_UPDATE="\$SUDO apk update --quiet" +else + echo "ERROR: No supported package manager (apt/dnf/yum/apk)" + exit 1 +fi + +# Check if port is already in use (not by our stunnel) +if ss -tln 2>/dev/null | grep -qE ":\${OBFS_PORT}[[:space:]]"; then + LISTENER=\$(ss -tlnp 2>/dev/null | grep ":\${OBFS_PORT} " | head -1) + if echo "\$LISTENER" | grep -q stunnel; then + echo "INFO: stunnel already listening on port \${OBFS_PORT} — updating config" + else + echo "ERROR: Port \${OBFS_PORT} already in use by another service:" + echo " \$LISTENER" + echo "Choose a different OBFS_PORT or stop the conflicting service." + exit 2 + fi +fi + +# Install stunnel (skip if already present) +if command -v stunnel >/dev/null 2>&1 || command -v stunnel4 >/dev/null 2>&1; then + echo "stunnel already installed" +else + echo "Installing stunnel..." + \$PKG_UPDATE 2>/dev/null || true + # Try stunnel4 first (Debian/Ubuntu), then stunnel (RHEL/Alpine) + \$PKG_INSTALL stunnel4 >/dev/null 2>&1 || \$PKG_INSTALL stunnel >/dev/null 2>&1 || true + # Verify it actually installed + if ! command -v stunnel >/dev/null 2>&1 && ! command -v stunnel4 >/dev/null 2>&1; then + echo "ERROR: Failed to install stunnel" + echo " Try manually: ssh into the server and install stunnel" + exit 1 + fi + echo "stunnel installed" +fi + +# Ensure openssl is available +command -v openssl >/dev/null 2>&1 || \$PKG_INSTALL openssl >/dev/null 2>&1 || true + +# Generate self-signed cert if missing +CERT_DIR="/etc/stunnel" +CERT_FILE="\${CERT_DIR}/tunnelforge.pem" +\$SUDO mkdir -p "\$CERT_DIR" + +if [ ! -f "\$CERT_FILE" ]; then + echo "Generating self-signed TLS certificate..." + \$SUDO openssl req -new -x509 -days 3650 -nodes \ + -out "\$CERT_FILE" -keyout "\$CERT_FILE" \ + -subj "/CN=tunnelforge/O=TunnelForge/C=US" 2>/dev/null || { + echo "ERROR: Failed to generate certificate" + exit 1 + } + \$SUDO chmod 600 "\$CERT_FILE" + echo "Certificate generated: \$CERT_FILE" +else + echo "Certificate exists: \$CERT_FILE" +fi + +# Write stunnel config +CONF_FILE="\${CERT_DIR}/tunnelforge-ssh.conf" +\$SUDO tee "\$CONF_FILE" >/dev/null </dev/null 2>&1; then + SVC="" + for _sn in stunnel4 stunnel; do + if systemctl list-unit-files "\${_sn}.service" 2>/dev/null | grep -q "\${_sn}"; then + SVC="\$_sn" + break + fi + done + if [ -n "\$SVC" ]; then + \$SUDO systemctl enable "\$SVC" 2>/dev/null || true + \$SUDO systemctl restart "\$SVC" 2>/dev/null || true + echo "Stunnel service restarted (\${SVC})" + else + \$SUDO stunnel "\$CONF_FILE" 2>/dev/null || { echo "ERROR: stunnel failed to start"; exit 1; } + echo "Stunnel started directly" + fi +else + \$SUDO stunnel "\$CONF_FILE" 2>/dev/null || { echo "ERROR: stunnel failed to start"; exit 1; } + echo "Stunnel started" +fi + +# Open firewall port +if command -v ufw >/dev/null 2>&1; then + \$SUDO ufw allow "\${OBFS_PORT}/tcp" 2>/dev/null || true + echo "UFW: port \${OBFS_PORT} opened" +elif command -v firewall-cmd >/dev/null 2>&1; then + \$SUDO firewall-cmd --permanent --add-port="\${OBFS_PORT}/tcp" 2>/dev/null || true + \$SUDO firewall-cmd --reload 2>/dev/null || true + echo "firewalld: port \${OBFS_PORT} opened" +fi + +# Verify +sleep 1 +if ss -tln 2>/dev/null | grep -qE ":\${OBFS_PORT}[[:space:]]"; then + echo "SUCCESS: stunnel listening on port \${OBFS_PORT}" +else + echo "WARNING: stunnel may not be listening yet — check manually" +fi +OBFS_SCRIPT + + unset SSHPASS 2>/dev/null || true + return "$_setup_rc" +} + +# CLI entry: tunnelforge obfs-setup +_obfs_setup_stunnel() { + local name="$1" + local -A _os_prof=() + load_profile "$name" _os_prof || { log_error "Cannot load profile '${name}'"; return 1; } + + local host="${_os_prof[SSH_HOST]:-}" + local ssh_port="${_os_prof[SSH_PORT]:-22}" + local obfs_port="${_os_prof[OBFS_PORT]:-443}" + local user="${_os_prof[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}" + + if [[ -z "$host" ]]; then + log_error "No SSH host in profile '${name}'" + return 1 + fi + + if [[ "${_os_prof[OBFS_MODE]:-none}" == "none" ]]; then + log_warn "Profile '${name}' does not have obfuscation enabled" + printf "${DIM} Enable it first: edit profile and set OBFS_MODE=stunnel${RESET}\n" + printf "${DIM} Or use the wizard: tunnelforge edit ${name}${RESET}\n\n" + + if ! confirm_action "Set up stunnel anyway?"; then + return 0 + fi + # Auto-enable if they proceed + _os_prof[OBFS_MODE]="stunnel" + _os_prof[OBFS_PORT]="$obfs_port" + save_profile "$name" _os_prof 2>/dev/null || true + log_info "Obfuscation enabled for profile '${name}'" + fi + + printf "\n${BOLD_CYAN}═══ TLS Obfuscation Setup ═══${RESET}\n" + printf "${DIM}Configuring stunnel on %s@%s to accept TLS on port %s${RESET}\n\n" "$user" "$host" "$obfs_port" + + printf "This will:\n" + printf " 1. Install stunnel + openssl on the remote server\n" + printf " 2. Generate a self-signed TLS certificate\n" + printf " 3. Configure stunnel: port %s (TLS) → port %s (SSH)\n" "$obfs_port" "$ssh_port" + printf " 4. Enable stunnel service and open firewall port\n\n" + + if ! confirm_action "Proceed with stunnel setup on ${host}?"; then + log_info "Setup cancelled" + return 0 + fi + + _obfs_remote_ssh _os_prof || { log_error "Cannot build SSH command"; return 1; } + log_info "Connecting to ${user}@${host}..." + + local _rc=0 + _obfs_run_remote_setup "$obfs_port" "$ssh_port" || _rc=$? + + if (( _rc == 0 )); then + log_success "Stunnel configured on ${host}:${obfs_port}" + printf "\n${DIM} SSH traffic will be wrapped in TLS — DPI sees HTTPS on port ${obfs_port}.${RESET}\n" + printf "${DIM} Start your tunnel: tunnelforge start ${name}${RESET}\n\n" + elif (( _rc == 2 )); then + log_error "Port ${obfs_port} is in use on ${host} — choose a different OBFS_PORT" + return 1 + else + log_error "Stunnel setup failed on ${host} (exit code: ${_rc})" + return 1 + fi + return 0 +} + +# Wizard entry: setup stunnel using profile nameref (before profile is saved) +_obfs_setup_stunnel_direct() { + local -n _osd_prof="$1" + local host="${_osd_prof[SSH_HOST]:-}" + local ssh_port="${_osd_prof[SSH_PORT]:-22}" + local obfs_port="${_osd_prof[OBFS_PORT]:-443}" + local user="${_osd_prof[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}" + + if [[ -z "$host" ]]; then + log_error "No SSH host configured" + return 1 + fi + + printf "\n${BOLD_CYAN}═══ TLS Obfuscation Setup ═══${RESET}\n" >/dev/tty + printf "${DIM}Configuring stunnel on %s@%s (port %s → %s)${RESET}\n\n" "$user" "$host" "$obfs_port" "$ssh_port" >/dev/tty + + _obfs_remote_ssh _osd_prof || { log_error "Cannot build SSH command"; return 1; } + log_info "Connecting to ${user}@${host}..." + + local _rc=0 + _obfs_run_remote_setup "$obfs_port" "$ssh_port" || _rc=$? + + unset SSHPASS 2>/dev/null || true + + if (( _rc == 0 )); then + log_success "Stunnel configured on ${host}:${obfs_port}" + elif (( _rc == 2 )); then + log_error "Port ${obfs_port} is in use on ${host}" + else + log_error "Stunnel setup failed (exit code: ${_rc})" + fi + return "$_rc" +} + +# ── Local Stunnel (Inbound TLS + PSK) ── +# Wraps the SOCKS5/local listener with TLS+PSK so clients connect securely. +# Architecture: client ──TLS+PSK──→ stunnel ──→ 127.0.0.1:LOCAL_PORT + +# Generate a random 32-byte hex PSK +_obfs_generate_psk() { + local _psk="" + if command -v openssl &>/dev/null; then + _psk=$(openssl rand -hex 32 2>/dev/null) || true + fi + if [[ -z "$_psk" ]] && [[ -r /dev/urandom ]]; then + _psk=$(head -c 32 /dev/urandom 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || true + fi + if [[ -z "$_psk" ]]; then + # Last resort: bash $RANDOM (weaker but functional) + local _i + for _i in 1 2 3 4 5 6 7 8; do + printf '%08x' "$RANDOM$RANDOM" 2>/dev/null + done + return 0 + fi + printf '%s' "$_psk" + return 0 +} + +# Write local stunnel config and PSK secrets file. +# Args: name local_port obfs_local_port psk +_obfs_write_local_conf() { + local _name="$1" _lport="$2" _olport="$3" _psk="$4" + local _conf_dir="${CONFIG_DIR}/stunnel" + local _conf_file="${_conf_dir}/${_name}-local.conf" + local _psk_file="${_conf_dir}/${_name}-local.psk" + local _pid_f="${PID_DIR}/${_name}.stunnel" + local _log_f="${LOG_DIR}/${_name}-stunnel.log" + + mkdir -p "$_conf_dir" 2>/dev/null || true + + # Write PSK secrets file (identity:key format) + printf 'tunnelforge:%s\n' "$_psk" > "$_psk_file" 2>/dev/null || { + log_error "Cannot write PSK file: $_psk_file" + return 1 + } + if ! chmod 600 "$_psk_file" 2>/dev/null; then + log_error "Failed to secure PSK file permissions: $_psk_file" + rm -f "$_psk_file" 2>/dev/null || true + return 1 + fi + + # Write stunnel config — global options MUST come before [section] + printf '; TunnelForge inbound TLS+PSK wrapper\n' > "$_conf_file" + printf 'pid = %s\n' "$_pid_f" >> "$_conf_file" + printf 'output = %s\n' "$_log_f" >> "$_conf_file" + printf 'foreground = no\n\n' >> "$_conf_file" + printf '[tunnelforge-inbound]\n' >> "$_conf_file" + printf 'accept = 0.0.0.0:%s\n' "$_olport" >> "$_conf_file" + printf 'connect = 127.0.0.1:%s\n' "$_lport" >> "$_conf_file" + printf 'PSKsecrets = %s\n' "$_psk_file" >> "$_conf_file" + printf 'ciphers = PSK\n' >> "$_conf_file" + + chmod 600 "$_conf_file" 2>/dev/null || true + return 0 +} + +# Start local stunnel for inbound TLS+PSK. +# Args: name +# Reads profile to get LOCAL_PORT, OBFS_LOCAL_PORT, OBFS_PSK. +_obfs_start_local_stunnel() { + local _name="$1" + local -n _osl_prof="$2" + local _lport="${_osl_prof[LOCAL_PORT]:-}" + local _olport="${_osl_prof[OBFS_LOCAL_PORT]:-}" + local _psk="${_osl_prof[OBFS_PSK]:-}" + + if [[ -z "$_olport" ]] || [[ "$_olport" == "0" ]]; then return 0; fi + if [[ -z "$_lport" ]]; then + log_warn "No LOCAL_PORT for local stunnel — skipping inbound TLS" + return 0 + fi + if [[ -z "$_psk" ]]; then + log_warn "No PSK for local stunnel — skipping inbound TLS" + return 0 + fi + + if ! command -v stunnel &>/dev/null && ! command -v stunnel4 &>/dev/null; then + log_info "Installing stunnel for inbound TLS..." + if [[ -n "${PKG_UPDATE:-}" ]]; then ${PKG_UPDATE} &>/dev/null || true; fi + install_package "stunnel4" 2>/dev/null || install_package "stunnel" 2>/dev/null || true + if ! command -v stunnel &>/dev/null && ! command -v stunnel4 &>/dev/null; then + log_error "Failed to install stunnel — inbound TLS unavailable" + return 1 + fi + log_success "stunnel installed" + fi + + # Write config + PSK file (includes global options at top) + _obfs_write_local_conf "$_name" "$_lport" "$_olport" "$_psk" || return 1 + + local _conf_file="${CONFIG_DIR}/stunnel/${_name}-local.conf" + local _pid_f="${PID_DIR}/${_name}.stunnel" + local _log_f="${LOG_DIR}/${_name}-stunnel.log" + + # Check if OBFS_LOCAL_PORT is already in use + if is_port_in_use "$_olport" "0.0.0.0"; then + log_error "Inbound TLS port ${_olport} already in use" + return 1 + fi + + # Launch stunnel + local _stunnel_bin="stunnel" + if ! command -v stunnel &>/dev/null; then _stunnel_bin="stunnel4"; fi + + "$_stunnel_bin" "$_conf_file" >> "$_log_f" 2>&1 || { + log_error "Local stunnel failed to start (check ${_log_f})" + return 1 + } + + # Wait for stunnel to actually listen on the port (not just PID file) + local _sw + for _sw in 1 2 3 4 5; do + if is_port_in_use "$_olport" "0.0.0.0"; then break; fi + sleep 1 + done + + if is_port_in_use "$_olport" "0.0.0.0"; then + local _spid="" + _spid=$(cat "$_pid_f" 2>/dev/null) || true + log_success "Inbound TLS active on 0.0.0.0:${_olport} → 127.0.0.1:${_lport} (PID: ${_spid:-?})" + else + log_error "stunnel failed to listen on port ${_olport} (check ${_log_f})" + return 1 + fi + return 0 +} + +# Stop local stunnel for a profile. +# Args: name +_obfs_stop_local_stunnel() { + local _name="$1" + local _pid_f="${PID_DIR}/${_name}.stunnel" + local _conf_dir="${CONFIG_DIR}/stunnel" + + if [[ ! -f "$_pid_f" ]]; then return 0; fi + + local _spid="" + _spid=$(cat "$_pid_f" 2>/dev/null) || true + if [[ -n "$_spid" ]] && kill -0 "$_spid" 2>/dev/null; then + log_info "Stopping local stunnel (PID: ${_spid})..." + kill "$_spid" 2>/dev/null || true + local _sw=0 + while (( _sw < 3 )) && kill -0 "$_spid" 2>/dev/null; do + sleep 1; (( ++_sw )) + done + if kill -0 "$_spid" 2>/dev/null; then + kill -9 "$_spid" 2>/dev/null || true + fi + fi + + # Clean up files + rm -f "$_pid_f" \ + "${_conf_dir}/${_name}-local.conf" \ + "${_conf_dir}/${_name}-local.psk" 2>/dev/null || true + return 0 +} + +# Display client stunnel config for the user to copy. +# Args: name prof_ref +_obfs_show_client_config() { + local _name="$1" + local -n _occ_prof="$2" + local _olport="${_occ_prof[OBFS_LOCAL_PORT]:-}" + local _psk="${_occ_prof[OBFS_PSK]:-}" + local _lport="${_occ_prof[LOCAL_PORT]:-}" + local _host="" + + if [[ -z "$_olport" ]] || [[ "$_olport" == "0" ]]; then return 0; fi + + # Determine the server's reachable IP/hostname + _host="${_occ_prof[SSH_HOST]:-localhost}" + # If this machine has a public IP, try to detect it + local _pub_ip="" + _pub_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | cut -d' ' -f2) || true + if [[ -n "$_pub_ip" ]]; then _host="$_pub_ip"; fi + + printf "\n${BOLD_CYAN}═══ Client Connection Info ═══${RESET}\n" + printf "${DIM}Users connect to this server via TLS+PSK on port ${_olport}${RESET}\n\n" + + printf "${BOLD}Server address:${RESET} %s:%s\n" "$_host" "$_olport" + printf "${BOLD}PSK identity:${RESET} tunnelforge\n" + printf "${BOLD}PSK secret:${RESET} %s\n" "$_psk" + printf "${BOLD}SOCKS5 port:${RESET} %s (tunneled through TLS)\n\n" "$_lport" + + printf "${BOLD}── Client stunnel.conf ──${RESET}\n" + printf "${DIM}Save this on the user's PC and run: stunnel stunnel.conf${RESET}\n\n" + printf "[tunnelforge]\n" + printf "client = yes\n" + printf "accept = 127.0.0.1:%s\n" "$_lport" + printf "connect = %s:%s\n" "$_host" "$_olport" + printf "PSKsecrets = psk.txt\n" + printf "ciphers = PSK\n\n" + + printf "${BOLD}── psk.txt ──${RESET}\n" + printf "tunnelforge:%s\n\n" "$_psk" + + printf "${DIM}After setup, configure browser/apps to use SOCKS5 proxy:${RESET}\n" + printf "${DIM} 127.0.0.1:%s (on the user's PC)${RESET}\n\n" "$_lport" + return 0 +} + +# Show all profiles with inbound TLS+PSK configured — admin quick-reference +# for sharing client connection details. +_menu_client_configs() { + _menu_header "Client Configs" + printf " ${DIM}Profiles with inbound TLS+PSK protection${RESET}\n\n" >/dev/tty + + local _mcc_profiles _mcc_found=0 + _mcc_profiles=$(list_profiles) || true + if [[ -z "$_mcc_profiles" ]]; then + printf " ${YELLOW}No profiles found.${RESET}\n" >/dev/tty + return 0 + fi + + # Detect this machine's IP once + local _mcc_pub_ip="" + _mcc_pub_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | cut -d' ' -f2) || true + + while IFS= read -r _mcc_name; do + [[ -z "$_mcc_name" ]] && continue + local -A _mcc_p=() + load_profile "$_mcc_name" _mcc_p || continue + + local _mcc_olport="${_mcc_p[OBFS_LOCAL_PORT]:-}" + [[ -z "$_mcc_olport" ]] || [[ "$_mcc_olport" == "0" ]] && { unset _mcc_p; continue; } + + local _mcc_psk="${_mcc_p[OBFS_PSK]:-}" + local _mcc_lport="${_mcc_p[LOCAL_PORT]:-1080}" + local _mcc_host="${_mcc_pub_ip:-${_mcc_p[SSH_HOST]:-localhost}}" + local _mcc_running="" + if is_tunnel_running "$_mcc_name"; then + _mcc_running="${GREEN}ALIVE${RESET}" + else + _mcc_running="${RED}STOPPED${RESET}" + fi + + (( ++_mcc_found )) + + printf " ${BOLD_CYAN}┌─── %s [%b]${RESET}\n" "$_mcc_name" "$_mcc_running" >/dev/tty + printf " ${CYAN}│${RESET} ${BOLD}Server address:${RESET} %s\n" "$_mcc_host" >/dev/tty + printf " ${CYAN}│${RESET} ${BOLD}Port:${RESET} %s\n" "$_mcc_olport" >/dev/tty + printf " ${CYAN}│${RESET} ${BOLD}Local SOCKS5 port:${RESET} %s ${DIM}(default for client)${RESET}\n" "$_mcc_lport" >/dev/tty + printf " ${CYAN}│${RESET} ${BOLD}PSK secret key:${RESET} %s\n" "$_mcc_psk" >/dev/tty + printf " ${CYAN}└───${RESET}\n\n" >/dev/tty + + unset _mcc_p + done <<< "$_mcc_profiles" + + if (( _mcc_found == 0 )); then + printf " ${YELLOW}No profiles have inbound TLS+PSK configured.${RESET}\n" >/dev/tty + printf " ${DIM}Create a tunnel with inbound protection enabled to see configs here.${RESET}\n" >/dev/tty + else + printf " ${DIM}─────────────────────────────────────────────${RESET}\n" >/dev/tty + printf " ${DIM}Give clients the Server, Port, and PSK above.${RESET}\n" >/dev/tty + printf " ${DIM}They can use tunnelforge-client.bat (Windows) or the Linux script.${RESET}\n" >/dev/tty + printf " ${DIM}CLI: tunnelforge client-config │ tunnelforge client-script ${RESET}\n" >/dev/tty + fi + printf "\n" >/dev/tty + return 0 +} + +# Generate a self-contained client setup script. +# Users run this on their PC and it installs stunnel + connects automatically. +# Args: name prof_ref [output_file] +_obfs_generate_client_script() { + local _name="$1" + local -n _ogs_prof="$2" + local _out="${3:-}" + local _olport="${_ogs_prof[OBFS_LOCAL_PORT]:-}" + local _psk="${_ogs_prof[OBFS_PSK]:-}" + local _lport="${_ogs_prof[LOCAL_PORT]:-}" + + if [[ -z "$_olport" ]] || [[ "$_olport" == "0" ]]; then + log_error "No inbound TLS configured on profile '$_name'" + return 1 + fi + if [[ -z "$_psk" ]]; then + log_error "No PSK configured on profile '$_name'" + return 1 + fi + + # Determine server IP + local _host="" + _host="${_ogs_prof[SSH_HOST]:-localhost}" + local _pub_ip="" + _pub_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | cut -d' ' -f2) || true + if [[ -n "$_pub_ip" ]]; then _host="$_pub_ip"; fi + + # Validate interpolated values to prevent injection in generated script + if ! [[ "$_psk" =~ ^[a-fA-F0-9]+$ ]]; then + log_error "PSK contains invalid characters (expected hex)" + return 1 + fi + if ! [[ "$_host" =~ ^[a-zA-Z0-9._-]+$ ]]; then + log_error "Host contains invalid characters" + return 1 + fi + if ! [[ "$_olport" =~ ^[0-9]+$ ]] || ! [[ "$_lport" =~ ^[0-9]+$ ]]; then + log_error "Port values must be numeric" + return 1 + fi + + # Default output file + if [[ -z "$_out" ]]; then + _out="${CONFIG_DIR}/tunnelforge-connect.sh" + fi + + cat > "$_out" << CLIENTSCRIPT +#!/usr/bin/env bash +# ═══════════════════════════════════════════════════════ +# TunnelForge Client Connect Script +# Generated: $(date '+%Y-%m-%d %H:%M:%S') +# Server: ${_host}:${_olport} +# ═══════════════════════════════════════════════════════ +set -e + +SERVER="${_host}" +PORT="${_olport}" +LOCAL_PORT="${_lport}" +PSK_IDENTITY="tunnelforge" +PSK_SECRET="${_psk}" + +CONF_DIR="\${HOME}/.tunnelforge-client" +CONF_FILE="\${CONF_DIR}/stunnel.conf" +PSK_FILE="\${CONF_DIR}/psk.txt" +PID_FILE="\${CONF_DIR}/stunnel.pid" +LOG_FILE="\${CONF_DIR}/stunnel.log" + +RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m' +BOLD='\033[1m'; DIM='\033[2m'; RESET='\033[0m' + +info() { printf "\${GREEN}[+]\${RESET} %s\n" "\$1"; } +error() { printf "\${RED}[!]\${RESET} %s\n" "\$1"; } +dim() { printf "\${DIM}%s\${RESET}\n" "\$1"; } + +# ── Stop ── +do_stop() { + if [[ -f "\$PID_FILE" ]]; then + local pid="" + pid=\$(cat "\$PID_FILE" 2>/dev/null) || true + if [[ -n "\$pid" ]] && kill -0 "\$pid" 2>/dev/null; then + kill "\$pid" 2>/dev/null || true + info "Disconnected (PID: \$pid)" + fi + rm -f "\$PID_FILE" 2>/dev/null || true + else + error "Not connected" + fi + exit 0 +} + +# ── Status ── +do_status() { + if [[ -f "\$PID_FILE" ]]; then + local pid="" + pid=\$(cat "\$PID_FILE" 2>/dev/null) || true + if [[ -n "\$pid" ]] && kill -0 "\$pid" 2>/dev/null; then + info "Connected (PID: \$pid)" + dim "SOCKS5 proxy: 127.0.0.1:\${LOCAL_PORT}" + exit 0 + fi + fi + error "Not connected" + exit 1 +} + +case "\${1:-}" in + stop) do_stop ;; + status) do_status ;; +esac + +printf "\n\${BOLD}\${CYAN}═══ TunnelForge Client ═══\${RESET}\n" +printf "\${DIM}Connecting to \${SERVER}:\${PORT} via TLS+PSK\${RESET}\n\n" + +# ── Check/install stunnel ── +if ! command -v stunnel >/dev/null 2>&1 && ! command -v stunnel4 >/dev/null 2>&1; then + info "Installing stunnel..." + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq stunnel4 + elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y -q stunnel + elif command -v yum >/dev/null 2>&1; then + sudo yum install -y -q stunnel + elif command -v pacman >/dev/null 2>&1; then + sudo pacman -S --noconfirm stunnel + elif command -v brew >/dev/null 2>&1; then + brew install stunnel + else + error "Cannot install stunnel automatically" + error "Install it manually: https://www.stunnel.org/downloads.html" + exit 1 + fi +fi + +if ! command -v stunnel >/dev/null 2>&1 && ! command -v stunnel4 >/dev/null 2>&1; then + error "stunnel installation failed" + exit 1 +fi +info "stunnel found" + +# ── Already running? ── +if [[ -f "\$PID_FILE" ]]; then + old_pid=\$(cat "\$PID_FILE" 2>/dev/null) || true + if [[ -n "\$old_pid" ]] && kill -0 "\$old_pid" 2>/dev/null; then + info "Already connected (PID: \$old_pid)" + dim "SOCKS5 proxy: 127.0.0.1:\${LOCAL_PORT}" + dim "Run '\$0 stop' to disconnect" + exit 0 + fi + rm -f "\$PID_FILE" 2>/dev/null || true +fi + +# ── Write config ── +mkdir -p "\$CONF_DIR" 2>/dev/null +chmod 700 "\$CONF_DIR" 2>/dev/null || true + +printf '%s:%s\n' "\$PSK_IDENTITY" "\$PSK_SECRET" > "\$PSK_FILE" +chmod 600 "\$PSK_FILE" + +cat > "\$CONF_FILE" << STCONF +; TunnelForge client config +pid = \${PID_FILE} +output = \${LOG_FILE} +foreground = no + +[tunnelforge] +client = yes +accept = 127.0.0.1:\${LOCAL_PORT} +connect = \${SERVER}:\${PORT} +PSKsecrets = \${PSK_FILE} +ciphers = PSK +STCONF + +# ── Connect ── +STUNNEL_BIN="stunnel" +if ! command -v stunnel >/dev/null 2>&1; then STUNNEL_BIN="stunnel4"; fi + +"\$STUNNEL_BIN" "\$CONF_FILE" 2>/dev/null || { + error "Failed to connect (check \$LOG_FILE)" + exit 1 +} + +sleep 1 +if [[ -f "\$PID_FILE" ]]; then + pid=\$(cat "\$PID_FILE" 2>/dev/null) || true + if [[ -n "\$pid" ]] && kill -0 "\$pid" 2>/dev/null; then + printf "\n" + info "Connected! (PID: \$pid)" + printf "\n" + printf " \${BOLD}SOCKS5 proxy:\${RESET} 127.0.0.1:\${LOCAL_PORT}\n" + printf "\n" + dim " Browser setup: Settings → Proxy → Manual" + dim " SOCKS Host: 127.0.0.1 Port: \${LOCAL_PORT}" + dim " Select SOCKS v5, enable Proxy DNS" + printf "\n" + dim " Commands:" + dim " \$0 status — check connection" + dim " \$0 stop — disconnect" + printf "\n" + exit 0 + fi +fi +error "Connection failed (check \$LOG_FILE)" +exit 1 +CLIENTSCRIPT + + chmod +x "$_out" 2>/dev/null || true + log_success "Linux/Mac script: $_out" + printf " ${BOLD}./tunnelforge-connect.sh${RESET} # Connect\n" + printf " ${BOLD}./tunnelforge-connect.sh stop${RESET} # Disconnect\n" + printf " ${BOLD}./tunnelforge-connect.sh status${RESET} # Check status\n\n" + return 0 +} + +# Generate a Windows PowerShell client connect script. +# Args: name prof_ref [output_file] +_obfs_generate_client_script_win() { + local _name="$1" + local -n _ogw_prof="$2" + local _out="${3:-}" + local _olport="${_ogw_prof[OBFS_LOCAL_PORT]:-}" + local _psk="${_ogw_prof[OBFS_PSK]:-}" + local _lport="${_ogw_prof[LOCAL_PORT]:-}" + + if [[ -z "$_olport" ]] || [[ "$_olport" == "0" ]]; then + log_error "No inbound TLS configured on profile '$_name'" + return 1 + fi + if [[ -z "$_psk" ]]; then + log_error "No PSK configured on profile '$_name'" + return 1 + fi + + local _host="" + _host="${_ogw_prof[SSH_HOST]:-localhost}" + local _pub_ip="" + _pub_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | cut -d' ' -f2) || true + if [[ -n "$_pub_ip" ]]; then _host="$_pub_ip"; fi + + # Validate interpolated values to prevent injection in generated script + if ! [[ "$_psk" =~ ^[a-fA-F0-9]+$ ]]; then + log_error "PSK contains invalid characters (expected hex)" + return 1 + fi + if ! [[ "$_host" =~ ^[a-zA-Z0-9._-]+$ ]]; then + log_error "Host contains invalid characters" + return 1 + fi + if ! [[ "$_olport" =~ ^[0-9]+$ ]] || ! [[ "$_lport" =~ ^[0-9]+$ ]]; then + log_error "Port values must be numeric" + return 1 + fi + + if [[ -z "$_out" ]]; then + _out="${CONFIG_DIR}/tunnelforge-connect.ps1" + fi + + cat > "$_out" << 'WINSCRIPT_TOP' +# ═══════════════════════════════════════════════════════ +# TunnelForge Client Connect Script (Windows) +WINSCRIPT_TOP + + cat >> "$_out" << WINSCRIPT_VARS +# Generated: $(date '+%Y-%m-%d %H:%M:%S') +# Server: ${_host}:${_olport} +# ═══════════════════════════════════════════════════════ + +\$Server = "${_host}" +\$Port = "${_olport}" +\$LocalPort = "${_lport}" +\$PskIdentity = "tunnelforge" +\$PskSecret = "${_psk}" +WINSCRIPT_VARS + + cat >> "$_out" << 'WINSCRIPT_BODY' + +$ConfDir = "$env:USERPROFILE\.tunnelforge-client" +$StunnelDir = "$ConfDir\stunnel" +$ConfFile = "$ConfDir\stunnel.conf" +$PskFile = "$ConfDir\psk.txt" +$PidFile = "$ConfDir\stunnel.pid" +$LogFile = "$ConfDir\stunnel.log" +$StunnelExe = "$StunnelDir\stunnel.exe" +$StunnelZip = "$ConfDir\stunnel.zip" +$StunnelUrl = "https://www.stunnel.org/downloads/stunnel-5.72-win64-installer.exe" + +function Write-Info($msg) { Write-Host "[+] $msg" -ForegroundColor Green } +function Write-Err($msg) { Write-Host "[!] $msg" -ForegroundColor Red } +function Write-Dim($msg) { Write-Host " $msg" -ForegroundColor DarkGray } + +# ── Stop ── +if ($args[0] -eq "stop") { + if (Test-Path $PidFile) { + $pid = Get-Content $PidFile -ErrorAction SilentlyContinue + if ($pid) { + try { Stop-Process -Id $pid -Force -ErrorAction Stop; Write-Info "Disconnected (PID: $pid)" } + catch { Write-Err "Process $pid not found" } + } + Remove-Item $PidFile -Force -ErrorAction SilentlyContinue + } else { Write-Err "Not connected" } + exit +} + +# ── Status ── +if ($args[0] -eq "status") { + if (Test-Path $PidFile) { + $pid = Get-Content $PidFile -ErrorAction SilentlyContinue + if ($pid) { + try { + Get-Process -Id $pid -ErrorAction Stop | Out-Null + Write-Info "Connected (PID: $pid)" + Write-Dim "SOCKS5 proxy: 127.0.0.1:$LocalPort" + exit 0 + } catch {} + } + } + Write-Err "Not connected" + exit 1 +} + +Write-Host "" +Write-Host "=== TunnelForge Client ===" -ForegroundColor Cyan +Write-Host "Connecting to ${Server}:${Port} via TLS+PSK" -ForegroundColor DarkGray +Write-Host "" + +# ── Create config dir ── +if (-not (Test-Path $ConfDir)) { New-Item -ItemType Directory -Path $ConfDir -Force | Out-Null } + +# ── Find or install stunnel ── +$stunnel = $null + +# Check common locations +$searchPaths = @( + "$StunnelExe", + "C:\Program Files (x86)\stunnel\bin\stunnel.exe", + "C:\Program Files\stunnel\bin\stunnel.exe", + "$env:ProgramFiles\stunnel\bin\stunnel.exe", + "${env:ProgramFiles(x86)}\stunnel\bin\stunnel.exe" +) +foreach ($p in $searchPaths) { + if (Test-Path $p) { $stunnel = $p; break } +} + +# Check PATH +if (-not $stunnel) { + $inPath = Get-Command stunnel -ErrorAction SilentlyContinue + if ($inPath) { $stunnel = $inPath.Source } +} + +if (-not $stunnel) { + Write-Info "stunnel not found. Please install it:" + Write-Host "" + Write-Host " Option 1: Download from https://www.stunnel.org/downloads.html" -ForegroundColor Yellow + Write-Host " Install the Win64 version" -ForegroundColor DarkGray + Write-Host "" + Write-Host " Option 2: Using winget:" -ForegroundColor Yellow + Write-Host " winget install stunnel" -ForegroundColor White + Write-Host "" + Write-Host " Option 3: Using chocolatey:" -ForegroundColor Yellow + Write-Host " choco install stunnel" -ForegroundColor White + Write-Host "" + Write-Host "After installing, run this script again." -ForegroundColor DarkGray + exit 1 +} + +Write-Info "stunnel found: $stunnel" + +# ── Check if already running ── +if (Test-Path $PidFile) { + $oldPid = Get-Content $PidFile -ErrorAction SilentlyContinue + if ($oldPid) { + try { + Get-Process -Id $oldPid -ErrorAction Stop | Out-Null + Write-Info "Already connected (PID: $oldPid)" + Write-Dim "SOCKS5 proxy: 127.0.0.1:$LocalPort" + Write-Dim "Run '$($MyInvocation.MyCommand.Name) stop' to disconnect" + exit 0 + } catch {} + } + Remove-Item $PidFile -Force -ErrorAction SilentlyContinue +} + +# ── Write config files ── +Set-Content -Path $PskFile -Value "${PskIdentity}:${PskSecret}" -Force +Set-Content -Path $ConfFile -Value @" +; TunnelForge client config +pid = $PidFile +output = $LogFile +foreground = no + +[tunnelforge] +client = yes +accept = 127.0.0.1:$LocalPort +connect = ${Server}:${Port} +PSKsecrets = $PskFile +ciphers = PSK +"@ -Force + +# ── Connect ── +Write-Info "Connecting..." +$proc = Start-Process -FilePath $stunnel -ArgumentList "`"$ConfFile`"" -PassThru -NoNewWindow -ErrorAction SilentlyContinue + +Start-Sleep -Seconds 2 + +# Check if stunnel created a PID file or is running +if (Test-Path $PidFile) { + $newPid = Get-Content $PidFile -ErrorAction SilentlyContinue + if ($newPid) { + try { + Get-Process -Id $newPid -ErrorAction Stop | Out-Null + Write-Host "" + Write-Info "Connected! (PID: $newPid)" + Write-Host "" + Write-Host " SOCKS5 proxy: 127.0.0.1:$LocalPort" -ForegroundColor White + Write-Host "" + Write-Dim "Browser setup: Settings > Proxy > Manual" + Write-Dim " SOCKS Host: 127.0.0.1 Port: $LocalPort" + Write-Dim " Select SOCKS v5, enable Proxy DNS" + Write-Host "" + Write-Dim "Commands:" + Write-Dim " .\$($MyInvocation.MyCommand.Name) status - check connection" + Write-Dim " .\$($MyInvocation.MyCommand.Name) stop - disconnect" + Write-Host "" + exit 0 + } catch {} + } +} + +# Fallback: check if process is running +if ($proc -and -not $proc.HasExited) { + Write-Host "" + Write-Info "Connected! (PID: $($proc.Id))" + Set-Content -Path $PidFile -Value $proc.Id + Write-Host "" + Write-Host " SOCKS5 proxy: 127.0.0.1:$LocalPort" -ForegroundColor White + Write-Host "" + exit 0 +} + +Write-Err "Connection failed (check $LogFile)" +exit 1 +WINSCRIPT_BODY + + log_success "Windows script: $_out" + printf " ${BOLD}powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1${RESET} # Connect\n" + printf " ${BOLD}powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 stop${RESET} # Disconnect\n" + printf " ${BOLD}powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 status${RESET} # Check\n\n" + printf "${DIM}Send both files to users — .sh for Linux/Mac, .ps1 for Windows.${RESET}\n\n" + return 0 +} + +# ── Systemd Service Management ── +# Generates and manages systemd unit files for tunnel profiles + +_service_unit_path() { + printf "%s/tunnelforge-%s.service" "$_SYSTEMD_DIR" "$1" +} + +generate_service() { + local name="$1" + + if [[ $EUID -ne 0 ]]; then + log_error "Service management requires root privileges" + return 1 + fi + if [[ "$INIT_SYSTEM" != "systemd" ]]; then + log_error "Systemd is required (detected: ${INIT_SYSTEM})" + return 1 + fi + + local -A _svc_prof + load_profile "$name" _svc_prof 2>/dev/null || { + log_error "Cannot load profile '${name}'" + return 1 + } + + local tunnel_type="${_svc_prof[TUNNEL_TYPE]:-socks5}" + local ssh_host="${_svc_prof[SSH_HOST]:-}" + local ssh_port="${_svc_prof[SSH_PORT]:-22}" + local ssh_user="${_svc_prof[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}" + + if [[ -z "$ssh_host" ]]; then + log_error "No SSH host in profile '${name}'" + return 1 + fi + + local unit_file + unit_file=$(_service_unit_path "$name") + + # Escape % for systemd specifier safety in all user-derived fields + local safe_name="${name//%/%%}" + local exec_cmd="${INSTALL_DIR}/tunnelforge.sh start ${safe_name}" + + # Build description + local desc="TunnelForge ${tunnel_type} tunnel '${name}' (${ssh_user}@${ssh_host}:${ssh_port})" + desc="${desc//%/%%}" + + { + printf "[Unit]\n" + printf "Description=%s\n" "$desc" + printf "Documentation=man:ssh(1)\n" + printf "After=network-online.target\n" + printf "Wants=network-online.target\n" + printf "StartLimitIntervalSec=60\n" + printf "StartLimitBurst=3\n" + printf "\n" + printf "[Service]\n" + printf "Type=oneshot\n" + printf "RemainAfterExit=yes\n" + printf "ExecStart=%s\n" "$exec_cmd" + printf "ExecStop=%s stop %s\n" "${INSTALL_DIR}/tunnelforge.sh" "$safe_name" + printf "TimeoutStartSec=30\n" + printf "TimeoutStopSec=15\n" + printf "\n" + printf "# Security sandboxing\n" + local _needs_net_admin=false _needs_resolv=false + if [[ "${_svc_prof[KILL_SWITCH]:-}" == "true" ]]; then _needs_net_admin=true; fi + if [[ "${_svc_prof[DNS_LEAK_PROTECTION]:-}" == "true" ]]; then _needs_resolv=true; fi + printf "ProtectSystem=strict\n" + printf "ProtectHome=tmpfs\n" + local _svc_home + _svc_home=$(getent passwd root 2>/dev/null | cut -d: -f6) || true + : "${_svc_home:=/root}" + printf "BindReadOnlyPaths=%s/.ssh\n" "$_svc_home" + printf "ReadWritePaths=%s\n" "$INSTALL_DIR" + if [[ "$_needs_resolv" == true ]]; then + printf "ReadWritePaths=/etc\n" + fi + printf "PrivateTmp=true\n" + # Build capabilities dynamically based on features + local _caps="" + if [[ "$_needs_net_admin" == true ]]; then _caps="CAP_NET_ADMIN CAP_NET_RAW"; fi + if [[ "$_needs_resolv" == true ]]; then + if [[ -n "$_caps" ]]; then _caps="${_caps} CAP_LINUX_IMMUTABLE"; else _caps="CAP_LINUX_IMMUTABLE"; fi + fi + if [[ -n "$_caps" ]]; then + printf "AmbientCapabilities=%s\n" "$_caps" + printf "CapabilityBoundingSet=%s\n" "$_caps" + else + printf "NoNewPrivileges=true\n" + fi + printf "\n" + printf "[Install]\n" + printf "WantedBy=multi-user.target\n" + } > "$unit_file" 2>/dev/null || { + log_error "Failed to write service file: ${unit_file}" + return 1 + } + + chmod 644 "$unit_file" 2>/dev/null || true + systemctl daemon-reload 2>/dev/null || true + + log_success "Service file created: ${unit_file}" + printf "\n${DIM}Manage with:${RESET}\n" + printf " systemctl enable tunnelforge-%s ${DIM}# Start on boot${RESET}\n" "$name" + printf " systemctl start tunnelforge-%s ${DIM}# Start now${RESET}\n" "$name" + printf " systemctl status tunnelforge-%s ${DIM}# Check status${RESET}\n" "$name" + printf " systemctl stop tunnelforge-%s ${DIM}# Stop${RESET}\n" "$name" + printf " systemctl disable tunnelforge-%s ${DIM}# Remove from boot${RESET}\n\n" "$name" + return 0 +} + +enable_service() { + local name="$1" + + if [[ $EUID -ne 0 ]]; then + log_error "Service management requires root privileges" + return 1 + fi + + local unit_file + unit_file=$(_service_unit_path "$name") + + if [[ ! -f "$unit_file" ]]; then + log_info "No service file found, generating..." + generate_service "$name" || return 1 + fi + + systemctl enable "tunnelforge-${name}" 2>/dev/null || { + log_error "Failed to enable service" + return 1 + } + systemctl start "tunnelforge-${name}" 2>/dev/null || { + log_error "Failed to start service" + return 1 + } + log_success "Service tunnelforge-${name} enabled and started" + return 0 +} + +disable_service() { + local name="$1" + + if [[ $EUID -ne 0 ]]; then + log_error "Service management requires root privileges" + return 1 + fi + + systemctl stop "tunnelforge-${name}" 2>/dev/null || true + systemctl disable "tunnelforge-${name}" 2>/dev/null || true + log_success "Service tunnelforge-${name} stopped and disabled" + return 0 +} + +remove_service() { + local name="$1" + + if [[ $EUID -ne 0 ]]; then + log_error "Service management requires root privileges" + return 1 + fi + + disable_service "$name" || true + + local unit_file + unit_file=$(_service_unit_path "$name") + if [[ -f "$unit_file" ]]; then + rm -f "$unit_file" 2>/dev/null || true + systemctl daemon-reload 2>/dev/null || true + log_success "Removed service file: ${unit_file}" + else + log_info "No service file to remove" + fi + return 0 +} + +service_status() { + local name="$1" + + local unit_name="tunnelforge-${name}" + local unit_file + unit_file=$(_service_unit_path "$name") + + printf "\n${BOLD}Service: %s${RESET}\n" "$unit_name" + + if [[ ! -f "$unit_file" ]]; then + printf " ${DIM}■${RESET} No service file\n\n" + return 0 + fi + + local _svc_active _svc_enabled + _svc_active=$(systemctl is-active "$unit_name" 2>/dev/null) || true + _svc_enabled=$(systemctl is-enabled "$unit_name" 2>/dev/null) || true + + if [[ "$_svc_active" == "active" ]]; then + printf " ${GREEN}●${RESET} Status: active (running)\n" + elif [[ "$_svc_active" == "activating" ]]; then + printf " ${YELLOW}▲${RESET} Status: activating\n" + else + printf " ${DIM}■${RESET} Status: %s\n" "${_svc_active:-unknown}" + fi + + if [[ "$_svc_enabled" == "enabled" ]]; then + printf " ${GREEN}●${RESET} Boot: enabled\n" + else + printf " ${DIM}■${RESET} Boot: %s\n" "${_svc_enabled:-disabled}" + fi + + # Show recent log entries + if command -v journalctl &>/dev/null; then + printf "\n${DIM}Recent logs (last 5 lines):${RESET}\n" + journalctl -u "$unit_name" --no-pager -n 5 2>/dev/null || true + fi + printf "\n" + return 0 +} + +# ── Service interactive menu ── + +_menu_service() { + local name="$1" + + while true; do + clear >/dev/tty 2>/dev/null || true + printf "\n${BOLD_CYAN}═══ Service Manager: %s ═══${RESET}\n\n" "$name" >/dev/tty + + printf " ${CYAN}1${RESET}) Generate service file\n" >/dev/tty + printf " ${CYAN}2${RESET}) Enable + start service\n" >/dev/tty + printf " ${CYAN}3${RESET}) Disable + stop service\n" >/dev/tty + printf " ${CYAN}4${RESET}) Show service status\n" >/dev/tty + printf " ${CYAN}5${RESET}) Remove service file\n" >/dev/tty + printf " ${YELLOW}q${RESET}) Back\n\n" >/dev/tty + + local _sv_choice + printf " ${BOLD}Select${RESET}: " >/dev/tty + read -rsn1 _sv_choice /dev/tty + + case "$_sv_choice" in + 1) generate_service "$name" || true; _press_any_key ;; + 2) enable_service "$name" || true; _press_any_key ;; + 3) disable_service "$name" || true; _press_any_key ;; + 4) service_status "$name" || true; _press_any_key ;; + 5) + if confirm_action "Remove service for '${name}'?"; then + remove_service "$name" || true + fi + _press_any_key ;; + q|Q) return 0 ;; + *) true ;; + esac + done +} + +# ── Backup & Restore ── + +backup_tunnelforge() { + local timestamp + timestamp=$(date '+%Y%m%d_%H%M%S') + local backup_name="tunnelforge_backup_${timestamp}.tar.gz" + local backup_path="${BACKUP_DIR}/${backup_name}" + + mkdir -p "$BACKUP_DIR" 2>/dev/null || true + + log_info "Creating backup: ${backup_name}..." + + # Build list of paths to include + local -a _bk_paths=() + if [[ -d "$CONFIG_DIR" ]]; then _bk_paths+=("$CONFIG_DIR"); fi + if [[ -d "$PROFILES_DIR" ]]; then _bk_paths+=("$PROFILES_DIR"); fi + if [[ -d "$DATA_DIR" ]]; then _bk_paths+=("$DATA_DIR"); fi + + # Include security-related backups (sshd_config, iptables rules) + if [[ -d "$BACKUP_DIR" ]]; then + _bk_paths+=("$BACKUP_DIR") + fi + + if [[ ${#_bk_paths[@]} -eq 0 ]]; then + log_error "Nothing to backup" + return 1 + fi + + if tar czf "$backup_path" --exclude='*.tar.gz' "${_bk_paths[@]}" 2>/dev/null; then + chmod 600 "$backup_path" 2>/dev/null || true + local _bk_size + _bk_size=$(stat -c %s "$backup_path" 2>/dev/null || stat -f %z "$backup_path" 2>/dev/null) || true + _bk_size=$(format_bytes "${_bk_size:-0}") + log_success "Backup created: ${backup_path} (${_bk_size})" + + # Rotate old backups (keep last 5) + local _bk_count + _bk_count=$(find "$BACKUP_DIR" -name "tunnelforge_backup_*.tar.gz" -type f 2>/dev/null | wc -l) || true + : "${_bk_count:=0}" + if (( _bk_count > 5 )); then + find "$BACKUP_DIR" -name "tunnelforge_backup_*.tar.gz" -type f 2>/dev/null | \ + sort | head -n $(( _bk_count - 5 )) | while IFS= read -r _old_bk; do + rm -f "$_old_bk" 2>/dev/null + done || true + log_debug "Rotated old backups" + fi + else + rm -f "$backup_path" 2>/dev/null || true + log_error "Failed to create backup" + return 1 + fi + + return 0 +} + +restore_tunnelforge() { + if [[ $EUID -ne 0 ]]; then + log_error "Restore requires root privileges" + return 1 + fi + + local backup_file="${1:-}" + + # If no file specified, find the latest + if [[ -z "$backup_file" ]]; then + backup_file=$(find "$BACKUP_DIR" -name "tunnelforge_backup_*.tar.gz" -type f 2>/dev/null | \ + sort -r | head -1) || true + if [[ -z "$backup_file" ]]; then + log_error "No backup files found in ${BACKUP_DIR}" + return 1 + fi + log_info "Found backup: $(basename "$backup_file")" + fi + + if [[ ! -f "$backup_file" ]]; then + log_error "Backup file not found: ${backup_file}" + return 1 + fi + + printf "\n${BOLD}Backup contents:${RESET}\n" + if ! tar tzf "$backup_file" >/dev/null 2>&1; then + log_error "Cannot read backup archive" + return 1 + fi + tar tzf "$backup_file" 2>/dev/null | head -20 || true + printf "${DIM} ... (truncated)${RESET}\n\n" + + if ! confirm_action "Restore from this backup? (current config will be overwritten)"; then + log_info "Restore cancelled" + return 0 + fi + + log_info "Restoring from backup..." + + # Pre-scan archive for path traversal and symlink attacks + local _bad_paths _symlinks _tar_listing + _tar_listing=$(tar tzf "$backup_file" 2>/dev/null || true) + _bad_paths=$(printf '%s\n' "$_tar_listing" | grep -E '(^|/)\.\.(/|$)|^/' || true) + if [[ -n "$_bad_paths" ]]; then + log_error "Backup archive contains unsafe paths (potential path traversal)" + log_error "Suspicious entries: $(printf '%s' "$_bad_paths" | head -5)" + return 1 + fi + # Check for symlinks in the archive (tar tvf shows 'l' type) + _symlinks=$(tar tvzf "$backup_file" 2>/dev/null | grep -E '^l' || true) + if [[ -n "$_symlinks" ]]; then + log_error "Backup archive contains symlinks (potential symlink attack)" + log_error "Suspicious entries: $(printf '%s' "$_symlinks" | head -5)" + return 1 + fi + + # Feature-detect --no-unsafe-links support + local -a _tar_safe_opts=() + if tar --help 2>&1 | grep -q -- '--no-unsafe-links' 2>/dev/null; then + _tar_safe_opts=(--no-unsafe-links) + fi + if tar xzf "$backup_file" -C / --no-same-owner --no-same-permissions "${_tar_safe_opts[@]}" 2>/dev/null; then + log_success "Backup restored successfully" + log_info "Reapplying directory permissions..." + init_directories 2>/dev/null || true + # Secure config and profile files + find "${CONFIG_DIR}" -type f -exec chmod 600 {} \; 2>/dev/null || true + find "${PROFILES_DIR}" -type f -exec chmod 600 {} \; 2>/dev/null || true + log_info "Reloading settings..." + load_settings || true + else + log_error "Failed to restore backup" + return 1 + fi + + return 0 +} + +# ── Backup interactive menu ── + +_menu_backup_restore() { + while true; do + clear >/dev/tty 2>/dev/null || true + printf "\n${BOLD_CYAN}═══ Backup & Restore ═══${RESET}\n\n" >/dev/tty + + printf " ${CYAN}1${RESET}) Create backup now\n" >/dev/tty + printf " ${CYAN}2${RESET}) Restore from latest backup\n" >/dev/tty + printf " ${CYAN}3${RESET}) List available backups\n" >/dev/tty + printf " ${YELLOW}q${RESET}) Back\n\n" >/dev/tty + + local _br_choice + printf " ${BOLD}Select${RESET}: " >/dev/tty + read -rsn1 _br_choice /dev/tty + + case "$_br_choice" in + 1) backup_tunnelforge || true; _press_any_key ;; + 2) restore_tunnelforge || true; _press_any_key ;; + 3) + printf "\n${BOLD}Available Backups:${RESET}\n" + local _br_found=false + local _br_f + while IFS= read -r _br_f; do + [[ -z "$_br_f" ]] && continue + _br_found=true + local _br_sz + _br_sz=$(stat -c %s "$_br_f" 2>/dev/null || stat -f %z "$_br_f" 2>/dev/null) || true + _br_sz=$(format_bytes "${_br_sz:-0}") + printf " ${CYAN}●${RESET} %s ${DIM}(%s)${RESET}\n" "$(basename "$_br_f")" "$_br_sz" + done < <(find "$BACKUP_DIR" -name "tunnelforge_backup_*.tar.gz" -type f 2>/dev/null | sort -r || true) + if [[ "$_br_found" != true ]]; then + printf " ${DIM}No backups found${RESET}\n" + fi + printf "\n" + _press_any_key ;; + q|Q) return 0 ;; + *) true ;; + esac + done +} + +# ── Uninstall ── + +uninstall_tunnelforge() { + if [[ $EUID -ne 0 ]]; then + log_error "Uninstall requires root privileges" + return 1 + fi + + printf "\n${BOLD_RED}═══ TunnelForge Uninstall ═══${RESET}\n\n" + printf "This will remove:\n" + printf " - All tunnel profiles and configuration\n" + printf " - Systemd service files\n" + printf " - Installation directory (%s)\n" "$INSTALL_DIR" + printf " - CLI symlink (%s)\n" "$BIN_LINK" + printf "\n${YELLOW}This will NOT remove:${RESET}\n" + printf " - Your SSH keys (~/.ssh/)\n" + printf " - Final backup (saved to ~/)\n\n" + + if ! confirm_action "Are you absolutely sure you want to uninstall?"; then + log_info "Uninstall cancelled" + return 0 + fi + + # Offer backup BEFORE any destructive operations + if confirm_action "Create a backup before uninstalling?"; then + backup_tunnelforge || true + local _ui_bk + _ui_bk=$(find "$BACKUP_DIR" -name "tunnelforge_backup_*.tar.gz" -type f 2>/dev/null | sort -r | head -1) || true + if [[ -n "$_ui_bk" ]]; then + local _ui_home_bk="${HOME}/tunnelforge_final_backup.tar.gz" + if cp "$_ui_bk" "$_ui_home_bk" 2>/dev/null; then + log_info "Backup saved to: ${_ui_home_bk}" + else + log_error "Failed to copy backup to ${_ui_home_bk}" + if ! confirm_action "Continue uninstall WITHOUT backup?"; then + return 0 + fi + fi + fi + fi + + # Stop all running tunnels + log_info "Stopping all tunnels..." + stop_all_tunnels 2>/dev/null || true + + # Remove systemd services + log_info "Removing systemd services..." + local _ui_svc + while IFS= read -r _ui_svc; do + [[ -z "$_ui_svc" ]] && continue + local _ui_name + _ui_name=$(basename "$_ui_svc" .service) + _ui_name="${_ui_name#tunnelforge-}" + systemctl stop "tunnelforge-${_ui_name}" 2>/dev/null || true + systemctl disable "tunnelforge-${_ui_name}" 2>/dev/null || true + rm -f "$_ui_svc" 2>/dev/null || true + done < <(find "$_SYSTEMD_DIR" -name "tunnelforge-*.service" -type f 2>/dev/null || true) + systemctl daemon-reload 2>/dev/null || true + + # Disable security features if active + if is_dns_leak_protected; then + log_info "Disabling DNS leak protection..." + disable_dns_leak_protection 2>/dev/null || true + fi + if is_kill_switch_active; then + log_info "Disabling kill switch..." + local _fw_cmd + for _fw_cmd in iptables ip6tables; do + if command -v "$_fw_cmd" &>/dev/null; then + "$_fw_cmd" -D OUTPUT -j "$_TF_CHAIN" 2>/dev/null || true + "$_fw_cmd" -D FORWARD -j "$_TF_CHAIN" 2>/dev/null || true + "$_fw_cmd" -F "$_TF_CHAIN" 2>/dev/null || true + "$_fw_cmd" -X "$_TF_CHAIN" 2>/dev/null || true + fi + done + fi + + # Remove symlink + rm -f "$BIN_LINK" 2>/dev/null || true + log_success "Removed CLI symlink" + + # Remove sysctl config + rm -f /etc/sysctl.d/99-tunnelforge.conf 2>/dev/null || true + sysctl --system >/dev/null 2>&1 || true + + # Remove fail2ban jail and reload + rm -f /etc/fail2ban/jail.d/tunnelforge-sshd.conf 2>/dev/null || true + systemctl reload fail2ban 2>/dev/null || systemctl restart fail2ban 2>/dev/null || true + + # Restore original sshd_config if we have a backup + if [[ -f "$_SSHD_BACKUP" ]]; then + log_info "Restoring original sshd_config..." + if cp "$_SSHD_BACKUP" "$_SSHD_CONFIG" 2>/dev/null; then + systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null || true + log_success "Restored original sshd_config" + else + log_warn "Could not restore sshd_config" + fi + fi + + # Remove data dirs first, install dir last (contains running script) + rm -rf "${PID_DIR}" 2>/dev/null || true + rm -rf "${LOG_DIR}" 2>/dev/null || true + rm -rf "${DATA_DIR}" 2>/dev/null || true + rm -rf "${SSH_CONTROL_DIR}" 2>/dev/null || true + rm -rf "${PROFILES_DIR}" 2>/dev/null || true + rm -rf "${CONFIG_DIR}" 2>/dev/null || true + + # Print farewell BEFORE deleting the script itself + printf "\n${BOLD_GREEN}TunnelForge has been uninstalled.${RESET}\n" >/dev/tty 2>/dev/null || true + printf "${DIM}Thank you for using TunnelForge!${RESET}\n\n" >/dev/tty 2>/dev/null || true + + rm -rf "${INSTALL_DIR}" 2>/dev/null || true + return 0 +} + +# ── Self-Update ────────────────────────────────────────────────────────────── + +update_tunnelforge() { + if [[ $EUID -ne 0 ]]; then + log_error "Update requires root privileges" + return 1 + fi + + printf "\n${BOLD_CYAN}═══ TunnelForge Update ═══${RESET}\n\n" >/dev/tty + + local _script_path="${INSTALL_DIR}/tunnelforge.sh" + if [[ ! -f "$_script_path" ]]; then + log_error "TunnelForge not installed at ${_script_path}" + return 1 + fi + + # Download latest script to temp file + printf " Checking for updates..." >/dev/tty + local _tmp_file="" + _tmp_file=$(mktemp /tmp/tunnelforge-update.XXXXXX) || { log_error "Failed to create temp file"; return 1; } + + if ! curl -sf --connect-timeout 10 --max-time 60 \ + "${GITEA_RAW}/tunnelforge.sh" \ + -o "$_tmp_file" 2>/dev/null; then + printf "\r \r" >/dev/tty + log_error "Could not reach update server (check your internet connection)" + rm -f "$_tmp_file" 2>/dev/null || true + return 1 + fi + printf "\r \r" >/dev/tty + + # Compare SHA256 of installed vs remote + local _local_sha="" _remote_sha="" + _local_sha=$(sha256sum "$_script_path" 2>/dev/null | cut -d' ' -f1) || true + _remote_sha=$(sha256sum "$_tmp_file" 2>/dev/null | cut -d' ' -f1) || true + + if [[ -z "$_remote_sha" ]] || [[ -z "$_local_sha" ]]; then + log_error "Failed to compute file checksums" + rm -f "$_tmp_file" 2>/dev/null || true + return 1 + fi + + if [[ "$_local_sha" == "$_remote_sha" ]]; then + printf " ${GREEN}Already up to date${RESET} ${DIM}(v%s)${RESET}\n\n" "$VERSION" >/dev/tty + rm -f "$_tmp_file" 2>/dev/null || true + return 0 + fi + + # Update available — show info and ask + local _remote_ver="" + _remote_ver=$(grep -oE 'readonly VERSION="[^"]+"' "$_tmp_file" 2>/dev/null \ + | head -1 | grep -oE '"[^"]+"' | tr -d '"') || true + + printf " ${YELLOW}Update available${RESET}\n" >/dev/tty + if [[ -n "$_remote_ver" ]] && [[ "$_remote_ver" != "$VERSION" ]]; then + printf " Installed : ${DIM}v%s${RESET}\n" "$VERSION" >/dev/tty + printf " Latest : ${BOLD}v%s${RESET}\n\n" "$_remote_ver" >/dev/tty + else + printf " ${DIM}New changes available (v%s)${RESET}\n\n" "$VERSION" >/dev/tty + fi + + if ! confirm_action "Install update?"; then + printf "\n ${DIM}Update skipped.${RESET}\n\n" >/dev/tty + rm -f "$_tmp_file" 2>/dev/null || true + return 0 + fi + + # Validate downloaded script + if ! bash -n "$_tmp_file" 2>/dev/null; then + log_error "Downloaded file failed syntax check — aborting" + rm -f "$_tmp_file" 2>/dev/null || true + return 1 + fi + + # Backup current script + if [[ -f "$_script_path" ]]; then + cp "$_script_path" "${_script_path}.bak" 2>/dev/null || true + fi + + # Replace script + mv "$_tmp_file" "$_script_path" || { log_error "Failed to install update"; rm -f "$_tmp_file" 2>/dev/null || true; return 1; } + chmod +x "$_script_path" 2>/dev/null || true + + if [[ -n "$_remote_ver" ]] && [[ "$_remote_ver" != "$VERSION" ]]; then + printf "\n ${BOLD_GREEN}Updated successfully${RESET} ${DIM}(v%s → v%s)${RESET}\n" \ + "$VERSION" "$_remote_ver" >/dev/tty + else + printf "\n ${BOLD_GREEN}Updated successfully${RESET}\n" >/dev/tty + fi + printf " ${DIM}Running tunnels are not affected.${RESET}\n" >/dev/tty + printf " ${DIM}Previous version backed up to %s.bak${RESET}\n\n" "$_script_path" >/dev/tty + return 0 +} + +# ============================================================================ +# TELEGRAM NOTIFICATIONS (Phase 6) +# ============================================================================ + +readonly _TG_API="https://api.telegram.org" + +_telegram_enabled() { + [[ "$(config_get TELEGRAM_ENABLED false)" == "true" ]] || return 1 + [[ -n "$(config_get TELEGRAM_BOT_TOKEN)" ]] || return 1 + [[ -n "$(config_get TELEGRAM_CHAT_ID)" ]] || return 1 + return 0 +} + +# Find a running SOCKS5 proxy port for Telegram API calls +# (Telegram may be blocked on the local network) +_tg_find_proxy() { + local _pn _pt _pp + for _pn in $(list_profiles 2>/dev/null); do + if is_tunnel_running "$_pn" 2>/dev/null; then + _pt=$(get_profile_field "$_pn" "TUNNEL_TYPE" 2>/dev/null) || true + if [[ "$_pt" == "socks5" ]]; then + _pp=$(get_profile_field "$_pn" "LOCAL_PORT" 2>/dev/null) || true + if [[ -n "$_pp" ]]; then + printf '%s' "$_pp" + return 0 + fi + fi + fi + done + return 1 +} + +# Build curl proxy args if a SOCKS5 tunnel is available +# Sets _TG_PROXY_ARGS array for caller +_tg_proxy_args() { + _TG_PROXY_ARGS=() + local _proxy_port + _proxy_port=$(_tg_find_proxy 2>/dev/null) || true + if [[ -n "$_proxy_port" ]]; then + _TG_PROXY_ARGS=(--socks5-hostname "127.0.0.1:${_proxy_port}") + fi + return 0 +} + +# Send a message via Telegram Bot API +# Usage: _telegram_send "message text" [parse_mode] +_telegram_send() { + local message="$1" + local parse_mode="${2:-}" + local token chat_id + + token=$(config_get TELEGRAM_BOT_TOKEN "") + chat_id=$(config_get TELEGRAM_CHAT_ID "") + + [[ -n "$token" ]] && [[ -n "$chat_id" ]] || return 1 + + local _tg_url="${_TG_API}/bot${token}/sendMessage" + log_debug "Telegram send to chat ${chat_id}" + + local -a _TG_PROXY_ARGS=() + _tg_proxy_args || true + + local -a curl_args=( + -s --max-time 15 + "${_TG_PROXY_ARGS[@]}" + -X POST + --data-urlencode "chat_id=${chat_id}" + --data-urlencode "text=${message}" + --data-urlencode "disable_web_page_preview=true" + ) + if [[ -n "$parse_mode" ]]; then + curl_args+=(--data-urlencode "parse_mode=${parse_mode}") + fi + + # Write URL to temp file to hide bot token from process listing and /proc/fd + local _tg_cfg + _tg_cfg=$(mktemp "${TMP_DIR}/tg_cfg.XXXXXX") || return 1 + printf 'url = "%s"\n' "$_tg_url" > "$_tg_cfg" 2>/dev/null || return 1 + chmod 600 "$_tg_cfg" 2>/dev/null || true + local _tg_rc=0 + curl --config "$_tg_cfg" "${curl_args[@]}" >/dev/null 2>&1 || _tg_rc=$? + rm -f "$_tg_cfg" 2>/dev/null || true + return "$_tg_rc" +} + +# Send a notification (checks if enabled + alerts flag) +_telegram_notify() { + local message="$1" + if _telegram_enabled && [[ "$(config_get TELEGRAM_ALERTS true)" == "true" ]]; then + _telegram_send "$message" & + _TG_BG_PIDS+=($!) + fi + # Reap any completed background sends + local -a _tg_alive=() + local _tg_p + for _tg_p in "${_TG_BG_PIDS[@]}"; do + if kill -0 "$_tg_p" 2>/dev/null; then + _tg_alive+=("$_tg_p") + else + wait "$_tg_p" 2>/dev/null || true + fi + done + _TG_BG_PIDS=("${_tg_alive[@]}") + return 0 +} + +# Test Telegram connectivity +telegram_test() { + if ! _telegram_enabled; then + log_error "Telegram not configured (set bot token and chat ID first)" + return 1 + fi + + local hostname _t_ip _t_running=0 _t_stopped=0 _t_total=0 + hostname=$(hostname 2>/dev/null || echo "unknown") + _t_ip=$(hostname -I 2>/dev/null | awk '{print $1}') || true + : "${_t_ip:=unknown}" + + # Count tunnel status + local _t_name + while IFS= read -r _t_name; do + [[ -z "$_t_name" ]] && continue + (( ++_t_total )) + if is_tunnel_running "$_t_name" 2>/dev/null; then + (( ++_t_running )) + else + (( ++_t_stopped )) + fi + done < <(list_profiles 2>/dev/null) + + local _t_alerts _t_reports + _t_alerts=$(config_get TELEGRAM_ALERTS true) + _t_reports=$(config_get TELEGRAM_PERIODIC_STATUS false) + + # Build tunnel list + local _t_list="" + local _tl_name + while IFS= read -r _tl_name; do + [[ -z "$_tl_name" ]] && continue + if is_tunnel_running "$_tl_name" 2>/dev/null; then + _t_list="${_t_list} ✅ ${_tl_name} [ALIVE] +" + else + _t_list="${_t_list} ⛔ ${_tl_name} [STOPPED] +" + fi + done < <(list_profiles 2>/dev/null) + [[ -z "$_t_list" ]] && _t_list=" (none configured) +" + + local test_msg + test_msg="$(printf '✅ TunnelForge Connected + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🖥 Server Info +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Host: %s +IP: %s +Version: %s +Time: %s + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 Tunnels (%d running / %d stopped) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +%s +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔔 Alerts +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Alerts: %s +Status Reports: %s + +You will be notified on: + tunnel start/stop/fail/reconnect + periodic status reports + security audit alerts + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🤖 Bot Commands +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +/tf_help - Show this help +/tf_status - Tunnel status +/tf_list - List all tunnels +/tf_ip - Show server IP +/tf_config - Get client config (PSK) +/tf_uptime - Server uptime +/tf_report - Full status report + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⌨️ Server CLI +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +tunnelforge start/stop/restart +tunnelforge list +tunnelforge dashboard +tunnelforge menu +tunnelforge telegram share +tunnelforge telegram report' \ + "$hostname" "$_t_ip" "${VERSION}" "$(date '+%Y-%m-%d %H:%M:%S')" \ + "$_t_running" "$_t_stopped" "$_t_list" "$_t_alerts" "$_t_reports")" + + log_info "Sending test message..." + local _tt_token + _tt_token=$(config_get TELEGRAM_BOT_TOKEN "") + local _tt_url="${_TG_API}/bot${_tt_token}/sendMessage" + + local -a _TG_PROXY_ARGS=() + _tg_proxy_args || true + + # Write URL to temp file to hide bot token from /proc (matches _telegram_send pattern) + local _tt_cfg + _tt_cfg=$(mktemp "${TMP_DIR}/tg_test.XXXXXX") || { log_error "Cannot create temp file"; return 1; } + printf 'url = "%s"\n' "$_tt_url" > "$_tt_cfg" 2>/dev/null || { rm -f "$_tt_cfg" 2>/dev/null; return 1; } + chmod 600 "$_tt_cfg" 2>/dev/null || true + local response + response=$(curl --config "$_tt_cfg" \ + -s --max-time 15 "${_TG_PROXY_ARGS[@]}" -X POST \ + --data-urlencode "chat_id=$(config_get TELEGRAM_CHAT_ID)" \ + --data-urlencode "text=${test_msg}" 2>/dev/null) || true + rm -f "$_tt_cfg" 2>/dev/null + + if printf '%s' "$response" | grep -qF '"ok":true' 2>/dev/null; then + log_success "Telegram test message sent successfully" + return 0 + else + local err_desc + err_desc=$(printf '%s' "$response" | grep -oE '"description":"[^"]*"' 2>/dev/null | head -1) || true + log_error "Telegram test failed: ${err_desc:-no response}" + return 1 + fi +} + +# Show Telegram status +telegram_status() { + printf "\n${BOLD}Telegram Notification Status${RESET}\n\n" + printf " Enabled : ${BOLD}%s${RESET}\n" "$(config_get TELEGRAM_ENABLED false)" + printf " Bot Token : ${BOLD}%s${RESET}\n" \ + "$(if [[ -n "$(config_get TELEGRAM_BOT_TOKEN)" ]]; then echo '••••••••(set)'; else echo '(not set)'; fi)" + printf " Chat ID : ${BOLD}%s${RESET}\n" \ + "$(local _cid; _cid=$(config_get TELEGRAM_CHAT_ID); if [[ -n "$_cid" ]]; then echo "****${_cid: -4}"; else echo '(not set)'; fi)" + printf " Alerts : ${BOLD}%s${RESET}\n" "$(config_get TELEGRAM_ALERTS true)" + printf " Status Reports: ${BOLD}%s${RESET}\n" "$(config_get TELEGRAM_PERIODIC_STATUS false)" + printf " Report Interval: ${BOLD}%s${RESET}s\n" "$(config_get TELEGRAM_STATUS_INTERVAL 3600)" + printf "\n" + return 0 +} + +# Telegram update offset file — prevents reprocessing same messages +_tg_offset_file() { printf '%s' "${CONFIG_DIR}/tg_offset"; } + +_tg_get_offset() { + local _f + _f=$(_tg_offset_file) + if [[ -f "$_f" ]]; then + local _val + _val=$(cat "$_f" 2>/dev/null) || true + if [[ "$_val" =~ ^[0-9]+$ ]]; then + printf '%s' "$_val"; return 0 + fi + fi + printf '0' + return 0 +} + +_tg_set_offset() { + local _f + _f=$(_tg_offset_file) + printf '%s' "$1" > "$_f" 2>/dev/null || true +} + +# Auto-detect chat ID from recent messages to the bot +# Uses offset tracking to avoid reprocessing old updates +_telegram_get_chat_id() { + local token="$1" + [[ -z "$token" ]] && return 1 + + local -a _TG_PROXY_ARGS=() + _tg_proxy_args || true + + local _offset + _offset=$(_tg_get_offset) || true + + local _curl_cfg _url_str + _curl_cfg=$(mktemp "${TMP_DIR}/tg_cid.XXXXXX") || return 1 + chmod 600 "$_curl_cfg" 2>/dev/null || true + if [[ "$_offset" -gt 0 ]] 2>/dev/null; then + _url_str=$(printf '%s/bot%s/getUpdates?offset=%s' "$_TG_API" "$token" "$_offset") + else + _url_str=$(printf '%s/bot%s/getUpdates' "$_TG_API" "$token") + fi + printf 'url = "%s"\n' "$_url_str" > "$_curl_cfg" + local response + response=$(curl -s --max-time 15 "${_TG_PROXY_ARGS[@]}" --max-filesize 1048576 -K "$_curl_cfg" 2>/dev/null) || true + rm -f "$_curl_cfg" 2>/dev/null || true + [[ -z "$response" ]] && return 1 + printf '%s' "$response" | grep -qF '"ok":true' || return 1 + + local chat_id="" max_update_id="" + if command -v python3 &>/dev/null; then + local _py_out + _py_out=$(python3 -c " +import json,sys +try: + d=json.loads(sys.stdin.read()) + results=d.get('result',[]) + max_uid=0 + cid='' + for u in results: + uid=u.get('update_id',0) + if uid>max_uid: max_uid=uid + for u in reversed(results): + if 'message' in u: + cid=str(u['message']['chat']['id']); break + elif 'my_chat_member' in u: + cid=str(u['my_chat_member']['chat']['id']); break + print(cid+'|'+str(max_uid)) +except: print('|0') +" <<< "$response" 2>/dev/null) || true + chat_id="${_py_out%%|*}" + max_update_id="${_py_out##*|}" + fi + # Fallback: grep extraction + if [[ -z "$chat_id" ]]; then + chat_id=$(printf '%s' "$response" | grep -oE '"chat"[[:space:]]*:[[:space:]]*\{[[:space:]]*"id"[[:space:]]*:[[:space:]]*-?[0-9]+' \ + | grep -oE -- '-?[0-9]+$' | tail -1 2>/dev/null) || true + # Extract max update_id via grep + if [[ -z "$max_update_id" ]] || [[ "$max_update_id" == "0" ]]; then + max_update_id=$(printf '%s' "$response" | grep -oE '"update_id"[[:space:]]*:[[:space:]]*[0-9]+' \ + | grep -oE '[0-9]+$' | sort -n | tail -1 2>/dev/null) || true + fi + fi + + # Confirm processed updates by advancing offset + if [[ -n "$max_update_id" ]] && [[ "$max_update_id" =~ ^[0-9]+$ ]] && (( max_update_id > 0 )); then + _tg_set_offset "$(( max_update_id + 1 ))" + fi + + if [[ -n "$chat_id" ]] && [[ "$chat_id" =~ ^-?[0-9]+$ ]]; then + _TG_DETECTED_CHAT_ID="$chat_id" + return 0 + fi + return 1 +} + +# Telegram interactive setup wizard +telegram_setup() { + local _saved_token _saved_chatid _saved_enabled + _saved_token=$(config_get TELEGRAM_BOT_TOKEN "") + _saved_chatid=$(config_get TELEGRAM_CHAT_ID "") + _saved_enabled=$(config_get TELEGRAM_ENABLED "false") + + # Restore on Ctrl+C + trap 'config_set TELEGRAM_BOT_TOKEN "$_saved_token"; config_set TELEGRAM_CHAT_ID "$_saved_chatid"; config_set TELEGRAM_ENABLED "$_saved_enabled"; trap - INT; printf "\n" >/dev/tty; return 0' INT + + clear >/dev/tty 2>/dev/null || true + printf "${BOLD_CYAN}══════════════════════════════════════════════════════════════${RESET}\n" >/dev/tty + printf " ${BOLD}TELEGRAM NOTIFICATIONS SETUP${RESET}\n" >/dev/tty + printf "${BOLD_CYAN}══════════════════════════════════════════════════════════════${RESET}\n\n" >/dev/tty + + # ── Step 1: Bot Token ── + printf " ${BOLD}Step 1: Create a Telegram Bot${RESET}\n" >/dev/tty + printf " ${CYAN}─────────────────────────────${RESET}\n" >/dev/tty + printf " 1. Open Telegram and search for ${BOLD}@BotFather${RESET}\n" >/dev/tty + printf " 2. Send ${YELLOW}/newbot${RESET}\n" >/dev/tty + printf " 3. Choose a name (e.g. \"TunnelForge Monitor\")\n" >/dev/tty + printf " 4. Choose a username (e.g. \"my_tunnel_bot\")\n" >/dev/tty + printf " 5. BotFather will give you a token like:\n" >/dev/tty + printf " ${YELLOW}123456789:ABCdefGHIjklMNOpqrsTUVwxyz${RESET}\n\n" >/dev/tty + + local _tg_token="" + read -rp " Enter your bot token: " _tg_token /dev/tty || { trap - INT; return 0; } + _tg_token="${_tg_token## }"; _tg_token="${_tg_token%% }" + printf "\n" >/dev/tty + + if [[ -z "$_tg_token" ]]; then + printf " ${RED}No token entered. Setup cancelled.${RESET}\n" >/dev/tty + _press_any_key || true; trap - INT; return 0 + fi + + # Validate token format + if [[ ! "$_tg_token" =~ ^[0-9]+:[A-Za-z0-9_-]+$ ]]; then + printf " ${RED}Invalid token format. Should be like: 123456789:ABCdefGHI...${RESET}\n" >/dev/tty + _press_any_key || true; trap - INT; return 0 + fi + + # Verify token with Telegram API (route through SOCKS5 if available) + local -a _TG_PROXY_ARGS=() + _tg_proxy_args || true + + printf " Verifying bot token... " >/dev/tty + local _me_cfg _me_resp + _me_cfg=$(mktemp "${TMP_DIR}/tg_me.XXXXXX") || true + if [[ -n "$_me_cfg" ]]; then + chmod 600 "$_me_cfg" 2>/dev/null || true + printf 'url = "%s/bot%s/getMe"\n' "$_TG_API" "$_tg_token" > "$_me_cfg" + _me_resp=$(curl -s --max-time 15 "${_TG_PROXY_ARGS[@]}" -K "$_me_cfg" 2>/dev/null) || true + rm -f "$_me_cfg" 2>/dev/null || true + if printf '%s' "$_me_resp" | grep -qF '"ok":true' 2>/dev/null; then + printf "${GREEN}Valid${RESET}\n" >/dev/tty + else + printf "${RED}Invalid token${RESET}\n" >/dev/tty + printf " ${RED}The Telegram API rejected this token. Check it and try again.${RESET}\n" >/dev/tty + _press_any_key || true; trap - INT; return 0 + fi + fi + + # ── Step 2: Chat ID (auto-detect) ── + printf "\n ${BOLD}Step 2: Get Your Chat ID${RESET}\n" >/dev/tty + printf " ${CYAN}────────────────────────${RESET}\n" >/dev/tty + printf " 1. Open your new bot in Telegram\n" >/dev/tty + printf " 2. Send it the message: ${YELLOW}/start${RESET}\n\n" >/dev/tty + printf " ${YELLOW}Important:${RESET} You MUST send ${BOLD}/start${RESET} to the bot first!\n\n" >/dev/tty + + read -rp " Press Enter after sending /start to your bot... " /dev/tty || { trap - INT; return 0; } + + printf "\n Detecting chat ID... " >/dev/tty + local _TG_DETECTED_CHAT_ID="" _attempts=0 + while (( _attempts < 3 )) && [[ -z "$_TG_DETECTED_CHAT_ID" ]]; do + _telegram_get_chat_id "$_tg_token" || true + if [[ -n "$_TG_DETECTED_CHAT_ID" ]]; then break; fi + (( ++_attempts )) + sleep 2 + done + + local _tg_chat_id="" + if [[ -n "$_TG_DETECTED_CHAT_ID" ]]; then + _tg_chat_id="$_TG_DETECTED_CHAT_ID" + printf "${GREEN}Found: ${_tg_chat_id}${RESET}\n" >/dev/tty + else + printf "${RED}Could not auto-detect${RESET}\n\n" >/dev/tty + printf " ${BOLD}You can enter it manually:${RESET}\n" >/dev/tty + printf " ${CYAN}────────────────────────────${RESET}\n" >/dev/tty + printf " Option 1: Press Enter to retry detection\n" >/dev/tty + printf " Option 2: Find your chat ID via ${BOLD}@userinfobot${RESET} on Telegram\n\n" >/dev/tty + + local _manual_chatid="" + read -rp " Enter chat ID (or Enter to retry): " _manual_chatid /dev/tty || true + + if [[ -z "$_manual_chatid" ]]; then + # Retry + printf "\n Retrying detection... " >/dev/tty + _attempts=0 + while (( _attempts < 5 )) && [[ -z "$_TG_DETECTED_CHAT_ID" ]]; do + _telegram_get_chat_id "$_tg_token" || true + if [[ -n "$_TG_DETECTED_CHAT_ID" ]]; then break; fi + (( ++_attempts )) + sleep 2 + done + if [[ -n "$_TG_DETECTED_CHAT_ID" ]]; then + _tg_chat_id="$_TG_DETECTED_CHAT_ID" + printf "${GREEN}Found: ${_tg_chat_id}${RESET}\n" >/dev/tty + fi + elif [[ "$_manual_chatid" =~ ^-?[0-9]+$ ]]; then + _tg_chat_id="$_manual_chatid" + else + printf " ${RED}Invalid chat ID. Must be a number.${RESET}\n" >/dev/tty + fi + + if [[ -z "$_tg_chat_id" ]]; then + printf " ${RED}Could not get chat ID. Setup cancelled.${RESET}\n" >/dev/tty + _press_any_key || true; trap - INT; return 0 + fi + fi + + # ── Step 3: Save and test ── + config_set "TELEGRAM_BOT_TOKEN" "$_tg_token" + config_set "TELEGRAM_CHAT_ID" "$_tg_chat_id" + config_set "TELEGRAM_ENABLED" "true" + save_settings || true + + printf "\n Sending test message... " >/dev/tty + if telegram_test 2>/dev/null; then + printf "${GREEN}Success!${RESET}\n" >/dev/tty + printf "\n ${GREEN}Telegram notifications are now active.${RESET}\n" >/dev/tty + else + printf "${RED}Failed to send.${RESET}\n" >/dev/tty + printf " ${YELLOW}Token/chat ID saved but test failed — check credentials.${RESET}\n" >/dev/tty + fi + + _press_any_key || true + trap - INT + return 0 +} + +# ── Notification message builders ── + +_notify_tunnel_start() { + local name="$1" tunnel_type="${2:-tunnel}" pid="${3:-}" + local hostname + hostname=$(hostname 2>/dev/null || echo "unknown") + local _nts_obfs="" + local -A _nts_prof=() + if load_profile "$name" _nts_prof 2>/dev/null; then + if [[ "${_nts_prof[OBFS_MODE]:-none}" != "none" ]]; then + _nts_obfs=$(printf '\nObfuscation: %s (port %s)' "${_nts_prof[OBFS_MODE]}" "${_nts_prof[OBFS_PORT]:-443}") + fi + if [[ -n "${_nts_prof[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_nts_prof[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then + _nts_obfs="${_nts_obfs}$(printf '\nInbound TLS: port %s (PSK)' "${_nts_prof[OBFS_LOCAL_PORT]}")" + fi + fi + _telegram_notify "$(printf '✅ Tunnel Started\n\nName: %s\nType: %s\nHost: %s\nPID: %s%s\nTime: %s' \ + "$name" "$tunnel_type" "$hostname" "${pid:-?}" "$_nts_obfs" "$(date '+%H:%M:%S')")" + return 0 +} + +_notify_tunnel_stop() { + local name="$1" + _telegram_notify "$(printf '⛔ Tunnel Stopped\n\nName: %s\nTime: %s' \ + "$name" "$(date '+%H:%M:%S')")" + return 0 +} + +_notify_tunnel_fail() { + local name="$1" + _telegram_notify "$(printf '❌ Tunnel Failed\n\nName: %s\nTime: %s\nCheck logs for details.' \ + "$name" "$(date '+%H:%M:%S')")" + return 0 +} + +_notify_reconnect() { + local name="$1" reason="${2:-unknown}" + _telegram_notify "$(printf '🔄 Tunnel Reconnect\n\nName: %s\nReason: %s\nTime: %s' \ + "$name" "$reason" "$(date '+%H:%M:%S')")" + return 0 +} + + +# Generate a periodic status report (with timestamp dedup to prevent repeats) +telegram_send_status() { + if ! _telegram_enabled; then return 0; fi + if [[ "$(config_get TELEGRAM_PERIODIC_STATUS false)" != "true" ]]; then return 0; fi + + # Dedup: check last send timestamp to prevent repeat sends + local _ts_file="${CONFIG_DIR}/tg_last_report" + local _interval + _interval=$(config_get TELEGRAM_STATUS_INTERVAL 3600) + if [[ -f "$_ts_file" ]]; then + local _last_ts _now_ts + _last_ts=$(cat "$_ts_file" 2>/dev/null) || true + _now_ts=$(date +%s 2>/dev/null) || true + if [[ "$_last_ts" =~ ^[0-9]+$ ]] && [[ "$_now_ts" =~ ^[0-9]+$ ]]; then + if (( _now_ts - _last_ts < _interval )); then + return 0 # Too soon, skip + fi + fi + fi + + local hostname running=0 stopped=0 total=0 + hostname=$(hostname 2>/dev/null || echo "unknown") + + local name + while IFS= read -r name; do + [[ -z "$name" ]] && continue + ((++total)) + if is_tunnel_running "$name"; then + ((++running)) + else + ((++stopped)) + fi + done < <(list_profiles) + + (( total > 0 )) || return 0 + + local public_ip + public_ip=$(hostname -I 2>/dev/null | awk '{print $1}') || true + : "${public_ip:=unknown}" + + local msg + msg=$(printf '📊 Status Report\n\nHost: %s\nIP: %s\nTunnels: %d running / %d stopped / %d total\nTime: %s' \ + "$hostname" "$public_ip" "$running" "$stopped" "$total" "$(date '+%Y-%m-%d %H:%M:%S')") + + if _telegram_send "$msg"; then + # Record send timestamp only on success + date +%s > "$_ts_file" 2>/dev/null || true + fi + return 0 +} + +# ── Telegram Bot Command Handler ── +# Polls getUpdates, processes /tf_* commands, responds via sendMessage. +# Call periodically (e.g., from dashboard loop). Uses offset tracking to avoid repeats. + +_tg_cmd_response() { + local _cmd="$1" _chat="$2" _token="$3" + + local _resp="" + case "$_cmd" in + /tf_help|/tf_help@*) + _resp="$(printf '🤖 TunnelForge Bot Commands + +/tf_help - Show this help +/tf_status - Tunnel status overview +/tf_list - List all tunnels +/tf_ip - Show server IP +/tf_config - Get client configs (PSK) +/tf_uptime - Server uptime +/tf_report - Full status report')" + ;; + /tf_status|/tf_status@*) + local _r=0 _s=0 _t=0 _n + while IFS= read -r _n; do + [[ -z "$_n" ]] && continue + (( ++_t )) + if is_tunnel_running "$_n" 2>/dev/null; then (( ++_r )); else (( ++_s )); fi + done < <(list_profiles 2>/dev/null) + _resp="$(printf '📊 Tunnel Status\n\nRunning: %d\nStopped: %d\nTotal: %d\nTime: %s' \ + "$_r" "$_s" "$_t" "$(date '+%H:%M:%S')")" + ;; + /tf_list|/tf_list@*) + local _lines="" _n + while IFS= read -r _n; do + [[ -z "$_n" ]] && continue + if is_tunnel_running "$_n" 2>/dev/null; then + _lines="${_lines}✅ ${_n} [ALIVE]\n" + else + _lines="${_lines}⛔ ${_n} [STOPPED]\n" + fi + done < <(list_profiles 2>/dev/null) + [[ -z "$_lines" ]] && _lines="(no tunnels configured)\n" + _resp="$(printf '📋 Tunnel List\n\n%b' "$_lines")" + ;; + /tf_ip|/tf_ip@*) + local _ip + _ip=$(hostname -I 2>/dev/null | awk '{print $1}') || true + : "${_ip:=unknown}" + _resp="$(printf '🌐 Server IP: %s\nHostname: %s' "$_ip" "$(hostname 2>/dev/null || echo unknown)")" + ;; + /tf_config|/tf_config@*) + # Send config for all profiles with inbound TLS + local _cfg_lines="" _n + while IFS= read -r _n; do + [[ -z "$_n" ]] && continue + local -A _cp=() + if load_profile "$_n" _cp 2>/dev/null; then + local _olp="${_cp[OBFS_LOCAL_PORT]:-}" + if [[ -n "$_olp" ]] && [[ "$_olp" != "0" ]]; then + local _h="${_cp[SSH_HOST]:-localhost}" + local _pub + _pub=$(hostname -I 2>/dev/null | awk '{print $1}') || true + [[ -n "$_pub" ]] && _h="$_pub" + local _st="STOPPED" + is_tunnel_running "$_n" 2>/dev/null && _st="ALIVE" + _cfg_lines="${_cfg_lines}$(printf '┌── %s [%s]\n│ Server: %s\n│ Port: %s\n│ SOCKS5: 127.0.0.1:%s\n│ PSK: %s\n└──\n\n' \ + "$_n" "$_st" "$_h" "$_olp" "${_cp[LOCAL_PORT]:-1080}" "${_cp[OBFS_PSK]:-N/A}")" + fi + fi + done < <(list_profiles 2>/dev/null) + if [[ -z "$_cfg_lines" ]]; then + _resp="No profiles with inbound TLS configured." + else + _resp="$(printf '🔐 Client Configs\n\n%s' "$_cfg_lines")" + fi + ;; + /tf_uptime|/tf_uptime@*) + local _up + _up=$(uptime -p 2>/dev/null || uptime 2>/dev/null || echo "unknown") + _resp="$(printf '⏱ Server Uptime\n\n%s\nTime: %s' "$_up" "$(date '+%Y-%m-%d %H:%M:%S')")" + ;; + /tf_report|/tf_report@*) + local _r=0 _s=0 _t=0 _n _tlist="" + while IFS= read -r _n; do + [[ -z "$_n" ]] && continue + (( ++_t )) + if is_tunnel_running "$_n" 2>/dev/null; then + (( ++_r )) + _tlist="${_tlist} ✅ ${_n}\n" + else + (( ++_s )) + _tlist="${_tlist} ⛔ ${_n}\n" + fi + done < <(list_profiles 2>/dev/null) + local _ip + _ip=$(hostname -I 2>/dev/null | awk '{print $1}') || true + local _up + _up=$(uptime -p 2>/dev/null || echo "N/A") + _resp="$(printf '📊 Full Status Report\n\n🖥 %s (%s)\n⏱ %s\n\n📡 Tunnels: %d running / %d stopped\n%b\n🕐 %s' \ + "$(hostname 2>/dev/null || echo unknown)" "${_ip:-unknown}" "$_up" "$_r" "$_s" "$_tlist" "$(date '+%Y-%m-%d %H:%M:%S')")" + ;; + /start|/start@*) + _resp="$(printf '🤖 TunnelForge Bot Active\n\nSend /tf_help for available commands.')" + ;; + *) + # Unknown command — ignore + return 0 + ;; + esac + + if [[ -n "$_resp" ]]; then + # Send response via _telegram_send (uses proxy automatically) + if _telegram_send "$_resp"; then + log_info "TG bot: replied to '${_cmd}'" + else + log_warn "TG bot: failed to send reply for '${_cmd}'" + fi + fi + return 0 +} + +# Poll for and process Telegram bot commands (one-shot) +_tg_process_commands() { + if ! _telegram_enabled; then return 0; fi + + local _token _chat_id + _token=$(config_get TELEGRAM_BOT_TOKEN "") + _chat_id=$(config_get TELEGRAM_CHAT_ID "") + [[ -n "$_token" ]] && [[ -n "$_chat_id" ]] || return 0 + + local -a _TG_PROXY_ARGS=() + _tg_proxy_args || true + + if [[ ${#_TG_PROXY_ARGS[@]} -eq 0 ]]; then + log_debug "TG poll: no SOCKS5 proxy, trying direct" + fi + + local _offset + _offset=$(_tg_get_offset) || true + + local _curl_cfg _url_str + _curl_cfg=$(mktemp "${TMP_DIR}/tg_poll.XXXXXX") || return 0 + chmod 600 "$_curl_cfg" 2>/dev/null || true + if [[ "$_offset" -gt 0 ]] 2>/dev/null; then + _url_str=$(printf '%s/bot%s/getUpdates?offset=%s&timeout=0' "$_TG_API" "$_token" "$_offset") + else + _url_str=$(printf '%s/bot%s/getUpdates?timeout=0' "$_TG_API" "$_token") + fi + printf 'url = "%s"\n' "$_url_str" > "$_curl_cfg" + local response + response=$(curl -s --max-time 20 "${_TG_PROXY_ARGS[@]}" --max-filesize 1048576 -K "$_curl_cfg" 2>/dev/null) || true + rm -f "$_curl_cfg" 2>/dev/null || true + if [[ -z "$response" ]]; then + log_debug "TG poll: empty response from getUpdates" + return 0 + fi + if ! printf '%s' "$response" | grep -qF '"ok":true'; then + log_debug "TG poll: API error: ${response:0:200}" + return 0 + fi + + # Parse updates with python3 (fast, reliable JSON parsing) + if ! command -v python3 &>/dev/null; then + log_debug "TG poll: python3 not found" + return 0 + fi + + # Validate chat_id is numeric to prevent injection (negative for group chats) + if ! [[ "$_chat_id" =~ ^-?[0-9]+$ ]]; then + log_warn "TG poll: invalid chat_id, skipping" + return 0 + fi + + local _updates + _updates=$(TF_CHAT_ID="$_chat_id" python3 -c " +import json,sys,os +try: + cid=os.environ.get('TF_CHAT_ID','') + d=json.loads(sys.stdin.read()) + for u in d.get('result',[]): + uid=u.get('update_id',0) + msg=u.get('message',{}) + text=msg.get('text','') + chat_id=msg.get('chat',{}).get('id',0) + if text.startswith('/') and str(chat_id)==cid: + cmd=text.split()[0].lower() + print(str(uid)+'|'+cmd) + else: + print(str(uid)+'|') +except: pass +" <<< "$response" 2>/dev/null) || true + + local _max_uid=0 + local _line _uid _cmd + while IFS= read -r _line; do + [[ -z "$_line" ]] && continue + _uid="${_line%%|*}" + _cmd="${_line#*|}" + if [[ "$_uid" =~ ^[0-9]+$ ]] && (( _uid > _max_uid )); then + _max_uid=$_uid + fi + # Process command if present + if [[ -n "$_cmd" ]]; then + log_info "TG bot: received command '${_cmd}'" + _tg_cmd_response "$_cmd" "$_chat_id" "$_token" || true + fi + done <<< "$_updates" + + # Advance offset to skip processed updates + if (( _max_uid > 0 )); then + _tg_set_offset "$(( _max_uid + 1 ))" + log_debug "TG poll: offset advanced to $(( _max_uid + 1 ))" + fi + return 0 +} + +# Non-blocking wrapper: runs _tg_process_commands in background +# Uses lock file to prevent concurrent polls +_tg_process_commands_bg() { + local _lock="${TMP_DIR}/tg_cmd.lock" + # Skip if previous poll still running + if [[ -f "$_lock" ]]; then + local _lpid + _lpid=$(cat "$_lock" 2>/dev/null) || true + if [[ -n "$_lpid" ]] && kill -0 "$_lpid" 2>/dev/null; then + return 0 + fi + rm -f "$_lock" 2>/dev/null || true + fi + ( + printf '%s' "$BASHPID" > "$_lock" 2>/dev/null || true + _tg_process_commands 2>/dev/null || true + rm -f "$_lock" 2>/dev/null || true + ) &>/dev/null & + disown 2>/dev/null || true + return 0 +} + +# Send a file via Telegram bot API (sendDocument). +# Args: file_path [caption] +_telegram_send_file() { + local _file="$1" _caption="${2:-}" + local _token _chat_id + _token=$(config_get TELEGRAM_BOT_TOKEN "") + _chat_id=$(config_get TELEGRAM_CHAT_ID "") + [[ -n "$_token" ]] && [[ -n "$_chat_id" ]] || return 1 + [[ -f "$_file" ]] || return 1 + + local _tg_url="${_TG_API}/bot${_token}/sendDocument" + local _tg_cfg + _tg_cfg=$(mktemp "${TMP_DIR}/tg_cfg.XXXXXX") || return 1 + printf 'url = "%s"\n' "$_tg_url" > "$_tg_cfg" 2>/dev/null || { rm -f "$_tg_cfg" 2>/dev/null || true; return 1; } + chmod 600 "$_tg_cfg" 2>/dev/null || true + + local -a _TG_PROXY_ARGS=() + _tg_proxy_args || true + + local -a curl_args=( + -s --max-time 30 + "${_TG_PROXY_ARGS[@]}" + -X POST + -F "chat_id=${_chat_id}" + -F "document=@${_file}" + ) + if [[ -n "$_caption" ]]; then + curl_args+=(-F "caption=${_caption}") + fi + + local _rc=0 + curl --config "$_tg_cfg" "${curl_args[@]}" >/dev/null 2>&1 || _rc=$? + rm -f "$_tg_cfg" 2>/dev/null || true + return "$_rc" +} + +# Share client connection info + scripts via Telegram. +# Args: profile_name +telegram_share_client() { + local _name="${1:-}" + + if ! _telegram_enabled; then + log_error "Telegram is not configured. Run: tunnelforge telegram setup" + return 1 + fi + + if [[ -z "$_name" ]]; then + # Pick from running profiles with inbound TLS + local _profiles="" _found=0 + while IFS= read -r _pn; do + [[ -z "$_pn" ]] && continue + local -A _tp=() + if load_profile "$_pn" _tp 2>/dev/null; then + if [[ -n "${_tp[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_tp[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then + _profiles="${_profiles}${_pn}\n" + ((++_found)) + fi + fi + done < <(list_profiles) + + if (( _found == 0 )); then + log_error "No profiles with inbound TLS found" + return 1 + fi + + printf "\n${BOLD}Profiles with inbound TLS:${RESET}\n" + local -a _parr=() _pi=0 + while IFS= read -r _pn; do + [[ -z "$_pn" ]] && continue + ((++_pi)) + _parr+=("$_pn") + printf " ${CYAN}%d${RESET}) %s\n" "$_pi" "$_pn" + done < <(printf '%b' "$_profiles") + + printf "\n" + local _sel="" + read -rp " Select profile [1-${_pi}]: " _sel _pi )); then + log_error "Invalid selection" + return 1 + fi + _name="${_parr[$((_sel - 1))]}" + fi + + local -A _sp=() + load_profile "$_name" _sp || { log_error "Cannot load profile '$_name'"; return 1; } + + local _olport="${_sp[OBFS_LOCAL_PORT]:-}" + local _psk="${_sp[OBFS_PSK]:-}" + local _lport="${_sp[LOCAL_PORT]:-}" + + if [[ -z "$_olport" ]] || [[ "$_olport" == "0" ]]; then + log_error "Profile '$_name' has no inbound TLS configured" + return 1 + fi + + # Determine server IP + local _host="${_sp[SSH_HOST]:-localhost}" + local _pub_ip="" + _pub_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | cut -d' ' -f2) || true + if [[ -n "$_pub_ip" ]]; then _host="$_pub_ip"; fi + + log_info "Sharing client info for '${_name}' via Telegram..." + + # Determine running status + local _status_txt="STOPPED" + if is_tunnel_running "$_name" 2>/dev/null; then _status_txt="ALIVE"; fi + + # 1. Send connection info message (clean, formatted like the menu display) + local _msg + _msg=$(printf '🔐 TunnelForge Client Config + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📡 %s [%s] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Server: %s +Port: %s +SOCKS5: 127.0.0.1:%s +PSK Key: %s + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚡ Quick Start (Windows) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +1. Install stunnel from stunnel.org +2. Save the .bat file below +3. Edit the SERVER, PORT, PSK values +4. Double-click to connect +5. Set browser proxy: 127.0.0.1:%s + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🐧 Quick Start (Linux/Mac) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +1. Install stunnel: apt install stunnel4 +2. Save the .sh file below +3. chmod +x tunnelforge-connect.sh +4. ./tunnelforge-connect.sh +5. Set browser proxy: 127.0.0.1:%s + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔧 Manual stunnel Config +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +stunnel.conf: +[tunnelforge] +client = yes +accept = 127.0.0.1:%s +connect = %s:%s +PSKsecrets = psk.txt +ciphers = PSK + +psk.txt: +tunnelforge:%s + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🌐 Browser Setup +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Firefox: + Settings → Proxy → Manual + SOCKS Host: 127.0.0.1 + Port: %s + SOCKS v5 ✓ + Proxy DNS ✓ + +Chrome: + chrome --proxy-server="socks5://127.0.0.1:%s"' \ + "$_name" "$_status_txt" \ + "$_host" "$_olport" "$_lport" "$_psk" \ + "$_lport" \ + "$_lport" \ + "$_lport" "$_host" "$_olport" \ + "$_psk" \ + "$_lport" "$_lport") + + if _telegram_send "$_msg"; then + log_success "Connection info sent" + else + log_error "Failed to send connection info" + return 1 + fi + + # 2. Generate and send Linux script + local _sh_file="${TMP_DIR}/tunnelforge-connect.sh" + if _obfs_generate_client_script "$_name" _sp "$_sh_file" 2>/dev/null; then + if _telegram_send_file "$_sh_file" "Linux/Mac client — chmod +x and run"; then + log_success "Linux script sent" + else + log_warn "Failed to send Linux script" + fi + fi + rm -f "$_sh_file" 2>/dev/null || true + + # 3. Send Windows bat file if it exists in the install dir + local _bat_file="" + for _bp in "${INSTALL_DIR}/tunnelforge-client.bat" \ + "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")/tunnelforge-client.bat" \ + "/opt/tunnelforge/tunnelforge-client.bat"; do + if [[ -f "$_bp" ]]; then _bat_file="$_bp"; break; fi + done + + if [[ -n "$_bat_file" ]]; then + if _telegram_send_file "$_bat_file" "Windows client — double-click to run"; then + log_success "Windows script sent" + else + log_warn "Failed to send Windows script" + fi + else + log_info "Windows .bat not found — place tunnelforge-client.bat in /opt/tunnelforge/" + fi + + printf "\n${GREEN}Client setup shared via Telegram.${RESET}\n" + printf "${DIM}Users in the chat can see the info and download the scripts.${RESET}\n\n" + return 0 +} + +# ── Telegram interactive menu ── + +_menu_telegram() { + while true; do + clear >/dev/tty 2>/dev/null || true + printf "\n${BOLD_CYAN}═══ Telegram Notifications ═══${RESET}\n\n" >/dev/tty + + local _tg_status_icon="${RED}●${RESET}" + if _telegram_enabled; then + _tg_status_icon="${GREEN}●${RESET}" + fi + + printf " Status: %b %s\n\n" "$_tg_status_icon" \ + "$(if _telegram_enabled; then echo 'Connected'; else echo 'Not configured'; fi)" >/dev/tty + + printf " ${CYAN}1${RESET}) Setup / reconfigure\n" >/dev/tty + printf " ${CYAN}2${RESET}) Send test message\n" >/dev/tty + printf " ${CYAN}3${RESET}) Toggle alerts : ${BOLD}%s${RESET}\n" "$(config_get TELEGRAM_ALERTS true)" >/dev/tty + printf " ${CYAN}4${RESET}) Toggle status reports: ${BOLD}%s${RESET}\n" "$(config_get TELEGRAM_PERIODIC_STATUS false)" >/dev/tty + printf " ${CYAN}5${RESET}) Status interval : ${BOLD}%s${RESET}s\n" "$(config_get TELEGRAM_STATUS_INTERVAL 3600)" >/dev/tty + printf " ${CYAN}6${RESET}) Show full status\n" >/dev/tty + printf " ${CYAN}7${RESET}) Share client setup (scripts + PSK)\n" >/dev/tty + printf " ${CYAN}8${RESET}) Disable Telegram\n" >/dev/tty + printf " ${YELLOW}0${RESET}) Back\n\n" >/dev/tty + + local _tg_choice + printf " ${BOLD}Select${RESET}: " >/dev/tty + read -rsn1 _tg_choice /dev/tty + + case "$_tg_choice" in + 1) telegram_setup || true ;; + 2) telegram_test || true; _press_any_key ;; + 3) + if [[ "$(config_get TELEGRAM_ALERTS true)" == "true" ]]; then + config_set "TELEGRAM_ALERTS" "false" + log_info "Telegram alerts disabled" + else + config_set "TELEGRAM_ALERTS" "true" + log_info "Telegram alerts enabled" + fi + save_settings || true ;; + 4) + if [[ "$(config_get TELEGRAM_PERIODIC_STATUS false)" == "true" ]]; then + config_set "TELEGRAM_PERIODIC_STATUS" "false" + log_info "Periodic status reports disabled" + else + config_set "TELEGRAM_PERIODIC_STATUS" "true" + log_info "Periodic status reports enabled" + fi + save_settings || true ;; + 5) + local _tg_int + _read_tty " Status interval (seconds)" _tg_int "$(config_get TELEGRAM_STATUS_INTERVAL 3600)" + if [[ "$_tg_int" =~ ^[0-9]+$ ]] && (( _tg_int >= 60 )); then + config_set "TELEGRAM_STATUS_INTERVAL" "$_tg_int" + save_settings || true + log_info "Status interval set to ${_tg_int}s" + else + log_error "Invalid interval (minimum 60 seconds)" + fi + _press_any_key ;; + 6) telegram_status || true; _press_any_key ;; + 7) telegram_share_client "" || true; _press_any_key ;; + 8) + config_set "TELEGRAM_ENABLED" "false" + save_settings || true + log_info "Telegram notifications disabled" ;; + 0|q) return 0 ;; + *) true ;; + esac + done +} + +# ── Log rotation ── + +_rotate_dir_logs() { + local dir="$1" max_size="$2" max_count="$3" + local log_f + while IFS= read -r log_f; do + [[ -f "$log_f" ]] || continue + local fsize + fsize=$(stat -c %s "$log_f" 2>/dev/null || stat -f %z "$log_f" 2>/dev/null) || true + : "${fsize:=0}" + if (( fsize > max_size )); then + local ri + for (( ri=max_count; ri>=1; ri-- )); do + local prev=$(( ri - 1 )) + local src="${log_f}" + if [[ $prev -gt 0 ]]; then src="${log_f}.${prev}"; fi + if [[ -f "$src" ]]; then mv -f "$src" "${log_f}.${ri}" 2>/dev/null || true; fi + done + : > "$log_f" 2>/dev/null || true + log_debug "Rotated log: $(basename "$log_f")" + fi + done < <(find "$dir" -maxdepth 1 -name "*.log" -type f 2>/dev/null || true) + return 0 +} + +rotate_logs() { + local max_size max_count + max_size=$(config_get LOG_MAX_SIZE 10485760) + max_count=$(config_get LOG_ROTATE_COUNT 5) + _rotate_dir_logs "$LOG_DIR" "$max_size" "$max_count" + _rotate_dir_logs "$RECONNECT_LOG_DIR" "$max_size" "$max_count" + return 0 +} + +# ── Connection quality indicator ── +# Returns a quality rating based on latency to the SSH host + +_get_ns_timestamp() { + local _ts + _ts=$(date +%s%N 2>/dev/null) || true + if [[ "$_ts" =~ ^[0-9]+$ ]]; then printf '%s' "$_ts"; return 0; fi + # macOS fallback: try perl for sub-second precision + _ts=$(perl -MTime::HiRes=time -e 'printf "%d", time()*1000000000' 2>/dev/null) || true + if [[ "$_ts" =~ ^[0-9]+$ ]]; then printf '%s' "$_ts"; return 0; fi + # Last resort: second-level precision + _ts=$(date +%s 2>/dev/null) || true + if [[ "$_ts" =~ ^[0-9]+$ ]]; then printf '%s' "$(( _ts * 1000000000 ))"; return 0; fi + printf '0' +} + +_connection_quality() { + local host="$1" port="${2:-22}" + local start_ms end_ms + + # Validate host/port to prevent injection in bash -c /dev/tcp + if ! [[ "$port" =~ ^[0-9]+$ ]]; then printf 'unknown'; return 0; fi + if ! [[ "$host" =~ ^[a-zA-Z0-9._:-]+$ ]]; then printf 'unknown'; return 0; fi + + # Quick TCP connect test with 2s timeout (kills hangs from iptables DROP) + start_ms=$(_get_ns_timestamp) + + local _cq_ok=false + if command -v timeout &>/dev/null; then + if timeout 2 bash -c ": /dev/null; then + _cq_ok=true + fi + elif command -v nc &>/dev/null; then + if nc -z -w2 "$host" "$port" 2>/dev/null; then _cq_ok=true; fi + fi + + if [[ "$_cq_ok" == true ]]; then + end_ms=$(_get_ns_timestamp) + + if (( start_ms > 0 && end_ms > 0 )); then + local latency_ms=$(( (end_ms - start_ms) / 1000000 )) + if (( latency_ms < 50 )); then + printf "excellent" + elif (( latency_ms < 150 )); then + printf "good" + elif (( latency_ms < 300 )); then + printf "fair" + else + printf "poor" + fi + return 0 + fi + fi + printf "unknown" + return 0 +} + +# Map quality to visual indicator +_quality_icon() { + case "$1" in + excellent) printf "${GREEN}▁▃▅▇${RESET}" ;; + good) printf "${GREEN}▁▃▅${RESET}${DIM}▇${RESET}" ;; + fair) printf "${YELLOW}▁▃${RESET}${DIM}▅▇${RESET}" ;; + poor) printf "${RED}▁${RESET}${DIM}▃▅▇${RESET}" ;; + *) printf "${DIM}▁▃▅▇${RESET}" ;; + esac +} + +# ============================================================================ +# DISPLAY HELPERS +# ============================================================================ + +show_banner() { + printf "${BOLD_CYAN}" + cat <<'BANNER' + + ╔════════════════════════════════════════════════════════════════╗ + ║ ▀▀█▀▀ █ █ █▄ █ █▄ █ █▀▀ █ █▀▀ █▀█ █▀█ █▀▀ █▀▀ ║ + ║ █ █ █ █ ▀█ █ ▀█ █▀▀ █ █▀ █ █ █▀█ █ █ █▀▀ ║ + ║ █ ▀▀ █ █ █ █ ▀▀▀ ▀▀▀ █ ▀▀▀ █ █ ▀▀▀ ▀▀▀ ║ + ╚════════════════════════════════════════════════════════════════╝ +BANNER + printf "${RESET}" + printf "${DIM} SSH Tunnel Manager v%s${RESET}\n\n" "$VERSION" +} + +show_help() { + show_banner + printf '%b\n' "${BOLD}USAGE:${RESET} + tunnelforge [command] [options] + +${BOLD}TUNNEL COMMANDS:${RESET} + start Start a tunnel + stop Stop a tunnel + restart Restart a tunnel + start-all Start all autostart tunnels + stop-all Stop all running tunnels + status Show all tunnel statuses + dashboard, dash Live TUI dashboard + logs [name] Tail tunnel logs + +${BOLD}PROFILE COMMANDS:${RESET} + list, ls List all profiles + create, new Create new tunnel (wizard) + delete Delete a profile + +${BOLD}SECURITY COMMANDS:${RESET} + audit Run security audit + key-gen [type] Generate SSH key (ed25519/rsa) + key-deploy Deploy SSH key to profile's server + fingerprint Check SSH host fingerprint + +${BOLD}TELEGRAM COMMANDS:${RESET} + telegram setup Configure Telegram bot + telegram test Send test message + telegram status Show notification config + telegram send Send a message via Telegram + telegram report Send status report now + +${BOLD}SERVICE COMMANDS:${RESET} + service Generate systemd service file + service enable Enable + start service + service disable Disable + stop service + service status Show service status + service remove Remove service file + +${BOLD}SYSTEM COMMANDS:${RESET} + menu Interactive TUI menu + install Install TunnelForge + health Run health check + server-setup Harden local server for tunnels + server-setup Enable forwarding on remote server + obfs-setup Set up TLS obfuscation (stunnel) on server + client-config Show client connection config (TLS+PSK) + client-script Generate client scripts (Linux + Windows) + backup Backup profiles + keys + restore [file] Restore from backup + update Check for updates and install latest + uninstall Remove everything + version Show version + help Show this help + +${BOLD}EXAMPLES:${RESET} + tunnelforge create # Interactive wizard + tunnelforge start office-proxy # Start a tunnel + tunnelforge dashboard # Live monitoring + tunnelforge service myproxy enable # Autostart on boot + tunnelforge backup # Backup everything +" +} + +show_version() { printf "%s v%s\n" "$APP_NAME" "$VERSION"; } + +show_status() { + local profiles name + profiles=$(list_profiles) + + if [[ -z "$profiles" ]]; then + log_info "No profiles configured. Run 'tunnelforge create' to get started." + return 0 + fi + + local _st_width + _st_width=$(get_term_width) + if (( _st_width > 120 )); then _st_width=120; fi + if (( _st_width < 82 )); then _st_width=82; fi + local _name_col=$(( _st_width - 62 )) + if (( _name_col < 18 )); then _name_col=18; fi + printf "\n${BOLD}%-${_name_col}s %-8s %-10s %-22s %-12s %-10s${RESET}\n" \ + "NAME" "TYPE" "STATUS" "LOCAL" "TRAFFIC" "UPTIME" + print_line "─" "$_st_width" + + while IFS= read -r name; do + [[ -z "$name" ]] && continue + + unset _st 2>/dev/null || true + local -A _st=() + load_profile "$name" _st 2>/dev/null || continue + + local ttype="${_st[TUNNEL_TYPE]:-?}" + local addr="${_st[LOCAL_BIND_ADDR]:-}:${_st[LOCAL_PORT]:-}" + + if is_tunnel_running "$name"; then + local up_s up_str traffic rchar wchar total traf_str + up_s=$(get_tunnel_uptime "$name" 2>/dev/null || true) + : "${up_s:=0}" + up_str=$(format_duration "$up_s") + traffic=$(get_tunnel_traffic "$name" 2>/dev/null || true) + : "${traffic:=0 0}" + read -r rchar wchar <<< "$traffic" + [[ "$rchar" =~ ^[0-9]+$ ]] || rchar=0 + [[ "$wchar" =~ ^[0-9]+$ ]] || wchar=0 + total=$(( rchar + wchar )) + traf_str=$(format_bytes "$total") + + local _nd=$(( _name_col - 2 )) + printf " %-${_nd}s %-8s %s %-7s %-22s %-12s %-10s\n" \ + "$name" "${ttype^^}" "${GREEN}●${RESET}" "${GREEN}ALIVE${RESET}" \ + "$addr" "$traf_str" "$up_str" + else + local _nd=$(( _name_col - 2 )) + printf " %-${_nd}s %-8s %s %-7s ${DIM}%-22s %-12s %-10s${RESET}\n" \ + "$name" "${ttype^^}" "${DIM}■${RESET}" "${DIM}STOP${RESET}" \ + "$addr" "0 B" "-" + fi + + done <<< "$profiles" + printf "\n" +} + +# ============================================================================ +# SETUP WIZARD (Phase 2) +# ============================================================================ + +# Read a line from the terminal (works even when stdin is piped) +_read_tty() { + local _prompt="$1" _var_name="$2" _default="${3:-}" + local _input + + if [[ -n "$_default" ]]; then + printf "${BOLD}%s${RESET} ${DIM}[%s]${RESET}: " "$_prompt" "$_default" >/dev/tty + else + printf "${BOLD}%s${RESET}: " "$_prompt" >/dev/tty + fi + + if ! read -r _input /dev/tty + else + printf "${BOLD}%s${RESET}: " "$_prompt" >/dev/tty + fi + + if ! read -rs _input /dev/tty + printf -v "$_var_name" '%s' "" + return 1 + fi + fi + printf "\n" >/dev/tty + _input="${_input:-$_default}" + printf -v "$_var_name" '%s' "$_input" +} + +# Read a yes/no answer; returns 0=yes, 1=no +_read_yn() { + local _prompt="$1" _default="${2:-n}" + local _input _hint="y/N" + if [[ "$_default" == "y" ]]; then _hint="Y/n"; fi + + printf "${BOLD}%s${RESET} ${DIM}[%s]${RESET}: " "$_prompt" "$_hint" >/dev/tty + read -r _input /dev/tty + local _sep="" + for (( _si=0; _si<40; _si++ )); do _sep+="─"; done + printf " %s\n" "$_sep" >/dev/tty + + local _oi + for (( _oi=0; _oi<_count; _oi++ )); do + printf " ${CYAN}%d${RESET}) %s\n" "$((_oi+1))" "${_options[$_oi]}" >/dev/tty + done + printf "\n" >/dev/tty + + local _choice + while true; do + printf "${BOLD}Select [1-%d]${RESET} ${DIM}(q=quit, b=back)${RESET}: " "$_count" >/dev/tty + if ! read -r _choice = 1 && _choice <= _count )); then + _WIZ_NAV="" + echo "$((_choice - 1))" + return 0 + fi + printf " ${RED}Invalid choice. Try again.${RESET}\n" >/dev/tty + done +} + +# Wait for keypress +_press_any_key() { + printf "\n${DIM}Press any key to continue...${RESET}" >/dev/tty 2>/dev/null || true + read -rsn1 _ /dev/tty 2>/dev/null || true +} + +# Test SSH connectivity +test_ssh_connection() { + local host="$1" port="$2" user="$3" key="${4:-}" password="${5:-}" + + printf "\n${CYAN}Testing SSH connection to %s@%s:%s...${RESET}\n" \ + "$user" "$host" "$port" >/dev/tty + + local -a ssh_args=(-o "ConnectTimeout=10" -p "$port") + # Use accept-new for test: accepts host key on first connect (saves to known_hosts), + # rejects if key changes later. Subsequent tunnel connections use strict=yes. + ssh_args+=(-o "StrictHostKeyChecking=accept-new") + if [[ -n "$key" ]] && [[ -f "$key" ]]; then ssh_args+=(-i "$key"); fi + + local -a cmd_prefix=() + if [[ -n "$password" ]]; then + if ! command -v sshpass &>/dev/null; then + printf " ${DIM}Installing sshpass...${RESET}\n" >/dev/tty + if [[ -n "${PKG_UPDATE:-}" ]]; then ${PKG_UPDATE} &>/dev/null || true; fi + install_package "sshpass" 2>/dev/null || true + fi + if command -v sshpass &>/dev/null; then + cmd_prefix=(env "SSHPASS=${password}" sshpass -e) + ssh_args+=(-o "BatchMode=no") + else + # No sshpass — let SSH prompt interactively on /dev/tty + ssh_args+=(-o "BatchMode=no") + printf " ${DIM}(sshpass unavailable — SSH will prompt for password)${RESET}\n" >/dev/tty + fi + else + ssh_args+=(-o "BatchMode=yes") + fi + + local _test_output _test_rc=0 + _test_output=$("${cmd_prefix[@]}" ssh "${ssh_args[@]}" "${user}@${host}" "echo ok" 2>&1 /dev/tty + return 0 + else + printf " ${RED}✗ Authentication failed${RESET}\n" >/dev/tty + if [[ -n "$_test_output" ]]; then + printf " ${DIM}%s${RESET}\n" "$_test_output" >/dev/tty + fi + return 1 + fi +} + +# ── Wizard navigation helpers ── + +declare -g _WIZ_NAV="" + +_wiz_read() { + _WIZ_NAV="" + local _wr_prompt="$1" _wr_var="$2" _wr_default="${3:-}" + local _wr_input + if [[ -n "$_wr_default" ]]; then + printf "${BOLD}%s${RESET} ${DIM}[%s]${RESET} ${DIM}(q/b)${RESET}: " "$_wr_prompt" "$_wr_default" >/dev/tty + else + printf "${BOLD}%s${RESET} ${DIM}(q/b)${RESET}: " "$_wr_prompt" >/dev/tty + fi + if ! read -r _wr_input /dev/tty + read -r _input /dev/tty +} + +# ── Per-type sub-wizards ── + +wizard_socks5() { + local -n _ws_prof="$1" + local _ss=1 bind="" port="" + + while (( _ss >= 1 )); do + case $_ss in + 1) + printf "\n${BOLD_MAGENTA}── SOCKS5 Proxy Configuration ──${RESET} ${DIM}(q=quit, b=back)${RESET}\n\n" >/dev/tty + cat >/dev/tty <<'DIAGRAM' + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Client │──SOCKS5──│ SSH Host │──────────│ Internet │ + │ (local) │ :1080 │ (proxy) │ │ │ + └──────────┘ └──────────┘ └──────────┘ + ssh -D 1080 user@host +DIAGRAM + printf "\n" >/dev/tty + printf "${DIM} Your apps connect to a local SOCKS5 port and${RESET}\n" >/dev/tty + printf "${DIM} all traffic routes through the SSH server.${RESET}\n" >/dev/tty + printf "${DIM} After setup: set your browser proxy to this${RESET}\n" >/dev/tty + printf "${DIM} address and port.${RESET}\n\n" >/dev/tty + printf "${DIM} Tip: 127.0.0.1 = local only${RESET}\n" >/dev/tty + printf "${DIM} 0.0.0.0 = allow LAN devices${RESET}\n" >/dev/tty + _wiz_read "Local bind address" bind "${_ws_prof[LOCAL_BIND_ADDR]:-127.0.0.1}" + if _wiz_quit; then return 1; fi + if _wiz_back; then return 2; fi + if ! { validate_ip "$bind" || validate_ip6 "$bind" || [[ "$bind" == "localhost" ]] || [[ "$bind" == "*" ]]; }; then + printf " ${RED}Invalid bind address: ${bind}${RESET}\n" >/dev/tty; continue + fi + (( ++_ss )) ;; + 2) + printf "${DIM} Tip: Common ports: 1080 (standard), 9050 (Tor-style). Avoid ports < 1024${RESET}\n" >/dev/tty + _wiz_read "Local SOCKS5 port" port "${_ws_prof[LOCAL_PORT]:-1080}" + if _wiz_quit; then return 1; fi + if _wiz_back; then (( --_ss )); continue; fi + if ! validate_port "$port"; then + printf " ${RED}Invalid port: ${port}${RESET}\n" >/dev/tty; continue + fi + if ! _check_port_conflict "$port"; then continue; fi + _ws_prof[LOCAL_BIND_ADDR]="$bind" + _ws_prof[LOCAL_PORT]="$port" + _ws_prof[REMOTE_HOST]="" + _ws_prof[REMOTE_PORT]="" + return 0 ;; + esac + done +} + +wizard_local_forward() { + local -n _wlf_prof="$1" + local _ls=1 bind="" lport="" rhost="" rport="" + + while (( _ls >= 1 )); do + case $_ls in + 1) + printf "\n${BOLD_MAGENTA}── Local Port Forward Configuration ──${RESET} ${DIM}(q=quit, b=back)${RESET}\n\n" >/dev/tty + cat >/dev/tty <<'DIAGRAM' + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Client │──Local───│ SSH Host │──────────│ Remote │ + │ :8080 │ Fwd │ (relay) │ │ :8080 │ + └──────────┘ └──────────┘ └──────────┘ + ssh -L 8080:127.0.0.1:8080 user@host +DIAGRAM + printf "\n" >/dev/tty + printf "${DIM} A port opens on THIS machine and connects${RESET}\n" >/dev/tty + printf "${DIM} through SSH to a service on the remote side.${RESET}\n" >/dev/tty + printf "${DIM} Example: VPS port 3306 (MySQL) → localhost:3306${RESET}\n\n" >/dev/tty + printf "${DIM} Tip: 127.0.0.1 = local only${RESET}\n" >/dev/tty + printf "${DIM} 0.0.0.0 = allow LAN devices${RESET}\n" >/dev/tty + _wiz_read "Local bind address" bind "${_wlf_prof[LOCAL_BIND_ADDR]:-127.0.0.1}" + if _wiz_quit; then return 1; fi + if _wiz_back; then return 2; fi + if ! { validate_ip "$bind" || validate_ip6 "$bind" || [[ "$bind" == "localhost" ]] || [[ "$bind" == "*" ]]; }; then + printf " ${RED}Invalid bind address: ${bind}${RESET}\n" >/dev/tty; continue + fi + (( ++_ls )) ;; + 2) + printf "${DIM} Tip: The port you will connect to on THIS machine (e.g. 8080, 3306, 5432)${RESET}\n" >/dev/tty + _wiz_read "Local port" lport "${_wlf_prof[LOCAL_PORT]:-8080}" + if _wiz_quit; then return 1; fi + if _wiz_back; then (( --_ls )); continue; fi + if ! validate_port "$lport"; then + printf " ${RED}Invalid port: ${lport}${RESET}\n" >/dev/tty; continue + fi + if ! _check_port_conflict "$lport"; then continue; fi + (( ++_ls )) ;; + 3) + printf "${DIM} Tip: 127.0.0.1 = service on the SSH server${RESET}\n" >/dev/tty + printf "${DIM} Or use another IP for a different machine.${RESET}\n" >/dev/tty + _wiz_read "Remote target host" rhost "${_wlf_prof[REMOTE_HOST]:-127.0.0.1}" + if _wiz_quit; then return 1; fi + if _wiz_back; then (( --_ls )); continue; fi + (( ++_ls )) ;; + 4) + printf "${DIM} Tip: The port of the service on the remote side (e.g. 8080, 3306, 443)${RESET}\n" >/dev/tty + _wiz_read "Remote target port" rport "${_wlf_prof[REMOTE_PORT]:-8080}" + if _wiz_quit; then return 1; fi + if _wiz_back; then (( --_ls )); continue; fi + if ! validate_port "$rport"; then + printf " ${RED}Invalid port: ${rport}${RESET}\n" >/dev/tty; continue + fi + _wlf_prof[LOCAL_BIND_ADDR]="$bind" + _wlf_prof[LOCAL_PORT]="$lport" + _wlf_prof[REMOTE_HOST]="$rhost" + _wlf_prof[REMOTE_PORT]="$rport" + return 0 ;; + esac + done +} + +wizard_remote_forward() { + local -n _wrf_prof="$1" + local _rs=1 bind="" rport="" lhost="" lport="" + + while (( _rs >= 1 )); do + case $_rs in + 1) + printf "\n${BOLD_MAGENTA}── Remote (Reverse) Forward Configuration ──${RESET} ${DIM}(q=quit, b=back)${RESET}\n\n" >/dev/tty + cat >/dev/tty <<'DIAGRAM' + ┌──────────────┐ ┌──────────────┐ ┌──────────┐ + │ THIS machine │──Reverse─│ SSH Server │──Listen──│ Users │ + │ :3000 │ Fwd │ :9090 │ │ │ + └──────────────┘ └──────────────┘ └──────────┘ + ssh -R 9090:127.0.0.1:3000 user@host +DIAGRAM + printf "\n" >/dev/tty + printf "${DIM} A port opens on the SSH SERVER and connects${RESET}\n" >/dev/tty + printf "${DIM} back to a service on THIS machine.${RESET}\n" >/dev/tty + printf "${DIM} Example: local :3000 → reachable at VPS:9090${RESET}\n\n" >/dev/tty + printf "${DIM} Tip: 127.0.0.1 = SSH server only${RESET}\n" >/dev/tty + printf "${DIM} 0.0.0.0 = public (needs GatewayPorts=yes)${RESET}\n" >/dev/tty + _wiz_read "Remote bind address (on SSH server)" bind "${_wrf_prof[LOCAL_BIND_ADDR]:-127.0.0.1}" + if _wiz_quit; then return 1; fi + if _wiz_back; then return 2; fi + if ! { validate_ip "$bind" || validate_ip6 "$bind" || [[ "$bind" == "localhost" ]] || [[ "$bind" == "*" ]]; }; then + printf " ${RED}Invalid bind address: ${bind}${RESET}\n" >/dev/tty; continue + fi + (( ++_rs )) ;; + 2) + printf "${DIM} Tip: Port to open on the SSH server.${RESET}\n" >/dev/tty + printf "${DIM} Example: 9090, 8080, 443${RESET}\n" >/dev/tty + _wiz_read "Remote listen port (on SSH server)" rport "${_wrf_prof[REMOTE_PORT]:-9090}" + if _wiz_quit; then return 1; fi + if _wiz_back; then (( --_rs )); continue; fi + if ! validate_port "$rport"; then + printf " ${RED}Invalid port: ${rport}${RESET}\n" >/dev/tty; continue + fi + (( ++_rs )) ;; + 3) + printf "${DIM} Tip: 127.0.0.1 = this machine, or another${RESET}\n" >/dev/tty + printf "${DIM} IP for a LAN device.${RESET}\n" >/dev/tty + _wiz_read "Local service host" lhost "${_wrf_prof[REMOTE_HOST]:-127.0.0.1}" + if _wiz_quit; then return 1; fi + if _wiz_back; then (( --_rs )); continue; fi + (( ++_rs )) ;; + 4) + printf "${DIM} Tip: What port is your local service running on? (e.g. 3000, 8080, 22)${RESET}\n" >/dev/tty + _wiz_read "Local service port" lport "${_wrf_prof[LOCAL_PORT]:-3000}" + if _wiz_quit; then return 1; fi + if _wiz_back; then (( --_rs )); continue; fi + if ! validate_port "$lport"; then + printf " ${RED}Invalid port: ${lport}${RESET}\n" >/dev/tty; continue + fi + _wrf_prof[LOCAL_BIND_ADDR]="$bind" + _wrf_prof[LOCAL_PORT]="$lport" + _wrf_prof[REMOTE_HOST]="$lhost" + _wrf_prof[REMOTE_PORT]="$rport" + return 0 ;; + esac + done +} + +wizard_jump_host() { + local -n _wjh_prof="$1" + local _js=1 jumps="" _jh_ttype="" bind="" port="" lport="" rhost="" rport="" + + while (( _js >= 1 )); do + case $_js in + 1) + printf "\n${BOLD_MAGENTA}── Jump Host (Multi-Hop) Configuration ──${RESET} ${DIM}(q=quit, b=back)${RESET}\n\n" >/dev/tty + cat >/dev/tty <<'DIAGRAM' + ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Client │─────│ Jump 1 │─────│ Jump 2 │─────│ Target │ + │ (local) │ │ (relay) │ │ (relay) │ │ (final) │ + └──────────┘ └──────────┘ └──────────┘ └──────────┘ + ssh -J jump1,jump2 user@target -D 1080 +DIAGRAM + printf "\n" >/dev/tty + printf "${DIM} SSH hops through intermediate servers${RESET}\n" >/dev/tty + printf "${DIM} to reach the final target.${RESET}\n" >/dev/tty + printf "${DIM} Use when target is behind a firewall.${RESET}\n\n" >/dev/tty + printf "${DIM} Tip: Comma-separated, in hop order.${RESET}\n" >/dev/tty + printf "${DIM} Format: user@host:port or just host${RESET}\n" >/dev/tty + printf "${DIM} e.g. admin@bastion:22,10.0.0.5${RESET}\n\n" >/dev/tty + _wiz_read "Jump hosts (comma-separated)" jumps "${_wjh_prof[JUMP_HOSTS]:-}" + if _wiz_quit; then return 1; fi + if _wiz_back; then return 2; fi + if [[ -z "$jumps" ]]; then + printf " ${RED}At least one jump host is required${RESET}\n" >/dev/tty; continue + fi + (( ++_js )) ;; + 2) + # Select tunnel type at destination + printf "\n${BOLD}Choose tunnel type at destination:${RESET}\n" >/dev/tty + printf "${DIM} SOCKS5 = route all traffic (VPN-like)${RESET}\n" >/dev/tty + printf "${DIM} Local Forward = access a specific port${RESET}\n" >/dev/tty + local _jh_types=("SOCKS5 Proxy" "Local Port Forward") + local _jh_choice + _jh_choice=$(_select_option "Tunnel type at destination" "${_jh_types[@]}") || true + # _select_option echoes "q"/"b" for nav (since _WIZ_NAV is lost in subshell) + if [[ "$_jh_choice" == "q" ]]; then _WIZ_NAV="quit"; return 1; fi + if [[ "$_jh_choice" == "b" ]]; then (( --_js )); continue; fi + case "$_jh_choice" in + 0) _jh_ttype="socks5" ;; + 1) _jh_ttype="local" ;; + *) continue ;; + esac + (( ++_js )) ;; + 3) + printf "${DIM} Tip: 127.0.0.1 = local only${RESET}\n" >/dev/tty + printf "${DIM} 0.0.0.0 = allow LAN devices${RESET}\n" >/dev/tty + if [[ "$_jh_ttype" == "socks5" ]]; then + _wiz_read "Local SOCKS5 bind address" bind "${_wjh_prof[LOCAL_BIND_ADDR]:-127.0.0.1}" + else + _wiz_read "Local bind address" bind "${_wjh_prof[LOCAL_BIND_ADDR]:-127.0.0.1}" + fi + if _wiz_quit; then return 1; fi + if _wiz_back; then (( --_js )); continue; fi + if ! { validate_ip "$bind" || validate_ip6 "$bind" || [[ "$bind" == "localhost" ]] || [[ "$bind" == "*" ]]; }; then + printf " ${RED}Invalid bind address: ${bind}${RESET}\n" >/dev/tty; continue + fi + (( ++_js )) ;; + 4) + if [[ "$_jh_ttype" == "socks5" ]]; then + printf "${DIM} Tip: Common ports: 1080 (standard), 9050 (Tor-style). Avoid ports < 1024${RESET}\n" >/dev/tty + _wiz_read "Local SOCKS5 port" port "${_wjh_prof[LOCAL_PORT]:-1080}" + if _wiz_quit; then return 1; fi + if _wiz_back; then (( --_js )); continue; fi + if ! validate_port "$port"; then + printf " ${RED}Invalid port: ${port}${RESET}\n" >/dev/tty; continue + fi + if ! _check_port_conflict "$port"; then continue; fi + # Save SOCKS5 config + _wjh_prof[JUMP_HOSTS]="$jumps" + _wjh_prof[TUNNEL_TYPE]="socks5" + _wjh_prof[LOCAL_BIND_ADDR]="$bind" + _wjh_prof[LOCAL_PORT]="$port" + _wjh_prof[REMOTE_HOST]="" + _wjh_prof[REMOTE_PORT]="" + return 0 + else + printf "${DIM} Tip: The port you will connect to on THIS machine (e.g. 8080, 3306, 5432)${RESET}\n" >/dev/tty + _wiz_read "Local port" lport "${_wjh_prof[LOCAL_PORT]:-8080}" + if _wiz_quit; then return 1; fi + if _wiz_back; then (( --_js )); continue; fi + if ! validate_port "$lport"; then + printf " ${RED}Invalid port: ${lport}${RESET}\n" >/dev/tty; continue + fi + if ! _check_port_conflict "$lport"; then continue; fi + (( ++_js )) + fi ;; + 5) + printf "${DIM} Tip: The host the final SSH server connects to (127.0.0.1 = the target itself)${RESET}\n" >/dev/tty + _wiz_read "Remote target host" rhost "${_wjh_prof[REMOTE_HOST]:-127.0.0.1}" + if _wiz_quit; then return 1; fi + if _wiz_back; then (( --_js )); continue; fi + (( ++_js )) ;; + 6) + printf "${DIM} Tip: The port of the service on the remote side (e.g. 8080, 3306, 443)${RESET}\n" >/dev/tty + _wiz_read "Remote target port" rport "${_wjh_prof[REMOTE_PORT]:-8080}" + if _wiz_quit; then return 1; fi + if _wiz_back; then (( --_js )); continue; fi + if ! validate_port "$rport"; then + printf " ${RED}Invalid port: ${rport}${RESET}\n" >/dev/tty; continue + fi + # Save Local Forward config + _wjh_prof[JUMP_HOSTS]="$jumps" + _wjh_prof[TUNNEL_TYPE]="local" + _wjh_prof[LOCAL_BIND_ADDR]="$bind" + _wjh_prof[LOCAL_PORT]="$lport" + _wjh_prof[REMOTE_HOST]="$rhost" + _wjh_prof[REMOTE_PORT]="$rport" + return 0 ;; + esac + done +} + +# ── Main wizard flow ── + +wizard_create_profile() { + if [[ ! -t 0 ]] && [[ ! -e /dev/tty ]]; then + log_error "Interactive terminal required for wizard" + return 1 + fi + + _WIZ_NAV="" + local _step=1 + local name="" ssh_host="" ssh_port="" ssh_user="" ssh_password="" identity_key="" + local tunnel_type="" type_choice="" desc="" + local -A _new_profile=() + + while (( _step >= 1 )); do + case $_step in + + 1) # ── Profile name ── + show_banner >/dev/tty + _wiz_header "New Tunnel Profile" + printf "${DIM} A profile saves all settings for one tunnel connection.${RESET}\n" >/dev/tty + printf "${DIM} Tip: Use a short descriptive name (e.g. 'work-vpn', 'db-tunnel', 'home-proxy')${RESET}\n" >/dev/tty + _wiz_read "Profile name" name "" + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then log_info "Already at first step"; continue; fi + if [[ -z "$name" ]]; then + printf " ${RED}Name cannot be empty${RESET}\n" >/dev/tty; continue + fi + if ! validate_profile_name "$name"; then + printf " ${RED}Invalid name. Use letters, numbers, hyphens, underscores (max 64 chars)${RESET}\n" >/dev/tty; continue + fi + if [[ -f "$(_profile_path "$name")" ]]; then + printf " ${RED}Profile '%s' already exists${RESET}\n" "$name" >/dev/tty; continue + fi + (( ++_step )) ;; + + 2) # ── Tunnel type selection ── + _wiz_header "Choose Tunnel Type" + printf "${DIM} What type of tunnel do you need?${RESET}\n\n" >/dev/tty + printf " ${BOLD}SOCKS5 Proxy${RESET} ${DIM}Route all traffic through${RESET}\n" >/dev/tty + printf " ${DIM} the remote server (VPN-like)${RESET}\n\n" >/dev/tty + printf " ${BOLD}Local Forward${RESET} ${DIM}Access a remote service${RESET}\n" >/dev/tty + printf " ${DIM} on your local machine${RESET}\n\n" >/dev/tty + printf " ${BOLD}Remote Forward${RESET} ${DIM}Expose a local service${RESET}\n" >/dev/tty + printf " ${DIM} to the remote server${RESET}\n\n" >/dev/tty + printf " ${BOLD}Jump Host${RESET} ${DIM}Connect through relay${RESET}\n" >/dev/tty + printf " ${DIM} servers to the target${RESET}\n\n" >/dev/tty + local _wz_types=("SOCKS5 Proxy (-D)" "Local Port Forward (-L)" "Remote/Reverse Forward (-R)" "Jump Host / Multi-hop (-J)") + type_choice=$(_select_option "Select tunnel type" "${_wz_types[@]}") || true + # _select_option echoes "q"/"b" for nav (since _WIZ_NAV is lost in subshell) + if [[ "$type_choice" == "q" ]]; then log_info "Wizard cancelled"; return 0; fi + if [[ "$type_choice" == "b" ]]; then (( --_step )); continue; fi + case "$type_choice" in + 0) tunnel_type="socks5" ;; + 1) tunnel_type="local" ;; + 2) tunnel_type="remote" ;; + 3) tunnel_type="jump" ;; + *) continue ;; + esac + (( ++_step )) ;; + + 3) # ── SSH host ── + _wiz_header "SSH Connection Details" + printf "${DIM} Enter the details of the SSH server you want to connect to.${RESET}\n\n" >/dev/tty + case "$tunnel_type" in + socks5) printf "${DIM} Tip: This server will proxy your traffic${RESET}\n" >/dev/tty ;; + local) printf "${DIM} Tip: Server with the service you want to access${RESET}\n" >/dev/tty ;; + remote) printf "${DIM} Tip: Server where your local service will be exposed${RESET}\n" >/dev/tty ;; + jump) printf "${DIM} Tip: FINAL target server (jump hosts configured next)${RESET}\n" >/dev/tty ;; + esac + _wiz_read "SSH host (IP or hostname)" ssh_host "${ssh_host:-}" + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then (( --_step )); continue; fi + if [[ -z "$ssh_host" ]]; then + printf " ${RED}SSH host is required${RESET}\n" >/dev/tty; continue + fi + if ! validate_hostname "$ssh_host"; then + printf " ${RED}Invalid hostname: ${ssh_host}${RESET}\n" >/dev/tty; continue + fi + (( ++_step )) ;; + + 4) # ── SSH port ── + printf "${DIM} Tip: Default SSH port is 22. Change only if your server uses a custom port${RESET}\n" >/dev/tty + _wiz_read "SSH port" ssh_port "${ssh_port:-$(config_get SSH_DEFAULT_PORT 22)}" + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then (( --_step )); continue; fi + if ! validate_port "$ssh_port"; then + printf " ${RED}Invalid port: ${ssh_port}${RESET}\n" >/dev/tty; continue + fi + (( ++_step )) ;; + + 5) # ── SSH user ── + printf "${DIM} Tip: The username to log in with (e.g. root, ubuntu, admin)${RESET}\n" >/dev/tty + if [[ "$tunnel_type" == "jump" ]]; then + printf "${DIM} Note: This is the user on the FINAL target, not the jump host.${RESET}\n" >/dev/tty + fi + _wiz_read "SSH user" ssh_user "${ssh_user:-$(config_get SSH_DEFAULT_USER root)}" + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then (( --_step )); continue; fi + (( ++_step )) ;; + + 6) # ── SSH password ── + printf "${DIM} Tip: Enter your SSH password, or press Enter to skip if using SSH keys.${RESET}\n" >/dev/tty + printf "${DIM} The password is stored securely and used for automatic login.${RESET}\n" >/dev/tty + if [[ "$tunnel_type" == "jump" ]]; then + printf "${DIM} Note: This is for the FINAL target, not the jump host.${RESET}\n" >/dev/tty + fi + _read_secret_tty "SSH password (Enter to skip)" ssh_password "$ssh_password" || true + # No q/b detection for passwords — password could literally be "q" or "b" + (( ++_step )) ;; + + 7) # ── Identity key ── + printf "${DIM} Tip: Path to your SSH private key file (e.g. ~/.ssh/id_rsa, ~/.ssh/id_ed25519)${RESET}\n" >/dev/tty + printf "${DIM} Press Enter to skip if you entered a password above.${RESET}\n" >/dev/tty + if [[ "$tunnel_type" == "jump" ]]; then + printf "${DIM} Note: This key is for the FINAL target, not the jump host.${RESET}\n" >/dev/tty + fi + _wiz_read "Identity key path (optional)" identity_key "${identity_key:-$(config_get SSH_DEFAULT_KEY)}" + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then (( --_step )); continue; fi + if [[ -n "$identity_key" ]] && [[ ! -f "$identity_key" ]]; then + log_warn "Key file not found: ${identity_key}" + if ! _wiz_yn "Continue anyway?"; then + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then (( --_step )); continue; fi + continue # "no" → re-ask for key path + fi + fi + (( ++_step )) ;; + + 8) # ── Auth test ── + _wiz_header "Testing Authentication" + printf "${DIM} Verifying SSH connection to ${ssh_user}@${ssh_host}:${ssh_port}...${RESET}\n" >/dev/tty + if [[ "$tunnel_type" == "jump" ]]; then + printf "${DIM} Note: Direct test only — jump hosts are configured next.${RESET}\n" >/dev/tty + fi + if ! test_ssh_connection "$ssh_host" "$ssh_port" "$ssh_user" "$identity_key" "$ssh_password"; then + printf "\n${DIM} Common fixes: wrong password, wrong user,${RESET}\n" >/dev/tty + printf "${DIM} host unreachable, or SSH key not accepted.${RESET}\n" >/dev/tty + if [[ "$tunnel_type" == "jump" ]]; then + printf "${DIM} For jump hosts, this test may fail if the${RESET}\n" >/dev/tty + printf "${DIM} target is only reachable via relay servers.${RESET}\n" >/dev/tty + fi + printf "${DIM} 'no' takes you back to edit details.${RESET}\n\n" >/dev/tty + if ! _wiz_yn "Authentication failed. Continue anyway?"; then + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + _step=5; continue # back to user/password/key + fi + fi + (( ++_step )) ;; + + 9) # ── Type-specific sub-wizard ── + _new_profile=( + [PROFILE_NAME]="$name" + [TUNNEL_TYPE]="$tunnel_type" + [SSH_HOST]="$ssh_host" + [SSH_PORT]="$ssh_port" + [SSH_USER]="$ssh_user" + [SSH_PASSWORD]="$ssh_password" + [IDENTITY_KEY]="$identity_key" + [LOCAL_BIND_ADDR]="127.0.0.1" + [LOCAL_PORT]="" + [REMOTE_HOST]="" + [REMOTE_PORT]="" + [JUMP_HOSTS]="" + [SSH_OPTIONS]="" + [AUTOSSH_ENABLED]="$(config_get AUTOSSH_ENABLED true)" + [AUTOSSH_MONITOR_PORT]="0" + [DNS_LEAK_PROTECTION]="false" + [KILL_SWITCH]="false" + [AUTOSTART]="false" + [OBFS_MODE]="none" + [OBFS_PORT]="443" + [OBFS_LOCAL_PORT]="" + [OBFS_PSK]="" + [DESCRIPTION]="" + ) + local _sub_rc=0 + case "$tunnel_type" in + socks5) wizard_socks5 _new_profile || _sub_rc=$? ;; + local) wizard_local_forward _new_profile || _sub_rc=$? ;; + remote) wizard_remote_forward _new_profile || _sub_rc=$? ;; + jump) wizard_jump_host _new_profile || _sub_rc=$? ;; + esac + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back || (( _sub_rc == 2 )); then _step=2; continue; fi + if (( _sub_rc != 0 )); then return 1; fi + (( ++_step )) ;; + + 10) # ── Connection mode: Regular SSH or TLS encrypted ── + _wiz_header "Connection Mode" + printf "${DIM} Choose how your SSH connection reaches the server.${RESET}\n\n" >/dev/tty + printf " ${BOLD}1)${RESET} ${GREEN}Regular SSH${RESET} — standard SSH connection (default)\n" >/dev/tty + printf " ${DIM}Works everywhere SSH is not blocked.${RESET}\n" >/dev/tty + printf " ${BOLD}2)${RESET} ${CYAN}TLS Encrypted (stunnel)${RESET} — SSH wrapped in HTTPS\n" >/dev/tty + printf " ${DIM}Bypasses DPI firewalls (Iran, China, etc.)${RESET}\n" >/dev/tty + printf " ${DIM}Traffic looks like normal HTTPS on port 443.${RESET}\n\n" >/dev/tty + printf " Regular: TLS Encrypted:\n" >/dev/tty + printf " ┌──────┐ SSH:22 ┌──────┐ ┌──────┐ TLS:443 ┌────────┐\n" >/dev/tty + printf " │Client├────────┤Server│ │Client├─────────┤stunnel │\n" >/dev/tty + printf " └──────┘ └──────┘ └──────┘ HTTPS │→SSH :22│\n" >/dev/tty + printf " └────────┘\n" >/dev/tty + printf "\n" >/dev/tty + local _conn_mode="" + _wiz_read "Connection mode [1=Regular, 2=TLS]" _conn_mode "1" + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then _step=2; continue; fi + case "$_conn_mode" in + 2) + _new_profile[OBFS_MODE]="stunnel" + local _obfs_port="" + printf "\n${DIM} Port 443 mimics HTTPS (most effective).${RESET}\n" >/dev/tty + printf "${DIM} Only change if 443 is already in use on the server.${RESET}\n" >/dev/tty + _wiz_read "TLS port" _obfs_port "${_new_profile[OBFS_PORT]:-443}" + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then continue; fi + if ! validate_port "$_obfs_port"; then + printf " ${RED}Invalid port: ${_obfs_port}${RESET}\n" >/dev/tty + continue + fi + # Check if port is available on the remote server + printf "\n${DIM} Checking port ${_obfs_port} on ${ssh_host}...${RESET}\n" >/dev/tty + local _port_check_rc=0 + _obfs_remote_ssh _new_profile || _port_check_rc=1 + if (( _port_check_rc == 0 )); then + local _port_in_use="" + _port_in_use=$("${_OBFS_SSH_CMD[@]}" "ss -tln 2>/dev/null | grep -E ':${_obfs_port}[[:space:]]' | head -1" 2>/dev/null) || true + unset SSHPASS 2>/dev/null || true + if [[ -n "$_port_in_use" ]]; then + printf " ${YELLOW}Port ${_obfs_port} is in use on ${ssh_host}:${RESET}\n" >/dev/tty + printf " ${DIM}${_port_in_use}${RESET}\n" >/dev/tty + local _alt_port="8443" + if [[ "$_obfs_port" == "8443" ]]; then _alt_port="8444"; fi + printf " ${DIM}Suggested alternative: ${_alt_port}${RESET}\n\n" >/dev/tty + _wiz_read "TLS port (try ${_alt_port})" _obfs_port "$_alt_port" + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then continue; fi + if ! validate_port "$_obfs_port"; then + printf " ${RED}Invalid port: ${_obfs_port}${RESET}\n" >/dev/tty + continue + fi + else + printf " ${GREEN}Port ${_obfs_port} is available${RESET}\n" >/dev/tty + fi + else + unset SSHPASS 2>/dev/null || true + printf " ${DIM}Could not check (SSH failed) — will verify during setup${RESET}\n" >/dev/tty + fi + _new_profile[OBFS_PORT]="$_obfs_port" + + printf "\n${DIM} TunnelForge can install stunnel on your server automatically.${RESET}\n" >/dev/tty + printf "${DIM} This requires SSH access (using the credentials above).${RESET}\n" >/dev/tty + if _wiz_yn "Set up stunnel on server now?"; then + _obfs_setup_stunnel_direct _new_profile || true + fi + printf "\n" >/dev/tty + ;; + *) + _new_profile[OBFS_MODE]="none" + ;; + esac + (( ++_step )) ;; + + 11) # ── Inbound TLS protection (for VPS deployments) ── + _wiz_header "Inbound Protection" + printf "${DIM} If this server is a VPS (not your home PC), users connecting${RESET}\n" >/dev/tty + printf "${DIM} from their devices need TLS protection too — otherwise DPI${RESET}\n" >/dev/tty + printf "${DIM} can detect the SOCKS5 traffic entering the VPS.${RESET}\n\n" >/dev/tty + printf " User PC ──TLS+PSK──→ This VPS ──tunnel──→ Exit VPS ──→ Internet\n\n" >/dev/tty + printf " ${BOLD}1)${RESET} ${GREEN}No inbound protection${RESET} — direct SOCKS5 access (default)\n" >/dev/tty + printf " ${DIM}Fine for home server or trusted LAN.${RESET}\n" >/dev/tty + printf " ${BOLD}2)${RESET} ${CYAN}TLS + PSK inbound${RESET} — encrypted + authenticated access\n" >/dev/tty + printf " ${DIM}Users need stunnel client + pre-shared key to connect.${RESET}\n" >/dev/tty + printf " ${DIM}Recommended for VPS in censored networks.${RESET}\n\n" >/dev/tty + local _inbound_mode="" + _wiz_read "Inbound protection [1=None, 2=TLS+PSK]" _inbound_mode "1" + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then (( --_step )); continue; fi + case "$_inbound_mode" in + 2) + local _ol_port="" + printf "\n${DIM} Choose a port for client TLS connections.${RESET}\n" >/dev/tty + printf "${DIM} Use 443 or 8443 to look like HTTPS traffic.${RESET}\n" >/dev/tty + _wiz_read "Inbound TLS port" _ol_port "1443" + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then continue; fi + if ! validate_port "$_ol_port"; then + printf " ${RED}Invalid port: ${_ol_port}${RESET}\n" >/dev/tty + continue + fi + _new_profile[OBFS_LOCAL_PORT]="$_ol_port" + + # Auto-generate PSK + printf "\n${DIM} Generating pre-shared key...${RESET}\n" >/dev/tty + local _gen_psk="" + _gen_psk=$(_obfs_generate_psk) || true + if [[ -z "$_gen_psk" ]]; then + printf " ${RED}Failed to generate PSK${RESET}\n" >/dev/tty + continue + fi + _new_profile[OBFS_PSK]="$_gen_psk" + printf " ${GREEN}PSK generated${RESET} ${DIM}(will be shown after tunnel starts)${RESET}\n" >/dev/tty + + # Force 127.0.0.1 binding + _new_profile[LOCAL_BIND_ADDR]="127.0.0.1" + printf " ${DIM}Bind address forced to 127.0.0.1 (stunnel handles external access)${RESET}\n" >/dev/tty + printf "\n" >/dev/tty + ;; + *) + _new_profile[OBFS_LOCAL_PORT]="" + _new_profile[OBFS_PSK]="" + ;; + esac + (( ++_step )) ;; + + 12) # ── Optional settings ── + _wiz_header "Optional Settings" + printf "${DIM} Tip: A short note to help you remember${RESET}\n" >/dev/tty + printf "${DIM} e.g. 'MySQL access', 'browsing proxy'${RESET}\n" >/dev/tty + _wiz_read "Description (optional)" desc "" + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + # Go back to tunnel type selection (step 9 reinits _new_profile, so skip it) + if _wiz_back; then _step=2; continue; fi + _new_profile[DESCRIPTION]="$desc" + + printf "\n${DIM} AutoSSH auto-reconnects if the tunnel drops.${RESET}\n" >/dev/tty + printf "${DIM} Recommended for long-running tunnels.${RESET}\n" >/dev/tty + if _wiz_yn "Enable AutoSSH reconnection?" "y"; then + _new_profile[AUTOSSH_ENABLED]="true" + else + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then continue; fi + _new_profile[AUTOSSH_ENABLED]="false" + fi + + printf "\n${DIM} Creates a systemd service so this tunnel${RESET}\n" >/dev/tty + printf "${DIM} starts automatically on boot.${RESET}\n" >/dev/tty + if _wiz_yn "Auto-start on system boot?"; then + _new_profile[AUTOSTART]="true" + else + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then continue; fi + fi + (( ++_step )) ;; + + 13) # ── Summary + save ── + printf "\n${BOLD_CYAN}── Profile Summary ──${RESET}\n\n" >/dev/tty + printf " ${BOLD}Name:${RESET} %s\n" "$name" >/dev/tty + printf " ${BOLD}Type:${RESET} %s\n" "${_new_profile[TUNNEL_TYPE]^^}" >/dev/tty + printf " ${BOLD}SSH Host:${RESET} %s@%s:%s\n" "$ssh_user" "$ssh_host" "$ssh_port" >/dev/tty + if [[ -n "$ssh_password" ]]; then + printf " ${BOLD}Password:${RESET} ****\n" >/dev/tty + fi + if [[ -n "$identity_key" ]]; then + printf " ${BOLD}Key:${RESET} %s\n" "$identity_key" >/dev/tty + fi + if [[ -n "${_new_profile[LOCAL_PORT]}" ]]; then + printf " ${BOLD}Local:${RESET} %s:%s\n" "${_new_profile[LOCAL_BIND_ADDR]}" "${_new_profile[LOCAL_PORT]}" >/dev/tty + fi + if [[ -n "${_new_profile[REMOTE_HOST]}" ]]; then + printf " ${BOLD}Remote:${RESET} %s:%s\n" "${_new_profile[REMOTE_HOST]}" "${_new_profile[REMOTE_PORT]}" >/dev/tty + fi + if [[ -n "${_new_profile[JUMP_HOSTS]}" ]]; then + printf " ${BOLD}Jump Hosts:${RESET} %s\n" "${_new_profile[JUMP_HOSTS]}" >/dev/tty + fi + if [[ "${_new_profile[OBFS_MODE]:-none}" != "none" ]]; then + printf " ${BOLD}Obfuscation:${RESET} %s (port %s)\n" "${_new_profile[OBFS_MODE]}" "${_new_profile[OBFS_PORT]}" >/dev/tty + fi + if [[ -n "${_new_profile[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_new_profile[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then + printf " ${BOLD}Inbound TLS:${RESET} port %s (PSK protected)\n" "${_new_profile[OBFS_LOCAL_PORT]}" >/dev/tty + fi + printf " ${BOLD}AutoSSH:${RESET} %s\n" "${_new_profile[AUTOSSH_ENABLED]}" >/dev/tty + printf " ${BOLD}Autostart:${RESET} %s\n" "${_new_profile[AUTOSTART]}" >/dev/tty + if [[ -n "$desc" ]]; then + printf " ${BOLD}Description:${RESET} %s\n" "$desc" >/dev/tty + fi + printf "\n" >/dev/tty + + if ! _wiz_yn "Save this profile?" "y"; then + if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi + if _wiz_back; then (( --_step )); continue; fi + log_info "Profile creation cancelled" + return 0 + fi + + local _wz_pfile + _wz_pfile=$(_profile_path "$name") + if ! _save_profile_data "$_wz_pfile" _new_profile; then + log_error "Failed to save profile '${name}'" + return 1 + fi + log_success "Profile '${name}' created successfully" + + printf "\n${DIM} You can start/stop tunnels from the main menu.${RESET}\n" >/dev/tty + if _read_yn "Start tunnel now?"; then + start_tunnel "$name" || true + # Show "What's Next?" guide based on tunnel type + local _wn_bind="${_new_profile[LOCAL_BIND_ADDR]}" + local _wn_lport="${_new_profile[LOCAL_PORT]}" + local _wn_rhost="${_new_profile[REMOTE_HOST]}" + local _wn_rport="${_new_profile[REMOTE_PORT]}" + local _wn_shost="${_new_profile[SSH_HOST]}" + printf "\n${BOLD_CYAN}── What's Next? ──${RESET}\n\n" >/dev/tty + case "${_new_profile[TUNNEL_TYPE]}" in + socks5) + printf " ${BOLD}Configure your apps to use the proxy:${RESET}\n\n" >/dev/tty + printf " ${DIM}Browser (Firefox):${RESET}\n" >/dev/tty + printf " Settings → Proxy → Manual config\n" >/dev/tty + printf " SOCKS Host: ${BOLD}%s${RESET} Port: ${BOLD}%s${RESET}\n" "$_wn_bind" "$_wn_lport" >/dev/tty + printf " Select SOCKS v5, enable Proxy DNS\n\n" >/dev/tty + printf " ${DIM}Test from command line:${RESET}\n" >/dev/tty + printf " curl --socks5-hostname %s:%s https://ifconfig.me\n\n" "$_wn_bind" "$_wn_lport" >/dev/tty + if [[ "$_wn_bind" == "0.0.0.0" ]]; then + printf " ${DIM}From other devices on your LAN, use${RESET}\n" >/dev/tty + printf " ${DIM}this machine's IP instead of 0.0.0.0${RESET}\n\n" >/dev/tty + fi ;; + local) + printf " ${BOLD}Access the remote service locally:${RESET}\n\n" >/dev/tty + printf " ${DIM}Open in browser or connect to:${RESET}\n" >/dev/tty + printf " http://%s:%s\n\n" "$_wn_bind" "$_wn_lport" >/dev/tty + printf " ${DIM}This reaches %s:%s on the remote side${RESET}\n" "$_wn_rhost" "$_wn_rport" >/dev/tty + printf " ${DIM}through the SSH tunnel.${RESET}\n\n" >/dev/tty + if [[ "$_wn_bind" == "0.0.0.0" ]]; then + printf " ${DIM}From other devices on your LAN, use${RESET}\n" >/dev/tty + printf " ${DIM}this machine's IP instead of 0.0.0.0${RESET}\n\n" >/dev/tty + fi ;; + remote) + printf " ${BOLD}Before using this tunnel:${RESET}\n\n" >/dev/tty + printf " ${DIM}1. Make sure a service is running on${RESET}\n" >/dev/tty + printf " ${BOLD}%s:%s${RESET} (this machine)\n\n" "$_wn_rhost" "$_wn_lport" >/dev/tty + printf " ${DIM}2. The service is now reachable at:${RESET}\n" >/dev/tty + printf " ${BOLD}%s:%s${RESET} (on the SSH server)\n\n" "$_wn_bind" "$_wn_rport" >/dev/tty + printf " ${DIM}Test from the SSH server:${RESET}\n" >/dev/tty + printf " curl http://localhost:%s\n\n" "$_wn_rport" >/dev/tty ;; + jump) + printf " ${DIM}Same as the tunnel type you chose${RESET}\n" >/dev/tty + printf " ${DIM}(SOCKS5 or Local Forward) but routed${RESET}\n" >/dev/tty + printf " ${DIM}through the jump host(s).${RESET}\n\n" >/dev/tty ;; + esac + fi + return 0 ;; + + esac + done +} + +setup_wizard() { + wizard_create_profile || true +} + +# ============================================================================ +# INTERACTIVE MENUS (Phase 2) +# ============================================================================ + +# Clear screen and show banner for menus +_menu_header() { + local title="${1:-}" + clear >/dev/tty 2>/dev/null || true + show_banner >/dev/tty + if [[ -n "$title" ]]; then + printf " ${BOLD_CYAN}%s${RESET}\n" "$title" >/dev/tty + local _mh_sep="" + for (( _mhi=0; _mhi<60; _mhi++ )); do _mh_sep+="─"; done + printf " %s\n\n" "$_mh_sep" >/dev/tty + fi +} + +# ── Settings menu ── + +show_settings_menu() { + while true; do + _menu_header "Settings" + + printf " ${BOLD}Current Defaults:${RESET}\n\n" >/dev/tty + printf " ${CYAN}1${RESET}) SSH User : ${BOLD}%s${RESET}\n" "$(config_get SSH_DEFAULT_USER root)" >/dev/tty + printf " ${CYAN}2${RESET}) SSH Port : ${BOLD}%s${RESET}\n" "$(config_get SSH_DEFAULT_PORT 22)" >/dev/tty + printf " ${CYAN}3${RESET}) SSH Key : ${BOLD}%s${RESET}\n" "$(config_get SSH_DEFAULT_KEY '(none)')" >/dev/tty + printf " ${CYAN}4${RESET}) Connect Timeout : ${BOLD}%s${RESET}s\n" "$(config_get SSH_CONNECT_TIMEOUT 10)" >/dev/tty + printf " ${CYAN}5${RESET}) AutoSSH Enabled : ${BOLD}%s${RESET}\n" "$(config_get AUTOSSH_ENABLED true)" >/dev/tty + printf " ${CYAN}6${RESET}) AutoSSH Poll : ${BOLD}%s${RESET}s\n" "$(config_get AUTOSSH_POLL 30)" >/dev/tty + printf " ${CYAN}7${RESET}) ControlMaster : ${BOLD}%s${RESET}\n" "$(config_get CONTROLMASTER_ENABLED false)" >/dev/tty + printf " ${CYAN}8${RESET}) Log Level : ${BOLD}%s${RESET}\n" "$(config_get LOG_LEVEL info)" >/dev/tty + printf " ${CYAN}9${RESET}) Dashboard Refresh : ${BOLD}%s${RESET}s\n" "$(config_get DASHBOARD_REFRESH 5)" >/dev/tty + printf "\n ${YELLOW}0${RESET}) Back\n\n" >/dev/tty + + local _sm_choice + printf " ${BOLD}Select [0-9]${RESET}: " >/dev/tty + read -rsn1 _sm_choice /dev/tty + + case "$_sm_choice" in + 1) local val; _read_tty " SSH default user" val "$(config_get SSH_DEFAULT_USER root)" || true + if [[ -n "$val" ]]; then + config_set "SSH_DEFAULT_USER" "$val"; save_settings || true + else + log_error "User cannot be empty"; _press_any_key + fi ;; + 2) local val; _read_tty " SSH default port" val "$(config_get SSH_DEFAULT_PORT 22)" || true + if validate_port "$val"; then + config_set "SSH_DEFAULT_PORT" "$val"; save_settings || true + else + log_error "Invalid port"; _press_any_key + fi ;; + 3) local val; _read_tty " SSH default key path" val "$(config_get SSH_DEFAULT_KEY)" || true + config_set "SSH_DEFAULT_KEY" "$val"; save_settings || true ;; + 4) local val; _read_tty " Connect timeout (seconds)" val "$(config_get SSH_CONNECT_TIMEOUT 10)" || true + if [[ "$val" =~ ^[0-9]+$ ]] && (( val >= 1 )); then + config_set "SSH_CONNECT_TIMEOUT" "$val"; save_settings || true + else + log_error "Must be a positive number"; _press_any_key + fi ;; + 5) local cur; cur=$(config_get AUTOSSH_ENABLED true) + if [[ "$cur" == "true" ]]; then + config_set "AUTOSSH_ENABLED" "false" + log_success "AutoSSH disabled" + else + config_set "AUTOSSH_ENABLED" "true" + log_success "AutoSSH enabled" + fi + save_settings || true ;; + 6) local val; _read_tty " AutoSSH poll interval (seconds)" val "$(config_get AUTOSSH_POLL 30)" || true + if [[ "$val" =~ ^[0-9]+$ ]] && (( val >= 1 )); then + config_set "AUTOSSH_POLL" "$val"; save_settings || true + else + log_error "Must be a positive number"; _press_any_key + fi ;; + 7) local cur; cur=$(config_get CONTROLMASTER_ENABLED false) + if [[ "$cur" == "true" ]]; then + config_set "CONTROLMASTER_ENABLED" "false" + log_success "ControlMaster disabled" + else + config_set "CONTROLMASTER_ENABLED" "true" + log_success "ControlMaster enabled" + fi + save_settings || true ;; + 8) local _ll_opts=("debug" "info" "warn" "error") + local _ll_idx + if _ll_idx=$(_select_option " Log level" "${_ll_opts[@]}"); then + config_set "LOG_LEVEL" "${_ll_opts[$_ll_idx]}" + save_settings || true + fi ;; + 9) local val; _read_tty " Dashboard refresh rate (seconds)" val "$(config_get DASHBOARD_REFRESH 5)" || true + if [[ "$val" =~ ^[0-9]+$ ]] && (( val >= 1 )); then + config_set "DASHBOARD_REFRESH" "$val"; save_settings || true + else + log_error "Must be a positive number"; _press_any_key + fi ;; + 0|q) return 0 ;; + *) true ;; + esac + done +} + +# ── Edit profile sub-menu ── + +_edit_profile_menu() { + local _ep_name="$1" + local -A _eprof + load_profile "$_ep_name" _eprof || { log_error "Cannot load profile"; return 1; } + # Snapshot original values for dirty-check on discard + local -A _eprof_orig + local _ep_fld + for _ep_fld in "${!_eprof[@]}"; do _eprof_orig[$_ep_fld]="${_eprof[$_ep_fld]}"; done + + while true; do + _menu_header "Edit Profile: ${_ep_name}" + + printf " ${CYAN}1${RESET}) SSH Host : ${BOLD}%s${RESET}\n" "${_eprof[SSH_HOST]:-}" >/dev/tty + printf " ${CYAN}2${RESET}) SSH Port : ${BOLD}%s${RESET}\n" "${_eprof[SSH_PORT]:-22}" >/dev/tty + printf " ${CYAN}3${RESET}) SSH User : ${BOLD}%s${RESET}\n" "${_eprof[SSH_USER]:-}" >/dev/tty + printf " ${CYAN}4${RESET}) Identity Key : ${BOLD}%s${RESET}\n" "${_eprof[IDENTITY_KEY]:-none}" >/dev/tty + printf " ${CYAN}5${RESET}) Local Bind : ${BOLD}%s:%s${RESET}\n" "${_eprof[LOCAL_BIND_ADDR]:-}" "${_eprof[LOCAL_PORT]:-}" >/dev/tty + printf " ${CYAN}6${RESET}) Remote Target : ${BOLD}%s:%s${RESET}\n" "${_eprof[REMOTE_HOST]:-}" "${_eprof[REMOTE_PORT]:-}" >/dev/tty + printf " ${CYAN}7${RESET}) AutoSSH : ${BOLD}%s${RESET}\n" "${_eprof[AUTOSSH_ENABLED]:-true}" >/dev/tty + printf " ${CYAN}8${RESET}) Autostart : ${BOLD}%s${RESET}\n" "${_eprof[AUTOSTART]:-false}" >/dev/tty + printf " ${CYAN}9${RESET}) Description : ${BOLD}%s${RESET}\n" "${_eprof[DESCRIPTION]:-}" >/dev/tty + printf "\n" >/dev/tty + printf " ${CYAN}a${RESET}) Kill Switch : ${BOLD}%s${RESET}\n" "${_eprof[KILL_SWITCH]:-false}" >/dev/tty + printf " ${CYAN}b${RESET}) DNS Leak Prot. : ${BOLD}%s${RESET}\n" "${_eprof[DNS_LEAK_PROTECTION]:-false}" >/dev/tty + printf " ${CYAN}c${RESET}) TLS Obfuscation : ${BOLD}%s${RESET}\n" "${_eprof[OBFS_MODE]:-none}" >/dev/tty + printf " ${CYAN}d${RESET}) TLS Port : ${BOLD}%s${RESET}\n" "${_eprof[OBFS_PORT]:-443}" >/dev/tty + printf " ${CYAN}e${RESET}) Jump Hosts : ${BOLD}%s${RESET}\n" "${_eprof[JUMP_HOSTS]:-none}" >/dev/tty + printf "\n ${GREEN}s${RESET}) Save changes\n" >/dev/tty + printf " ${YELLOW}0${RESET}) Back (discard)\n\n" >/dev/tty + + local _ep_choice + printf " ${BOLD}Select${RESET}: " >/dev/tty + read -rsn1 _ep_choice /dev/tty + + case "$_ep_choice" in + 1) local val; _read_tty " SSH host" val "${_eprof[SSH_HOST]:-}" || true + if validate_hostname "$val" || validate_ip "$val"; then + _eprof[SSH_HOST]="$val" + else + log_error "Invalid hostname or IP"; _press_any_key + fi ;; + 2) local val; _read_tty " SSH port" val "${_eprof[SSH_PORT]:-22}" || true + if validate_port "$val"; then + _eprof[SSH_PORT]="$val" + else + log_error "Invalid port"; _press_any_key + fi ;; + 3) local val; _read_tty " SSH user" val "${_eprof[SSH_USER]:-}" || true + if [[ -n "$val" ]] && [[ "$val" =~ ^[a-zA-Z0-9._@-]+$ ]]; then + _eprof[SSH_USER]="$val" + elif [[ -z "$val" ]]; then + log_warn "SSH user cleared — will use default ($(config_get SSH_DEFAULT_USER root))" + _eprof[SSH_USER]="" + else + log_error "Invalid SSH user"; _press_any_key + fi ;; + 4) local val; _read_tty " Identity key path" val "${_eprof[IDENTITY_KEY]:-}" || true + _eprof[IDENTITY_KEY]="$val" ;; + 5) local bval pval + _read_tty " Bind address" bval "${_eprof[LOCAL_BIND_ADDR]:-127.0.0.1}" || true + _read_tty " Local port" pval "${_eprof[LOCAL_PORT]:-}" || true + if ! { validate_ip "$bval" || validate_ip6 "$bval" || [[ "$bval" == "localhost" ]] || [[ "$bval" == "*" ]]; }; then + log_error "Invalid bind address — changes discarded"; _press_any_key + elif ! validate_port "$pval"; then + log_error "Invalid port — changes discarded"; _press_any_key + else + _eprof[LOCAL_BIND_ADDR]="$bval" + _eprof[LOCAL_PORT]="$pval" + fi ;; + 6) local hval pval + _read_tty " Remote host" hval "${_eprof[REMOTE_HOST]:-}" || true + _read_tty " Remote port" pval "${_eprof[REMOTE_PORT]:-}" || true + if [[ -z "$pval" ]] && [[ "${_eprof[TUNNEL_TYPE]:-}" == "socks5" ]]; then + # SOCKS5 doesn't use remote host/port — allow clearing + _eprof[REMOTE_HOST]="" + _eprof[REMOTE_PORT]="" + elif [[ -n "$pval" ]] && validate_port "$pval"; then + _eprof[REMOTE_HOST]="$hval" + _eprof[REMOTE_PORT]="$pval" + else + log_error "Invalid port — changes discarded"; _press_any_key + fi ;; + 7) if [[ "${_eprof[AUTOSSH_ENABLED]:-true}" == "true" ]]; then + _eprof[AUTOSSH_ENABLED]="false" + else + _eprof[AUTOSSH_ENABLED]="true" + fi ;; + 8) if [[ "${_eprof[AUTOSTART]:-false}" == "true" ]]; then + _eprof[AUTOSTART]="false" + else + _eprof[AUTOSTART]="true" + fi ;; + 9) local val; _read_tty " Description" val "${_eprof[DESCRIPTION]:-}" || true + _eprof[DESCRIPTION]="$val" ;; + a|A) if [[ "${_eprof[KILL_SWITCH]:-false}" == "true" ]]; then + _eprof[KILL_SWITCH]="false" + log_info "Kill switch disabled" + else + _eprof[KILL_SWITCH]="true" + log_warn "Kill switch enabled — blocks traffic if tunnel drops (requires root)" + fi ;; + b|B) if [[ "${_eprof[DNS_LEAK_PROTECTION]:-false}" == "true" ]]; then + _eprof[DNS_LEAK_PROTECTION]="false" + log_info "DNS leak protection disabled" + else + _eprof[DNS_LEAK_PROTECTION]="true" + log_warn "DNS leak protection enabled — rewrites resolv.conf (requires root)" + fi ;; + c|C) if [[ "${_eprof[OBFS_MODE]:-none}" == "none" ]]; then + _eprof[OBFS_MODE]="stunnel" + log_info "TLS obfuscation enabled (stunnel)" + else + _eprof[OBFS_MODE]="none" + log_info "TLS obfuscation disabled" + fi ;; + d|D) local val; _read_tty " TLS obfuscation port" val "${_eprof[OBFS_PORT]:-443}" || true + if validate_port "$val"; then + _eprof[OBFS_PORT]="$val" + else + log_error "Invalid port"; _press_any_key + fi ;; + e|E) local val; _read_tty " Jump hosts (user@host:port or blank)" val "${_eprof[JUMP_HOSTS]:-}" || true + _eprof[JUMP_HOSTS]="$val" ;; + s|S) + if save_profile "$_ep_name" _eprof; then + log_success "Profile '${_ep_name}' saved" + else + log_error "Failed to save profile '${_ep_name}'" + fi + _press_any_key + return 0 ;; + 0|q) + # Check if any field was modified + local _ep_dirty=false _ep_ck + for _ep_ck in "${!_eprof[@]}"; do + if [[ "${_eprof[$_ep_ck]}" != "${_eprof_orig[$_ep_ck]:-}" ]]; then + _ep_dirty=true; break + fi + done + if [[ "$_ep_dirty" == true ]]; then + if confirm_action "Discard unsaved changes?"; then + return 0 + fi + else + return 0 + fi ;; + *) true ;; + esac + done +} + +# ── Profile management menu ── + +show_profiles_menu() { + while true; do + _menu_header "Profile Management" + + local _pm_profiles + _pm_profiles=$(list_profiles) + + local _pm_names=() + if [[ -z "$_pm_profiles" ]]; then + printf " ${DIM}No profiles configured.${RESET}\n\n" >/dev/tty + else + printf " ${BOLD}%-4s %-18s %-8s %-12s %-22s${RESET}\n" \ + "#" "NAME" "TYPE" "STATUS" "LOCAL" >/dev/tty + local _pm_sep="" + for (( _pmi=0; _pmi<66; _pmi++ )); do _pm_sep+="─"; done + printf " %s\n" "$_pm_sep" >/dev/tty + + local _pm_idx=0 + while IFS= read -r _pm_name; do + [[ -z "$_pm_name" ]] && continue + (( ++_pm_idx )) + _pm_names+=("$_pm_name") + local _pm_type _pm_status _pm_local + _pm_type=$(get_profile_field "$_pm_name" "TUNNEL_TYPE" 2>/dev/null) || true + _pm_local="$(get_profile_field "$_pm_name" "LOCAL_BIND_ADDR" 2>/dev/null || true):$(get_profile_field "$_pm_name" "LOCAL_PORT" 2>/dev/null || true)" + if is_tunnel_running "$_pm_name"; then + _pm_status="${GREEN}● running ${RESET}" + else + _pm_status="${DIM}■ stopped ${RESET}" + fi + printf " ${CYAN}%-4s${RESET} %-18s %-8s %b%-22s\n" \ + "${_pm_idx}" "$_pm_name" "${_pm_type:-?}" "$_pm_status" "$_pm_local" >/dev/tty + done <<< "$_pm_profiles" + fi + + printf "\n" >/dev/tty + printf " ${CYAN}c${RESET}) Create new profile\n" >/dev/tty + printf " ${CYAN}d${RESET}) Delete a profile\n" >/dev/tty + printf " ${CYAN}e${RESET}) Edit a profile\n" >/dev/tty + printf " ${YELLOW}0${RESET}) Back\n\n" >/dev/tty + + local _pm_choice + printf " ${BOLD}Select${RESET}: " >/dev/tty + read -rsn1 _pm_choice /dev/tty + + case "$_pm_choice" in + c|C) wizard_create_profile || true; _press_any_key ;; + d|D) + local _pm_dinput _pm_dname + _read_tty " Profile # or name to delete" _pm_dinput "" || true + if [[ "$_pm_dinput" =~ ^[0-9]+$ ]] && (( _pm_dinput >= 1 && _pm_dinput <= ${#_pm_names[@]} )); then + _pm_dname="${_pm_names[$((_pm_dinput-1))]}" + else + _pm_dname="$_pm_dinput" + fi + if [[ -n "$_pm_dname" ]] && validate_profile_name "$_pm_dname"; then + if confirm_action "Delete profile '${_pm_dname}'?"; then + delete_profile "$_pm_dname" || true + fi + fi + _press_any_key ;; + e|E) + local _pm_einput _pm_ename + _read_tty " Profile # or name to edit" _pm_einput "" || true + if [[ "$_pm_einput" =~ ^[0-9]+$ ]] && (( _pm_einput >= 1 && _pm_einput <= ${#_pm_names[@]} )); then + _pm_ename="${_pm_names[$((_pm_einput-1))]}" + else + _pm_ename="$_pm_einput" + fi + if [[ -n "$_pm_ename" ]] && validate_profile_name "$_pm_ename" && [[ -f "$(_profile_path "$_pm_ename")" ]]; then + _edit_profile_menu "$_pm_ename" || true + else + log_error "Profile not found: ${_pm_ename}" + _press_any_key + fi ;; + 0|q) return 0 ;; + *) true ;; + esac + done +} + +# ── About screen ── + +show_about() { + _menu_header "" + local _ab_os _ab_w=58 + _ab_os="$(uname -s 2>/dev/null || echo unknown) $(uname -m 2>/dev/null || echo unknown)" + local _ab_max_os=$(( _ab_w - 2 - 5 - 11 )) + if (( ${#_ab_os} > _ab_max_os )); then _ab_os="${_ab_os:0:_ab_max_os}"; fi + + local _ab_border="" _abi + for (( _abi=0; _abi < _ab_w - 2; _abi++ )); do _ab_border+="═"; done + + printf "\n ${BOLD_CYAN}╔%s╗${RESET}\n" "$_ab_border" >/dev/tty + printf " ${BOLD_CYAN}║%-*s║${RESET}\n" "$((_ab_w - 2))" "" >/dev/tty + printf " ${BOLD_CYAN}║${RESET} ${BOLD_WHITE}TunnelForge${RESET} — SSH Tunnel Manager%*s${BOLD_CYAN}║${RESET}\n" \ + "$((_ab_w - 38))" "" >/dev/tty + printf " ${BOLD_CYAN}║%-*s║${RESET}\n" "$((_ab_w - 2))" "" >/dev/tty + printf " ${BOLD_CYAN}║${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}║${RESET}\n" "$((_ab_w - 5))" "Version : ${VERSION}" >/dev/tty + printf " ${BOLD_CYAN}║${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}║${RESET}\n" "$((_ab_w - 5))" "Author : SamNet Technologies, LLC" >/dev/tty + printf " ${BOLD_CYAN}║${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}║${RESET}\n" "$((_ab_w - 5))" "License : GPL v3.0" >/dev/tty + printf " ${BOLD_CYAN}║${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}║${RESET}\n" "$((_ab_w - 5))" "Platform : ${_ab_os}" >/dev/tty + printf " ${BOLD_CYAN}║${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}║${RESET}\n" "$((_ab_w - 5))" "Repo : git.samnet.dev/SamNet-dev/tunnelforge" >/dev/tty + printf " ${BOLD_CYAN}║%-*s║${RESET}\n" "$((_ab_w - 2))" "" >/dev/tty + printf " ${BOLD_CYAN}║${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}║${RESET}\n" "$((_ab_w - 5))" "A single-file SSH tunnel manager with TUI menu," >/dev/tty + printf " ${BOLD_CYAN}║${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}║${RESET}\n" "$((_ab_w - 5))" "live dashboard, DNS leak protection, kill switch," >/dev/tty + printf " ${BOLD_CYAN}║${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}║${RESET}\n" "$((_ab_w - 5))" "server hardening, and Telegram notifications." >/dev/tty + printf " ${BOLD_CYAN}║%-*s║${RESET}\n" "$((_ab_w - 2))" "" >/dev/tty + printf " ${BOLD_CYAN}║${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}║${RESET}\n" "$((_ab_w - 5))" "This program is free software under the GNU GPL" >/dev/tty + printf " ${BOLD_CYAN}║${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}║${RESET}\n" "$((_ab_w - 5))" "v3. See LICENSE file or gnu.org/licenses/gpl-3.0" >/dev/tty + printf " ${BOLD_CYAN}║%-*s║${RESET}\n" "$((_ab_w - 2))" "" >/dev/tty + printf " ${BOLD_CYAN}╚%s╝${RESET}\n\n" "$_ab_border" >/dev/tty + _press_any_key +} + +# ── Learn: SSH tunnel explanations ── + +show_learn_menu() { + while true; do + _menu_header "Learn: SSH Tunnels" + + printf " ${BOLD}── SSH Fundamentals ──${RESET}\n" >/dev/tty + printf " ${CYAN}1${RESET}) What is an SSH Tunnel?\n" >/dev/tty + printf " ${CYAN}2${RESET}) SOCKS5 Dynamic Proxy (-D)\n" >/dev/tty + printf " ${CYAN}3${RESET}) Local Port Forwarding (-L)\n" >/dev/tty + printf " ${CYAN}4${RESET}) Remote/Reverse Forwarding (-R)\n" >/dev/tty + printf " ${CYAN}5${RESET}) Jump Hosts & Multi-hop (-J)\n" >/dev/tty + printf " ${CYAN}6${RESET}) ControlMaster Multiplexing\n" >/dev/tty + printf " ${CYAN}7${RESET}) AutoSSH & Reconnection\n" >/dev/tty + printf "\n ${BOLD}── TLS Obfuscation ──${RESET}\n" >/dev/tty + printf " ${CYAN}8${RESET}) What is TLS Obfuscation?\n" >/dev/tty + printf " ${CYAN}9${RESET}) PSK Authentication\n" >/dev/tty + printf "\n ${BOLD}── Clients ──${RESET}\n" >/dev/tty + printf " ${CYAN}m${RESET}) Mobile Client Connection\n" >/dev/tty + printf "\n ${YELLOW}0${RESET}) Back\n\n" >/dev/tty + + local _lm_choice + printf " ${BOLD}Select${RESET}: " >/dev/tty + read -rsn1 _lm_choice /dev/tty + + case "$_lm_choice" in + 1) _learn_what_is_tunnel || true ;; + 2) _learn_socks5 || true ;; + 3) _learn_local_forward || true ;; + 4) _learn_remote_forward || true ;; + 5) _learn_jump_host || true ;; + 6) _learn_controlmaster || true ;; + 7) _learn_autossh || true ;; + 8) _learn_tls_obfuscation || true ;; + 9) _learn_psk_auth || true ;; + m|M) _learn_mobile_client || true ;; + 0|q) return 0 ;; + *) true ;; + esac + done +} + +_learn_what_is_tunnel() { + _menu_header "What is an SSH Tunnel?" + cat >/dev/tty <<'EOF' + + An SSH tunnel creates an encrypted channel between your local + machine and a remote server. Network traffic is forwarded through + this encrypted tunnel, protecting it from eavesdropping. + + ┌──────────────────────────────────────────────────────────────┐ + │ │ + │ ┌────────┐ Encrypted SSH Tunnel ┌────────────┐ │ + │ │ Your │ ══════════════════════════ │ Remote │ │ + │ │Machine │ (port 22) │ Server │ │ + │ └────────┘ └────────────┘ │ + │ │ + │ All traffic inside the tunnel is encrypted and secure. │ + │ │ + └──────────────────────────────────────────────────────────────┘ + + Common use cases: + • Bypass firewalls and NAT + • Secure access to remote services + • Create encrypted SOCKS proxies + • Expose local services to the internet + +EOF + _press_any_key +} + +_learn_socks5() { + _menu_header "SOCKS5 Dynamic Proxy (-D)" + cat >/dev/tty <<'EOF' + + A SOCKS5 proxy creates a dynamic forwarding tunnel. Any application + configured to use the SOCKS proxy will route traffic through the + SSH server. + + ┌──────────┐ ┌─────────────┐ ┌───────────┐ + │ Browser │────>│ SSH Server │────>│ Website │ + │ :1080 │ │ (proxy) │ │ │ + └──────────┘ └─────────────┘ └───────────┘ + + Command: ssh -D 1080 user@server + + Configure your browser/app to use: + SOCKS5 proxy: 127.0.0.1:1080 + + Benefits: + • Route ALL TCP traffic through the tunnel + • Appears to browse from the server's IP + • Supports DNS resolution through proxy + • Works with any SOCKS5-aware application + +EOF + _press_any_key +} + +_learn_local_forward() { + _menu_header "Local Port Forwarding (-L)" + cat >/dev/tty <<'EOF' + + Local forwarding maps a port on your local machine to a port on + a remote machine, through the SSH server. + + ┌──────────┐ ┌─────────────┐ ┌───────────┐ + │ Local │────>│ SSH Server │────>│ Target │ + │ :8080 │ │ (relay) │ │ :80 │ + └──────────┘ └─────────────┘ └───────────┘ + + Command: ssh -L 8080:target:80 user@server + + Now http://localhost:8080 → target:80 via SSH server + + Use cases: + • Access a database behind a firewall + • Reach internal web apps securely + • Connect to services on a private network + +EOF + _press_any_key +} + +_learn_remote_forward() { + _menu_header "Remote/Reverse Forwarding (-R)" + cat >/dev/tty <<'EOF' + + Remote forwarding exposes a local service on the remote SSH server. + Users connecting to the server's port reach your local machine. + + ┌──────────┐ ┌─────────────┐ ┌───────────┐ + │ Local │<────│ SSH Server │<────│ Users │ + │ :3000 │ │ :9090 │ │ │ + └──────────┘ └─────────────┘ └───────────┘ + + Command: ssh -R 9090:localhost:3000 user@server + + Now server:9090 → your localhost:3000 + + Use cases: + • Expose local dev server to the internet + • Webhook development & testing + • Remote access to services behind NAT + • Demo local apps to clients + +EOF + _press_any_key +} + +_learn_jump_host() { + _menu_header "Jump Hosts & Multi-hop (-J)" + cat >/dev/tty <<'EOF' + + Jump hosts let you reach a target through one or more intermediate + SSH servers. Useful when the target is not directly accessible. + + ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ + │ Local │───>│ Jump 1 │───>│ Jump 2 │───>│ Target │ + │ │ │ │ │ │ │ │ + └────────┘ └────────┘ └────────┘ └────────┘ + + Command: ssh -J jump1,jump2 user@target + + Combined with tunnel: + ssh -J jump1,jump2 -D 1080 user@target + + Use cases: + • Reach servers in isolated networks + • Multi-tier security environments + • Bastion/jump box architectures + • Chain through multiple datacenters + +EOF + _press_any_key +} + +_learn_controlmaster() { + _menu_header "ControlMaster Multiplexing" + cat >/dev/tty <<'EOF' + + ControlMaster allows multiple SSH sessions to share a single + network connection. Subsequent connections reuse the existing + TCP connection and skip authentication. + + First connection (creates socket): + ┌────────┐ ═══TCP═══ ┌────────┐ + │ Local │──socket──>│ Server │ + └────────┘ └────────┘ + + Subsequent connections (reuse socket): + ┌────────┐ ──socket──> + │ Local │ ──socket──> (instant, no auth) + └────────┘ ──socket──> + + Config: + ControlMaster auto + ControlPath ~/.ssh/sockets/%r@%h:%p + ControlPersist 600 + + Benefits: + • Instant connection for subsequent sessions + • Reduced server authentication load + • Shared connection for tunnels + shell + +EOF + _press_any_key +} + +_learn_autossh() { + _menu_header "AutoSSH & Reconnection" + cat >/dev/tty <<'EOF' + + AutoSSH monitors an SSH connection and automatically restarts + it if the connection drops. Essential for persistent tunnels. + + ┌──────────┐ ┌──────────┐ + │ AutoSSH │──watch──>│ SSH │ + │ (monitor)│──restart>│ (tunnel) │ + └──────────┘ └──────────┘ + + How it works: + 1. AutoSSH launches SSH with your tunnel config + 2. It monitors the connection health + 3. If SSH dies, AutoSSH restarts it automatically + 4. Exponential backoff prevents rapid reconnection loops + + TunnelForge config: + AUTOSSH_POLL = 30 (seconds between health checks) + AUTOSSH_GATETIME = 30 (min uptime before restart) + AUTOSSH_MONITOR = 0 (use ServerAlive instead) + + Tip: Combined with systemd, AutoSSH gives you a tunnel + that survives reboots AND network outages. + +EOF + _press_any_key +} + +_learn_tls_obfuscation() { + _menu_header "TLS Obfuscation (stunnel)" + printf >/dev/tty '%s\n' \ +'' \ +' THE PROBLEM:' \ +' Some countries (Iran, China, Russia) use Deep Packet' \ +' Inspection (DPI) to detect and block SSH connections.' \ +' Even though SSH is encrypted, DPI can identify the' \ +' SSH protocol by its handshake pattern.' \ +'' \ +' THE SOLUTION — TLS WRAPPING:' \ +' Wrap SSH inside a TLS (HTTPS) connection using stunnel.' \ +' DPI sees standard HTTPS traffic — the same protocol' \ +' used by every website. It cannot tell SSH is inside.' \ +'' \ +' WITHOUT OBFUSCATION:' \ +' ┌──────┐ SSH:22 ┌──────┐' \ +' │Client├─────────>│Server│ DPI: "This is SSH → BLOCK"' \ +' └──────┘ └──────┘' \ +'' \ +' WITH TLS OBFUSCATION:' \ +' ┌──────┐ TLS:443 ┌────────┐' \ +' │Client├────────>│stunnel │ DPI: "This is HTTPS → ALLOW"' \ +' └──────┘ (HTTPS) │→SSH :22│' \ +' └────────┘' \ +'' \ +' HOW STUNNEL WORKS:' \ +' Server side:' \ +' stunnel listens on port 443 (TLS)' \ +' → unwraps TLS → forwards to SSH on port 22' \ +'' \ +' Client side (TunnelForge handles this):' \ +' SSH uses ProxyCommand with openssl to connect' \ +' through the TLS tunnel instead of directly' \ +'' \ +' WHY PORT 443?' \ +' Port 443 is the standard HTTPS port. Every website' \ +' uses it. Blocking port 443 would break the internet,' \ +' so censors cannot block it.' \ +'' \ +' SETUP IN TUNNELFORGE:' \ +' In the wizard, at "Connection Mode", pick:' \ +' 2) TLS Encrypted (stunnel)' \ +' TunnelForge auto-installs stunnel on your server.' \ +'' \ +' TWO TYPES OF TLS IN TUNNELFORGE:' \ +' Outbound TLS — wraps SSH going TO a remote server' \ +' Inbound TLS — wraps SOCKS5 port for clients coming IN' \ +' Both can be active at once for full-chain encryption.' \ +'' + _press_any_key +} + +_learn_psk_auth() { + _menu_header "PSK Authentication" + printf >/dev/tty '%s\n' \ +'' \ +' WHAT IS PSK?' \ +' Pre-Shared Key — a shared secret between server and' \ +' client. Both sides know the same key. Only clients' \ +' with the correct key can connect.' \ +'' \ +' WHY USE PSK?' \ +' When TunnelForge runs on a VPS and accepts connections' \ +' from user PCs, the SOCKS5 port needs protection:' \ +' - Without PSK: anyone who finds the port can use it' \ +' - With PSK: only authorized users can connect' \ +'' \ +' HOW IT WORKS:' \ +' ┌──────────┐ TLS + PSK ┌──────────────┐' \ +' │ User PC ├────────────────>│ VPS stunnel │' \ +' │ stunnel │ "I know the │ verifies PSK │' \ +' │ (client) │ secret key" │ → SOCKS5 │' \ +' └──────────┘ └──────────────┘' \ +'' \ +' 1. Server stunnel has a PSK secrets file' \ +' 2. Client stunnel has the SAME PSK' \ +' 3. During TLS handshake, they prove they share' \ +' the same key — no certificates needed' \ +' 4. If key doesn'"'"'t match → connection refused' \ +'' \ +' PSK FORMAT:' \ +' identity:hexkey' \ +' Example: tunnelforge:a1b2c3d4e5f6....' \ +'' \ +' IN TUNNELFORGE:' \ +' PSK is auto-generated when you enable "Inbound TLS+PSK"' \ +' in the wizard (step 11). 32-byte random hex key.' \ +'' \ +' View PSK: tunnelforge client-config ' \ +' Share setup: tunnelforge client-script ' \ +'' \ +' REVOKING ACCESS:' \ +' To block a user: change the PSK in the profile,' \ +' restart the tunnel, and send the new script only' \ +' to authorized users. Old PSK stops working.' \ +'' + _press_any_key +} + +_learn_mobile_client() { + _menu_header "Mobile Client Connection" + cat >/dev/tty <<'EOF' + + HOW TO CONNECT FROM A MOBILE PHONE + + Your TunnelForge tunnel runs on a server and exposes a SOCKS5 + port. To use it from a phone, you need a SOCKS5-capable app. + + ┌──────────┐ SOCKS5 ┌──────────────┐ SSH ┌──────┐ + │ Phone ├─────────────>│ VPS/Server ├─────────>│ Dest │ + │ App │ proxy conn │ TunnelForge │ tunnel │ │ + └──────────┘ └──────────────┘ └──────┘ + + ── WITHOUT TLS/PSK (bind 0.0.0.0) ── + + Make sure your profile uses LOCAL_BIND_ADDR=0.0.0.0 so + the SOCKS5 port accepts external connections. + + Android: + • SocksDroid (free) — set SOCKS5: : + • Drony — per-app SOCKS5 routing + • Any browser with proxy settings + + iOS: + • Shadowrocket — add SOCKS5 server + • Surge / Quantumult — SOCKS5 proxy node + • iOS WiFi Settings — HTTP proxy (limited) + + Settings: + Type: SOCKS5 + Server: + Port: + + WARNING: Without PSK, anyone who finds the port can use + your tunnel. Enable inbound TLS+PSK for protection. + + ── WITH TLS+PSK (recommended) ── + + When inbound TLS+PSK is enabled, stunnel wraps the SOCKS5 + port. Mobile clients need an stunnel-compatible layer: + + Android: + 1. Install SST (Simple Stunnel Tunnel) or Termux + 2. In Termux: pkg install stunnel, then use the config + from: tunnelforge client-config + 3. Point your SOCKS5 app at 127.0.0.1: + + iOS: + 1. Shadowrocket supports TLS-over-SOCKS natively + 2. Or use iSH terminal + stunnel with client config + + Generate client config: + tunnelforge client-config + tunnelforge client-script + + ── QUICK CHECKLIST ── + + [ ] Server tunnel is running (tunnelforge status) + [ ] Firewall allows the port (ufw allow ) + [ ] Phone and server on same network, or port is public + [ ] SOCKS5 app configured with correct IP:PORT + [ ] If PSK: stunnel running on phone with correct key + +EOF + _press_any_key +} + +# ── Example Scenarios ── + +show_scenarios_menu() { + while true; do + _menu_header "Example Scenarios" + + printf " ${BOLD}── Basic SSH Tunnels ──${RESET}\n" >/dev/tty + printf " ${CYAN}1${RESET}) SOCKS5 Proxy — Browse privately\n" >/dev/tty + printf " ${CYAN}2${RESET}) Local Forward — Access remote database\n" >/dev/tty + printf " ${CYAN}3${RESET}) Remote Forward — Share local website\n" >/dev/tty + printf " ${CYAN}4${RESET}) Jump Host — Reach a hidden server\n" >/dev/tty + printf "\n ${BOLD}── TLS Obfuscation (Anti-Censorship) ──${RESET}\n" >/dev/tty + printf " ${CYAN}5${RESET}) Client → VPS (single server, bypass DPI)\n" >/dev/tty + printf " ${CYAN}6${RESET}) Client → VPS → VPS (double TLS, full chain)\n" >/dev/tty + printf " ${CYAN}7${RESET}) Share tunnel with others (client script)\n" >/dev/tty + printf "\n ${YELLOW}0${RESET}) Back\n\n" >/dev/tty + + local _sc_choice + printf " ${BOLD}Select${RESET}: " >/dev/tty + read -rsn1 _sc_choice /dev/tty + + case "$_sc_choice" in + 1) _scenario_socks5 || true ;; + 2) _scenario_local_forward || true ;; + 3) _scenario_remote_forward || true ;; + 4) _scenario_jump_host || true ;; + 5) _scenario_tls_single_vps || true ;; + 6) _scenario_tls_double_vps || true ;; + 7) _scenario_tls_share_tunnel || true ;; + 0|q) return 0 ;; + *) true ;; + esac + done +} + +_scenario_socks5() { + _menu_header "Scenario: Browse Privately via SOCKS5 Proxy" + cat >/dev/tty <<'EOF' + + GOAL: Route your browser traffic through a VPS so websites + see the VPS IP instead of your real IP. + + WHAT YOU NEED: + • A VPS or remote server with SSH access + • A browser (Firefox, Chrome, etc.) + + NETWORK DIAGRAM: + + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Your PC │──SOCKS5──│ VPS │──────────│ Internet │ + │ :1080 │ tunnel │ (proxy) │ │ │ + └──────────┘ └──────────┘ └──────────┘ + + WIZARD SETTINGS: + Tunnel type ......... SOCKS5 Proxy + SSH host ............ Your VPS IP (e.g. 45.33.32.10) + SSH port ............ 22 + SSH user ............ root + Bind address ........ 127.0.0.1 (local only) + 0.0.0.0 (share with LAN) + SOCKS port .......... 1080 + + AFTER TUNNEL STARTS — CONFIGURE YOUR BROWSER: + + Firefox: + 1. Settings → search "proxy" → Manual proxy + 2. SOCKS Host: 127.0.0.1 Port: 1080 + 3. Select "SOCKS v5" + 4. Check "Proxy DNS when using SOCKS v5" + 5. Click OK + + Chrome (command line): + google-chrome --proxy-server="socks5://127.0.0.1:1080" + + TEST IT WORKS: + From the command line: + curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me + → Should show your VPS IP, not your real IP + + If you used 0.0.0.0 as bind, other devices on your + LAN can use it too: + curl --socks5-hostname :1080 https://ifconfig.me + +EOF + _press_any_key +} + +_scenario_local_forward() { + _menu_header "Scenario: Access a Remote Database Locally" + cat >/dev/tty <<'EOF' + + GOAL: Access a MySQL database running on your VPS as if it + were on your local machine. + + WHAT YOU NEED: + • A VPS with MySQL running on port 3306 + • An SSH account on that VPS + + NETWORK DIAGRAM: + + ┌──────────┐ ┌──────────┐ + │ Your PC │──Local───│ VPS │ + │ :3306 │ Forward │ MySQL │ + │ │ │ :3306 │ + └──────────┘ └──────────┘ + + WIZARD SETTINGS: + Tunnel type ......... Local Port Forward + SSH host ............ Your VPS IP (e.g. 45.33.32.10) + SSH port ............ 22 + SSH user ............ root + Local bind .......... 127.0.0.1 (local only) + 0.0.0.0 (share with LAN) + Local port .......... 3306 (port on YOUR PC) + Remote host ......... 127.0.0.1 (means "on the VPS") + Remote port ......... 3306 (MySQL on VPS) + + AFTER TUNNEL STARTS — CONNECT: + + MySQL client: + mysql -h 127.0.0.1 -P 3306 -u dbuser -p + + Web app (e.g. phpMyAdmin, DBeaver): + Host: 127.0.0.1 Port: 3306 + + Web service (e.g. a web server on VPS port 8080): + Change remote port to 8080, then open: + http://127.0.0.1:8080 + in your browser. + + TEST IT WORKS: + If forwarding a web service: + curl http://127.0.0.1: + + If using 0.0.0.0 as bind, other LAN devices connect: + http://: + + COMMON VARIATIONS: + • Forward port 5432 for PostgreSQL + • Forward port 6379 for Redis + • Forward port 8080 for a web admin panel + • Remote host can be another IP on the VPS network + (e.g. 10.0.0.5:3306 for a DB on a private subnet) + +EOF + _press_any_key +} + +_scenario_remote_forward() { + _menu_header "Scenario: Share a Local Website with the World" + cat >/dev/tty <<'EOF' + + GOAL: You have a website running on your local machine + (e.g. port 3000) and want to make it accessible + from the internet through your VPS. + + WHAT YOU NEED: + • A local service running (e.g. Node.js on port 3000) + • A VPS with SSH access and a public IP + + NETWORK DIAGRAM: + + ┌──────────────┐ ┌──────────────┐ + │ Your PC │──Reverse─│ VPS │ + │ localhost │ Forward │ public IP │ + │ :3000 │ │ :9090 │ + └──────────────┘ └──────────────┘ + ↑ + Anyone can access + http://VPS-IP:9090 + + WIZARD SETTINGS: + Tunnel type ......... Remote/Reverse Forward + SSH host ............ Your VPS IP (e.g. 45.33.32.10) + SSH port ............ 22 + SSH user ............ root + Remote bind ......... 127.0.0.1 (VPS localhost only) + 0.0.0.0 (public, needs + GatewayPorts yes) + Remote port ......... 9090 (port on VPS) + Local host .......... 127.0.0.1 (your machine) + Local port .......... 3000 (your service) + + BEFORE STARTING — MAKE SURE: + 1. Your local service is running: + python3 -m http.server 3000 + (or node app.js, etc.) + + 2. If using 0.0.0.0 bind, your VPS sshd_config + needs: GatewayPorts yes + Then restart sshd: systemctl restart sshd + + AFTER TUNNEL STARTS — TEST FROM VPS: + ssh root@ + curl http://localhost:9090 + → Should show your local website content + + TEST FROM ANYWHERE (if bind is 0.0.0.0): + curl http://:9090 + → Same content, accessible from the internet + + COMMON VARIATIONS: + • Share a dev server for client demos + • Receive webhooks from services like GitHub/Stripe + • Remote access to a home service behind NAT + • Expose port 22 to allow SSH into your home PC + +EOF + _press_any_key +} + +_scenario_jump_host() { + _menu_header "Scenario: Reach a Server Behind a Firewall" + cat >/dev/tty <<'EOF' + + GOAL: You need to access a server that is NOT directly + reachable from the internet. You have SSH access + to an intermediate "jump" server that CAN reach it. + + WHAT YOU NEED: + • A jump server (bastion) you can SSH into + • A target server the jump server can reach + • SSH credentials for both servers + + NETWORK DIAGRAM: + + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Your PC │────>│ Jump │────>│ Target │ + │ │ │ (bastion)│ │ (hidden) │ + └──────────┘ └──────────┘ └──────────┘ + You can't reach Target directly, + but Jump can reach it. + + WIZARD SETTINGS (example with SOCKS5 at target): + Tunnel type ......... Jump Host + SSH host ............ Target IP (e.g. 10.0.0.50) + SSH port ............ 22 + SSH user ............ admin (user on TARGET) + SSH password ........ **** (for TARGET) + Jump hosts .......... root@bastion.example.com:22 + Dest tunnel type .... SOCKS5 Proxy + Bind address ........ 127.0.0.1 + SOCKS port .......... 1080 + + WIZARD SETTINGS (example with Local Forward): + Tunnel type ......... Jump Host + SSH host ............ 10.0.0.50 (target) + SSH user ............ admin + Jump hosts .......... root@45.33.32.10:22 + Dest tunnel type .... Local Port Forward + Local bind .......... 0.0.0.0 + Local port .......... 8080 + Remote host ......... 127.0.0.1 (on the target) + Remote port ......... 80 (web server) + + STEP-BY-STEP LOGIC: + 1. TunnelForge connects to the JUMP server first + 2. Through the jump server, it connects to TARGET + 3. Then it sets up your chosen tunnel (SOCKS5 or + Local Forward) at the target + + AFTER TUNNEL STARTS: + SOCKS5 mode: + Set browser proxy to 127.0.0.1:1080 + curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me + + Local Forward mode: + Open http://127.0.0.1:8080 in your browser + → Shows the web server on the hidden target + + MULTIPLE JUMP HOSTS: + You can chain through several servers: + Jump hosts: user1@hop1:22,user2@hop2:22 + + ┌────┐ ┌──────┐ ┌──────┐ ┌────────┐ + │ PC │─>│ Hop1 │─>│ Hop2 │─>│ Target │ + └────┘ └──────┘ └──────┘ └────────┘ + + TIPS: + • The SSH host/user/password are for the TARGET + • Jump host credentials go in the jump hosts field + (e.g. root@bastion:22) + • Auth test may fail for the target — that's OK, + the connection goes through the jump host + +EOF + _press_any_key +} + +_scenario_tls_single_vps() { + _menu_header "Scenario: Bypass Censorship (Single VPS)" + printf >/dev/tty '%s\n' \ +'' \ +' GOAL: You are in a censored country (Iran, China, etc.)' \ +' and your ISP blocks or detects SSH connections.' \ +' You want to browse freely using a VPS outside.' \ +'' \ +' WHAT YOU NEED:' \ +' - 1 VPS outside the censored country (e.g. US, Europe)' \ +' - SSH access to that VPS' \ +'' \ +' HOW IT WORKS:' \ +' SSH is wrapped in TLS so it looks like normal HTTPS.' \ +' Your ISP sees encrypted HTTPS traffic — not SSH.' \ +'' \ +' NETWORK DIAGRAM:' \ +'' \ +' Your PC (Iran) VPS (Outside)' \ +' ┌──────────┐ TLS:443 ┌─────────────┐' \ +' │TunnelForge├──────────>│ stunnel │' \ +' │ SOCKS5 │ (HTTPS) │ → SSH :22 │──> Internet' \ +' │ :1080 │ └─────────────┘' \ +' └──────────┘' \ +' DPI sees: HTTPS traffic (allowed)' \ +'' \ +' STEP-BY-STEP SETUP:' \ +' 1. Install TunnelForge on your PC (Linux/WSL)' \ +' 2. Run: tunnelforge wizard' \ +' 3. Enter VPS connection details (host, user, password)' \ +' 4. Pick tunnel type: SOCKS5 Proxy' \ +' 5. At "Connection Mode": choose TLS Encrypted' \ +' - Port: 443 (or 8443 if 443 is busy)' \ +' - Say YES to "Set up stunnel on server now?"' \ +' - TunnelForge auto-installs stunnel on VPS' \ +' 6. At "Inbound Protection": choose No (not needed,' \ +' you are connecting directly from your own PC)' \ +' 7. Save and start the tunnel' \ +'' \ +' AFTER TUNNEL STARTS:' \ +' Set browser SOCKS5 proxy: 127.0.0.1:1080' \ +' Test: curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me' \ +' → Should show VPS IP' \ +'' \ +' WHAT DPI SEES:' \ +' Your PC ──HTTPS:443──> VPS IP' \ +' Looks like you are browsing a normal website.' \ +'' + _press_any_key +} + +_scenario_tls_double_vps() { + _menu_header "Scenario: Double TLS Chain (Two VPS)" + printf >/dev/tty '%s\n' \ +'' \ +' GOAL: You run a shared proxy for multiple users.' \ +' One VPS is inside the censored country (relay),' \ +' one is outside (exit). Users connect to the relay' \ +' and traffic exits through the outside VPS.' \ +'' \ +' WHAT YOU NEED:' \ +' - VPS-A: Inside censored country (e.g. Iran datacenter)' \ +' - VPS-B: Outside (e.g. US, Europe)' \ +'' \ +' HOW IT WORKS:' \ +' Both legs are TLS-wrapped. Users connect with PSK.' \ +'' \ +' NETWORK DIAGRAM:' \ +'' \ +' Users VPS-A (Iran) VPS-B (Outside)' \ +' ┌──────┐ TLS+PSK ┌──────────────┐ TLS:443 ┌──────────┐' \ +' │ PC 1 ├────────>│ TunnelForge ├──────────>│ stunnel │' \ +' ├──────┤ :1443 │ stunnel+PSK │ (HTTPS) │ → SSH:22 │─> Net' \ +' │ PC 2 ├────────>│ → SOCKS5:1080│ └──────────┘' \ +' └──────┘ └──────────────┘' \ +' DPI sees: HTTPS DPI sees: HTTPS' \ +'' \ +' STEP-BY-STEP SETUP ON VPS-A:' \ +' 1. Install TunnelForge on VPS-A' \ +' 2. Run: tunnelforge wizard' \ +' 3. SSH host = VPS-B IP, enter credentials' \ +' 4. Tunnel type: SOCKS5 Proxy' \ +' 5. Bind address: 0.0.0.0 (auto-forced to 127.0.0.1)' \ +' 6. At "Connection Mode": choose TLS Encrypted' \ +' - Port: 443 (auto-installs stunnel on VPS-B)' \ +' 7. At "Inbound Protection": choose TLS + PSK' \ +' - Port: 1443 (users connect here)' \ +' - PSK auto-generated' \ +' 8. Save and start' \ +'' \ +' AFTER TUNNEL STARTS:' \ +' TunnelForge shows the client config + PSK.' \ +' Generate a connect script for users:' \ +' tunnelforge client-script ' \ +'' \ +' USER SETUP (on each user PC):' \ +' Option A: Run the generated script:' \ +' ./tunnelforge-connect.sh' \ +' → Auto-installs stunnel, connects, done' \ +'' \ +' Option B: Manual stunnel config:' \ +' tunnelforge client-config ' \ +' → Shows stunnel.conf + psk.txt to copy' \ +'' \ +' Then set browser proxy: 127.0.0.1:1080' \ +'' \ +' WHAT DPI SEES:' \ +' User PC ──HTTPS:1443──> VPS-A (normal TLS)' \ +' VPS-A ──HTTPS:443───> VPS-B (normal TLS)' \ +' No SSH protocol visible anywhere in the chain.' \ +'' + _press_any_key +} + +_scenario_tls_share_tunnel() { + _menu_header "Scenario: Share Your Tunnel with Others" + printf >/dev/tty '%s\n' \ +'' \ +' GOAL: You have a working TLS tunnel on a VPS and want' \ +' to let friends/family use it from their own PCs.' \ +'' \ +' WHAT YOU NEED:' \ +' - A running TunnelForge tunnel with Inbound TLS+PSK' \ +' - Friends who need to connect' \ +'' \ +' HOW IT WORKS:' \ +' TunnelForge generates a one-file script. Users run it' \ +' and it auto-installs stunnel, configures everything,' \ +' and connects. No technical knowledge needed.' \ +'' \ +' STEP 1 — GENERATE THE SCRIPT (on the server):' \ +'' \ +' tunnelforge client-script ' \ +'' \ +' This creates: tunnelforge-connect.sh' \ +' The script contains the server address, port,' \ +' and the PSK — everything needed to connect.' \ +'' \ +' STEP 2 — SEND IT TO USERS:' \ +' Share tunnelforge-connect.sh via:' \ +' - Telegram, WhatsApp, email, USB drive' \ +' - Any method that can transfer a small file' \ +'' \ +' STEP 3 — USER RUNS THE SCRIPT:' \ +'' \ +' chmod +x tunnelforge-connect.sh' \ +' ./tunnelforge-connect.sh' \ +'' \ +' The script will:' \ +' 1. Install stunnel if not present (apt/dnf/brew)' \ +' 2. Write config files to ~/.tunnelforge-client/' \ +' 3. Start stunnel and create a local SOCKS5 proxy' \ +' 4. Print browser setup instructions' \ +'' \ +' USER COMMANDS:' \ +' ./tunnelforge-connect.sh Connect' \ +' ./tunnelforge-connect.sh stop Disconnect' \ +' ./tunnelforge-connect.sh status Check connection' \ +'' \ +' AFTER CONNECTING:' \ +' Browser proxy: 127.0.0.1:' \ +' All traffic routes through your tunnel.' \ +'' \ +' SECURITY NOTES:' \ +' - The script contains the PSK (shared secret)' \ +' - Only share with trusted people' \ +' - To revoke access: change PSK in profile,' \ +' regenerate script, and restart tunnel' \ +' - Each user gets their own local SOCKS5 proxy' \ +' - The server stunnel handles multiple connections' \ +'' \ +' OTHER USEFUL COMMANDS:' \ +' tunnelforge client-config ' \ +' → Show connection details + PSK (for manual setup)' \ +' tunnelforge client-script /path/to/output.sh' \ +' → Save script to specific location' \ +'' + _press_any_key +} + +# ── Menu helpers for start/stop ── + +_menu_start_tunnel() { + local _ms_profiles + _ms_profiles=$(list_profiles) + if [[ -z "$_ms_profiles" ]]; then + log_info "No profiles found. Create one first." + return 0 + fi + + printf "\n${BOLD}Available tunnels:${RESET}\n" >/dev/tty + local _ms_names=() _ms_idx=0 + while IFS= read -r _ms_pn; do + [[ -z "$_ms_pn" ]] && continue + (( ++_ms_idx )) + _ms_names+=("$_ms_pn") + local _ms_st + if is_tunnel_running "$_ms_pn"; then + _ms_st="${GREEN}● running${RESET}" + else + _ms_st="${DIM}■ stopped${RESET}" + fi + printf " ${CYAN}%d${RESET}) %-20s %b\n" "$_ms_idx" "$_ms_pn" "$_ms_st" >/dev/tty + done <<< "$_ms_profiles" + + printf "\n" >/dev/tty + local _ms_choice + if ! _read_tty "Select tunnel # (or name)" _ms_choice ""; then return 0; fi + + local _ms_target + if [[ "$_ms_choice" =~ ^[0-9]+$ ]] && (( _ms_choice >= 1 && _ms_choice <= ${#_ms_names[@]} )); then + _ms_target="${_ms_names[$((_ms_choice-1))]}" + else + _ms_target="$_ms_choice" + fi + + if [[ -n "$_ms_target" ]]; then + start_tunnel "$_ms_target" || true + fi +} + +_menu_stop_tunnel() { + local _mt_profiles + _mt_profiles=$(list_profiles) + if [[ -z "$_mt_profiles" ]]; then + log_info "No profiles found. Create one first." + return 0 + fi + + local _mt_names=() _mt_idx=0 _mt_header_shown=false + while IFS= read -r _mt_pn; do + [[ -z "$_mt_pn" ]] && continue + if is_tunnel_running "$_mt_pn"; then + if [[ "$_mt_header_shown" == false ]]; then + printf "\n${BOLD}Running tunnels:${RESET}\n" >/dev/tty + _mt_header_shown=true + fi + (( ++_mt_idx )) + _mt_names+=("$_mt_pn") + printf " ${CYAN}%d${RESET}) %s\n" "$_mt_idx" "$_mt_pn" >/dev/tty + fi + done <<< "$_mt_profiles" + + if [[ ${#_mt_names[@]} -eq 0 ]]; then + log_info "No running tunnels." + return 0 + fi + + printf "\n" >/dev/tty + local _mt_choice + if ! _read_tty "Select tunnel # (or name)" _mt_choice ""; then return 0; fi + + local _mt_target + if [[ "$_mt_choice" =~ ^[0-9]+$ ]] && (( _mt_choice >= 1 && _mt_choice <= ${#_mt_names[@]} )); then + _mt_target="${_mt_names[$((_mt_choice-1))]}" + else + _mt_target="$_mt_choice" + fi + + if [[ -n "$_mt_target" ]]; then + stop_tunnel "$_mt_target" || true + fi +} + +# ── Security sub-menus ── + +_menu_ssh_keys() { + while true; do + clear >/dev/tty 2>/dev/null || true + printf "\n${BOLD_CYAN}═══ SSH Key Management ═══${RESET}\n\n" >/dev/tty + printf " ${CYAN}1${RESET}) Generate new SSH key\n" >/dev/tty + printf " ${CYAN}2${RESET}) Deploy key to server\n" >/dev/tty + printf " ${CYAN}3${RESET}) Check key permissions\n" >/dev/tty + printf " ${YELLOW}q${RESET}) Back\n\n" >/dev/tty + + local _mk_choice + printf " ${BOLD}Select${RESET}: " >/dev/tty + read -rsn1 _mk_choice /dev/tty + + case "$_mk_choice" in + 1) + local _mk_type _mk_path + _read_tty "Key type (ed25519/rsa/ecdsa)" _mk_type "ed25519" || true + case "$_mk_type" in + ed25519|rsa|ecdsa) ;; + *) log_error "Unsupported key type: ${_mk_type} (use ed25519, rsa, or ecdsa)" ;; + esac + if [[ "$_mk_type" =~ ^(ed25519|rsa|ecdsa)$ ]]; then + _mk_path="${HOME}/.ssh/id_${_mk_type}" + generate_ssh_key "$_mk_type" "$_mk_path" || true + fi + _press_any_key ;; + 2) + local _mk_name + _read_tty "Profile name" _mk_name "" || true + if [[ -n "$_mk_name" ]] && validate_profile_name "$_mk_name"; then + deploy_ssh_key "$_mk_name" || true + elif [[ -n "$_mk_name" ]]; then + log_error "Invalid profile name: ${_mk_name}" + fi + _press_any_key ;; + 3) + local _mk_kpath + _read_tty "Key path" _mk_kpath "${HOME}/.ssh/id_ed25519" || true + check_key_permissions "$_mk_kpath" || true + _press_any_key ;; + q|Q) return 0 ;; + *) true ;; + esac + done +} + +_menu_service_select() { + local _msv_profiles + _msv_profiles=$(list_profiles) + if [[ -z "$_msv_profiles" ]]; then + log_info "No profiles found. Create one first." + return 0 + fi + + printf "\n${BOLD}Available profiles:${RESET}\n" >/dev/tty + local _msv_names=() _msv_idx=0 + while IFS= read -r _msv_pn; do + [[ -z "$_msv_pn" ]] && continue + (( ++_msv_idx )) + _msv_names+=("$_msv_pn") + printf " ${CYAN}%d${RESET}) %s\n" "$_msv_idx" "$_msv_pn" >/dev/tty + done <<< "$_msv_profiles" + + printf "\n" >/dev/tty + local _msv_choice + if ! _read_tty "Select profile # (or name)" _msv_choice ""; then return 0; fi + + local _msv_target + if [[ "$_msv_choice" =~ ^[0-9]+$ ]] && (( _msv_choice >= 1 && _msv_choice <= ${#_msv_names[@]} )); then + _msv_target="${_msv_names[$((_msv_choice-1))]}" + else + _msv_target="$_msv_choice" + fi + + if [[ -n "$_msv_target" ]]; then + _menu_service "$_msv_target" || true + fi + return 0 +} + +_menu_fingerprint() { + while true; do + clear >/dev/tty 2>/dev/null || true + printf "\n${BOLD_CYAN}═══ SSH Host Fingerprint Verification ═══${RESET}\n\n" >/dev/tty + + local _mf_choice + printf " ${CYAN}1${RESET}) Enter host manually\n" >/dev/tty + printf " ${CYAN}2${RESET}) Check from profile\n" >/dev/tty + printf " ${YELLOW}q${RESET}) Back\n\n" >/dev/tty + + printf " ${BOLD}Select${RESET}: " >/dev/tty + read -rsn1 _mf_choice /dev/tty + + case "$_mf_choice" in + 1) + local _mf_host _mf_port + _read_tty "Host" _mf_host "" || true + _read_tty "Port" _mf_port "22" || true + if [[ -n "$_mf_host" ]]; then + if validate_port "$_mf_port"; then + verify_host_fingerprint "$_mf_host" "$_mf_port" || true + else + log_error "Invalid port: ${_mf_port}" + fi + else + log_error "Host cannot be empty" + fi + _press_any_key ;; + 2) + local _mf_name + _read_tty "Profile name" _mf_name "" || true + if [[ -n "$_mf_name" ]]; then + local -A _mf_prof + if load_profile "$_mf_name" _mf_prof 2>/dev/null; then + local _mf_h="${_mf_prof[SSH_HOST]:-}" + local _mf_p="${_mf_prof[SSH_PORT]:-22}" + if [[ -n "$_mf_h" ]]; then + verify_host_fingerprint "$_mf_h" "$_mf_p" || true + else + log_error "No SSH host in profile '${_mf_name}'" + fi + else + log_error "Cannot load profile '${_mf_name}'" + fi + fi + _press_any_key ;; + q|Q) return 0 ;; + *) true ;; + esac + done +} + +# ── Main interactive menu ── + +show_menu() { + while true; do + _menu_header "" + + # Quick status summary + local _mm_profiles _mm_total=0 _mm_running=0 + _mm_profiles=$(list_profiles) + if [[ -n "$_mm_profiles" ]]; then + while IFS= read -r _mm_pn; do + [[ -z "$_mm_pn" ]] && continue + (( ++_mm_total )) + if is_tunnel_running "$_mm_pn"; then + (( ++_mm_running )) + fi + done <<< "$_mm_profiles" + fi + + printf " ${DIM}Tunnels: ${RESET}${GREEN}%d running${RESET} ${DIM}/ %d total${RESET}\n\n" \ + "$_mm_running" "$_mm_total" >/dev/tty + + printf " ${BOLD}── Tunnel Operations ──${RESET}\n" >/dev/tty + printf " ${CYAN}1${RESET}) ${BOLD}Create${RESET} new tunnel ${DIM}Setup wizard${RESET}\n" >/dev/tty + printf " ${CYAN}2${RESET}) ${BOLD}Start${RESET} a tunnel ${DIM}Launch SSH tunnel${RESET}\n" >/dev/tty + printf " ${CYAN}3${RESET}) ${BOLD}Stop${RESET} a tunnel ${DIM}Terminate tunnel${RESET}\n" >/dev/tty + printf " ${CYAN}4${RESET}) ${BOLD}Start All${RESET} tunnels ${DIM}Launch autostart tunnels${RESET}\n" >/dev/tty + printf " ${CYAN}5${RESET}) ${BOLD}Stop All${RESET} tunnels ${DIM}Terminate all${RESET}\n" >/dev/tty + + printf "\n ${BOLD}── Monitoring ──${RESET}\n" >/dev/tty + printf " ${CYAN}6${RESET}) ${BOLD}Status${RESET} ${DIM}Show tunnel statuses${RESET}\n" >/dev/tty + printf " ${CYAN}7${RESET}) ${BOLD}Dashboard${RESET} ${DIM}Live TUI dashboard${RESET}\n" >/dev/tty + + printf "\n ${BOLD}── Management ──${RESET}\n" >/dev/tty + printf " ${CYAN}8${RESET}) ${BOLD}Profiles${RESET} ${DIM}Manage tunnel profiles${RESET}\n" >/dev/tty + printf " ${CYAN}9${RESET}) ${BOLD}Settings${RESET} ${DIM}Configure defaults${RESET}\n" >/dev/tty + printf " ${CYAN}s${RESET}) ${BOLD}Services${RESET} ${DIM}Systemd service manager${RESET}\n" >/dev/tty + printf " ${CYAN}b${RESET}) ${BOLD}Backup / Restore${RESET} ${DIM}Manage backups${RESET}\n" >/dev/tty + + printf "\n ${BOLD}── Security & Notifications ──${RESET}\n" >/dev/tty + printf " ${CYAN}x${RESET}) ${BOLD}Security Audit${RESET} ${DIM}Check security posture${RESET}\n" >/dev/tty + printf " ${CYAN}k${RESET}) ${BOLD}SSH Key Management${RESET} ${DIM}Generate & deploy keys${RESET}\n" >/dev/tty + printf " ${CYAN}f${RESET}) ${BOLD}Fingerprint Check${RESET} ${DIM}Verify host fingerprints${RESET}\n" >/dev/tty + printf " ${CYAN}t${RESET}) ${BOLD}Telegram${RESET} ${DIM}Notification settings${RESET}\n" >/dev/tty + printf " ${CYAN}c${RESET}) ${BOLD}Client Configs${RESET} ${DIM}TLS+PSK connection info${RESET}\n" >/dev/tty + + printf "\n ${BOLD}── Information ──${RESET}\n" >/dev/tty + printf " ${CYAN}e${RESET}) ${BOLD}Examples${RESET} ${DIM}Real-world scenarios${RESET}\n" >/dev/tty + printf " ${CYAN}l${RESET}) ${BOLD}Learn${RESET} ${DIM}SSH tunnel concepts${RESET}\n" >/dev/tty + printf " ${CYAN}a${RESET}) ${BOLD}About${RESET} ${DIM}Version & info${RESET}\n" >/dev/tty + printf " ${CYAN}h${RESET}) ${BOLD}Help${RESET} ${DIM}CLI reference${RESET}\n" >/dev/tty + + printf "\n ${CYAN}w${RESET}) ${BOLD}Update${RESET} ${DIM}Check for updates${RESET}\n" >/dev/tty + printf " ${RED}u${RESET}) ${BOLD}Uninstall${RESET} ${DIM}Remove everything${RESET}\n" >/dev/tty + printf " ${YELLOW}q${RESET}) ${BOLD}Quit${RESET}\n\n" >/dev/tty + + local _mm_choice + printf " ${BOLD}Select${RESET}: " >/dev/tty + read -rsn1 _mm_choice /dev/tty + + case "$_mm_choice" in + 1) wizard_create_profile || true; _press_any_key ;; + 2) _menu_start_tunnel || true; _press_any_key ;; + 3) _menu_stop_tunnel || true; _press_any_key ;; + 4) start_all_tunnels || true; _press_any_key ;; + 5) stop_all_tunnels || true; _press_any_key ;; + 6) show_status || true; _press_any_key ;; + 7) show_dashboard || true ;; + 8) show_profiles_menu || true ;; + 9) show_settings_menu || true ;; + e|E) show_scenarios_menu || true ;; + l|L) show_learn_menu || true ;; + a|A) show_about || true ;; + h|H) show_help || true; _press_any_key ;; + x|X) security_audit || true; _press_any_key ;; + k|K) _menu_ssh_keys || true ;; + f|F) _menu_fingerprint || true ;; + t|T) _menu_telegram || true ;; + c|C) _menu_client_configs || true; _press_any_key ;; + s|S) _menu_service_select || true ;; + b|B) _menu_backup_restore || true ;; + w|W) update_tunnelforge || true; _press_any_key ;; + u|U) if confirm_action "Uninstall TunnelForge completely?"; then + clear >/dev/tty 2>/dev/null || true + uninstall_tunnelforge || true + return 0 + fi ;; + q|Q) clear >/dev/tty 2>/dev/null || true + printf " ${DIM}Goodbye!${RESET}\n\n" >/dev/tty + return 0 ;; + *) true ;; + esac + done +} + +# ============================================================================ +# DASHBOARD & LIVE MONITORING (Phase 3) +# ============================================================================ + +# Sparkline block characters (8 levels) +readonly SPARK_CHARS=( "▁" "▂" "▃" "▄" "▅" "▆" "▇" "█" ) + +# Dashboard caches (avoid expensive re-computation each render) +declare -gA _DASH_LATENCY=() # cached quality string per tunnel +declare -gA _DASH_LATENCY_TS=() # epoch of last latency check +declare -gA _DASH_LAT_HOST=() # latency cache by host:port (shared across tunnels) +declare -gA _DASH_LAT_HOST_TS=() # epoch of last check per host:port +declare -g _DASH_SS_CACHE="" # cached ss -tn output per render cycle +declare -g _DASH_SYSRES="" # cached system resources line +declare -g _DASH_SYSRES_TS=0 # epoch of last sysres check +declare -g _DASH_LAST_SPEED="" # last speed test result string +declare -gi _DASH_PAGE=0 # current page (0-indexed) +declare -gi _DASH_PER_PAGE=4 # tunnels per page +declare -gi _DASH_TOTAL_PAGES=1 # computed per render + +# Record a bandwidth sample to history (optimized: zero forks in hot path) +declare -gA _BW_WRITE_COUNT=() # per-tunnel write counter for throttled trim +_bw_record() { + local name="$1" rx="$2" tx="$3" + local bw_file="${BW_HISTORY_DIR}/${name}.dat" + [[ -d "$BW_HISTORY_DIR" ]] || mkdir -p "$BW_HISTORY_DIR" 2>/dev/null || true + + # Light mkdir lock for append+trim + local _bw_lock="${bw_file}.lck" + local _bw_try=0 + while ! mkdir "$_bw_lock" 2>/dev/null; do + local _bw_stale_pid="" + read -r _bw_stale_pid < "${_bw_lock}/pid" 2>/dev/null || true + if [[ -n "$_bw_stale_pid" ]] && ! kill -0 "$_bw_stale_pid" 2>/dev/null; then + rm -f "${_bw_lock}/pid" 2>/dev/null || true + rmdir "$_bw_lock" 2>/dev/null || true + continue + fi + if (( ++_bw_try >= 3 )); then return 0; fi + sleep 0.1 + done + printf '%s' "$$" > "${_bw_lock}/pid" 2>/dev/null || true + + # printf '%(%s)T' is a bash 4.2+ builtin — no fork needed for timestamp + local _now_epoch + printf -v _now_epoch '%(%s)T' -1 + printf "%d %d %d\n" "$_now_epoch" "$rx" "$tx" >> "$bw_file" 2>/dev/null || true + + # Throttle trimming: only check every 10 writes (not every single write) + local _wc=${_BW_WRITE_COUNT[$name]:-0} + (( ++_wc )) || true + _BW_WRITE_COUNT["$name"]=$_wc + if (( _wc >= 10 )); then + _BW_WRITE_COUNT["$name"]=0 + # Count lines without forking wc + local _line_count=0 + while IFS= read -r _; do + (( ++_line_count )) || true + done < "$bw_file" 2>/dev/null + if (( _line_count > 120 )); then + local tmp_bw_file="${bw_file}.tmp" + if tail -n 120 "$bw_file" > "$tmp_bw_file" 2>/dev/null; then + mv "$tmp_bw_file" "$bw_file" 2>/dev/null || rm -f "$tmp_bw_file" 2>/dev/null + else + rm -f "$tmp_bw_file" 2>/dev/null + fi + fi + fi + + rm -f "${_bw_lock}/pid" 2>/dev/null || true + rmdir "$_bw_lock" 2>/dev/null || true +} + +# Read last N bandwidth deltas from history (optimized: no regex per line) +_bw_read_deltas() { + local name="$1" count="${2:-30}" + local bw_file="${BW_HISTORY_DIR}/${name}.dat" + [[ -f "$bw_file" ]] || return 0 + + local -a timestamps rx_vals tx_vals + local ts rx tx + # Data file is our own trusted format (epoch rx tx per line) — skip regex + while read -r ts rx tx; do + [[ -z "$ts" ]] && continue + timestamps+=("$ts") + rx_vals+=("$rx") + tx_vals+=("$tx") + done < <(tail -n "$(( count + 1 ))" "$bw_file" 2>/dev/null) + + local total=${#timestamps[@]} + if (( total < 2 )); then return 0; fi + + local _i dt drx dtx + for (( _i=1; _i max_val )); then max_val="$v"; fi + done + + local spark="" _si + if (( max_val == 0 )); then + for (( _si=0; _si= max_val )); then + idx=7 + else + idx=$(( (v * 7 + max_val / 2) / max_val )) + fi + if (( idx > 7 )); then idx=7; fi + if (( idx < 0 )); then idx=0; fi + spark+="${SPARK_CHARS[$idx]}" + done + fi + printf '%s' "$spark" +} + +# Get reconnect stats for a tunnel +_reconnect_stats() { + local name="$1" + local rlog="${RECONNECT_LOG_DIR}/${name}.log" + [[ -f "$rlog" ]] || { echo "0 -"; return 0; } + + # Count lines + capture last line in one pass (no wc/tail/cut forks) + local _total=0 _last_line="" + while IFS= read -r _last_line; do + (( ++_total )) || true + done < "$rlog" 2>/dev/null + # Extract timestamp (before first '|') + local _last_ts="${_last_line%%|*}" + echo "${_total} ${_last_ts:--}" + return 0 +} + +# Simple speed test using curl (routes through SOCKS5 tunnel if available) +_speed_test() { + local -a _test_urls=( + "http://speedtest.tele2.net/1MB.zip" + "http://proof.ovh.net/files/1Mb.dat" + "http://ipv4.download.thinkbroadband.com/1MB.zip" + ) + local test_size=1048576 # 1MB + + printf "\n ${BOLD_CYAN}── Speed Test ──${RESET}\n\n" >/dev/tty + + if ! command -v curl &>/dev/null; then + printf " ${RED}curl is required for speed test${RESET}\n" >/dev/tty + return 0 + fi + + # Route through SOCKS5 tunnel if available (measures tunnel throughput) + local -a _proxy_args=() + local _proxy_port + _proxy_port=$(_tg_find_proxy 2>/dev/null) || true + if [[ -n "$_proxy_port" ]]; then + _proxy_args=(--socks5-hostname "127.0.0.1:${_proxy_port}") + printf " ${DIM}Testing through SOCKS5 tunnel (port %s)...${RESET}\n" "$_proxy_port" >/dev/tty + else + printf " ${DIM}Testing direct connection...${RESET}\n" >/dev/tty + fi + + printf " ${DIM}Downloading 1MB test file...${RESET}\n" >/dev/tty + + local start_time end_time elapsed speed_bps speed_str + local _test_ok=false + + for _turl in "${_test_urls[@]}"; do + start_time=$(_get_ns_timestamp) + if curl -s -o /dev/null --max-time 15 "${_proxy_args[@]}" "$_turl" 2>/dev/null; then + end_time=$(_get_ns_timestamp) + _test_ok=true + break + fi + done + + if [[ "$_test_ok" == true ]] && (( start_time > 0 && end_time > 0 )); then + elapsed=$(( (end_time - start_time) / 1000000 )) # milliseconds + if (( elapsed < 1 )); then elapsed=1; fi + speed_bps=$(( test_size * 1000 / elapsed )) # bytes/sec + + speed_str=$(format_bytes "$speed_bps") + printf " ${GREEN}●${RESET} Download speed: ${BOLD}%s/s${RESET}\n" "$speed_str" >/dev/tty + printf " ${DIM}Time: %d.%03ds${RESET}\n" "$((elapsed/1000))" "$((elapsed%1000))" >/dev/tty + _DASH_LAST_SPEED="${speed_str}/s" + else + printf " ${RED}✗${RESET} Speed test failed (check connection)\n" >/dev/tty + fi + return 0 +} + +# ── Dashboard renderer ── + +_dash_box_top() { + local width="$1" + local line="╔" + local _i + for (( _i=0; _i/dev/null || true + fi + + # Memory from /proc/meminfo (pure bash, no grep/awk forks) + local mem_total=0 mem_avail=0 mem_used=0 mem_pct=0 + if [[ -f /proc/meminfo ]]; then + local _key _val _unit + while read -r _key _val _unit; do + case "$_key" in + MemTotal:) mem_total=$(( _val / 1024 )) ;; + MemAvailable:) mem_avail=$(( _val / 1024 )); break ;; + esac + done < /proc/meminfo 2>/dev/null + if (( mem_total > 0 )); then + mem_used=$(( mem_total - mem_avail )) + mem_pct=$(( mem_used * 100 / mem_total )) + fi + fi + + local mem_str + if (( mem_total >= 1024 )); then + # Integer GB with one decimal via bash math (no awk fork) + local _mu_whole=$(( mem_used / 1024 )) _mu_frac=$(( (mem_used % 1024) * 10 / 1024 )) + local _mt_whole=$(( mem_total / 1024 )) _mt_frac=$(( (mem_total % 1024) * 10 / 1024 )) + mem_str="${_mu_whole}.${_mu_frac}G/${_mt_whole}.${_mt_frac}G (${mem_pct}%)" + elif (( mem_total > 0 )); then + mem_str="${mem_used}M/${mem_total}M (${mem_pct}%)" + else + mem_str="N/A" + fi + + _DASH_SYSRES="MEM: ${mem_str} │ Load: ${load_1:-?} ${load_5:-?} ${load_15:-?}" + _DASH_SYSRES_TS=$now + return 0 +} + +# ── Dashboard helper: active connections on a port (optimized: no awk forks) ── +_dash_active_conns() { + local port="$1" + [[ "$port" =~ ^[0-9]+$ ]] || { echo "0 clients"; return 0; } + + local -A _seen=() + local -a _unique=() + local _line + # Use cached ss output if available (set by _dash_render), else run ss + local _ss_data="${_DASH_SS_CACHE:-}" + if [[ -z "$_ss_data" ]]; then + _ss_data=$(ss -tn 2>/dev/null) || true + fi + while IFS= read -r _line; do + [[ -z "$_line" ]] && continue + # Extract peer address via bash string ops (no awk fork) + # ss output: ESTAB 0 0 local:port peer:port + local _rest="${_line#*ESTAB}" + [[ "$_rest" == "$_line" ]] && continue # not ESTAB + # Split on whitespace: skip recv-q, send-q, local addr; get peer addr + read -r _ _ _ _src <<< "$_rest" || continue + # Strip port: 192.168.1.5:43210 → 192.168.1.5 + _src="${_src%:*}" + if [[ -n "$_src" ]] && [[ -z "${_seen[$_src]:-}" ]]; then + _seen["$_src"]=1 + _unique+=("$_src") + fi + done < <(echo "$_ss_data" | grep -F ":${port}" || true) + + local count=${#_unique[@]} + if (( count == 0 )); then + echo "0 clients" + elif (( count <= 5 )); then + local _joined + _joined=$(IFS=, ; echo "${_unique[*]}") + echo "${count} clients: ${_joined// /}" + else + local _first5 + _first5=$(IFS=, ; echo "${_unique[*]:0:5}") + echo "${count} clients: ${_first5// /} (+$((count-5)))" + fi + return 0 +} + +# ── Dashboard helper: cached latency check (30s TTL) ── +# Result stored in _DASH_LATENCY[name] — caller reads directly (no subshell) +_dash_latency_cached() { + local name="$1" host="$2" port="${3:-22}" + local now + printf -v now '%(%s)T' -1 + + # Cache by host:port (not tunnel name) so multiple tunnels to same server share one check + local _cache_key="${host}:${port}" + if [[ -n "${_DASH_LAT_HOST[$_cache_key]:-}" ]] && [[ -n "${_DASH_LAT_HOST_TS[$_cache_key]:-}" ]] \ + && (( now - ${_DASH_LAT_HOST_TS[$_cache_key]} < 30 )); then + _DASH_LATENCY["$name"]="${_DASH_LAT_HOST[$_cache_key]}" + return 0 + fi + + local rating icon + rating=$(_connection_quality "$host" "$port" 2>/dev/null) || true + : "${rating:=unknown}" + icon=$(_quality_icon "$rating" 2>/dev/null) || true + : "${icon:=?}" + + local _result="${rating} ${icon}" + _DASH_LAT_HOST["$_cache_key"]="$_result" + _DASH_LAT_HOST_TS["$_cache_key"]=$now + _DASH_LATENCY["$name"]="$_result" + return 0 +} + +# Render the complete dashboard frame +_dash_render() { + local LC_CTYPE=C.UTF-8 + local width=72 + + # Cache ss output once per render cycle (used by get_tunnel_connections + _dash_active_conns) + _DASH_SS_CACHE=$(ss -tn 2>/dev/null) || true + + # Load profiles + compute pagination (before header, so subtitle can show page) + local profiles + profiles=$(list_profiles) + local has_tunnels=false + local -A _dash_alive=() # cache: name→1 for running tunnels + local -A _dash_port=() # cache: name→LOCAL_PORT for running tunnels + local -A _dash_tls_port=() # cache: name→OBFS_LOCAL_PORT for running tunnels + local -a _dash_page_names=() # tunnels on current page (for active conns) + + local -a _all_profiles=() + if [[ -n "$profiles" ]]; then + while IFS= read -r _pname; do + [[ -z "$_pname" ]] && continue + _all_profiles+=("$_pname") + done <<< "$profiles" + fi + local _total=${#_all_profiles[@]} + + # Auto-calculate tunnels per page based on terminal height + # Fixed overhead: header(8) + colhdr(3) + sysres(3) + active_conn_hdr(2) + log(4) + # + reconnect(4) + sysinfo(2) + footer(3) = ~29 lines + # Per tunnel on page: ~5 lines (status + sparkline + route + auth + active_conn_row) + local _term_h="${_DASH_TERM_H:-40}" + local _overhead=29 + _DASH_PER_PAGE=$(( (_term_h - _overhead) / 5 )) + if (( _DASH_PER_PAGE < 2 )); then _DASH_PER_PAGE=2; fi + if (( _DASH_PER_PAGE > 8 )); then _DASH_PER_PAGE=8; fi + + if (( _total > 0 )); then + _DASH_TOTAL_PAGES=$(( (_total + _DASH_PER_PAGE - 1) / _DASH_PER_PAGE )) + else + _DASH_TOTAL_PAGES=1 + fi + if (( _DASH_PAGE >= _DASH_TOTAL_PAGES )); then _DASH_PAGE=$(( _DASH_TOTAL_PAGES - 1 )); fi + if (( _DASH_PAGE < 0 )); then _DASH_PAGE=0; fi + local _pg_start=$(( _DASH_PAGE * _DASH_PER_PAGE )) + local _pg_end=$(( _pg_start + _DASH_PER_PAGE )) + if (( _pg_end > _total )); then _pg_end=$_total; fi + + # Header + printf "${BOLD_GREEN}" + _dash_box_top "$width" + printf "${RESET}\n" + + # Title bar (3-row ASCII art) + local _tr1=" ▀▀█▀▀ █ █ █▄ █ █▄ █ █▀▀ █ █▀▀ █▀█ █▀█ █▀▀ █▀▀" + local _tr2=" █ █ █ █ ▀█ █ ▀█ █▀▀ █ █▀ █ █ █▀█ █ █ █▀▀" + local _tr3=" █ ▀▀ █ █ █ █ ▀▀▀ ▀▀▀ █ ▀▀▀ █ █ ▀▀▀ ▀▀▀" + local _tpad _tr + for _tr in "$_tr1" "$_tr2" "$_tr3"; do + _tpad=$(( width - ${#_tr} - 4 )) + if (( _tpad < 0 )); then _tpad=0; fi + printf "${BOLD_GREEN}║${RESET} ${BOLD_CYAN}%s${RESET}%*s ${BOLD_GREEN}║${RESET}\n" "$_tr" "$_tpad" "" + done + + # Subtitle + local now_ts + printf -v now_ts '%(%Y-%m-%d %H:%M:%S)T' -1 + local _page_ind="" + if (( _DASH_TOTAL_PAGES > 1 )); then + _page_ind=" │ Page $(( _DASH_PAGE + 1 ))/${_DASH_TOTAL_PAGES}" + fi + local sub_text=" Dashboard v${VERSION}${_page_ind} │ ${now_ts}" + printf "${BOLD_GREEN}║${RESET} ${DIM}%s${RESET}" "$sub_text" + local sub_len=${#sub_text} + local _sub_pad=$(( width - sub_len - 4 )) + if (( _sub_pad < 0 )); then _sub_pad=0; fi + printf '%*s' "$_sub_pad" "" + printf " ${BOLD_GREEN}║${RESET}\n" + + printf "${BOLD_GREEN}" + _dash_box_mid "$width" + printf "${RESET}\n" + + # Column headers — fixed 70 inner width layout + local hdr + hdr=$(printf " ${BOLD}%-12s %-5s %-8s %-13s %-8s %-5s %-8s${RESET}" \ + "TUNNEL" "TYPE" "STATUS" "LOCAL" "TRAFFIC" "CONNS" "UPTIME") + printf "${BOLD_GREEN}║${RESET}%s" "$hdr" + local hdr_stripped + _strip_ansi_v hdr_stripped "$hdr" + local hdr_len=${#hdr_stripped} + local _hdr_pad=$(( width - hdr_len - 2 )) + if (( _hdr_pad < 0 )); then _hdr_pad=0; fi + printf '%*s' "$_hdr_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + + # Separator + printf "${BOLD_GREEN}║${RESET}${DIM}" + local _i + for (( _i=0; _i 0 )); then + local _pidx + for (( _pidx=0; _pidx<_total; _pidx++ )); do + local _dname="${_all_profiles[$_pidx]}" + has_tunnels=true + + # For ALL tunnels: track running status (needed by logs section) + if is_tunnel_running "$_dname"; then + _dash_alive["$_dname"]=1 + fi + + # Skip rendering + heavy work for tunnels not on current page + if (( _pidx < _pg_start || _pidx >= _pg_end )); then + continue + fi + _dash_page_names+=("$_dname") + + # Truncate long names for column alignment + local _dname_display="$_dname" + if (( ${#_dname_display} > 12 )); then + _dname_display="${_dname_display:0:11}~" + fi + + unset _dp 2>/dev/null || true + local -A _dp=() + load_profile "$_dname" _dp 2>/dev/null || true + + local dtype="${_dp[TUNNEL_TYPE]:-?}" + local daddr="${_dp[LOCAL_BIND_ADDR]:-}:${_dp[LOCAL_PORT]:-}" + + # Populate port caches (for active connections on this page) + local _is_running=false + if [[ -n "${_dash_alive[$_dname]:-}" ]]; then + _is_running=true + _dash_port["$_dname"]="${_dp[LOCAL_PORT]:-}" + local _olp_cache="${_dp[OBFS_LOCAL_PORT]:-0}" + [[ "$_olp_cache" == "0" ]] && _olp_cache="" + _dash_tls_port["$_dname"]="$_olp_cache" + fi + + if [[ "$_is_running" == true ]]; then + # Gather live stats + local up_s up_str traffic rchar wchar total traf_str conns + up_s=$(get_tunnel_uptime "$_dname" 2>/dev/null || true) + : "${up_s:=0}" + up_str=$(format_duration "$up_s") + traffic=$(get_tunnel_traffic "$_dname" 2>/dev/null || true) + : "${traffic:=0 0}" + read -r rchar wchar <<< "$traffic" + [[ "$rchar" =~ ^[0-9]+$ ]] || rchar=0 + [[ "$wchar" =~ ^[0-9]+$ ]] || wchar=0 + total=$(( rchar + wchar )) + traf_str=$(format_bytes "$total") + # Inline connection count — profile already loaded, skip redundant load_profile + local _conn_port="${_dp[LOCAL_PORT]:-}" + local _conn_olp="${_dp[OBFS_LOCAL_PORT]:-0}" + if [[ "$_conn_olp" =~ ^[0-9]+$ ]] && (( _conn_olp > 0 )); then + _conn_port="$_conn_olp" + fi + conns=$(_count_port_conns "$_conn_port" 2>/dev/null || true) + : "${conns:=0}" + + # Record bandwidth for sparkline + _bw_record "$_dname" "$rchar" "$wchar" + + local row + row=$(printf " %-12s %-5s ${GREEN}● %-6s${RESET} %-13s %-8s %-5s %-8s" \ + "$_dname_display" "${dtype^^}" "ALIVE" "$daddr" "$traf_str" "$conns" "$up_str") + printf "${BOLD_GREEN}║${RESET}%s" "$row" + local row_stripped + _strip_ansi_v row_stripped "$row" + local row_len=${#row_stripped} + local _row_pad=$(( width - row_len - 2 )) + if (( _row_pad < 0 )); then _row_pad=0; fi + printf '%*s' "$_row_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + + # Sparkline row + local -a rx_deltas=() + local drx dtx + while read -r drx dtx; do + rx_deltas+=("$drx") + done < <(_bw_read_deltas "$_dname" 30) + + if [[ ${#rx_deltas[@]} -gt 2 ]]; then + local spark_str + spark_str=$(_sparkline "${rx_deltas[@]}") + local last_rate="${rx_deltas[-1]:-0}" + local rate_str + rate_str=$(format_bytes "$last_rate") + + # [1] Peak speed from deltas + local _peak=0 _dv + for _dv in "${rx_deltas[@]}"; do + if (( _dv > _peak )); then _peak=$_dv; fi + done + local peak_str + peak_str=$(format_bytes "$_peak") + + local spark_row + spark_row=$(printf " ${DIM}%-12s${RESET} ${CYAN}%s${RESET} ${DIM}%s/s${RESET} ${DIM}│${RESET} ${DIM}peak: %s/s${RESET}" \ + "" "$spark_str" "$rate_str" "$peak_str") + printf "${BOLD_GREEN}║${RESET}%s" "$spark_row" + local spark_stripped + _strip_ansi_v spark_stripped "$spark_row" + local spark_len=${#spark_stripped} + local _sp_pad=$(( width - spark_len - 2 )) + if (( _sp_pad < 0 )); then _sp_pad=0; fi + printf '%*s' "$_sp_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + fi + + # [2] Route row — show hop chain + local _route_str="" + local _ssh_host="${_dp[SSH_HOST]:-}" + local _jump="${_dp[JUMP_HOSTS]:-}" + if [[ -n "$_jump" ]]; then + # Extract jump host IP (user@host:port → host) + local _jh="${_jump%%,*}" # first jump host + _jh="${_jh#*@}" # strip user@ + _jh="${_jh%%:*}" # strip :port + _route_str="route: → ${_jh} → ${_ssh_host}" + elif [[ -n "$_ssh_host" ]]; then + _route_str="route: → ${_ssh_host}" + fi + if [[ -n "$_route_str" ]]; then + local _rr + _rr=$(printf " ${DIM}%-13s${RESET}${DIM}%s${RESET}" "" "$_route_str") + printf "${BOLD_GREEN}║${RESET}%s" "$_rr" + local _rr_s + _strip_ansi_v _rr_s "$_rr" + local _rr_pad=$(( width - ${#_rr_s} - 2 )) + if (( _rr_pad < 0 )); then _rr_pad=0; fi + printf '%*s' "$_rr_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + fi + + # [3] Auth + latency row + local _auth_method="interactive" + local _has_key=false _has_pass=false + if [[ -n "${_dp[IDENTITY_KEY]:-}" ]] && [[ -f "${_dp[IDENTITY_KEY]:-}" ]]; then _has_key=true; fi + if [[ -n "${_dp[SSH_PASSWORD]:-}" ]]; then _has_pass=true; fi + if [[ "$_has_key" == true ]] && [[ "$_has_pass" == true ]]; then _auth_method="key+pass" + elif [[ "$_has_key" == true ]]; then _auth_method="key" + elif [[ "$_has_pass" == true ]]; then _auth_method="password" + fi + local _lat_rating="" _lat_icon="" + if [[ "$_is_running" == true ]]; then + # Tunnel alive — skip slow TCP probe, show "active" directly + _lat_rating="active" + _lat_icon="${GREEN}▁▃▅▇${RESET}" + else + _dash_latency_cached "$_dname" "${_dp[SSH_HOST]:-}" "${_dp[SSH_PORT]:-22}" + local _lat_info="${_DASH_LATENCY[$_dname]:-unknown ?}" + _lat_rating="${_lat_info%% *}" + _lat_icon="${_lat_info#* }" + fi + local _obfs_ind="" + if [[ "${_dp[OBFS_MODE]:-none}" != "none" ]]; then + _obfs_ind="${DIM}│${RESET}${GREEN}tls${RESET}" + if [[ -n "${_dp[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_dp[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then + _obfs_ind="${_obfs_ind}${DIM}+psk:${RESET}${GREEN}${_dp[OBFS_LOCAL_PORT]}${RESET}" + fi + elif [[ -n "${_dp[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_dp[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then + _obfs_ind="${DIM}│${RESET}${GREEN}psk:${_dp[OBFS_LOCAL_PORT]}${RESET}" + fi + local _ar + _ar=$(printf " ${DIM}%-13s${RESET}${DIM}auth:${RESET}${BOLD}%s${RESET} ${DIM}│${RESET}${DIM}lat:${RESET}%s${CYAN}%s${RESET} %s" \ + "" "$_auth_method" "$_lat_rating" "$_lat_icon" "$_obfs_ind") + printf "${BOLD_GREEN}║${RESET}%s" "$_ar" + local _ar_s + _strip_ansi_v _ar_s "$_ar" + local _ar_pad=$(( width - ${#_ar_s} - 2 )) + if (( _ar_pad < 0 )); then _ar_pad=0; fi + printf '%*s' "$_ar_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + + # [4] Security row (only if dns or kill configured) + local _show_sec=false + if [[ "${_dp[DNS_LEAK_PROTECTION]:-}" == "true" ]] || [[ "${_dp[KILL_SWITCH]:-}" == "true" ]]; then + _show_sec=true + fi + if [[ "$_show_sec" == true ]]; then + local _dns_ind _kill_ind + if [[ "${_dp[DNS_LEAK_PROTECTION]:-}" == "true" ]]; then + _dns_ind="${GREEN}●${RESET}" + else + _dns_ind="${DIM}○${RESET}" + fi + if [[ "${_dp[KILL_SWITCH]:-}" == "true" ]]; then + _kill_ind="${GREEN}●${RESET}" + else + _kill_ind="${DIM}○${RESET}" + fi + local _sr + _sr=$(printf " ${DIM}%-13s${RESET}${DIM}security:${RESET} dns %s kill %s" "" "$_dns_ind" "$_kill_ind") + printf "${BOLD_GREEN}║${RESET}%s" "$_sr" + local _sr_s + _strip_ansi_v _sr_s "$_sr" + local _sr_pad=$(( width - ${#_sr_s} - 2 )) + if (( _sr_pad < 0 )); then _sr_pad=0; fi + printf '%*s' "$_sr_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + fi + else + local row + row=$(printf " ${DIM}%-12s %-5s ■ %-6s %-13s %-8s %-5s %-8s${RESET}" \ + "$_dname_display" "${dtype^^}" "STOP" "$daddr" "-" "-" "-") + printf "${BOLD_GREEN}║${RESET}%s" "$row" + local row_stripped + _strip_ansi_v row_stripped "$row" + local row_len=${#row_stripped} + local _row_pad=$(( width - row_len - 2 )) + if (( _row_pad < 0 )); then _row_pad=0; fi + printf '%*s' "$_row_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + fi + + done + fi + + if [[ "$has_tunnels" != true ]]; then + local empty_msg=" No tunnels configured. Press 'c' to create one." + printf "${BOLD_GREEN}║${RESET}${DIM}%s${RESET}" "$empty_msg" + printf '%*s' "$(( width - ${#empty_msg} - 2 ))" "" + printf "${BOLD_GREEN}║${RESET}\n" + fi + + # [5] System Resources section + printf "${BOLD_GREEN}" + _dash_box_mid "$width" + printf "${RESET}\n" + + local _sysres_hdr + _sysres_hdr=$(printf " ${BOLD}System Resources${RESET}") + printf "${BOLD_GREEN}║${RESET}%s" "$_sysres_hdr" + local _sysres_hdr_s + _strip_ansi_v _sysres_hdr_s "$_sysres_hdr" + local _sysres_hdr_pad=$(( width - ${#_sysres_hdr_s} - 2 )) + if (( _sysres_hdr_pad < 0 )); then _sysres_hdr_pad=0; fi + printf '%*s' "$_sysres_hdr_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + + _dash_system_resources + local _sysres_data="$_DASH_SYSRES" + local _sysres_row + _sysres_row=$(printf " ${DIM}%s${RESET}" "$_sysres_data") + printf "${BOLD_GREEN}║${RESET}%s" "$_sysres_row" + local _sysres_row_s + _strip_ansi_v _sysres_row_s "$_sysres_row" + local _sysres_row_pad=$(( width - ${#_sysres_row_s} - 2 )) + if (( _sysres_row_pad < 0 )); then _sysres_row_pad=0; fi + printf '%*s' "$_sysres_row_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + + # [6] Active Connections section (current page only) + if [[ "$has_tunnels" == true ]]; then + local _any_alive=false _ac_lines="" + local _acname + for _acname in "${_dash_page_names[@]}"; do + [[ -z "${_dash_alive[$_acname]:-}" ]] && continue + _any_alive=true + local _ac_port="${_dash_port[$_acname]:-}" + if [[ -n "$_ac_port" ]]; then + # Use cached TLS port from tunnel row loop (no re-load) + local _ac_tls_port="${_dash_tls_port[$_acname]:-}" + local _ac_data _ac_label + if [[ -n "$_ac_tls_port" ]]; then + _ac_data=$(_dash_active_conns "$_ac_tls_port") + _ac_label=":${_ac_tls_port}" + else + _ac_data=$(_dash_active_conns "$_ac_port") + _ac_label=":${_ac_port}" + fi + _ac_lines+="${_acname}|${_ac_label}|${_ac_data}"$'\n' + fi + done + if [[ "$_any_alive" == true ]] && [[ -n "$_ac_lines" ]]; then + printf "${BOLD_GREEN}" + _dash_box_mid "$width" + printf "${RESET}\n" + + local _ac_hdr + _ac_hdr=$(printf " ${BOLD}Active Connections${RESET}") + printf "${BOLD_GREEN}║${RESET}%s" "$_ac_hdr" + local _ac_hdr_s + _strip_ansi_v _ac_hdr_s "$_ac_hdr" + local _ac_hdr_pad=$(( width - ${#_ac_hdr_s} - 2 )) + if (( _ac_hdr_pad < 0 )); then _ac_hdr_pad=0; fi + printf '%*s' "$_ac_hdr_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + + while IFS='|' read -r _ac_n _ac_p _ac_d; do + [[ -z "$_ac_n" ]] && continue + local _ac_row + _ac_row=$(printf " ${DIM}%s %s${RESET} %s" "$_ac_n" "$_ac_p" "$_ac_d") + printf "${BOLD_GREEN}║${RESET}%s" "$_ac_row" + local _ac_row_s + _strip_ansi_v _ac_row_s "$_ac_row" + local _ac_pad=$(( width - ${#_ac_row_s} - 2 )) + if (( _ac_pad < 0 )); then _ac_pad=0; fi + printf '%*s' "$_ac_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + done <<< "$_ac_lines" + fi + fi + + # [7] Recent Log section (last lines per active tunnel, max 2 total) + if [[ "$has_tunnels" == true ]]; then + local _log_lines="" _log_count=0 + local _lgname + for _lgname in "${!_dash_alive[@]}"; do + (( _log_count >= 2 )) && break + local _lf="${LOG_DIR}/${_lgname}.log" + if [[ -f "$_lf" ]]; then + local _ltail + _ltail=$(tail -20 "$_lf" 2>/dev/null) || true + while IFS= read -r _ll; do + [[ -z "$_ll" ]] && continue + (( _log_count >= 2 )) && break + # Skip normal SSH/SOCKS5 proxy noise + [[ "$_ll" == *"channel"*"open failed"* ]] && continue + [[ "$_ll" == *"Connection refused"* ]] && continue + [[ "$_ll" == *"Name or service not known"* ]] && continue + [[ "$_ll" == *"bind"*"Address already in use"* ]] && continue + [[ "$_ll" == *"cannot listen to"* ]] && continue + [[ "$_ll" == *"not request local forwarding"* ]] && continue + # Truncate long lines + local _ldisp="[${_lgname}] ${_ll}" + if (( ${#_ldisp} > width - 5 )); then + _ldisp="${_ldisp:0:$(( width - 8 ))}..." + fi + _log_lines+="${_ldisp}"$'\n' + ((++_log_count)) + done <<< "$_ltail" + fi + done + if (( _log_count > 0 )); then + printf "${BOLD_GREEN}" + _dash_box_mid "$width" + printf "${RESET}\n" + + local _lg_hdr + _lg_hdr=$(printf " ${BOLD}Recent Log${RESET}") + printf "${BOLD_GREEN}║${RESET}%s" "$_lg_hdr" + local _lg_hdr_s + _strip_ansi_v _lg_hdr_s "$_lg_hdr" + local _lg_hdr_pad=$(( width - ${#_lg_hdr_s} - 2 )) + if (( _lg_hdr_pad < 0 )); then _lg_hdr_pad=0; fi + printf '%*s' "$_lg_hdr_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + + while IFS= read -r _lg_row; do + [[ -z "$_lg_row" ]] && continue + local _lgr + _lgr=$(printf " ${DIM}%s${RESET}" "$_lg_row") + printf "${BOLD_GREEN}║${RESET}%s" "$_lgr" + local _lgr_s + _strip_ansi_v _lgr_s "$_lgr" + local _lgr_pad=$(( width - ${#_lgr_s} - 2 )) + if (( _lgr_pad < 0 )); then _lgr_pad=0; fi + printf '%*s' "$_lgr_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + done <<< "$_log_lines" + fi + fi + + # Reconnect summary section + printf "${BOLD_GREEN}" + _dash_box_mid "$width" + printf "${RESET}\n" + + local rc_header + rc_header=$(printf " ${BOLD}Reconnect Log${RESET}") + printf "${BOLD_GREEN}║${RESET}%s" "$rc_header" + local rc_hdr_stripped + _strip_ansi_v rc_hdr_stripped "$rc_header" + printf '%*s' "$(( width - ${#rc_hdr_stripped} - 2 ))" "" + printf "${BOLD_GREEN}║${RESET}\n" + + if [[ -n "$profiles" ]]; then + local _any_rc=false _rc_shown=0 + while IFS= read -r _rcname; do + [[ -z "$_rcname" ]] && continue + (( _rc_shown >= 2 )) && break + local rc_total rc_last + read -r rc_total rc_last <<< "$(_reconnect_stats "$_rcname")" + if (( rc_total > 0 )); then + _any_rc=true + (( ++_rc_shown )) || true + local rc_row + local _rc_display="$_rcname" + if (( ${#_rc_display} > 12 )); then _rc_display="${_rc_display:0:11}~"; fi + rc_row=$(printf " ${DIM}%-12s${RESET} reconnects: ${YELLOW}%d${RESET} last: ${DIM}%s${RESET}" \ + "$_rc_display" "$rc_total" "$rc_last") + printf "${BOLD_GREEN}║${RESET}%s" "$rc_row" + local rc_stripped + _strip_ansi_v rc_stripped "$rc_row" + local _rc_pad=$(( width - ${#rc_stripped} - 2 )) + if (( _rc_pad < 0 )); then _rc_pad=0; fi + printf '%*s' "$_rc_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + fi + done <<< "$profiles" + if [[ "$_any_rc" != true ]]; then + local no_rc=" ${DIM}No reconnections recorded${RESET}" + printf "${BOLD_GREEN}║${RESET}%s" "$no_rc" + local no_rc_stripped + _strip_ansi_v no_rc_stripped "$no_rc" + printf '%*s' "$(( width - ${#no_rc_stripped} - 2 ))" "" + printf "${BOLD_GREEN}║${RESET}\n" + fi + fi + + # System info row + printf "${BOLD_GREEN}" + _dash_box_mid "$width" + printf "${RESET}\n" + + local pub_ip="${_DASH_PUB_IP:-unknown}" + local _tg_indicator="" + if _telegram_enabled; then + _tg_indicator=" ${DIM}│${RESET} ${DIM}TG:${RESET} ${GREEN}●${RESET}" + fi + local _speed_indicator="" + if [[ -n "${_DASH_LAST_SPEED:-}" ]]; then + _speed_indicator=" ${DIM}│${RESET} ${CYAN}${_DASH_LAST_SPEED}${RESET}" + fi + local _dash_ref_rate + _dash_ref_rate=$(config_get DASHBOARD_REFRESH 5) + local _dash_time_str + printf -v _dash_time_str '%(%H:%M:%S)T' -1 + local sys_row + sys_row=$(printf " ${DIM}IP:${RESET} ${BOLD}%s${RESET} ${DIM}│${RESET} ${DIM}Refresh:${RESET} %ss%s%s ${DIM}│${RESET} ${DIM}%s${RESET}" \ + "$pub_ip" "$_dash_ref_rate" "$_tg_indicator" "$_speed_indicator" "$_dash_time_str") + printf "${BOLD_GREEN}║${RESET}%s" "$sys_row" + local sys_stripped + _strip_ansi_v sys_stripped "$sys_row" + local _sys_pad=$(( width - ${#sys_stripped} - 2 )) + if (( _sys_pad < 0 )); then _sys_pad=0; fi + printf '%*s' "$_sys_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + + # Footer with controls + printf "${BOLD_GREEN}" + _dash_box_mid "$width" + printf "${RESET}\n" + + local ctrl_row + local _pg_hint="" + if (( _DASH_TOTAL_PAGES > 1 )); then + _pg_hint=" ${DIM}│${RESET} ${CYAN}1${RESET}-${CYAN}${_DASH_TOTAL_PAGES}${RESET}${DIM}=page${RESET}" + fi + ctrl_row=$(printf " ${CYAN}s${RESET}=start ${CYAN}t${RESET}=stop ${CYAN}r${RESET}=restart ${CYAN}c${RESET}=create ${CYAN}p${RESET}=speed ${CYAN}g${RESET}=qlty ${CYAN}q${RESET}=quit%s" "$_pg_hint") + printf "${BOLD_GREEN}║${RESET}%s" "$ctrl_row" + local ctrl_stripped + _strip_ansi_v ctrl_stripped "$ctrl_row" + local _ctrl_pad=$(( width - ${#ctrl_stripped} - 2 )) + if (( _ctrl_pad < 0 )); then _ctrl_pad=0; fi + printf '%*s' "$_ctrl_pad" "" + printf "${BOLD_GREEN}║${RESET}\n" + + printf "${BOLD_GREEN}" + _dash_box_bottom "$width" + printf "${RESET}\n" +} + +# ── Main dashboard loop ── + +show_dashboard() { + local refresh + refresh=$(config_get DASHBOARD_REFRESH 5) + if (( refresh < 1 )); then refresh=1; fi + + # Enter alternate screen buffer + tput smcup 2>/dev/null || true + # Hide cursor + tput civis 2>/dev/null || true + + # Frame buffer for flicker-free rendering + local _frame_file="${TMPDIR:-/tmp}/tf-dash-$$" + : > "$_frame_file" 2>/dev/null || _frame_file="/tmp/tf-dash-$$" + : > "$_frame_file" + + # Restore terminal on exit (normal return, Ctrl+C, or TERM) + local _dash_cleanup_done=false + _dash_exit() { + if [[ "$_dash_cleanup_done" == true ]]; then return 0; fi + _dash_cleanup_done=true + rm -f "$_frame_file" "${TMP_DIR}/tg_cmd.lock" 2>/dev/null || true + tput cnorm 2>/dev/null || true # show cursor + tput rmcup 2>/dev/null || true # leave alternate screen + trap - TSTP CONT + trap cleanup INT TERM HUP QUIT # restore global traps + } + trap '_dash_exit' RETURN + local _dash_interrupted=false + trap '_dash_exit; _dash_interrupted=true' INT + trap '_dash_exit; _dash_interrupted=true' TERM + trap '_dash_exit; _dash_interrupted=true' HUP + trap '_dash_exit; _dash_interrupted=true' QUIT + trap 'tput cnorm 2>/dev/null || true; tput rmcup 2>/dev/null || true' TSTP + trap 'tput smcup 2>/dev/null || true; tput civis 2>/dev/null || true' CONT + + # Get local IP once (instant, no network call) + local _DASH_PUB_IP="" + _DASH_PUB_IP=$(hostname -I 2>/dev/null | awk '{print $1}') || true + if [[ -z "$_DASH_PUB_IP" ]]; then + _DASH_PUB_IP=$(ip -4 route get 1 2>/dev/null | grep -oE 'src [0-9.]+' | awk '{print $2}') || true + fi + : "${_DASH_PUB_IP:=unknown}" + + # Cache terminal height — updated on SIGWINCH, not every frame + declare -g _DASH_TERM_H + _DASH_TERM_H=$(tput lines 2>/dev/null) || _DASH_TERM_H=40 + trap '_DASH_TERM_H=$(tput lines 2>/dev/null) || _DASH_TERM_H=40' WINCH + local _dash_rot_count=0 + local _dash_tg_count=0 + + while true; do + if [[ "$_dash_interrupted" == true ]]; then break; fi + # Periodic log rotation (~5 min at default 3s refresh) + if (( ++_dash_rot_count >= 100 )); then + rotate_logs 2>/dev/null || true + _dash_rot_count=0 + fi + # Poll Telegram bot commands in background (~every 3 refreshes ≈ 9s) + if (( ++_dash_tg_count >= 3 )); then + _tg_process_commands_bg || true + _dash_tg_count=0 + fi + # Buffered render: compute frame to file, then flush to screen in one shot + _dash_render > "$_frame_file" 2>/dev/null || true + tput cup 0 0 2>/dev/null || printf '\033[H' + cat "$_frame_file" 2>/dev/null + tput ed 2>/dev/null || true # clear stale content below frame + + # Non-blocking read with timeout for refresh + local key="" + read -rsn1 -t "$refresh" key /dev/null || true + tput rmcup 2>/dev/null || true + _menu_start_tunnel || true + _press_any_key || true + tput smcup 2>/dev/null || true + tput civis 2>/dev/null || true + ;; + t|T) + tput cnorm 2>/dev/null || true + tput rmcup 2>/dev/null || true + _menu_stop_tunnel || true + _press_any_key || true + tput smcup 2>/dev/null || true + tput civis 2>/dev/null || true + ;; + r|R) + # Restart all running tunnels + tput cnorm 2>/dev/null || true + tput rmcup 2>/dev/null || true + local _dr_profiles + _dr_profiles=$(list_profiles) + if [[ -n "$_dr_profiles" ]]; then + while IFS= read -r _dr_name; do + [[ -z "$_dr_name" ]] && continue + if is_tunnel_running "$_dr_name"; then + restart_tunnel "$_dr_name" || true + fi + done <<< "$_dr_profiles" + fi + _press_any_key || true + tput smcup 2>/dev/null || true + tput civis 2>/dev/null || true + ;; + c|C) + tput cnorm 2>/dev/null || true + tput rmcup 2>/dev/null || true + wizard_create_profile || true + _press_any_key || true + tput smcup 2>/dev/null || true + tput civis 2>/dev/null || true + ;; + p|P) + tput cnorm 2>/dev/null || true + tput rmcup 2>/dev/null || true + _speed_test || true + _press_any_key || true + tput smcup 2>/dev/null || true + tput civis 2>/dev/null || true + ;; + g|G) + tput cnorm 2>/dev/null || true + tput rmcup 2>/dev/null || true + printf "\n ${BOLD}Connection Quality Check${RESET}\n\n" >/dev/tty + local _gq_profiles + _gq_profiles=$(list_profiles) + if [[ -n "$_gq_profiles" ]]; then + while IFS= read -r _gq_name; do + [[ -z "$_gq_name" ]] && continue + local _gq_host + _gq_host=$(get_profile_field "$_gq_name" "SSH_HOST" 2>/dev/null) || true + local _gq_port + _gq_port=$(get_profile_field "$_gq_name" "SSH_PORT" 2>/dev/null) || true + : "${_gq_port:=22}" + if [[ -n "$_gq_host" ]]; then + local _gq_rating + _gq_rating=$(_connection_quality "$_gq_host" "$_gq_port" 2>/dev/null) || true + : "${_gq_rating:=unknown}" + printf " %-16s %s %s → %s:%s\n" "$_gq_name" "$(_quality_icon "$_gq_rating")" "$_gq_rating" "$_gq_host" "$_gq_port" >/dev/tty + fi + done <<< "$_gq_profiles" + else + printf " ${DIM}No profiles configured${RESET}\n" >/dev/tty + fi + printf "\n" >/dev/tty + _press_any_key || true + tput smcup 2>/dev/null || true + tput civis 2>/dev/null || true + ;; + \[|,) + # Previous page + if (( _DASH_PAGE > 0 )); then _DASH_PAGE=$(( _DASH_PAGE - 1 )); fi + ;; + \]|.) + # Next page + if (( _DASH_PAGE < _DASH_TOTAL_PAGES - 1 )); then _DASH_PAGE=$(( _DASH_PAGE + 1 )); fi + ;; + [1-9]) + # Jump to page N + local _target_pg=$(( key - 1 )) + if (( _target_pg < _DASH_TOTAL_PAGES )); then + _DASH_PAGE=$_target_pg + fi + ;; + *) true ;; + esac + done +} + +# ============================================================================ +# INSTALLER +# ============================================================================ + +install_tunnelforge() { + show_banner >/dev/tty + log_info "Installing ${APP_NAME} v${VERSION}..." + + check_root "install" || return 1 + detect_os + + init_directories || { log_error "Failed to create directories"; return 1; } + + # Copy script + local script_path dest + script_path="$(cd "$(dirname "$0")" 2>/dev/null && pwd || pwd)/$(basename "$0")" + dest="${INSTALL_DIR}/tunnelforge.sh" + + if [[ "$script_path" != "$dest" ]]; then + cp "$script_path" "$dest" + chmod +x "$dest" + log_success "Installed to ${dest}" + fi + + if ln -sf "$dest" "$BIN_LINK" 2>/dev/null; then + log_success "Created symlink: ${BIN_LINK}" + else + log_warn "Could not create symlink: ${BIN_LINK}" + fi + + check_dependencies || log_warn "Some dependencies could not be installed" + + if [[ ! -f "$MAIN_CONFIG" ]]; then + if save_settings; then + log_success "Created config: ${MAIN_CONFIG}" + else + log_warn "Could not create config file — using defaults" + fi + fi + + printf "\n" + printf "${BOLD_GREEN}" + printf " ╔══════════════════════════════════════════════════════════╗\n" + printf " ║ %s installed successfully! ║\n" "$APP_NAME" + printf " ╠══════════════════════════════════════════════════════════╣\n" + printf " ║ ║\n" + printf " ║ Commands: ║\n" + printf " ║ tunnelforge menu Interactive menu ║\n" + printf " ║ tunnelforge create Create a tunnel ║\n" + printf " ║ tunnelforge help Show all commands ║\n" + printf " ║ ║\n" + printf " ╚══════════════════════════════════════════════════════════╝\n" + printf "${RESET}\n" + + # Offer to launch interactive menu + local _ans="" + printf " Launch interactive menu now? [y/N] " >/dev/tty + read -rsn1 _ans /dev/tty + if [[ "$_ans" == "y" || "$_ans" == "Y" ]]; then + detect_os; load_settings + show_menu || true + fi +} + +# ============================================================================ +# CLI ENTRY POINT +# ============================================================================ + +is_installed() { + [[ -f "${INSTALL_DIR}/tunnelforge.sh" && -f "$MAIN_CONFIG" ]] +} + +cli_main() { + local command="${1:-}" + shift 2>/dev/null || true + + # Ensure runtime directories exist for all commands + init_directories 2>/dev/null || true + + case "$command" in + # ── Tunnel commands ── + start) + [[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge start "; return 1; } + validate_profile_name "$1" || { log_error "Invalid profile name"; return 1; } + detect_os; load_settings; start_tunnel "$1" ;; + stop) + [[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge stop "; return 1; } + validate_profile_name "$1" || { log_error "Invalid profile name"; return 1; } + load_settings; stop_tunnel "$1" || true ;; + restart) + [[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge restart "; return 1; } + validate_profile_name "$1" || { log_error "Invalid profile name"; return 1; } + detect_os; load_settings; restart_tunnel "$1" || true ;; + start-all) + detect_os; load_settings; start_all_tunnels || true ;; + stop-all) + load_settings; stop_all_tunnels || true ;; + status) + load_settings; show_status || true ;; + + # ── Profile commands ── + list|ls) + load_settings + local profiles + profiles=$(list_profiles) + if [[ -z "$profiles" ]]; then + log_info "No profiles found. Run 'tunnelforge create' to get started." + else + printf "\n${BOLD}Tunnel Profiles:${RESET}\n" + print_line "─" 50 + while IFS= read -r _ls_name; do + local _ls_ptype _ls_status + _ls_ptype=$(get_profile_field "$_ls_name" "TUNNEL_TYPE" 2>/dev/null) || true + if is_tunnel_running "$_ls_name"; then + _ls_status="${GREEN}● running${RESET}" + else + _ls_status="${DIM}■ stopped${RESET}" + fi + printf " %-20s %-10s %b\n" "$_ls_name" "${_ls_ptype:-?}" "$_ls_status" + done <<< "$profiles" + printf "\n" + fi ;; + create|new) + detect_os; load_settings + setup_wizard || true ;; + delete) + [[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge delete "; return 1; } + validate_profile_name "$1" || { log_error "Invalid profile name"; return 1; } + load_settings + if confirm_action "Delete profile '${1}'?"; then + delete_profile "$1" || true + fi ;; + + # ── Display commands ── + dashboard|dash) + if ! : >/dev/tty 2>/dev/null; then + log_error "Dashboard requires an interactive terminal (/dev/tty not available)" + return 1 + fi + load_settings + show_dashboard || true ;; + menu) + detect_os; load_settings + show_menu ;; + logs) + load_settings + local target="${1:-}" + if [[ -n "$target" ]]; then + validate_profile_name "$target" || { log_error "Invalid profile name"; return 1; } + local lf; lf=$(_log_file "$target") + if [[ -f "$lf" ]]; then + tail -f "$lf" || true + else + log_error "No logs for '${target}'" + fi + else + local ml="${LOG_DIR}/${APP_NAME_LOWER}.log" + if [[ -f "$ml" ]]; then + tail -f "$ml" || true + else + log_info "No logs found" + fi + fi ;; + + # ── Security commands ── + audit|security) + load_settings; security_audit || true ;; + key-gen) + load_settings + local ktype="${1:-ed25519}" + case "$ktype" in + ed25519|rsa|ecdsa) ;; + *) log_error "Unsupported key type '${ktype}' (use: ed25519, rsa, ecdsa)"; return 1 ;; + esac + generate_ssh_key "$ktype" "${HOME}/.ssh/id_${ktype}" || true ;; + key-deploy) + [[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge key-deploy "; return 1; } + validate_profile_name "$1" || { log_error "Invalid profile name"; return 1; } + load_settings; deploy_ssh_key "$1" || true ;; + fingerprint) + [[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge fingerprint [port]"; return 1; } + load_settings; verify_host_fingerprint "$1" "${2:-22}" || true ;; + + # ── Telegram commands ── + telegram|tg) + load_settings + local tg_action="${1:-status}" + case "$tg_action" in + setup) telegram_setup || true ;; + test) telegram_test || true ;; + status) telegram_status || true ;; + send) shift; [[ -z "$*" ]] && { log_error "Usage: tunnelforge telegram send "; return 1; }; _telegram_send "$*" || { log_error "Send failed (is Telegram configured?)"; return 1; } ;; + report) telegram_send_status || true ;; + share) shift; telegram_share_client "${1:-}" || true ;; + *) log_error "Usage: tunnelforge telegram [setup|test|status|send|report|share]"; return 1 ;; + esac ;; + + # ── System commands ── + health) + load_settings; security_audit || true ;; + server-setup) + detect_os; load_settings + server_setup "${1:-}" || true ;; + obfs-setup|obfuscate) + [[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge obfs-setup "; return 1; } + detect_os; load_settings + _obfs_setup_stunnel "$1" || true ;; + client-config) + [[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge client-config "; return 1; } + detect_os; load_settings + local -A _cc_prof=() + load_profile "$1" _cc_prof || { log_error "Cannot load profile '$1'"; return 1; } + if [[ -z "${_cc_prof[OBFS_LOCAL_PORT]:-}" ]] || [[ "${_cc_prof[OBFS_LOCAL_PORT]:-0}" == "0" ]]; then + log_error "Profile '$1' has no inbound TLS configured" + printf "${DIM}Enable it in the wizard or set OBFS_LOCAL_PORT and OBFS_PSK in the profile.${RESET}\n" + return 1 + fi + _obfs_show_client_config "$1" _cc_prof || true ;; + client-script) + [[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge client-script [output-file]"; return 1; } + detect_os; load_settings + local -A _cs_prof=() + load_profile "$1" _cs_prof || { log_error "Cannot load profile '$1'"; return 1; } + _obfs_generate_client_script "$1" _cs_prof "${2:-}" || true + _obfs_generate_client_script_win "$1" _cs_prof "" || true ;; + service) + [[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge service [enable|disable|status|remove]"; return 1; } + validate_profile_name "$1" || { log_error "Invalid profile name"; return 1; } + detect_os; load_settings + local svc_name="$1" svc_action="${2:-}" + case "$svc_action" in + enable) enable_service "$svc_name" || true ;; + disable) disable_service "$svc_name" || true ;; + status) service_status "$svc_name" || true ;; + remove) remove_service "$svc_name" || true ;; + "") generate_service "$svc_name" || true ;; + *) log_error "Unknown action: ${svc_action}"; return 1 ;; + esac ;; + backup) + load_settings + backup_tunnelforge || true ;; + restore) + load_settings + restore_tunnelforge "${1:-}" || true ;; + uninstall) + detect_os; load_settings + uninstall_tunnelforge ;; + install) + install_tunnelforge ;; + update) + detect_os; load_settings + update_tunnelforge ;; + + # ── Info commands ── + version|-v|--version) + show_version ;; + help|-h|--help) + show_help ;; + + # ── Default: first run or menu ── + "") + if is_installed; then + detect_os; load_settings + show_menu + else + install_tunnelforge + if is_installed; then + _press_any_key + detect_os; load_settings + show_menu + fi + fi ;; + + *) + log_error "Unknown command: ${command}" + log_info "Run 'tunnelforge help' for available commands" + return 1 ;; + esac +} + +# ============================================================================ +# MAIN +# ============================================================================ + +main() { cli_main "$@"; } +main "$@" diff --git a/windows-client/tunnelforge-client.bat b/windows-client/tunnelforge-client.bat new file mode 100644 index 0000000..21198ac --- /dev/null +++ b/windows-client/tunnelforge-client.bat @@ -0,0 +1,233 @@ +@echo off +setlocal EnableDelayedExpansion +title TunnelForge Client +color 0A + +:: TunnelForge Client for Windows +:: Download this file and run it. Paste your connection +:: code and you're connected. + +set "CONF_DIR=%USERPROFILE%\.tunnelforge-client" +set "STUNNEL_CONF=%CONF_DIR%\stunnel.conf" +set "PSK_FILE=%CONF_DIR%\psk.txt" +set "PID_FILE=%CONF_DIR%\stunnel.pid" +set "LOG_FILE=%CONF_DIR%\stunnel.log" +set "SAVED_FILE=%CONF_DIR%\connection.dat" + +if /i "%~1"=="stop" goto :do_stop +if /i "%~1"=="status" goto :do_status + +echo. +echo =================================== +echo TunnelForge Client v1.0 +echo Secure TLS+PSK Connection +echo =================================== +echo. + +if not exist "%CONF_DIR%" mkdir "%CONF_DIR%" 2>nul + +if exist "%SAVED_FILE%" ( + echo [*] Found saved connection. + set /p "REUSE= Use saved connection? [Y/n]: " + if /i "!REUSE!"=="n" goto :new_connection + for /f "tokens=1,2,3,4 delims=:" %%a in ('type "%SAVED_FILE%"') do ( + set "SERVER=%%a" + set "PORT=%%b" + set "LOCAL_PORT=%%c" + set "PSK=%%d" + ) + if defined SERVER if defined PSK goto :connect + echo [X] Saved connection is invalid. Enter new details. +) + +:new_connection +echo. +echo Enter connection details from your admin: +echo (Ask them to run: tunnelforge client-config ^) +echo. +set /p "SERVER= Server address: " +if "!SERVER!"=="" ( + echo [X] Server address required. + goto :new_connection +) +set /p "PORT= Port [1443]: " +if "!PORT!"=="" set "PORT=1443" +set /p "LOCAL_PORT= Local SOCKS5 port [1080]: " +if "!LOCAL_PORT!"=="" set "LOCAL_PORT=1080" +set /p "PSK= PSK secret key: " +if "!PSK!"=="" ( + echo [X] PSK key required. + goto :new_connection +) + +echo !SERVER!:!PORT!:!LOCAL_PORT!:!PSK!> "%SAVED_FILE%" +attrib +h "%SAVED_FILE%" 2>nul + +:connect +echo. +echo [*] Server: !SERVER!:!PORT! +echo [*] Proxy: 127.0.0.1:!LOCAL_PORT! +echo. + +set "STUNNEL_BIN=" + +for %%p in ( + "C:\Program Files (x86)\stunnel\bin\stunnel.exe" + "C:\Program Files\stunnel\bin\stunnel.exe" + "%ProgramFiles%\stunnel\bin\stunnel.exe" + "%ProgramFiles(x86)%\stunnel\bin\stunnel.exe" + "%CONF_DIR%\stunnel\bin\stunnel.exe" +) do ( + if exist %%p set "STUNNEL_BIN=%%~p" +) + +if "!STUNNEL_BIN!"=="" ( + where stunnel >nul 2>&1 + if !errorlevel!==0 ( + for /f "delims=" %%i in ('where stunnel 2^>nul') do set "STUNNEL_BIN=%%i" + ) +) + +if "!STUNNEL_BIN!"=="" ( + echo [X] stunnel not found. Installing... + echo. + where winget >nul 2>&1 + if !errorlevel!==0 ( + echo [*] Trying winget... + winget install --id stunnel.stunnel --accept-source-agreements --accept-package-agreements >nul 2>&1 + for %%p in ( + "C:\Program Files (x86)\stunnel\bin\stunnel.exe" + "C:\Program Files\stunnel\bin\stunnel.exe" + ) do ( + if exist %%p set "STUNNEL_BIN=%%~p" + ) + ) + if "!STUNNEL_BIN!"=="" ( + where choco >nul 2>&1 + if !errorlevel!==0 ( + echo [*] Trying chocolatey... + choco install stunnel -y >nul 2>&1 + for %%p in ( + "C:\Program Files (x86)\stunnel\bin\stunnel.exe" + "C:\Program Files\stunnel\bin\stunnel.exe" + ) do ( + if exist %%p set "STUNNEL_BIN=%%~p" + ) + ) + ) + if "!STUNNEL_BIN!"=="" ( + echo. + echo [X] Could not install stunnel automatically. + echo. + echo Please install manually: + echo 1. Go to https://www.stunnel.org/downloads.html + echo 2. Download "stunnel-X.XX-win64-installer.exe" + echo 3. Install with default settings + echo 4. Run this script again + echo. + pause + exit /b 1 + ) +) + +echo [+] stunnel found: !STUNNEL_BIN! + +:: Check if already connected (port listening) +netstat -an 2>nul | find ":!LOCAL_PORT! " | find "LISTENING" >nul 2>&1 +if !errorlevel!==0 ( + echo [+] Already connected + echo SOCKS5 proxy: 127.0.0.1:!LOCAL_PORT! + echo. + echo Browser Setup: + echo Firefox: Settings - Proxy - Manual + echo SOCKS Host: 127.0.0.1 + echo Port: !LOCAL_PORT! + echo Select SOCKS v5 + echo Check "Proxy DNS when using SOCKS v5" + echo. + echo Chrome: + echo chrome --proxy-server="socks5://127.0.0.1:!LOCAL_PORT!" + echo. + echo Run "%~nx0 stop" to disconnect + goto :done +) + +echo tunnelforge:!PSK!> "%PSK_FILE%" + +( +echo ; TunnelForge client config +echo output = %LOG_FILE% +echo. +echo [tunnelforge] +echo client = yes +echo accept = 127.0.0.1:!LOCAL_PORT! +echo connect = !SERVER!:!PORT! +echo PSKsecrets = %PSK_FILE% +echo ciphers = PSK +) > "%STUNNEL_CONF%" + +echo [*] Connecting to !SERVER!:!PORT!... +start "" "!STUNNEL_BIN!" "%STUNNEL_CONF%" + +timeout /t 4 /nobreak >nul 2>&1 + +:: Check if local port is now listening +netstat -an 2>nul | find ":!LOCAL_PORT! " | find "LISTENING" >nul 2>&1 +if !errorlevel!==0 ( + echo. + echo =================================== + echo Connected + echo =================================== + echo. + echo SOCKS5 Proxy: 127.0.0.1:!LOCAL_PORT! + echo. + echo Browser Setup: + echo Firefox: Settings - Proxy - Manual + echo SOCKS Host: 127.0.0.1 + echo Port: !LOCAL_PORT! + echo Select SOCKS v5 + echo Check "Proxy DNS when using SOCKS v5" + echo. + echo Chrome: + echo chrome --proxy-server="socks5://127.0.0.1:!LOCAL_PORT!" + echo. + echo Commands: + echo %~nx0 status - check connection + echo %~nx0 stop - disconnect + echo. + goto :done +) + +echo [X] Connection failed. +echo Check %LOG_FILE% for details. +echo. +if exist "%LOG_FILE%" ( + echo Last log entries: + for /f "tokens=*" %%l in ('type "%LOG_FILE%" 2^>nul') do echo %%l +) +goto :done + +:do_stop +tasklist /fi "IMAGENAME eq stunnel.exe" 2>nul | find "stunnel" >nul 2>&1 +if !errorlevel!==1 ( + echo [X] Not connected. + goto :done +) +taskkill /im stunnel.exe /f >nul 2>&1 +echo [+] Disconnected. +goto :done + +:do_status +netstat -an 2>nul | find ":!LOCAL_PORT! " | find "LISTENING" >nul 2>&1 +if !errorlevel!==0 ( + echo [+] Connected + echo SOCKS5 proxy: 127.0.0.1:!LOCAL_PORT! +) else ( + echo [X] Not connected +) +goto :done + +:done +echo. +pause +endlocal