diff --git a/README.md b/README.md index 7b1eece..d24ebea 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ just switch your wireless off and on. Sshuttle makes the kernel setting it changes permanent, so this won't happen again, even after a reboot. +Required Software +================= + + - You need PyXAPI, available here: + http://www.pps.univ-paris-diderot.fr/~ylg/PyXAPI/ + - You also need autossh, available in various package management systems + - Python 2.x, both locally and the remote system + sshuttle: where transparent proxy meets VPN meets ssh ===================================================== @@ -51,19 +59,34 @@ Prerequisites Linux.) - If you use MacOS or BSD on your client machine: - Your kernel needs to be compiled with IPFIREWALL_FORWARD + Your kernel needs to be compiled with `IPFIREWALL_FORWARD` (MacOS has this by default) and you need to have ipfw available. (The server doesn't need to be MacOS or BSD.) -This is how you use it: +Obtaining sshuttle +------------------ + + - First, go get PyXAPI from the link above + + - Clone github.com/jwyllie83/sshuttle/tree/local + + +Usage on (Ubuntu) Linux ----------------------- - - git clone git://github.com/apenwarr/sshuttle - on your client machine. You'll need root or sudo - access, and python needs to be installed. + - `cd packaging; ./make_deb` + + - `sudo dpkg -i ./sshuttle-VERSION.deb` + + - Check out the files in `/etc/sshuttle`; configure them so your tunnel works + + - `sudo service sshuttle start` + + +Usage on other Linuxes and OSes +------------------------------- - - The most basic use of sshuttle looks like: ./sshuttle -r username@sshserver 0.0.0.0/0 -vv - There is a shortcut for 0.0.0.0/0 for those that value @@ -83,6 +106,9 @@ then the remote ssh password. Or you might have sudo and ssh set up to not require passwords, in which case you won't be prompted at all.) +Usage Notes +----------- + That's it! Now your local machine can access the remote network as if you were right there. And if your "client" machine is a router, everyone on your local network can make connections to your remote network. diff --git a/client.py b/client.py deleted file mode 100644 index 449a75a..0000000 --- a/client.py +++ /dev/null @@ -1,394 +0,0 @@ -import struct, socket, select, errno, re, signal, time -import compat.ssubprocess as ssubprocess -import helpers, ssnet, ssh, ssyslog -from ssnet import SockWrapper, Handler, Proxy, Mux, MuxWrapper -from helpers import * - -_extra_fd = os.open('/dev/null', os.O_RDONLY) - -def got_signal(signum, frame): - log('exiting on signal %d\n' % signum) - sys.exit(1) - - -_pidname = None -def check_daemon(pidfile): - global _pidname - _pidname = os.path.abspath(pidfile) - try: - oldpid = open(_pidname).read(1024) - except IOError, e: - if e.errno == errno.ENOENT: - return # no pidfile, ok - else: - raise Fatal("can't read %s: %s" % (_pidname, e)) - if not oldpid: - os.unlink(_pidname) - return # invalid pidfile, ok - oldpid = int(oldpid.strip() or 0) - if oldpid <= 0: - os.unlink(_pidname) - return # invalid pidfile, ok - try: - os.kill(oldpid, 0) - except OSError, e: - if e.errno == errno.ESRCH: - os.unlink(_pidname) - return # outdated pidfile, ok - elif e.errno == errno.EPERM: - pass - else: - raise - raise Fatal("%s: sshuttle is already running (pid=%d)" - % (_pidname, oldpid)) - - -def daemonize(): - if os.fork(): - os._exit(0) - os.setsid() - if os.fork(): - os._exit(0) - - outfd = os.open(_pidname, os.O_WRONLY|os.O_CREAT|os.O_EXCL, 0666) - try: - os.write(outfd, '%d\n' % os.getpid()) - finally: - os.close(outfd) - os.chdir("/") - - # Normal exit when killed, or try/finally won't work and the pidfile won't - # be deleted. - signal.signal(signal.SIGTERM, got_signal) - - si = open('/dev/null', 'r+') - os.dup2(si.fileno(), 0) - os.dup2(si.fileno(), 1) - si.close() - - ssyslog.stderr_to_syslog() - - -def daemon_cleanup(): - try: - os.unlink(_pidname) - except OSError, e: - if e.errno == errno.ENOENT: - pass - else: - raise - - -def original_dst(sock): - try: - SO_ORIGINAL_DST = 80 - SOCKADDR_MIN = 16 - sockaddr_in = sock.getsockopt(socket.SOL_IP, - SO_ORIGINAL_DST, SOCKADDR_MIN) - (proto, port, a,b,c,d) = struct.unpack('!HHBBBB', sockaddr_in[:8]) - assert(socket.htons(proto) == socket.AF_INET) - ip = '%d.%d.%d.%d' % (a,b,c,d) - return (ip,port) - except socket.error, e: - if e.args[0] == errno.ENOPROTOOPT: - return sock.getsockname() - raise - - -class FirewallClient: - def __init__(self, port, subnets_include, subnets_exclude, dnsport): - self.port = port - self.auto_nets = [] - self.subnets_include = subnets_include - self.subnets_exclude = subnets_exclude - self.dnsport = dnsport - argvbase = ([sys.argv[1], sys.argv[0], sys.argv[1]] + - ['-v'] * (helpers.verbose or 0) + - ['--firewall', str(port), str(dnsport)]) - if ssyslog._p: - argvbase += ['--syslog'] - argv_tries = [ - ['sudo', '-p', '[local sudo] Password: '] + argvbase, - ['su', '-c', ' '.join(argvbase)], - argvbase - ] - - # 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(): - # run in the child process - s2.close() - e = None - if os.getuid() == 0: - argv_tries = argv_tries[-1:] # last entry only - for argv in argv_tries: - try: - if argv[0] == 'su': - sys.stderr.write('[local su] ') - self.p = ssubprocess.Popen(argv, stdout=s1, preexec_fn=setup) - e = None - break - except OSError, e: - pass - self.argv = argv - s1.close() - self.pfile = s2.makefile('wb+') - if e: - log('Spawning firewall manager: %r\n' % self.argv) - raise Fatal(e) - line = self.pfile.readline() - self.check() - if line != 'READY\n': - raise Fatal('%r expected READY, got %r' % (self.argv, line)) - - def check(self): - rv = self.p.poll() - if rv: - raise Fatal('%r returned %d' % (self.argv, rv)) - - def start(self): - self.pfile.write('ROUTES\n') - for (ip,width) in self.subnets_include+self.auto_nets: - self.pfile.write('%d,0,%s\n' % (width, ip)) - for (ip,width) in self.subnets_exclude: - self.pfile.write('%d,1,%s\n' % (width, ip)) - self.pfile.write('GO\n') - self.pfile.flush() - line = self.pfile.readline() - self.check() - if line != 'STARTED\n': - raise Fatal('%r expected STARTED, got %r' % (self.argv, line)) - - def sethostip(self, hostname, ip): - assert(not re.search(r'[^-\w]', hostname)) - assert(not re.search(r'[^0-9.]', ip)) - self.pfile.write('HOST %s,%s\n' % (hostname, ip)) - self.pfile.flush() - - def done(self): - self.pfile.close() - rv = self.p.wait() - if rv: - raise Fatal('cleanup: %r returned %d' % (self.argv, rv)) - - -def _main(listener, fw, ssh_cmd, remotename, python, latency_control, - dnslistener, seed_hosts, auto_nets, - syslog, daemon): - handlers = [] - if helpers.verbose >= 1: - helpers.logprefix = 'c : ' - else: - helpers.logprefix = 'client: ' - debug1('connecting to server...\n') - - try: - (serverproc, serversock) = ssh.connect(ssh_cmd, remotename, python, - stderr=ssyslog._p and ssyslog._p.stdin, - options=dict(latency_control=latency_control)) - except socket.error, e: - if e.args[0] == errno.EPIPE: - raise Fatal("failed to establish ssh session (1)") - else: - raise - mux = Mux(serversock, serversock) - handlers.append(mux) - - expected = 'SSHUTTLE0001' - - try: - v = 'x' - while v and v != '\0': - v = serversock.recv(1) - v = 'x' - while v and v != '\0': - v = serversock.recv(1) - initstring = serversock.recv(len(expected)) - except socket.error, e: - if e.args[0] == errno.ECONNRESET: - raise Fatal("failed to establish ssh session (2)") - else: - raise - - rv = serverproc.poll() - if rv: - raise Fatal('server died with error code %d' % rv) - - if initstring != expected: - raise Fatal('expected server init string %r; got %r' - % (expected, initstring)) - debug1('connected.\n') - print 'Connected.' - sys.stdout.flush() - if daemon: - daemonize() - log('daemonizing (%s).\n' % _pidname) - elif syslog: - debug1('switching to syslog.\n') - ssyslog.stderr_to_syslog() - - def onroutes(routestr): - if auto_nets: - for line in routestr.strip().split('\n'): - (ip,width) = line.split(',', 1) - fw.auto_nets.append((ip,int(width))) - - # we definitely want to do this *after* starting ssh, or we might end - # up intercepting the ssh connection! - # - # Moreover, now that we have the --auto-nets option, we have to wait - # for the server to send us that message anyway. Even if we haven't - # set --auto-nets, we might as well wait for the message first, then - # ignore its contents. - mux.got_routes = None - fw.start() - mux.got_routes = onroutes - - def onhostlist(hostlist): - debug2('got host list: %r\n' % hostlist) - for line in hostlist.strip().split(): - if line: - name,ip = line.split(',', 1) - fw.sethostip(name, ip) - mux.got_host_list = onhostlist - - def onaccept(): - global _extra_fd - try: - sock,srcip = listener.accept() - except socket.error, e: - if e.args[0] in [errno.EMFILE, errno.ENFILE]: - debug1('Rejected incoming connection: too many open files!\n') - # free up an fd so we can eat the connection - os.close(_extra_fd) - try: - sock,srcip = listener.accept() - sock.close() - finally: - _extra_fd = os.open('/dev/null', os.O_RDONLY) - return - else: - raise - dstip = original_dst(sock) - debug1('Accept: %s:%r -> %s:%r.\n' % (srcip[0],srcip[1], - dstip[0],dstip[1])) - if dstip[1] == listener.getsockname()[1] and islocal(dstip[0]): - debug1("-- ignored: that's my address!\n") - sock.close() - return - chan = mux.next_channel() - if not chan: - log('warning: too many open channels. Discarded connection.\n') - sock.close() - return - mux.send(chan, ssnet.CMD_CONNECT, '%s,%s' % dstip) - outwrap = MuxWrapper(mux, chan) - handlers.append(Proxy(SockWrapper(sock, sock), outwrap)) - handlers.append(Handler([listener], onaccept)) - - dnsreqs = {} - def dns_done(chan, data): - peer,timeout = dnsreqs.get(chan) or (None,None) - debug3('dns_done: channel=%r peer=%r\n' % (chan, peer)) - if peer: - del dnsreqs[chan] - debug3('doing sendto %r\n' % (peer,)) - dnslistener.sendto(data, peer) - def ondns(): - pkt,peer = dnslistener.recvfrom(4096) - now = time.time() - if pkt: - debug1('DNS request from %r: %d bytes\n' % (peer, len(pkt))) - chan = mux.next_channel() - dnsreqs[chan] = peer,now+30 - mux.send(chan, ssnet.CMD_DNS_REQ, pkt) - mux.channels[chan] = lambda cmd,data: dns_done(chan,data) - for chan,(peer,timeout) in dnsreqs.items(): - if timeout < now: - del dnsreqs[chan] - debug3('Remaining DNS requests: %d\n' % len(dnsreqs)) - if dnslistener: - handlers.append(Handler([dnslistener], ondns)) - - if seed_hosts != None: - debug1('seed_hosts: %r\n' % seed_hosts) - mux.send(0, ssnet.CMD_HOST_REQ, '\n'.join(seed_hosts)) - - while 1: - rv = serverproc.poll() - if rv: - raise Fatal('server died with error code %d' % rv) - - ssnet.runonce(handlers, mux) - if latency_control: - mux.check_fullness() - mux.callback() - - -def main(listenip, ssh_cmd, remotename, python, latency_control, dns, - seed_hosts, auto_nets, - subnets_include, subnets_exclude, syslog, daemon, pidfile): - if syslog: - ssyslog.start_syslog() - if daemon: - try: - check_daemon(pidfile) - except Fatal, e: - log("%s\n" % e) - return 5 - debug1('Starting sshuttle proxy.\n') - - if listenip[1]: - ports = [listenip[1]] - else: - ports = xrange(12300,9000,-1) - last_e = None - bound = False - debug2('Binding:') - for port in ports: - debug2(' %d' % port) - listener = socket.socket() - listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - dnslistener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - dnslistener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - try: - listener.bind((listenip[0], port)) - dnslistener.bind((listenip[0], port)) - bound = True - break - except socket.error, e: - last_e = e - debug2('\n') - if not bound: - assert(last_e) - raise last_e - listener.listen(10) - listenip = listener.getsockname() - debug1('Listening on %r.\n' % (listenip,)) - - if dns: - dnsip = dnslistener.getsockname() - debug1('DNS listening on %r.\n' % (dnsip,)) - dnsport = dnsip[1] - else: - dnsport = 0 - dnslistener = None - - fw = FirewallClient(listenip[1], subnets_include, subnets_exclude, dnsport) - - try: - return _main(listener, fw, ssh_cmd, remotename, - python, latency_control, dnslistener, - seed_hosts, auto_nets, syslog, daemon) - finally: - try: - if daemon: - # it's not our child anymore; can't waitpid - fw.p.returncode = 0 - fw.done() - finally: - if daemon: - daemon_cleanup() diff --git a/main.py b/main.py deleted file mode 100644 index 1cf00af..0000000 --- a/main.py +++ /dev/null @@ -1,130 +0,0 @@ -import sys, os, re -import helpers, options, client, server, firewall, hostwatch -import compat.ssubprocess as ssubprocess -from helpers import * - - -# list of: -# 1.2.3.4/5 or just 1.2.3.4 -def parse_subnets(subnets_str): - subnets = [] - for s in subnets_str: - m = re.match(r'(\d+)(?:\.(\d+)\.(\d+)\.(\d+))?(?:/(\d+))?$', s) - if not m: - raise Fatal('%r is not a valid IP subnet format' % s) - (a,b,c,d,width) = m.groups() - (a,b,c,d) = (int(a or 0), int(b or 0), int(c or 0), int(d or 0)) - if width == None: - width = 32 - else: - width = int(width) - if a > 255 or b > 255 or c > 255 or d > 255: - raise Fatal('%d.%d.%d.%d has numbers > 255' % (a,b,c,d)) - if width > 32: - raise Fatal('*/%d is greater than the maximum of 32' % width) - subnets.append(('%d.%d.%d.%d' % (a,b,c,d), width)) - return subnets - - -# 1.2.3.4:567 or just 1.2.3.4 or just 567 -def parse_ipport(s): - s = str(s) - m = re.match(r'(?:(\d+)\.(\d+)\.(\d+)\.(\d+))?(?::)?(?:(\d+))?$', s) - if not m: - raise Fatal('%r is not a valid IP:port format' % s) - (a,b,c,d,port) = m.groups() - (a,b,c,d,port) = (int(a or 0), int(b or 0), int(c or 0), int(d or 0), - int(port or 0)) - if a > 255 or b > 255 or c > 255 or d > 255: - raise Fatal('%d.%d.%d.%d has numbers > 255' % (a,b,c,d)) - if port > 65535: - raise Fatal('*:%d is greater than the maximum of 65535' % port) - if a == None: - a = b = c = d = 0 - return ('%d.%d.%d.%d' % (a,b,c,d), port) - - -optspec = """ -sshuttle [-l [ip:]port] [-r [username@]sshserver[:port]] -sshuttle --server -sshuttle --firewall -sshuttle --hostwatch --- -l,listen= transproxy to this ip address and port number [127.0.0.1:0] -H,auto-hosts scan for remote hostnames and update local /etc/hosts -N,auto-nets automatically determine subnets to route -dns capture local DNS requests and forward to the remote DNS server -python= path to python interpreter on the remote server -r,remote= ssh hostname (and optional username) of remote sshuttle server -x,exclude= exclude this subnet (can be used more than once) -v,verbose increase debug message verbosity -e,ssh-cmd= the command to use to connect to the remote [ssh] -seed-hosts= with -H, use these hostnames for initial scan (comma-separated) -no-latency-control sacrifice latency to improve bandwidth benchmarks -wrap= restart counting channel numbers after this number (for testing) -D,daemon run in the background as a daemon -syslog send log messages to syslog (default if you use --daemon) -pidfile= pidfile name (only if using --daemon) [./sshuttle.pid] -server (internal use only) -firewall (internal use only) -hostwatch (internal use only) -""" -o = options.Options(optspec) -(opt, flags, extra) = o.parse(sys.argv[2:]) - -if opt.daemon: - opt.syslog = 1 -if opt.wrap: - import ssnet - ssnet.MAX_CHANNEL = int(opt.wrap) -helpers.verbose = opt.verbose - -try: - if opt.server: - if len(extra) != 0: - o.fatal('no arguments expected') - server.latency_control = opt.latency_control - sys.exit(server.main()) - elif opt.firewall: - if len(extra) != 2: - o.fatal('exactly two arguments expected') - sys.exit(firewall.main(int(extra[0]), int(extra[1]), opt.syslog)) - elif opt.hostwatch: - sys.exit(hostwatch.hw_main(extra)) - else: - if len(extra) < 1 and not opt.auto_nets: - o.fatal('at least one subnet (or -N) expected') - includes = extra - excludes = ['127.0.0.0/8'] - for k,v in flags: - if k in ('-x','--exclude'): - excludes.append(v) - remotename = opt.remote - if remotename == '' or remotename == '-': - remotename = None - if opt.seed_hosts and not opt.auto_hosts: - o.fatal('--seed-hosts only works if you also use -H') - if opt.seed_hosts: - sh = re.split(r'[\s,]+', (opt.seed_hosts or "").strip()) - elif opt.auto_hosts: - sh = [] - else: - sh = None - sys.exit(client.main(parse_ipport(opt.listen or '0.0.0.0:0'), - opt.ssh_cmd, - remotename, - opt.python, - opt.latency_control, - opt.dns, - sh, - opt.auto_nets, - parse_subnets(includes), - parse_subnets(excludes), - opt.syslog, opt.daemon, opt.pidfile)) -except Fatal, e: - log('fatal: %s\n' % e) - sys.exit(99) -except KeyboardInterrupt: - log('\n') - log('Keyboard interrupt: exiting.\n') - sys.exit(1) diff --git a/packaging/control b/packaging/control new file mode 100644 index 0000000..ff162eb --- /dev/null +++ b/packaging/control @@ -0,0 +1,26 @@ +Package: sshuttle +Version: 0.2 +Architecture: i386 +Maintainer: Jim Wyllie +Depends: autossh, upstart, python (>=2.6) +Section: utils +Priority: optional +Homepage: http://github.com/jwyllie83/sshuttle.udp +Description: "Full-featured" VPN over an SSH tunnel, allowing full remote + access somewhere where all you have is an SSH connection. It works well if + you generally find yourself in the following situation: + . + - Your client machine (or router) is Linux, FreeBSD, or MacOS. + - You have access to a remote network via ssh. + - You don't necessarily have admin access on the remote network. + - You do not wish to, or can't, use other VPN software + - You don't want to create an ssh port forward for every + single host/port on the remote network. + - You hate openssh's port forwarding because it's randomly + slow and/or stupid. + - You can't use openssh's PermitTunnel feature because + it's disabled by default on openssh servers; plus it does + TCP-over-TCP, which has suboptimal performance + . + It also has hooks for more complicated setups (VPN-in-a-SSH-VPN, etc) to allow + you to set it up as you like. diff --git a/packaging/make_deb b/packaging/make_deb new file mode 100755 index 0000000..96fa589 --- /dev/null +++ b/packaging/make_deb @@ -0,0 +1,41 @@ +#!/bin/bash +# +# This script puts together a .deb package suitable for installing on an Ubuntu +# system + +B="/tmp/sshuttle/build" + +if [ ! -x /usr/bin/dpkg ]; then + echo 'Unable to build: dpkg not found on system' + exit 1 +fi + +# Create the new directory structure +mkdir -p ${B}/etc/sshuttle/pre-start.d +mkdir -p ${B}/etc/sshuttle/post-stop.d +mkdir -p ${B}/usr/share/sshuttle +mkdir -p ${B}/usr/bin +mkdir -p ${B}/etc/init +mkdir -p ${B}/DEBIAN + +# Copy over all of the files +cp -r ../src/* ${B}/usr/share/sshuttle +cp ../src/sshuttle ${B}/usr/bin +cp -r sshuttle.conf ${B}/etc/init +cp prefixes.conf ${B}/etc/sshuttle +cp tunnel.conf ${B}/etc/sshuttle + +# Copy the control file over, as well +cp control ${B}/DEBIAN + +# Create the md5sum manifest +if [ -x /usr/bin/md5sum ]; then + cd ${B} + find . -type f | egrep -v DEBIAN | sed -re 's/^..//' | xargs md5sum > ${B}/DEBIAN/md5sums + cd ${OLDPWD} +fi + +# Build the debian package +VERSION=$(egrep -e '^Version' control | sed -re 's/^[^:]*: //') +dpkg --build ${B} ./sshuttle-${VERSION}.deb +rm -rf ${B} diff --git a/packaging/prefixes.conf b/packaging/prefixes.conf new file mode 100644 index 0000000..ca472b9 --- /dev/null +++ b/packaging/prefixes.conf @@ -0,0 +1,5 @@ +# Output prefixes here, one per line. Prefix is in: +# prefix/netmask format +# Like this: +# 192.168.0.0/16 +# 192.0.43.10/32 diff --git a/packaging/sshuttle.conf b/packaging/sshuttle.conf new file mode 100644 index 0000000..0adff3f --- /dev/null +++ b/packaging/sshuttle.conf @@ -0,0 +1,90 @@ +description "Create a transparent proxy over SSH" +author "Jim Wyllie " + +manual +nice -5 + +# Edit this file with network prefixes that should be loaded through the SSH +# tunnel. +env PREFIX_LOCATION=/etc/sshuttle/prefixes.conf + +# Routing table; defaults to 100 +env ROUTE_TABLE=100 + +# fwmark; defaults to 1 +env FWMARK=1 + +# SSH tunnel configuration file +env SSHUTTLE_TUNNEL_FILE=/etc/sshuttle/tunnel.conf + +# File containing the tunnel proxy name / host / whatever +env TUNNEL_PROXY="/etc/sshuttle/tunnel.conf" + +# Any other commands needed to run before or after loading the SSH tunnel. +# This is where you can put any of your hacks to set up tunnels-in-tunnels, +# etc. Scripts in this directory are executed in order. +env MISC_START_DIR=/etc/sshuttle/pre-start.d +env MISC_STOP_DIR=/etc/sshuttle/post-stop.d + +start on (local-filesystems and net-device-up IFACE!=lo) +stop on stopping network-services + +#respawn + +pre-start script + # Make sure we have created the routes + sudo ip rule add fwmark ${FWMARK} lookup ${ROUTE_TABLE} + logger "Starting sshuttle..." + + if [ -f "${PREFIX_LOCATION}" ]; then + cat "${PREFIX_LOCATION}" | while read ROUTE; do + + # Skip comments + if [ -n "$(echo ${ROUTE} | egrep "^[ ]*#")" ]; then + continue + fi + + # Skip empty lines + if [ -z "${ROUTE}" ]; then + continue + fi + + logger "Adding route: ${ROUTE}" + ip route add local ${ROUTE} dev lo table ${ROUTE_TABLE} + done + fi + + for RUNFILE in ${MISC_START_DIR}/*; do + logger "Executing ${RUNFILE}" + /bin/sh -c "${RUNFILE}" + done +end script + +post-stop script + if [ -f "${PREFIX_LOCATION}" ]; then + cat "${PREFIX_LOCATION}" | while read ROUTE; do + + # Skip comments + if [ -n "$(echo ${ROUTE} | egrep "^[ ]*#")" ]; then + continue + fi + + # Skip empty lines + if [ -z "${ROUTE}" ]; then + continue + fi + + logger "Deleting route: ${ROUTE}" + ip route del local ${ROUTE} dev lo table ${ROUTE_TABLE} + done + fi + + ip rule del fwmark ${FWMARK} + + for RUNFILE in "${MISC_STOP_DIR}/*"; do + logger "Executing ${RUNFILE}" + /bin/sh -c "${RUNFILE}" + done +end script + +exec /usr/bin/sshuttle --dns --method=tproxy --listen 0.0.0.0 --remote sshuttle_tunnel -s /etc/sshuttle/prefixes.conf -e "ssh -F ${TUNNEL_PROXY}" diff --git a/packaging/tunnel.conf b/packaging/tunnel.conf new file mode 100644 index 0000000..64fb54e --- /dev/null +++ b/packaging/tunnel.conf @@ -0,0 +1,19 @@ +# Here is where you can specify any SSH tunnel options See ssh_config(5) for +# details. You need to leave the Host line intact, but everything else can +# specify whatever you want +Host sshuttle_tunnel + +# REQUIRED: Set this to be the host to which you would like to connect your +# tunnel +#Hostname localhost + +# REQUIRED: Set this to be the target SSH user on the remote system +#User foo + +# --------------------------------------------------------------------------- +# The rest are all optional; see ssh_config(5) for the full list of what can +# be specified. Some very commonly needed ones are below. +# --------------------------------------------------------------------------- + +# SSH key used for connecting +#IdentityFile /path/to/key diff --git a/Makefile b/src/Makefile similarity index 100% rename from Makefile rename to src/Makefile diff --git a/all.do b/src/all.do similarity index 100% rename from all.do rename to src/all.do diff --git a/assembler.py b/src/assembler.py similarity index 100% rename from assembler.py rename to src/assembler.py diff --git a/clean.do b/src/clean.do similarity index 100% rename from clean.do rename to src/clean.do diff --git a/src/client.py b/src/client.py new file mode 100644 index 0000000..cb034cd --- /dev/null +++ b/src/client.py @@ -0,0 +1,718 @@ +import struct, select, errno, re, signal, time +import compat.ssubprocess as ssubprocess +import helpers, ssnet, ssh, ssyslog +from ssnet import SockWrapper, Handler, Proxy, Mux, MuxWrapper +from helpers import * + +recvmsg = None +try: + # try getting recvmsg from python + import socket as pythonsocket + getattr(pythonsocket.socket,"recvmsg") + socket = pythonsocket + recvmsg = "python" +except AttributeError: + # try getting recvmsg from socket_ext library + try: + import socket_ext + getattr(socket_ext.socket,"recvmsg") + socket = socket_ext + recvmsg = "socket_ext" + except ImportError: + import socket + +_extra_fd = os.open('/dev/null', os.O_RDONLY) + +def got_signal(signum, frame): + log('exiting on signal %d\n' % signum) + sys.exit(1) + + +_pidname = None +IP_TRANSPARENT = 19 +IP_ORIGDSTADDR = 20 +IP_RECVORIGDSTADDR = IP_ORIGDSTADDR +SOL_IPV6 = 41 +IPV6_ORIGDSTADDR = 74 +IPV6_RECVORIGDSTADDR = IPV6_ORIGDSTADDR + + +if recvmsg == "python": + def recv_udp(listener, bufsize): + debug3('Accept UDP python using recvmsg.\n') + data, ancdata, msg_flags, srcip = listener.recvmsg(4096,socket.CMSG_SPACE(24)) + dstip = None + family = None + for cmsg_level, cmsg_type, cmsg_data in ancdata: + if cmsg_level == socket.SOL_IP and cmsg_type == IP_ORIGDSTADDR: + family,port = struct.unpack('=HH', cmsg_data[0:4]) + port = socket.htons(port) + if family == socket.AF_INET: + start = 4 + length = 4 + else: + raise Fatal("Unsupported socket type '%s'"%family) + ip = socket.inet_ntop(family, cmsg_data[start:start+length]) + dstip = (ip, port) + break + elif cmsg_level == SOL_IPV6 and cmsg_type == IPV6_ORIGDSTADDR: + family,port = struct.unpack('=HH', cmsg_data[0:4]) + port = socket.htons(port) + if family == socket.AF_INET6: + start = 8 + length = 16 + else: + raise Fatal("Unsupported socket type '%s'"%family) + ip = socket.inet_ntop(family, cmsg_data[start:start+length]) + dstip = (ip, port) + break + return (srcip, dstip, data) +elif recvmsg == "socket_ext": + def recv_udp(listener, bufsize): + debug3('Accept UDP using socket_ext recvmsg.\n') + srcip, data, adata, flags = listener.recvmsg((bufsize,),socket.CMSG_SPACE(24)) + dstip = None + family = None + for a in adata: + if a.cmsg_level == socket.SOL_IP and a.cmsg_type == IP_ORIGDSTADDR: + family,port = struct.unpack('=HH', a.cmsg_data[0:4]) + port = socket.htons(port) + if family == socket.AF_INET: + start = 4 + length = 4 + else: + raise Fatal("Unsupported socket type '%s'"%family) + ip = socket.inet_ntop(family, a.cmsg_data[start:start+length]) + dstip = (ip, port) + break + elif a.cmsg_level == SOL_IPV6 and a.cmsg_type == IPV6_ORIGDSTADDR: + family,port = struct.unpack('=HH', a.cmsg_data[0:4]) + port = socket.htons(port) + if family == socket.AF_INET6: + start = 8 + length = 16 + else: + raise Fatal("Unsupported socket type '%s'"%family) + ip = socket.inet_ntop(family, a.cmsg_data[start:start+length]) + dstip = (ip, port) + break + return (srcip, dstip, data[0]) +else: + def recv_udp(listener, bufsize): + debug3('Accept UDP using recvfrom.\n') + data, srcip = listener.recvfrom(bufsize) + return (srcip, None, data) + + +def check_daemon(pidfile): + global _pidname + _pidname = os.path.abspath(pidfile) + try: + oldpid = open(_pidname).read(1024) + except IOError, e: + if e.errno == errno.ENOENT: + return # no pidfile, ok + else: + raise Fatal("can't read %s: %s" % (_pidname, e)) + if not oldpid: + os.unlink(_pidname) + return # invalid pidfile, ok + oldpid = int(oldpid.strip() or 0) + if oldpid <= 0: + os.unlink(_pidname) + return # invalid pidfile, ok + try: + os.kill(oldpid, 0) + except OSError, e: + if e.errno == errno.ESRCH: + os.unlink(_pidname) + return # outdated pidfile, ok + elif e.errno == errno.EPERM: + pass + else: + raise + raise Fatal("%s: sshuttle is already running (pid=%d)" + % (_pidname, oldpid)) + + +def daemonize(): + if os.fork(): + os._exit(0) + os.setsid() + if os.fork(): + os._exit(0) + + outfd = os.open(_pidname, os.O_WRONLY|os.O_CREAT|os.O_EXCL, 0666) + try: + os.write(outfd, '%d\n' % os.getpid()) + finally: + os.close(outfd) + os.chdir("/") + + # Normal exit when killed, or try/finally won't work and the pidfile won't + # be deleted. + signal.signal(signal.SIGTERM, got_signal) + + si = open('/dev/null', 'r+') + os.dup2(si.fileno(), 0) + os.dup2(si.fileno(), 1) + si.close() + + ssyslog.stderr_to_syslog() + + +def daemon_cleanup(): + try: + os.unlink(_pidname) + except OSError, e: + if e.errno == errno.ENOENT: + pass + else: + raise + + +def original_dst(sock): + try: + SO_ORIGINAL_DST = 80 + SOCKADDR_MIN = 16 + sockaddr_in = sock.getsockopt(socket.SOL_IP, + SO_ORIGINAL_DST, SOCKADDR_MIN) + (proto, port, a,b,c,d) = struct.unpack('=HHBBBB', sockaddr_in[:8]) + port = socket.htons(port) + assert(proto == socket.AF_INET) + ip = '%d.%d.%d.%d' % (a,b,c,d) + return (ip,port) + except socket.error, e: + if e.args[0] == errno.ENOPROTOOPT: + return sock.getsockname() + raise + + +class MultiListener: + + def __init__(self, type=socket.SOCK_STREAM, proto=0): + self.v6 = socket.socket(socket.AF_INET6, type, proto) + self.v4 = socket.socket(socket.AF_INET, type, proto) + + def setsockopt(self, level, optname, value): + if self.v6: + self.v6.setsockopt(level, optname, value) + if self.v4: + self.v4.setsockopt(level, optname, value) + + def add_handler(self, handlers, callback, method, mux): + if self.v6: + handlers.append(Handler([self.v6], lambda: callback(self.v6, method, mux, handlers))) + if self.v4: + handlers.append(Handler([self.v4], lambda: callback(self.v4, method, mux, handlers))) + + def listen(self, backlog): + if self.v6: + self.v6.listen(backlog) + if self.v4: + try: + self.v4.listen(backlog) + except socket.error, e: + # on some systems v4 bind will fail if the v6 suceeded, + # in this case the v6 socket will receive v4 too. + if e.errno == errno.EADDRINUSE and self.v6: + self.v4 = None + else: + raise e + + def bind(self, address_v6, address_v4): + if address_v6 and self.v6: + self.v6.bind(address_v6) + else: + self.v6 = None + if address_v4 and self.v4: + self.v4.bind(address_v4) + else: + self.v4 = None + + def print_listening(self, what): + if self.v6: + listenip = self.v6.getsockname() + debug1('%s listening on %r.\n' % (what, listenip)) + if self.v4: + listenip = self.v4.getsockname() + debug1('%s listening on %r.\n' % (what, listenip)) + + +class FirewallClient: + def __init__(self, port_v6, port_v4, subnets_include, subnets_exclude, dnsport_v6, dnsport_v4, method, udp): + self.auto_nets = [] + self.subnets_include = subnets_include + self.subnets_exclude = subnets_exclude + argvbase = ([sys.argv[1], sys.argv[0], sys.argv[1]] + + ['-v'] * (helpers.verbose or 0) + + ['--firewall', str(port_v6), str(port_v4), + str(dnsport_v6), str(dnsport_v4), + method, str(int(udp))]) + if ssyslog._p: + argvbase += ['--syslog'] + argv_tries = [ + ['sudo', '-p', '[local sudo] Password: '] + argvbase, + ['su', '-c', ' '.join(argvbase)], + argvbase + ] + + # 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(): + # run in the child process + s2.close() + e = None + if os.getuid() == 0: + argv_tries = argv_tries[-1:] # last entry only + for argv in argv_tries: + try: + if argv[0] == 'su': + sys.stderr.write('[local su] ') + self.p = ssubprocess.Popen(argv, stdout=s1, preexec_fn=setup) + e = None + break + except OSError, e: + pass + self.argv = argv + s1.close() + self.pfile = s2.makefile('wb+') + if e: + log('Spawning firewall manager: %r\n' % self.argv) + raise Fatal(e) + line = self.pfile.readline() + self.check() + if line[0:5] != 'READY': + raise Fatal('%r expected READY, got %r' % (self.argv, line)) + self.method = line[6:-1] + + def check(self): + rv = self.p.poll() + if rv: + raise Fatal('%r returned %d' % (self.argv, rv)) + + def start(self): + self.pfile.write('ROUTES\n') + for (family,ip,width) in self.subnets_include+self.auto_nets: + self.pfile.write('%d,%d,0,%s\n' % (family, width, ip)) + for (family,ip,width) in self.subnets_exclude: + self.pfile.write('%d,%d,1,%s\n' % (family, width, ip)) + self.pfile.write('GO\n') + self.pfile.flush() + line = self.pfile.readline() + self.check() + if line != 'STARTED\n': + raise Fatal('%r expected STARTED, got %r' % (self.argv, line)) + + def sethostip(self, hostname, ip): + assert(not re.search(r'[^-\w]', hostname)) + assert(not re.search(r'[^0-9.]', ip)) + self.pfile.write('HOST %s,%s\n' % (hostname, ip)) + self.pfile.flush() + + def done(self): + self.pfile.close() + rv = self.p.wait() + if rv: + raise Fatal('cleanup: %r returned %d' % (self.argv, rv)) + + +dnsreqs = {} +udp_by_src = {} +def expire_connections(now, mux): + for chan,timeout in dnsreqs.items(): + if timeout < now: + debug3('expiring dnsreqs channel=%d\n' % chan) + del mux.channels[chan] + del dnsreqs[chan] + debug3('Remaining DNS requests: %d\n' % len(dnsreqs)) + for peer,(chan,timeout) in udp_by_src.items(): + if timeout < now: + debug3('expiring UDP channel channel=%d peer=%r\n' % (chan, peer)) + mux.send(chan, ssnet.CMD_UDP_CLOSE, '') + del mux.channels[chan] + del udp_by_src[peer] + debug3('Remaining UDP channels: %d\n' % len(udp_by_src)) + + +def onaccept_tcp(listener, method, mux, handlers): + global _extra_fd + try: + sock,srcip = listener.accept() + except socket.error, e: + if e.args[0] in [errno.EMFILE, errno.ENFILE]: + debug1('Rejected incoming connection: too many open files!\n') + # free up an fd so we can eat the connection + os.close(_extra_fd) + try: + sock,srcip = listener.accept() + sock.close() + finally: + _extra_fd = os.open('/dev/null', os.O_RDONLY) + return + else: + raise + if method == "tproxy": + dstip = sock.getsockname(); + else: + dstip = original_dst(sock) + debug1('Accept TCP: %s:%r -> %s:%r.\n' % (srcip[0],srcip[1], + dstip[0],dstip[1])) + if dstip[1] == sock.getsockname()[1] and islocal(dstip[0], sock.family): + debug1("-- ignored: that's my address!\n") + sock.close() + return + chan = mux.next_channel() + if not chan: + log('warning: too many open channels. Discarded connection.\n') + sock.close() + return + mux.send(chan, ssnet.CMD_TCP_CONNECT, '%d,%s,%s' % (sock.family, dstip[0], dstip[1])) + outwrap = MuxWrapper(mux, chan) + handlers.append(Proxy(SockWrapper(sock, sock), outwrap)) + expire_connections(time.time(), mux) + + +def udp_done(chan, data, method, family, dstip): + (src,srcport,data) = data.split(",",2) + srcip = (src,int(srcport)) + debug3('doing send from %r to %r\n' % (srcip,dstip,)) + + try: + sender = socket.socket(family, socket.SOCK_DGRAM) + sender.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sender.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) + sender.bind(srcip) + sender.sendto(data, dstip) + sender.close() + except socket.error, e: + debug1('-- ignored socket error sending UDP data: %r\n'%e) + + +def onaccept_udp(listener, method, mux, handlers): + now = time.time() + srcip, dstip, data = recv_udp(listener, 4096) + if not dstip: + debug1("-- ignored UDP from %r: couldn't determine destination IP address\n" % (srcip,)) + return + debug1('Accept UDP: %r -> %r.\n' % (srcip,dstip,)) + if srcip in udp_by_src: + chan,timeout = udp_by_src[srcip] + else: + chan = mux.next_channel() + mux.channels[chan] = lambda cmd,data: udp_done(chan, data, method, listener.family, dstip=srcip) + mux.send(chan, ssnet.CMD_UDP_OPEN, listener.family) + udp_by_src[srcip] = chan,now+30 + + hdr = "%s,%r,"%(dstip[0], dstip[1]) + mux.send(chan, ssnet.CMD_UDP_DATA, hdr+data) + + expire_connections(now, mux) + + +def dns_done(chan, data, method, sock, srcip, dstip, mux): + debug3('dns_done: channel=%d src=%r dst=%r\n' % (chan,srcip,dstip)) + del mux.channels[chan] + del dnsreqs[chan] + if method == "tproxy": + debug3('doing send from %r to %r\n' % (srcip,dstip,)) + sender = socket.socket(sock.family, socket.SOCK_DGRAM) + sender.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sender.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) + sender.bind(srcip) + sender.sendto(data, dstip) + sender.close() + else: + debug3('doing sendto %r\n' % (dstip,)) + sock.sendto(data, dstip) + + +def ondns(listener, method, mux, handlers): + now = time.time() + srcip, dstip, data = recv_udp(listener, 4096) + if method == "tproxy" and not dstip: + debug1("-- ignored UDP from %r: couldn't determine destination IP address\n" % (srcip,)) + return + debug1('DNS request from %r to %r: %d bytes\n' % (srcip,dstip,len(data))) + chan = mux.next_channel() + dnsreqs[chan] = now+30 + mux.send(chan, ssnet.CMD_DNS_REQ, data) + mux.channels[chan] = lambda cmd,data: dns_done(chan, data, method, listener, srcip=dstip, dstip=srcip, mux=mux) + expire_connections(now, mux) + + +def _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename, python, latency_control, + dns_listener, method, seed_hosts, auto_nets, + syslog, daemon): + handlers = [] + if helpers.verbose >= 1: + helpers.logprefix = 'c : ' + else: + helpers.logprefix = 'client: ' + debug1('connecting to server...\n') + + try: + (serverproc, serversock) = ssh.connect(ssh_cmd, remotename, python, + stderr=ssyslog._p and ssyslog._p.stdin, + options=dict(latency_control=latency_control, method=method)) + except socket.error, e: + if e.args[0] == errno.EPIPE: + raise Fatal("failed to establish ssh session (1)") + else: + raise + mux = Mux(serversock, serversock) + handlers.append(mux) + + expected = 'SSHUTTLE0001' + + try: + v = 'x' + while v and v != '\0': + v = serversock.recv(1) + v = 'x' + while v and v != '\0': + v = serversock.recv(1) + initstring = serversock.recv(len(expected)) + except socket.error, e: + if e.args[0] == errno.ECONNRESET: + raise Fatal("failed to establish ssh session (2)") + else: + raise + + rv = serverproc.poll() + if rv: + raise Fatal('server died with error code %d' % rv) + + if initstring != expected: + raise Fatal('expected server init string %r; got %r' + % (expected, initstring)) + debug1('connected.\n') + print 'Connected.' + sys.stdout.flush() + if daemon: + daemonize() + log('daemonizing (%s).\n' % _pidname) + elif syslog: + debug1('switching to syslog.\n') + ssyslog.stderr_to_syslog() + + def onroutes(routestr): + if auto_nets: + for line in routestr.strip().split('\n'): + (family,ip,width) = line.split(',', 2) + fw.auto_nets.append((family,ip,int(width))) + + # we definitely want to do this *after* starting ssh, or we might end + # up intercepting the ssh connection! + # + # Moreover, now that we have the --auto-nets option, we have to wait + # for the server to send us that message anyway. Even if we haven't + # set --auto-nets, we might as well wait for the message first, then + # ignore its contents. + mux.got_routes = None + fw.start() + mux.got_routes = onroutes + + def onhostlist(hostlist): + debug2('got host list: %r\n' % hostlist) + for line in hostlist.strip().split(): + if line: + name,ip = line.split(',', 1) + fw.sethostip(name, ip) + mux.got_host_list = onhostlist + + tcp_listener.add_handler(handlers, onaccept_tcp, method, mux) + + if udp_listener: + udp_listener.add_handler(handlers, onaccept_udp, method, mux) + + if dns_listener: + dns_listener.add_handler(handlers, ondns, method, mux) + + if seed_hosts != None: + debug1('seed_hosts: %r\n' % seed_hosts) + mux.send(0, ssnet.CMD_HOST_REQ, '\n'.join(seed_hosts)) + + while 1: + rv = serverproc.poll() + if rv: + raise Fatal('server died with error code %d' % rv) + + ssnet.runonce(handlers, mux) + if latency_control: + mux.check_fullness() + mux.callback() + + +def main(listenip_v6, listenip_v4, + ssh_cmd, remotename, python, latency_control, dns, + method, seed_hosts, auto_nets, + subnets_include, subnets_exclude, syslog, daemon, pidfile): + + if syslog: + ssyslog.start_syslog() + if daemon: + try: + check_daemon(pidfile) + except Fatal, e: + log("%s\n" % e) + return 5 + debug1('Starting sshuttle proxy.\n') + + if recvmsg is not None: + debug1("recvmsg %s support enabled.\n"%recvmsg) + + if method == "tproxy": + if recvmsg is not None: + debug1("tproxy UDP support enabled.\n") + udp = True + else: + debug1("tproxy UDP support requires recvmsg function.\n") + udp = False + if dns and recvmsg is None: + debug1("tproxy DNS support requires recvmsg function.\n") + dns = False + else: + debug1("UDP support requires tproxy; disabling UDP.\n") + udp = False + + if listenip_v6 and listenip_v6[1] and listenip_v4 and listenip_v4[1]: + # if both ports given, no need to search for a spare port + ports = [ 0, ] + else: + # if at least one port missing, we have to search + ports = xrange(12300,9000,-1) + + # search for free ports and try to bind + last_e = None + redirectport_v6 = 0 + redirectport_v4 = 0 + bound = False + debug2('Binding redirector:') + for port in ports: + debug2(' %d' % port) + tcp_listener = MultiListener() + tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + if udp: + udp_listener = MultiListener(socket.SOCK_DGRAM) + udp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + else: + udp_listener = None + + if listenip_v6 and listenip_v6[1]: + lv6 = listenip_v6 + redirectport_v6 = lv6[1] + elif listenip_v6: + lv6 = (listenip_v6[0],port) + redirectport_v6 = port + else: + lv6 = None + redirectport_v6 = 0 + + if listenip_v4 and listenip_v4[1]: + lv4 = listenip_v4 + redirectport_v4 = lv4[1] + elif listenip_v4: + lv4 = (listenip_v4[0],port) + redirectport_v4 = port + else: + lv4 = None + redirectport_v4 = 0 + + try: + tcp_listener.bind(lv6, lv4) + if udp_listener: + udp_listener.bind(lv6, lv4) + bound = True + break + except socket.error, e: + if e.errno == errno.EADDRINUSE: + last_e = e + else: + raise e + debug2('\n') + if not bound: + assert(last_e) + raise last_e + tcp_listener.listen(10) + tcp_listener.print_listening("TCP redirector") + if udp_listener: + udp_listener.print_listening("UDP redirector") + + bound = False + if dns: + # search for spare port for DNS + debug2('Binding DNS:') + ports = xrange(12300,9000,-1) + for port in ports: + debug2(' %d' % port) + dns_listener = MultiListener(socket.SOCK_DGRAM) + + if listenip_v6: + lv6 = (listenip_v6[0],port) + dnsport_v6 = port + else: + lv6 = None + dnsport_v6 = 0 + + if listenip_v4: + lv4 = (listenip_v4[0],port) + dnsport_v4 = port + else: + lv4 = None + dnsport_v4 = 0 + + try: + dns_listener.bind(lv6, lv4) + bound = True + break + except socket.error, e: + if e.errno == errno.EADDRINUSE: + last_e = e + else: + raise e + debug2('\n') + dns_listener.print_listening("DNS") + if not bound: + assert(last_e) + raise last_e + else: + dnsport_v6 = 0 + dnsport_v4 = 0 + dns_listener = None + + fw = FirewallClient(redirectport_v6, redirectport_v4, subnets_include, subnets_exclude, dnsport_v6, dnsport_v4, method, udp) + + if fw.method == "tproxy": + tcp_listener.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) + if udp_listener: + udp_listener.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) + if udp_listener.v4 is not None: + udp_listener.v4.setsockopt(socket.SOL_IP, IP_RECVORIGDSTADDR, 1) + if udp_listener.v6 is not None: + udp_listener.v6.setsockopt(SOL_IPV6, IPV6_RECVORIGDSTADDR, 1) + if dns_listener: + dns_listener.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) + if dns_listener.v4 is not None: + dns_listener.v4.setsockopt(socket.SOL_IP, IP_RECVORIGDSTADDR, 1) + if dns_listener.v6 is not None: + dns_listener.v6.setsockopt(SOL_IPV6, IPV6_RECVORIGDSTADDR, 1) + + try: + return _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename, + python, latency_control, dns_listener, + fw.method, seed_hosts, auto_nets, syslog, + daemon) + finally: + try: + if daemon: + # it's not our child anymore; can't waitpid + fw.p.returncode = 0 + fw.done() + finally: + if daemon: + daemon_cleanup() diff --git a/compat/__init__.py b/src/compat/__init__.py similarity index 100% rename from compat/__init__.py rename to src/compat/__init__.py diff --git a/compat/ssubprocess.py b/src/compat/ssubprocess.py similarity index 100% rename from compat/ssubprocess.py rename to src/compat/ssubprocess.py diff --git a/default.8.do b/src/default.8.do similarity index 100% rename from default.8.do rename to src/default.8.do diff --git a/do b/src/do similarity index 100% rename from do rename to src/do diff --git a/firewall.py b/src/firewall.py similarity index 66% rename from firewall.py rename to src/firewall.py index 4fd8c79..1e233c3 100644 --- a/firewall.py +++ b/src/firewall.py @@ -14,8 +14,14 @@ def nonfatal(func, *args): log('error: %s\n' % e) -def ipt_chain_exists(name): - argv = ['iptables', '-t', 'nat', '-nL'] +def ipt_chain_exists(family, table, name): + if family == socket.AF_INET6: + cmd = 'ip6tables' + elif family == socket.AF_INET: + cmd = 'iptables' + else: + raise Exception('Unsupported family "%s"'%family_to_string(family)) + argv = [cmd, '-t', table, '-nL'] p = ssubprocess.Popen(argv, stdout = ssubprocess.PIPE) for line in p.stdout: if line.startswith('Chain %s ' % name): @@ -25,8 +31,13 @@ def ipt_chain_exists(name): raise Fatal('%r returned %d' % (argv, rv)) -def ipt(*args): - argv = ['iptables', '-t', 'nat'] + list(args) +def _ipt(family, table, *args): + if family == socket.AF_INET6: + argv = ['ip6tables', '-t', table] + list(args) + elif family == socket.AF_INET: + argv = ['iptables', '-t', table] + list(args) + else: + raise Exception('Unsupported family "%s"'%family_to_string(family)) debug1('>> %s\n' % ' '.join(argv)) rv = ssubprocess.call(argv) if rv: @@ -34,7 +45,7 @@ def ipt(*args): _no_ttl_module = False -def ipt_ttl(*args): +def _ipt_ttl(family, *args): global _no_ttl_module if not _no_ttl_module: # we avoid infinite loops by generating server-side connections @@ -42,16 +53,15 @@ def ipt_ttl(*args): # connections, in case client == server. try: argsplus = list(args) + ['-m', 'ttl', '!', '--ttl', '42'] - ipt(*argsplus) + _ipt(family, *argsplus) except Fatal: - ipt(*args) + _ipt(family, *args) # we only get here if the non-ttl attempt succeeds log('sshuttle: warning: your iptables is missing ' 'the ttl module.\n') _no_ttl_module = True else: - ipt(*args) - + _ipt(family, *args) # We name the chain based on the transproxy port number so that it's possible @@ -59,11 +69,23 @@ def ipt_ttl(*args): # multiple copies shouldn't have overlapping subnets, or only the most- # recently-started one will win (because we use "-I OUTPUT 1" instead of # "-A OUTPUT"). -def do_iptables(port, dnsport, subnets): +def do_iptables_nat(port, dnsport, family, subnets, udp): + # only ipv4 supported with NAT + if family != socket.AF_INET: + raise Exception('Address family "%s" unsupported by nat method'%family_to_string(family)) + if udp: + raise Exception("UDP not supported by nat method") + + table = "nat" + def ipt(*args): + return _ipt(family, table, *args) + def ipt_ttl(*args): + return _ipt_ttl(family, table, *args) + chain = 'sshuttle-%s' % port # basic cleanup/setup of chains - if ipt_chain_exists(chain): + if ipt_chain_exists(family, table, chain): nonfatal(ipt, '-D', 'OUTPUT', '-j', chain) nonfatal(ipt, '-D', 'PREROUTING', '-j', chain) nonfatal(ipt, '-F', chain) @@ -81,7 +103,7 @@ def do_iptables(port, dnsport, subnets): # to least-specific, and at any given level of specificity, we want # excludes to come first. That's why the columns are in such a non- # intuitive order. - for swidth,sexclude,snet in sorted(subnets, reverse=True): + for f,swidth,sexclude,snet in sorted(subnets, key=lambda s: s[1], reverse=True): if sexclude: ipt('-A', chain, '-j', 'RETURN', '--dest', '%s/%s' % (snet,swidth), @@ -91,10 +113,10 @@ def do_iptables(port, dnsport, subnets): '--dest', '%s/%s' % (snet,swidth), '-p', 'tcp', '--to-ports', str(port)) - + if dnsport: nslist = resolvconf_nameservers() - for ip in nslist: + for f,ip in filter(lambda i: i[0]==family, nslist): ipt_ttl('-A', chain, '-j', 'REDIRECT', '--dest', '%s/32' % ip, '-p', 'udp', @@ -102,6 +124,98 @@ def do_iptables(port, dnsport, subnets): '--to-ports', str(dnsport)) +def do_iptables_tproxy(port, dnsport, family, subnets, udp): + if family not in [socket.AF_INET, socket.AF_INET6]: + raise Exception('Address family "%s" unsupported by tproxy method'%family_to_string(family)) + + table = "mangle" + def ipt(*args): + return _ipt(family, table, *args) + def ipt_ttl(*args): + return _ipt_ttl(family, table, *args) + + mark_chain = 'sshuttle-m-%s' % port + tproxy_chain = 'sshuttle-t-%s' % port + divert_chain = 'sshuttle-d-%s' % port + + # basic cleanup/setup of chains + if ipt_chain_exists(family, table, mark_chain): + ipt('-D', 'OUTPUT', '-j', mark_chain) + ipt('-F', mark_chain) + ipt('-X', mark_chain) + + if ipt_chain_exists(family, table, tproxy_chain): + ipt('-D', 'PREROUTING', '-j', tproxy_chain) + ipt('-F', tproxy_chain) + ipt('-X', tproxy_chain) + + if ipt_chain_exists(family, table, divert_chain): + ipt('-F', divert_chain) + ipt('-X', divert_chain) + + if subnets or dnsport: + ipt('-N', mark_chain) + ipt('-F', mark_chain) + ipt('-N', divert_chain) + ipt('-F', divert_chain) + ipt('-N', tproxy_chain) + ipt('-F', tproxy_chain) + ipt('-I', 'OUTPUT', '1', '-j', mark_chain) + ipt('-I', 'PREROUTING', '1', '-j', tproxy_chain) + ipt('-A', divert_chain, '-j', 'MARK', '--set-mark', '1') + ipt('-A', divert_chain, '-j', 'ACCEPT') + ipt('-A', tproxy_chain, '-m', 'socket', '-j', divert_chain, + '-m', 'tcp', '-p', 'tcp') + if subnets and udp: + ipt('-A', tproxy_chain, '-m', 'socket', '-j', divert_chain, + '-m', 'udp', '-p', 'udp') + + if dnsport: + nslist = resolvconf_nameservers() + for f,ip in filter(lambda i: i[0]==family, nslist): + ipt('-A', mark_chain, '-j', 'MARK', '--set-mark', '1', + '--dest', '%s/32' % ip, + '-m', 'udp', '-p', 'udp', '--dport', '53') + ipt('-A', tproxy_chain, '-j', 'TPROXY', '--tproxy-mark', '0x1/0x1', + '--dest', '%s/32' % ip, + '-m', 'udp', '-p', 'udp', '--dport', '53', + '--on-port', str(dnsport)) + + if subnets: + for f,swidth,sexclude,snet in sorted(subnets, key=lambda s: s[1], reverse=True): + if sexclude: + ipt('-A', mark_chain, '-j', 'RETURN', + '--dest', '%s/%s' % (snet,swidth), + '-m', 'tcp', '-p', 'tcp') + ipt('-A', tproxy_chain, '-j', 'RETURN', + '--dest', '%s/%s' % (snet,swidth), + '-m', 'tcp', '-p', 'tcp') + else: + ipt('-A', mark_chain, '-j', 'MARK', '--set-mark', '1', + '--dest', '%s/%s' % (snet,swidth), + '-m', 'tcp', '-p', 'tcp') + ipt('-A', tproxy_chain, '-j', 'TPROXY', '--tproxy-mark', '0x1/0x1', + '--dest', '%s/%s' % (snet,swidth), + '-m', 'tcp', '-p', 'tcp', + '--on-port', str(port)) + + if sexclude and udp: + ipt('-A', mark_chain, '-j', 'RETURN', + '--dest', '%s/%s' % (snet,swidth), + '-m', 'udp', '-p', 'udp') + ipt('-A', tproxy_chain, '-j', 'RETURN', + '--dest', '%s/%s' % (snet,swidth), + '-m', 'udp', '-p', 'udp') + elif udp: + ipt('-A', mark_chain, '-j', 'MARK', '--set-mark', '1', + '--dest', '%s/%s' % (snet,swidth), + '-m', 'udp', '-p', 'udp') + ipt('-A', tproxy_chain, '-j', 'TPROXY', '--tproxy-mark', '0x1/0x1', + '--dest', '%s/%s' % (snet,swidth), + '-m', 'udp', '-p', 'udp', + '--on-port', str(port)) + + def ipfw_rule_exists(n): argv = ['ipfw', 'list'] p = ssubprocess.Popen(argv, stdout = ssubprocess.PIPE) @@ -207,7 +321,13 @@ def ipfw(*args): raise Fatal('%r returned %d' % (argv, rv)) -def do_ipfw(port, dnsport, subnets): +def do_ipfw(port, dnsport, family, subnets, udp): + # IPv6 not supported + if family not in [socket.AF_INET, ]: + raise Exception('Address family "%s" unsupported by ipfw method'%family_to_string(family)) + if udp: + raise Exception("UDP not supported by ipfw method") + sport = str(port) xsport = str(port+1) @@ -240,7 +360,7 @@ def do_ipfw(port, dnsport, subnets): if subnets: # create new subnet entries - for swidth,sexclude,snet in sorted(subnets, reverse=True): + for f,swidth,sexclude,snet in sorted(subnets, key=lambda s: s[1], reverse=True): if sexclude: ipfw('add', sport, 'skipto', xsport, 'log', 'tcp', @@ -286,7 +406,7 @@ def do_ipfw(port, dnsport, subnets): divertsock.bind(('0.0.0.0', port)) # IP field is ignored nslist = resolvconf_nameservers() - for ip in nslist: + for f,ip in filter(lambda i: i[0]==family, nslist): # relabel and then catch outgoing DNS requests ipfw('add', sport, 'divert', sport, 'log', 'udp', @@ -369,21 +489,35 @@ def restore_etc_hosts(port): # exit. In case that fails, it's not the end of the world; future runs will # supercede it in the transproxy list, at least, so the leftover rules # are hopefully harmless. -def main(port, dnsport, syslog): - assert(port > 0) - assert(port <= 65535) - assert(dnsport >= 0) - assert(dnsport <= 65535) +def main(port_v6, port_v4, dnsport_v6, dnsport_v4, method, udp, syslog): + assert(port_v6 >= 0) + assert(port_v6 <= 65535) + assert(port_v4 >= 0) + assert(port_v4 <= 65535) + assert(dnsport_v6 >= 0) + assert(dnsport_v6 <= 65535) + assert(dnsport_v4 >= 0) + assert(dnsport_v4 <= 65535) if os.getuid() != 0: raise Fatal('you must be root (or enable su/sudo) to set the firewall') - if program_exists('ipfw'): + if method == "auto": + if program_exists('ipfw'): + method = "ipfw" + elif program_exists('iptables'): + method = "nat" + else: + raise Fatal("can't find either ipfw or iptables; check your PATH") + + if method == "nat": + do_it = do_iptables_nat + elif method == "tproxy": + do_it = do_iptables_tproxy + elif method == "ipfw": do_it = do_ipfw - elif program_exists('iptables'): - do_it = do_iptables else: - raise Fatal("can't find either ipfw or iptables; check your PATH") + raise Exception('Unknown method "%s"'%method) # because of limitations of the 'su' command, the *real* stdin/stdout # are both attached to stdout initially. Clone stdout into stdin so we @@ -394,8 +528,8 @@ def main(port, dnsport, syslog): ssyslog.start_syslog() ssyslog.stderr_to_syslog() - debug1('firewall manager ready.\n') - sys.stdout.write('READY\n') + debug1('firewall manager ready method %s.\n'%method) + sys.stdout.write('READY %s\n'%method) sys.stdout.flush() # ctrl-c shouldn't be passed along to me. When the main sshuttle dies, @@ -419,15 +553,27 @@ def main(port, dnsport, syslog): elif line == 'GO\n': break try: - (width,exclude,ip) = line.strip().split(',', 2) + (family,width,exclude,ip) = line.strip().split(',', 3) except: raise Fatal('firewall: expected route or GO but got %r' % line) - subnets.append((int(width), bool(int(exclude)), ip)) + subnets.append((int(family), int(width), bool(int(exclude)), ip)) try: if line: debug1('firewall manager: starting transproxy.\n') - do_wait = do_it(port, dnsport, subnets) + + subnets_v6 = filter(lambda i: i[0]==socket.AF_INET6, subnets) + if port_v6: + do_wait = do_it(port_v6, dnsport_v6, socket.AF_INET6, subnets_v6, udp) + elif len(subnets_v6) > 0: + debug1("IPv6 subnets defined but IPv6 disabled\n") + + subnets_v4 = filter(lambda i: i[0]==socket.AF_INET, subnets) + if port_v4: + do_wait = do_it(port_v4, dnsport_v4, socket.AF_INET, subnets_v4, udp) + elif len(subnets_v4) > 0: + debug1('IPv4 subnets defined but IPv4 disabled\n') + sys.stdout.write('STARTED\n') try: @@ -446,7 +592,7 @@ def main(port, dnsport, syslog): if line.startswith('HOST '): (name,ip) = line[5:].strip().split(',', 1) hostmap[name] = ip - rewrite_etc_hosts(port) + rewrite_etc_hosts(port_v6 or port_v4) elif line: raise Fatal('expected EOF, got %r' % line) else: @@ -456,5 +602,8 @@ def main(port, dnsport, syslog): debug1('firewall manager: undoing changes.\n') except: pass - do_it(port, 0, []) - restore_etc_hosts(port) + if port_v6: + do_it(port_v6, 0, socket.AF_INET6, [], udp) + if port_v4: + do_it(port_v4, 0, socket.AF_INET, [], udp) + restore_etc_hosts(port_v6 or port_v4) diff --git a/helpers.py b/src/helpers.py similarity index 70% rename from helpers.py rename to src/helpers.py index c169d0c..f08b731 100644 --- a/helpers.py +++ b/src/helpers.py @@ -1,4 +1,4 @@ -import sys, os, socket +import sys, os, socket, errno logprefix = '' verbose = 0 @@ -42,7 +42,10 @@ def resolvconf_nameservers(): for line in open('/etc/resolv.conf'): words = line.lower().split() if len(words) >= 2 and words[0] == 'nameserver': - l.append(words[1]) + if ':' in words[1]: + l.append((socket.AF_INET6,words[1])) + else: + l.append((socket.AF_INET,words[1])) return l @@ -55,11 +58,11 @@ def resolvconf_random_nameserver(): random.shuffle(l) return l[0] else: - return '127.0.0.1' + return (socket.AF_INET,'127.0.0.1') -def islocal(ip): - sock = socket.socket() +def islocal(ip,family): + sock = socket.socket(family) try: try: sock.bind((ip, 0)) @@ -73,3 +76,18 @@ def islocal(ip): return True # it's a local IP, or there would have been an error +def guess_address_family(ip): + if ':' in ip: + return socket.AF_INET6 + else: + return socket.AF_INET + + +def family_to_string(family): + if family == socket.AF_INET6: + return "AF_INET6" + elif family == socket.AF_INET: + return "AF_INET" + else: + return str(family) + diff --git a/hostwatch.py b/src/hostwatch.py similarity index 100% rename from hostwatch.py rename to src/hostwatch.py diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..8d277c9 --- /dev/null +++ b/src/main.py @@ -0,0 +1,221 @@ +import sys, os, re, socket +import helpers, options, client, server, firewall, hostwatch +import compat.ssubprocess as ssubprocess +from helpers import * + + +# 1.2.3.4/5 or just 1.2.3.4 +def parse_subnet4(s): + m = re.match(r'(\d+)(?:\.(\d+)\.(\d+)\.(\d+))?(?:/(\d+))?$', s) + if not m: + raise Fatal('%r is not a valid IP subnet format' % s) + (a,b,c,d,width) = m.groups() + (a,b,c,d) = (int(a or 0), int(b or 0), int(c or 0), int(d or 0)) + if width == None: + width = 32 + else: + width = int(width) + if a > 255 or b > 255 or c > 255 or d > 255: + raise Fatal('%d.%d.%d.%d has numbers > 255' % (a,b,c,d)) + if width > 32: + raise Fatal('*/%d is greater than the maximum of 32' % width) + return(socket.AF_INET, '%d.%d.%d.%d' % (a,b,c,d), width) + + +# 1:2::3/64 or just 1:2::3 +def parse_subnet6(s): + m = re.match(r'(?:([a-fA-F\d:]+))?(?:/(\d+))?$', s) + if not m: + raise Fatal('%r is not a valid IP subnet format' % s) + (net,width) = m.groups() + if width == None: + width = 128 + else: + width = int(width) + if width > 128: + raise Fatal('*/%d is greater than the maximum of 128' % width) + return(socket.AF_INET6, net, width) + + +# Subnet file, supporting empty lines and hash-started comment lines +def parse_subnet_file(s): + try: + handle = open(s, 'r') + except OSError, e: + raise Fatal('Unable to open subnet file: %s' % s) + + raw_config_lines = handle.readlines() + config_lines = [] + for line_no, line in enumerate(raw_config_lines): + line = line.strip() + if len(line) == 0: + continue + if line[0] == '#': + continue + config_lines.append(line) + + return config_lines + + +# list of: +# 1.2.3.4/5 or just 1.2.3.4 +# 1:2::3/64 or just 1:2::3 +def parse_subnets(subnets_str): + subnets = [] + for s in subnets_str: + if ':' in s: + subnet = parse_subnet6(s) + else: + subnet = parse_subnet4(s) + subnets.append(subnet) + return subnets + + +# 1.2.3.4:567 or just 1.2.3.4 or just 567 +def parse_ipport4(s): + s = str(s) + m = re.match(r'(?:(\d+)\.(\d+)\.(\d+)\.(\d+))?(?::)?(?:(\d+))?$', s) + if not m: + raise Fatal('%r is not a valid IP:port format' % s) + (a,b,c,d,port) = m.groups() + (a,b,c,d,port) = (int(a or 0), int(b or 0), int(c or 0), int(d or 0), + int(port or 0)) + if a > 255 or b > 255 or c > 255 or d > 255: + raise Fatal('%d.%d.%d.%d has numbers > 255' % (a,b,c,d)) + if port > 65535: + raise Fatal('*:%d is greater than the maximum of 65535' % port) + if a == None: + a = b = c = d = 0 + return ('%d.%d.%d.%d' % (a,b,c,d), port) + + +# [1:2::3]:456 or [1:2::3] or 456 +def parse_ipport6(s): + s = str(s) + m = re.match(r'(?:\[([^]]*)])?(?::)?(?:(\d+))?$', s) + if not m: + raise Fatal('%s is not a valid IP:port format' % s) + (ip,port) = m.groups() + (ip,port) = (ip or '::', int(port or 0)) + return (ip, port) + + +optspec = """ +sshuttle [-l [ip:]port] [-r [username@]sshserver[:port]] +sshuttle --server +sshuttle --firewall +sshuttle --hostwatch +-- +l,listen= transproxy to this ip address and port number +H,auto-hosts scan for remote hostnames and update local /etc/hosts +N,auto-nets automatically determine subnets to route +dns capture local DNS requests and forward to the remote DNS server +method= auto, nat, tproxy, or ipfw +python= path to python interpreter on the remote server +r,remote= ssh hostname (and optional username) of remote sshuttle server +x,exclude= exclude this subnet (can be used more than once) +v,verbose increase debug message verbosity +e,ssh-cmd= the command to use to connect to the remote [ssh] +seed-hosts= with -H, use these hostnames for initial scan (comma-separated) +no-latency-control sacrifice latency to improve bandwidth benchmarks +wrap= restart counting channel numbers after this number (for testing) +D,daemon run in the background as a daemon +s,subnets= file where the subnets are stored, instead of on the command line +syslog send log messages to syslog (default if you use --daemon) +pidfile= pidfile name (only if using --daemon) [./sshuttle.pid] +server (internal use only) +firewall (internal use only) +hostwatch (internal use only) +""" +o = options.Options(optspec) +(opt, flags, extra) = o.parse(sys.argv[2:]) + +if opt.daemon: + opt.syslog = 1 +if opt.wrap: + import ssnet + ssnet.MAX_CHANNEL = int(opt.wrap) +helpers.verbose = opt.verbose + +try: + if opt.server: + if len(extra) != 0: + o.fatal('no arguments expected') + server.latency_control = opt.latency_control + sys.exit(server.main()) + elif opt.firewall: + if len(extra) != 6: + o.fatal('exactly six arguments expected') + sys.exit(firewall.main(int(extra[0]), int(extra[1]), + int(extra[2]), int(extra[3]), + extra[4], int(extra[5]), opt.syslog)) + elif opt.hostwatch: + sys.exit(hostwatch.hw_main(extra)) + else: + if len(extra) < 1 and not opt.auto_nets and not opt.subnets: + o.fatal('at least one subnet, subnet file, or -N expected') + includes = extra + excludes = ['127.0.0.0/8'] + for k,v in flags: + if k in ('-x','--exclude'): + excludes.append(v) + remotename = opt.remote + if remotename == '' or remotename == '-': + remotename = None + if opt.seed_hosts and not opt.auto_hosts: + o.fatal('--seed-hosts only works if you also use -H') + if opt.seed_hosts: + sh = re.split(r'[\s,]+', (opt.seed_hosts or "").strip()) + elif opt.auto_hosts: + sh = [] + else: + sh = None + if opt.subnets: + includes = parse_subnet_file(opt.subnets) + if not opt.method: + method = "auto" + elif opt.method in [ "auto", "nat", "tproxy", "ipfw" ]: + method = opt.method + else: + o.fatal("method %s not supported"%opt.method) + if not opt.listen: + if opt.method == "tproxy": + ipport_v6 = parse_ipport6('[::1]:0') + else: + ipport_v6 = None + ipport_v4 = parse_ipport4('127.0.0.1:0') + else: + ipport_v6 = None + ipport_v4 = None + list = opt.listen.split(",") + for ip in list: + if '[' in ip and ']' in ip and opt.method == "tproxy": + ipport_v6 = parse_ipport6(ip) + else: + ipport_v4 = parse_ipport4(ip) + return_code = client.main(ipport_v6, ipport_v4, + opt.ssh_cmd, + remotename, + opt.python, + opt.latency_control, + opt.dns, + method, + sh, + opt.auto_nets, + parse_subnets(includes), + parse_subnets(excludes), + opt.syslog, opt.daemon, opt.pidfile) + + if return_code == 0: + log('Normal exit code, exiting...') + else: + log('Abnormal exit code detected, failing...' % return_code) + sys.exit(return_code) + +except Fatal, e: + log('fatal: %s\n' % e) + sys.exit(99) +except KeyboardInterrupt: + log('\n') + log('Keyboard interrupt: exiting.\n') + sys.exit(1) diff --git a/options.py b/src/options.py similarity index 100% rename from options.py rename to src/options.py diff --git a/server.py b/src/server.py similarity index 69% rename from server.py rename to src/server.py index e1b327d..bc0bea8 100644 --- a/server.py +++ b/src/server.py @@ -59,7 +59,7 @@ def _list_routes(): mask = _maskbits(maskw) # returns 32 if maskw is null width = min(ipw[1], mask) ip = ipw[0] & _shl(_shl(1, width) - 1, 32-width) - routes.append((socket.inet_ntoa(struct.pack('!I', ip)), width)) + routes.append((socket.AF_INET, socket.inet_ntoa(struct.pack('!I', ip)), width)) rv = p.wait() if rv != 0: log('WARNING: %r returned %d\n' % (argv, rv)) @@ -68,9 +68,9 @@ def _list_routes(): def list_routes(): - for (ip,width) in _list_routes(): + for (family, ip,width) in _list_routes(): if not ip.startswith('0.') and not ip.startswith('127.'): - yield (ip,width) + yield (family, ip,width) def _exc_dump(): @@ -108,6 +108,7 @@ def __init__(self): class DnsProxy(Handler): def __init__(self, mux, chan, request): + # FIXME! IPv4 specific sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) Handler.__init__(self, [sock]) self.timeout = time.time()+30 @@ -117,6 +118,7 @@ def __init__(self, mux, chan, request): self.peer = None self.request = request self.sock = sock + # FIXME! IPv4 specific self.sock.setsockopt(socket.SOL_IP, socket.IP_TTL, 42) self.try_send() @@ -124,7 +126,8 @@ def try_send(self): if self.tries >= 3: return self.tries += 1 - self.peer = resolvconf_random_nameserver() + # FIXME! Support IPv6 nameservers + self.peer = resolvconf_random_nameserver()[1] self.sock.connect((self.peer, 53)) debug2('DNS: sending to %r\n' % self.peer) try: @@ -160,6 +163,35 @@ def callback(self): self.ok = False +class UdpProxy(Handler): + def __init__(self, mux, chan, family): + sock = socket.socket(family, socket.SOCK_DGRAM) + Handler.__init__(self, [sock]) + self.timeout = time.time()+30 + self.mux = mux + self.chan = chan + self.sock = sock + if family == socket.AF_INET: + self.sock.setsockopt(socket.SOL_IP, socket.IP_TTL, 42) + + def send(self, dstip, data): + debug2('UDP: sending to %r port %d\n' % dstip) + try: + self.sock.sendto(data,dstip) + except socket.error, e: + log('UDP send to %r port %d: %s\n' % (dstip[0], dstip[1], e)) + return + + def callback(self): + try: + data,peer = self.sock.recvfrom(4096) + except socket.error, e: + log('UDP recv from %r port %d: %s\n' % (peer[0], peer[1], e)) + return + debug2('UDP response: %d bytes\n' % len(data)) + hdr = "%s,%r,"%(peer[0], peer[1]) + self.mux.send(self.chan, ssnet.CMD_UDP_DATA, hdr+data) + def main(): if helpers.verbose >= 1: helpers.logprefix = ' s: ' @@ -170,7 +202,7 @@ def main(): routes = list(list_routes()) debug1('available routes:\n') for r in routes: - debug1(' %s/%d\n' % r) + debug1(' %d/%s/%d\n' % r) # synchronization header sys.stdout.write('\0\0SSHUTTLE0001') @@ -184,7 +216,7 @@ def main(): handlers.append(mux) routepkt = '' for r in routes: - routepkt += '%s,%d\n' % r + routepkt += '%d,%s,%d\n' % r mux.send(0, ssnet.CMD_ROUTES, routepkt) hw = Hostwatch() @@ -213,20 +245,49 @@ def got_host_req(data): mux.got_host_req = got_host_req def new_channel(channel, data): - (dstip,dstport) = data.split(',', 1) + (family,dstip,dstport) = data.split(',', 2) + family = int(family) dstport = int(dstport) - outwrap = ssnet.connect_dst(dstip,dstport) + outwrap = ssnet.connect_dst(family, dstip, dstport) handlers.append(Proxy(MuxWrapper(mux, channel), outwrap)) mux.new_channel = new_channel dnshandlers = {} def dns_req(channel, data): - debug2('Incoming DNS request.\n') + debug2('Incoming DNS request channel=%d.\n' % channel) h = DnsProxy(mux, channel, data) handlers.append(h) dnshandlers[channel] = h mux.got_dns_req = dns_req + udphandlers = {} + def udp_req(channel, cmd, data): + debug2('Incoming UDP request channel=%d, cmd=%d\n' % (channel,cmd)) + if cmd == ssnet.CMD_UDP_DATA: + (dstip,dstport,data) = data.split(",",2) + dstport = int(dstport) + debug2('is incoming UDP data. %r %d.\n' % (dstip,dstport)) + h = udphandlers[channel] + h.send((dstip,dstport),data) + elif cmd == ssnet.CMD_UDP_CLOSE: + debug2('is incoming UDP close\n') + h = udphandlers[channel] + h.ok = False + del mux.channels[channel] + + def udp_open(channel, data): + debug2('Incoming UDP open.\n') + family = int(data) + mux.channels[channel] = lambda cmd, data: udp_req(channel, cmd, data) + if channel in udphandlers: + raise Fatal('UDP connection channel %d already open'%channel) + else: + h = UdpProxy(mux, channel, family) + handlers.append(h) + udphandlers[channel] = h + mux.got_udp_open = udp_open + + while mux.ok: if hw.pid: assert(hw.pid > 0) @@ -243,5 +304,13 @@ def dns_req(channel, data): now = time.time() for channel,h in dnshandlers.items(): if h.timeout < now or not h.ok: + debug3('expiring dnsreqs channel=%d\n' % channel) del dnshandlers[channel] + h.sock.close() + h.ok = False + for channel,h in udphandlers.items(): + if not h.ok: + debug3('expiring UDP channel=%d\n' % channel) + del udphandlers[channel] + h.sock.close() h.ok = False diff --git a/ssh.py b/src/ssh.py similarity index 95% rename from ssh.py rename to src/ssh.py index c4bf06e..85d96b4 100644 --- a/ssh.py +++ b/src/ssh.py @@ -27,9 +27,7 @@ def connect(ssh_cmd, rhostport, python, stderr, options): main_exe = sys.argv[0] portl = [] - rhostIsIPv6 = False if (rhostport or '').count(':') > 1: - rhostIsIPv6 = True if rhostport.count(']') or rhostport.count('['): result = rhostport.split(']') rhost = result[0].strip('[') @@ -48,10 +46,6 @@ def connect(ssh_cmd, rhostport, python, stderr, options): if rhost == '-': rhost = None - ipv6flag = [] - if rhostIsIPv6: - ipv6flag = ['-6'] - z = zlib.compressobj(1) content = readfile('assembler.py') optdata = ''.join("%s=%r\n" % (k,v) for (k,v) in options.items()) @@ -88,7 +82,6 @@ def connect(ssh_cmd, rhostport, python, stderr, options): "exec \"$P\" -c '%s'") % pyscript argv = (sshl + portl + - ipv6flag + [rhost, '--', pycmd]) (s1,s2) = socket.socketpair() def setup(): diff --git a/sshuttle b/src/sshuttle similarity index 82% rename from sshuttle rename to src/sshuttle index 2d234d5..6ee47f1 100755 --- a/sshuttle +++ b/src/sshuttle @@ -4,7 +4,8 @@ for i in 1 2 3 4 5 6 7 8 9 10; do [ -L "$EXE" ] || break EXE=$(readlink "$EXE") done -DIR=$(dirname "$EXE") +#DIR=$(dirname "$EXE") +DIR=/usr/share/sshuttle if python2 -V 2>/dev/null; then exec python2 "$DIR/main.py" python2 "$@" else diff --git a/sshuttle.md b/src/sshuttle.md similarity index 100% rename from sshuttle.md rename to src/sshuttle.md diff --git a/ssnet.py b/src/ssnet.py similarity index 90% rename from ssnet.py rename to src/ssnet.py index b6d73c2..67747a4 100644 --- a/ssnet.py +++ b/src/ssnet.py @@ -16,29 +16,35 @@ CMD_EXIT = 0x4200 CMD_PING = 0x4201 CMD_PONG = 0x4202 -CMD_CONNECT = 0x4203 -CMD_STOP_SENDING = 0x4204 -CMD_EOF = 0x4205 -CMD_DATA = 0x4206 +CMD_TCP_CONNECT = 0x4203 +CMD_TCP_STOP_SENDING = 0x4204 +CMD_TCP_EOF = 0x4205 +CMD_TCP_DATA = 0x4206 CMD_ROUTES = 0x4207 CMD_HOST_REQ = 0x4208 CMD_HOST_LIST = 0x4209 CMD_DNS_REQ = 0x420a CMD_DNS_RESPONSE = 0x420b +CMD_UDP_OPEN = 0x420c +CMD_UDP_DATA = 0x420d +CMD_UDP_CLOSE = 0x420e cmd_to_name = { CMD_EXIT: 'EXIT', CMD_PING: 'PING', CMD_PONG: 'PONG', - CMD_CONNECT: 'CONNECT', - CMD_STOP_SENDING: 'STOP_SENDING', - CMD_EOF: 'EOF', - CMD_DATA: 'DATA', + CMD_TCP_CONNECT: 'TCP_CONNECT', + CMD_TCP_STOP_SENDING: 'TCP_STOP_SENDING', + CMD_TCP_EOF: 'TCP_EOF', + CMD_TCP_DATA: 'TCP_DATA', CMD_ROUTES: 'ROUTES', CMD_HOST_REQ: 'HOST_REQ', CMD_HOST_LIST: 'HOST_LIST', CMD_DNS_REQ: 'DNS_REQ', CMD_DNS_RESPONSE: 'DNS_RESPONSE', + CMD_UDP_OPEN: 'UDP_OPEN', + CMD_UDP_DATA: 'UDP_DATA', + CMD_UDP_CLOSE: 'UDP_CLOSE', } @@ -128,7 +134,8 @@ def try_connect(self): return # already connected self.rsock.setblocking(False) debug3('%r: trying connect to %r\n' % (self, self.connect_to)) - if socket.inet_aton(self.connect_to[0])[0] == '\0': + family = self.rsock.family + if family==socket.AF_INET and socket.inet_pton(family, self.connect_to[0])[0] == '\0': self.seterr(Exception("Can't connect to %r: " "IP address starts with zero\n" % (self.connect_to,))) @@ -152,6 +159,17 @@ def try_connect(self): debug3('%r: fixed connect result: %s\n' % (self, e)) if e.args[0] in [errno.EINPROGRESS, errno.EALREADY]: pass # not connected yet + elif e.args[0] == 0: + # connected successfully (weird Linux bug?) + # Sometimes Linux seems to return EINVAL when it isn't + # invalid. This *may* be caused by a race condition + # between connect() and getsockopt(SO_ERROR) (ie. it + # finishes connecting in between the two, so there is no + # longer an error). However, I'm not sure of that. + # + # I did get at least one report that the problem went away + # when we added this, however. + self.connect_to = None elif e.args[0] == errno.EISCONN: # connected successfully (BSD) self.connect_to = None @@ -306,6 +324,7 @@ def __init__(self, rsock, wsock): self.rsock = rsock self.wsock = wsock self.new_channel = self.got_dns_req = self.got_routes = None + self.got_udp_open = self.got_udp_data = self.got_udp_close = None self.got_host_req = self.got_host_list = None self.channels = {} self.chani = 0 @@ -363,7 +382,7 @@ def got_packet(self, channel, cmd, data): self.fullness = 0 elif cmd == CMD_EXIT: self.ok = False - elif cmd == CMD_CONNECT: + elif cmd == CMD_TCP_CONNECT: assert(not self.channels.get(channel)) if self.new_channel: self.new_channel(channel, data) @@ -371,6 +390,10 @@ def got_packet(self, channel, cmd, data): assert(not self.channels.get(channel)) if self.got_dns_req: self.got_dns_req(channel, data) + elif cmd == CMD_UDP_OPEN: + assert(not self.channels.get(channel)) + if self.got_udp_open: + self.got_udp_open(channel, data) elif cmd == CMD_ROUTES: if self.got_routes: self.got_routes(data) @@ -467,13 +490,13 @@ def __repr__(self): def noread(self): if not self.shut_read: self.shut_read = True - self.mux.send(self.channel, CMD_STOP_SENDING, '') + self.mux.send(self.channel, CMD_TCP_STOP_SENDING, '') self.maybe_close() def nowrite(self): if not self.shut_write: self.shut_write = True - self.mux.send(self.channel, CMD_EOF, '') + self.mux.send(self.channel, CMD_TCP_EOF, '') self.maybe_close() def maybe_close(self): @@ -490,7 +513,7 @@ def uwrite(self, buf): return 0 # too much already enqueued if len(buf) > 2048: buf = buf[:2048] - self.mux.send(self.channel, CMD_DATA, buf) + self.mux.send(self.channel, CMD_TCP_DATA, buf) return len(buf) def uread(self): @@ -500,20 +523,20 @@ def uread(self): return None # no data available right now def got_packet(self, cmd, data): - if cmd == CMD_EOF: + if cmd == CMD_TCP_EOF: self.noread() - elif cmd == CMD_STOP_SENDING: + elif cmd == CMD_TCP_STOP_SENDING: self.nowrite() - elif cmd == CMD_DATA: + elif cmd == CMD_TCP_DATA: self.buf.append(data) else: raise Exception('unknown command %d (%d bytes)' % (cmd, len(data))) -def connect_dst(ip, port): +def connect_dst(family, ip, port): debug2('Connecting to %s:%d\n' % (ip, port)) - outsock = socket.socket() + outsock = socket.socket(family) outsock.setsockopt(socket.SOL_IP, socket.IP_TTL, 42) return SockWrapper(outsock, outsock, connect_to = (ip,port), diff --git a/ssyslog.py b/src/ssyslog.py similarity index 100% rename from ssyslog.py rename to src/ssyslog.py diff --git a/stresstest.py b/src/stresstest.py old mode 100755 new mode 100644 similarity index 100% rename from stresstest.py rename to src/stresstest.py diff --git a/ui-macos/.gitignore b/src/ui-macos/.gitignore similarity index 100% rename from ui-macos/.gitignore rename to src/ui-macos/.gitignore diff --git a/ui-macos/Info.plist b/src/ui-macos/Info.plist similarity index 100% rename from ui-macos/Info.plist rename to src/ui-macos/Info.plist diff --git a/ui-macos/MainMenu.xib b/src/ui-macos/MainMenu.xib similarity index 100% rename from ui-macos/MainMenu.xib rename to src/ui-macos/MainMenu.xib diff --git a/ui-macos/UserDefaults.plist b/src/ui-macos/UserDefaults.plist similarity index 100% rename from ui-macos/UserDefaults.plist rename to src/ui-macos/UserDefaults.plist diff --git a/ui-macos/all.do b/src/ui-macos/all.do similarity index 100% rename from ui-macos/all.do rename to src/ui-macos/all.do diff --git a/ui-macos/app.icns b/src/ui-macos/app.icns similarity index 100% rename from ui-macos/app.icns rename to src/ui-macos/app.icns diff --git a/ui-macos/askpass.py b/src/ui-macos/askpass.py similarity index 100% rename from ui-macos/askpass.py rename to src/ui-macos/askpass.py diff --git a/ui-macos/bits/.gitignore b/src/ui-macos/bits/.gitignore similarity index 100% rename from ui-macos/bits/.gitignore rename to src/ui-macos/bits/.gitignore diff --git a/ui-macos/bits/PkgInfo b/src/ui-macos/bits/PkgInfo similarity index 100% rename from ui-macos/bits/PkgInfo rename to src/ui-macos/bits/PkgInfo diff --git a/ui-macos/bits/runpython.c b/src/ui-macos/bits/runpython.c similarity index 100% rename from ui-macos/bits/runpython.c rename to src/ui-macos/bits/runpython.c diff --git a/ui-macos/bits/runpython.do b/src/ui-macos/bits/runpython.do similarity index 100% rename from ui-macos/bits/runpython.do rename to src/ui-macos/bits/runpython.do diff --git a/ui-macos/chicken-tiny-bw.png b/src/ui-macos/chicken-tiny-bw.png similarity index 100% rename from ui-macos/chicken-tiny-bw.png rename to src/ui-macos/chicken-tiny-bw.png diff --git a/ui-macos/chicken-tiny-err.png b/src/ui-macos/chicken-tiny-err.png similarity index 100% rename from ui-macos/chicken-tiny-err.png rename to src/ui-macos/chicken-tiny-err.png diff --git a/ui-macos/chicken-tiny.png b/src/ui-macos/chicken-tiny.png similarity index 100% rename from ui-macos/chicken-tiny.png rename to src/ui-macos/chicken-tiny.png diff --git a/ui-macos/clean.do b/src/ui-macos/clean.do similarity index 100% rename from ui-macos/clean.do rename to src/ui-macos/clean.do diff --git a/ui-macos/debug.app.do b/src/ui-macos/debug.app.do similarity index 100% rename from ui-macos/debug.app.do rename to src/ui-macos/debug.app.do diff --git a/ui-macos/default.app.do b/src/ui-macos/default.app.do similarity index 100% rename from ui-macos/default.app.do rename to src/ui-macos/default.app.do diff --git a/ui-macos/default.app.tar.gz.do b/src/ui-macos/default.app.tar.gz.do similarity index 100% rename from ui-macos/default.app.tar.gz.do rename to src/ui-macos/default.app.tar.gz.do diff --git a/ui-macos/default.app.zip.do b/src/ui-macos/default.app.zip.do similarity index 100% rename from ui-macos/default.app.zip.do rename to src/ui-macos/default.app.zip.do diff --git a/ui-macos/default.nib.do b/src/ui-macos/default.nib.do similarity index 100% rename from ui-macos/default.nib.do rename to src/ui-macos/default.nib.do diff --git a/ui-macos/dist.do b/src/ui-macos/dist.do similarity index 100% rename from ui-macos/dist.do rename to src/ui-macos/dist.do diff --git a/ui-macos/git-export.do b/src/ui-macos/git-export.do similarity index 100% rename from ui-macos/git-export.do rename to src/ui-macos/git-export.do diff --git a/ui-macos/main.py b/src/ui-macos/main.py similarity index 100% rename from ui-macos/main.py rename to src/ui-macos/main.py diff --git a/ui-macos/models.py b/src/ui-macos/models.py similarity index 100% rename from ui-macos/models.py rename to src/ui-macos/models.py diff --git a/ui-macos/my.py b/src/ui-macos/my.py similarity index 100% rename from ui-macos/my.py rename to src/ui-macos/my.py diff --git a/ui-macos/run.do b/src/ui-macos/run.do similarity index 100% rename from ui-macos/run.do rename to src/ui-macos/run.do diff --git a/ui-macos/sources.list.do b/src/ui-macos/sources.list.do similarity index 100% rename from ui-macos/sources.list.do rename to src/ui-macos/sources.list.do diff --git a/ui-macos/sshuttle b/src/ui-macos/sshuttle similarity index 100% rename from ui-macos/sshuttle rename to src/ui-macos/sshuttle