diff options
-rw-r--r-- | docs/manpage.rst | 9 | ||||
-rw-r--r-- | docs/requirements.rst | 4 | ||||
-rw-r--r-- | requirements-tests.txt | 2 | ||||
-rw-r--r-- | requirements.txt | 1 | ||||
-rwxr-xr-x | setup.py | 1 | ||||
-rw-r--r-- | sshuttle/client.py | 5 | ||||
-rw-r--r-- | sshuttle/hostwatch.py | 79 | ||||
-rw-r--r-- | sshuttle/methods/nat.py | 16 | ||||
-rw-r--r-- | tests/client/test_methods_nat.py | 83 |
9 files changed, 153 insertions, 47 deletions
diff --git a/docs/manpage.rst b/docs/manpage.rst index ead9a16..8770235 100644 --- a/docs/manpage.rst +++ b/docs/manpage.rst @@ -89,6 +89,13 @@ Options few subnets over the VPN, you probably would prefer to keep using your local DNS server for everything else. + :program:`sshuttle` tries to store a cache of the hostnames in + ~/.sshuttle.hosts on the remote host. Similarly, it tries to read + the file when you later reconnect to the host with --auto-hosts + enabled to quickly populate the host list. When troubleshooting + this feature, try removing this file on the remote host when + sshuttle is not running. + .. option:: -N, --auto-nets In addition to the subnets provided on the command @@ -178,7 +185,7 @@ Options A comma-separated list of hostnames to use to initialize the :option:`--auto-hosts` scan algorithm. - :option:`--auto-hosts` does things like poll local SMB servers + :option:`--auto-hosts` does things like poll netstat output for lists of local hostnames, but can speed things up if you use this option to give it a few names to start from. diff --git a/docs/requirements.rst b/docs/requirements.rst index f4499ea..fb04178 100644 --- a/docs/requirements.rst +++ b/docs/requirements.rst @@ -15,10 +15,12 @@ Supports: * IPv4 TCP * IPv4 DNS +* IPv6 TCP +* IPv6 DNS Requires: -* iptables DNAT, REDIRECT, and ttl modules. +* iptables DNAT, REDIRECT, and ttl modules. ip6tables for IPv6. Linux with nft method ~~~~~~~~~~~~~~~~~~~~~ diff --git a/requirements-tests.txt b/requirements-tests.txt index 9bdcb0e..174dadb 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -1,5 +1,5 @@ -r requirements.txt pytest==6.2.4 -pytest-cov==2.12.0 +pytest-cov==2.12.1 flake8==3.9.2 pyflakes==2.3.1 diff --git a/requirements.txt b/requirements.txt index 48581a1..e5473dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ setuptools-scm==6.0.1 -psutil @@ -63,7 +63,6 @@ setup( }, python_requires='>=3.6', install_requires=[ - 'psutil', ], tests_require=[ 'pytest', diff --git a/sshuttle/client.py b/sshuttle/client.py index 349ad86..ef4f36b 100644 --- a/sshuttle/client.py +++ b/sshuttle/client.py @@ -6,7 +6,6 @@ import subprocess as ssubprocess import os import sys import platform -import psutil import sshuttle.helpers as helpers import sshuttle.ssnet as ssnet @@ -650,7 +649,9 @@ def _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename, # poll() won't tell us when process exited since the # process is no longer our child (it returns 0 all the # time). - if not psutil.pid_exists(serverproc.pid): + try: + os.kill(serverproc.pid, 0) + except OSError: raise Fatal('ssh connection to server (pid %d) exited.' % serverproc.pid) else: diff --git a/sshuttle/hostwatch.py b/sshuttle/hostwatch.py index 7e4d3c5..a016f4f 100644 --- a/sshuttle/hostwatch.py +++ b/sshuttle/hostwatch.py @@ -15,6 +15,8 @@ POLL_TIME = 60 * 15 NETSTAT_POLL_TIME = 30 CACHEFILE = os.path.expanduser('~/.sshuttle.hosts') +# Have we already failed to write CACHEFILE? +CACHE_WRITE_FAILED = False hostnames = {} queue = {} @@ -31,7 +33,10 @@ def _is_ip(s): def write_host_cache(): + """If possible, write our hosts file to disk so future connections + can reuse the hosts that we already found.""" tmpname = '%s.%d.tmp' % (CACHEFILE, os.getpid()) + global CACHE_WRITE_FAILED try: f = open(tmpname, 'wb') for name, ip in sorted(hostnames.items()): @@ -39,7 +44,15 @@ def write_host_cache(): f.close() os.chmod(tmpname, 384) # 600 in octal, 'rw-------' os.rename(tmpname, CACHEFILE) - finally: + CACHE_WRITE_FAILED = False + except (OSError, IOError): + # Write message if we haven't yet or if we get a failure after + # a previous success. + if not CACHE_WRITE_FAILED: + log("Failed to write host cache to temporary file " + "%s and rename it to %s" % (tmpname, CACHEFILE)) + CACHE_WRITE_FAILED = True + try: os.unlink(tmpname) except BaseException: @@ -47,25 +60,34 @@ def write_host_cache(): def read_host_cache(): + """If possible, read the cache file from disk to populate hosts that + were found in a previous sshuttle run.""" try: f = open(CACHEFILE) - except IOError: + except (OSError, IOError): _, e = sys.exc_info()[:2] if e.errno == errno.ENOENT: return else: - raise + log("Failed to read existing host cache file %s on remote host" + % CACHEFILE) + return for line in f: words = line.strip().split(',') if len(words) == 2: (name, ip) = words name = re.sub(r'[^-\w\.]', '-', name).strip() + # Remove characters that shouldn't be in IP ip = re.sub(r'[^0-9.]', '', ip).strip() if name and ip: found_host(name, ip) def found_host(name, ip): + """The provided name maps to the given IP. Add the host to the + hostnames list, send the host to the sshuttle client via + stdout, and write the host to the cache file. + """ hostname = re.sub(r'\..*', '', name) hostname = re.sub(r'[^-\w\.]', '_', hostname) if (ip.startswith('127.') or ip.startswith('255.') or @@ -84,29 +106,37 @@ def found_host(name, ip): def _check_etc_hosts(): - debug2(' > hosts') - for line in open('/etc/hosts'): - line = re.sub(r'#.*', '', line) - words = line.strip().split() - if not words: - continue - ip = words[0] - names = words[1:] - if _is_ip(ip): - debug3('< %s %r' % (ip, names)) - for n in names: - check_host(n) - found_host(n, ip) + """If possible, read /etc/hosts to find hosts.""" + filename = '/etc/hosts' + debug2(' > Reading %s on remote host' % filename) + try: + for line in open(filename): + line = re.sub(r'#.*', '', line) # remove comments + words = line.strip().split() + if not words: + continue + ip = words[0] + if _is_ip(ip): + names = words[1:] + debug3('< %s %r' % (ip, names)) + for n in names: + check_host(n) + found_host(n, ip) + except (OSError, IOError): + debug1("Failed to read %s on remote host" % filename) def _check_revdns(ip): + """Use reverse DNS to try to get hostnames from an IP addresses.""" debug2(' > rev: %s' % ip) try: r = socket.gethostbyaddr(ip) debug3('< %s' % r[0]) check_host(r[0]) found_host(r[0], ip) - except (socket.herror, UnicodeError): + except (OSError, socket.error, UnicodeError): + # This case is expected to occur regularly. + # debug3('< %s gethostbyaddr failed on remote host' % ip) pass @@ -134,7 +164,14 @@ def _check_netstat(): log('%r failed: %r' % (argv, e)) return + # The same IPs may appear multiple times. Consolidate them so the + # debug message doesn't print the same IP repeatedly. + ip_list = [] for ip in re.findall(r'\d+\.\d+\.\d+\.\d+', content): + if ip not in ip_list: + ip_list.append(ip) + + for ip in sorted(ip_list): debug3('< %s' % ip) check_host(ip) @@ -179,13 +216,19 @@ def hw_main(seed_hosts, auto_hosts): while 1: now = time.time() + # For each item in the queue for t, last_polled in list(queue.items()): (op, args) = t if not _stdin_still_ok(0): break + + # Determine if we need to run. maxtime = POLL_TIME + # netstat runs more often than other jobs if op == _check_netstat: maxtime = NETSTAT_POLL_TIME + + # Check if this jobs needs to run. if now - last_polled > maxtime: queue[t] = time.time() op(*args) @@ -195,5 +238,5 @@ def hw_main(seed_hosts, auto_hosts): break # FIXME: use a smarter timeout based on oldest last_polled - if not _stdin_still_ok(1): + if not _stdin_still_ok(1): # sleeps for up to 1 second break diff --git a/sshuttle/methods/nat.py b/sshuttle/methods/nat.py index ac4c56a..a7a661c 100644 --- a/sshuttle/methods/nat.py +++ b/sshuttle/methods/nat.py @@ -14,14 +14,12 @@ class Method(BaseMethod): # "-A OUTPUT"). def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, user, ttl, tmark): - # only ipv4 supported with NAT - if family != socket.AF_INET: + if family != socket.AF_INET and family != socket.AF_INET6: raise Exception( 'Address family "%s" unsupported by nat method_name' % family_to_string(family)) if udp: raise Exception("UDP not supported by nat method_name") - table = "nat" def _ipt(*args): @@ -53,13 +51,18 @@ class Method(BaseMethod): # This TTL hack allows the client and server to run on the # same host. The connections the sshuttle server makes will # have TTL set to 63. - _ipt_ttl('-A', chain, '-j', 'RETURN', '-m', 'ttl', '--ttl', '%s' % ttl) + if family == socket.AF_INET: + _ipt_ttl('-A', chain, '-j', 'RETURN', '-m', 'ttl', '--ttl', + '%s' % ttl) + else: # ipv6, ttl is renamed to 'hop limit' + _ipt_ttl('-A', chain, '-j', 'RETURN', '-m', 'hl', '--hl-eq', + '%s' % ttl) # Redirect DNS traffic as requested. This includes routing traffic # to localhost DNS servers through sshuttle. for _, ip in [i for i in nslist if i[0] == family]: _ipt('-A', chain, '-j', 'REDIRECT', - '--dest', '%s/32' % ip, + '--dest', '%s' % ip, '-p', 'udp', '--dport', '53', '--to-ports', str(dnsport)) @@ -87,7 +90,7 @@ class Method(BaseMethod): def restore_firewall(self, port, family, udp, user): # only ipv4 supported with NAT - if family != socket.AF_INET: + if family != socket.AF_INET and family != socket.AF_INET6: raise Exception( 'Address family "%s" unsupported by nat method_name' % family_to_string(family)) @@ -123,6 +126,7 @@ class Method(BaseMethod): def get_supported_features(self): result = super(Method, self).get_supported_features() result.user = True + result.ipv6 = True return result def is_supported(self): diff --git a/tests/client/test_methods_nat.py b/tests/client/test_methods_nat.py index e0bf2a9..0bd0ec2 100644 --- a/tests/client/test_methods_nat.py +++ b/tests/client/test_methods_nat.py @@ -11,7 +11,7 @@ from sshuttle.methods import get_method def test_get_supported_features(): method = get_method('nat') features = method.get_supported_features() - assert not features.ipv6 + assert features.ipv6 assert not features.udp assert features.dns @@ -92,18 +92,51 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt_ttl, mock_ipt): method = get_method('nat') assert method.name == 'nat' - with pytest.raises(Exception) as excinfo: - method.setup_firewall( - 1024, 1026, - [(AF_INET6, u'2404:6800:4004:80c::33')], - AF_INET6, - [(AF_INET6, 64, False, u'2404:6800:4004:80c::', 0, 0), - (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 80, 80)], - True, - None, - 63, '0x01') - assert str(excinfo.value) \ - == 'Address family "AF_INET6" unsupported by nat method_name' + assert mock_ipt_chain_exists.mock_calls == [] + assert mock_ipt_ttl.mock_calls == [] + assert mock_ipt.mock_calls == [] + method.setup_firewall( + 1024, 1026, + [(AF_INET6, u'2404:6800:4004:80c::33')], + AF_INET6, + [(AF_INET6, 64, False, u'2404:6800:4004:80c::', 0, 0), + (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 80, 80)], + False, + None, + 63, '0x01') + + assert mock_ipt_chain_exists.mock_calls == [ + call(AF_INET6, 'nat', 'sshuttle-1024') + ] + assert mock_ipt_ttl.mock_calls == [ + call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'RETURN', + '-m', 'hl', '--hl-eq', '63') + ] + assert mock_ipt.mock_calls == [ + call(AF_INET6, 'nat', '-D', 'OUTPUT', '-j', 'sshuttle-1024'), + call(AF_INET6, 'nat', '-D', 'PREROUTING', '-j', 'sshuttle-1024'), + call(AF_INET6, 'nat', '-F', 'sshuttle-1024'), + call(AF_INET6, 'nat', '-X', 'sshuttle-1024'), + call(AF_INET6, 'nat', '-N', 'sshuttle-1024'), + call(AF_INET6, 'nat', '-F', 'sshuttle-1024'), + call(AF_INET6, 'nat', '-I', 'OUTPUT', '1', '-j', 'sshuttle-1024'), + call(AF_INET6, 'nat', '-I', 'PREROUTING', '1', '-j', 'sshuttle-1024'), + call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'REDIRECT', + '--dest', u'2404:6800:4004:80c::33', '-p', 'udp', + '--dport', '53', '--to-ports', '1026'), + call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'RETURN', + '-m', 'addrtype', '--dst-type', 'LOCAL'), + call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'RETURN', + '--dest', u'2404:6800:4004:80c::101f/128', '-p', 'tcp', + '--dport', '80:80'), + call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'REDIRECT', + '--dest', u'2404:6800:4004:80c::/64', '-p', 'tcp', + '--to-ports', '1024') + ] + mock_ipt_chain_exists.reset_mock() + mock_ipt_ttl.reset_mock() + mock_ipt.reset_mock() + assert mock_ipt_chain_exists.mock_calls == [] assert mock_ipt_ttl.mock_calls == [] assert mock_ipt.mock_calls == [] @@ -149,7 +182,7 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt_ttl, mock_ipt): call(AF_INET, 'nat', '-I', 'OUTPUT', '1', '-j', 'sshuttle-1025'), call(AF_INET, 'nat', '-I', 'PREROUTING', '1', '-j', 'sshuttle-1025'), call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'REDIRECT', - '--dest', u'1.2.3.33/32', '-p', 'udp', + '--dest', u'1.2.3.33', '-p', 'udp', '--dport', '53', '--to-ports', '1027'), call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'RETURN', '-m', 'addrtype', '--dst-type', 'LOCAL'), @@ -169,11 +202,29 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt_ttl, mock_ipt): ] assert mock_ipt_ttl.mock_calls == [] assert mock_ipt.mock_calls == [ - call(AF_INET, 'nat', '-D', 'OUTPUT', '-j', 'sshuttle-1025'), - call(AF_INET, 'nat', '-D', 'PREROUTING', '-j', 'sshuttle-1025'), + call(AF_INET, 'nat', '-D', 'OUTPUT', '-j', + 'sshuttle-1025'), + call(AF_INET, 'nat', '-D', 'PREROUTING', '-j', + 'sshuttle-1025'), call(AF_INET, 'nat', '-F', 'sshuttle-1025'), call(AF_INET, 'nat', '-X', 'sshuttle-1025') ] mock_ipt_chain_exists.reset_mock() mock_ipt_ttl.reset_mock() mock_ipt.reset_mock() + + method.restore_firewall(1025, AF_INET6, False, None) + assert mock_ipt_chain_exists.mock_calls == [ + call(AF_INET6, 'nat', 'sshuttle-1025') + ] + assert mock_ipt_ttl.mock_calls == [] + assert mock_ipt.mock_calls == [ + call(AF_INET6, 'nat', '-D', 'OUTPUT', '-j', 'sshuttle-1025'), + call(AF_INET6, 'nat', '-D', 'PREROUTING', '-j', + 'sshuttle-1025'), + call(AF_INET6, 'nat', '-F', 'sshuttle-1025'), + call(AF_INET6, 'nat', '-X', 'sshuttle-1025') + ] + mock_ipt_chain_exists.reset_mock() + mock_ipt_ttl.reset_mock() + mock_ipt.reset_mock() |