mirror of
https://github.com/MikeWang000000/Natter.git
synced 2025-09-26 21:55:51 +08:00
1831 lines
63 KiB
Python
Executable File
1831 lines
63 KiB
Python
Executable File
#!/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 <http://www.gnu.org/licenses/>.
|
|
'''
|
|
|
|
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 = "<html><body><h1>It works!</h1><hr/>Natter</body></html>"
|
|
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 "<UPnPService service_type=%s, service_id=%s>" % (
|
|
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 = (
|
|
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n"
|
|
"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"\r\n"
|
|
" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">\r\n"
|
|
" <s:Body>\r\n"
|
|
" <m:AddPortMapping xmlns:m=\"%s\">\r\n"
|
|
" <NewRemoteHost>%s</NewRemoteHost>\r\n"
|
|
" <NewExternalPort>%s</NewExternalPort>\r\n"
|
|
" <NewProtocol>%s</NewProtocol>\r\n"
|
|
" <NewInternalPort>%s</NewInternalPort>\r\n"
|
|
" <NewInternalClient>%s</NewInternalClient>\r\n"
|
|
" <NewEnabled>1</NewEnabled>\r\n"
|
|
" <NewPortMappingDescription>%s</NewPortMappingDescription>\r\n"
|
|
" <NewLeaseDuration>%d</NewLeaseDuration>\r\n"
|
|
" </m:AddPortMapping>\r\n"
|
|
" </s:Body>\r\n"
|
|
"</s:Envelope>\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"<errorCode\s*>([^<]*?)</errorCode\s*>", r)
|
|
if m:
|
|
errno = m.group(1).strip()
|
|
m = re.search(r"<errorDescription\s*>([^<]*?)</errorDescription\s*>", 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 "<UPnPDevice ipaddr=%s>" % (
|
|
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"<service\s*>([\s\S]+?)</service\s*>", xmlcontent)
|
|
for srv_str in srv_str_l:
|
|
srv = UPnPService(bind_ip=self._bind_ip, interface=self._bind_interface)
|
|
m = re.search(r"<serviceType\s*>([^<]*?)</serviceType\s*>", srv_str)
|
|
if m:
|
|
srv.service_type = m.group(1).strip()
|
|
m = re.search(r"<serviceId\s*>([^<]*?)</serviceId\s*>", srv_str)
|
|
if m:
|
|
srv.service_id = m.group(1).strip()
|
|
m = re.search(r"<SCPDURL\s*>([^<]*?)</SCPDURL\s*>", srv_str)
|
|
if m:
|
|
srv.scpd_url = full_url(m.group(1).strip(), url)
|
|
m = re.search(r"<controlURL\s*>([^<]*?)</controlURL\s*>", srv_str)
|
|
if m:
|
|
srv.control_url = full_url(m.group(1).strip(), url)
|
|
m = re.search(r"<eventSubURL\s*>([^<]*?)</eventSubURL\s*>", 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="<interval>", default=15,
|
|
help="seconds between each keep-alive"
|
|
)
|
|
group.add_argument(
|
|
"-s", metavar="<address>", action="append",
|
|
help="hostname or address to STUN server"
|
|
)
|
|
group.add_argument(
|
|
"-h", type=str, metavar="<address>", default=None,
|
|
help="hostname or address to keep-alive server"
|
|
)
|
|
group.add_argument(
|
|
"-e", type=str, metavar="<path>", default=None,
|
|
help="script path for notifying mapped address"
|
|
)
|
|
group = argp.add_argument_group("bind options")
|
|
group.add_argument(
|
|
"-i", type=str, metavar="<interface>", default="0.0.0.0",
|
|
help="network interface name or IP to bind"
|
|
)
|
|
group.add_argument(
|
|
"-b", type=int, metavar="<port>", default=0,
|
|
help="port number to bind"
|
|
)
|
|
group = argp.add_argument_group("forward options")
|
|
group.add_argument(
|
|
"-m", type=str, metavar="<method>", default=None,
|
|
help="forward method, common values are 'iptables', 'nftables', "
|
|
"'socat', 'gost' and 'socket'"
|
|
)
|
|
group.add_argument(
|
|
"-t", type=str, metavar="<address>", default="0.0.0.0",
|
|
help="IP address of forward target"
|
|
)
|
|
group.add_argument(
|
|
"-p", type=int, metavar="<port>", 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()
|