diff options
author | Avery Pennarun <apenwarr@gmail.com> | 2011-01-26 05:29:51 -0800 |
---|---|---|
committer | Avery Pennarun <apenwarr@gmail.com> | 2011-01-26 05:29:51 -0800 |
commit | 0bf0351d9becae3da9379c335b0e71094cde2760 (patch) | |
tree | b2db817a660478842162fe435eadb51f4fac7c84 | |
parent | e7a19890aa579e94dc99cbd662bd5ed598d642fd (diff) | |
parent | 9731680d2e51d8083012cd21838acaa34189812b (diff) |
Merge branch 'dns'sshuttle-0.50
* dns:
dns on MacOS: use divert sockets instead of 'fwd' rules.
client.py: do DNS listener on the same port as the TCP listener.
Move client._islocal() to helpers.islocal() in preparation for sharing.
dns: add support for MacOS (but it doesn't work...)
Oops, dns_done() crashed if the request had already been timed out.
dns: trim DNS channel handlers after a response, or after a timeout.
dns: extract 'nameserver' lines from /etc/resolv.conf
Extremely basic, but functional, DNS proxying support (--dns option)
-rw-r--r-- | client.py | 73 | ||||
-rw-r--r-- | firewall.py | 130 | ||||
-rw-r--r-- | helpers.py | 40 | ||||
-rwxr-xr-x | main.py | 8 | ||||
-rw-r--r-- | server.py | 36 | ||||
-rw-r--r-- | ssnet.py | 10 |
6 files changed, 256 insertions, 41 deletions
@@ -1,4 +1,4 @@ -import struct, socket, select, errno, re, signal +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 @@ -6,21 +6,6 @@ from helpers import * _extra_fd = os.open('/dev/null', os.O_RDONLY) -def _islocal(ip): - sock = socket.socket() - try: - try: - sock.bind((ip, 0)) - except socket.error, e: - if e.args[0] == errno.EADDRNOTAVAIL: - return False # not a local IP - else: - raise - finally: - sock.close() - return True # it's a local IP, or there would have been an error - - def got_signal(signum, frame): log('exiting on signal %d\n' % signum) sys.exit(1) @@ -111,14 +96,15 @@ def original_dst(sock): class FirewallClient: - def __init__(self, port, subnets_include, subnets_exclude): + 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[0]] + ['-v'] * (helpers.verbose or 0) + - ['--firewall', str(port)]) + ['--firewall', str(port), str(dnsport)]) if ssyslog._p: argvbase += ['--syslog'] argv_tries = [ @@ -190,7 +176,7 @@ class FirewallClient: def _main(listener, fw, ssh_cmd, remotename, python, latency_control, - seed_hosts, auto_nets, + dnslistener, seed_hosts, auto_nets, syslog, daemon): handlers = [] if helpers.verbose >= 1: @@ -282,7 +268,7 @@ def _main(listener, fw, ssh_cmd, remotename, python, latency_control, 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]): + if dstip[1] == listener.getsockname()[1] and islocal(dstip[0]): debug1("-- ignored: that's my address!\n") sock.close() return @@ -292,6 +278,30 @@ def _main(listener, fw, ssh_cmd, remotename, python, latency_control, 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)) @@ -307,7 +317,7 @@ def _main(listener, fw, ssh_cmd, remotename, python, latency_control, mux.callback() -def main(listenip, ssh_cmd, remotename, python, latency_control, +def main(listenip, ssh_cmd, remotename, python, latency_control, dns, seed_hosts, auto_nets, subnets_include, subnets_exclude, syslog, daemon, pidfile): if syslog: @@ -319,8 +329,7 @@ def main(listenip, ssh_cmd, remotename, python, latency_control, log("%s\n" % e) return 5 debug1('Starting sshuttle proxy.\n') - listener = socket.socket() - listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if listenip[1]: ports = [listenip[1]] else: @@ -330,8 +339,13 @@ def main(listenip, ssh_cmd, remotename, python, latency_control, 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: @@ -344,11 +358,20 @@ def main(listenip, ssh_cmd, remotename, python, latency_control, listenip = listener.getsockname() debug1('Listening on %r.\n' % (listenip,)) - fw = FirewallClient(listenip[1], subnets_include, subnets_exclude) + if dns: + dnsip = dnslistener.getsockname() + debug1('DNS listening on %r.\n' % (dnsip,)) + dnsport = dnsip[1] + else: + dnsport = 0 + dnslistener = None + dnslistener.bind((listenip[0], 0)) + + fw = FirewallClient(listenip[1], subnets_include, subnets_exclude, dnsport) try: return _main(listener, fw, ssh_cmd, remotename, - python, latency_control, + python, latency_control, dnslistener, seed_hosts, auto_nets, syslog, daemon) finally: try: diff --git a/firewall.py b/firewall.py index b63bffa..c7557ed 100644 --- a/firewall.py +++ b/firewall.py @@ -1,8 +1,11 @@ -import re, errno +import re, errno, socket, select, struct import compat.ssubprocess as ssubprocess import helpers, ssyslog from helpers import * +# python doesn't have a definition for this +IPPROTO_DIVERT = 254 + def ipt_chain_exists(name): argv = ['iptables', '-t', 'nat', '-nL'] @@ -49,7 +52,7 @@ 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, subnets): +def do_iptables(port, dnsport, subnets): chain = 'sshuttle-%s' % port # basic cleanup/setup of chains @@ -59,12 +62,13 @@ def do_iptables(port, subnets): ipt('-F', chain) ipt('-X', chain) - if subnets: + if subnets or dnsport: ipt('-N', chain) ipt('-F', chain) ipt('-I', 'OUTPUT', '1', '-j', chain) ipt('-I', 'PREROUTING', '1', '-j', chain) + if subnets: # create new subnet entries. Note that we're sorting in a very # particular order: we need to go from most-specific (largest swidth) # to least-specific, and at any given level of specificity, we want @@ -80,6 +84,15 @@ def do_iptables(port, subnets): '--dest', '%s/%s' % (snet,swidth), '-p', 'tcp', '--to-ports', str(port)) + + if dnsport: + nslist = resolvconf_nameservers() + for ip in nslist: + ipt_ttl('-A', chain, '-j', 'REDIRECT', + '--dest', '%s/32' % ip, + '-p', 'udp', + '--dport', '53', + '--to-ports', str(dnsport)) def ipfw_rule_exists(n): @@ -88,7 +101,7 @@ def ipfw_rule_exists(n): found = False for line in p.stdout: if line.startswith('%05d ' % n): - if not ('ipttl 42 setup keep-state' in line + if not ('ipttl 42' in line or ('skipto %d' % (n+1)) in line or 'check-state' in line): log('non-sshuttle ipfw rule: %r\n' % line.strip()) @@ -135,6 +148,39 @@ def sysctl_set(name, val): if val != oldval: _changedctls.append(name) return _sysctl_set(name, val) + + +def _udp_unpack(p): + src = (socket.inet_ntoa(p[12:16]), struct.unpack('!H', p[20:22])[0]) + dst = (socket.inet_ntoa(p[16:20]), struct.unpack('!H', p[22:24])[0]) + return src, dst + + +def _udp_repack(p, src, dst): + addrs = socket.inet_aton(src[0]) + socket.inet_aton(dst[0]) + ports = struct.pack('!HH', src[1], dst[1]) + return p[:12] + addrs + ports + p[24:] + + +_real_dns_server = [None] +def _handle_diversion(divertsock, dnsport): + p,tag = divertsock.recvfrom(4096) + src,dst = _udp_unpack(p) + debug3('got diverted packet from %r to %r\n' % (src, dst)) + if dst[1] == 53: + # outgoing DNS + debug3('...packet is a DNS request.\n') + _real_dns_server[0] = dst + dst = ('127.0.0.1', dnsport) + elif src[1] == dnsport: + if islocal(src[0]): + debug3('...packet is a DNS response.\n') + src = _real_dns_server[0] + else: + log('weird?! unexpected divert from %r to %r\n' % (src, dst)) + assert(0) + newp = _udp_repack(p, src, dst) + divertsock.sendto(newp, tag) def ipfw(*args): @@ -145,7 +191,7 @@ def ipfw(*args): raise Fatal('%r returned %d' % (argv, rv)) -def do_ipfw(port, subnets): +def do_ipfw(port, dnsport, subnets): sport = str(port) xsport = str(port+1) @@ -158,13 +204,14 @@ def do_ipfw(port, subnets): oldval = _oldctls[name] _sysctl_set(name, oldval) - if subnets: + if subnets or dnsport: sysctl_set('net.inet.ip.fw.enable', 1) sysctl_set('net.inet.ip.scopedroute', 0) ipfw('add', sport, 'check-state', 'ip', 'from', 'any', 'to', 'any') - + + if subnets: # create new subnet entries for swidth,sexclude,snet in sorted(subnets, reverse=True): if sexclude: @@ -177,6 +224,65 @@ def do_ipfw(port, subnets): 'from', 'any', 'to', '%s/%s' % (snet,swidth), 'not', 'ipttl', '42', 'keep-state', 'setup') + # This part is much crazier than it is on Linux, because MacOS (at least + # 10.6, and probably other versions, and maybe FreeBSD too) doesn't + # correctly fixup the dstip/dstport for UDP packets when it puts them + # through a 'fwd' rule. It also doesn't fixup the srcip/srcport in the + # response packet. In Linux iptables, all that happens magically for us, + # so we just redirect the packets and relax. + # + # On MacOS, we have to fix the ports ourselves. For that, we use a + # 'divert' socket, which receives raw packets and lets us mangle them. + # + # Here's how it works. Let's say the local DNS server is 1.1.1.1:53, + # and the remote DNS server is 2.2.2.2:53, and the local transproxy port + # is 10.0.0.1:12300, and a client machine is making a request from + # 10.0.0.5:9999. We see a packet like this: + # 10.0.0.5:9999 -> 1.1.1.1:53 + # Since the destip:port matches one of our local nameservers, it will + # match a 'fwd' rule, thus grabbing it on the local machine. However, + # the local kernel will then see a packet addressed to *:53 and + # not know what to do with it; there's nobody listening on port 53. Thus, + # we divert it, rewriting it into this: + # 10.0.0.5:9999 -> 10.0.0.1:12300 + # This gets proxied out to the server, which sends it to 2.2.2.2:53, + # and the answer comes back, and the proxy sends it back out like this: + # 10.0.0.1:12300 -> 10.0.0.5:9999 + # But that's wrong! The original machine expected an answer from + # 1.1.1.1:53, so we have to divert the *answer* and rewrite it: + # 1.1.1.1:53 -> 10.0.0.5:9999 + # + # See? Easy stuff. + if dnsport: + divertsock = socket.socket(socket.AF_INET, socket.SOCK_RAW, + IPPROTO_DIVERT) + divertsock.bind(('0.0.0.0', port)) # IP field is ignored + + nslist = resolvconf_nameservers() + for ip in nslist: + # relabel and then catch outgoing DNS requests + ipfw('add', sport, 'divert', sport, + 'log', 'udp', + 'from', 'any', 'to', '%s/32' % ip, '53', + 'not', 'ipttl', '42') + # relabel DNS responses + ipfw('add', sport, 'divert', sport, + 'log', 'udp', + 'from', 'any', str(dnsport), 'to', 'any', + 'not', 'ipttl', '42') + + def do_wait(): + while 1: + r,w,x = select.select([sys.stdin, divertsock], [], []) + if divertsock in r: + _handle_diversion(divertsock, dnsport) + if sys.stdin in r: + return + else: + do_wait = None + + return do_wait + def program_exists(name): paths = (os.getenv('PATH') or os.defpath).split(os.pathsep) @@ -185,6 +291,7 @@ def program_exists(name): if os.path.exists(fn): return not os.path.isdir(fn) and os.access(fn, os.X_OK) + hostmap = {} def rewrite_etc_hosts(port): HOSTSFILE='/etc/hosts' @@ -235,9 +342,11 @@ 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, syslog): +def main(port, dnsport, syslog): assert(port > 0) assert(port <= 65535) + assert(dnsport >= 0) + assert(dnsport <= 65535) if os.getuid() != 0: raise Fatal('you must be root (or enable su/sudo) to set the firewall') @@ -291,7 +400,7 @@ def main(port, syslog): try: if line: debug1('firewall manager: starting transproxy.\n') - do_it(port, subnets) + do_wait = do_it(port, dnsport, subnets) sys.stdout.write('STARTED\n') try: @@ -305,6 +414,7 @@ def main(port, syslog): # to stay running so that we don't need a *second* password # authentication at shutdown time - that cleanup is important! while 1: + if do_wait: do_wait() line = sys.stdin.readline(128) if line.startswith('HOST '): (name,ip) = line[5:].strip().split(',', 1) @@ -319,5 +429,5 @@ def main(port, syslog): debug1('firewall manager: undoing changes.\n') except: pass - do_it(port, []) + do_it(port, 0, []) restore_etc_hosts(port) @@ -1,4 +1,4 @@ -import sys, os +import sys, os, socket logprefix = '' verbose = 0 @@ -35,3 +35,41 @@ def list_contains_any(l, sub): if i in l: return True return False + + +def resolvconf_nameservers(): + l = [] + for line in open('/etc/resolv.conf'): + words = line.lower().split() + if len(words) >= 2 and words[0] == 'nameserver': + l.append(words[1]) + return l + + +def resolvconf_random_nameserver(): + l = resolvconf_nameservers() + if l: + if len(l) > 1: + # don't import this unless we really need it + import random + random.shuffle(l) + return l[0] + else: + return '127.0.0.1' + + +def islocal(ip): + sock = socket.socket() + try: + try: + sock.bind((ip, 0)) + except socket.error, e: + if e.args[0] == errno.EADDRNOTAVAIL: + return False # not a local IP + else: + raise + finally: + sock.close() + return True # it's a local IP, or there would have been an error + + @@ -54,6 +54,7 @@ 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 [python] r,remote= ssh hostname (and optional username) of remote sshuttle server x,exclude= exclude this subnet (can be used more than once) @@ -82,9 +83,9 @@ try: server.latency_control = opt.latency_control sys.exit(server.main()) elif opt.firewall: - if len(extra) != 1: - o.fatal('exactly one argument expected') - sys.exit(firewall.main(int(extra[0]), opt.syslog)) + 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: @@ -111,6 +112,7 @@ try: remotename, opt.python, opt.latency_control, + opt.dns, sh, opt.auto_nets, parse_subnets(includes), @@ -1,4 +1,4 @@ -import re, struct, socket, select, traceback +import re, struct, socket, select, traceback, time if not globals().get('skip_imports'): import ssnet, helpers, hostwatch import compat.ssubprocess as ssubprocess @@ -106,6 +106,25 @@ class Hostwatch: self.sock = None +class DnsProxy(Handler): + def __init__(self, mux, chan, request): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + Handler.__init__(self, [sock]) + self.sock = sock + self.timeout = time.time()+30 + self.mux = mux + self.chan = chan + self.sock.setsockopt(socket.SOL_IP, socket.IP_TTL, 42) + self.sock.connect((resolvconf_random_nameserver(), 53)) + self.sock.send(request) + + def callback(self): + data = self.sock.recv(4096) + debug2('DNS response: %d bytes\n' % len(data)) + self.mux.send(self.chan, ssnet.CMD_DNS_RESPONSE, data) + self.ok = False + + def main(): if helpers.verbose >= 1: helpers.logprefix = ' s: ' @@ -165,6 +184,14 @@ def main(): handlers.append(Proxy(MuxWrapper(mux, channel), outwrap)) mux.new_channel = new_channel + dnshandlers = {} + def dns_req(channel, data): + debug2('Incoming DNS request.\n') + h = DnsProxy(mux, channel, data) + handlers.append(h) + dnshandlers[channel] = h + mux.got_dns_req = dns_req + while mux.ok: if hw.pid: assert(hw.pid > 0) @@ -176,3 +203,10 @@ def main(): if latency_control: mux.check_fullness() mux.callback() + + if dnshandlers: + now = time.time() + for channel,h in dnshandlers.items(): + if h.timeout < now or not h.ok: + del dnshandlers[channel] + h.ok = False @@ -21,6 +21,8 @@ CMD_DATA = 0x4206 CMD_ROUTES = 0x4207 CMD_HOST_REQ = 0x4208 CMD_HOST_LIST = 0x4209 +CMD_DNS_REQ = 0x420a +CMD_DNS_RESPONSE = 0x420b cmd_to_name = { CMD_EXIT: 'EXIT', @@ -33,6 +35,8 @@ cmd_to_name = { CMD_ROUTES: 'ROUTES', CMD_HOST_REQ: 'HOST_REQ', CMD_HOST_LIST: 'HOST_LIST', + CMD_DNS_REQ: 'DNS_REQ', + CMD_DNS_RESPONSE: 'DNS_RESPONSE', } @@ -281,7 +285,7 @@ class Mux(Handler): Handler.__init__(self, [rsock, wsock]) self.rsock = rsock self.wsock = wsock - self.new_channel = self.got_routes = None + self.new_channel = self.got_dns_req = self.got_routes = None self.got_host_req = self.got_host_list = None self.channels = {} self.chani = 0 @@ -343,6 +347,10 @@ class Mux(Handler): assert(not self.channels.get(channel)) if self.new_channel: self.new_channel(channel, data) + elif cmd == CMD_DNS_REQ: + assert(not self.channels.get(channel)) + if self.got_dns_req: + self.got_dns_req(channel, data) elif cmd == CMD_ROUTES: if self.got_routes: self.got_routes(data) |