` | 转发目标的端口号 | 整数 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 |
--------------------------------------------------------------------------------