#!/usr/bin/env python3
'''
Natter - https://github.com/MikeWang000000/Natter
Copyright (C) 2023 MikeWang000000
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
'''
import os
import re
import sys
import json
import time
import errno
import atexit
import codecs
import random
import signal
import socket
import struct
import argparse
import threading
import subprocess
__version__ = "2.1.0-dev"
class Logger(object):
DEBUG = 0
INFO = 1
WARN = 2
ERROR = 3
rep = {DEBUG: "D", INFO: "I", WARN: "W", ERROR: "E"}
level = INFO
if "256color" in os.environ.get("TERM", ""):
GREY = "\033[90;20m"
YELLOW_BOLD = "\033[33;1m"
RED_BOLD = "\033[31;1m"
RESET = "\033[0m"
else:
GREY = YELLOW_BOLD = RED_BOLD = RESET = ""
@staticmethod
def set_level(level):
Logger.level = level
@staticmethod
def debug(text=""):
if Logger.level <= Logger.DEBUG:
sys.stderr.write((Logger.GREY + "%s [%s] %s\n" + Logger.RESET) % (
time.strftime("%Y-%m-%d %H:%M:%S"), Logger.rep[Logger.DEBUG], text
))
@staticmethod
def info(text=""):
if Logger.level <= Logger.INFO:
sys.stderr.write(("%s [%s] %s\n") % (
time.strftime("%Y-%m-%d %H:%M:%S"), Logger.rep[Logger.INFO], text
))
@staticmethod
def warning(text=""):
if Logger.level <= Logger.WARN:
sys.stderr.write((Logger.YELLOW_BOLD + "%s [%s] %s\n" + Logger.RESET) % (
time.strftime("%Y-%m-%d %H:%M:%S"), Logger.rep[Logger.WARN], text
))
@staticmethod
def error(text=""):
if Logger.level <= Logger.ERROR:
sys.stderr.write((Logger.RED_BOLD + "%s [%s] %s\n" + Logger.RESET) % (
time.strftime("%Y-%m-%d %H:%M:%S"), Logger.rep[Logger.ERROR], text
))
class NatterExit(object):
atexit.register(lambda : NatterExit._atexit[0]())
_atexit = [lambda : None]
@staticmethod
def set_atexit(func):
NatterExit._atexit[0] = func
class PortTest(object):
def test_lan(self, addr, source_ip=None, interface=None, info=False):
print_status = Logger.info if info else Logger.debug
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
socket_set_opt(
sock,
bind_addr = (source_ip, 0) if source_ip else None,
interface = interface,
timeout = 1
)
if sock.connect_ex(addr) == 0:
print_status("LAN > %-21s [ OPEN ]" % addr_to_str(addr))
return 1
else:
print_status("LAN > %-21s [ CLOSED ]" % addr_to_str(addr))
return -1
except (OSError, socket.error) as ex:
print_status("LAN > %-21s [ UNKNOWN ]" % addr_to_str(addr))
Logger.debug("Cannot test port %s from LAN because: %s" % (addr_to_str(addr), ex))
return 0
finally:
sock.close()
def test_wan(self, addr, source_ip=None, interface=None, info=False):
# only port number in addr is used, WAN IP will be ignored
print_status = Logger.info if info else Logger.debug
ret01 = self._test_ifconfigco(addr[1], source_ip, interface)
if ret01 == 1:
print_status("WAN > %-21s [ OPEN ]" % addr_to_str(addr))
return 1
ret02 = self._test_transmission(addr[1], source_ip, interface)
if ret02 == 1:
print_status("WAN > %-21s [ OPEN ]" % addr_to_str(addr))
return 1
if ret01 == ret02 == -1:
print_status("WAN > %-21s [ CLOSED ]" % addr_to_str(addr))
return -1
print_status("WAN > %-21s [ UNKNOWN ]" % addr_to_str(addr))
return 0
def _test_ifconfigco(self, port, source_ip=None, interface=None):
# repo: https://github.com/mpolden/echoip
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
socket_set_opt(
sock,
bind_addr = (source_ip, 0) if source_ip else None,
interface = interface,
timeout = 8
)
sock.connect(("ifconfig.co", 80))
sock.sendall((
"GET /port/%d HTTP/1.0\r\n"
"Host: ifconfig.co\r\n"
"User-Agent: curl/8.0.0 (Natter)\r\n"
"Accept: */*\r\n"
"Connection: close\r\n"
"\r\n" % port
).encode())
response = b""
while True:
buff = sock.recv(4096)
if not buff:
break
response += buff
Logger.debug("port-test: ifconfig.co: %s" % response)
_, content = response.split(b"\r\n\r\n", 1)
dat = json.loads(content.decode())
return 1 if dat["reachable"] else -1
except (OSError, LookupError, ValueError, TypeError, socket.error) as ex:
Logger.debug("Cannot test port %d from ifconfig.co because: %s" % (port, ex))
return 0
finally:
sock.close()
def _test_transmission(self, port, source_ip=None, interface=None):
# repo: https://github.com/transmission/portcheck
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
socket_set_opt(
sock,
bind_addr = (source_ip, 0) if source_ip else None,
interface = interface,
timeout = 8
)
sock.connect(("portcheck.transmissionbt.com", 80))
sock.sendall((
"GET /%d HTTP/1.0\r\n"
"Host: portcheck.transmissionbt.com\r\n"
"User-Agent: curl/8.0.0 (Natter)\r\n"
"Accept: */*\r\n"
"Connection: close\r\n"
"\r\n" % port
).encode())
response = b""
while True:
buff = sock.recv(4096)
if not buff:
break
response += buff
Logger.debug("port-test: portcheck.transmissionbt.com: %s" % response)
_, content = response.split(b"\r\n\r\n", 1)
if content.strip() == b"1":
return 1
elif content.strip() == b"0":
return -1
raise ValueError("Unexpected response: %s" % response)
except (OSError, LookupError, ValueError, TypeError, socket.error) as ex:
Logger.debug(
"Cannot test port %d from portcheck.transmissionbt.com "
"because: %s" % (port, ex)
)
return 0
finally:
sock.close()
class StunClient(object):
class ServerUnavailable(Exception):
pass
def __init__(self, stun_server_list, source_host="0.0.0.0", source_port=0,
interface=None, udp=False):
if not stun_server_list:
raise ValueError("STUN server list is empty")
self.stun_server_list = stun_server_list
self.source_host = source_host
self.source_port = source_port
self.interface = interface
self.udp = udp
def get_mapping(self):
first = self.stun_server_list[0]
while True:
try:
return self._get_mapping()
except StunClient.ServerUnavailable as ex:
Logger.warning("stun: STUN server %s is unavailable: %s" % (
addr_to_uri(self.stun_server_list[0], udp = self.udp), ex
))
self.stun_server_list.append(self.stun_server_list.pop(0))
if self.stun_server_list[0] == first:
Logger.error("stun: No STUN server is available right now")
# force sleep for 10 seconds, then try the next loop
time.sleep(10)
def _get_mapping(self):
# ref: https://www.rfc-editor.org/rfc/rfc5389
socket_type = socket.SOCK_DGRAM if self.udp else socket.SOCK_STREAM
stun_host, stun_port = self.stun_server_list[0]
sock = socket.socket(socket.AF_INET, socket_type)
socket_set_opt(
sock,
reuse = True,
bind_addr = (self.source_host, self.source_port),
interface = self.interface,
timeout = 3
)
try:
sock.connect((stun_host, stun_port))
inner_addr = sock.getsockname()
self.source_host, self.source_port = inner_addr
sock.send(struct.pack(
"!LLLLL", 0x00010000, 0x2112a442, 0x4e415452,
random.getrandbits(32), random.getrandbits(32)
))
buff = sock.recv(1500)
ip = port = 0
payload = buff[20:]
while payload:
attr_type, attr_len = struct.unpack("!HH", payload[:4])
if attr_type in [1, 32]:
_, _, port, ip = struct.unpack("!BBHL", payload[4:4+attr_len])
if attr_type == 32:
port ^= 0x2112
ip ^= 0x2112a442
break
payload = payload[4 + attr_len:]
else:
raise ValueError("Invalid STUN response")
outer_addr = socket.inet_ntop(socket.AF_INET, struct.pack("!L", ip)), port
Logger.debug("stun: Got address %s from %s, source %s" % (
addr_to_uri(outer_addr, udp=self.udp),
addr_to_uri((stun_host, stun_port), udp=self.udp),
addr_to_uri(inner_addr, udp=self.udp)
))
return inner_addr, outer_addr
except (OSError, ValueError, struct.error, socket.error) as ex:
raise StunClient.ServerUnavailable(ex)
finally:
sock.close()
class KeepAlive(object):
def __init__(self, host, port, source_host, source_port, interface=None, udp=False):
self.sock = None
self.host = host
self.port = port
self.source_host = source_host
self.source_port = source_port
self.interface = interface
self.udp = udp
self.reconn = False
def __del__(self):
if self.sock:
self.sock.close()
def _connect(self):
sock_type = socket.SOCK_DGRAM if self.udp else socket.SOCK_STREAM
sock = socket.socket(socket.AF_INET, sock_type)
socket_set_opt(
sock,
reuse = True,
bind_addr = (self.source_host, self.source_port),
interface = self.interface,
timeout = 3
)
sock.connect((self.host, self.port))
if not self.udp:
Logger.debug("keep-alive: Connected to host %s" % (
addr_to_uri((self.host, self.port), udp=self.udp)
))
if self.reconn:
Logger.info("keep-alive: connection restored")
self.reconn = False
self.sock = sock
def keep_alive(self):
if self.sock is None:
self._connect()
if self.udp:
self._keep_alive_udp()
else:
self._keep_alive_tcp()
Logger.debug("keep-alive: OK")
def reset(self):
if self.sock is not None:
self.sock.close()
self.sock = None
self.reconn = True
def _keep_alive_tcp(self):
# send a HTTP request
self.sock.sendall((
"HEAD /natter-keep-alive HTTP/1.1\r\n"
"Host: %s\r\n"
"User-Agent: curl/8.0.0 (Natter)\r\n"
"Accept: */*\r\n"
"Connection: keep-alive\r\n"
"\r\n" % self.host
).encode())
buff = b""
try:
while True:
buff = self.sock.recv(4096)
if not buff:
raise OSError("Keep-alive server closed connection")
except socket.timeout as ex:
if not buff:
raise ex
return
def _keep_alive_udp(self):
# send a DNS request
self.sock.send(
struct.pack(
"!HHHHHH", random.getrandbits(16), 0x0100, 0x0001, 0x0000, 0x0000, 0x0000
) + b"\x09keepalive\x06natter\x00" + struct.pack("!HH", 0x0001, 0x0001)
)
buff = b""
try:
while True:
buff = self.sock.recv(1500)
if not buff:
raise OSError("Keep-alive server closed connection")
except socket.timeout as ex:
if not buff:
raise ex
# fix: Keep-alive cause STUN socket timeout on Windows
if sys.platform == "win32":
self.reset()
return
class ForwardNone(object):
# Do nothing. Don't forward.
def start_forward(self, ip, port, toip, toport, udp=False):
pass
def stop_forward(self):
pass
class ForwardTestServer(object):
def __init__(self):
self.active = False
self.sock = None
self.sock_type = None
self.buff_size = 8192
self.timeout = 3
# Start a socket server for testing purpose
# target address is ignored
def start_forward(self, ip, port, toip, toport, udp=False):
self.sock_type = socket.SOCK_DGRAM if udp else socket.SOCK_STREAM
self.sock = socket.socket(socket.AF_INET, self.sock_type)
socket_set_opt(
self.sock,
reuse = True,
bind_addr = ("", port)
)
Logger.debug("fwd-test: Starting test server at %s" % addr_to_uri((ip, port), udp=udp))
if udp:
th = start_daemon_thread(self._test_server_run_udp)
else:
th = start_daemon_thread(self._test_server_run_http)
time.sleep(1)
if not th.is_alive():
raise OSError("Test server thread exited too quickly")
self.active = True
def _test_server_run_http(self):
self.sock.listen(5)
while self.sock.fileno() != -1:
try:
conn, addr = self.sock.accept()
Logger.debug("fwd-test: got client %s" % (addr,))
except (OSError, socket.error):
return
try:
conn.settimeout(self.timeout)
conn.recv(self.buff_size)
content = "
It works!
Natter"
content_len = len(content.encode())
data = (
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html\r\n"
"Content-Length: %d\r\n"
"Connection: close\r\n"
"Server: Natter\r\n"
"\r\n"
"%s\r\n" % (content_len, content)
).encode()
conn.sendall(data)
conn.shutdown(socket.SHUT_RDWR)
except (OSError, socket.error):
pass
finally:
conn.close()
def _test_server_run_udp(self):
while self.sock.fileno() != -1:
try:
msg, addr = self.sock.recvfrom(self.buff_size)
Logger.debug("fwd-test: got client %s" % (addr,))
self.sock.sendto(b"It works! - Natter\r\n", addr)
except (OSError, socket.error):
return
def stop_forward(self):
Logger.debug("fwd-test: Stopping test server")
self.sock.close()
self.active = False
class ForwardIptables(object):
def __init__(self, snat=False, sudo=False):
self.rules = []
self.active = False
self.min_ver = (1, 4, 1)
self.curr_ver = (0, 0, 0)
self.snat = snat
self.sudo = sudo
if sudo:
self.iptables_cmd = ["sudo", "-n", "iptables"]
else:
self.iptables_cmd = ["iptables"]
if not self._iptables_check():
raise OSError("iptables >= %s not available" % str(self.min_ver))
# wait for iptables lock, since iptables 1.4.20
if self.curr_ver >= (1, 4, 20):
self.iptables_cmd += ["-w"]
self._iptables_init()
self._iptables_clean()
def __del__(self):
if self.active:
self.stop_forward()
def _iptables_check(self):
if os.name != "posix":
return False
if not self.sudo and os.getuid() != 0:
Logger.warning("fwd-iptables: You are not root")
try:
output = subprocess.check_output(
self.iptables_cmd + ["--version"]
).decode()
except (OSError, subprocess.CalledProcessError) as e:
return False
m = re.search(r"iptables v([0-9]+)\.([0-9]+)\.([0-9]+)", output)
if m:
self.curr_ver = tuple(int(v) for v in m.groups())
Logger.debug("fwd-iptables: Found iptables %s" % str(self.curr_ver))
if self.curr_ver < self.min_ver:
return False
# check nat table
try:
subprocess.check_output(
self.iptables_cmd + ["-t", "nat", "--list-rules"]
)
except (OSError, subprocess.CalledProcessError) as e:
return False
return True
def _iptables_init(self):
try:
subprocess.check_output(
self.iptables_cmd + ["-t", "nat", "--list-rules", "NATTER"],
stderr=subprocess.STDOUT
)
return
except subprocess.CalledProcessError:
pass
Logger.debug("fwd-iptables: Creating Natter chain")
subprocess.check_output(
self.iptables_cmd + ["-t", "nat", "-N", "NATTER"]
)
subprocess.check_output(
self.iptables_cmd + ["-t", "nat", "-I", "PREROUTING", "-j", "NATTER"]
)
subprocess.check_output(
self.iptables_cmd + ["-t", "nat", "-I", "OUTPUT", "-j", "NATTER"]
)
subprocess.check_output(
self.iptables_cmd + ["-t", "nat", "-N", "NATTER_SNAT"]
)
subprocess.check_output(
self.iptables_cmd + ["-t", "nat", "-I", "POSTROUTING", "-j", "NATTER_SNAT"]
)
subprocess.check_output(
self.iptables_cmd + ["-t", "nat", "-I", "INPUT", "-j", "NATTER_SNAT"]
)
def _iptables_clean(self):
Logger.debug("fwd-iptables: Cleaning up Natter rules")
while self.rules:
rule = self.rules.pop()
rule_rm = ["-D" if arg in ("-I", "-A") else arg for arg in rule]
try:
subprocess.check_output(
self.iptables_cmd + rule_rm,
stderr=subprocess.STDOUT
)
return
except subprocess.CalledProcessError as ex:
Logger.error("fwd-iptables: Failed to execute %s: %s" % (ex.cmd, ex.output))
continue
def start_forward(self, ip, port, toip, toport, udp=False):
if ip != toip:
self._check_sys_forward_config()
if (ip, port) == (toip, toport):
raise ValueError("Cannot forward to the same address %s" % addr_to_str((ip, port)))
proto = "udp" if udp else "tcp"
Logger.debug("fwd-iptables: Adding rule %s forward to %s" % (
addr_to_uri((ip, port), udp=udp), addr_to_uri((toip, toport), udp=udp)
))
rule = [
"-t", "nat",
"-I", "NATTER",
"-p", proto,
"--dst", ip,
"--dport", "%d" % port,
"-j", "DNAT",
"--to-destination", "%s:%d" % (toip, toport)
]
subprocess.check_output(self.iptables_cmd + rule)
self.rules.append(rule)
if self.snat:
rule = [
"-t", "nat",
"-I", "NATTER_SNAT",
"-p", proto,
"--dst", toip,
"--dport", "%d" % toport,
"-j", "SNAT",
"--to-source", ip
]
subprocess.check_output(self.iptables_cmd + rule)
self.rules.append(rule)
self.active = True
def stop_forward(self):
self._iptables_clean()
self.active = False
def _check_sys_forward_config(self):
fpath = "/proc/sys/net/ipv4/ip_forward"
if os.path.exists(fpath):
fin = open(fpath, "r")
buff = fin.read()
fin.close()
if buff.strip() != "1":
raise OSError("IP forwarding is not allowed. Please do `sysctl net.ipv4.ip_forward=1`")
else:
Logger.warning("fwd-iptables: '%s' not found" % str(fpath))
class ForwardSudoIptables(ForwardIptables):
def __init__(self):
super().__init__(sudo=True)
class ForwardIptablesSnat(ForwardIptables):
def __init__(self):
super().__init__(snat=True)
class ForwardSudoIptablesSnat(ForwardIptables):
def __init__(self):
super().__init__(snat=True, sudo=True)
class ForwardNftables(object):
def __init__(self, snat=False, sudo=False):
self.handle = -1
self.handle_snat = -1
self.active = False
self.min_ver = (0, 9, 0)
self.snat = snat
self.sudo = sudo
if sudo:
self.nftables_cmd = ["sudo", "-n", "nft"]
else:
self.nftables_cmd = ["nft"]
if not self._nftables_check():
raise OSError("nftables >= %s not available" % str(self.min_ver))
self._nftables_init()
self._nftables_clean()
def __del__(self):
if self.active:
self.stop_forward()
def _nftables_check(self):
if os.name != "posix":
return False
if not self.sudo and os.getuid() != 0:
Logger.warning("fwd-nftables: You are not root")
try:
output = subprocess.check_output(
self.nftables_cmd + ["--version"]
).decode()
except (OSError, subprocess.CalledProcessError) as e:
return False
m = re.search(r"nftables v([0-9]+)\.([0-9]+)\.([0-9]+)", output)
if m:
curr_ver = tuple(int(v) for v in m.groups())
Logger.debug("fwd-nftables: Found nftables %s" % str(curr_ver))
if curr_ver < self.min_ver:
return False
# check nat table
try:
subprocess.check_output(
self.nftables_cmd + ["list table ip nat"]
)
except (OSError, subprocess.CalledProcessError) as e:
return False
return True
def _nftables_init(self):
try:
subprocess.check_output(
self.nftables_cmd + ["list chain ip nat NATTER"],
stderr=subprocess.STDOUT
)
return
except subprocess.CalledProcessError:
pass
Logger.debug("fwd-nftables: Creating Natter chain")
subprocess.check_output(
self.nftables_cmd + ["add chain ip nat NATTER"]
)
subprocess.check_output(
self.nftables_cmd + ["insert rule ip nat PREROUTING counter jump NATTER"]
)
subprocess.check_output(
self.nftables_cmd + ["insert rule ip nat OUTPUT counter jump NATTER"]
)
subprocess.check_output(
self.nftables_cmd + ["add chain ip nat NATTER_SNAT"]
)
subprocess.check_output(
self.nftables_cmd + ["insert rule ip nat PREROUTING counter jump NATTER_SNAT"]
)
subprocess.check_output(
self.nftables_cmd + ["insert rule ip nat OUTPUT counter jump NATTER_SNAT"]
)
def _nftables_clean(self):
Logger.debug("fwd-nftables: Cleaning up Natter rules")
if self.handle > 0:
subprocess.check_output(
self.nftables_cmd + ["delete rule ip nat NATTER handle %d" % self.handle]
)
if self.handle_snat > 0:
subprocess.check_output(
self.nftables_cmd + ["delete rule ip nat NATTER_SNAT handle %d" % self.handle_snat]
)
def start_forward(self, ip, port, toip, toport, udp=False):
if ip != toip:
self._check_sys_forward_config()
if (ip, port) == (toip, toport):
raise ValueError("Cannot forward to the same address %s" % addr_to_str((ip, port)))
proto = "udp" if udp else "tcp"
Logger.debug("fwd-nftables: Adding rule %s forward to %s" % (
addr_to_uri((ip, port), udp=udp), addr_to_uri((toip, toport), udp=udp)
))
output = subprocess.check_output(self.nftables_cmd + [
"--echo", "--handle",
"insert rule ip nat NATTER ip daddr %s %s dport %d counter dnat to %s:%d" % (
ip, proto, port, toip, toport
)
]).decode()
m = re.search(r"# handle ([0-9]+)$", output, re.MULTILINE)
if not m:
raise ValueError("Unknown nftables handle")
self.handle = int(m.group(1))
if self.snat:
output = subprocess.check_output(self.nftables_cmd + [
"--echo", "--handle",
"insert rule ip nat NATTER_SNAT ip daddr %s %s dport %d counter snat to %s" % (
toip, proto, toport, ip
)
]).decode()
m = re.search(r"# handle ([0-9]+)$", output, re.MULTILINE)
if not m:
raise ValueError("Unknown nftables handle")
self.handle_snat = int(m.group(1))
self.active = True
def stop_forward(self):
self._nftables_clean()
self.active = False
def _check_sys_forward_config(self):
fpath = "/proc/sys/net/ipv4/ip_forward"
if os.path.exists(fpath):
fin = open(fpath, "r")
buff = fin.read()
fin.close()
if buff.strip() != "1":
raise OSError("IP forwarding is disabled by system. Please do `sysctl net.ipv4.ip_forward=1`")
else:
Logger.warning("fwd-nftables: '%s' not found" % str(fpath))
class ForwardSudoNftables(ForwardNftables):
def __init__(self):
super().__init__(sudo=True)
class ForwardNftablesSnat(ForwardNftables):
def __init__(self):
super().__init__(snat=True)
class ForwardSudoNftablesSnat(ForwardNftables):
def __init__(self):
super().__init__(snat=True, sudo=True)
class ForwardGost(object):
def __init__(self):
self.active = False
self.min_ver = (2, 3)
self.proc = None
self.udp_timeout = 60
if not self._gost_check():
raise OSError("gost >= %s not available" % str(self.min_ver))
def __del__(self):
if self.active:
self.stop_forward()
def _gost_check(self):
try:
output = subprocess.check_output(
["gost", "-V"], stderr=subprocess.STDOUT
).decode()
except (OSError, subprocess.CalledProcessError) as e:
return False
m = re.search(r"gost v?([0-9]+)\.([0-9]+)", output)
if m:
current_ver = tuple(int(v) for v in m.groups())
Logger.debug("fwd-gost: Found gost %s" % str(current_ver))
return current_ver >= self.min_ver
return False
def start_forward(self, ip, port, toip, toport, udp=False):
if (ip, port) == (toip, toport):
raise ValueError("Cannot forward to the same address %s" % addr_to_str((ip, port)))
proto = "udp" if udp else "tcp"
Logger.debug("fwd-gost: Starting gost %s forward to %s" % (
addr_to_uri((ip, port), udp=udp), addr_to_uri((toip, toport), udp=udp)
))
gost_arg = "-L=%s://:%d/%s:%d" % (proto, port, toip, toport)
if udp:
gost_arg += "?ttl=%ds" % self.udp_timeout
self.proc = subprocess.Popen(["gost", gost_arg])
time.sleep(1)
if self.proc.poll() is not None:
raise OSError("gost exited too quickly")
self.active = True
def stop_forward(self):
Logger.debug("fwd-gost: Stopping gost")
if self.proc and self.proc.returncode is not None:
return
self.proc.terminate()
self.active = False
class ForwardSocat(object):
def __init__(self):
self.active = False
self.min_ver = (1, 7, 2)
self.proc = None
self.udp_timeout = 60
self.max_children = 128
if not self._socat_check():
raise OSError("socat >= %s not available" % str(self.min_ver))
def __del__(self):
if self.active:
self.stop_forward()
def _socat_check(self):
try:
output = subprocess.check_output(
["socat", "-V"], stderr=subprocess.STDOUT
).decode()
except (OSError, subprocess.CalledProcessError) as e:
return False
m = re.search(r"socat version ([0-9]+)\.([0-9]+)\.([0-9]+)", output)
if m:
current_ver = tuple(int(v) for v in m.groups())
Logger.debug("fwd-socat: Found socat %s" % str(current_ver))
return current_ver >= self.min_ver
return False
def start_forward(self, ip, port, toip, toport, udp=False):
if (ip, port) == (toip, toport):
raise ValueError("Cannot forward to the same address %s" % addr_to_str((ip, port)))
proto = "UDP" if udp else "TCP"
Logger.debug("fwd-socat: Starting socat %s forward to %s" % (
addr_to_uri((ip, port), udp=udp), addr_to_uri((toip, toport), udp=udp)
))
if udp:
socat_cmd = ["socat", "-T%d" % self.udp_timeout]
else:
socat_cmd = ["socat"]
self.proc = subprocess.Popen(socat_cmd + [
"%s4-LISTEN:%d,reuseaddr,fork,max-children=%d" % (proto, port, self.max_children),
"%s4:%s:%d" % (proto, toip, toport)
])
time.sleep(1)
if self.proc.poll() is not None:
raise OSError("socat exited too quickly")
self.active = True
def stop_forward(self):
Logger.debug("fwd-socat: Stopping socat")
if self.proc and self.proc.returncode is not None:
return
self.proc.terminate()
self.active = False
class ForwardSocket(object):
def __init__(self):
self.active = False
self.sock = None
self.sock_type = None
self.outbound_addr = None
self.buff_size = 8192
self.udp_timeout = 60
self.max_threads = 128
def __del__(self):
if self.active:
self.stop_forward()
def start_forward(self, ip, port, toip, toport, udp=False):
if (ip, port) == (toip, toport):
raise ValueError("Cannot forward to the same address %s" % addr_to_str((ip, port)))
self.sock_type = socket.SOCK_DGRAM if udp else socket.SOCK_STREAM
self.sock = socket.socket(socket.AF_INET, self.sock_type)
socket_set_opt(
self.sock,
reuse = True,
bind_addr = ("", port)
)
self.outbound_addr = toip, toport
Logger.debug("fwd-socket: Starting socket %s forward to %s" % (
addr_to_uri((ip, port), udp=udp), addr_to_uri((toip, toport), udp=udp)
))
if udp:
th = start_daemon_thread(self._socket_udp_recvfrom)
else:
th = start_daemon_thread(self._socket_tcp_listen)
time.sleep(1)
if not th.is_alive():
raise OSError("Socket thread exited too quickly")
self.active = True
def _socket_tcp_listen(self):
self.sock.listen(5)
while True:
try:
sock_inbound, _ = self.sock.accept()
except (OSError, socket.error) as ex:
if not closed_socket_ex(ex):
Logger.error("fwd-socket: socket listening thread is exiting: %s" % ex)
return
sock_outbound = socket.socket(socket.AF_INET, self.sock_type)
try:
sock_outbound.settimeout(3)
sock_outbound.connect(self.outbound_addr)
sock_outbound.settimeout(None)
if threading.active_count() >= self.max_threads:
raise OSError("Too many threads")
start_daemon_thread(self._socket_tcp_forward, args=(sock_inbound, sock_outbound))
start_daemon_thread(self._socket_tcp_forward, args=(sock_outbound, sock_inbound))
except (OSError, socket.error) as ex:
Logger.error("fwd-socket: cannot forward port: %s" % ex)
sock_inbound.close()
sock_outbound.close()
continue
def _socket_tcp_forward(self, sock_to_recv, sock_to_send):
try:
while sock_to_recv.fileno() != -1:
buff = sock_to_recv.recv(self.buff_size)
if buff and sock_to_send.fileno() != -1:
sock_to_send.sendall(buff)
else:
sock_to_recv.close()
sock_to_send.close()
return
except (OSError, socket.error) as ex:
if not closed_socket_ex(ex):
Logger.error("fwd-socket: socket forwarding thread is exiting: %s" % ex)
sock_to_recv.close()
sock_to_send.close()
return
def _socket_udp_recvfrom(self):
outbound_socks = {}
while True:
try:
buff, addr = self.sock.recvfrom(self.buff_size)
s = outbound_socks.get(addr)
except (OSError, socket.error) as ex:
if not closed_socket_ex(ex):
Logger.error("fwd-socket: socket recvfrom thread is exiting: %s" % ex)
return
try:
if not s:
s = outbound_socks[addr] = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(self.udp_timeout)
s.connect(self.outbound_addr)
if threading.active_count() >= self.max_threads:
raise OSError("Too many threads")
start_daemon_thread(self._socket_udp_send, args=(self.sock, s, addr))
if buff:
s.send(buff)
else:
s.close()
del outbound_socks[addr]
except (OSError, socket.error):
if addr in outbound_socks:
outbound_socks[addr].close()
del outbound_socks[addr]
continue
def _socket_udp_send(self, server_sock, outbound_sock, client_addr):
try:
while outbound_sock.fileno() != -1:
buff = outbound_sock.recv(self.buff_size)
if buff:
server_sock.sendto(buff, client_addr)
else:
outbound_sock.close()
except (OSError, socket.error) as ex:
if not closed_socket_ex(ex):
Logger.error("fwd-socket: socket send thread is exiting: %s" % ex)
outbound_sock.close()
return
def stop_forward(self):
Logger.debug("fwd-socket: Stopping socket")
self.sock.close()
self.active = False
class UPnPService(object):
def __init__(self, bind_ip = None, interface = None):
self.service_type = None
self.service_id = None
self.scpd_url = None
self.control_url = None
self.eventsub_url = None
self._sock_timeout = 3
self._bind_ip = bind_ip
self._bind_interface = interface
def __repr__(self):
return "" % (
repr(self.service_type), repr(self.service_id)
)
def is_valid(self):
if self.service_type and self.service_id and self.control_url:
return True
return False
def is_forward(self):
if self.service_type in (
"urn:schemas-upnp-org:service:WANIPConnection:1",
"urn:schemas-upnp-org:service:WANIPConnection:2",
"urn:schemas-upnp-org:service:WANPPPConnection:1"
) and self.service_id and self.control_url:
return True
return False
def forward_port(self, host, port, dest_host, dest_port, udp=False, duration=0):
if not self.is_forward():
raise NotImplementedError("Unsupported service type: %s" % self.service_type)
proto = "UDP" if udp else "TCP"
ctl_hostname, ctl_port, ctl_path = split_url(self.control_url)
descpt = "Natter"
content = (
"\r\n"
"\r\n"
" \r\n"
" \r\n"
" %s\r\n"
" %s\r\n"
" %s\r\n"
" %s\r\n"
" %s\r\n"
" 1\r\n"
" %s\r\n"
" %d\r\n"
" \r\n"
" \r\n"
"\r\n" % (
self.service_type, host, port, proto, dest_port, dest_host, descpt, duration
)
)
content_len = len(content.encode())
data = (
"POST %s HTTP/1.1\r\n"
"Host: %s:%d\r\n"
"User-Agent: curl/8.0.0 (Natter)\r\n"
"Accept: */*\r\n"
"SOAPAction: \"%s#AddPortMapping\"\r\n"
"Content-Type: text/xml\r\n"
"Content-Length: %d\r\n"
"Connection: close\r\n"
"\r\n"
"%s\r\n" % (ctl_path, ctl_hostname, ctl_port, self.service_type, content_len, content)
).encode()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
socket_set_opt(
sock,
bind_addr = (self._bind_ip, 0) if self._bind_ip else None,
interface = self._bind_interface,
timeout = self._sock_timeout
)
sock.connect((ctl_hostname, ctl_port))
sock.sendall(data)
response = b""
while True:
buff = sock.recv(4096)
if not buff:
break
response += buff
sock.close()
r = response.decode("utf-8", "ignore")
errno = errmsg = ""
m = re.search(r"([^<]*?)", r)
if m:
errno = m.group(1).strip()
m = re.search(r"([^<]*?)", r)
if m:
errmsg = m.group(1).strip()
if errno or errmsg:
Logger.error("upnp: Error from device %s: [%s] %s" % (self.ipaddr, errno, errmsg))
return False
return True
class UPnPDevice(object):
def __init__(self, ipaddr, xml_urls, bind_ip = None, interface = None):
self.ipaddr = ipaddr
self.xml_urls = xml_urls
self.services = []
self.forward_srv = None
self._sock_timeout = 3
self._bind_ip = bind_ip
self._bind_interface = interface
def __repr__(self):
return "" % (
repr(self.ipaddr),
)
def _load_services(self):
if self.services:
return
services_d = {} # service_id => UPnPService()
for url in self.xml_urls:
sd = self._get_srv_dict(url)
services_d.update(sd)
self.services.extend(services_d.values())
for srv in self.services:
if srv.is_forward():
self.forward_srv = srv
break
def _http_get(self, url):
hostname, port, path = split_url(url)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
socket_set_opt(
sock,
bind_addr = (self._bind_ip, 0) if self._bind_ip else None,
interface = self._bind_interface,
timeout = self._sock_timeout
)
sock.connect((hostname, port))
data = (
"GET %s HTTP/1.1\r\n"
"Host: %s\r\n"
"User-Agent: curl/8.0.0 (Natter)\r\n"
"Accept: */*\r\n"
"Connection: close\r\n"
"\r\n" % (path, hostname)
).encode()
sock.sendall(data)
response = b""
while True:
buff = sock.recv(4096)
if not buff:
break
response += buff
sock.close()
if not response.startswith(b"HTTP/"):
raise ValueError("Invalid response from HTTP server")
s = response.split(b"\r\n\r\n", 1)
if len(s) != 2:
raise ValueError("Invalid response from HTTP server")
return s[1]
def _get_srv_dict(self, url):
xmlcontent = self._http_get(url).decode("utf-8", "ignore")
services_d = {}
srv_str_l = re.findall(r"([\s\S]+?)", xmlcontent)
for srv_str in srv_str_l:
srv = UPnPService(bind_ip=self._bind_ip, interface=self._bind_interface)
m = re.search(r"([^<]*?)", srv_str)
if m:
srv.service_type = m.group(1).strip()
m = re.search(r"([^<]*?)", srv_str)
if m:
srv.service_id = m.group(1).strip()
m = re.search(r"([^<]*?)", srv_str)
if m:
srv.scpd_url = full_url(m.group(1).strip(), url)
m = re.search(r"([^<]*?)", srv_str)
if m:
srv.control_url = full_url(m.group(1).strip(), url)
m = re.search(r"([^<]*?)", srv_str)
if m:
srv.eventsub_url = full_url(m.group(1).strip(), url)
if srv.is_valid():
services_d[srv.service_id] = srv
return services_d
class UPnPClient(object):
def __init__(self, bind_ip = None, interface = None):
self.ssdp_addr = ("239.255.255.250", 1900)
self.router = None
self._sock_timeout = 1
self._fwd_host = None
self._fwd_port = None
self._fwd_dest_host = None
self._fwd_dest_port = None
self._fwd_udp = False
self._fwd_duration = 0
self._fwd_started = False
self._bind_ip = bind_ip
self._bind_interface = interface
def discover_router(self):
router_l = []
try:
devs = self._discover()
for dev in devs:
if dev.forward_srv:
router_l.append(dev)
except (OSError, socket.error) as ex:
Logger.error("upnp: failed to discover router: %s" % ex)
if not router_l:
self.router = None
elif len(router_l) > 1:
Logger.warning("upnp: multiple routers found: %s" % (router_l,))
self.router = router_l[0]
else:
self.router = router_l[0]
return self.router
def _discover(self):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
socket_set_opt(
sock,
reuse = True,
bind_addr = (self._bind_ip, 0) if self._bind_ip else None,
interface = self._bind_interface,
timeout = self._sock_timeout
)
dat01 = (
"M-SEARCH * HTTP/1.1\r\n"
"ST: ssdp:all\r\n"
"MX: 2\r\n"
"MAN: \"ssdp:discover\"\r\n"
"HOST: %s:%d\r\n"
"\r\n" % self.ssdp_addr
).encode()
dat02 = (
"M-SEARCH * HTTP/1.1\r\n"
"ST: upnp:rootdevice\r\n"
"MX: 2\r\n"
"MAN: \"ssdp:discover\"\r\n"
"HOST: %s:%d\r\n"
"\r\n" % self.ssdp_addr
).encode()
sock.sendto(dat01, self.ssdp_addr)
sock.sendto(dat02, self.ssdp_addr)
upnp_urls_d = {}
while True:
try:
buff, addr = sock.recvfrom(4096)
m = re.search(r"LOCATION: *(http://[^\[]\S+)\s+", buff.decode("utf-8"))
if not m:
continue
ipaddr = addr[0]
location = m.group(1)
Logger.debug("upnp: Got URL %s" % location)
if ipaddr in upnp_urls_d:
upnp_urls_d[ipaddr].add(location)
else:
upnp_urls_d[ipaddr] = set([location])
except socket.timeout:
break
devs = []
for ipaddr, urls in upnp_urls_d.items():
d = UPnPDevice(ipaddr, urls, bind_ip=self._bind_ip, interface=self._bind_interface)
d._load_services()
devs.append(d)
return devs
def forward(self, host, port, dest_host, dest_port, udp=False, duration=0):
if not self.router:
raise RuntimeError("No router is available")
self.router.forward_srv.forward_port(host, port, dest_host, dest_port, udp, duration)
self._fwd_host = host
self._fwd_port = port
self._fwd_dest_host = dest_host
self._fwd_dest_port = dest_port
self._fwd_udp = udp
self._fwd_duration = duration
self._fwd_started = True
def renew(self):
if not self._fwd_started:
raise RuntimeError("UPnP forward not started")
self.router.forward_srv.forward_port(
self._fwd_host, self._fwd_port, self._fwd_dest_host,
self._fwd_dest_port, self._fwd_udp, self._fwd_duration
)
Logger.debug("upnp: OK")
class NatterExitException(Exception):
pass
class NatterRetryException(Exception):
pass
def socket_set_opt(sock, reuse=False, bind_addr=None, interface=None, timeout=-1):
if reuse:
if hasattr(socket, "SO_REUSEADDR"):
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
if interface is not None:
if hasattr(socket, "SO_BINDTODEVICE"):
sock.setsockopt(
socket.SOL_SOCKET, socket.SO_BINDTODEVICE, interface.encode() + b"\0"
)
else:
raise RuntimeError("Binding to an interface is not supported on your platform.")
if bind_addr is not None:
sock.bind(bind_addr)
if timeout != -1:
sock.settimeout(timeout)
return sock
def start_daemon_thread(target, args=()):
th = threading.Thread(target=target, args=args)
th.daemon = True
th.start()
return th
def closed_socket_ex(ex):
if not hasattr(ex, "errno"):
return False
if hasattr(errno, "ECONNABORTED") and ex.errno == errno.ECONNABORTED:
return True
if hasattr(errno, "EBADFD") and ex.errno == errno.EBADFD:
return True
if hasattr(errno, "EBADF") and ex.errno == errno.EBADF:
return True
if hasattr(errno, "WSAEBADF") and ex.errno == errno.WSAEBADF:
return True
if hasattr(errno, "WSAEINTR") and ex.errno == errno.WSAEINTR:
return True
return False
def fix_codecs(codec_list = ["utf-8", "idna"]):
missing_codecs = []
for codec_name in codec_list:
try:
codecs.lookup(codec_name)
except LookupError:
missing_codecs.append(codec_name.lower())
def search_codec(name):
if name.lower() in missing_codecs:
return codecs.CodecInfo(codecs.ascii_encode, codecs.ascii_decode, name="ascii")
if missing_codecs:
codecs.register(search_codec)
def check_docker_network():
if not sys.platform.startswith("linux"):
return
if not os.path.exists("/.dockerenv"):
return
if not os.path.isfile("/sys/class/net/eth0/address"):
return
fo = open("/sys/class/net/eth0/address", "r")
macaddr = fo.read().strip()
fo.close()
hostname = socket.gethostname()
try:
ipaddr = socket.gethostbyname(hostname)
except socket.gaierror:
Logger.warning("check-docket-network: Cannot resolve hostname `%s`" % hostname)
return
docker_macaddr = "02:42:" + ":".join(["%02x" % int(x) for x in ipaddr.split(".")])
if macaddr == docker_macaddr:
raise RuntimeError("Docker's `--net=host` option is required.")
if not os.path.isfile("/proc/sys/kernel/osrelease"):
return
fo = open("/proc/sys/kernel/osrelease", "r")
uname_r = fo.read().strip()
fo.close()
uname_r_sfx = uname_r.rsplit("-").pop()
if uname_r_sfx.lower() in ["linuxkit", "wsl2"] and hostname.lower() == "docker-desktop":
raise RuntimeError("Network from Docker Desktop is not supported.")
def split_url(url):
if not url.startswith("http://"):
raise ValueError("Unsupported URL: %s" % url)
host, rpath = url.split("http://", 1)[1].split("/", 1)
if '[' in host:
raise ValueError("Unsupported URL: %s" % url)
path = "/" + rpath
if ":" in host:
hostname, port_ = host.split(":")
port = int(port_)
else:
hostname = host
port = 80
return hostname, port, path
def full_url(u, refurl):
if not u.startswith("/"):
return u
hostname, port, _ = split_url(refurl)
return "http://%s:%d" % (hostname, port) + u
def addr_to_str(addr):
return "%s:%d" % addr
def addr_to_uri(addr, udp=False):
if udp:
return "udp://%s:%d" % addr
else:
return "tcp://%s:%d" % addr
def validate_ip(s, err=True):
try:
socket.inet_aton(s)
return True
except (OSError, socket.error):
if err:
raise ValueError("Invalid IP address: %s" % s)
return False
def validate_port(s, err=True):
if str(s).isdigit() and int(s) in range(65536):
return True
if err:
raise ValueError("Invalid port number: %s" % s)
return False
def validate_addr_str(s, err=True):
l = str(s).split(":", 1)
if len(l) == 1:
return True
return validate_port(l[1], err)
def validate_positive(s, err=True):
if str(s).isdigit() and int(s) > 0:
return True
if err:
raise ValueError("Not a positive integer: %s" % s)
return False
def validate_filepath(s, err=True):
if os.path.isfile(s):
return True
if err:
raise ValueError("File not found: %s" % s)
return False
def ip_normalize(ipaddr):
return socket.inet_ntoa(socket.inet_aton(ipaddr))
def natter_main(show_title = True):
argp = argparse.ArgumentParser(
description="Expose your port behind full-cone NAT to the Internet.", add_help=False
)
group = argp.add_argument_group("options")
group.add_argument(
"--version", "-V", action="version", version="Natter %s" % __version__,
help="show the version of Natter and exit"
)
group.add_argument(
"--help", action="help", help="show this help message and exit"
)
group.add_argument(
"-v", action="store_true", help="verbose mode, printing debug messages"
)
group.add_argument(
"-q", action="store_true", help="exit when mapped address is changed"
)
group.add_argument(
"-u", action="store_true", help="UDP mode"
)
group.add_argument(
"-U", action="store_true", help="enable UPnP/IGD discovery"
)
group.add_argument(
"-k", type=int, metavar="", default=15,
help="seconds between each keep-alive"
)
group.add_argument(
"-s", metavar="", action="append",
help="hostname or address to STUN server"
)
group.add_argument(
"-h", type=str, metavar="", default=None,
help="hostname or address to keep-alive server"
)
group.add_argument(
"-e", type=str, metavar="", default=None,
help="script path for notifying mapped address"
)
group = argp.add_argument_group("bind options")
group.add_argument(
"-i", type=str, metavar="", default="0.0.0.0",
help="network interface name or IP to bind"
)
group.add_argument(
"-b", type=int, metavar="", default=0,
help="port number to bind"
)
group = argp.add_argument_group("forward options")
group.add_argument(
"-m", type=str, metavar="", default=None,
help="forward method, common values are 'iptables', 'nftables', "
"'socat', 'gost' and 'socket'"
)
group.add_argument(
"-t", type=str, metavar="", default="0.0.0.0",
help="IP address of forward target"
)
group.add_argument(
"-p", type=int, metavar="", default=0,
help="port number of forward target"
)
group.add_argument(
"-r", action="store_true", help="keep retrying until the port of forward target is open"
)
args = argp.parse_args()
verbose = args.v
udp_mode = args.u
upnp_enabled = args.U
interval = args.k
stun_list = args.s
keepalive_srv = args.h
notify_sh = args.e
bind_ip = args.i
bind_interface = None
bind_port = args.b
method = args.m
to_ip = args.t
to_port = args.p
keep_retry = args.r
exit_when_changed = args.q
sys.tracebacklimit = 0
if verbose:
sys.tracebacklimit = None
Logger.set_level(Logger.DEBUG)
validate_positive(interval)
if stun_list:
for stun_srv in stun_list:
validate_addr_str(stun_srv)
validate_addr_str(keepalive_srv)
if notify_sh:
validate_filepath(notify_sh)
if not validate_ip(bind_ip, err=False):
bind_interface = bind_ip
bind_ip = "0.0.0.0"
validate_port(bind_port)
validate_ip(to_ip)
validate_port(to_port)
# Normalize IPv4 in dotted-decimal notation
# e.g. 10.1 -> 10.0.0.1
bind_ip = ip_normalize(bind_ip)
to_ip = ip_normalize(to_ip)
if not stun_list:
stun_list = [
"fwa.lifesizecloud.com",
"global.turn.twilio.com",
"turn.cloudflare.com",
"stun.isp.net.au",
"stun.nextcloud.com",
"stun.freeswitch.org",
"stun.voip.blackberry.com",
"stunserver.stunprotocol.org",
"stun.sipnet.com",
"stun.radiojar.com",
"stun.sonetel.com"
]
if not udp_mode:
stun_list = [
"turn.cloud-rtc.com:80"
] + stun_list
else:
stun_list = [
"stun.miwifi.com",
"stun.chat.bilibili.com",
"stun.hitv.com",
"stun.cdnbye.com",
"stun.douyucdn.cn:18000"
] + stun_list
if not keepalive_srv:
keepalive_srv = "www.baidu.com"
if udp_mode:
keepalive_srv = "119.29.29.29"
stun_srv_list = []
for item in stun_list:
l = item.split(":", 2) + ["3478"]
stun_srv_list.append((l[0], int(l[1])),)
if udp_mode:
l = keepalive_srv.split(":", 2) + ["53"]
keepalive_host, keepalive_port = l[0], int(l[1])
else:
l = keepalive_srv.split(":", 2) + ["80"]
keepalive_host, keepalive_port = l[0], int(l[1])
# forward method defaults
if not method:
if to_ip == "0.0.0.0" and to_port == 0 and \
bind_ip == "0.0.0.0" and bind_port == 0 and bind_interface is None:
method = "test"
elif to_ip == "0.0.0.0" and to_port == 0:
method = "none"
else:
method = "socket"
if method == "none":
ForwardImpl = ForwardNone
elif method == "test":
ForwardImpl = ForwardTestServer
elif method == "iptables":
ForwardImpl = ForwardIptables
elif method == "sudo-iptables":
ForwardImpl = ForwardSudoIptables
elif method == "iptables-snat":
ForwardImpl = ForwardIptablesSnat
elif method == "sudo-iptables-snat":
ForwardImpl = ForwardSudoIptablesSnat
elif method == "nftables":
ForwardImpl = ForwardNftables
elif method == "sudo-nftables":
ForwardImpl = ForwardSudoNftables
elif method == "nftables-snat":
ForwardImpl = ForwardNftablesSnat
elif method == "sudo-nftables-snat":
ForwardImpl = ForwardSudoNftablesSnat
elif method == "socat":
ForwardImpl = ForwardSocat
elif method == "gost":
ForwardImpl = ForwardGost
elif method == "socket":
ForwardImpl = ForwardSocket
else:
raise ValueError("Unknown method name: %s" % method)
#
# Natter
#
if show_title:
Logger.info("Natter v%s" % __version__)
if len(sys.argv) == 1:
Logger.info("Tips: Use `--help` to see help messages")
check_docker_network()
forwarder = ForwardImpl()
port_test = PortTest()
stun = StunClient(stun_srv_list, bind_ip, bind_port, udp=udp_mode, interface=bind_interface)
natter_addr, outer_addr = stun.get_mapping()
# set actual ip and port for keep-alive socket to bind, instead of zero
bind_ip, bind_port = natter_addr
keep_alive = KeepAlive(keepalive_host, keepalive_port, bind_ip, bind_port, udp=udp_mode, interface=bind_interface)
keep_alive.keep_alive()
# get the mapped address again after the keep-alive connection is established
outer_addr_prev = outer_addr
natter_addr, outer_addr = stun.get_mapping()
if outer_addr != outer_addr_prev:
Logger.warning("Network is unstable, or not full cone")
# set actual ip of localhost for correct forwarding
if socket.inet_aton(to_ip) in [socket.inet_aton("127.0.0.1"), socket.inet_aton("0.0.0.0")]:
to_ip = natter_addr[0]
# if not specified, the target port is set to be the same as the outer port
if not to_port:
to_port = outer_addr[1]
# some exceptions: ForwardNone and ForwardTestServer are not real forward methods,
# so let target ip and port equal to natter's
if ForwardImpl in (ForwardNone, ForwardTestServer):
to_ip, to_port = natter_addr
to_addr = (to_ip, to_port)
forwarder.start_forward(natter_addr[0], natter_addr[1], to_addr[0], to_addr[1], udp=udp_mode)
NatterExit.set_atexit(forwarder.stop_forward)
# UPnP
upnp = None
if upnp_enabled:
upnp = UPnPClient(bind_ip=natter_addr[0], interface=bind_interface)
Logger.info()
Logger.info("Scanning UPnP Devices...")
upnp_router = upnp.discover_router()
if upnp_router:
Logger.info("[UPnP] Found router %s" % upnp_router.ipaddr)
upnp.forward("", bind_port, bind_ip, bind_port, udp=udp_mode, duration=interval*3)
# Display route information
Logger.info()
route_str = ""
if ForwardImpl not in (ForwardNone, ForwardTestServer):
route_str += "%s <--%s--> " % (addr_to_uri(to_addr, udp=udp_mode), method)
route_str += "%s <--Natter--> %s" % (
addr_to_uri(natter_addr, udp=udp_mode), addr_to_uri(outer_addr, udp=udp_mode)
)
Logger.info(route_str)
Logger.info()
# Test mode notice
if ForwardImpl == ForwardTestServer:
Logger.info("Test mode in on.")
Logger.info("Please check [ %s://%s ]" % ("udp" if udp_mode else "http", addr_to_str(outer_addr)))
Logger.info()
# Call notification script
if notify_sh:
protocol = "udp" if udp_mode else "tcp"
inner_ip, inner_port = to_addr if method else natter_addr
outer_ip, outer_port = outer_addr
Logger.info("Calling script: %s" % notify_sh)
subprocess.call([
os.path.abspath(notify_sh), protocol, str(inner_ip), str(inner_port), str(outer_ip), str(outer_port)
], shell=False)
# Display check results, TCP only
if not udp_mode:
ret1 = port_test.test_lan(to_addr, info=True)
ret2 = port_test.test_lan(natter_addr, info=True)
ret3 = port_test.test_lan(outer_addr, source_ip=natter_addr[0], interface=bind_interface, info=True)
ret4 = port_test.test_wan(outer_addr, source_ip=natter_addr[0], interface=bind_interface, info=True)
if ret1 == -1:
Logger.warning("!! Target port is closed !!")
elif ret1 == 1 and ret3 == ret4 == -1:
Logger.warning("!! Hole punching failed !!")
elif ret3 == 1 and ret4 == -1:
Logger.warning("!! You may be behind a firewall !!")
Logger.info()
# retry
if keep_retry and ret1 == -1:
Logger.info("Retry after %d seconds..." % interval)
time.sleep(interval)
forwarder.stop_forward()
raise NatterRetryException("Target port is closed")
#
# Main loop
#
need_recheck = False
cnt = 0
while True:
# force recheck every 20th loop
cnt = (cnt + 1) % 20
if cnt == 0:
need_recheck = True
if need_recheck:
Logger.debug("Start recheck")
need_recheck = False
# check LAN port first
if udp_mode or port_test.test_lan(outer_addr, source_ip=natter_addr[0], interface=bind_interface) == -1:
# then check through STUN
_, outer_addr_curr = stun.get_mapping()
if outer_addr_curr != outer_addr:
forwarder.stop_forward()
# exit or retry
if exit_when_changed:
Logger.info("Natter is exiting because mapped address has changed")
raise NatterExitException("Mapped address has changed")
raise NatterRetryException("Mapped address has changed")
# end of recheck
ts = time.time()
try:
keep_alive.keep_alive()
except (OSError, socket.error) as ex:
if udp_mode:
Logger.debug("keep-alive: UDP response not received: %s" % ex)
else:
Logger.error("keep-alive: connection broken: %s" % ex)
keep_alive.reset()
need_recheck = True
if upnp:
try:
upnp.renew()
except (OSError, socket.error) as ex:
Logger.error("upnp: failed to renew upnp: %s" % ex)
sleep_sec = interval - (time.time() - ts)
if sleep_sec > 0:
time.sleep(sleep_sec)
def main():
signal.signal(signal.SIGTERM, lambda s,f:exit(143))
fix_codecs()
show_title = True
while True:
try:
natter_main(show_title)
except NatterRetryException:
pass
except (NatterExitException, KeyboardInterrupt):
sys.exit()
show_title = False
if __name__ == "__main__":
main()