From 4baa10ce06da178e90e31f1dcde0138237fdf81c Mon Sep 17 00:00:00 2001 From: SamNet-dev Date: Tue, 24 Feb 2026 01:38:26 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20migrate=20tunnelforge=20to=20Gitea=20?= =?UTF-8?q?=E2=80=94=20update=20all=20URLs=20and=20self-update=20mechanism?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE | 677 ++ README.md | 2677 ++++++ screenshots/dashboard.png | Bin 0 -> 73358 bytes screenshots/tunnelforge-main.png | Bin 0 -> 103732 bytes tunnelforge.sh | 10354 ++++++++++++++++++++++++ windows-client/tunnelforge-client.bat | 233 + 6 files changed, 13941 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 screenshots/dashboard.png create mode 100644 screenshots/tunnelforge-main.png create mode 100644 tunnelforge.sh create mode 100644 windows-client/tunnelforge-client.bat 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 0000000000000000000000000000000000000000..3a0900ec67d7e3d0d7f7bb1d6fd2e5275194bb3f GIT binary patch literal 73358 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 l{Ogg;OHcWK8OSxnne6c~`LbvoN14LQ$k4. +# + +# ============================================================================ +# 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