From a2fcb08a2d4622092eeffc44ae154896ec304a56 Mon Sep 17 00:00:00 2001 From: Avery Pennarun Date: Wed, 26 Jan 2011 02:00:19 -0800 Subject: Extremely basic, but functional, DNS proxying support (--dns option) Limitations: - uses a hardcoded DNS server IP on both client and server - never expires request/response objects, so leaks memory and sockets - works only with iptables, not with ipfw --- client.py | 42 ++++++++++++++++++++++++++++++++++++------ firewall.py | 19 ++++++++++++++----- main.py | 8 +++++--- server.py | 25 +++++++++++++++++++++++++ ssnet.py | 10 +++++++++- 5 files changed, 89 insertions(+), 15 deletions(-) diff --git a/client.py b/client.py index e584933..7872bae 100644 --- a/client.py +++ b/client.py @@ -111,14 +111,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 +191,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: @@ -292,6 +293,25 @@ def _main(listener, fw, ssh_cmd, remotename, python, latency_control, handlers.append(Proxy(SockWrapper(sock, sock), outwrap)) handlers.append(Handler([listener], onaccept)) + dnspeers = {} + def dns_done(chan, data): + peer = dnspeers.get(chan) + debug1('dns_done: channel=%r peer=%r\n' % (chan, peer)) + if peer: + del dnspeers[chan] + debug1('doing sendto %r\n' % (peer,)) + dnslistener.sendto(data, peer) + def ondns(): + pkt,peer = dnslistener.recvfrom(4096) + if pkt: + debug1('Got DNS request from %r: %d bytes\n' % (peer, len(pkt))) + chan = mux.next_channel() + dnspeers[chan] = peer + mux.send(chan, ssnet.CMD_DNS_REQ, pkt) + mux.channels[chan] = lambda cmd,data: dns_done(chan,data) + 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 +327,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,6 +339,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]: @@ -344,11 +365,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) + dnsport = 0 + dnslistener = None + if dns: + dnslistener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + dnslistener.bind((listenip[0], 0)) + dnsip = dnslistener.getsockname() + debug1('DNS listening on %r.\n' % (dnsip,)) + dnsport = dnsip[1] + + 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..8ec67bc 100644 --- a/firewall.py +++ b/firewall.py @@ -49,7 +49,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 @@ -80,6 +80,13 @@ def do_iptables(port, subnets): '--dest', '%s/%s' % (snet,swidth), '-p', 'tcp', '--to-ports', str(port)) + + if dnsport: + ipt_ttl('-A', chain, '-j', 'REDIRECT', + '--dest', '192.168.42.1/32', + '-p', 'udp', + '--dport', '53', + '--to-ports', str(dnsport)) def ipfw_rule_exists(n): @@ -145,7 +152,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) @@ -235,9 +242,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 +300,7 @@ def main(port, syslog): try: if line: debug1('firewall manager: starting transproxy.\n') - do_it(port, subnets) + do_it(port, dnsport, subnets) sys.stdout.write('STARTED\n') try: @@ -319,5 +328,5 @@ def main(port, syslog): debug1('firewall manager: undoing changes.\n') except: pass - do_it(port, []) + do_it(port, 0, []) restore_etc_hosts(port) diff --git a/main.py b/main.py index 5597177..e76e596 100755 --- a/main.py +++ b/main.py @@ -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), diff --git a/server.py b/server.py index ae7a921..3395c9e 100644 --- a/server.py +++ b/server.py @@ -106,6 +106,23 @@ 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.mux = mux + self.chan = chan + self.sock.setsockopt(socket.SOL_IP, socket.IP_TTL, 42) + self.sock.connect(('192.168.42.1', 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) + + def main(): if helpers.verbose >= 1: helpers.logprefix = ' s: ' @@ -165,6 +182,14 @@ def main(): handlers.append(Proxy(MuxWrapper(mux, channel), outwrap)) mux.new_channel = new_channel + dnshandlers = {} + def dns_req(channel, data): + debug1('got 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) diff --git a/ssnet.py b/ssnet.py index 62fa378..554d870 100644 --- a/ssnet.py +++ b/ssnet.py @@ -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) -- cgit v1.2.3 From 4c5185dc55fb1f141da8657eadcf07f207d42fab Mon Sep 17 00:00:00 2001 From: Avery Pennarun Date: Wed, 26 Jan 2011 02:15:00 -0800 Subject: dns: extract 'nameserver' lines from /etc/resolv.conf --- firewall.py | 12 +++++++----- helpers.py | 23 +++++++++++++++++++++++ server.py | 2 +- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/firewall.py b/firewall.py index 8ec67bc..ed576a2 100644 --- a/firewall.py +++ b/firewall.py @@ -82,11 +82,13 @@ def do_iptables(port, dnsport, subnets): '--to-ports', str(port)) if dnsport: - ipt_ttl('-A', chain, '-j', 'REDIRECT', - '--dest', '192.168.42.1/32', - '-p', 'udp', - '--dport', '53', - '--to-ports', str(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): diff --git a/helpers.py b/helpers.py index 18871a2..d8d7e85 100644 --- a/helpers.py +++ b/helpers.py @@ -35,3 +35,26 @@ 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' + + diff --git a/server.py b/server.py index 3395c9e..60eaa42 100644 --- a/server.py +++ b/server.py @@ -114,7 +114,7 @@ class DnsProxy(Handler): self.mux = mux self.chan = chan self.sock.setsockopt(socket.SOL_IP, socket.IP_TTL, 42) - self.sock.connect(('192.168.42.1', 53)) + self.sock.connect((resolvconf_random_nameserver(), 53)) self.sock.send(request) def callback(self): -- cgit v1.2.3 From b570778894045d0ae461368d071e86ba4e82415a Mon Sep 17 00:00:00 2001 From: Avery Pennarun Date: Wed, 26 Jan 2011 02:34:12 -0800 Subject: dns: trim DNS channel handlers after a response, or after a timeout. This avoids memory/socket leaks. --- client.py | 21 +++++++++++++-------- server.py | 15 ++++++++++++--- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/client.py b/client.py index 7872bae..0f596eb 100644 --- a/client.py +++ b/client.py @@ -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 @@ -293,22 +293,27 @@ def _main(listener, fw, ssh_cmd, remotename, python, latency_control, handlers.append(Proxy(SockWrapper(sock, sock), outwrap)) handlers.append(Handler([listener], onaccept)) - dnspeers = {} + dnsreqs = {} def dns_done(chan, data): - peer = dnspeers.get(chan) - debug1('dns_done: channel=%r peer=%r\n' % (chan, peer)) + peer,timeout = dnsreqs.get(chan) + debug3('dns_done: channel=%r peer=%r\n' % (chan, peer)) if peer: - del dnspeers[chan] - debug1('doing sendto %r\n' % (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('Got DNS request from %r: %d bytes\n' % (peer, len(pkt))) + debug1('DNS request from %r: %d bytes\n' % (peer, len(pkt))) chan = mux.next_channel() - dnspeers[chan] = peer + 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)) diff --git a/server.py b/server.py index 60eaa42..45bc2fc 100644 --- a/server.py +++ b/server.py @@ -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 @@ -111,6 +111,7 @@ class DnsProxy(Handler): 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) @@ -119,8 +120,9 @@ class DnsProxy(Handler): def callback(self): data = self.sock.recv(4096) - debug2('dns response: %d bytes\n' % len(data)) + debug2('DNS response: %d bytes\n' % len(data)) self.mux.send(self.chan, ssnet.CMD_DNS_RESPONSE, data) + self.ok = False def main(): @@ -184,7 +186,7 @@ def main(): dnshandlers = {} def dns_req(channel, data): - debug1('got dns request!\n') + debug2('Incoming DNS request.\n') h = DnsProxy(mux, channel, data) handlers.append(h) dnshandlers[channel] = h @@ -201,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 -- cgit v1.2.3 From 760740e9aa7861a6eba566b96f4b4a6371c1f29a Mon Sep 17 00:00:00 2001 From: Avery Pennarun Date: Wed, 26 Jan 2011 05:19:03 -0800 Subject: Oops, dns_done() crashed if the request had already been timed out. --- client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client.py b/client.py index 0f596eb..a824bff 100644 --- a/client.py +++ b/client.py @@ -295,7 +295,7 @@ def _main(listener, fw, ssh_cmd, remotename, python, latency_control, dnsreqs = {} def dns_done(chan, data): - peer,timeout = dnsreqs.get(chan) + peer,timeout = dnsreqs.get(chan) or (None,None) debug3('dns_done: channel=%r peer=%r\n' % (chan, peer)) if peer: del dnsreqs[chan] -- cgit v1.2.3 From ebfc3703ec209af8ec818394d5d623ead1a18d1d Mon Sep 17 00:00:00 2001 From: Avery Pennarun Date: Wed, 26 Jan 2011 03:46:59 -0800 Subject: dns: add support for MacOS (but it doesn't work...) ...because stupid MacOS ipfw 'fwd' rules don't work quite right with udp. It can intercept packets bound for remote hosts, but it doesn't correctly rewrite the port number from its original to the new socket, so it gets dropped by the local kernel anyway. That is, a packet to 1.2.3.4:53 should be redirected to, say, 127.0.0.1:9999, the local DNS listener socket. But instead, it gets sent to 127.0.0.1:53, which nobody is listening on, so it gets eaten. Sigh. --- firewall.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/firewall.py b/firewall.py index ed576a2..ccf576f 100644 --- a/firewall.py +++ b/firewall.py @@ -59,12 +59,13 @@ def do_iptables(port, dnsport, 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 @@ -98,6 +99,7 @@ def ipfw_rule_exists(n): for line in p.stdout: if line.startswith('%05d ' % n): if not ('ipttl 42 setup keep-state' in line + or 'ipttl 42 keep-state' in line or ('skipto %d' % (n+1)) in line or 'check-state' in line): log('non-sshuttle ipfw rule: %r\n' % line.strip()) @@ -167,13 +169,14 @@ def do_ipfw(port, dnsport, 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: @@ -186,6 +189,14 @@ def do_ipfw(port, dnsport, subnets): 'from', 'any', 'to', '%s/%s' % (snet,swidth), 'not', 'ipttl', '42', 'keep-state', 'setup') + if dnsport: + nslist = resolvconf_nameservers() + for ip in nslist: + ipfw('add', sport, 'fwd', '127.0.0.1,%d' % dnsport, + 'log', 'udp', + 'from', 'any', 'to', '%s/32' % ip, '53', + 'not', 'ipttl', '42', 'keep-state') + def program_exists(name): paths = (os.getenv('PATH') or os.defpath).split(os.pathsep) @@ -194,6 +205,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' -- cgit v1.2.3 From 7f3c522c564b79e0f96a25c7a94705a99a65d09e Mon Sep 17 00:00:00 2001 From: Avery Pennarun Date: Wed, 26 Jan 2011 04:32:00 -0800 Subject: Move client._islocal() to helpers.islocal() in preparation for sharing. --- client.py | 17 +---------------- helpers.py | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/client.py b/client.py index a824bff..4f47226 100644 --- a/client.py +++ b/client.py @@ -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) @@ -283,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 diff --git a/helpers.py b/helpers.py index d8d7e85..c169d0c 100644 --- a/helpers.py +++ b/helpers.py @@ -1,4 +1,4 @@ -import sys, os +import sys, os, socket logprefix = '' verbose = 0 @@ -57,4 +57,19 @@ def resolvconf_random_nameserver(): 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 + + -- cgit v1.2.3 From 88937e148e2cb72ae1337f9a65367af8909eaae5 Mon Sep 17 00:00:00 2001 From: Avery Pennarun Date: Wed, 26 Jan 2011 04:44:32 -0800 Subject: client.py: do DNS listener on the same port as the TCP listener. UDP and TCP have separate port namespaces, so to make it easier to keep track of what's going on, just use the same transproxy port number for both. We still need two sockets, but now tcpdumps are easier to understand. --- client.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/client.py b/client.py index 4f47226..0835cbe 100644 --- a/client.py +++ b/client.py @@ -330,8 +330,6 @@ def main(listenip, ssh_cmd, remotename, python, latency_control, dns, 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: @@ -341,8 +339,13 @@ def main(listenip, ssh_cmd, remotename, python, latency_control, dns, 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: @@ -355,14 +358,14 @@ def main(listenip, ssh_cmd, remotename, python, latency_control, dns, listenip = listener.getsockname() debug1('Listening on %r.\n' % (listenip,)) - dnsport = 0 - dnslistener = None if dns: - dnslistener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - dnslistener.bind((listenip[0], 0)) 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) -- cgit v1.2.3 From 9731680d2e51d8083012cd21838acaa34189812b Mon Sep 17 00:00:00 2001 From: Avery Pennarun Date: Wed, 26 Jan 2011 04:54:17 -0800 Subject: dns on MacOS: use divert sockets instead of 'fwd' rules. It turns out diverting UDP sockets is pretty easy compared to TCP (which makes it all the more embarrassing that they screwed up 'fwd' support for UDP and not TCP, but oh well). So let's use divert sockets instead of transproxy for our DNS packets. This is a little tricky because we have to do it all in firewall.py, since divert sockets require root access, and only firewall.py has root access. --- firewall.py | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 6 deletions(-) diff --git a/firewall.py b/firewall.py index ccf576f..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'] @@ -98,8 +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 - or 'ipttl 42 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()) @@ -146,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): @@ -189,13 +224,64 @@ def do_ipfw(port, dnsport, 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: - ipfw('add', sport, 'fwd', '127.0.0.1,%d' % dnsport, + # relabel and then catch outgoing DNS requests + ipfw('add', sport, 'divert', sport, 'log', 'udp', 'from', 'any', 'to', '%s/32' % ip, '53', - 'not', 'ipttl', '42', 'keep-state') + '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): @@ -314,7 +400,7 @@ def main(port, dnsport, syslog): try: if line: debug1('firewall manager: starting transproxy.\n') - do_it(port, dnsport, subnets) + do_wait = do_it(port, dnsport, subnets) sys.stdout.write('STARTED\n') try: @@ -328,6 +414,7 @@ def main(port, dnsport, 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) -- cgit v1.2.3