├── .gitattributes ├── LICENSE ├── README.md ├── docs ├── README.md ├── forward.md ├── script.md ├── upgrade.md └── usage.md ├── natter-check ├── Dockerfile ├── README.md ├── build_and_push.sh └── natter-check.py ├── natter-docker ├── Dockerfile.alpine-amd64 ├── Dockerfile.alpine-arm64 ├── Dockerfile.debian-amd64 ├── Dockerfile.debian-arm64 ├── Dockerfile.minimal-amd64 ├── Dockerfile.minimal-arm64 ├── Dockerfile.openwrt-amd64 ├── Dockerfile.openwrt-arm64 ├── README.md ├── build_and_push.sh ├── docker-compose.yml ├── minecraft │ ├── README.md │ ├── cf-srv.py │ └── docker-compose.yml ├── nginx-cloudflare │ ├── README.md │ ├── cf-redir.py │ ├── docker-compose.yml │ └── html │ │ └── index.html ├── nginx │ ├── README.md │ ├── docker-compose.yml │ └── html │ │ └── index.html ├── qbittorrent │ ├── README.md │ ├── config │ │ └── qBittorrent │ │ │ └── qBittorrent.conf │ ├── docker-compose.yml │ └── qb.sh ├── transmission │ ├── README.md │ ├── docker-compose.yml │ └── tr.sh └── v2fly-nginx-cloudflare │ ├── README.md │ ├── cf-redir.py │ ├── config.json │ ├── docker-compose.yml │ ├── html │ └── index.html │ └── v2subsc.py └── natter.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Natter (v2) 2 | 3 | Expose your port behind full-cone NAT to the Internet. 4 | 5 | [中文文档](docs/README.md) 6 | 7 | 8 | ## Quick start 9 | 10 | ```bash 11 | python3 natter.py 12 | ``` 13 | 14 | Or, using Docker: 15 | 16 | ```bash 17 | docker run --net=host nattertool/natter 18 | ``` 19 | 20 | ``` 21 | 2023-11-01 01:00:08 [I] Natter 22 | 2023-11-01 01:00:08 [I] Tips: Use `--help` to see help messages 23 | 2023-11-01 01:00:12 [I] 24 | 2023-11-01 01:00:12 [I] tcp://192.168.1.100:13483 <--Natter--> tcp://203.0.113.10:14500 25 | 2023-11-01 01:00:12 [I] 26 | 2023-11-01 01:00:12 [I] Test mode in on. 27 | 2023-11-01 01:00:12 [I] Please check [ http://203.0.113.10:14500 ] 28 | 2023-11-01 01:00:12 [I] 29 | 2023-11-01 01:00:12 [I] LAN > 192.168.1.100:13483 [ OPEN ] 30 | 2023-11-01 01:00:12 [I] LAN > 192.168.1.100:13483 [ OPEN ] 31 | 2023-11-01 01:00:12 [I] LAN > 203.0.113.10:14500 [ OPEN ] 32 | 2023-11-01 01:00:13 [I] WAN > 203.0.113.10:14500 [ OPEN ] 33 | 2023-11-01 01:00:13 [I] 34 | ``` 35 | 36 | In the example above, `203.0.113.10` is your public IP address outside the full-cone NAT. Natter opened TCP port `203.0.113.10:14500` for testing. 37 | 38 | Visit `http://203.0.113.10:14500` outside your LAN, you will see the web page: 39 | 40 | ``` 41 | It works! 42 | 43 | -------- 44 | Natter 45 | ``` 46 | 47 | 48 | ## Usage 49 | 50 | ``` 51 | usage: natter.py [--version] [--help] [-v] [-q] [-u] [-U] [-k ] 52 | [-s
] [-h
] [-e ] [-i ] 53 | [-b ] [-m ] [-t
] [-p ] [-r] 54 | 55 | Expose your port behind full-cone NAT to the Internet. 56 | 57 | options: 58 | --version, -V show the version of Natter and exit 59 | --help show this help message and exit 60 | -v verbose mode, printing debug messages 61 | -q exit when mapped address is changed 62 | -u UDP mode 63 | -U enable UPnP/IGD discovery 64 | -k seconds between each keep-alive 65 | -s
hostname or address to STUN server 66 | -h
hostname or address to keep-alive server 67 | -e script path for notifying mapped address 68 | 69 | bind options: 70 | -i network interface name or IP to bind 71 | -b port number to bind 72 | 73 | forward options: 74 | -m forward method, common values are 'iptables', 'nftables', 75 | 'socat', 'gost' and 'socket' 76 | -t
IP address of forward target 77 | -p port number of forward target 78 | -r keep retrying until the port of forward target is open 79 | ``` 80 | 81 | 82 | ## Usage for Docker 83 | 84 | Read [natter-docker](natter-docker) for details. 85 | 86 | 87 | ## Use cases 88 | 89 | Expose local port 80 to the Internet, using built-in forward method: 90 | 91 | ```bash 92 | python3 natter.py -p 80 93 | ``` 94 | 95 | Expose local port 80 to the Internet, using iptables kernel forward method (requires root permission): 96 | 97 | ```bash 98 | sudo python3 natter.py -m iptables -p 80 99 | ``` 100 | 101 | 102 | ## Dependencies 103 | 104 | - Python 2.7 (minimum), >= 3.6 (recommended) 105 | - No third-party modules are required. 106 | 107 | 108 | ## License 109 | 110 | GNU General Public License v3.0 111 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Natter 2 | 3 | 将 fullcone NAT (NAT 1) 后的端口,打洞暴露至互联网。 4 | 5 | *注意:Natter 2.0 重写了整个程序,并不兼容先前版本的命令行用法。详见 [更新说明](upgrade.md) 。* 6 | 7 | 8 | ## 快速开始 9 | 10 | ```bash 11 | python3 natter.py 12 | ``` 13 | 14 | 或者, 使用 Docker: 15 | 16 | ```bash 17 | docker run --net=host nattertool/natter 18 | ``` 19 | 20 | ``` 21 | 2023-11-01 01:00:08 [I] Natter 22 | 2023-11-01 01:00:08 [I] Tips: Use `--help` to see help messages 23 | 2023-11-01 01:00:12 [I] 24 | 2023-11-01 01:00:12 [I] tcp://192.168.1.100:13483 <--Natter--> tcp://203.0.113.10:14500 25 | 2023-11-01 01:00:12 [I] 26 | 2023-11-01 01:00:12 [I] Test mode in on. 27 | 2023-11-01 01:00:12 [I] Please check [ http://203.0.113.10:14500 ] 28 | 2023-11-01 01:00:12 [I] 29 | 2023-11-01 01:00:12 [I] LAN > 192.168.1.100:13483 [ OPEN ] 30 | 2023-11-01 01:00:12 [I] LAN > 192.168.1.100:13483 [ OPEN ] 31 | 2023-11-01 01:00:12 [I] LAN > 203.0.113.10:14500 [ OPEN ] 32 | 2023-11-01 01:00:13 [I] WAN > 203.0.113.10:14500 [ OPEN ] 33 | 2023-11-01 01:00:13 [I] 34 | ``` 35 | 36 | 上述例子中, `203.0.113.10` 是您 NAT 1 外部的公网 IP 地址。Natter 打开了 TCP 端口 `203.0.113.10:14500` 以供测试。 37 | 38 | 在局域网外访问 `http://203.0.113.10:14500` ,您可以看到如下网页: 39 | 40 | ``` 41 | It works! 42 | 43 | -------- 44 | Natter 45 | ``` 46 | 47 | 48 | ## 使用方法 49 | 50 | - 详见 [参数说明](usage.md) 。 51 | - 有关转发方法,详见 [转发方法](forward.md) 。 52 | - 有关通知脚本,详见 [Natter 通知脚本](script.md) 。 53 | 54 | ``` 55 | usage: natter.py [--version] [--help] [-v] [-q] [-u] [-U] [-k ] 56 | [-s
] [-h
] [-e ] [-i ] 57 | [-b ] [-m ] [-t
] [-p ] [-r] 58 | 59 | Expose your port behind full-cone NAT to the Internet. 60 | 61 | options: 62 | --version, -V show the version of Natter and exit 63 | --help show this help message and exit 64 | -v verbose mode, printing debug messages 65 | -q exit when mapped address is changed 66 | -u UDP mode 67 | -U enable UPnP/IGD discovery 68 | -k seconds between each keep-alive 69 | -s
hostname or address to STUN server 70 | -h
hostname or address to keep-alive server 71 | -e script path for notifying mapped address 72 | 73 | bind options: 74 | -i network interface name or IP to bind 75 | -b port number to bind 76 | 77 | forward options: 78 | -m forward method, common values are 'iptables', 'nftables', 79 | 'socat', 'gost' and 'socket' 80 | -t
IP address of forward target 81 | -p port number of forward target 82 | -r keep retrying until the port of forward target is open 83 | ``` 84 | 85 | 86 | ## Docker 使用方法 87 | 88 | 详见 [natter-docker](../natter-docker) 。 89 | 90 | 91 | ## 使用例 92 | 93 | 使用内置转发,对外开放本机 80 端口: 94 | 95 | ```bash 96 | python3 natter.py -p 80 97 | ``` 98 | 99 | 使用 iptables 内核转发(需要 root 权限),对外开放本机 80 端口: 100 | 101 | ```bash 102 | sudo python3 natter.py -m iptables -p 80 103 | ``` 104 | 105 | 106 | ## 依赖 107 | 108 | - Python 2.7 (最低), >= 3.6 (推荐) 109 | - 不需要安装第三方模块。 110 | 111 | 112 | ## 许可证 113 | 114 | GNU General Public License v3.0 115 | -------------------------------------------------------------------------------- /docs/forward.md: -------------------------------------------------------------------------------- 1 | # 转发方法 2 | 3 | Natter 提供多种途径,将 Natter 端口流量转发至目标端口。 4 | 5 | 6 | ## 方法对比 7 | 8 | 下表列出了不同转发方法之间的差异。 9 | 10 | 总的来说: 11 | - *iptables* 是最推荐的转发方法。 12 | - 如果您不是 Linux 用户,您可以选择 *socat*, *gost* 或者 *socket* 方法。 13 | - *socket* 方法,是 Natter 采用的默认转发方法。 14 | 15 | |   | iptables | nftables | socat | gost | socket | 16 | | ------------ | -------- | -------- | ------- | ------- | ------- | 17 | | 操作系统限制 | 仅 Linux | 仅 Linux | 跨平台 | 跨平台 | 跨平台 | 18 | | 保留源 IP | 可 | 可 | 不可 | 不可 | 不可 | 19 | | 转发效率 | 高 | 高 | 中 | 中 | 中 | 20 | | 转发类型 | 内核 | 内核 | 多进程 | 协程 | 多线程 | 21 | | root 权限 | 需要 | 需要 | 无需 | 无需 | 无需 | 22 | | 第三方依赖 | 是 | 是 | 是 | 是 | 否 | 23 | | 依赖最低版本 | 1.4.1 | 0.9.0 | 1.7.2 | 2.3 | - | 24 | | 依赖最佳版本 | ≥ 1.4.20 | ≥ 1.0.6 | ≥ 1.7.2 | ≥ 2.3 | - | 25 | 26 | 27 | > 注: 28 | > 保留源 IP,转发目标所属的应用程序可以获得来访者的真实 IP 和端口; 29 | > 不保留源 IP,应用程序获得的 IP 地址则为 Natter 所在的 IP 地址。 30 | 31 | 32 | 33 | ## iptables 转发 34 | [iptables](https://www.netfilter.org/projects/iptables/) 是一个用于控制 Linux 内核 netfilter 模块的命令行工具。 35 | 36 | 使用 iptables 转发,有以下四种命令行可选: 37 | ``` 38 | -m iptables -t <目标 IP> -p <目标端口> 39 | ``` 40 | ``` 41 | -m iptables-snat -t <目标 IP> -p <目标端口> 42 | ``` 43 | ``` 44 | -m sudo-iptables -t <目标 IP> -p <目标端口> 45 | ``` 46 | ``` 47 | -m sudo-iptables-snat -t <目标 IP> -p <目标端口> 48 | ``` 49 | 50 | 1. `-m iptables` 51 | 52 | - 使用此方法时,Natter 应当具有 root 权限,例如:`sudo python natter.py -m iptables`; 53 | - 此方法可保留源 IP 地址; 54 | - 此方法的目标 IP 具有限制,应当为 **本机** 或其 **下级主机** 的 IP 地址。 55 | 56 | > 注: 57 | > 下级主机,指网关为 Natter 所在 IP 的主机。 58 | > 例如:Natter 运行于路由器,其 LAN 口的机器均为下级;或者 Natter 运行于服务器,这台服务器上的虚拟机均为下级。 59 | 60 | 2. `-m iptables-snat` 61 | 62 | - 使用此方法时,Natter 应当具有 root 权限,例如:`sudo python natter.py -m iptables-snat`; 63 | - 此方法不保留源 IP 地址; 64 | 65 | 3. `-m sudo-iptables` 66 | 67 | - 使用此方法时,Natter 所在用户对于 `iptables` 具有 `sudo` 免密权限,这样 Natter 可不以 root 方式运行。 68 | - 除此之外,其他与 `-m iptables` 相同。 69 | 70 | 4. `-m sudo-iptables-snat` 71 | 72 | - 使用此方法时,Natter 所在用户对于 `iptables` 具有 `sudo` 免密权限,这样 Natter 可不以 root 方式运行。 73 | - 除此之外,其他与 `-m iptables-snat` 相同。 74 | 75 | ### 技术细节 76 | 使用 iptables 转发时,Natter 会在 `iptables` 中 `nat` 表内创建 `NATTER` 和 `NATTER_SNAT` 两个链: 77 | ``` 78 | -N NATTER 79 | -N NATTER_SNAT 80 | -A PREROUTING -j NATTER 81 | -A INPUT -j NATTER_SNAT 82 | -A OUTPUT -j NATTER 83 | -A POSTROUTING -j NATTER_SNAT 84 | ``` 85 | 所有规则均会创建在这两个链内。您可以通过以下命令查看具体规则: 86 | ```bash 87 | iptables -t nat -S NATTER 88 | ``` 89 | ```bash 90 | iptables -t nat -S NATTER_SNAT 91 | ``` 92 | 除了强制退出,例如 `SIGKILL`,Natter 在正常退出时均会清理相关规则。否则需要手动清理,或者重启机器让系统自动重置。 93 | 94 | 95 | ## nftables 转发 96 | 97 | [nftables](https://www.netfilter.org/projects/nftables/) 是 iptables 的最新替代,同样适用于数据包分类等工作。 98 | 99 | > 如果您不确定是否应使用 `-m nftables`,请使用 `-m iptables`。 100 | 101 | 使用 nftables 转发,有以下四种命令行可选: 102 | ``` 103 | -m nftables -t <目标 IP> -p <目标端口> 104 | ``` 105 | ``` 106 | -m nftables-snat -t <目标 IP> -p <目标端口> 107 | ``` 108 | ``` 109 | -m sudo-nftables -t <目标 IP> -p <目标端口> 110 | ``` 111 | ``` 112 | -m sudo-nftables-snat -t <目标 IP> -p <目标端口> 113 | ``` 114 | 115 | 相关作用请参照上文 iptables 转发部分。 116 | 117 | 118 | ## socat 转发 119 | [socat](http://www.dest-unreach.org/socat/) 是一个开源的,由 C 语言实现的多功能中继工具。 120 | 121 | 使用 socat 转发,命令行为: 122 | ``` 123 | -m socat -t <目标 IP> -p <目标端口> 124 | ``` 125 | 126 | - `socat` 程序所在目录应当在 `PATH` 环境变量内,以便 Natter 调用; 127 | - `socat` 使用多进程的方式维护连接,连接数不宜过多; 128 | - 此转发方法不保留源 IP 地址。 129 | 130 | 131 | ## gost 转发 132 | [gost](https://gost.run/) 是一个开源的,由 Go 语言实现的安全隧道。 133 | 134 | 使用 gost 转发,命令行为: 135 | ``` 136 | -m gost -t <目标 IP> -p <目标端口> 137 | ``` 138 | 139 | - `gost` 程序所在目录应当在 `PATH` 环境变量内,以便 Natter 调用; 140 | - 此转发方法不保留源 IP 地址。 141 | 142 | 143 | ## socket 转发 144 | [socket](https://docs.python.org/3/library/socket.html) 转发,是 Natter 基于 Python 内置 socket 库的一个简单的端口转发实现。 145 | 146 | 使用 socket 转发,命令行为: 147 | ``` 148 | -m socket -t <目标 IP> -p <目标端口> 149 | ``` 150 | 151 | - 此转发方法使用多线程的方式维护连接,连接数不宜过多; 152 | - 此转发方法不保留源 IP 地址。 153 | -------------------------------------------------------------------------------- /docs/script.md: -------------------------------------------------------------------------------- 1 | # Natter 通知脚本 2 | 3 | 在 NAT 1 中,不仅外部 IP 是动态的,外部端口也是动态的。 4 | 5 | 使用 Natter 对外开放端口时,Natter 可以调用通知脚本,以通知实时的外部 IP 和端口号。 6 | 7 | Natter 设置通知脚本的参数为 `-e `,映射成功时,会调用路径为 `` 的脚本。 8 | 9 | Natter 调用脚本时,会在命令行传入五个参数: 10 | 11 | | 参数序号 | 参数说明 | 参数格式 | 12 | | -------- | ---------- | --------------------- | 13 | | 1 | 传输层协议 | `tcp`, `udp` 二者之一 | 14 | | 2 | 内部 IP | 点分十进制 IPv4 地址 | 15 | | 3 | 内部端口 | `1` - `65535` 的整数 | 16 | | 4 | 外部 IP | 点分十进制 IPv4 地址 | 17 | | 5 | 外部端口 | `1` - `65535` 的整数 | 18 | 19 | 如果您使用 Shell 脚本,我们推荐您使用以下的代码作为开头: 20 | 21 | ```bash 22 | #!/bin/sh 23 | 24 | # Natter notification script arguments 25 | protocol="$1"; private_ip="$2"; private_port="$3"; public_ip="$4"; public_port="$5" 26 | ``` 27 | 28 | 这样,您便可以直接使用这五个变量,例如 `echo "${public_port}"` 。 29 | 30 | Natter 也可以调用 Python 脚本,我们推荐您使用以下的代码作为开头: 31 | 32 | ```python 33 | #!/usr/bin/env python3 34 | 35 | # Natter notification script arguments 36 | protocol, private_ip, private_port, public_ip, public_port = sys.argv[1:6] 37 | ``` 38 | 39 | 这样,您便可以直接使用这五个变量,例如 `print(public_port)` 。 40 | 41 | 需要注意,通知脚本需要具有可执行权限。使用下方命令赋予脚本执行权限: 42 | 43 | ```bash 44 | chmod a+x <通知脚本路径> 45 | ``` 46 | 47 | 48 | ## 调用示例 49 | 50 | 下面将使用示例,具体说明通知脚本是如何被 Natter 调用的。 51 | 52 | 下方示例中,Natter 会将映射的实时外部 IP 和端口号通知给 `/opt/qb.sh` 脚本: 53 | 54 | ```bash 55 | python3 natter.py -m iptables -e /opt/qb.sh 56 | ``` 57 | 58 | Natter 打洞成功,端口关系显示如下: 59 | ``` 60 | tcp://192.168.1.100:14600 <--iptables--> tcp://192.168.1.100:43910 <--Natter--> tcp://203.0.113.10:14600 61 | ``` 62 | > 注:示例中的 Natter 命令没有指定目标 IP 和端口,此时目标为本机,且目标端口与外部端口保持一致。 63 | 64 | 此时,Natter 将使用以下命令行调用 `/opt/qb.sh` 65 | 66 | ```bash 67 | /opt/qb.sh "tcp" "192.168.1.100" "14600" "203.0.113.10" "14600" 68 | ``` 69 | 70 | 71 | ## Natter 提供的实用通知脚本 72 | 73 | Natter 仓库中包含一些已经写好的通知脚本。您只需修改脚本中的一些配置,如对应服务的 URL 等,便可直接使用。 74 | 75 | 这些实用通知脚本如下: 76 | 77 | - [`qb.sh`](../natter-docker/qbittorrent/qb.sh):Shell 脚本,用于更新 qBittorrent 监听端口,使其向 tracker 通告的端口号与外部端口一致; 78 | - [`tr.sh`](../natter-docker/transmission/tr.sh):Shell 脚本,用于更新 Transmission 监听端口,使其向 tracker 通告的端口号与外部端口一致; 79 | - [`cf-srv.py`](../natter-docker/minecraft/cf-srv.py):Python 脚本,用于更新 Cloudflare 域名的 A 记录和 SRV 记录,使得 Minecraft 等服务可通过域名直接访问。 80 | - [`cf-redir.py`](../natter-docker/nginx-cloudflare/cf-redir.py):Python 脚本,用于实现 Cloudflare 的跳转功能,使得直接访问域名即可动态跳转到目标端口。 81 | -------------------------------------------------------------------------------- /docs/upgrade.md: -------------------------------------------------------------------------------- 1 | # 更新说明 - 升级至 Natter (v2) 2 | 3 | 4 | ## 前言 5 | 6 | Natter 于 2022 年创建,目前已经更新至第二代。第二代 Natter 重写了整个程序,特色功能有 Docker 的支持、自动设置内核转发等。 7 | 8 | 第二代 Natter 不再兼容先前版本,而是使用了完全不同的命令行用法;同时也也去除了一些功能,例如配置文件。Natter 多端口转发的配置,将由 Docker Compose 替代,以便更好的融合到您的 Docker 容器管理中。 9 | 10 | 11 | ## 我是否应该升级至 Natter (v2) ? 12 | 13 | 如果您的主力设备是 NAS,我们强烈建议您升级 Natter 至 v2。 14 | 15 | 总的来说,在以下情形中,您需要停留在先前版本: 16 | 17 | - 我希望使用原有的配置文件; 18 | - 我不想花费时间学习新的使用方法。 19 | 20 | 除此之外,您都应该升级至 Natter (v2)。 21 | 22 | 23 | ## 用法上的变化 24 | 25 | - 第一代 Natter: 26 | 27 | 先前版本的 Natter 多数场景需要依赖 OpenWrt 的端口转发功能,因此需要安装在 OpenWrt 中。 28 | 29 | ``` 30 | 服务器 <------> OpenWrt 路由器 (Natter) <---运营商 NAT---> 互联网 31 | ``` 32 | 33 | - 第二代 Natter: 34 | 35 | 第二代 Natter 具有内置转发,因此可以下移部署至服务器上,而路由器仅需全端口转发(设置 DMZ 主机)至服务器即可。 36 | 37 | ``` 38 | 服务器 (Natter) <---DMZ 主机---> 普通路由器 <---运营商 NAT---> 互联网 39 | ``` 40 | 41 | 42 | ## 命令上的变化 43 | 44 | 第二代 Natter 不再兼容先前版本 Natter 的命令行用法。 45 | 46 | 请使用以下命令查看命令行帮助,或者参考 [参数说明](../docs/usage.md) : 47 | 48 | ``` 49 | python3 natter.py --help 50 | ``` 51 | 52 | 下面是一些常用命令的对照: 53 | 54 | ### 启用测试 HTTP 服务器 55 | 56 | - 第一代 Natter: 57 | 58 | ```bash 59 | python natter.py -t 3456 60 | ``` 61 | 62 | - 第二代 Natter: 63 | 64 | ```bash 65 | python natter.py -m test -b 3456 66 | ``` 67 | 68 | ### 仅打洞 69 | 70 | - 第一代 Natter: 71 | 72 | ```bash 73 | python natter.py 3456 74 | ``` 75 | 76 | - 第二代 Natter: 77 | 78 | ```bash 79 | python natter.py -m none -b 3456 80 | ``` 81 | 82 | ### 检查 NAT 类型 83 | 84 | - 第一代 Natter: 85 | 86 | ```bash 87 | python natter.py --check-nat 88 | ``` 89 | 90 | - 第二代 Natter: 91 | 92 | ```bash 93 | python natter-check.py 94 | ``` 95 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # 参数说明 2 | 3 | | 参数 | 说明 | 可接受值 | 示例 | 默认值 | 4 | | ---------------- | --------------------------------- | --------------------- | ------------------- | -------------------- | 5 | | ***一般选项:*** | | | | | 6 | | `--version` | 打印 Natter 版本并退出 | / | `--version` | / | 7 | | `--help` | 打印此帮助并退出 | / | `--help` | / | 8 | | `-v` | 详细模式,打印调试信息 | / | `-v` | / | 9 | | `-q` | 映射地址改变时立即退出 | / | `-q` | / | 10 | | `-u` | UDP 模式 | / | `-u` | / | 11 | | `-U` | 启用 UPnP/IGD 发现 | / | `-U` | / | 12 | | `-k ` | 每次保活的间隔秒数 | 整数 >=1 | `-k 20` | `15` | 13 | | `-s
` | STUN 服务器名或地址 | 域名
域名:端口号
IP地址
IP地址:端口号 | `-s stun01.example.com`
`-s stun02.example.com:1478`
`-s 202.64.12.121`
`-s 202.64.12.121:2478` | 内置 STUN 服务器列表 | 14 | | `-h
` | 保活服务器名或地址 | 域名
域名:端口号
IP地址
IP地址:端口号 | `-h example.com`
`-h example.com:8080`
`-h 202.64.34.101`
`-h 202.64.34.101:8888` | TCP模式:
`www.baidu.com:80`
UDP模式:
`8.8.8.8:53` | 15 | | `-e ` | 通知脚本路径 | 本地文件路径 | `-e /opt/notify.sh` | 无,不启用通知脚本 | 16 | | | | | | | 17 | | ***绑定选项:*** | | | | | 18 | | `-i ` | Natter 绑定的网络接口名或 IP 地址 | 网络接口名
IP 地址 | `-i eth0`
`-i 192.168.1.101` | `0.0.0.0`,绑定默认IP地址 | 19 | | `-b ` | Natter 绑定的端口号 | 整数 0-65535 | `-b 3456` | `0`,绑定默认端口 | 20 | | | | | | | 21 | | ***转发选项:*** | | | | | 22 | | `-m ` | 转发方法 | 字符串 | `-m none`
`-m test`
`-m iptables`
`-m nftables`
`-m socat`
`-m gost`
`-m socket` | 由其他参数决定为以下某个:
`-m test`
`-m none`
`-m socket` | 23 | | `-t
` | 转发目标的 IP 地址 | IP 地址 | `-t 192.168.1.102` | 本机 IP 地址 | 24 | | `-p ` | 转发目标的端口号 | 整数 1-65535 | `-p 80` | 与公网映射端口号一致 | 25 | | `-r` | 重试直至目标端口开放 | / | `-r` | / | 26 | 27 | - TCP 模式中,Natter 使用基于 TCP 的 STUN 协议访问 STUN 服务器,使用 HTTP 协议访问保活服务器; 28 | - UDP 模式中,Natter 使用基于 UDP 的 STUN 协议访问 STUN 服务器,使用 DNS 协议访问保活服务器; 29 | - 部分平台不支持绑定到网络接口,请尝试绑定至接口的 IP 地址; 30 | - 选项 `-r` 用于启动速度很慢的目标程序,避免 Natter 在目标程序准备就绪前提前运作。 31 | - 选项 `-e` 中,关于通知脚本的具体说明,参见 [Natter 通知脚本](script.md) 。 32 | - 选项 `-m` 中,关于转发选项的具体说明,参见 [转发方法](forward.md) 。 33 | -------------------------------------------------------------------------------- /natter-check/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | COPY natter-check.py /opt/natter-check.py 4 | 5 | RUN apk update \ 6 | && apk add python3 \ 7 | && chmod a+x /opt/natter-check.py 8 | 9 | 10 | ENV HOME /opt 11 | ENV PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 12 | ENV LANG C.UTF-8 13 | ENV LANGUAGE C.UTF-8 14 | ENV LC_ALL C.UTF-8 15 | 16 | ENTRYPOINT ["/opt/natter-check.py"] 17 | -------------------------------------------------------------------------------- /natter-check/README.md: -------------------------------------------------------------------------------- 1 | # NatterCheck 2 | 3 | 使用 NatterCheck 检查您当前网络的 NAT 类型: 4 | 5 | ```bash 6 | python3 natter-check.py 7 | ``` 8 | 9 | 或者使用 Docker: 10 | 11 | ```bash 12 | docker run --rm --net=host nattertool/check 13 | ``` 14 | 15 | 两项指标均显示 OK ,表示您当前使用的网络可以正常使用 Natter。 16 | 17 | ``` 18 | > NatterCheck 19 | 20 | Checking TCP NAT... [ OK ] ... NAT Type: 1 21 | Checking UDP NAT... [ OK ] ... NAT Type: 1 22 | ``` 23 | -------------------------------------------------------------------------------- /natter-check/build_and_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | natter_check_repo="nattertool/check" 4 | natter_check_ver=$(python3 -Bc 'print(__import__("natter-check").__version__)') 5 | [ "dev" = $(echo "$natter_check_ver" | cut -d- -f2) ] && natter_check_ver="dev" 6 | 7 | function tag_natter_check() 8 | { 9 | tag="$1" 10 | new_tags=("${@:2}") 11 | cmd=() 12 | for new_tag in "${new_tags[@]}"; do 13 | cmd+=("-t") 14 | cmd+=("$natter_check_repo:$new_tag") 15 | done 16 | docker buildx imagetools create "${cmd[@]}" "$natter_check_repo:$tag" 17 | } 18 | 19 | function build_and_push() 20 | { 21 | docker buildx build --push --tag "$natter_check_repo:dev" --platform linux/amd64,linux/arm64 . 22 | } 23 | 24 | function tag_release() 25 | { 26 | tag_natter_check dev "$natter_check_ver" latest 27 | } 28 | 29 | 30 | build_and_push 31 | if [ "$1" == "release" ]; then 32 | tag_release 33 | fi 34 | -------------------------------------------------------------------------------- /natter-check/natter-check.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | NatterCheck - https://github.com/MikeWang000000/Natter 5 | Copyright (C) 2023 MikeWang000000 6 | 7 | This program is free software: you can redistribute it and/or modify 8 | it under the terms of the GNU General Public License as published by 9 | the Free Software Foundation, either version 3 of the License, or 10 | (at your option) any later version. 11 | 12 | This program is distributed in the hope that it will be useful, 13 | but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | GNU General Public License for more details. 16 | 17 | You should have received a copy of the GNU General Public License 18 | along with this program. If not, see . 19 | ''' 20 | 21 | import os 22 | import sys 23 | import time 24 | import socket 25 | import struct 26 | import codecs 27 | 28 | __version__ = "2.1.1" 29 | 30 | 31 | def fix_codecs(codec_list = ["utf-8", "idna"]): 32 | missing_codecs = [] 33 | for codec_name in codec_list: 34 | try: 35 | codecs.lookup(codec_name) 36 | except LookupError: 37 | missing_codecs.append(codec_name.lower()) 38 | def search_codec(name): 39 | if name.lower() in missing_codecs: 40 | return codecs.CodecInfo(codecs.ascii_encode, codecs.ascii_decode, name="ascii") 41 | if missing_codecs: 42 | codecs.register(search_codec) 43 | 44 | 45 | def new_socket_reuse(family, type): 46 | sock = socket.socket(family, type) 47 | if hasattr(socket, "SO_REUSEADDR"): 48 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 49 | if hasattr(socket, "SO_REUSEPORT"): 50 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 51 | return sock 52 | 53 | 54 | def check_docker_network(): 55 | if not sys.platform.startswith("linux"): 56 | return 57 | if not os.path.exists("/.dockerenv"): 58 | return 59 | if not os.path.isfile("/sys/class/net/eth0/address"): 60 | return 61 | fo = open("/sys/class/net/eth0/address", "r") 62 | macaddr = fo.read().strip() 63 | fo.close() 64 | ipaddr = socket.gethostbyname(socket.getfqdn()) 65 | docker_macaddr = "02:42:" + ":".join(["%02x" % int(x) for x in ipaddr.split(".")]) 66 | if macaddr == docker_macaddr: 67 | sys.stderr.write("Error: Docker's `--net=host` option is required.\n") 68 | exit(-1) 69 | 70 | 71 | class Status(object): 72 | NA = 0 73 | OK = 1 74 | COMPAT = 2 75 | FAIL = 3 76 | @staticmethod 77 | def rep(status): 78 | return { 79 | Status.NA: "[ NA ]", 80 | Status.OK: "[ OK ]", 81 | Status.COMPAT: "[ COMPAT ]", 82 | Status.FAIL: "[ FAIL ]" 83 | }[status] 84 | 85 | 86 | class StunTest(object): 87 | # Note: IPv4 Only. 88 | # Reference: 89 | # https://www.rfc-editor.org/rfc/rfc3489 90 | # https://www.rfc-editor.org/rfc/rfc5389 91 | # https://www.rfc-editor.org/rfc/rfc8489 92 | 93 | # Servers in this list must be compatible with rfc5389 or rfc8489 94 | stun_server_tcp = [ 95 | "fwa.lifesizecloud.com", 96 | "global.turn.twilio.com", 97 | "turn.cloudflare.com", 98 | "stun.voip.blackberry.com", 99 | "stun.radiojar.com", 100 | "stun.isp.net.au" 101 | ] 102 | # Servers in this list must be compatible with rfc3489, with "change IP" and "change port" functions available 103 | stun_server_udp = [ 104 | "stun.miwifi.com", 105 | "stun.chat.bilibili.com", 106 | "stun.hitv.com", 107 | "stun.cdnbye.com" 108 | ] 109 | # Port test server. ref: https://github.com/transmission/portcheck 110 | port_test_server = "portcheck.transmissionbt.com" 111 | 112 | # HTTP keep-alive server 113 | keep_alive_server = "www.baidu.com" 114 | 115 | MTU = 1500 116 | STUN_PORT = 3478 117 | MAGIC_COOKIE = 0x2112a442 118 | BIND_REQUEST = 0x0001 119 | BIND_RESPONSE = 0x0101 120 | FAMILY_IPV4 = 0x01 121 | FAMILY_IPV6 = 0x02 122 | CHANGE_PORT = 0x0002 123 | CHANGE_IP = 0x0004 124 | ATTRIB_MAPPED_ADDRESS = 0x0001 125 | ATTRIB_CHANGE_REQUEST = 0x0003 126 | ATTRIB_XOR_MAPPED_ADDRESS = 0x0020 127 | NAT_UNKNOWN = -1 128 | NAT_OPEN_INTERNET = 0 129 | NAT_FULL_CONE = 1 130 | NAT_RESTRICTED = 2 131 | NAT_PORT_RESTRICTED = 3 132 | NAT_SYMMETRIC = 4 133 | NAT_SYM_UDP_FIREWALL = 5 134 | 135 | def __init__(self, source_ip = "0.0.0.0"): 136 | self.source_ip = source_ip 137 | self.stun_ip_tcp = [] 138 | self.stun_ip_udp = [] 139 | for hostname in self.stun_server_tcp: 140 | self.stun_ip_tcp.extend(self._resolve_hostname(hostname)) 141 | for hostname in self.stun_server_udp: 142 | self.stun_ip_udp.extend(self._resolve_hostname(hostname)) 143 | if not self.stun_ip_tcp or not self.stun_ip_udp: 144 | raise RuntimeError("cannot resolve hostname") 145 | 146 | def _get_free_port(self, udp=False): 147 | socket_type = socket.SOCK_DGRAM if udp else socket.SOCK_STREAM 148 | sock = new_socket_reuse(socket.AF_INET, socket_type) 149 | sock.bind(("", 0)) 150 | ret = sock.getsockname()[1] 151 | sock.close() 152 | return ret 153 | 154 | def _resolve_hostname(self, hostname): 155 | try: 156 | host, alias, ip_addresses = socket.gethostbyname_ex(hostname) 157 | return ip_addresses 158 | except (socket.error, OSError) as e: 159 | return [] 160 | 161 | def _random_tran_id(self, use_magic_cookie = False): 162 | if use_magic_cookie: 163 | # Compatible with rfc3489, rfc5389 and rfc8489 164 | return struct.pack("!L", self.MAGIC_COOKIE) + os.urandom(12) 165 | else: 166 | # Compatible with rfc3489 167 | return os.urandom(16) 168 | 169 | def _pack_stun_message(self, msg_type, tran_id, payload = b""): 170 | return struct.pack("!HH", msg_type, len(payload)) + tran_id + payload 171 | 172 | def _unpack_stun_message(self, data): 173 | msg_type, msg_length = struct.unpack("!HH", data[:4]) 174 | tran_id = data[4:20] 175 | payload = data[20:20 + msg_length] 176 | return msg_type, tran_id, payload 177 | 178 | def _extract_mapped_addr(self, payload): 179 | while payload: 180 | attrib_type, attrib_length = struct.unpack("!HH", payload[:4]) 181 | attrib_value = payload[4:4 + attrib_length] 182 | payload = payload[4 + attrib_length:] 183 | if attrib_type == self.ATTRIB_MAPPED_ADDRESS: 184 | _, family, port = struct.unpack("!BBH", attrib_value[:4]) 185 | if family == self.FAMILY_IPV4: 186 | ip = socket.inet_ntoa(attrib_value[4:8]) 187 | return ip, port 188 | elif attrib_type == self.ATTRIB_XOR_MAPPED_ADDRESS: 189 | # rfc5389 and rfc8489 190 | _, family, xor_port = struct.unpack("!BBH", attrib_value[:4]) 191 | if family == self.FAMILY_IPV4: 192 | xor_iip, = struct.unpack("!L", attrib_value[4:8]) 193 | ip = socket.inet_ntoa(struct.pack("!L", self.MAGIC_COOKIE ^ xor_iip)) 194 | port = (self.MAGIC_COOKIE >> 16) ^ xor_port 195 | return ip, port 196 | return None 197 | 198 | def tcp_test(self, stun_host, source_port, timeout = 3): 199 | # rfc5389 and rfc8489 only 200 | tran_id = self._random_tran_id(use_magic_cookie = True) 201 | sock = new_socket_reuse(socket.AF_INET, socket.SOCK_STREAM) 202 | sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 203 | sock.settimeout(timeout) 204 | try: 205 | sock.bind((self.source_ip, source_port)) 206 | sock.connect((stun_host, self.STUN_PORT)) 207 | data = self._pack_stun_message(self.BIND_REQUEST, tran_id) 208 | sock.sendall(data) 209 | buf = sock.recv(self.MTU) 210 | msg_type, msg_id, payload = self._unpack_stun_message(buf) 211 | if tran_id == msg_id and msg_type == self.BIND_RESPONSE: 212 | source_addr = sock.getsockname() 213 | mapped_addr = self._extract_mapped_addr(payload) 214 | ret = source_addr, mapped_addr 215 | else: 216 | ret = None 217 | sock.shutdown(socket.SHUT_RDWR) 218 | sock.close() 219 | except Exception as e: 220 | sock.close() 221 | ret = None 222 | return ret 223 | 224 | def udp_test(self, stun_host, source_port, change_ip = False, change_port = False, timeout = 3, repeat = 3): 225 | time_start = time.time() 226 | tran_id = self._random_tran_id() 227 | sock = new_socket_reuse(socket.AF_INET, socket.SOCK_DGRAM) 228 | sock.settimeout(timeout) 229 | try: 230 | sock.bind((self.source_ip, source_port)) 231 | flags = 0 232 | if change_ip: 233 | flags |= self.CHANGE_IP 234 | if change_port: 235 | flags |= self.CHANGE_PORT 236 | if flags: 237 | payload = struct.pack("!HHL", self.ATTRIB_CHANGE_REQUEST, 0x4, flags) 238 | data = self._pack_stun_message(self.BIND_REQUEST, tran_id, payload) 239 | else: 240 | data = self._pack_stun_message(self.BIND_REQUEST, tran_id) 241 | # Send packets repeatedly to avoid packet loss. 242 | for _ in range(repeat): 243 | sock.sendto(data, (stun_host, self.STUN_PORT)) 244 | while True: 245 | time_left = time_start + timeout - time.time() 246 | if time_left <= 0: 247 | return None 248 | sock.settimeout(time_left) 249 | buf, recv_addr = sock.recvfrom(self.MTU) 250 | recv_host, recv_port = recv_addr 251 | # check STUN packet 252 | if len(buf) < 20: 253 | continue 254 | msg_type, msg_id, payload = self._unpack_stun_message(buf) 255 | if tran_id != msg_id or msg_type != self.BIND_RESPONSE: 256 | continue 257 | source_addr = sock.getsockname() 258 | mapped_addr = self._extract_mapped_addr(payload) 259 | ip_changed = (recv_host != stun_host) 260 | port_changed = (recv_port != self.STUN_PORT) 261 | return source_addr, mapped_addr, ip_changed, port_changed 262 | except Exception: 263 | return None 264 | finally: 265 | sock.close() 266 | 267 | def get_tcp_mapping(self, source_port = 0): 268 | server_ip = first = self.stun_ip_tcp[0] 269 | while True: 270 | ret = self.tcp_test(server_ip, source_port) 271 | if ret is None: 272 | # server unavailable, put it at the end of the list 273 | self.stun_ip_tcp.append(self.stun_ip_tcp.pop(0)) 274 | server_ip = self.stun_ip_tcp[0] 275 | if server_ip == first: 276 | raise RuntimeError("No STUN server avaliable") 277 | else: 278 | source_addr, mapped_addr = ret 279 | return source_addr, mapped_addr 280 | 281 | def get_udp_mapping(self, source_port = 0): 282 | server_ip = first = self.stun_ip_udp[0] 283 | while True: 284 | ret = self.udp_test(server_ip, source_port) 285 | if ret is None: 286 | # server unavailable, put it at the end of the list 287 | self.stun_ip_udp.append(self.stun_ip_udp.pop(0)) 288 | server_ip = self.stun_ip_udp[0] 289 | if server_ip == first: 290 | raise RuntimeError("No STUN server avaliable") 291 | else: 292 | source_addr, mapped_addr, ip_changed, port_changed = ret 293 | return source_addr, mapped_addr 294 | 295 | def _check_tcp_cone(self, source_port = 0): 296 | # Detect NAT behavior for TCP. Requires at least three STUN servers for accuracy. 297 | if source_port == 0: 298 | source_port = self._get_free_port() 299 | mapped_addr_first = None 300 | count = 0 301 | for server_ip in self.stun_ip_tcp: 302 | if count >= 3: 303 | return 1 304 | ret = self.tcp_test(server_ip, source_port) 305 | if ret is not None: 306 | source_addr, mapped_addr = ret 307 | if mapped_addr_first is not None and mapped_addr != mapped_addr_first: 308 | return -1 309 | mapped_addr_first = ret[1] 310 | count += 1 311 | return 0 312 | 313 | def _check_tcp_fullcone(self, source_port = 0): 314 | if source_port == 0: 315 | source_port = self._get_free_port() 316 | # Open port 317 | srv_sock = new_socket_reuse(socket.AF_INET, socket.SOCK_STREAM) 318 | try: 319 | srv_sock.bind((self.source_ip, source_port)) 320 | srv_sock.listen(5) 321 | except (OSError, socket.error): 322 | srv_sock.close() 323 | return 0 324 | ka_sock = new_socket_reuse(socket.AF_INET, socket.SOCK_STREAM) 325 | # Make keep-alive & get NAPT mapping 326 | try: 327 | ka_sock.bind((self.source_ip, source_port)) 328 | ka_sock.connect((self.keep_alive_server, 80)) 329 | ka_sock.sendall(( 330 | "GET /~ HTTP/1.1\r\nHost: %s\r\nConnection: keep-alive\r\n\r\n" % self.keep_alive_server 331 | ).encode()) 332 | source_addr, mapped_addr = self.get_tcp_mapping(source_port) 333 | public_port = mapped_addr[1] 334 | except (OSError, socket.error): 335 | srv_sock.close() 336 | ka_sock.close() 337 | return 0 338 | # Check if is open Internet 339 | if source_addr == mapped_addr: 340 | return 2 341 | # Check public port 342 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 343 | sock.settimeout(8) 344 | try: 345 | sock.bind((self.source_ip, 0)) 346 | sock.connect((StunTest.port_test_server, 80)) 347 | sock.sendall(( 348 | "GET /%d HTTP/1.0\r\n" 349 | "Host: %s\r\n" 350 | "User-Agent: curl/8.0.0 (Natter)\r\n" 351 | "Accept: */*\r\n" 352 | "Connection: close\r\n" 353 | "\r\n" % (public_port, StunTest.port_test_server) 354 | ).encode()) 355 | response = b"" 356 | while True: 357 | buff = sock.recv(4096) 358 | if not buff: 359 | break 360 | response += buff 361 | _, content = response.split(b"\r\n\r\n", 1) 362 | if content.strip() == b"1": 363 | return 1 364 | elif content.strip() == b"0": 365 | return -1 366 | raise ValueError("Unexpected response: %s" % response) 367 | except (OSError, LookupError, ValueError, TypeError, socket.error) as ex: 368 | return 0 369 | finally: 370 | ka_sock.close() 371 | srv_sock.close() 372 | sock.close() 373 | 374 | def check_udp_nat_type(self, source_port = 0): 375 | # Like classic STUN (rfc3489). Detect NAT behavior for UDP. 376 | # Modified from rfc3489. Requires at least two STUN servers. 377 | ret_test1_1 = None 378 | ret_test1_2 = None 379 | ret_test2 = None 380 | ret_test3 = None 381 | if source_port == 0: 382 | source_port = self._get_free_port(udp=True) 383 | 384 | for server_ip in self.stun_ip_udp: 385 | ret = self.udp_test(server_ip, source_port, change_ip=False, change_port=False) 386 | if ret is None: 387 | # Try another STUN server 388 | continue 389 | if ret_test1_1 is None: 390 | ret_test1_1 = ret 391 | continue 392 | ret_test1_2 = ret 393 | ret = self.udp_test(server_ip, source_port, change_ip=True, change_port=True) 394 | if ret is not None: 395 | source_addr, mapped_addr, ip_changed, port_changed = ret 396 | if not ip_changed or not port_changed: 397 | # Try another STUN server 398 | continue 399 | ret_test2 = ret 400 | ret_test3 = self.udp_test(server_ip, source_port, change_ip=False, change_port=True) 401 | break 402 | else: 403 | return StunTest.NAT_UNKNOWN 404 | 405 | source_addr_1_1, mapped_addr_1_1, _, _ = ret_test1_1 406 | source_addr_1_2, mapped_addr_1_2, _, _ = ret_test1_2 407 | if mapped_addr_1_1 != mapped_addr_1_2: 408 | return StunTest.NAT_SYMMETRIC 409 | if source_addr_1_1 == mapped_addr_1_1: 410 | if ret_test2 is not None: 411 | return StunTest.NAT_OPEN_INTERNET 412 | else: 413 | return StunTest.NAT_SYM_UDP_FIREWALL 414 | else: 415 | if ret_test2 is not None: 416 | return StunTest.NAT_FULL_CONE 417 | else: 418 | if ret_test3 is not None: 419 | return StunTest.NAT_RESTRICTED 420 | else: 421 | return StunTest.NAT_PORT_RESTRICTED 422 | 423 | def check_tcp_nat_type(self, source_port = 0): 424 | if source_port == 0: 425 | source_port = self._get_free_port() 426 | ret = self._check_tcp_fullcone(source_port) 427 | if ret == 2: 428 | return StunTest.NAT_OPEN_INTERNET 429 | elif ret == 1: 430 | return StunTest.NAT_FULL_CONE 431 | elif ret == 0: 432 | return StunTest.NAT_UNKNOWN 433 | ret = self._check_tcp_cone() 434 | if ret == 1: 435 | return StunTest.NAT_PORT_RESTRICTED 436 | elif ret == -1: 437 | return StunTest.NAT_SYMMETRIC 438 | else: 439 | return StunTest.NAT_UNKNOWN 440 | 441 | 442 | class Check(object): 443 | def __init__(self): 444 | self.stun_test = None 445 | 446 | def do_check(self): 447 | self._print_info("Checking TCP NAT...", self._check_tcp_nat) 448 | self._print_info("Checking UDP NAT...", self._check_udp_nat) 449 | 450 | def _print_info(self, text, func): 451 | sys.stdout.write("%-36s " % text) 452 | sys.stdout.flush() 453 | try: 454 | status, info = func() 455 | except Exception as ex: 456 | status, info = Status.FAIL, str(ex) 457 | sys.stdout.write("%s ... %s\n" % (Status.rep(status), info)) 458 | sys.stdout.flush() 459 | 460 | def _get_free_port(self): 461 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 462 | if hasattr(socket, "SO_REUSEADDR"): 463 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 464 | if hasattr(socket, "SO_REUSEPORT"): 465 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 466 | sock.bind(("", 0)) 467 | ret = sock.getsockname()[1] 468 | sock.close() 469 | return ret 470 | 471 | def _check_tcp_nat(self): 472 | if self.stun_test is None: 473 | self.stun_test = StunTest() 474 | type = self.stun_test.check_tcp_nat_type() 475 | info = "NAT Type: %s" % type 476 | if type in [StunTest.NAT_OPEN_INTERNET, StunTest.NAT_FULL_CONE]: 477 | status = Status.OK 478 | elif type == StunTest.NAT_UNKNOWN: 479 | status = Status.NA 480 | else: 481 | status = Status.FAIL 482 | return status, info 483 | 484 | def _check_udp_nat(self): 485 | if self.stun_test is None: 486 | self.stun_test = StunTest() 487 | type = self.stun_test.check_udp_nat_type() 488 | info = "NAT Type: %s" % type 489 | if type in [StunTest.NAT_OPEN_INTERNET, StunTest.NAT_FULL_CONE]: 490 | status = Status.OK 491 | elif type == StunTest.NAT_UNKNOWN: 492 | status = Status.NA 493 | else: 494 | status = Status.FAIL 495 | return status, info 496 | 497 | 498 | def main(): 499 | fix_codecs() 500 | check_docker_network() 501 | print("> NatterCheck v%s\n" % __version__) 502 | check = Check() 503 | check.do_check() 504 | 505 | 506 | if __name__ == "__main__": 507 | main() 508 | -------------------------------------------------------------------------------- /natter-docker/Dockerfile.alpine-amd64: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 amd64/alpine:3.19 2 | 3 | COPY natter.py /opt/natter.py 4 | 5 | RUN apk update \ 6 | && apk add ca-certificates curl gzip iptables iptables-legacy jq nftables python3 socat wget \ 7 | && ln -sf iptables-legacy /sbin/iptables \ 8 | && curl -L 'https://github.com/ginuerzh/gost/releases/download/v2.11.5/gost-linux-amd64-2.11.5.gz' | gunzip > /usr/bin/gost \ 9 | && chmod a+x /usr/bin/gost \ 10 | && chmod a+x /opt/natter.py 11 | 12 | 13 | ENV HOME /opt 14 | ENV PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 15 | ENV LANG C.UTF-8 16 | ENV LANGUAGE C.UTF-8 17 | ENV LC_ALL C.UTF-8 18 | 19 | ENTRYPOINT ["/opt/natter.py"] 20 | -------------------------------------------------------------------------------- /natter-docker/Dockerfile.alpine-arm64: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/arm64 arm64v8/alpine:3.19 2 | 3 | COPY natter.py /opt/natter.py 4 | 5 | RUN apk update \ 6 | && apk add ca-certificates curl gzip iptables iptables-legacy jq nftables python3 socat wget \ 7 | && ln -sf iptables-legacy /sbin/iptables \ 8 | && curl -L 'https://github.com/ginuerzh/gost/releases/download/v2.11.5/gost-linux-armv8-2.11.5.gz' | gunzip > /usr/bin/gost \ 9 | && chmod a+x /usr/bin/gost \ 10 | && chmod a+x /opt/natter.py 11 | 12 | 13 | ENV HOME /opt 14 | ENV PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 15 | ENV LANG C.UTF-8 16 | ENV LANGUAGE C.UTF-8 17 | ENV LC_ALL C.UTF-8 18 | 19 | ENTRYPOINT ["/opt/natter.py"] 20 | -------------------------------------------------------------------------------- /natter-docker/Dockerfile.debian-amd64: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 amd64/debian:12 2 | 3 | COPY natter.py /opt/natter.py 4 | 5 | RUN apt-get update \ 6 | && apt-get install -y --no-install-recommends ca-certificates curl gzip iptables jq nftables python3 socat wget \ 7 | && update-alternatives --set iptables /usr/sbin/iptables-legacy \ 8 | && curl -L 'https://github.com/ginuerzh/gost/releases/download/v2.11.5/gost-linux-amd64-2.11.5.gz' | gunzip > /usr/bin/gost \ 9 | && chmod a+x /usr/bin/gost \ 10 | && chmod a+x /opt/natter.py 11 | 12 | 13 | ENV HOME /opt 14 | ENV PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 15 | ENV LANG C.UTF-8 16 | ENV LANGUAGE C.UTF-8 17 | ENV LC_ALL C.UTF-8 18 | 19 | ENTRYPOINT ["/opt/natter.py"] 20 | -------------------------------------------------------------------------------- /natter-docker/Dockerfile.debian-arm64: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/arm64 arm64v8/debian:12 2 | 3 | COPY natter.py /opt/natter.py 4 | 5 | RUN apt-get update \ 6 | && apt-get install -y --no-install-recommends ca-certificates curl gzip iptables jq nftables python3 socat wget \ 7 | && update-alternatives --set iptables /usr/sbin/iptables-legacy \ 8 | && curl -L 'https://github.com/ginuerzh/gost/releases/download/v2.11.5/gost-linux-armv8-2.11.5.gz' | gunzip > /usr/bin/gost \ 9 | && chmod a+x /usr/bin/gost \ 10 | && chmod a+x /opt/natter.py 11 | 12 | 13 | ENV HOME /opt 14 | ENV PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 15 | ENV LANG C.UTF-8 16 | ENV LANGUAGE C.UTF-8 17 | ENV LC_ALL C.UTF-8 18 | 19 | ENTRYPOINT ["/opt/natter.py"] 20 | -------------------------------------------------------------------------------- /natter-docker/Dockerfile.minimal-amd64: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 openwrt/rootfs:x86-64-23.05.2 as builder 2 | 3 | COPY natter.py /opt/natter.py 4 | 5 | RUN mkdir -p /var/lock/ \ 6 | && opkg update \ 7 | && opkg install python3-light \ 8 | && mkdir -p /image/lib/ /image/usr/lib/ /image/usr/bin/ /image/opt/ \ 9 | && cp -a /lib/ld-musl-*.so.1 /lib/libc.so /lib/libgcc_s.so.1 /image/lib/ \ 10 | && cp -a /usr/lib/libpython* /usr/lib/python* /image/usr/lib/ \ 11 | && cp -a /usr/bin/python* /image/usr/bin/ \ 12 | && opkg install python3 \ 13 | && python3 -m compileall -b -o 2 /opt/natter.py \ 14 | && cp -a /opt/natter.pyc /image/opt/ 15 | 16 | 17 | FROM scratch 18 | 19 | COPY --from=builder /image/ / 20 | 21 | ENV HOME /opt 22 | ENV PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 23 | ENV LANG C.UTF-8 24 | ENV LANGUAGE C.UTF-8 25 | ENV LC_ALL C.UTF-8 26 | 27 | ENTRYPOINT ["/usr/bin/python3", "/opt/natter.pyc"] 28 | -------------------------------------------------------------------------------- /natter-docker/Dockerfile.minimal-arm64: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/aarch64_generic openwrt/rootfs:aarch64_generic-23.05.2 as builder 2 | 3 | COPY natter.py /opt/natter.py 4 | 5 | RUN mkdir -p /var/lock/ \ 6 | && opkg update \ 7 | && opkg install python3-light \ 8 | && mkdir -p /image/lib/ /image/usr/lib/ /image/usr/bin/ /image/opt/ \ 9 | && cp -a /lib/ld-musl-*.so.1 /lib/libc.so /lib/libgcc_s.so.1 /image/lib/ \ 10 | && cp -a /usr/lib/libpython* /usr/lib/python* /image/usr/lib/ \ 11 | && cp -a /usr/bin/python* /image/usr/bin/ \ 12 | && opkg install python3 \ 13 | && python3 -m compileall -b -o 2 /opt/natter.py \ 14 | && cp -a /opt/natter.pyc /image/opt/ 15 | 16 | 17 | FROM scratch 18 | 19 | COPY --from=builder /image/ / 20 | 21 | ENV HOME /opt 22 | ENV PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 23 | ENV LANG C.UTF-8 24 | ENV LANGUAGE C.UTF-8 25 | ENV LC_ALL C.UTF-8 26 | 27 | ENTRYPOINT ["/usr/bin/python3", "/opt/natter.pyc"] 28 | -------------------------------------------------------------------------------- /natter-docker/Dockerfile.openwrt-amd64: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 openwrt/rootfs:x86-64-23.05.2 as builder 2 | 3 | COPY natter.py /opt/natter.py 4 | 5 | RUN mkdir -p /var/lock/ /var/run/ \ 6 | && opkg update \ 7 | && opkg install ca-certificates curl gzip iptables-legacy jq nftables python3 socat wget \ 8 | && opkg remove 'kmod-*' --force-depends \ 9 | && curl -L 'https://github.com/ginuerzh/gost/releases/download/v2.11.5/gost-linux-amd64-2.11.5.gz' | gunzip > /usr/bin/gost \ 10 | && chmod a+x /usr/bin/gost \ 11 | && chmod a+x /opt/natter.py 12 | 13 | 14 | ENV HOME /opt 15 | ENV PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 16 | ENV LANG C.UTF-8 17 | ENV LANGUAGE C.UTF-8 18 | ENV LC_ALL C.UTF-8 19 | 20 | ENTRYPOINT ["/opt/natter.py"] 21 | -------------------------------------------------------------------------------- /natter-docker/Dockerfile.openwrt-arm64: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/aarch64_generic openwrt/rootfs:aarch64_generic-23.05.2 as builder 2 | 3 | COPY natter.py /opt/natter.py 4 | 5 | RUN mkdir -p /var/lock/ /var/run/ \ 6 | && opkg update \ 7 | && opkg install ca-certificates curl gzip iptables-legacy jq nftables python3 socat wget \ 8 | && opkg remove 'kmod-*' --force-depends \ 9 | && curl -L 'https://github.com/ginuerzh/gost/releases/download/v2.11.5/gost-linux-armv8-2.11.5.gz' | gunzip > /usr/bin/gost \ 10 | && chmod a+x /usr/bin/gost \ 11 | && chmod a+x /opt/natter.py 12 | 13 | 14 | ENV HOME /opt 15 | ENV PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 16 | ENV LANG C.UTF-8 17 | ENV LANGUAGE C.UTF-8 18 | ENV LC_ALL C.UTF-8 19 | 20 | ENTRYPOINT ["/opt/natter.py"] 21 | -------------------------------------------------------------------------------- /natter-docker/README.md: -------------------------------------------------------------------------------- 1 | # Natter & Docker 2 | 3 | 在 Docker 中使用 Natter ,将 Fullcone NAT (NAT 1) 中的 TCP / UDP 端口,打洞暴露至公网上。 4 | 5 | ## 准备工作 6 | 7 | 1. 与公网 IP 操作相同,将外部流量全部转发至内网服务器上,下方为参考步骤: 8 | - 设置光猫为桥接模式; 9 | - 设置路由器「DMZ 主机」为内网服务器 IP 地址。 10 | 11 | 拓扑参考: 12 | ``` 13 | 服务器 (Natter) <---DMZ 主机---> 路由器 <---桥接---> 光猫 <---运营商 NAT---> 互联网 14 | ``` 15 | 16 | > 设置完成后,您可以在服务器上运行 [NatterCheck](../natter-check) 检查 NAT 类型是否满足要求。 17 | 18 | 2. 在服务器上安装 Docker ,下方使用 [清华大学开源软件镜像站](https://mirrors.tuna.tsinghua.edu.cn/help/docker-ce/) 作为参考: 19 | 20 | ```bash 21 | export DOWNLOAD_URL="https://mirrors.tuna.tsinghua.edu.cn/docker-ce" 22 | curl -fsSL https://get.docker.com/ | sudo -E sh 23 | ``` 24 | 25 | > 需要注意,Natter 只能在 Linux 主机上的 Docker 内工作,而 Docker Desktop for Mac、Docker Desktop for Windows 等不被支持,因为它们不能使用 Host 网络。非 Linux 用户,请在主机上安装 Python 使用 Natter。 26 | 27 | ## 使用 Natter 28 | 29 | 运行 Natter ,默认会开启 HTTP 测试模式: 30 | 31 | ```bash 32 | docker run --net=host nattertool/natter 33 | ``` 34 | 35 | 使用内置转发,对外开放本机 80 端口: 36 | 37 | ```bash 38 | docker run --net=host nattertool/natter -p 80 39 | ``` 40 | 41 | 使用 iptables 内核转发(需要额外权限),对外开放本机 80 端口: 42 | 43 | ```bash 44 | docker run \ 45 | --net=host \ 46 | --cap-add=NET_ADMIN \ 47 | --cap-add=NET_RAW \ 48 | nattertool/natter -m iptables -p 80 49 | ``` 50 | 51 | 52 | 查询命令行相关帮助: 53 | 54 | ```bash 55 | docker run --rm --net=host nattertool/natter --help 56 | ``` 57 | 58 | 有关详细参数用法,参见 [参数说明](../docs/usage.md) 。 59 | 60 | 61 | ## 选择不同的 Tag 62 | 63 | 使用默认的 `latest` Tag 就可以满足绝大多数需求。以下是全部种类: 64 | 65 | - `nattertool/natter:debian` (等同于 `latest`。基于 Debian 系统) 66 | - `nattertool/natter:alpine` (基于 Alpine Linux 系统) 67 | - `nattertool/natter:openwrt` (基于 OpenWrt 系统) 68 | - `nattertool/natter:minimal` (不推荐。基于 OpenWrt 系统。最小体积,只能使用最基本的功能) 69 | 70 | 目前仅支持 `AMD64`(又称 `x86_64`, `x64`)和 `ARM64`(又称 `AArch64`, `ARMv8`)两种架构。 71 | 72 | 73 | ## 与其他 Docker 服务结合使用 74 | 75 | Natter 可以和众多的 Docker 服务结合使用。本仓库提供了一些用例,可供您参考编写。 76 | 77 | ### Web 服务器 78 | 79 | 本仓库提供了以下用例,作为最基础的用法参考: 80 | - [Nginx](nginx) 81 | 82 | ### BT 类程序 83 | 84 | BT 类程序的特点是,需要向 Tracker 宣告自己的端口号。 85 | 本仓库提供了以下两种用例,可以开箱即用: 86 | - [qBittorrent](qbittorrent) 87 | - [Transmission](transmission) 88 | 89 | ### 使用 SRV 记录的程序 90 | 91 | 利用更改 DNS 的 SRV 记录应对随时可能变化的外部端口号。 92 | 本仓库提供了以下用例,需要填写您的 CloudFlare API 令牌: 93 | - [Minecraft](minecraft) 94 | 95 | ### 使用 HTTP 跳转服务 96 | 97 | 利用 HTTP 跳转,实时跳转到当前的外部端口的 HTTP 服务。 98 | 本仓库提供了以下用例,需要填写您的 CloudFlare API 令牌: 99 | - [Nginx-CloudFlare](nginx-cloudflare) 100 | 101 | ### 使用订阅服务 102 | 103 | 利用订阅服务,及时更新外部 IP 和端口号,使用代理工具回家。 104 | 订阅服务本身由 HTTP 跳转实现。 105 | 本仓库提供了以下用例,需要填写您的 CloudFlare API 令牌: 106 | - [V2Fly-Nginx-CloudFlare](v2fly-nginx-cloudflare) 107 | -------------------------------------------------------------------------------- /natter-docker/build_and_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | natter_repo="nattertool/natter" 4 | natter_ver=$(cd .. && python3 -Bc 'print(__import__("natter").__version__)') 5 | [ "dev" = $(echo "$natter_ver" | cut -d- -f2) ] && natter_ver="dev" 6 | 7 | function push_natter_manifest() 8 | { 9 | tag="$1" 10 | docker manifest create "$natter_repo:$tag" \ 11 | "$natter_repo:$tag-amd64" \ 12 | "$natter_repo:$tag-arm64" 13 | docker manifest push "$natter_repo:$tag" 14 | docker manifest rm "$natter_repo:$tag" 15 | } 16 | 17 | function tag_natter() 18 | { 19 | tag="$1" 20 | new_tags=("${@:2}") 21 | cmd=() 22 | for new_tag in "${new_tags[@]}"; do 23 | cmd+=("-t") 24 | cmd+=("$natter_repo:$new_tag") 25 | done 26 | docker buildx imagetools create "${cmd[@]}" "$natter_repo:$tag" 27 | } 28 | 29 | function build_and_push() 30 | { 31 | docker compose build --no-cache 32 | 33 | docker push "$natter_repo" --all-tags 34 | 35 | push_natter_manifest dev-debian 36 | push_natter_manifest dev-alpine 37 | push_natter_manifest dev-openwrt 38 | push_natter_manifest dev-minimal 39 | 40 | tag_natter dev-debian dev 41 | } 42 | 43 | function tag_release() 44 | { 45 | tag_natter dev-debian "$natter_ver-debian" debian "$natter_ver" latest 46 | tag_natter dev-alpine "$natter_ver-alpine" alpine 47 | tag_natter dev-openwrt "$natter_ver-openwrt" openwrt 48 | tag_natter dev-minimal "$natter_ver-minimal" minimal 49 | } 50 | 51 | 52 | build_and_push 53 | if [ "$1" == "release" ]; then 54 | tag_release 55 | fi 56 | -------------------------------------------------------------------------------- /natter-docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | natter-debian-amd64: 4 | command: -m test 5 | cap_add: 6 | - NET_ADMIN 7 | - NET_RAW 8 | environment: 9 | - TZ=Asia/Shanghai 10 | network_mode: host 11 | image: nattertool/natter:dev-debian-amd64 12 | platform: linux/amd64 13 | build: 14 | context: .. 15 | dockerfile: natter-docker/Dockerfile.debian-amd64 16 | platforms: 17 | - linux/amd64 18 | 19 | natter-debian-arm64: 20 | command: -m test 21 | cap_add: 22 | - NET_ADMIN 23 | - NET_RAW 24 | environment: 25 | - TZ=Asia/Shanghai 26 | network_mode: host 27 | image: nattertool/natter:dev-debian-arm64 28 | platform: linux/arm64 29 | build: 30 | context: .. 31 | dockerfile: natter-docker/Dockerfile.debian-arm64 32 | platforms: 33 | - linux/arm64 34 | 35 | natter-alpine-amd64: 36 | command: -m test 37 | cap_add: 38 | - NET_ADMIN 39 | - NET_RAW 40 | environment: 41 | - TZ=Asia/Shanghai 42 | network_mode: host 43 | image: nattertool/natter:dev-alpine-amd64 44 | platform: linux/amd64 45 | build: 46 | context: .. 47 | dockerfile: natter-docker/Dockerfile.alpine-amd64 48 | platforms: 49 | - linux/amd64 50 | 51 | natter-alpine-arm64: 52 | command: -m test 53 | cap_add: 54 | - NET_ADMIN 55 | - NET_RAW 56 | environment: 57 | - TZ=Asia/Shanghai 58 | network_mode: host 59 | image: nattertool/natter:dev-alpine-arm64 60 | platform: linux/arm64 61 | build: 62 | context: .. 63 | dockerfile: natter-docker/Dockerfile.alpine-arm64 64 | platforms: 65 | - linux/arm64 66 | 67 | natter-openwrt-amd64: 68 | command: -m test 69 | cap_add: 70 | - NET_ADMIN 71 | - NET_RAW 72 | environment: 73 | - TZ=Asia/Shanghai 74 | network_mode: host 75 | image: nattertool/natter:dev-openwrt-amd64 76 | platform: linux/amd64 77 | build: 78 | context: .. 79 | dockerfile: natter-docker/Dockerfile.openwrt-amd64 80 | platforms: 81 | - linux/amd64 82 | 83 | natter-openwrt-arm64: 84 | command: -m test 85 | cap_add: 86 | - NET_ADMIN 87 | - NET_RAW 88 | environment: 89 | - TZ=Asia/Shanghai 90 | network_mode: host 91 | image: nattertool/natter:dev-openwrt-arm64 92 | platform: linux/arm64 93 | build: 94 | context: .. 95 | dockerfile: natter-docker/Dockerfile.openwrt-arm64 96 | platforms: 97 | - linux/arm64 98 | 99 | natter-minimal-amd64: 100 | command: -m test 101 | cap_add: 102 | - NET_ADMIN 103 | - NET_RAW 104 | environment: 105 | - TZ=Asia/Shanghai 106 | network_mode: host 107 | image: nattertool/natter:dev-minimal-amd64 108 | platform: linux/amd64 109 | build: 110 | context: .. 111 | dockerfile: natter-docker/Dockerfile.minimal-amd64 112 | platforms: 113 | - linux/amd64 114 | 115 | natter-minimal-arm64: 116 | command: -m test 117 | cap_add: 118 | - NET_ADMIN 119 | - NET_RAW 120 | environment: 121 | - TZ=Asia/Shanghai 122 | network_mode: host 123 | image: nattertool/natter:dev-minimal-arm64 124 | platform: linux/arm64 125 | build: 126 | context: .. 127 | dockerfile: natter-docker/Dockerfile.minimal-arm64 128 | platforms: 129 | - linux/arm64 130 | -------------------------------------------------------------------------------- /natter-docker/minecraft/README.md: -------------------------------------------------------------------------------- 1 | # Minecraft 2 | 3 | 此目录为在 Docker 中使用 Natter 的一个示例。 4 | 5 | 本示例可以运行一个 Minecraft 服务端,使用 Natter 将其端口映射至公网,并使用 CloudFlare 动态更新 A 记录和 SRV 记录。 6 | 7 | 动态更新的 A 记录保存了您的 IP 地址,SRV 保存了您 Minecraft 服务端的端口号。这样您就可以直接使用域名登录 Minecraft 服务器,而不用指定 IP 地址和端口号。 8 | 9 | 10 | ## 使用前 11 | 12 | - 您的域名需已加入 CloudFlare 13 | 14 | - 修改 `cf-srv.py` 中的相关参数: 15 | - `cf_srv_service` 值保持不变。 16 | - `cf_domain` 值修改为您想要设置的二级域名。 17 | - `cf_auth_email` 值修改为您的 CloudFlare 邮箱。 18 | - `cf_auth_key` 值修改为您的 CloudFlare API Key。获取方式: 19 | - 登录 [CloudFlare](https://dash.cloudflare.com/) 20 | - 进入 [https://dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens) 21 | - 点击 **Global API Key** 右侧「查看」按钮 22 | 23 | - 使用 `cd` 命令进入此目录 24 | 25 | 26 | ## 开始使用 27 | 28 | 前台运行: 29 | ```bash 30 | docker compose up 31 | ``` 32 | 33 | 后台运行: 34 | ```bash 35 | docker compose up -d 36 | ``` 37 | 38 | 查看日志: 39 | ```bash 40 | docker compose logs -f 41 | ``` 42 | 43 | 结束运行: 44 | ```bash 45 | docker compose down 46 | ``` 47 | 48 | 49 | ## 修改参数 50 | 51 | ### 使用特定的 Minecraft 版本号 52 | 53 | 启动容器前,请在 `docker-compose.yml` 中,请修改 `minecraft-server:` 下的 `environment:` 部分,将 `VERSION` 值设置为您想要的版本。 54 | 55 | ### 修改 Minecraft 服务的端口号 56 | 57 | 本示例使用 `25565` 端口。 58 | 59 | 在 `docker-compose.yml` 中,请修改 `minecraft-server:` 部分: 60 | 61 | ```yaml 62 | ports: 63 | - "25565:25565" 64 | ``` 65 | 66 | 以及 `natter-mc:` 部分: 67 | 68 | ```yaml 69 | command: -m iptables -e /opt/cf-srv.py -p 25565 -r 70 | ``` 71 | 72 | 将 `25565` 修改为其他端口。 73 | -------------------------------------------------------------------------------- /natter-docker/minecraft/cf-srv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import urllib.request 3 | import json 4 | import sys 5 | 6 | # Natter notification script arguments 7 | protocol, private_ip, private_port, public_ip, public_port = sys.argv[1:6] 8 | 9 | cf_srv_service = "_minecraft" 10 | cf_domain = "mc.example.com" 11 | cf_auth_email = "email@example.com" 12 | cf_auth_key = "d41d8cd98f00b204e9800998ecf8427e" 13 | 14 | 15 | def main(): 16 | cf = CloudFlareDNS(cf_auth_email, cf_auth_key) 17 | 18 | print(f"Setting {cf_domain} A record to {public_ip}...") 19 | cf.set_a_record(cf_domain, public_ip) 20 | 21 | print(f"Setting {cf_domain} SRV record to {protocol} port {public_port}...") 22 | cf.set_srv_record(cf_domain, public_port, service=cf_srv_service, protocol=f"_{protocol}") 23 | 24 | 25 | class CloudFlareDNS: 26 | def __init__(self, auth_email, auth_key): 27 | self.opener = urllib.request.build_opener() 28 | self.opener.addheaders = [ 29 | ("X-Auth-Email", auth_email), 30 | ("X-Auth-Key", auth_key), 31 | ("Content-Type", "application/json") 32 | ] 33 | 34 | def set_a_record(self, name, ipaddr): 35 | zone_id = self._find_zone_id(name) 36 | if not zone_id: 37 | raise ValueError("%s is not on CloudFlare" % name) 38 | rec_id = self._find_a_record(zone_id, name) 39 | if not rec_id: 40 | rec_id = self._create_a_record(zone_id, name, ipaddr) 41 | else: 42 | rec_id = self._update_a_record(zone_id, rec_id, name, ipaddr) 43 | return rec_id 44 | 45 | def set_srv_record(self, name, port, service="_natter", protocol="_tcp"): 46 | zone_id = self._find_zone_id(name) 47 | if not zone_id: 48 | raise ValueError("%s is not on CloudFlare" % name) 49 | rec_id = self._find_srv_record(zone_id, name) 50 | if not rec_id: 51 | rec_id = self._create_srv_record(zone_id, name, service, 52 | protocol, port, name) 53 | else: 54 | rec_id = self._update_srv_record(zone_id, rec_id, name, service, 55 | protocol, port, name) 56 | return rec_id 57 | 58 | def _url_req(self, url, data=None, method=None): 59 | data_bin = None 60 | if data is not None: 61 | data_bin = json.dumps(data).encode() 62 | req = urllib.request.Request(url, data=data_bin, method=method) 63 | try: 64 | with self.opener.open(req, timeout=10) as res: 65 | ret = json.load(res) 66 | except urllib.error.HTTPError as e: 67 | ret = json.load(e) 68 | if "errors" not in ret: 69 | raise RuntimeError(ret) 70 | if not ret.get("success"): 71 | raise RuntimeError(ret["errors"]) 72 | return ret 73 | 74 | def _find_zone_id(self, name): 75 | name = name.lower() 76 | data = self._url_req( 77 | f"https://api.cloudflare.com/client/v4/zones" 78 | ) 79 | for zone_data in data["result"]: 80 | zone_name = zone_data["name"] 81 | if name == zone_name or name.endswith("." + zone_name): 82 | zone_id = zone_data["id"] 83 | return zone_id 84 | return None 85 | 86 | def _find_a_record(self, zone_id, name): 87 | name = name.lower() 88 | data = self._url_req( 89 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" 90 | ) 91 | for rec_data in data["result"]: 92 | if rec_data["type"] == "A" and rec_data["name"] == name: 93 | rec_id = rec_data["id"] 94 | return rec_id 95 | return None 96 | 97 | def _create_a_record(self, zone_id, name, ipaddr, proxied=False, ttl=120): 98 | name = name.lower() 99 | data = self._url_req( 100 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records", 101 | data={ 102 | "content": ipaddr, 103 | "name": name, 104 | "proxied": proxied, 105 | "type": "A", 106 | "ttl": ttl 107 | }, 108 | method="POST" 109 | ) 110 | return data["result"]["id"] 111 | 112 | def _update_a_record(self, zone_id, rec_id, name, ipaddr, proxied=False, ttl=120): 113 | name = name.lower() 114 | data = self._url_req( 115 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{rec_id}", 116 | data={ 117 | "content": ipaddr, 118 | "name": name, 119 | "proxied": proxied, 120 | "type": "A", 121 | "ttl": ttl 122 | }, 123 | method="PUT" 124 | ) 125 | return data["result"]["id"] 126 | 127 | def _find_srv_record(self, zone_id, name): 128 | name = name.lower() 129 | data = self._url_req( 130 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" 131 | ) 132 | for rec_data in data["result"]: 133 | if rec_data["type"] == "SRV" and rec_data["data"]["name"] == name: 134 | rec_id = rec_data["id"] 135 | return rec_id 136 | return None 137 | 138 | def _create_srv_record(self, zone_id, name, service, protocol, port, target, 139 | priority=1, weight=10, ttl=120): 140 | name = name.lower() 141 | data = self._url_req( 142 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records", 143 | data={ 144 | "data": { 145 | "name": name, 146 | "port": port, 147 | "priority": priority, 148 | "proto": protocol, 149 | "service": service, 150 | "target": target, 151 | "weight": weight 152 | }, 153 | "proxied": False, 154 | "type": "SRV", 155 | "ttl": ttl 156 | }, 157 | method="POST" 158 | ) 159 | return data["result"]["id"] 160 | 161 | def _update_srv_record(self, zone_id, rec_id, name, service, protocol, port, target, 162 | priority=1, weight=10, ttl=120): 163 | name = name.lower() 164 | data = self._url_req( 165 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{rec_id}", 166 | data={ 167 | "data": { 168 | "name": name, 169 | "port": port, 170 | "priority": priority, 171 | "proto": protocol, 172 | "service": service, 173 | "target": target, 174 | "weight": weight 175 | }, 176 | "proxied": False, 177 | "type": "SRV", 178 | "ttl": ttl 179 | }, 180 | method="PUT" 181 | ) 182 | return data["result"]["id"] 183 | 184 | 185 | if __name__ == "__main__": 186 | main() 187 | -------------------------------------------------------------------------------- /natter-docker/minecraft/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | natter-mc: 4 | command: -e /opt/cf-srv.py -p 25565 -r 5 | volumes: 6 | - ./cf-srv.py:/opt/cf-srv.py 7 | environment: 8 | - TZ=Asia/Shanghai 9 | network_mode: host 10 | image: nattertool/natter 11 | restart: always 12 | depends_on: 13 | - minecraft-server 14 | 15 | minecraft-server: 16 | volumes: 17 | - ./data:/data 18 | environment: 19 | - TZ=Asia/Shanghai 20 | - VERSION=1.20.2 21 | - EULA=TRUE 22 | ports: 23 | - "25565:25565" 24 | stdin_open: true 25 | tty: true 26 | image: itzg/minecraft-server 27 | restart: always 28 | -------------------------------------------------------------------------------- /natter-docker/nginx-cloudflare/README.md: -------------------------------------------------------------------------------- 1 | # Nginx-CloudFlare 2 | 3 | 此目录为在 Docker 中使用 Natter 的一个示例。 4 | 5 | 本示例可以运行一个 Nginx 服务器,使用 Natter 将其端口映射至公网,并使用 CloudFlare 动态跳转。 6 | 7 | 8 | ## 使用前 9 | 10 | - 您的域名需已加入 CloudFlare 11 | 12 | - 修改 `cf-redir.py` 中的相关参数: 13 | - `cf_redirect_to_https` 值保持不变。 14 | - `cf_redirect_host` 值修改为您的“跳转域名”,访问该域名会跳转到“直连域名:动态端口号”。 15 | - `cf_direct_host` 值修改为您的“直连域名”,该域名指向您的动态 IP 地址。 16 | - `cf_auth_email` 值修改为您的 CloudFlare 邮箱。 17 | - `cf_auth_key` 值修改为您的 CloudFlare API Key。获取方式: 18 | - 登录 [CloudFlare](https://dash.cloudflare.com/) 19 | - 进入 [https://dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens) 20 | - 点击 **Global API Key** 右侧「查看」按钮 21 | 22 | - 使用 `cd` 命令进入此目录 23 | 24 | 25 | ## 开始使用 26 | 27 | 前台运行: 28 | ```bash 29 | docker compose up 30 | ``` 31 | 32 | 后台运行: 33 | ```bash 34 | docker compose up -d 35 | ``` 36 | 37 | 查看日志: 38 | ```bash 39 | docker compose logs -f 40 | ``` 41 | 42 | 结束运行: 43 | ```bash 44 | docker compose down 45 | ``` 46 | 47 | 48 | ## 修改参数 49 | 50 | ### 修改 Nginx 服务的端口号 51 | 52 | 本示例使用 `18888` 端口。 53 | 54 | 在 `docker-compose.yml` 中,请修改 `nginx:` 部分: 55 | 56 | ```yaml 57 | ports: 58 | - "18888:80" 59 | ``` 60 | 61 | 以及 `natter-nginx:` 部分: 62 | 63 | ```yaml 64 | command: -m iptables -e /opt/cf-redir.py -p 18888 65 | ``` 66 | 67 | 将 `18888` 修改为其他端口。 68 | -------------------------------------------------------------------------------- /natter-docker/nginx-cloudflare/cf-redir.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import urllib.request 3 | import json 4 | import sys 5 | 6 | # Natter notification script arguments 7 | protocol, private_ip, private_port, public_ip, public_port = sys.argv[1:6] 8 | 9 | cf_redirect_to_https = False 10 | cf_redirect_host = "redirect.example.com" 11 | cf_direct_host = "direct.example.com" 12 | cf_auth_email = "email@example.com" 13 | cf_auth_key = "d41d8cd98f00b204e9800998ecf8427e" 14 | 15 | 16 | def main(): 17 | cf = CloudFlareRedir(cf_auth_email, cf_auth_key) 18 | 19 | print(f"Setting [ {cf_redirect_host} ] DNS to [ {public_ip} ] proxied by CloudFlare...") 20 | cf.set_a_record(cf_redirect_host, public_ip, proxied=True) 21 | 22 | print(f"Setting [ {cf_direct_host} ] DNS to [ {public_ip} ] directly...") 23 | cf.set_a_record(cf_direct_host, public_ip, proxied=False) 24 | 25 | print(f"Setting [ {cf_redirect_host} ] redirecting to [ {cf_direct_host}:{public_port} ], https={cf_redirect_to_https}...") 26 | cf.set_redirect_rule(cf_redirect_host, cf_direct_host, public_port, cf_redirect_to_https) 27 | 28 | 29 | class CloudFlareRedir: 30 | def __init__(self, auth_email, auth_key): 31 | self.opener = urllib.request.build_opener() 32 | self.opener.addheaders = [ 33 | ("X-Auth-Email", auth_email), 34 | ("X-Auth-Key", auth_key), 35 | ("Content-Type", "application/json") 36 | ] 37 | 38 | def set_a_record(self, name, ipaddr, proxied=False): 39 | zone_id = self._find_zone_id(name) 40 | if not zone_id: 41 | raise ValueError("%s is not on CloudFlare" % name) 42 | rec_id = self._find_a_record(zone_id, name) 43 | if not rec_id: 44 | rec_id = self._create_a_record(zone_id, name, ipaddr, proxied) 45 | else: 46 | rec_id = self._update_a_record(zone_id, rec_id, name, ipaddr, proxied) 47 | return rec_id 48 | 49 | def set_redirect_rule(self, redirect_host, direct_host, public_port, https): 50 | zone_id = self._find_zone_id(redirect_host) 51 | ruleset_id = self._get_redir_ruleset(zone_id) 52 | if not ruleset_id: 53 | ruleset_id = self._create_redir_ruleset(zone_id) 54 | rule_id = self._find_redir_rule(zone_id, ruleset_id, redirect_host) 55 | if not rule_id: 56 | rule_id = self._create_redir_rule(zone_id, ruleset_id, redirect_host, direct_host, public_port, https) 57 | else: 58 | rule_id = self._update_redir_rule(zone_id, ruleset_id, rule_id, redirect_host, direct_host, public_port, https) 59 | return rule_id 60 | 61 | def _url_req(self, url, data=None, method=None): 62 | data_bin = None 63 | if data is not None: 64 | data_bin = json.dumps(data).encode() 65 | req = urllib.request.Request(url, data=data_bin, method=method) 66 | try: 67 | with self.opener.open(req, timeout=10) as res: 68 | ret = json.load(res) 69 | except urllib.error.HTTPError as e: 70 | ret = json.load(e) 71 | if "errors" not in ret: 72 | raise RuntimeError(ret) 73 | if not ret.get("success"): 74 | raise RuntimeError(ret["errors"]) 75 | return ret 76 | 77 | def _find_zone_id(self, name): 78 | name = name.lower() 79 | data = self._url_req( 80 | f"https://api.cloudflare.com/client/v4/zones" 81 | ) 82 | for zone_data in data["result"]: 83 | zone_name = zone_data["name"] 84 | if name == zone_name or name.endswith("." + zone_name): 85 | zone_id = zone_data["id"] 86 | return zone_id 87 | return None 88 | 89 | def _find_a_record(self, zone_id, name): 90 | name = name.lower() 91 | data = self._url_req( 92 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" 93 | ) 94 | for rec_data in data["result"]: 95 | if rec_data["type"] == "A" and rec_data["name"] == name: 96 | rec_id = rec_data["id"] 97 | return rec_id 98 | return None 99 | 100 | def _create_a_record(self, zone_id, name, ipaddr, proxied=False, ttl=120): 101 | name = name.lower() 102 | data = self._url_req( 103 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records", 104 | data={ 105 | "content": ipaddr, 106 | "name": name, 107 | "proxied": proxied, 108 | "type": "A", 109 | "ttl": ttl 110 | }, 111 | method="POST" 112 | ) 113 | return data["result"]["id"] 114 | 115 | def _update_a_record(self, zone_id, rec_id, name, ipaddr, proxied=False, ttl=120): 116 | name = name.lower() 117 | data = self._url_req( 118 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{rec_id}", 119 | data={ 120 | "content": ipaddr, 121 | "name": name, 122 | "proxied": proxied, 123 | "type": "A", 124 | "ttl": ttl 125 | }, 126 | method="PUT" 127 | ) 128 | return data["result"]["id"] 129 | 130 | def _get_redir_ruleset(self, zone_id): 131 | data = self._url_req( 132 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets" 133 | ) 134 | for ruleset_data in data["result"]: 135 | if ruleset_data["phase"] == "http_request_dynamic_redirect": 136 | ruleset_id = ruleset_data["id"] 137 | return ruleset_id 138 | return None 139 | 140 | def _create_redir_ruleset(self, zone_id): 141 | data = self._url_req( 142 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets", 143 | data={ 144 | "name": "Redirect rules ruleset", 145 | "kind": "zone", 146 | "phase": "http_request_dynamic_redirect", 147 | "rules": [] 148 | }, 149 | method="POST" 150 | ) 151 | return data["result"]["id"] 152 | 153 | def _get_description(self, redirect_host): 154 | return f"Natter: {redirect_host}" 155 | 156 | def _find_redir_rule(self, zone_id, ruleset_id, redirect_host): 157 | data = self._url_req( 158 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets/{ruleset_id}" 159 | ) 160 | if "rules" not in data["result"]: 161 | return None 162 | for rule_data in data["result"]["rules"]: 163 | if rule_data["description"] == self._get_description(redirect_host): 164 | rule_id = rule_data["id"] 165 | return rule_id 166 | return None 167 | 168 | def _create_redir_rule(self, zone_id, ruleset_id, redirect_host, direct_host, public_port, https): 169 | proto = "http" 170 | if https: 171 | proto = "https" 172 | data = self._url_req( 173 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets/{ruleset_id}/rules", 174 | data={ 175 | "action": "redirect", 176 | "action_parameters": { 177 | "from_value": { 178 | "status_code": 302, 179 | "target_url": { 180 | "expression": f'concat("{proto}://{direct_host}:{public_port}", http.request.uri.path)' 181 | }, 182 | "preserve_query_string": True 183 | } 184 | }, 185 | "description": self._get_description(redirect_host), 186 | "enabled": True, 187 | "expression": f'(http.host eq "{redirect_host}")' 188 | }, 189 | method="POST" 190 | ) 191 | for rule_data in data["result"]["rules"]: 192 | if rule_data["description"] == self._get_description(redirect_host): 193 | rule_id = rule_data["id"] 194 | return rule_id 195 | raise RuntimeError("Failed to create redirect rule") 196 | 197 | def _update_redir_rule(self, zone_id, ruleset_id, rule_id, redirect_host, direct_host, public_port, https): 198 | proto = "http" 199 | if https: 200 | proto = "https" 201 | data = self._url_req( 202 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets/{ruleset_id}/rules/{rule_id}", 203 | data={ 204 | "action": "redirect", 205 | "action_parameters": { 206 | "from_value": { 207 | "status_code": 302, 208 | "target_url": { 209 | "expression": f'concat("{proto}://{direct_host}:{public_port}", http.request.uri.path)' 210 | }, 211 | "preserve_query_string": True 212 | } 213 | }, 214 | "description": self._get_description(redirect_host), 215 | "enabled": True, 216 | "expression": f'(http.host eq "{redirect_host}")' 217 | }, 218 | method="PATCH" 219 | ) 220 | for rule_data in data["result"]["rules"]: 221 | if rule_data["description"] == self._get_description(redirect_host): 222 | rule_id = rule_data["id"] 223 | return rule_id 224 | raise RuntimeError("Failed to update redirect rule") 225 | 226 | 227 | if __name__ == "__main__": 228 | main() 229 | -------------------------------------------------------------------------------- /natter-docker/nginx-cloudflare/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | natter-nginx-cf: 4 | command: -e /opt/cf-redir.py -p 18888 5 | volumes: 6 | - ./cf-redir.py:/opt/cf-redir.py 7 | environment: 8 | - TZ=Asia/Shanghai 9 | network_mode: host 10 | image: nattertool/natter 11 | restart: always 12 | depends_on: 13 | - nginx 14 | 15 | nginx: 16 | volumes: 17 | - ./html:/usr/share/nginx/html 18 | ports: 19 | - "18888:80" 20 | environment: 21 | - TZ=Asia/Shanghai 22 | image: nginx 23 | restart: always 24 | -------------------------------------------------------------------------------- /natter-docker/nginx-cloudflare/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Welcome to nginx (Natter)! 5 | 12 | 13 | 14 |

Welcome to nginx (Natter)!

15 |

If you see this page, the nginx web server is successfully installed and 16 | working. Further configuration is required.

17 | 18 |

For online documentation and support please refer to 19 | nginx.org.
20 | Commercial support is available at 21 | nginx.com.

22 | 23 |

Thank you for using nginx.

24 | 25 | 26 | -------------------------------------------------------------------------------- /natter-docker/nginx/README.md: -------------------------------------------------------------------------------- 1 | # Nginx 2 | 3 | 此目录为在 Docker 中使用 Natter 的一个示例。 4 | 5 | 本示例可以运行一个 Nginx 服务器,并使用 Natter 将其端口映射至公网。 6 | 7 | 8 | ## 使用前 9 | 10 | - 使用 `cd` 命令进入此目录 11 | 12 | 13 | ## 开始使用 14 | 15 | 前台运行: 16 | ```bash 17 | docker compose up 18 | ``` 19 | 20 | 后台运行: 21 | ```bash 22 | docker compose up -d 23 | ``` 24 | 25 | 查看日志: 26 | ```bash 27 | docker compose logs -f 28 | ``` 29 | 30 | 结束运行: 31 | ```bash 32 | docker compose down 33 | ``` 34 | 35 | 36 | ## 修改参数 37 | 38 | ### 修改 Nginx 服务的端口号 39 | 40 | 本示例使用 `18888` 端口。 41 | 42 | 在 `docker-compose.yml` 中,请修改 `nginx:` 部分: 43 | 44 | ```yaml 45 | ports: 46 | - "18888:80" 47 | ``` 48 | 49 | 以及 `natter-nginx:` 部分: 50 | 51 | ```yaml 52 | command: -m iptables -p 18888 53 | ``` 54 | 55 | 将 `18888` 修改为其他端口。 56 | -------------------------------------------------------------------------------- /natter-docker/nginx/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | natter-nginx: 4 | command: -p 18888 5 | environment: 6 | - TZ=Asia/Shanghai 7 | network_mode: host 8 | image: nattertool/natter 9 | restart: always 10 | depends_on: 11 | - nginx 12 | 13 | nginx: 14 | volumes: 15 | - ./html:/usr/share/nginx/html 16 | ports: 17 | - "18888:80" 18 | environment: 19 | - TZ=Asia/Shanghai 20 | image: nginx 21 | restart: always 22 | -------------------------------------------------------------------------------- /natter-docker/nginx/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Welcome to nginx (Natter)! 5 | 12 | 13 | 14 |

Welcome to nginx (Natter)!

15 |

If you see this page, the nginx web server is successfully installed and 16 | working. Further configuration is required.

17 | 18 |

For online documentation and support please refer to 19 | nginx.org.
20 | Commercial support is available at 21 | nginx.com.

22 | 23 |

Thank you for using nginx.

24 | 25 | 26 | -------------------------------------------------------------------------------- /natter-docker/qbittorrent/README.md: -------------------------------------------------------------------------------- 1 | # qBittorrent 2 | 3 | 此目录为在 Docker 中使用 Natter 的一个示例。 4 | 5 | 本示例可以运行 qBittorrent 进行 BT 下载或做种,并使用 Natter 将其端口映射至公网。 6 | 7 | 8 | ## 使用前 9 | 10 | - 使用 `cd` 命令进入此目录 11 | 12 | 13 | ## 开始使用 14 | 15 | 前台运行: 16 | ```bash 17 | docker compose up 18 | ``` 19 | 20 | 后台运行: 21 | ```bash 22 | docker compose up -d 23 | ``` 24 | 25 | 查看日志: 26 | ```bash 27 | docker compose logs -f 28 | ``` 29 | 30 | 结束运行: 31 | ```bash 32 | docker compose down 33 | ``` 34 | 35 | Web 后台地址(请将 127.0.0.1 替换为当前主机 IP 地址): 36 | ``` 37 | http://127.0.0.1:18080/ 38 | ``` 39 | 40 | 41 | ## 修改参数 42 | 43 | ### 修改 qBittorrent 的用户名和密码 44 | 45 | 您可以直接在 qBittorrent 的 Web 页面中修改用户名和密码。 46 | 47 | 完成后,修改通知脚本 `qb.sh`: 48 | 49 | ```bash 50 | qb_username="admin" 51 | qb_password="adminadmin" 52 | ``` 53 | 54 | 将用户名 `admin` 和密码 `adminadmin` 修改为您设置的新用户名和密码。 55 | 56 | ### 修改 qBittorrent 的 Web 端口号 57 | 58 | 本示例使用 `18080` 端口。 59 | 60 | 在 `docker-compose.yml` 中,请修改 `qbittorrent:` 部分: 61 | 62 | ```yaml 63 | environment: 64 | - WEBUI_PORT=18080 65 | ``` 66 | 67 | 并修改通知脚本 `qb.sh`: 68 | 69 | ```bash 70 | qb_web_url="http://127.0.0.1:18080" 71 | ``` 72 | 73 | 将 `18080` 修改为其他端口。 74 | -------------------------------------------------------------------------------- /natter-docker/qbittorrent/config/qBittorrent/qBittorrent.conf: -------------------------------------------------------------------------------- 1 | [AutoRun] 2 | enabled=false 3 | 4 | [LegalNotice] 5 | Accepted=true 6 | 7 | [Preferences] 8 | Downloads\SavePath=/downloads/ 9 | Downloads\ScanDirsV2=@Variant(\0\0\0\x1c\0\0\0\0) 10 | Downloads\TempPath=/downloads/incomplete/ 11 | WebUI\Port=18080 12 | WebUI\Address=* 13 | WebUI\ServerDomains=* 14 | WebUI\Password_PBKDF2="@ByteArray(ARQ77eY1NUZaQsuDHbIMCA==:0WMRkYTUWVT9wVvdDtHAjU9b3b7uB8NR1Gur2hmQCvCDpm39Q+PsJRJPaCU51dEiz+dTzh8qbPsL8WkFljQYFQ==)" 15 | -------------------------------------------------------------------------------- /natter-docker/qbittorrent/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | natter-qb: 4 | command: -m iptables -e /opt/qb.sh -r 5 | volumes: 6 | - ./qb.sh:/opt/qb.sh 7 | cap_add: 8 | - NET_ADMIN 9 | - NET_RAW 10 | environment: 11 | - TZ=Asia/Shanghai 12 | network_mode: host 13 | image: nattertool/natter 14 | restart: always 15 | depends_on: 16 | - qbittorrent 17 | 18 | qbittorrent: 19 | volumes: 20 | - ./config:/config 21 | - ./downloads:/downloads 22 | environment: 23 | - TZ=Asia/Shanghai 24 | - WEBUI_PORT=18080 25 | - PUID=1000 26 | - PGID=1000 27 | - LANG=zh_CN.UTF-8 28 | - LC_ALL=zh_CN.UTF-8 29 | network_mode: host 30 | image: linuxserver/qbittorrent 31 | restart: always 32 | -------------------------------------------------------------------------------- /natter-docker/qbittorrent/qb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Natter notification script arguments 4 | protocol="$1"; private_ip="$2"; private_port="$3"; public_ip="$4"; public_port="$5" 5 | 6 | # qBittorrent 7 | qb_web_url="http://127.0.0.1:18080" 8 | qb_username="admin" 9 | qb_password="adminadmin" 10 | 11 | echo "Update qBittorrent listening port to ${public_port}..." 12 | 13 | qb_cookie=$( 14 | curl "${qb_web_url}/api/v2/auth/login" \ 15 | -X POST -sS --include \ 16 | -H "Referer: ${qb_web_url}" \ 17 | --data-raw "username=${qb_username}&password=${qb_password}" \ 18 | | grep -m1 -i '^Set-Cookie: ' | cut -c13- | tr -d '\r' 19 | ) 20 | 21 | curl "${qb_web_url}/api/v2/app/setPreferences" \ 22 | -X POST -sS \ 23 | -H "Referer: ${qb_web_url}" \ 24 | --cookie "${qb_cookie}" \ 25 | --data-raw 'json={"listen_port":"'"${public_port}"'"}' 26 | 27 | curl "${qb_web_url}/api/v2/auth/logout" \ 28 | -X POST -sS \ 29 | -H "Referer: ${qb_web_url}" \ 30 | --cookie "${qb_cookie}" 31 | 32 | echo "Done." 33 | -------------------------------------------------------------------------------- /natter-docker/transmission/README.md: -------------------------------------------------------------------------------- 1 | # Transmission 2 | 3 | 此目录为在 Docker 中使用 Natter 的一个示例。 4 | 5 | 本示例可以运行 Transmission 进行 BT 下载或做种,并使用 Natter 将其端口映射至公网。 6 | 7 | 8 | ## 使用前 9 | 10 | - 使用 `cd` 命令进入此目录 11 | 12 | 13 | ## 开始使用 14 | 15 | 前台运行: 16 | ```bash 17 | docker compose up 18 | ``` 19 | 20 | 后台运行: 21 | ```bash 22 | docker compose up -d 23 | ``` 24 | 25 | 查看日志: 26 | ```bash 27 | docker compose logs -f 28 | ``` 29 | 30 | 结束运行: 31 | ```bash 32 | docker compose down 33 | ``` 34 | 35 | Web 后台地址(请将 127.0.0.1 替换为当前主机 IP 地址): 36 | ``` 37 | http://127.0.0.1:9091/ 38 | ``` 39 | 40 | 41 | ## 修改参数 42 | 43 | ### 修改 Transmission 的用户名和密码 44 | 45 | 在 `docker-compose.yml` 中,请修改 `transmission:` 部分: 46 | 47 | ```yaml 48 | environment: 49 | - USER=admin 50 | - PASS=adminadmin 51 | ``` 52 | 53 | 并修改通知脚本 `tr.sh`: 54 | 55 | ```bash 56 | tr_username="admin" 57 | tr_password="adminadmin" 58 | ``` 59 | 60 | 将用户名 `admin` 和密码 `adminadmin` 修改为您所想要设置的值。 61 | 62 | ### 修改 Transmission 的 Web 端口号 63 | 64 | 本示例使用 `9091` 端口。 65 | 66 | 容器停止运行时,修改 Transmission 配置文件 `config/settings.json`: 67 | 68 | ```json 69 | "rpc-port": 9091, 70 | ``` 71 | 72 | 并修改通知脚本 `tr.sh`: 73 | 74 | ```bash 75 | tr_web_url="http://127.0.0.1:9091/transmission" 76 | ``` 77 | 78 | 将 `9091` 修改为其他端口。 79 | -------------------------------------------------------------------------------- /natter-docker/transmission/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | natter-tr: 4 | command: -m iptables -e /opt/tr.sh -r 5 | volumes: 6 | - ./tr.sh:/opt/tr.sh 7 | cap_add: 8 | - NET_ADMIN 9 | - NET_RAW 10 | environment: 11 | - TZ=Asia/Shanghai 12 | network_mode: host 13 | image: nattertool/natter 14 | restart: always 15 | depends_on: 16 | - transmission 17 | 18 | transmission: 19 | volumes: 20 | - ./config:/config 21 | - ./downloads:/downloads 22 | - ./watch:/watch 23 | environment: 24 | - TZ=Asia/Shanghai 25 | - USER=admin 26 | - PASS=adminadmin 27 | - WHITELIST=*.*.*.* 28 | - PUID=1000 29 | - PGID=1000 30 | network_mode: host 31 | image: linuxserver/transmission 32 | restart: always 33 | -------------------------------------------------------------------------------- /natter-docker/transmission/tr.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Natter notification script arguments 4 | protocol="$1"; private_ip="$2"; private_port="$3"; public_ip="$4"; public_port="$5" 5 | 6 | # Transmission 7 | tr_web_url="http://127.0.0.1:9091/transmission" 8 | tr_username="admin" 9 | tr_password="adminadmin" 10 | 11 | echo "Update Transmission listening port to ${public_port}..." 12 | 13 | tr_sessid=$( 14 | curl "${tr_web_url}/rpc" \ 15 | -X POST -Ss --include \ 16 | -u "${tr_username}:${tr_password}" \ 17 | -H "Referer: ${tr_web_url}" \ 18 | | grep -m1 -i '^X-Transmission-Session-Id: ' | cut -c28- | tr -d '\r' 19 | ) 20 | 21 | curl "${tr_web_url}/rpc" \ 22 | -X POST -Ss \ 23 | -u "${tr_username}:${tr_password}" \ 24 | -H "X-Transmission-Session-Id: ${tr_sessid}" \ 25 | -H "Referer: ${tr_web_url}" \ 26 | --data-raw '{"method":"session-set","arguments":{"peer-port":'"${public_port}"'}}' 27 | 28 | echo "Done." 29 | -------------------------------------------------------------------------------- /natter-docker/v2fly-nginx-cloudflare/README.md: -------------------------------------------------------------------------------- 1 | # V2Fly-Nginx-CloudFlare 2 | 3 | 此目录为在 Docker 中使用 Natter 的一个示例。 4 | 5 | 本示例使用 V2Fly 核心运行一个 VMess 服务器,并使用 Natter 将其端口映射至公网,外部设备可通过 VMess 协议接入内网。同时,参考 [Nginx-CloudFlare](../nginx-cloudflare) 建立一个 Web 服务,提供订阅信息,以便动态更新 IP 地址和端口。 6 | 7 | 8 | ## 使用前 9 | 10 | - 您的域名需已加入 CloudFlare 11 | 12 | - 修改 `cf-redir.py` 中的相关参数: 13 | - `cf_redirect_to_https` 值保持不变。 14 | - `cf_redirect_host` 值修改为您的“跳转域名”,访问该域名会跳转到“直连域名:动态端口号”。该域名将作为订阅链接的域名。 15 | - `cf_direct_host` 值修改为您的“直连域名”,该域名指向您的动态 IP 地址。 16 | - `cf_auth_email` 值修改为您的 CloudFlare 邮箱。 17 | - `cf_auth_key` 值修改为您的 CloudFlare API Key。获取方式: 18 | - 登录 [CloudFlare](https://dash.cloudflare.com/) 19 | - 进入 [https://dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens) 20 | - 点击 **Global API Key** 右侧「查看」按钮 21 | 22 | - 修改 `config.json` 中的 `id` 值: 23 | - 生成一个 UUID ,替换配置中默认的 `11111111-1111-1111-1111-111111111111` 24 | 25 | - 使用 `cd` 命令进入此目录 26 | 27 | 28 | ## 开始使用 29 | 30 | 前台运行: 31 | ```bash 32 | docker compose up 33 | ``` 34 | 35 | 后台运行: 36 | ```bash 37 | docker compose up -d 38 | ``` 39 | 40 | 查看日志: 41 | ```bash 42 | docker compose logs -f 43 | ``` 44 | 45 | 结束运行: 46 | ```bash 47 | docker compose down 48 | ``` 49 | 50 | 客户端配置: 51 | 52 | 假设 `cf_redirect_host` 的值为 `redirect.example.com`,客户端 ID 设置为 `11111111-1111-1111-1111-111111111111`。 53 | 54 | 该示例提供两种订阅地址,URL 为: 55 | ``` 56 | http://redirect.example.com/11111111-1111-1111-1111-111111111111.txt 57 | ``` 58 | ``` 59 | http://redirect.example.com/11111111-1111-1111-1111-111111111111.yml 60 | ``` 61 | 62 | 请选择客户端支持的一种订阅格式,将 URL 输入至客户端订阅列表中。 63 | 64 | 65 | ## 修改参数 66 | 67 | ### 修改 Nginx 服务的端口号 68 | 69 | 本示例使用 `18888` 端口。 70 | 71 | 在 `docker-compose.yml` 中,请修改 `nginx:` 部分: 72 | 73 | ```yaml 74 | ports: 75 | - "18888:80" 76 | ``` 77 | 78 | 以及 `natter-nginx:` 部分: 79 | 80 | ```yaml 81 | command: -m iptables -e /opt/cf-redir.py -p 18888 82 | ``` 83 | 84 | 将 `18888` 修改为其他端口。 85 | 86 | ### 修改 V2Fly 服务的端口号 87 | 88 | 本示例使用 `19999` 端口。 89 | 90 | 在 V2Fly 配置 `config.json` 中,请修改 `"inbounds":` 部分: 91 | 92 | ```json 93 | "port": 19999, 94 | ``` 95 | 96 | 并修改 `docker-compose.yml` 中的 `natter-v2fly:` 部分: 97 | 98 | ```yaml 99 | command: -m iptables -e /opt/v2subsc.py -p 19999 100 | ``` 101 | 102 | 将 `19999` 修改为其他端口。 103 | -------------------------------------------------------------------------------- /natter-docker/v2fly-nginx-cloudflare/cf-redir.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import urllib.request 3 | import json 4 | import sys 5 | 6 | # Natter notification script arguments 7 | protocol, private_ip, private_port, public_ip, public_port = sys.argv[1:6] 8 | 9 | cf_redirect_to_https = False 10 | cf_redirect_host = "redirect.example.com" 11 | cf_direct_host = "direct.example.com" 12 | cf_auth_email = "email@example.com" 13 | cf_auth_key = "d41d8cd98f00b204e9800998ecf8427e" 14 | 15 | 16 | def main(): 17 | cf = CloudFlareRedir(cf_auth_email, cf_auth_key) 18 | 19 | print(f"Setting [ {cf_redirect_host} ] DNS to [ {public_ip} ] proxied by CloudFlare...") 20 | cf.set_a_record(cf_redirect_host, public_ip, proxied=True) 21 | 22 | print(f"Setting [ {cf_direct_host} ] DNS to [ {public_ip} ] directly...") 23 | cf.set_a_record(cf_direct_host, public_ip, proxied=False) 24 | 25 | print(f"Setting [ {cf_redirect_host} ] redirecting to [ {cf_direct_host}:{public_port} ], https={cf_redirect_to_https}...") 26 | cf.set_redirect_rule(cf_redirect_host, cf_direct_host, public_port, cf_redirect_to_https) 27 | 28 | 29 | class CloudFlareRedir: 30 | def __init__(self, auth_email, auth_key): 31 | self.opener = urllib.request.build_opener() 32 | self.opener.addheaders = [ 33 | ("X-Auth-Email", auth_email), 34 | ("X-Auth-Key", auth_key), 35 | ("Content-Type", "application/json") 36 | ] 37 | 38 | def set_a_record(self, name, ipaddr, proxied=False): 39 | zone_id = self._find_zone_id(name) 40 | if not zone_id: 41 | raise ValueError("%s is not on CloudFlare" % name) 42 | rec_id = self._find_a_record(zone_id, name) 43 | if not rec_id: 44 | rec_id = self._create_a_record(zone_id, name, ipaddr, proxied) 45 | else: 46 | rec_id = self._update_a_record(zone_id, rec_id, name, ipaddr, proxied) 47 | return rec_id 48 | 49 | def set_redirect_rule(self, redirect_host, direct_host, public_port, https): 50 | zone_id = self._find_zone_id(redirect_host) 51 | ruleset_id = self._get_redir_ruleset(zone_id) 52 | if not ruleset_id: 53 | ruleset_id = self._create_redir_ruleset(zone_id) 54 | rule_id = self._find_redir_rule(zone_id, ruleset_id, redirect_host) 55 | if not rule_id: 56 | rule_id = self._create_redir_rule(zone_id, ruleset_id, redirect_host, direct_host, public_port, https) 57 | else: 58 | rule_id = self._update_redir_rule(zone_id, ruleset_id, rule_id, redirect_host, direct_host, public_port, https) 59 | return rule_id 60 | 61 | def _url_req(self, url, data=None, method=None): 62 | data_bin = None 63 | if data is not None: 64 | data_bin = json.dumps(data).encode() 65 | req = urllib.request.Request(url, data=data_bin, method=method) 66 | try: 67 | with self.opener.open(req, timeout=10) as res: 68 | ret = json.load(res) 69 | except urllib.error.HTTPError as e: 70 | ret = json.load(e) 71 | if "errors" not in ret: 72 | raise RuntimeError(ret) 73 | if not ret.get("success"): 74 | raise RuntimeError(ret["errors"]) 75 | return ret 76 | 77 | def _find_zone_id(self, name): 78 | name = name.lower() 79 | data = self._url_req( 80 | f"https://api.cloudflare.com/client/v4/zones" 81 | ) 82 | for zone_data in data["result"]: 83 | zone_name = zone_data["name"] 84 | if name == zone_name or name.endswith("." + zone_name): 85 | zone_id = zone_data["id"] 86 | return zone_id 87 | return None 88 | 89 | def _find_a_record(self, zone_id, name): 90 | name = name.lower() 91 | data = self._url_req( 92 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" 93 | ) 94 | for rec_data in data["result"]: 95 | if rec_data["type"] == "A" and rec_data["name"] == name: 96 | rec_id = rec_data["id"] 97 | return rec_id 98 | return None 99 | 100 | def _create_a_record(self, zone_id, name, ipaddr, proxied=False, ttl=120): 101 | name = name.lower() 102 | data = self._url_req( 103 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records", 104 | data={ 105 | "content": ipaddr, 106 | "name": name, 107 | "proxied": proxied, 108 | "type": "A", 109 | "ttl": ttl 110 | }, 111 | method="POST" 112 | ) 113 | return data["result"]["id"] 114 | 115 | def _update_a_record(self, zone_id, rec_id, name, ipaddr, proxied=False, ttl=120): 116 | name = name.lower() 117 | data = self._url_req( 118 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{rec_id}", 119 | data={ 120 | "content": ipaddr, 121 | "name": name, 122 | "proxied": proxied, 123 | "type": "A", 124 | "ttl": ttl 125 | }, 126 | method="PUT" 127 | ) 128 | return data["result"]["id"] 129 | 130 | def _get_redir_ruleset(self, zone_id): 131 | data = self._url_req( 132 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets" 133 | ) 134 | for ruleset_data in data["result"]: 135 | if ruleset_data["phase"] == "http_request_dynamic_redirect": 136 | ruleset_id = ruleset_data["id"] 137 | return ruleset_id 138 | return None 139 | 140 | def _create_redir_ruleset(self, zone_id): 141 | data = self._url_req( 142 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets", 143 | data={ 144 | "name": "Redirect rules ruleset", 145 | "kind": "zone", 146 | "phase": "http_request_dynamic_redirect", 147 | "rules": [] 148 | }, 149 | method="POST" 150 | ) 151 | return data["result"]["id"] 152 | 153 | def _get_description(self, redirect_host): 154 | return f"Natter: {redirect_host}" 155 | 156 | def _find_redir_rule(self, zone_id, ruleset_id, redirect_host): 157 | data = self._url_req( 158 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets/{ruleset_id}" 159 | ) 160 | if "rules" not in data["result"]: 161 | return None 162 | for rule_data in data["result"]["rules"]: 163 | if rule_data["description"] == self._get_description(redirect_host): 164 | rule_id = rule_data["id"] 165 | return rule_id 166 | return None 167 | 168 | def _create_redir_rule(self, zone_id, ruleset_id, redirect_host, direct_host, public_port, https): 169 | proto = "http" 170 | if https: 171 | proto = "https" 172 | data = self._url_req( 173 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets/{ruleset_id}/rules", 174 | data={ 175 | "action": "redirect", 176 | "action_parameters": { 177 | "from_value": { 178 | "status_code": 302, 179 | "target_url": { 180 | "expression": f'concat("{proto}://{direct_host}:{public_port}", http.request.uri.path)' 181 | }, 182 | "preserve_query_string": True 183 | } 184 | }, 185 | "description": self._get_description(redirect_host), 186 | "enabled": True, 187 | "expression": f'(http.host eq "{redirect_host}")' 188 | }, 189 | method="POST" 190 | ) 191 | for rule_data in data["result"]["rules"]: 192 | if rule_data["description"] == self._get_description(redirect_host): 193 | rule_id = rule_data["id"] 194 | return rule_id 195 | raise RuntimeError("Failed to create redirect rule") 196 | 197 | def _update_redir_rule(self, zone_id, ruleset_id, rule_id, redirect_host, direct_host, public_port, https): 198 | proto = "http" 199 | if https: 200 | proto = "https" 201 | data = self._url_req( 202 | f"https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets/{ruleset_id}/rules/{rule_id}", 203 | data={ 204 | "action": "redirect", 205 | "action_parameters": { 206 | "from_value": { 207 | "status_code": 302, 208 | "target_url": { 209 | "expression": f'concat("{proto}://{direct_host}:{public_port}", http.request.uri.path)' 210 | }, 211 | "preserve_query_string": True 212 | } 213 | }, 214 | "description": self._get_description(redirect_host), 215 | "enabled": True, 216 | "expression": f'(http.host eq "{redirect_host}")' 217 | }, 218 | method="PATCH" 219 | ) 220 | for rule_data in data["result"]["rules"]: 221 | if rule_data["description"] == self._get_description(redirect_host): 222 | rule_id = rule_data["id"] 223 | return rule_id 224 | raise RuntimeError("Failed to update redirect rule") 225 | 226 | 227 | if __name__ == "__main__": 228 | main() 229 | -------------------------------------------------------------------------------- /natter-docker/v2fly-nginx-cloudflare/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "inbounds": [ 3 | { 4 | "port": 19999, 5 | "protocol": "vmess", 6 | "settings": { 7 | "clients": [ 8 | { 9 | "id": "11111111-1111-1111-1111-111111111111" 10 | } 11 | ] 12 | } 13 | } 14 | ], 15 | "outbounds": [ 16 | { 17 | "protocol": "freedom" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /natter-docker/v2fly-nginx-cloudflare/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | natter-nginx-cf: 4 | command: -e /opt/cf-redir.py -p 18888 5 | volumes: 6 | - ./cf-redir.py:/opt/cf-redir.py 7 | environment: 8 | - TZ=Asia/Shanghai 9 | network_mode: host 10 | image: nattertool/natter 11 | restart: always 12 | depends_on: 13 | - nginx 14 | 15 | nginx: 16 | volumes: 17 | - ./html:/usr/share/nginx/html 18 | ports: 19 | - "18888:80" 20 | environment: 21 | - TZ=Asia/Shanghai 22 | image: nginx 23 | restart: always 24 | 25 | natter-v2fly: 26 | command: -e /opt/v2subsc.py -p 19999 27 | volumes: 28 | - ./v2subsc.py:/opt/v2subsc.py 29 | - ./html:/usr/share/nginx/html 30 | - ./config.json:/etc/v2ray/config.json 31 | environment: 32 | - TZ=Asia/Shanghai 33 | network_mode: host 34 | image: nattertool/natter 35 | restart: always 36 | depends_on: 37 | - v2fly 38 | 39 | v2fly: 40 | command: run -c /etc/v2ray/config.json 41 | volumes: 42 | - ./config.json:/etc/v2ray/config.json 43 | environment: 44 | - TZ=Asia/Shanghai 45 | network_mode: host 46 | image: v2fly/v2fly-core 47 | restart: always 48 | -------------------------------------------------------------------------------- /natter-docker/v2fly-nginx-cloudflare/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Welcome to nginx (Natter)! 5 | 12 | 13 | 14 |

Welcome to nginx (Natter)!

15 |

If you see this page, the nginx web server is successfully installed and 16 | working. Further configuration is required.

17 | 18 |

For online documentation and support please refer to 19 | nginx.org.
20 | Commercial support is available at 21 | nginx.com.

22 | 23 |

Thank you for using nginx.

24 | 25 | 26 | -------------------------------------------------------------------------------- /natter-docker/v2fly-nginx-cloudflare/v2subsc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import base64 3 | import json 4 | import sys 5 | import re 6 | 7 | # Natter notification script arguments 8 | protocol, private_ip, private_port, public_ip, public_port = sys.argv[1:6] 9 | 10 | v2ray_json_template = '{"v":"2","ps":"Home","add":"{{public_ip}}","port":"{{public_port}}","id":"{{client_id}}","type":"none","aid":"0","net":"tcp"}' 11 | 12 | clash_template = '''\ 13 | mode: rule 14 | proxies: 15 | - name: Home 16 | type: vmess 17 | server: {{public_ip}} 18 | port: {{public_port}} 19 | uuid: {{client_id}} 20 | alterId: 0 21 | cipher: auto 22 | mux: true 23 | proxy-groups: 24 | - name: GoHome 25 | type: select 26 | proxies: 27 | - Home 28 | rules: 29 | - IP-CIDR,10.0.0.0/8,GoHome,no-resolve 30 | - IP-CIDR,172.16.0.0/12,GoHome,no-resolve 31 | - IP-CIDR,192.168.0.0/16,GoHome,no-resolve 32 | - MATCH,DIRECT 33 | ''' 34 | 35 | 36 | def main(): 37 | config_path = "/etc/v2ray/config.json" 38 | client_id = get_client_id(config_path) 39 | 40 | v2ray_subsc_path = f"/usr/share/nginx/html/{client_id}.txt" 41 | write_v2ray_subscription(v2ray_subsc_path, v2ray_json_template, public_ip, public_port, client_id) 42 | print(f"V2ray subscription [{client_id}.txt] written successfully") 43 | 44 | clash_subsc_path = f"/usr/share/nginx/html/{client_id}.yml" 45 | write_clash_subscription(clash_subsc_path, clash_template, public_ip, public_port, client_id) 46 | print(f"Clash subscription [{client_id}.yml] written successfully") 47 | 48 | 49 | def get_client_id(config_path): 50 | with open(config_path, "r") as fin: 51 | conf = json.load(fin) 52 | vmess_conf = None 53 | for inb in conf["inbounds"]: 54 | if inb.get("protocol") == "vmess": 55 | if not vmess_conf: 56 | vmess_conf = inb 57 | else: 58 | raise ValueError("Multiple vmess inbounds are found") 59 | if vmess_conf and vmess_conf.get("settings") and vmess_conf["settings"].get("clients"): 60 | client_conf = vmess_conf["settings"]["clients"][0] 61 | client_id = client_conf["id"] 62 | else: 63 | raise ValueError("No vmess client ID is found") 64 | client_id = str(client_id) 65 | if not re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", client_id): 66 | raise ValueError(f"Invalid client ID: {client_id}") 67 | return client_id 68 | 69 | 70 | def write_clash_subscription(subsc_path, clash_template, public_ip, public_port, client_id): 71 | clash_subsc = clash_template.replace("{{public_ip}}", f"{public_ip}") \ 72 | .replace("{{public_port}}", f"{public_port}") \ 73 | .replace("{{client_id}}", f"{client_id}") 74 | with open(subsc_path, "w") as fout: 75 | fout.write(clash_subsc) 76 | 77 | 78 | def write_v2ray_subscription(subsc_path, v2ray_json_template, public_ip, public_port, client_id): 79 | v2ray_subsc_json = v2ray_json_template.replace("{{public_ip}}", f"{public_ip}") \ 80 | .replace("{{public_port}}", f"{public_port}") \ 81 | .replace("{{client_id}}", f"{client_id}") 82 | v2ray_subsc = base64.b64encode(b"vmess://" + base64.b64encode(v2ray_subsc_json.encode()) + b"\n").decode() 83 | with open(subsc_path, "w") as fout: 84 | fout.write(v2ray_subsc) 85 | 86 | 87 | if __name__ == "__main__": 88 | main() 89 | -------------------------------------------------------------------------------- /natter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | Natter - https://github.com/MikeWang000000/Natter 5 | Copyright (C) 2023 MikeWang000000 6 | 7 | This program is free software: you can redistribute it and/or modify 8 | it under the terms of the GNU General Public License as published by 9 | the Free Software Foundation, either version 3 of the License, or 10 | (at your option) any later version. 11 | 12 | This program is distributed in the hope that it will be useful, 13 | but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | GNU General Public License for more details. 16 | 17 | You should have received a copy of the GNU General Public License 18 | along with this program. If not, see . 19 | ''' 20 | 21 | import os 22 | import re 23 | import sys 24 | import json 25 | import time 26 | import errno 27 | import atexit 28 | import codecs 29 | import random 30 | import signal 31 | import socket 32 | import struct 33 | import argparse 34 | import threading 35 | import subprocess 36 | 37 | __version__ = "2.1.1" 38 | 39 | 40 | class Logger(object): 41 | DEBUG = 0 42 | INFO = 1 43 | WARN = 2 44 | ERROR = 3 45 | rep = {DEBUG: "D", INFO: "I", WARN: "W", ERROR: "E"} 46 | level = INFO 47 | if "256color" in os.environ.get("TERM", ""): 48 | GREY = "\033[90;20m" 49 | YELLOW_BOLD = "\033[33;1m" 50 | RED_BOLD = "\033[31;1m" 51 | RESET = "\033[0m" 52 | else: 53 | GREY = YELLOW_BOLD = RED_BOLD = RESET = "" 54 | 55 | @staticmethod 56 | def set_level(level): 57 | Logger.level = level 58 | 59 | @staticmethod 60 | def debug(text=""): 61 | if Logger.level <= Logger.DEBUG: 62 | sys.stderr.write((Logger.GREY + "%s [%s] %s\n" + Logger.RESET) % ( 63 | time.strftime("%Y-%m-%d %H:%M:%S"), Logger.rep[Logger.DEBUG], text 64 | )) 65 | 66 | @staticmethod 67 | def info(text=""): 68 | if Logger.level <= Logger.INFO: 69 | sys.stderr.write(("%s [%s] %s\n") % ( 70 | time.strftime("%Y-%m-%d %H:%M:%S"), Logger.rep[Logger.INFO], text 71 | )) 72 | 73 | @staticmethod 74 | def warning(text=""): 75 | if Logger.level <= Logger.WARN: 76 | sys.stderr.write((Logger.YELLOW_BOLD + "%s [%s] %s\n" + Logger.RESET) % ( 77 | time.strftime("%Y-%m-%d %H:%M:%S"), Logger.rep[Logger.WARN], text 78 | )) 79 | 80 | @staticmethod 81 | def error(text=""): 82 | if Logger.level <= Logger.ERROR: 83 | sys.stderr.write((Logger.RED_BOLD + "%s [%s] %s\n" + Logger.RESET) % ( 84 | time.strftime("%Y-%m-%d %H:%M:%S"), Logger.rep[Logger.ERROR], text 85 | )) 86 | 87 | 88 | class NatterExit(object): 89 | atexit.register(lambda : NatterExit._atexit[0]()) 90 | _atexit = [lambda : None] 91 | 92 | @staticmethod 93 | def set_atexit(func): 94 | NatterExit._atexit[0] = func 95 | 96 | 97 | class PortTest(object): 98 | def test_lan(self, addr, source_ip=None, interface=None, info=False): 99 | print_status = Logger.info if info else Logger.debug 100 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 101 | try: 102 | socket_set_opt( 103 | sock, 104 | bind_addr = (source_ip, 0) if source_ip else None, 105 | interface = interface, 106 | timeout = 1 107 | ) 108 | if sock.connect_ex(addr) == 0: 109 | print_status("LAN > %-21s [ OPEN ]" % addr_to_str(addr)) 110 | return 1 111 | else: 112 | print_status("LAN > %-21s [ CLOSED ]" % addr_to_str(addr)) 113 | return -1 114 | except (OSError, socket.error) as ex: 115 | print_status("LAN > %-21s [ UNKNOWN ]" % addr_to_str(addr)) 116 | Logger.debug("Cannot test port %s from LAN because: %s" % (addr_to_str(addr), ex)) 117 | return 0 118 | finally: 119 | sock.close() 120 | 121 | def test_wan(self, addr, source_ip=None, interface=None, info=False): 122 | # only port number in addr is used, WAN IP will be ignored 123 | print_status = Logger.info if info else Logger.debug 124 | ret01 = self._test_ifconfigco(addr[1], source_ip, interface) 125 | if ret01 == 1: 126 | print_status("WAN > %-21s [ OPEN ]" % addr_to_str(addr)) 127 | return 1 128 | ret02 = self._test_transmission(addr[1], source_ip, interface) 129 | if ret02 == 1: 130 | print_status("WAN > %-21s [ OPEN ]" % addr_to_str(addr)) 131 | return 1 132 | if ret01 == ret02 == -1: 133 | print_status("WAN > %-21s [ CLOSED ]" % addr_to_str(addr)) 134 | return -1 135 | print_status("WAN > %-21s [ UNKNOWN ]" % addr_to_str(addr)) 136 | return 0 137 | 138 | def _test_ifconfigco(self, port, source_ip=None, interface=None): 139 | # repo: https://github.com/mpolden/echoip 140 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 141 | try: 142 | socket_set_opt( 143 | sock, 144 | bind_addr = (source_ip, 0) if source_ip else None, 145 | interface = interface, 146 | timeout = 8 147 | ) 148 | sock.connect(("ifconfig.co", 80)) 149 | sock.sendall(( 150 | "GET /port/%d HTTP/1.0\r\n" 151 | "Host: ifconfig.co\r\n" 152 | "User-Agent: curl/8.0.0 (Natter)\r\n" 153 | "Accept: */*\r\n" 154 | "Connection: close\r\n" 155 | "\r\n" % port 156 | ).encode()) 157 | response = b"" 158 | while True: 159 | buff = sock.recv(4096) 160 | if not buff: 161 | break 162 | response += buff 163 | Logger.debug("port-test: ifconfig.co: %s" % response) 164 | _, content = response.split(b"\r\n\r\n", 1) 165 | dat = json.loads(content.decode()) 166 | return 1 if dat["reachable"] else -1 167 | except (OSError, LookupError, ValueError, TypeError, socket.error) as ex: 168 | Logger.debug("Cannot test port %d from ifconfig.co because: %s" % (port, ex)) 169 | return 0 170 | finally: 171 | sock.close() 172 | 173 | def _test_transmission(self, port, source_ip=None, interface=None): 174 | # repo: https://github.com/transmission/portcheck 175 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 176 | try: 177 | socket_set_opt( 178 | sock, 179 | bind_addr = (source_ip, 0) if source_ip else None, 180 | interface = interface, 181 | timeout = 8 182 | ) 183 | sock.connect(("portcheck.transmissionbt.com", 80)) 184 | sock.sendall(( 185 | "GET /%d HTTP/1.0\r\n" 186 | "Host: portcheck.transmissionbt.com\r\n" 187 | "User-Agent: curl/8.0.0 (Natter)\r\n" 188 | "Accept: */*\r\n" 189 | "Connection: close\r\n" 190 | "\r\n" % port 191 | ).encode()) 192 | response = b"" 193 | while True: 194 | buff = sock.recv(4096) 195 | if not buff: 196 | break 197 | response += buff 198 | Logger.debug("port-test: portcheck.transmissionbt.com: %s" % response) 199 | _, content = response.split(b"\r\n\r\n", 1) 200 | if content.strip() == b"1": 201 | return 1 202 | elif content.strip() == b"0": 203 | return -1 204 | raise ValueError("Unexpected response: %s" % response) 205 | except (OSError, LookupError, ValueError, TypeError, socket.error) as ex: 206 | Logger.debug( 207 | "Cannot test port %d from portcheck.transmissionbt.com " 208 | "because: %s" % (port, ex) 209 | ) 210 | return 0 211 | finally: 212 | sock.close() 213 | 214 | 215 | class StunClient(object): 216 | class ServerUnavailable(Exception): 217 | pass 218 | 219 | def __init__(self, stun_server_list, source_host="0.0.0.0", source_port=0, 220 | interface=None, udp=False): 221 | if not stun_server_list: 222 | raise ValueError("STUN server list is empty") 223 | self.stun_server_list = stun_server_list 224 | self.source_host = source_host 225 | self.source_port = source_port 226 | self.interface = interface 227 | self.udp = udp 228 | 229 | def get_mapping(self): 230 | first = self.stun_server_list[0] 231 | while True: 232 | try: 233 | return self._get_mapping() 234 | except StunClient.ServerUnavailable as ex: 235 | Logger.warning("stun: STUN server %s is unavailable: %s" % ( 236 | addr_to_uri(self.stun_server_list[0], udp = self.udp), ex 237 | )) 238 | self.stun_server_list.append(self.stun_server_list.pop(0)) 239 | if self.stun_server_list[0] == first: 240 | Logger.error("stun: No STUN server is available right now") 241 | # force sleep for 10 seconds, then try the next loop 242 | time.sleep(10) 243 | 244 | def _get_mapping(self): 245 | # ref: https://www.rfc-editor.org/rfc/rfc5389 246 | socket_type = socket.SOCK_DGRAM if self.udp else socket.SOCK_STREAM 247 | stun_host, stun_port = self.stun_server_list[0] 248 | sock = socket.socket(socket.AF_INET, socket_type) 249 | socket_set_opt( 250 | sock, 251 | reuse = True, 252 | bind_addr = (self.source_host, self.source_port), 253 | interface = self.interface, 254 | timeout = 3 255 | ) 256 | try: 257 | sock.connect((stun_host, stun_port)) 258 | inner_addr = sock.getsockname() 259 | self.source_host, self.source_port = inner_addr 260 | sock.send(struct.pack( 261 | "!LLLLL", 0x00010000, 0x2112a442, 0x4e415452, 262 | random.getrandbits(32), random.getrandbits(32) 263 | )) 264 | buff = sock.recv(1500) 265 | ip = port = 0 266 | payload = buff[20:] 267 | while payload: 268 | attr_type, attr_len = struct.unpack("!HH", payload[:4]) 269 | if attr_type in [1, 32]: 270 | _, _, port, ip = struct.unpack("!BBHL", payload[4:4+attr_len]) 271 | if attr_type == 32: 272 | port ^= 0x2112 273 | ip ^= 0x2112a442 274 | break 275 | payload = payload[4 + attr_len:] 276 | else: 277 | raise ValueError("Invalid STUN response") 278 | outer_addr = socket.inet_ntop(socket.AF_INET, struct.pack("!L", ip)), port 279 | Logger.debug("stun: Got address %s from %s, source %s" % ( 280 | addr_to_uri(outer_addr, udp=self.udp), 281 | addr_to_uri((stun_host, stun_port), udp=self.udp), 282 | addr_to_uri(inner_addr, udp=self.udp) 283 | )) 284 | return inner_addr, outer_addr 285 | except (OSError, ValueError, struct.error, socket.error) as ex: 286 | raise StunClient.ServerUnavailable(ex) 287 | finally: 288 | sock.close() 289 | 290 | 291 | class KeepAlive(object): 292 | def __init__(self, host, port, source_host, source_port, interface=None, udp=False): 293 | self.sock = None 294 | self.host = host 295 | self.port = port 296 | self.source_host = source_host 297 | self.source_port = source_port 298 | self.interface = interface 299 | self.udp = udp 300 | self.reconn = False 301 | 302 | def __del__(self): 303 | if self.sock: 304 | self.sock.close() 305 | 306 | def _connect(self): 307 | sock_type = socket.SOCK_DGRAM if self.udp else socket.SOCK_STREAM 308 | sock = socket.socket(socket.AF_INET, sock_type) 309 | socket_set_opt( 310 | sock, 311 | reuse = True, 312 | bind_addr = (self.source_host, self.source_port), 313 | interface = self.interface, 314 | timeout = 3 315 | ) 316 | sock.connect((self.host, self.port)) 317 | if not self.udp: 318 | Logger.debug("keep-alive: Connected to host %s" % ( 319 | addr_to_uri((self.host, self.port), udp=self.udp) 320 | )) 321 | if self.reconn: 322 | Logger.info("keep-alive: connection restored") 323 | self.reconn = False 324 | self.sock = sock 325 | 326 | def keep_alive(self): 327 | if self.sock is None: 328 | self._connect() 329 | if self.udp: 330 | self._keep_alive_udp() 331 | else: 332 | self._keep_alive_tcp() 333 | Logger.debug("keep-alive: OK") 334 | 335 | def reset(self): 336 | if self.sock is not None: 337 | self.sock.close() 338 | self.sock = None 339 | self.reconn = True 340 | 341 | def _keep_alive_tcp(self): 342 | # send a HTTP request 343 | self.sock.sendall(( 344 | "HEAD /natter-keep-alive HTTP/1.1\r\n" 345 | "Host: %s\r\n" 346 | "User-Agent: curl/8.0.0 (Natter)\r\n" 347 | "Accept: */*\r\n" 348 | "Connection: keep-alive\r\n" 349 | "\r\n" % self.host 350 | ).encode()) 351 | buff = b"" 352 | try: 353 | while True: 354 | buff = self.sock.recv(4096) 355 | if not buff: 356 | raise OSError("Keep-alive server closed connection") 357 | except socket.timeout as ex: 358 | if not buff: 359 | raise ex 360 | return 361 | 362 | def _keep_alive_udp(self): 363 | # send a DNS request 364 | self.sock.send( 365 | struct.pack( 366 | "!HHHHHH", random.getrandbits(16), 0x0100, 0x0001, 0x0000, 0x0000, 0x0000 367 | ) + b"\x09keepalive\x06natter\x00" + struct.pack("!HH", 0x0001, 0x0001) 368 | ) 369 | buff = b"" 370 | try: 371 | while True: 372 | buff = self.sock.recv(1500) 373 | if not buff: 374 | raise OSError("Keep-alive server closed connection") 375 | except socket.timeout as ex: 376 | if not buff: 377 | raise ex 378 | # fix: Keep-alive cause STUN socket timeout on Windows 379 | if sys.platform == "win32": 380 | self.reset() 381 | return 382 | 383 | 384 | class ForwardNone(object): 385 | # Do nothing. Don't forward. 386 | def start_forward(self, ip, port, toip, toport, udp=False): 387 | pass 388 | 389 | def stop_forward(self): 390 | pass 391 | 392 | 393 | class ForwardTestServer(object): 394 | def __init__(self): 395 | self.active = False 396 | self.sock = None 397 | self.sock_type = None 398 | self.buff_size = 8192 399 | self.timeout = 3 400 | 401 | # Start a socket server for testing purpose 402 | # target address is ignored 403 | def start_forward(self, ip, port, toip, toport, udp=False): 404 | self.sock_type = socket.SOCK_DGRAM if udp else socket.SOCK_STREAM 405 | self.sock = socket.socket(socket.AF_INET, self.sock_type) 406 | socket_set_opt( 407 | self.sock, 408 | reuse = True, 409 | bind_addr = ("", port) 410 | ) 411 | Logger.debug("fwd-test: Starting test server at %s" % addr_to_uri((ip, port), udp=udp)) 412 | if udp: 413 | th = start_daemon_thread(self._test_server_run_udp) 414 | else: 415 | th = start_daemon_thread(self._test_server_run_http) 416 | time.sleep(1) 417 | if not th.is_alive(): 418 | raise OSError("Test server thread exited too quickly") 419 | self.active = True 420 | 421 | def _test_server_run_http(self): 422 | self.sock.listen(5) 423 | while self.sock.fileno() != -1: 424 | try: 425 | conn, addr = self.sock.accept() 426 | Logger.debug("fwd-test: got client %s" % (addr,)) 427 | except (OSError, socket.error): 428 | return 429 | try: 430 | conn.settimeout(self.timeout) 431 | conn.recv(self.buff_size) 432 | content = "

It works!


Natter" 433 | content_len = len(content.encode()) 434 | data = ( 435 | "HTTP/1.1 200 OK\r\n" 436 | "Content-Type: text/html\r\n" 437 | "Content-Length: %d\r\n" 438 | "Connection: close\r\n" 439 | "Server: Natter\r\n" 440 | "\r\n" 441 | "%s\r\n" % (content_len, content) 442 | ).encode() 443 | conn.sendall(data) 444 | conn.shutdown(socket.SHUT_RDWR) 445 | except (OSError, socket.error): 446 | pass 447 | finally: 448 | conn.close() 449 | 450 | def _test_server_run_udp(self): 451 | while self.sock.fileno() != -1: 452 | try: 453 | msg, addr = self.sock.recvfrom(self.buff_size) 454 | Logger.debug("fwd-test: got client %s" % (addr,)) 455 | self.sock.sendto(b"It works! - Natter\r\n", addr) 456 | except (OSError, socket.error): 457 | return 458 | 459 | def stop_forward(self): 460 | Logger.debug("fwd-test: Stopping test server") 461 | self.sock.close() 462 | self.active = False 463 | 464 | 465 | class ForwardIptables(object): 466 | def __init__(self, snat=False, sudo=False): 467 | self.rules = [] 468 | self.active = False 469 | self.min_ver = (1, 4, 1) 470 | self.curr_ver = (0, 0, 0) 471 | self.snat = snat 472 | self.sudo = sudo 473 | if sudo: 474 | self.iptables_cmd = ["sudo", "-n", "iptables"] 475 | else: 476 | self.iptables_cmd = ["iptables"] 477 | if not self._iptables_check(): 478 | raise OSError("iptables >= %s not available" % str(self.min_ver)) 479 | # wait for iptables lock, since iptables 1.4.20 480 | if self.curr_ver >= (1, 4, 20): 481 | self.iptables_cmd += ["-w"] 482 | self._iptables_init() 483 | self._iptables_clean() 484 | 485 | def __del__(self): 486 | if self.active: 487 | self.stop_forward() 488 | 489 | def _iptables_check(self): 490 | if os.name != "posix": 491 | return False 492 | if not self.sudo and os.getuid() != 0: 493 | Logger.warning("fwd-iptables: You are not root") 494 | try: 495 | output = subprocess.check_output( 496 | self.iptables_cmd + ["--version"] 497 | ).decode() 498 | except (OSError, subprocess.CalledProcessError) as e: 499 | return False 500 | m = re.search(r"iptables v([0-9]+)\.([0-9]+)\.([0-9]+)", output) 501 | if m: 502 | self.curr_ver = tuple(int(v) for v in m.groups()) 503 | Logger.debug("fwd-iptables: Found iptables %s" % str(self.curr_ver)) 504 | if self.curr_ver < self.min_ver: 505 | return False 506 | # check nat table 507 | try: 508 | subprocess.check_output( 509 | self.iptables_cmd + ["-t", "nat", "--list-rules"] 510 | ) 511 | except (OSError, subprocess.CalledProcessError) as e: 512 | return False 513 | return True 514 | 515 | def _iptables_init(self): 516 | try: 517 | subprocess.check_output( 518 | self.iptables_cmd + ["-t", "nat", "--list-rules", "NATTER"], 519 | stderr=subprocess.STDOUT 520 | ) 521 | return 522 | except subprocess.CalledProcessError: 523 | pass 524 | Logger.debug("fwd-iptables: Creating Natter chain") 525 | subprocess.check_output( 526 | self.iptables_cmd + ["-t", "nat", "-N", "NATTER"] 527 | ) 528 | subprocess.check_output( 529 | self.iptables_cmd + ["-t", "nat", "-I", "PREROUTING", "-j", "NATTER"] 530 | ) 531 | subprocess.check_output( 532 | self.iptables_cmd + ["-t", "nat", "-I", "OUTPUT", "-j", "NATTER"] 533 | ) 534 | subprocess.check_output( 535 | self.iptables_cmd + ["-t", "nat", "-N", "NATTER_SNAT"] 536 | ) 537 | subprocess.check_output( 538 | self.iptables_cmd + ["-t", "nat", "-I", "POSTROUTING", "-j", "NATTER_SNAT"] 539 | ) 540 | subprocess.check_output( 541 | self.iptables_cmd + ["-t", "nat", "-I", "INPUT", "-j", "NATTER_SNAT"] 542 | ) 543 | 544 | def _iptables_clean(self): 545 | Logger.debug("fwd-iptables: Cleaning up Natter rules") 546 | while self.rules: 547 | rule = self.rules.pop() 548 | rule_rm = ["-D" if arg in ("-I", "-A") else arg for arg in rule] 549 | try: 550 | subprocess.check_output( 551 | self.iptables_cmd + rule_rm, 552 | stderr=subprocess.STDOUT 553 | ) 554 | return 555 | except subprocess.CalledProcessError as ex: 556 | Logger.error("fwd-iptables: Failed to execute %s: %s" % (ex.cmd, ex.output)) 557 | continue 558 | 559 | def start_forward(self, ip, port, toip, toport, udp=False): 560 | if ip != toip: 561 | self._check_sys_forward_config() 562 | if (ip, port) == (toip, toport): 563 | raise ValueError("Cannot forward to the same address %s" % addr_to_str((ip, port))) 564 | proto = "udp" if udp else "tcp" 565 | Logger.debug("fwd-iptables: Adding rule %s forward to %s" % ( 566 | addr_to_uri((ip, port), udp=udp), addr_to_uri((toip, toport), udp=udp) 567 | )) 568 | rule = [ 569 | "-t", "nat", 570 | "-I", "NATTER", 571 | "-p", proto, 572 | "--dst", ip, 573 | "--dport", "%d" % port, 574 | "-j", "DNAT", 575 | "--to-destination", "%s:%d" % (toip, toport) 576 | ] 577 | subprocess.check_output(self.iptables_cmd + rule) 578 | self.rules.append(rule) 579 | if self.snat: 580 | rule = [ 581 | "-t", "nat", 582 | "-I", "NATTER_SNAT", 583 | "-p", proto, 584 | "--dst", toip, 585 | "--dport", "%d" % toport, 586 | "-j", "SNAT", 587 | "--to-source", ip 588 | ] 589 | subprocess.check_output(self.iptables_cmd + rule) 590 | self.rules.append(rule) 591 | self.active = True 592 | 593 | def stop_forward(self): 594 | self._iptables_clean() 595 | self.active = False 596 | 597 | def _check_sys_forward_config(self): 598 | fpath = "/proc/sys/net/ipv4/ip_forward" 599 | if os.path.exists(fpath): 600 | fin = open(fpath, "r") 601 | buff = fin.read() 602 | fin.close() 603 | if buff.strip() != "1": 604 | raise OSError("IP forwarding is not allowed. Please do `sysctl net.ipv4.ip_forward=1`") 605 | else: 606 | Logger.warning("fwd-iptables: '%s' not found" % str(fpath)) 607 | 608 | 609 | class ForwardSudoIptables(ForwardIptables): 610 | def __init__(self): 611 | super().__init__(sudo=True) 612 | 613 | 614 | class ForwardIptablesSnat(ForwardIptables): 615 | def __init__(self): 616 | super().__init__(snat=True) 617 | 618 | 619 | class ForwardSudoIptablesSnat(ForwardIptables): 620 | def __init__(self): 621 | super().__init__(snat=True, sudo=True) 622 | 623 | 624 | class ForwardNftables(object): 625 | def __init__(self, snat=False, sudo=False): 626 | self.handle = -1 627 | self.handle_snat = -1 628 | self.active = False 629 | self.min_ver = (0, 9, 0) 630 | self.snat = snat 631 | self.sudo = sudo 632 | if sudo: 633 | self.nftables_cmd = ["sudo", "-n", "nft"] 634 | else: 635 | self.nftables_cmd = ["nft"] 636 | if not self._nftables_check(): 637 | raise OSError("nftables >= %s not available" % str(self.min_ver)) 638 | self._nftables_init() 639 | self._nftables_clean() 640 | 641 | def __del__(self): 642 | if self.active: 643 | self.stop_forward() 644 | 645 | def _nftables_check(self): 646 | if os.name != "posix": 647 | return False 648 | if not self.sudo and os.getuid() != 0: 649 | Logger.warning("fwd-nftables: You are not root") 650 | try: 651 | output = subprocess.check_output( 652 | self.nftables_cmd + ["--version"] 653 | ).decode() 654 | except (OSError, subprocess.CalledProcessError) as e: 655 | return False 656 | m = re.search(r"nftables v([0-9]+)\.([0-9]+)\.([0-9]+)", output) 657 | if m: 658 | curr_ver = tuple(int(v) for v in m.groups()) 659 | Logger.debug("fwd-nftables: Found nftables %s" % str(curr_ver)) 660 | if curr_ver < self.min_ver: 661 | return False 662 | # check nat table 663 | try: 664 | subprocess.check_output( 665 | self.nftables_cmd + ["list table ip nat"] 666 | ) 667 | except (OSError, subprocess.CalledProcessError) as e: 668 | return False 669 | return True 670 | 671 | def _nftables_init(self): 672 | try: 673 | subprocess.check_output( 674 | self.nftables_cmd + ["list chain ip nat NATTER"], 675 | stderr=subprocess.STDOUT 676 | ) 677 | return 678 | except subprocess.CalledProcessError: 679 | pass 680 | Logger.debug("fwd-nftables: Creating Natter chain") 681 | subprocess.check_output( 682 | self.nftables_cmd + ["add chain ip nat NATTER"] 683 | ) 684 | subprocess.check_output( 685 | self.nftables_cmd + ["insert rule ip nat PREROUTING counter jump NATTER"] 686 | ) 687 | subprocess.check_output( 688 | self.nftables_cmd + ["insert rule ip nat OUTPUT counter jump NATTER"] 689 | ) 690 | subprocess.check_output( 691 | self.nftables_cmd + ["add chain ip nat NATTER_SNAT"] 692 | ) 693 | subprocess.check_output( 694 | self.nftables_cmd + ["insert rule ip nat PREROUTING counter jump NATTER_SNAT"] 695 | ) 696 | subprocess.check_output( 697 | self.nftables_cmd + ["insert rule ip nat OUTPUT counter jump NATTER_SNAT"] 698 | ) 699 | 700 | def _nftables_clean(self): 701 | Logger.debug("fwd-nftables: Cleaning up Natter rules") 702 | if self.handle > 0: 703 | subprocess.check_output( 704 | self.nftables_cmd + ["delete rule ip nat NATTER handle %d" % self.handle] 705 | ) 706 | if self.handle_snat > 0: 707 | subprocess.check_output( 708 | self.nftables_cmd + ["delete rule ip nat NATTER_SNAT handle %d" % self.handle_snat] 709 | ) 710 | 711 | def start_forward(self, ip, port, toip, toport, udp=False): 712 | if ip != toip: 713 | self._check_sys_forward_config() 714 | if (ip, port) == (toip, toport): 715 | raise ValueError("Cannot forward to the same address %s" % addr_to_str((ip, port))) 716 | proto = "udp" if udp else "tcp" 717 | Logger.debug("fwd-nftables: Adding rule %s forward to %s" % ( 718 | addr_to_uri((ip, port), udp=udp), addr_to_uri((toip, toport), udp=udp) 719 | )) 720 | output = subprocess.check_output(self.nftables_cmd + [ 721 | "--echo", "--handle", 722 | "insert rule ip nat NATTER ip daddr %s %s dport %d counter dnat to %s:%d" % ( 723 | ip, proto, port, toip, toport 724 | ) 725 | ]).decode() 726 | m = re.search(r"# handle ([0-9]+)$", output, re.MULTILINE) 727 | if not m: 728 | raise ValueError("Unknown nftables handle") 729 | self.handle = int(m.group(1)) 730 | if self.snat: 731 | output = subprocess.check_output(self.nftables_cmd + [ 732 | "--echo", "--handle", 733 | "insert rule ip nat NATTER_SNAT ip daddr %s %s dport %d counter snat to %s" % ( 734 | toip, proto, toport, ip 735 | ) 736 | ]).decode() 737 | m = re.search(r"# handle ([0-9]+)$", output, re.MULTILINE) 738 | if not m: 739 | raise ValueError("Unknown nftables handle") 740 | self.handle_snat = int(m.group(1)) 741 | self.active = True 742 | 743 | def stop_forward(self): 744 | self._nftables_clean() 745 | self.active = False 746 | 747 | def _check_sys_forward_config(self): 748 | fpath = "/proc/sys/net/ipv4/ip_forward" 749 | if os.path.exists(fpath): 750 | fin = open(fpath, "r") 751 | buff = fin.read() 752 | fin.close() 753 | if buff.strip() != "1": 754 | raise OSError("IP forwarding is disabled by system. Please do `sysctl net.ipv4.ip_forward=1`") 755 | else: 756 | Logger.warning("fwd-nftables: '%s' not found" % str(fpath)) 757 | 758 | 759 | class ForwardSudoNftables(ForwardNftables): 760 | def __init__(self): 761 | super().__init__(sudo=True) 762 | 763 | 764 | class ForwardNftablesSnat(ForwardNftables): 765 | def __init__(self): 766 | super().__init__(snat=True) 767 | 768 | 769 | class ForwardSudoNftablesSnat(ForwardNftables): 770 | def __init__(self): 771 | super().__init__(snat=True, sudo=True) 772 | 773 | 774 | class ForwardGost(object): 775 | def __init__(self): 776 | self.active = False 777 | self.min_ver = (2, 3) 778 | self.proc = None 779 | self.udp_timeout = 60 780 | if not self._gost_check(): 781 | raise OSError("gost >= %s not available" % str(self.min_ver)) 782 | 783 | def __del__(self): 784 | if self.active: 785 | self.stop_forward() 786 | 787 | def _gost_check(self): 788 | try: 789 | output = subprocess.check_output( 790 | ["gost", "-V"], stderr=subprocess.STDOUT 791 | ).decode() 792 | except (OSError, subprocess.CalledProcessError) as e: 793 | return False 794 | m = re.search(r"gost v?([0-9]+)\.([0-9]+)", output) 795 | if m: 796 | current_ver = tuple(int(v) for v in m.groups()) 797 | Logger.debug("fwd-gost: Found gost %s" % str(current_ver)) 798 | return current_ver >= self.min_ver 799 | return False 800 | 801 | def start_forward(self, ip, port, toip, toport, udp=False): 802 | if (ip, port) == (toip, toport): 803 | raise ValueError("Cannot forward to the same address %s" % addr_to_str((ip, port))) 804 | proto = "udp" if udp else "tcp" 805 | Logger.debug("fwd-gost: Starting gost %s forward to %s" % ( 806 | addr_to_uri((ip, port), udp=udp), addr_to_uri((toip, toport), udp=udp) 807 | )) 808 | gost_arg = "-L=%s://:%d/%s:%d" % (proto, port, toip, toport) 809 | if udp: 810 | gost_arg += "?ttl=%ds" % self.udp_timeout 811 | self.proc = subprocess.Popen(["gost", gost_arg]) 812 | time.sleep(1) 813 | if self.proc.poll() is not None: 814 | raise OSError("gost exited too quickly") 815 | self.active = True 816 | 817 | def stop_forward(self): 818 | Logger.debug("fwd-gost: Stopping gost") 819 | if self.proc and self.proc.returncode is not None: 820 | return 821 | self.proc.terminate() 822 | self.active = False 823 | 824 | 825 | class ForwardSocat(object): 826 | def __init__(self): 827 | self.active = False 828 | self.min_ver = (1, 7, 2) 829 | self.proc = None 830 | self.udp_timeout = 60 831 | self.max_children = 128 832 | if not self._socat_check(): 833 | raise OSError("socat >= %s not available" % str(self.min_ver)) 834 | 835 | def __del__(self): 836 | if self.active: 837 | self.stop_forward() 838 | 839 | def _socat_check(self): 840 | try: 841 | output = subprocess.check_output( 842 | ["socat", "-V"], stderr=subprocess.STDOUT 843 | ).decode() 844 | except (OSError, subprocess.CalledProcessError) as e: 845 | return False 846 | m = re.search(r"socat version ([0-9]+)\.([0-9]+)\.([0-9]+)", output) 847 | if m: 848 | current_ver = tuple(int(v) for v in m.groups()) 849 | Logger.debug("fwd-socat: Found socat %s" % str(current_ver)) 850 | return current_ver >= self.min_ver 851 | return False 852 | 853 | def start_forward(self, ip, port, toip, toport, udp=False): 854 | if (ip, port) == (toip, toport): 855 | raise ValueError("Cannot forward to the same address %s" % addr_to_str((ip, port))) 856 | proto = "UDP" if udp else "TCP" 857 | Logger.debug("fwd-socat: Starting socat %s forward to %s" % ( 858 | addr_to_uri((ip, port), udp=udp), addr_to_uri((toip, toport), udp=udp) 859 | )) 860 | if udp: 861 | socat_cmd = ["socat", "-T%d" % self.udp_timeout] 862 | else: 863 | socat_cmd = ["socat"] 864 | self.proc = subprocess.Popen(socat_cmd + [ 865 | "%s4-LISTEN:%d,reuseaddr,fork,max-children=%d" % (proto, port, self.max_children), 866 | "%s4:%s:%d" % (proto, toip, toport) 867 | ]) 868 | time.sleep(1) 869 | if self.proc.poll() is not None: 870 | raise OSError("socat exited too quickly") 871 | self.active = True 872 | 873 | def stop_forward(self): 874 | Logger.debug("fwd-socat: Stopping socat") 875 | if self.proc and self.proc.returncode is not None: 876 | return 877 | self.proc.terminate() 878 | self.active = False 879 | 880 | 881 | class ForwardSocket(object): 882 | def __init__(self): 883 | self.active = False 884 | self.sock = None 885 | self.sock_type = None 886 | self.outbound_addr = None 887 | self.buff_size = 8192 888 | self.udp_timeout = 60 889 | self.max_threads = 128 890 | 891 | def __del__(self): 892 | if self.active: 893 | self.stop_forward() 894 | 895 | def start_forward(self, ip, port, toip, toport, udp=False): 896 | if (ip, port) == (toip, toport): 897 | raise ValueError("Cannot forward to the same address %s" % addr_to_str((ip, port))) 898 | self.sock_type = socket.SOCK_DGRAM if udp else socket.SOCK_STREAM 899 | self.sock = socket.socket(socket.AF_INET, self.sock_type) 900 | socket_set_opt( 901 | self.sock, 902 | reuse = True, 903 | bind_addr = ("", port) 904 | ) 905 | self.outbound_addr = toip, toport 906 | Logger.debug("fwd-socket: Starting socket %s forward to %s" % ( 907 | addr_to_uri((ip, port), udp=udp), addr_to_uri((toip, toport), udp=udp) 908 | )) 909 | if udp: 910 | th = start_daemon_thread(self._socket_udp_recvfrom) 911 | else: 912 | th = start_daemon_thread(self._socket_tcp_listen) 913 | time.sleep(1) 914 | if not th.is_alive(): 915 | raise OSError("Socket thread exited too quickly") 916 | self.active = True 917 | 918 | def _socket_tcp_listen(self): 919 | self.sock.listen(5) 920 | while True: 921 | try: 922 | sock_inbound, _ = self.sock.accept() 923 | except (OSError, socket.error) as ex: 924 | if not closed_socket_ex(ex): 925 | Logger.error("fwd-socket: socket listening thread is exiting: %s" % ex) 926 | return 927 | sock_outbound = socket.socket(socket.AF_INET, self.sock_type) 928 | try: 929 | sock_outbound.settimeout(3) 930 | sock_outbound.connect(self.outbound_addr) 931 | sock_outbound.settimeout(None) 932 | if threading.active_count() >= self.max_threads: 933 | raise OSError("Too many threads") 934 | start_daemon_thread(self._socket_tcp_forward, args=(sock_inbound, sock_outbound)) 935 | start_daemon_thread(self._socket_tcp_forward, args=(sock_outbound, sock_inbound)) 936 | except (OSError, socket.error) as ex: 937 | Logger.error("fwd-socket: cannot forward port: %s" % ex) 938 | sock_inbound.close() 939 | sock_outbound.close() 940 | continue 941 | 942 | def _socket_tcp_forward(self, sock_to_recv, sock_to_send): 943 | try: 944 | while sock_to_recv.fileno() != -1: 945 | buff = sock_to_recv.recv(self.buff_size) 946 | if buff and sock_to_send.fileno() != -1: 947 | sock_to_send.sendall(buff) 948 | else: 949 | sock_to_recv.close() 950 | sock_to_send.close() 951 | return 952 | except (OSError, socket.error) as ex: 953 | if not closed_socket_ex(ex): 954 | Logger.error("fwd-socket: socket forwarding thread is exiting: %s" % ex) 955 | sock_to_recv.close() 956 | sock_to_send.close() 957 | return 958 | 959 | def _socket_udp_recvfrom(self): 960 | outbound_socks = {} 961 | while True: 962 | try: 963 | buff, addr = self.sock.recvfrom(self.buff_size) 964 | s = outbound_socks.get(addr) 965 | except (OSError, socket.error) as ex: 966 | if not closed_socket_ex(ex): 967 | Logger.error("fwd-socket: socket recvfrom thread is exiting: %s" % ex) 968 | return 969 | try: 970 | if not s: 971 | s = outbound_socks[addr] = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 972 | s.settimeout(self.udp_timeout) 973 | s.connect(self.outbound_addr) 974 | if threading.active_count() >= self.max_threads: 975 | raise OSError("Too many threads") 976 | start_daemon_thread(self._socket_udp_send, args=(self.sock, s, addr)) 977 | if buff: 978 | s.send(buff) 979 | else: 980 | s.close() 981 | del outbound_socks[addr] 982 | except (OSError, socket.error): 983 | if addr in outbound_socks: 984 | outbound_socks[addr].close() 985 | del outbound_socks[addr] 986 | continue 987 | 988 | def _socket_udp_send(self, server_sock, outbound_sock, client_addr): 989 | try: 990 | while outbound_sock.fileno() != -1: 991 | buff = outbound_sock.recv(self.buff_size) 992 | if buff: 993 | server_sock.sendto(buff, client_addr) 994 | else: 995 | outbound_sock.close() 996 | except (OSError, socket.error) as ex: 997 | if not closed_socket_ex(ex): 998 | Logger.error("fwd-socket: socket send thread is exiting: %s" % ex) 999 | outbound_sock.close() 1000 | return 1001 | 1002 | def stop_forward(self): 1003 | Logger.debug("fwd-socket: Stopping socket") 1004 | self.sock.close() 1005 | self.active = False 1006 | 1007 | 1008 | class UPnPService(object): 1009 | def __init__(self, device, bind_ip = None, interface = None): 1010 | self.device = device 1011 | self.service_type = None 1012 | self.service_id = None 1013 | self.scpd_url = None 1014 | self.control_url = None 1015 | self.eventsub_url = None 1016 | self._sock_timeout = 3 1017 | self._bind_ip = bind_ip 1018 | self._bind_interface = interface 1019 | 1020 | def __repr__(self): 1021 | return "" % ( 1022 | repr(self.service_type), repr(self.service_id) 1023 | ) 1024 | 1025 | def is_valid(self): 1026 | if self.service_type and self.service_id and self.control_url: 1027 | return True 1028 | return False 1029 | 1030 | def is_forward(self): 1031 | if self.service_type in ( 1032 | "urn:schemas-upnp-org:service:WANIPConnection:1", 1033 | "urn:schemas-upnp-org:service:WANIPConnection:2", 1034 | "urn:schemas-upnp-org:service:WANPPPConnection:1" 1035 | ) and self.service_id and self.control_url: 1036 | return True 1037 | return False 1038 | 1039 | def forward_port(self, host, port, dest_host, dest_port, udp=False, duration=0): 1040 | if not self.is_forward(): 1041 | raise NotImplementedError("Unsupported service type: %s" % self.service_type) 1042 | 1043 | proto = "UDP" if udp else "TCP" 1044 | ctl_hostname, ctl_port, ctl_path = split_url(self.control_url) 1045 | descpt = "Natter" 1046 | content = ( 1047 | "\r\n" 1048 | "\r\n" 1050 | " \r\n" 1051 | " \r\n" 1052 | " %s\r\n" 1053 | " %s\r\n" 1054 | " %s\r\n" 1055 | " %s\r\n" 1056 | " %s\r\n" 1057 | " 1\r\n" 1058 | " %s\r\n" 1059 | " %d\r\n" 1060 | " \r\n" 1061 | " \r\n" 1062 | "\r\n" % ( 1063 | self.service_type, host, port, proto, dest_port, dest_host, descpt, duration 1064 | ) 1065 | ) 1066 | content_len = len(content.encode()) 1067 | data = ( 1068 | "POST %s HTTP/1.1\r\n" 1069 | "Host: %s:%d\r\n" 1070 | "User-Agent: curl/8.0.0 (Natter)\r\n" 1071 | "Accept: */*\r\n" 1072 | "SOAPAction: \"%s#AddPortMapping\"\r\n" 1073 | "Content-Type: text/xml\r\n" 1074 | "Content-Length: %d\r\n" 1075 | "Connection: close\r\n" 1076 | "\r\n" 1077 | "%s\r\n" % (ctl_path, ctl_hostname, ctl_port, self.service_type, content_len, content) 1078 | ).encode() 1079 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 1080 | socket_set_opt( 1081 | sock, 1082 | bind_addr = (self._bind_ip, 0) if self._bind_ip else None, 1083 | interface = self._bind_interface, 1084 | timeout = self._sock_timeout 1085 | ) 1086 | sock.connect((ctl_hostname, ctl_port)) 1087 | sock.sendall(data) 1088 | response = b"" 1089 | while True: 1090 | buff = sock.recv(4096) 1091 | if not buff: 1092 | break 1093 | response += buff 1094 | sock.close() 1095 | r = response.decode("utf-8", "ignore") 1096 | errno = errmsg = "" 1097 | m = re.search(r"([^<]*?)", r) 1098 | if m: 1099 | errno = m.group(1).strip() 1100 | m = re.search(r"([^<]*?)", r) 1101 | if m: 1102 | errmsg = m.group(1).strip() 1103 | if errno or errmsg: 1104 | Logger.error("upnp: Error from service %s of device %s: [%s] %s" % ( 1105 | self.service_type, self.device, errno, errmsg 1106 | )) 1107 | return False 1108 | return True 1109 | 1110 | 1111 | class UPnPDevice(object): 1112 | def __init__(self, ipaddr, xml_urls, bind_ip = None, interface = None): 1113 | self.ipaddr = ipaddr 1114 | self.xml_urls = xml_urls 1115 | self.services = [] 1116 | self.forward_srv = None 1117 | self._sock_timeout = 3 1118 | self._bind_ip = bind_ip 1119 | self._bind_interface = interface 1120 | 1121 | def __repr__(self): 1122 | return "" % ( 1123 | repr(self.ipaddr), 1124 | ) 1125 | 1126 | def _load_services(self): 1127 | if self.services: 1128 | return 1129 | services_d = {} # service_id => UPnPService() 1130 | for url in self.xml_urls: 1131 | sd = self._get_srv_dict(url) 1132 | services_d.update(sd) 1133 | self.services.extend(services_d.values()) 1134 | for srv in self.services: 1135 | if srv.is_forward(): 1136 | self.forward_srv = srv 1137 | break 1138 | 1139 | def _http_get(self, url): 1140 | hostname, port, path = split_url(url) 1141 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 1142 | socket_set_opt( 1143 | sock, 1144 | bind_addr = (self._bind_ip, 0) if self._bind_ip else None, 1145 | interface = self._bind_interface, 1146 | timeout = self._sock_timeout 1147 | ) 1148 | sock.connect((hostname, port)) 1149 | data = ( 1150 | "GET %s HTTP/1.1\r\n" 1151 | "Host: %s\r\n" 1152 | "User-Agent: curl/8.0.0 (Natter)\r\n" 1153 | "Accept: */*\r\n" 1154 | "Connection: close\r\n" 1155 | "\r\n" % (path, hostname) 1156 | ).encode() 1157 | sock.sendall(data) 1158 | response = b"" 1159 | while True: 1160 | buff = sock.recv(4096) 1161 | if not buff: 1162 | break 1163 | response += buff 1164 | sock.close() 1165 | if not response.startswith(b"HTTP/"): 1166 | raise ValueError("Invalid response from HTTP server") 1167 | s = response.split(b"\r\n\r\n", 1) 1168 | if len(s) != 2: 1169 | raise ValueError("Invalid response from HTTP server") 1170 | return s[1] 1171 | 1172 | def _get_srv_dict(self, url): 1173 | try: 1174 | xmlcontent = self._http_get(url).decode("utf-8", "ignore") 1175 | except (OSError, socket.error, ValueError) as ex: 1176 | Logger.error("upnp: failed to load service from %s: %s" % (url, ex)) 1177 | return 1178 | services_d = {} 1179 | srv_str_l = re.findall(r"([\s\S]+?)", xmlcontent) 1180 | for srv_str in srv_str_l: 1181 | srv = UPnPService(self, bind_ip=self._bind_ip, interface=self._bind_interface) 1182 | m = re.search(r"([^<]*?)", srv_str) 1183 | if m: 1184 | srv.service_type = m.group(1).strip() 1185 | m = re.search(r"([^<]*?)", srv_str) 1186 | if m: 1187 | srv.service_id = m.group(1).strip() 1188 | m = re.search(r"([^<]*?)", srv_str) 1189 | if m: 1190 | srv.scpd_url = full_url(m.group(1).strip(), url) 1191 | m = re.search(r"([^<]*?)", srv_str) 1192 | if m: 1193 | srv.control_url = full_url(m.group(1).strip(), url) 1194 | m = re.search(r"([^<]*?)", srv_str) 1195 | if m: 1196 | srv.eventsub_url = full_url(m.group(1).strip(), url) 1197 | if srv.is_valid(): 1198 | services_d[srv.service_id] = srv 1199 | return services_d 1200 | 1201 | 1202 | class UPnPClient(object): 1203 | def __init__(self, bind_ip = None, interface = None): 1204 | self.ssdp_addr = ("239.255.255.250", 1900) 1205 | self.router = None 1206 | self._sock_timeout = 1 1207 | self._fwd_host = None 1208 | self._fwd_port = None 1209 | self._fwd_dest_host = None 1210 | self._fwd_dest_port = None 1211 | self._fwd_udp = False 1212 | self._fwd_duration = 0 1213 | self._fwd_started = False 1214 | self._bind_ip = bind_ip 1215 | self._bind_interface = interface 1216 | 1217 | def discover_router(self): 1218 | router_l = [] 1219 | try: 1220 | devs = self._discover() 1221 | for dev in devs: 1222 | if dev.forward_srv: 1223 | router_l.append(dev) 1224 | except (OSError, socket.error) as ex: 1225 | Logger.error("upnp: failed to discover router: %s" % ex) 1226 | if not router_l: 1227 | self.router = None 1228 | elif len(router_l) > 1: 1229 | Logger.warning("upnp: multiple routers found: %s" % (router_l,)) 1230 | self.router = router_l[0] 1231 | else: 1232 | self.router = router_l[0] 1233 | return self.router 1234 | 1235 | def _discover(self): 1236 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 1237 | socket_set_opt( 1238 | sock, 1239 | reuse = True, 1240 | bind_addr = (self._bind_ip, 0) if self._bind_ip else None, 1241 | interface = self._bind_interface, 1242 | timeout = self._sock_timeout 1243 | ) 1244 | dat01 = ( 1245 | "M-SEARCH * HTTP/1.1\r\n" 1246 | "ST: ssdp:all\r\n" 1247 | "MX: 2\r\n" 1248 | "MAN: \"ssdp:discover\"\r\n" 1249 | "HOST: %s:%d\r\n" 1250 | "\r\n" % self.ssdp_addr 1251 | ).encode() 1252 | 1253 | dat02 = ( 1254 | "M-SEARCH * HTTP/1.1\r\n" 1255 | "ST: upnp:rootdevice\r\n" 1256 | "MX: 2\r\n" 1257 | "MAN: \"ssdp:discover\"\r\n" 1258 | "HOST: %s:%d\r\n" 1259 | "\r\n" % self.ssdp_addr 1260 | ).encode() 1261 | 1262 | sock.sendto(dat01, self.ssdp_addr) 1263 | sock.sendto(dat02, self.ssdp_addr) 1264 | 1265 | upnp_urls_d = {} 1266 | while True: 1267 | try: 1268 | buff, addr = sock.recvfrom(4096) 1269 | m = re.search(r"LOCATION: *(http://[^\[]\S+)\s+", buff.decode("utf-8")) 1270 | if not m: 1271 | continue 1272 | ipaddr = addr[0] 1273 | location = m.group(1) 1274 | Logger.debug("upnp: Got URL %s" % location) 1275 | if ipaddr in upnp_urls_d: 1276 | upnp_urls_d[ipaddr].add(location) 1277 | else: 1278 | upnp_urls_d[ipaddr] = set([location]) 1279 | except socket.timeout: 1280 | break 1281 | 1282 | devs = [] 1283 | for ipaddr, urls in upnp_urls_d.items(): 1284 | d = UPnPDevice(ipaddr, urls, bind_ip=self._bind_ip, interface=self._bind_interface) 1285 | d._load_services() 1286 | devs.append(d) 1287 | 1288 | return devs 1289 | 1290 | def forward(self, host, port, dest_host, dest_port, udp=False, duration=0): 1291 | if not self.router: 1292 | raise RuntimeError("No router is available") 1293 | self.router.forward_srv.forward_port(host, port, dest_host, dest_port, udp, duration) 1294 | self._fwd_host = host 1295 | self._fwd_port = port 1296 | self._fwd_dest_host = dest_host 1297 | self._fwd_dest_port = dest_port 1298 | self._fwd_udp = udp 1299 | self._fwd_duration = duration 1300 | self._fwd_started = True 1301 | 1302 | def renew(self): 1303 | if not self._fwd_started: 1304 | raise RuntimeError("UPnP forward not started") 1305 | self.router.forward_srv.forward_port( 1306 | self._fwd_host, self._fwd_port, self._fwd_dest_host, 1307 | self._fwd_dest_port, self._fwd_udp, self._fwd_duration 1308 | ) 1309 | Logger.debug("upnp: OK") 1310 | 1311 | 1312 | class NatterExitException(Exception): 1313 | pass 1314 | 1315 | 1316 | class NatterRetryException(Exception): 1317 | pass 1318 | 1319 | 1320 | def socket_set_opt(sock, reuse=False, bind_addr=None, interface=None, timeout=-1): 1321 | if reuse: 1322 | if hasattr(socket, "SO_REUSEADDR"): 1323 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 1324 | if hasattr(socket, "SO_REUSEPORT"): 1325 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 1326 | if interface is not None: 1327 | if hasattr(socket, "SO_BINDTODEVICE"): 1328 | sock.setsockopt( 1329 | socket.SOL_SOCKET, socket.SO_BINDTODEVICE, interface.encode() + b"\0" 1330 | ) 1331 | else: 1332 | raise RuntimeError("Binding to an interface is not supported on your platform.") 1333 | if bind_addr is not None: 1334 | sock.bind(bind_addr) 1335 | if timeout != -1: 1336 | sock.settimeout(timeout) 1337 | return sock 1338 | 1339 | 1340 | def start_daemon_thread(target, args=()): 1341 | th = threading.Thread(target=target, args=args) 1342 | th.daemon = True 1343 | th.start() 1344 | return th 1345 | 1346 | 1347 | def closed_socket_ex(ex): 1348 | if not hasattr(ex, "errno"): 1349 | return False 1350 | if hasattr(errno, "ECONNABORTED") and ex.errno == errno.ECONNABORTED: 1351 | return True 1352 | if hasattr(errno, "EBADFD") and ex.errno == errno.EBADFD: 1353 | return True 1354 | if hasattr(errno, "EBADF") and ex.errno == errno.EBADF: 1355 | return True 1356 | if hasattr(errno, "WSAEBADF") and ex.errno == errno.WSAEBADF: 1357 | return True 1358 | if hasattr(errno, "WSAEINTR") and ex.errno == errno.WSAEINTR: 1359 | return True 1360 | return False 1361 | 1362 | 1363 | def fix_codecs(codec_list = ["utf-8", "idna"]): 1364 | missing_codecs = [] 1365 | for codec_name in codec_list: 1366 | try: 1367 | codecs.lookup(codec_name) 1368 | except LookupError: 1369 | missing_codecs.append(codec_name.lower()) 1370 | def search_codec(name): 1371 | if name.lower() in missing_codecs: 1372 | return codecs.CodecInfo(codecs.ascii_encode, codecs.ascii_decode, name="ascii") 1373 | if missing_codecs: 1374 | codecs.register(search_codec) 1375 | 1376 | 1377 | def check_docker_network(): 1378 | if not sys.platform.startswith("linux"): 1379 | return 1380 | if not os.path.exists("/.dockerenv"): 1381 | return 1382 | if not os.path.isfile("/sys/class/net/eth0/address"): 1383 | return 1384 | fo = open("/sys/class/net/eth0/address", "r") 1385 | macaddr = fo.read().strip() 1386 | fo.close() 1387 | hostname = socket.gethostname() 1388 | try: 1389 | ipaddr = socket.gethostbyname(hostname) 1390 | except socket.gaierror: 1391 | Logger.warning("check-docket-network: Cannot resolve hostname `%s`" % hostname) 1392 | return 1393 | docker_macaddr = "02:42:" + ":".join(["%02x" % int(x) for x in ipaddr.split(".")]) 1394 | if macaddr == docker_macaddr: 1395 | raise RuntimeError("Docker's `--net=host` option is required.") 1396 | 1397 | if not os.path.isfile("/proc/sys/kernel/osrelease"): 1398 | return 1399 | fo = open("/proc/sys/kernel/osrelease", "r") 1400 | uname_r = fo.read().strip() 1401 | fo.close() 1402 | uname_r_sfx = uname_r.rsplit("-").pop() 1403 | if uname_r_sfx.lower() in ["linuxkit", "wsl2"] and hostname.lower() == "docker-desktop": 1404 | raise RuntimeError("Network from Docker Desktop is not supported.") 1405 | 1406 | 1407 | def split_url(url): 1408 | m = re.match( 1409 | r"^http://([^\[\]:/]+)(?:\:([0-9]+))?(/\S*)?$", url 1410 | ) 1411 | if not m: 1412 | raise ValueError("Unsupported URL: %s" % url) 1413 | hostname, port_str, path = m.groups() 1414 | port = 80 1415 | if port_str: 1416 | port = int(port_str) 1417 | if not path: 1418 | path = "/" 1419 | return hostname, port, path 1420 | 1421 | 1422 | def full_url(u, refurl): 1423 | if not u.startswith("/"): 1424 | return u 1425 | hostname, port, _ = split_url(refurl) 1426 | return "http://%s:%d" % (hostname, port) + u 1427 | 1428 | 1429 | def addr_to_str(addr): 1430 | return "%s:%d" % addr 1431 | 1432 | 1433 | def addr_to_uri(addr, udp=False): 1434 | if udp: 1435 | return "udp://%s:%d" % addr 1436 | else: 1437 | return "tcp://%s:%d" % addr 1438 | 1439 | 1440 | def validate_ip(s, err=True): 1441 | try: 1442 | socket.inet_aton(s) 1443 | return True 1444 | except (OSError, socket.error): 1445 | if err: 1446 | raise ValueError("Invalid IP address: %s" % s) 1447 | return False 1448 | 1449 | 1450 | def validate_port(s, err=True): 1451 | if str(s).isdigit() and int(s) in range(65536): 1452 | return True 1453 | if err: 1454 | raise ValueError("Invalid port number: %s" % s) 1455 | return False 1456 | 1457 | 1458 | def validate_addr_str(s, err=True): 1459 | l = str(s).split(":", 1) 1460 | if len(l) == 1: 1461 | return True 1462 | return validate_port(l[1], err) 1463 | 1464 | 1465 | def validate_positive(s, err=True): 1466 | if str(s).isdigit() and int(s) > 0: 1467 | return True 1468 | if err: 1469 | raise ValueError("Not a positive integer: %s" % s) 1470 | return False 1471 | 1472 | 1473 | def validate_filepath(s, err=True): 1474 | if os.path.isfile(s): 1475 | return True 1476 | if err: 1477 | raise ValueError("File not found: %s" % s) 1478 | return False 1479 | 1480 | 1481 | def ip_normalize(ipaddr): 1482 | return socket.inet_ntoa(socket.inet_aton(ipaddr)) 1483 | 1484 | 1485 | def natter_main(show_title = True): 1486 | argp = argparse.ArgumentParser( 1487 | description="Expose your port behind full-cone NAT to the Internet.", add_help=False 1488 | ) 1489 | group = argp.add_argument_group("options") 1490 | group.add_argument( 1491 | "--version", "-V", action="version", version="Natter %s" % __version__, 1492 | help="show the version of Natter and exit" 1493 | ) 1494 | group.add_argument( 1495 | "--help", action="help", help="show this help message and exit" 1496 | ) 1497 | group.add_argument( 1498 | "-v", action="store_true", help="verbose mode, printing debug messages" 1499 | ) 1500 | group.add_argument( 1501 | "-q", action="store_true", help="exit when mapped address is changed" 1502 | ) 1503 | group.add_argument( 1504 | "-u", action="store_true", help="UDP mode" 1505 | ) 1506 | group.add_argument( 1507 | "-U", action="store_true", help="enable UPnP/IGD discovery" 1508 | ) 1509 | group.add_argument( 1510 | "-k", type=int, metavar="", default=15, 1511 | help="seconds between each keep-alive" 1512 | ) 1513 | group.add_argument( 1514 | "-s", metavar="
", action="append", 1515 | help="hostname or address to STUN server" 1516 | ) 1517 | group.add_argument( 1518 | "-h", type=str, metavar="
", default=None, 1519 | help="hostname or address to keep-alive server" 1520 | ) 1521 | group.add_argument( 1522 | "-e", type=str, metavar="", default=None, 1523 | help="script path for notifying mapped address" 1524 | ) 1525 | group = argp.add_argument_group("bind options") 1526 | group.add_argument( 1527 | "-i", type=str, metavar="", default="0.0.0.0", 1528 | help="network interface name or IP to bind" 1529 | ) 1530 | group.add_argument( 1531 | "-b", type=int, metavar="", default=0, 1532 | help="port number to bind" 1533 | ) 1534 | group = argp.add_argument_group("forward options") 1535 | group.add_argument( 1536 | "-m", type=str, metavar="", default=None, 1537 | help="forward method, common values are 'iptables', 'nftables', " 1538 | "'socat', 'gost' and 'socket'" 1539 | ) 1540 | group.add_argument( 1541 | "-t", type=str, metavar="
", default="0.0.0.0", 1542 | help="IP address of forward target" 1543 | ) 1544 | group.add_argument( 1545 | "-p", type=int, metavar="", default=0, 1546 | help="port number of forward target" 1547 | ) 1548 | group.add_argument( 1549 | "-r", action="store_true", help="keep retrying until the port of forward target is open" 1550 | ) 1551 | 1552 | args = argp.parse_args() 1553 | verbose = args.v 1554 | udp_mode = args.u 1555 | upnp_enabled = args.U 1556 | interval = args.k 1557 | stun_list = args.s 1558 | keepalive_srv = args.h 1559 | notify_sh = args.e 1560 | bind_ip = args.i 1561 | bind_interface = None 1562 | bind_port = args.b 1563 | method = args.m 1564 | to_ip = args.t 1565 | to_port = args.p 1566 | keep_retry = args.r 1567 | exit_when_changed = args.q 1568 | 1569 | sys.tracebacklimit = 0 1570 | if verbose: 1571 | sys.tracebacklimit = None 1572 | Logger.set_level(Logger.DEBUG) 1573 | 1574 | validate_positive(interval) 1575 | if stun_list: 1576 | for stun_srv in stun_list: 1577 | validate_addr_str(stun_srv) 1578 | validate_addr_str(keepalive_srv) 1579 | if notify_sh: 1580 | validate_filepath(notify_sh) 1581 | if not validate_ip(bind_ip, err=False): 1582 | bind_interface = bind_ip 1583 | bind_ip = "0.0.0.0" 1584 | validate_port(bind_port) 1585 | validate_ip(to_ip) 1586 | validate_port(to_port) 1587 | 1588 | # Normalize IPv4 in dotted-decimal notation 1589 | # e.g. 10.1 -> 10.0.0.1 1590 | bind_ip = ip_normalize(bind_ip) 1591 | to_ip = ip_normalize(to_ip) 1592 | 1593 | if not stun_list: 1594 | stun_list = [ 1595 | "fwa.lifesizecloud.com", 1596 | "global.turn.twilio.com", 1597 | "turn.cloudflare.com", 1598 | "stun.isp.net.au", 1599 | "stun.nextcloud.com", 1600 | "stun.freeswitch.org", 1601 | "stun.voip.blackberry.com", 1602 | "stunserver.stunprotocol.org", 1603 | "stun.sipnet.com", 1604 | "stun.radiojar.com", 1605 | "stun.sonetel.com", 1606 | "stun.telnyx.com" 1607 | ] 1608 | if not udp_mode: 1609 | stun_list = stun_list + [ 1610 | "turn.cloud-rtc.com:80" 1611 | ] 1612 | else: 1613 | stun_list = [ 1614 | "stun.miwifi.com", 1615 | "stun.chat.bilibili.com", 1616 | "stun.hitv.com", 1617 | "stun.cdnbye.com", 1618 | "stun.douyucdn.cn:18000" 1619 | ] + stun_list 1620 | 1621 | if not keepalive_srv: 1622 | keepalive_srv = "www.baidu.com" 1623 | if udp_mode: 1624 | keepalive_srv = "119.29.29.29" 1625 | 1626 | stun_srv_list = [] 1627 | for item in stun_list: 1628 | l = item.split(":", 2) + ["3478"] 1629 | stun_srv_list.append((l[0], int(l[1])),) 1630 | 1631 | if udp_mode: 1632 | l = keepalive_srv.split(":", 2) + ["53"] 1633 | keepalive_host, keepalive_port = l[0], int(l[1]) 1634 | else: 1635 | l = keepalive_srv.split(":", 2) + ["80"] 1636 | keepalive_host, keepalive_port = l[0], int(l[1]) 1637 | 1638 | # forward method defaults 1639 | if not method: 1640 | if to_ip == "0.0.0.0" and to_port == 0 and \ 1641 | bind_ip == "0.0.0.0" and bind_port == 0 and bind_interface is None: 1642 | method = "test" 1643 | elif to_ip == "0.0.0.0" and to_port == 0: 1644 | method = "none" 1645 | else: 1646 | method = "socket" 1647 | 1648 | if method == "none": 1649 | ForwardImpl = ForwardNone 1650 | elif method == "test": 1651 | ForwardImpl = ForwardTestServer 1652 | elif method == "iptables": 1653 | ForwardImpl = ForwardIptables 1654 | elif method == "sudo-iptables": 1655 | ForwardImpl = ForwardSudoIptables 1656 | elif method == "iptables-snat": 1657 | ForwardImpl = ForwardIptablesSnat 1658 | elif method == "sudo-iptables-snat": 1659 | ForwardImpl = ForwardSudoIptablesSnat 1660 | elif method == "nftables": 1661 | ForwardImpl = ForwardNftables 1662 | elif method == "sudo-nftables": 1663 | ForwardImpl = ForwardSudoNftables 1664 | elif method == "nftables-snat": 1665 | ForwardImpl = ForwardNftablesSnat 1666 | elif method == "sudo-nftables-snat": 1667 | ForwardImpl = ForwardSudoNftablesSnat 1668 | elif method == "socat": 1669 | ForwardImpl = ForwardSocat 1670 | elif method == "gost": 1671 | ForwardImpl = ForwardGost 1672 | elif method == "socket": 1673 | ForwardImpl = ForwardSocket 1674 | else: 1675 | raise ValueError("Unknown method name: %s" % method) 1676 | # 1677 | # Natter 1678 | # 1679 | if show_title: 1680 | Logger.info("Natter v%s" % __version__) 1681 | if len(sys.argv) == 1: 1682 | Logger.info("Tips: Use `--help` to see help messages") 1683 | 1684 | check_docker_network() 1685 | 1686 | forwarder = ForwardImpl() 1687 | port_test = PortTest() 1688 | 1689 | stun = StunClient(stun_srv_list, bind_ip, bind_port, udp=udp_mode, interface=bind_interface) 1690 | natter_addr, outer_addr = stun.get_mapping() 1691 | # set actual ip and port for keep-alive socket to bind, instead of zero 1692 | bind_ip, bind_port = natter_addr 1693 | 1694 | keep_alive = KeepAlive(keepalive_host, keepalive_port, bind_ip, bind_port, udp=udp_mode, interface=bind_interface) 1695 | keep_alive.keep_alive() 1696 | 1697 | # get the mapped address again after the keep-alive connection is established 1698 | outer_addr_prev = outer_addr 1699 | natter_addr, outer_addr = stun.get_mapping() 1700 | if outer_addr != outer_addr_prev: 1701 | Logger.warning("Network is unstable, or not full cone") 1702 | 1703 | # set actual ip of localhost for correct forwarding 1704 | if socket.inet_aton(to_ip) in [socket.inet_aton("127.0.0.1"), socket.inet_aton("0.0.0.0")]: 1705 | to_ip = natter_addr[0] 1706 | 1707 | # if not specified, the target port is set to be the same as the outer port 1708 | if not to_port: 1709 | to_port = outer_addr[1] 1710 | 1711 | # some exceptions: ForwardNone and ForwardTestServer are not real forward methods, 1712 | # so let target ip and port equal to natter's 1713 | if ForwardImpl in (ForwardNone, ForwardTestServer): 1714 | to_ip, to_port = natter_addr 1715 | 1716 | to_addr = (to_ip, to_port) 1717 | forwarder.start_forward(natter_addr[0], natter_addr[1], to_addr[0], to_addr[1], udp=udp_mode) 1718 | NatterExit.set_atexit(forwarder.stop_forward) 1719 | 1720 | # UPnP 1721 | upnp = None 1722 | upnp_router = None 1723 | upnp_ready = False 1724 | 1725 | if upnp_enabled: 1726 | upnp = UPnPClient(bind_ip=natter_addr[0], interface=bind_interface) 1727 | Logger.info() 1728 | Logger.info("Scanning UPnP Devices...") 1729 | try: 1730 | upnp_router = upnp.discover_router() 1731 | except (OSError, socket.error, ValueError) as ex: 1732 | Logger.error("upnp: failed to discover router: %s" % ex) 1733 | 1734 | if upnp_router: 1735 | Logger.info("[UPnP] Found router %s" % upnp_router.ipaddr) 1736 | try: 1737 | upnp.forward("", bind_port, bind_ip, bind_port, udp=udp_mode, duration=interval*3) 1738 | except (OSError, socket.error, ValueError) as ex: 1739 | Logger.error("upnp: failed to forward port: %s" % ex) 1740 | else: 1741 | upnp_ready = True 1742 | 1743 | # Display route information 1744 | Logger.info() 1745 | route_str = "" 1746 | if ForwardImpl not in (ForwardNone, ForwardTestServer): 1747 | route_str += "%s <--%s--> " % (addr_to_uri(to_addr, udp=udp_mode), method) 1748 | route_str += "%s <--Natter--> %s" % ( 1749 | addr_to_uri(natter_addr, udp=udp_mode), addr_to_uri(outer_addr, udp=udp_mode) 1750 | ) 1751 | Logger.info(route_str) 1752 | Logger.info() 1753 | 1754 | # Test mode notice 1755 | if ForwardImpl == ForwardTestServer: 1756 | Logger.info("Test mode in on.") 1757 | Logger.info("Please check [ %s://%s ]" % ("udp" if udp_mode else "http", addr_to_str(outer_addr))) 1758 | Logger.info() 1759 | 1760 | # Call notification script 1761 | if notify_sh: 1762 | protocol = "udp" if udp_mode else "tcp" 1763 | inner_ip, inner_port = to_addr if method else natter_addr 1764 | outer_ip, outer_port = outer_addr 1765 | Logger.info("Calling script: %s" % notify_sh) 1766 | subprocess.call([ 1767 | os.path.abspath(notify_sh), protocol, str(inner_ip), str(inner_port), str(outer_ip), str(outer_port) 1768 | ], shell=False) 1769 | 1770 | # Display check results, TCP only 1771 | if not udp_mode: 1772 | ret1 = port_test.test_lan(to_addr, info=True) 1773 | ret2 = port_test.test_lan(natter_addr, info=True) 1774 | ret3 = port_test.test_lan(outer_addr, source_ip=natter_addr[0], interface=bind_interface, info=True) 1775 | ret4 = port_test.test_wan(outer_addr, source_ip=natter_addr[0], interface=bind_interface, info=True) 1776 | if ret1 == -1: 1777 | Logger.warning("!! Target port is closed !!") 1778 | elif ret1 == 1 and ret3 == ret4 == -1: 1779 | Logger.warning("!! Hole punching failed !!") 1780 | elif ret3 == 1 and ret4 == -1: 1781 | Logger.warning("!! You may be behind a firewall !!") 1782 | Logger.info() 1783 | # retry 1784 | if keep_retry and ret1 == -1: 1785 | Logger.info("Retry after %d seconds..." % interval) 1786 | time.sleep(interval) 1787 | forwarder.stop_forward() 1788 | raise NatterRetryException("Target port is closed") 1789 | # 1790 | # Main loop 1791 | # 1792 | need_recheck = False 1793 | cnt = 0 1794 | while True: 1795 | # force recheck every 20th loop 1796 | cnt = (cnt + 1) % 20 1797 | if cnt == 0: 1798 | need_recheck = True 1799 | if need_recheck: 1800 | Logger.debug("Start recheck") 1801 | need_recheck = False 1802 | # check LAN port first 1803 | if udp_mode or port_test.test_lan(outer_addr, source_ip=natter_addr[0], interface=bind_interface) == -1: 1804 | # then check through STUN 1805 | _, outer_addr_curr = stun.get_mapping() 1806 | if outer_addr_curr != outer_addr: 1807 | forwarder.stop_forward() 1808 | # exit or retry 1809 | if exit_when_changed: 1810 | Logger.info("Natter is exiting because mapped address has changed") 1811 | raise NatterExitException("Mapped address has changed") 1812 | raise NatterRetryException("Mapped address has changed") 1813 | # end of recheck 1814 | ts = time.time() 1815 | try: 1816 | keep_alive.keep_alive() 1817 | except (OSError, socket.error) as ex: 1818 | if udp_mode: 1819 | Logger.debug("keep-alive: UDP response not received: %s" % ex) 1820 | else: 1821 | Logger.error("keep-alive: connection broken: %s" % ex) 1822 | keep_alive.reset() 1823 | need_recheck = True 1824 | if upnp_ready: 1825 | try: 1826 | upnp.renew() 1827 | except (OSError, socket.error) as ex: 1828 | Logger.error("upnp: failed to renew upnp: %s" % ex) 1829 | sleep_sec = interval - (time.time() - ts) 1830 | if sleep_sec > 0: 1831 | time.sleep(sleep_sec) 1832 | 1833 | 1834 | def main(): 1835 | signal.signal(signal.SIGTERM, lambda s,f:exit(143)) 1836 | fix_codecs() 1837 | show_title = True 1838 | while True: 1839 | try: 1840 | natter_main(show_title) 1841 | except NatterRetryException: 1842 | pass 1843 | except (NatterExitException, KeyboardInterrupt): 1844 | sys.exit() 1845 | show_title = False 1846 | 1847 | 1848 | if __name__ == "__main__": 1849 | main() 1850 | --------------------------------------------------------------------------------