experimental windows method

This commit is contained in:
nom3ad 2022-09-07 12:26:21 +05:30 committed by Brian May
parent 2408563f3b
commit 5a64c81b5b
11 changed files with 380 additions and 110 deletions

View File

@ -65,8 +65,7 @@ Requires:
Windows
~~~~~~~
Not officially supported, however can be made to work with Vagrant. Requires
cmd.exe with Administrator access. See :doc:`windows` for more information.
Experimental built-in support available. See :doc:`windows` for more information.
Server side Requirements

View File

@ -1,7 +1,13 @@
Microsoft Windows
=================
Currently there is no built in support for running sshuttle directly on
Microsoft Windows.
Experimental support::
Experimental built-in support for Windows is availble through `windivert` method.
You have to install https://pypi.org/project/pydivert pacakge. You need Administrator privileges to use windivert method
Use Linux VM on Windows::
What we can really do is to create a Linux VM with Vagrant (or simply
Virtualbox if you like). In the Vagrant settings, remember to turn on bridged

View File

@ -1,4 +1,8 @@
"""Coverage.py's main entry point."""
import sys
import os
from sshuttle.cmdline import main
sys.exit(main())
from sshuttle.helpers import debug3
exit_code=main()
debug3("Exiting process %r (pid:%s) with code %s" % (sys.argv, os.getpid(), exit_code,))
sys.exit(exit_code)

View File

@ -14,7 +14,7 @@ import sshuttle.ssyslog as ssyslog
import sshuttle.sdnotify as sdnotify
from sshuttle.ssnet import SockWrapper, Handler, Proxy, Mux, MuxWrapper
from sshuttle.helpers import log, debug1, debug2, debug3, Fatal, islocal, \
resolvconf_nameservers, which
resolvconf_nameservers, which, is_admin_user
from sshuttle.methods import get_method, Features
from sshuttle import __version__
try:
@ -219,8 +219,14 @@ class FirewallClient:
# A list of commands that we can try to run to start the firewall.
argv_tries = []
if os.getuid() == 0: # No need to elevate privileges
if is_admin_user(): # No need to elevate privileges
argv_tries.append(argvbase)
else:
if sys.platform == 'win32':
argv_tries.append(argvbase)
# runas_path = which("runas")
# if runas_path:
# argv_tries.append(['runas' , '/noprofile', '/user:Administrator', 'python'])
else:
# Linux typically uses sudo; OpenBSD uses doas. However, some
# Linux distributions are starting to use doas.
@ -265,21 +271,45 @@ class FirewallClient:
# successful, set 'success' variable and break.
success = False
for argv in argv_tries:
if sys.platform != 'win32':
# we can't use stdin/stdout=subprocess.PIPE here, as we
# normally would, because stupid Linux 'su' requires that
# stdin be attached to a tty. Instead, attach a
# *bidirectional* socket to its stdout, and use that for
# talking in both directions.
(s1, s2) = socket.socketpair()
def setup():
pstdout = s1
pstdin = s1
penv = None
def preexec_fn():
# run in the child process
s2.close()
def get_pfile():
s1.close()
return s2.makefile('rwb')
else:
(s1, s2) = socket.socketpair()
pstdout = None
pstdin = ssubprocess.PIPE
preexec_fn = None
penv = os.environ.copy()
penv['PYTHONPATH'] = os.path.dirname(os.path.dirname(__file__))
def get_pfile():
import base64
socket_share_data = s1.share(self.p.pid)
s1.close()
socket_share_data_b64 = base64.b64encode(socket_share_data)
# debug3(f"{socket_share_data_b64=}")
self.p.stdin.write(socket_share_data_b64 + b'\n')
self.p.stdin.flush()
return s2.makefile('rwb')
try:
debug1("Starting firewall manager with command: %r" % argv)
self.p = ssubprocess.Popen(argv, stdout=s1, stdin=s1,
preexec_fn=setup)
self.p = ssubprocess.Popen(argv, stdout=pstdout, stdin=pstdin, env=penv,
preexec_fn=preexec_fn)
# No env: Talking to `FirewallClient.start`, which has no i18n.
except OSError as e:
# This exception will occur if the program isn't
@ -287,11 +317,15 @@ class FirewallClient:
debug1('Unable to start firewall manager. Popen failed. '
'Command=%r Exception=%s' % (argv, e))
continue
self.argv = argv
s1.close()
self.pfile = s2.makefile('rwb')
self.pfile = get_pfile()
try:
line = self.pfile.readline()
except ConnectionResetError:
# happens in Windows, when subprocess exists
line=''
rv = self.p.poll() # Check if process is still running
if rv:
@ -327,14 +361,14 @@ class FirewallClient:
'Command=%r' % (skipped_text, self.argv))
continue
method_name = line[6:-1]
method_name = line.strip()[6:]
self.method = get_method(method_name.decode("ASCII"))
self.method.set_firewall(self)
success = True
break
if not success:
raise Fatal("All attempts to elevate privileges failed.")
raise Fatal("All attempts to run firewall client with elevated privileges were failed.")
def setup(self, subnets_include, subnets_exclude, nslist,
redirectport_v6, redirectport_v4, dnsport_v6, dnsport_v4, udp,
@ -397,9 +431,9 @@ class FirewallClient:
(udp, user, group, bytes(self.tmark, 'ascii'), os.getpid()))
self.pfile.flush()
line = self.pfile.readline()
line = self.pfile.readline().strip()
self.check()
if line != b'STARTED\n':
if line != b'STARTED':
raise Fatal('%r expected STARTED, got %r' % (self.argv, line))
def sethostip(self, hostname, ip):
@ -562,24 +596,26 @@ def _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename,
auto_nets=auto_nets))
except socket.error as e:
if e.args[0] == errno.EPIPE:
debug3('Error: EPIPE: ' + repr(e))
raise Fatal("failed to establish ssh session (1)")
else:
raise
mux = Mux(serversock.makefile("rb"), serversock.makefile("wb"))
rfile, wfile = serversock.makefile("rb"), serversock.makefile("wb")
mux = Mux(rfile, wfile)
handlers.append(mux)
expected = b'SSHUTTLE0001'
try:
v = 'x'
while v and v != b'\0':
v = serversock.recv(1)
v = rfile.read(1)
v = 'x'
while v and v != b'\0':
v = serversock.recv(1)
initstring = serversock.recv(len(expected))
v = rfile.read(1)
initstring = rfile.read(len(expected))
except socket.error as e:
if e.args[0] == errno.ECONNRESET:
debug3('Error: ECONNRESET ' + repr(e))
raise Fatal("failed to establish ssh session (2)")
else:
raise

View File

@ -7,10 +7,12 @@ import os
import platform
import traceback
import subprocess as ssubprocess
import base64
import io
import sshuttle.ssyslog as ssyslog
import sshuttle.helpers as helpers
from sshuttle.helpers import log, debug1, debug2, Fatal
from sshuttle.helpers import is_admin_user, log, debug1, debug2, Fatal
from sshuttle.methods import get_auto_method, get_method
HOSTSFILE = '/etc/hosts'
@ -87,8 +89,8 @@ def firewall_exit(signum, frame):
# Isolate function that needs to be replaced for tests
def setup_daemon():
if os.getuid() != 0:
def _setup_daemon_unix():
if not is_admin_user():
raise Fatal('You must be root (or enable su/sudo) to set the firewall')
# don't disappear if our controlling terminal or stdout/stderr
@ -113,6 +115,25 @@ def setup_daemon():
return sys.stdin, sys.stdout
def _setup_daemon_windows():
if not is_admin_user():
raise Fatal('You must be administrator to set the firewall')
signal.signal(signal.SIGTERM, firewall_exit)
signal.signal(signal.SIGINT, firewall_exit)
socket_share_data_b64 = sys.stdin.readline()
# debug3(f'FROM_SHARE ${socket_share_data_b64=}')
socket_share_data = base64.b64decode(socket_share_data_b64)
sock = socket.fromshare(socket_share_data)
sys.stdin = io.TextIOWrapper(sock.makefile('rb'))
sys.stdout = io.TextIOWrapper(sock.makefile('wb'))
return sys.stdin, sys.stdout
if sys.platform == 'win32':
setup_daemon = _setup_daemon_windows
else:
setup_daemon = _setup_daemon_unix
# Note that we're sorting in a very particular order:
# we need to go from smaller, more specific, port ranges, to larger,
# less-specific, port ranges. At each level, we order by subnet
@ -190,9 +211,13 @@ def main(method_name, syslog):
# we wait until we get some input before creating the rules. That way,
# sshuttle can launch us as early as possible (and get sudo password
# authentication as early in the startup process as possible).
try:
line = stdin.readline(128)
if not line:
return # parent died; nothing to do
except ConnectionResetError:
# On windows, this is thrown when parent process closes it's socket pair end
return
subnets = []
if line != 'ROUTES\n':

View File

@ -220,3 +220,14 @@ def which(file, mode=os.F_OK | os.X_OK):
else:
debug2("which() could not find '%s' in %s" % (file, path))
return rv
def is_admin_user():
if sys.platform == 'win32':
import ctypes
# https://stackoverflow.com/questions/130763/request-uac-elevation-from-within-a-python-script/41930586#41930586
try:
return ctypes.windll.shell32.IsUserAnAdmin()
except:
return False
return os.getuid() != 0

View File

@ -1,6 +1,7 @@
import importlib
import socket
import struct
import sys
import errno
import ipaddress
from sshuttle.helpers import Fatal, debug3
@ -109,7 +110,7 @@ def get_method(method_name):
def get_auto_method():
debug3("Selecting a method automatically...")
# Try these methods, in order:
methods_to_try = ["nat", "nft", "pf", "ipfw"]
methods_to_try = ["nat", "nft", "pf", "ipfw"] if sys.platform != "win32" else ["windivert"]
for m in methods_to_try:
method = get_method(m)
if method.is_supported():

View File

@ -0,0 +1,126 @@
import sys
import ipaddress
import threading
from collections import namedtuple
try:
import pydivert
except ImportError:
raise Fatal('Could not import pydivert module. windivert requires https://pypi.org/project/pydivert')
from sshuttle.methods import BaseMethod
from sshuttle.helpers import log, debug1, debug2, Fatal
# https://reqrypt.org/windivert-doc.html#divert_iphdr
ConnectionTuple = namedtuple(
"ConnectionTuple", ["protocol", "src_addr", "src_port", "dst_addr", "dst_port"]
)
class ConnectionTracker:
def __init__(self) -> None:
self.d = {}
def add_tcp(self, src_addr, src_port, dst_addr, dst_port):
k = ("TCP", src_addr, src_port)
v = (dst_addr, dst_port)
if self.d.get(k) != v:
debug1("Adding tcp connection to tracker:" + repr((src_addr, src_port, dst_addr, dst_port)))
self.d[k] = v
def get_tcp(self, src_addr, src_port):
try:
return ConnectionTuple(
"TCP", src_addr, src_port, *self.d[("TCP", src_addr, src_port)]
)
except KeyError:
return None
class Method(BaseMethod):
def setup_firewall(self, port, dnsport, nslist, family, subnets, udp,
user, tmark):
log( f"{port=}, {dnsport=}, {nslist=}, {family=}, {subnets=}, {udp=}, {user=}, {tmark=}")
# port=12300, dnsport=0, nslist=[], family=<AddressFamily.AF_INET: 2>,
# subnets=[(2, 24, False, '10.111.10.0', 0, 0), (2, 16, False, '169.254.0.0', 0, 0), (2, 24, False, '172.31.0.0', 0, 0), (2, 16, False, '192.168.0.0', 0, 0), (2, 32, True, '0.0.0.0', 0, 0)],
# udp=False, user=None, tmark='0x01'
self.conntrack = ConnectionTracker()
proxy_addr = "10.0.2.15"
subnet_addreses = []
for (_, mask, exclude, network_addr, fport, lport) in subnets:
if exclude:
continue
assert fport == 0, 'custom port range not supported'
assert lport == 0, 'custom port range not supported'
subnet_addreses.append("%s/%s" % (network_addr, mask))
debug2("subnet_addreses=%s proxy_addr=%s:%s" % (subnet_addreses,proxy_addr,port))
# check permission
with pydivert.WinDivert('false'):
pass
threading.Thread(name='outbound_divert', target=self._outbound_divert, args=(subnet_addreses, proxy_addr, port), daemon=True).start()
threading.Thread(name='inbound_divert', target=self._inbound_divert, args=(proxy_addr, port), daemon=True).start()
def restore_firewall(self, port, family, udp, user):
pass
def get_supported_features(self):
result = super(Method, self).get_supported_features()
result.user = False
result.dns = False
result.ipv6 = False
return result
def get_tcp_dstip(self, sock):
return ('172.31.0.141', 80)
def is_supported(self):
if sys.platform == 'win32':
return True
return False
def _outbound_divert(self, subnets, proxy_addr, proxy_port):
# with pydivert.WinDivert(f"outbound and tcp and ip.DstAddr == {subnet}") as w:
filter = "outbound and ip and tcp"
subnet_selectors = []
for cidr in subnets:
ip_network = ipaddress.ip_network(cidr)
first_ip = ip_network.network_address
last_ip = ip_network.broadcast_address
subnet_selectors.append(f"(ip.DstAddr >= {first_ip} and ip.DstAddr <= {last_ip})")
filter = f"{filter} and ({'or'.join(subnet_selectors)}) "
debug1(f"[OUTBOUND] {filter=}")
with pydivert.WinDivert(filter) as w:
for pkt in w:
# debug3(repr(pkt))
self.conntrack.add_tcp(pkt.src_addr, pkt.src_port, pkt.dst_addr, pkt.dst_port)
pkt.ipv4.dst_addr = proxy_addr
pkt.tcp.dst_port = proxy_port
w.send(pkt, recalculate_checksum=True)
def _inbound_divert(self, proxy_addr, proxy_port):
filter = f"inbound and ip and tcp and ip.SrcAddr == {proxy_addr} and tcp.SrcPort == {proxy_port}"
debug2(f"[INBOUND] {filter=}")
with pydivert.WinDivert(filter) as w:
for pkt in w:
# debug2(repr(conntrack.d))
# debug2(repr((pkt.src_addr, pkt.src_port, pkt.dst_addr, pkt.dst_port)))
conn = self.conntrack.get_tcp(pkt.dst_addr, pkt.dst_port)
if not conn:
debug2("Unexpcted packet:" + repr((pkt.protocol,pkt.src_addr,pkt.src_port,pkt.dst_addr,pkt.dst_port)))
continue
pkt.ipv4.src_addr = conn.dst_addr
pkt.tcp.src_port = conn.dst_port
w.send(pkt, recalculate_checksum=True)

View File

@ -1,5 +1,6 @@
import re
import socket
import sys
from argparse import ArgumentParser, Action, ArgumentTypeError as Fatal
from sshuttle import __version__
@ -236,7 +237,7 @@ parser.add_argument(
parser.add_argument(
"--method",
choices=["auto", "nat", "nft", "tproxy", "pf", "ipfw"],
choices=["auto", "nat", "nft", "tproxy", "pf", "ipfw"] if sys.platform != 'win32' else ["auto", "windivert"],
metavar="TYPE",
default="auto",
help="""

View File

@ -175,9 +175,10 @@ def connect(ssh_cmd, rhostport, python, stderr, add_cmd_delimiter, options):
# case, sshuttle might not work at all since it is not
# possible to run python on the remote machine---even if
# it is present.
devnull='/dev/null'
pycmd = ("P=python3; $P -V 2>%s || P=python; "
"exec \"$P\" -c %s; exit 97") % \
(os.devnull, quote(pyscript))
(devnull, quote(pyscript))
pycmd = ("/bin/sh -c {}".format(quote(pycmd)))
if password is not None:
@ -203,19 +204,56 @@ def connect(ssh_cmd, rhostport, python, stderr, add_cmd_delimiter, options):
raise Fatal("Failed to find '%s' in path %s" % (argv[0], get_path()))
argv[0] = abs_path
(s1, s2) = socket.socketpair()
def setup():
if sys.platform != 'win32':
(s1, s2) = socket.socketpair()
def preexec_fn():
# runs in the child process
s2.close()
s1a, s1b = os.dup(s1.fileno()), os.dup(s1.fileno())
pstdin, pstdout = os.dup(s1.fileno()), os.dup(s1.fileno())
s1.close()
debug2('executing: %r' % argv)
p = ssubprocess.Popen(argv, stdin=s1a, stdout=s1b, preexec_fn=setup,
close_fds=True, stderr=stderr)
os.close(s1a)
os.close(s1b)
s2.sendall(content)
s2.sendall(content2)
return p, s2
def get_serversock():
os.close(pstdin)
os.close(pstdout)
return s2
else:
(s1, s2) = socket.socketpair()
preexec_fn = None
pstdin = ssubprocess.PIPE
pstdout = ssubprocess.PIPE
def get_serversock():
import threading
def steam_stdout_to_sock():
while True:
data = p.stdout.read(1)
if not data:
debug2("EOF on ssh process stdout. Process probably exited")
break
n = s1.sendall(data)
print("<<<<< p.stdout.read()", len(data), '->', n, data[:min(32,len(data))])
def stream_sock_to_stdin():
while True:
data = s1.recv(16384)
if not data:
print(">>>>>> EOF stream_sock_to_stdin")
break
n = p.stdin.write(data)
print(">>>>>> s1.recv()", len(data) , "->" , n , data[:min(32,len(data))])
p.communicate
threading.Thread(target=steam_stdout_to_sock, name='steam_stdout_to_sock', daemon=True).start()
threading.Thread(target=stream_sock_to_stdin, name='stream_sock_to_stdin', daemon=True).start()
# s2.setblocking(False)
return s2
# https://stackoverflow.com/questions/48671215/howto-workaround-of-close-fds-true-and-redirect-stdout-stderr-on-windows
close_fds = False if sys.platform == 'win32' else True
debug2("executing: %r" % argv)
p = ssubprocess.Popen(argv, stdin=pstdin, stdout=pstdout, preexec_fn=preexec_fn,
close_fds=close_fds, stderr=stderr, bufsize=0)
serversock = get_serversock()
serversock.sendall(content)
serversock.sendall(content2)
return p, serversock

View File

@ -4,6 +4,8 @@ import socket
import errno
import select
import os
if sys.platform != "win32":
import fcntl
from sshuttle.helpers import b, log, debug1, debug2, debug3, Fatal
@ -213,6 +215,9 @@ class SockWrapper:
return 0 # still connecting
self.wsock.setblocking(False)
try:
if sys.platform == 'win32':
return _nb_clean(self.wsock.send, buf)
else:
return _nb_clean(os.write, self.wsock.fileno(), buf)
except OSError:
_, e = sys.exc_info()[:2]
@ -236,6 +241,9 @@ class SockWrapper:
return
self.rsock.setblocking(False)
try:
if sys.platform == 'win32':
return _nb_clean(self.rsock.recv, 65536)
else:
return _nb_clean(os.read, self.rsock.fileno(), 65536)
except OSError:
_, e = sys.exc_info()[:2]
@ -431,6 +439,7 @@ class Mux(Handler):
callback(cmd, data)
def flush(self):
if sys.platform != "win32":
try:
os.set_blocking(self.wfile.fileno(), False)
except AttributeError:
@ -438,7 +447,13 @@ class Mux(Handler):
flags = fcntl.fcntl(self.wfile.fileno(), fcntl.F_GETFL)
flags |= os.O_NONBLOCK
fcntl.fcntl(self.wfile.fileno(), fcntl.F_SETFL, flags)
else:
self.wfile.raw._sock.setblocking(False)
if self.outbuf and self.outbuf[0]:
if sys.platform == 'win32':
wrote = _nb_clean(self.wfile.raw._sock.send, self.outbuf[0])
else:
wrote = _nb_clean(os.write, self.wfile.fileno(), self.outbuf[0])
debug2('mux wrote: %r/%d' % (wrote, len(self.outbuf[0])))
if wrote:
@ -447,6 +462,7 @@ class Mux(Handler):
self.outbuf[0:1] = []
def fill(self):
if sys.platform != "win32":
try:
os.set_blocking(self.rfile.fileno(), False)
except AttributeError:
@ -454,9 +470,16 @@ class Mux(Handler):
flags = fcntl.fcntl(self.rfile.fileno(), fcntl.F_GETFL)
flags |= os.O_NONBLOCK
fcntl.fcntl(self.rfile.fileno(), fcntl.F_SETFL, flags)
else:
self.rfile.raw._sock.setblocking(False)
try:
# If LATENCY_BUFFER_SIZE is inappropriately large, we will
# get a MemoryError here. Read no more than 1MiB.
if sys.platform == 'win32':
read = _nb_clean(self.rfile.raw._sock.recv,
min(1048576, LATENCY_BUFFER_SIZE))
else:
read = _nb_clean(os.read, self.rfile.fileno(),
min(1048576, LATENCY_BUFFER_SIZE))
except OSError: