#!/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 shlex
import atexit
import codecs
import random
import signal
import socket
import struct
import argparse
import threading
import subprocess
__version__ = "2.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, info=False):
print_status = Logger.info if info else Logger.debug
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1)
try:
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, 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)
if ret01 == 1:
print_status("WAN > %-21s [ OPEN ]" % addr_to_str(addr))
return 1
ret02 = self._test_transmission(addr[1], source_ip)
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):
# repo: https://github.com/mpolden/echoip
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(8)
try:
if source_ip:
sock.bind((source_ip, 0))
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)
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):
# repo: https://github.com/transmission/portcheck
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.settimeout(8)
if source_ip:
sock.bind((source_ip, 0))
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 avaliable 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 = new_socket_reuse(socket.AF_INET, socket_type)
sock.settimeout(3)
if self.interface is not None:
if not hasattr(socket, "SO_BINDTODEVICE"):
raise RuntimeError(
"Binding to an interface is not supported by current version of "
"Python or operating system"
)
sock.setsockopt(
socket.SOL_SOCKET, socket.SO_BINDTODEVICE, self.interface.encode() + b"\0"
)
sock.bind((self.source_host, self.source_port))
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, udp=False):
self.sock = None
self.host = host
self.port = port
self.source_host = source_host
self.source_port = source_port
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 = new_socket_reuse(socket.AF_INET, sock_type)
sock.bind((self.source_host, self.source_port))
sock.settimeout(3)
sock.connect((self.host, self.port))
Logger.debug("keep-alive: Connected to host %s" % (
addr_to_uri((self.host, self.port), udp=self.udp)
))
self.sock = sock
if self.reconn and not self.udp:
Logger.info("keep-alive: connection restored")
self.reconn = False
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((
"GET /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
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 = new_socket_reuse(socket.AF_INET, self.sock_type)
self.sock.bind(('', port))
Logger.debug("fwd-test: Starting test server at %s" % addr_to_uri((ip, port), udp=udp))
if udp:
start_daemon_thread(self._test_server_run_udp)
else:
start_daemon_thread(self._test_server_run_http)
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 = b"
It works!
Natter"
conn.sendall(
b"HTTP/1.1 200 OK\r\n"
b"Content-Type: text/html\r\n"
b"Content-Length: %d\r\n"
b"Connection: close\r\n"
b"Server: Natter\r\n"
b"\r\n"
b"%s\r\n" % (len(content), content)
)
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.uuid = self._get_uuid4()
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")
rules = subprocess.check_output(
self.iptables_cmd + ["-t", "nat", "--list-rules", "NATTER"]
).decode().splitlines()
rules += subprocess.check_output(
self.iptables_cmd + ["-t", "nat", "--list-rules", "NATTER_SNAT"]
).decode().splitlines()
for rule in rules:
m = re.search(r"NATTER_UUID=([0-9a-f\-]+)", rule)
if not rule.startswith("-A NATTER") or not m:
continue
rule_uuid = m.group(1)
if rule_uuid == self.uuid:
subprocess.check_output(
self.iptables_cmd + ["-t", "nat", "-D"] + shlex.split(rule[2:])
)
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)
))
subprocess.check_output(self.iptables_cmd + [
"-t", "nat",
"-I", "NATTER",
"-p", proto,
"--dst", ip,
"--dport", "%d" % port,
"-j", "DNAT",
"--to-destination", "%s:%d" % (toip, toport),
"-m", "comment", "--comment", "NATTER_UUID=%s" % self.uuid
])
if self.snat:
subprocess.check_output(self.iptables_cmd + [
"-t", "nat",
"-I", "NATTER_SNAT",
"-p", proto,
"--dst", toip,
"--dport", "%d" % toport,
"-j", "SNAT",
"--to-source", ip,
"-m", "comment", "--comment", "NATTER_UUID=%s" % self.uuid
])
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))
def _get_uuid4(self):
fpath = "/proc/sys/kernel/random/uuid"
if os.path.exists(fpath):
fin = open(fpath, "r")
buff = fin.read()
fin.close()
return buff.strip()
else:
return "%08x-%04x-%04x-%04x-%04x%08x" % (
random.getrandbits(32),
random.getrandbits(16),
random.getrandbits(12) | 0x4000,
random.getrandbits(14) | 0x8000,
random.getrandbits(16), random.getrandbits(32)
)
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 ([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 = new_socket_reuse(socket.AF_INET, self.sock_type)
self.sock.bind(("", 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:
start_daemon_thread(self._socket_udp_recvfrom)
else:
start_daemon_thread(self._socket_tcp_listen)
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 NatterExitException(Exception):
pass
class NatterRetryException(Exception):
pass
def new_socket_reuse(family, type):
sock = socket.socket(family, type)
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)
return sock
def start_daemon_thread(target, args=()):
th = threading.Thread(target=target, args=args)
th.daemon = True
th.start()
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
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()
fqdn = socket.getfqdn()
ipaddr = socket.gethostbyname(fqdn)
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 fqdn.lower() == "docker-desktop":
raise RuntimeError("Network from Docker Desktop is not supported.")
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(
"-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
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",
"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",
"stun.voipgate.com"
]
if udp_mode:
stun_list = ["stun.miwifi.com", "stun.qq.com", "stun.chat.bilibili.com"] + stun_list
if not keepalive_srv:
keepalive_srv = "www.baidu.com"
if udp_mode:
keepalive_srv = "8.8.8.8"
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)
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)
# Display route infomation
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, info=True)
ret4 = port_test.test_wan(outer_addr, source_ip=natter_addr[0], 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) == -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
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):
exit()
show_title = False
if __name__ == "__main__":
main()