[tor-commits] [flashproxy/master] move fac.py and associated tests to flashproxy-common

infinity0 at torproject.org infinity0 at torproject.org
Thu Nov 21 17:14:36 UTC 2013


commit 5d3ae963fa6b514c28e5ad37e60a165d82526831
Author: Ximin Luo <infinity0 at gmx.com>
Date:   Wed Nov 13 16:34:31 2013 +0000

    move fac.py and associated tests to flashproxy-common
    - use the facilitator's parse_addr_spec, and fix client uses to set defhost = ""
      - the old client side accepted an empty host when defhost = None.
      - the facilitator side requires setting defhost = "" to accept an empty host
      - behaviour is otherwise equivalent
---
 Makefile                               |    2 +-
 facilitator/HACKING                    |   26 ++
 facilitator/INSTALL                    |   15 +-
 facilitator/Makefile.am                |   10 +-
 facilitator/doc/facilitator-design.txt |    3 -
 facilitator/fac.py                     |  404 --------------------------------
 facilitator/facilitator                |   22 +-
 facilitator/facilitator-email-poller   |   10 +-
 facilitator/facilitator-reg-daemon     |   16 +-
 facilitator/facilitator-test.py        |  143 +----------
 facilitator/facilitator.cgi            |    2 +-
 flashproxy-client                      |   12 +-
 flashproxy-reg-appspot                 |    2 +-
 flashproxy-reg-email                   |    2 +-
 flashproxy-reg-url                     |    2 +-
 flashproxy/fac.py                      |  224 ++++++++++++++++++
 flashproxy/proc.py                     |   47 ++++
 flashproxy/reg.py                      |   31 +++
 flashproxy/test/test_fac.py            |   93 ++++++++
 flashproxy/test/test_reg.py            |   23 ++
 flashproxy/test/test_util.py           |   35 +++
 flashproxy/util.py                     |   92 ++++++--
 22 files changed, 618 insertions(+), 598 deletions(-)

diff --git a/Makefile b/Makefile
index 9e8d2dd..93287f6 100644
--- a/Makefile
+++ b/Makefile
@@ -84,7 +84,7 @@ test-full: test
 	cd facilitator && \
 	  { test -x ./config.status && ./config.status || \
 	  { test -x ./configure || ./autogen.sh; } && ./configure; } \
-	  && make && make check
+	  && make && PYTHONPATH=.. make check
 	cd proxy && make test
 
 force-dist:
diff --git a/facilitator/HACKING b/facilitator/HACKING
new file mode 100644
index 0000000..f102880
--- /dev/null
+++ b/facilitator/HACKING
@@ -0,0 +1,26 @@
+== Running from source checkout
+
+In order to run the code directly from a source checkout, you must make sure it
+can find the flashproxy module, located in the top-level directory of the
+source checkout, which is probably the parent directory. You have two options:
+
+1. Install it in "development mode", see [1]
+
+  flashproxy# python setup-common.py develop
+
+This process is reversible too:
+
+  flashproxy# python setup-common.py develop --uninstall
+
+The disadvantage is that other programs (such as a system-installed flashproxy,
+or other checkouts in another directory) will see this development copy, rather
+than a more appropriate copy.
+
+2. Export PYTHONPATH when you need to run
+
+  $ export PYTHONPATH=..
+  $ make check
+
+The disadvantage is that you need to do this every shell session.
+
+[1] http://pythonhosted.org/distribute/setuptools.html#development-mode
diff --git a/facilitator/INSTALL b/facilitator/INSTALL
index 6325e53..7716ccc 100644
--- a/facilitator/INSTALL
+++ b/facilitator/INSTALL
@@ -1,12 +1,21 @@
 Install the dependencies.
 
-	$ apt-get install make openssl python-m2crypto
-	$ apt-get install automake autoconf # if running from git
-	$ apt-get install apache2
+	# apt-get install make openssl python-m2crypto
+	# apt-get install automake autoconf # if running from git
+
+	# apt-get install apache2
 
 You may use a different webserver, but currently we only provide an apache2 site
 config example, so you will need to adapt this to the correct syntax.
 
+	# apt-get install flashproxy-common
+
+If your distro does not have flashproxy-common, you can install it
+directly from the top-level source directory:
+
+	flashproxy# python setup-common.py install --record install.log \
+	  --single-version-externally-managed
+
 Configure and install.
 
 	$ ./autogen.sh # if running from git or ./configure doesn't otherwise exist
diff --git a/facilitator/Makefile.am b/facilitator/Makefile.am
index 2d315b9..435956d 100644
--- a/facilitator/Makefile.am
+++ b/facilitator/Makefile.am
@@ -2,9 +2,7 @@
 
 fpfacilitatoruser = @fpfacilitatoruser@
 initconfdir = @initconfdir@
-# TODO(infinity0): switch this to @cgibindir@ once we replace fac.py with
-# flashproxy-common, so that we install facilitator.cgi in the right place
-cgibindir = @bindir@
+cgibindir = @cgibindir@
 
 # unfortunately sysvinit does not support having initscripts in /usr/local/etc
 # yet, so we have to hard code a path here. :(
@@ -16,7 +14,7 @@ appengineconfdir = $(pkgconfdir)/reg-appspot
 
 # automake PLVs
 
-dist_bin_SCRIPTS = facilitator facilitator-email-poller facilitator-reg-daemon facilitator-reg fac.py
+dist_bin_SCRIPTS = facilitator facilitator-email-poller facilitator-reg-daemon facilitator-reg
 dist_cgibin_SCRIPTS = facilitator.cgi
 if DO_INITSCRIPTS
 initscript_SCRIPTS = init.d/facilitator init.d/facilitator-email-poller init.d/facilitator-reg-daemon
@@ -29,13 +27,13 @@ pkgconf_DATA = examples/facilitator-relays
 dist_appengine_DATA = appengine/app.yaml appengine/config.go appengine/fp-reg.go
 appengineconf_DATA = appengine/config.go
 CLEANFILES = examples/fp-facilitator.conf
-EXTRA_DIST = examples/fp-facilitator.conf.in $(TESTS)
+EXTRA_DIST = examples/fp-facilitator.conf.in HACKING $(TESTS)
 
 TESTS = facilitator-test.py
 # see http://www.gnu.org/software/automake/manual/html_node/Parallel-Test-Harness.html#index-TEST_005fEXTENSIONS
 TEST_EXTENSIONS = .py
 PY_LOG_COMPILER = $(PYTHON)
-AM_TESTS_ENVIRONMENT = PYTHONPATH='$(srcdir)'; export PYTHONPATH;
+AM_TESTS_ENVIRONMENT = PYTHONPATH='$(srcdir):$(PYTHONPATH)'; export PYTHONPATH;
 AM_PY_LOG_FLAGS =
 
 # AC_CONFIG_FILES doesn't fully-expand directory variables
diff --git a/facilitator/doc/facilitator-design.txt b/facilitator/doc/facilitator-design.txt
index 0d84da3..20f9c0a 100644
--- a/facilitator/doc/facilitator-design.txt
+++ b/facilitator/doc/facilitator-design.txt
@@ -39,6 +39,3 @@ the HTTP method, either yours or that of another facilitator. It takes
 advantage of the fact that a censor cannot distinguish between a TLS
 connection to appspot.com or google.com, since the IPs are the same,
 and it is highly unlikely that anyone will try to block the latter.
-
-fac.py is a Python module containing code common to the various
-facilitator programs.
diff --git a/facilitator/fac.py b/facilitator/fac.py
deleted file mode 100644
index 00c103c..0000000
--- a/facilitator/fac.py
+++ /dev/null
@@ -1,404 +0,0 @@
-import errno
-import os
-import re
-import socket
-import stat
-import subprocess
-import pwd
-import urlparse
-from collections import namedtuple
-
-DEFAULT_CLIENT_TRANSPORT = "websocket"
-
-# Return true iff the given fd is readable, writable, and executable only by its
-# owner.
-def check_perms(fd):
-    mode = os.fstat(fd)[0]
-    return (mode & (stat.S_IRWXG | stat.S_IRWXO)) == 0
-
-# Drop privileges by switching ID to that of the given user.
-# http://stackoverflow.com/questions/2699907/dropping-root-permissions-in-python/2699996#2699996
-# https://www.securecoding.cert.org/confluence/display/seccode/POS36-C.+Observe+correct+revocation+order+while+relinquishing+privileges
-# https://www.securecoding.cert.org/confluence/display/seccode/POS37-C.+Ensure+that+privilege+relinquishment+is+successful
-def drop_privs(username):
-    uid = pwd.getpwnam(username).pw_uid
-    gid = pwd.getpwnam(username).pw_gid
-    os.setgroups([])
-    os.setgid(gid)
-    os.setuid(uid)
-    try:
-        os.setuid(0)
-    except OSError:
-        pass
-    else:
-        raise AssertionError("setuid(0) succeeded after attempting to drop privileges")
-
-# A decorator to ignore "broken pipe" errors.
-def catch_epipe(fn):
-    def ret(self, *args):
-        try:
-            return fn(self, *args)
-        except socket.error, e:
-            try:
-                err_num = e.errno
-            except AttributeError:
-                # Before Python 2.6, exception can be a pair.
-                err_num, errstr = e
-            except:
-                raise
-            if err_num != errno.EPIPE:
-                raise
-    return ret
-
-def parse_addr_spec(spec, defhost = None, defport = None):
-    """Parse a host:port specification and return a 2-tuple ("host", port) as
-    understood by the Python socket functions.
-    >>> parse_addr_spec("192.168.0.1:9999")
-    ('192.168.0.1', 9999)
-
-    If defhost or defport are given, those parts of the specification may be
-    omitted; if so, they will be filled in with defaults.
-    >>> parse_addr_spec("192.168.0.2:8888", defhost="192.168.0.1", defport=9999)
-    ('192.168.0.2', 8888)
-    >>> parse_addr_spec(":8888", defhost="192.168.0.1", defport=9999)
-    ('192.168.0.1', 8888)
-    >>> parse_addr_spec("192.168.0.2", defhost="192.168.0.1", defport=9999)
-    ('192.168.0.2', 9999)
-    >>> parse_addr_spec("192.168.0.2:", defhost="192.168.0.1", defport=9999)
-    ('192.168.0.2', 9999)
-    >>> parse_addr_spec(":", defhost="192.168.0.1", defport=9999)
-    ('192.168.0.1', 9999)
-    >>> parse_addr_spec("", defhost="192.168.0.1", defport=9999)
-    ('192.168.0.1', 9999)
-    IPv6 addresses must be enclosed in square brackets."""
-    host = None
-    port = None
-    af = 0
-    m = None
-    # IPv6 syntax.
-    if not m:
-        m = re.match(ur'^\[(.+)\]:(\d*)$', spec)
-        if m:
-            host, port = m.groups()
-            af = socket.AF_INET6
-    if not m:
-        m = re.match(ur'^\[(.+)\]$', spec)
-        if m:
-            host, = m.groups()
-            af = socket.AF_INET6
-    # IPv4/hostname/port-only syntax.
-    if not m:
-        try:
-            host, port = spec.split(":", 1)
-        except ValueError:
-            host = spec
-        if re.match(ur'^[\d.]+$', host):
-            af = socket.AF_INET
-        else:
-            af = 0
-    host = host or defhost
-    port = port or defport
-    if host is None or port is None:
-        raise ValueError("Bad address specification \"%s\"" % spec)
-    return host, int(port)
-
-def resolve_to_ip(host, port, af=0, gai_flags=0):
-    """Resolves a host string to an IP address in canonical format.
-
-    Note: in many cases this is not necessary since the consumer of the address
-    can probably accept host names directly.
-
-    :param: host string to resolve; may be a DNS name or an IP address.
-    :param: port of the host
-    :param: af address family, default unspecified. set to socket.AF_INET or
-        socket.AF_INET6 to force IPv4 or IPv6 name resolution.
-    :returns: (IP address in canonical format, port)
-    """
-    # Forward-resolve the name into an addrinfo struct. Real DNS resolution is
-    # done only if resolve is true; otherwise the address must be numeric.
-    try:
-        addrs = socket.getaddrinfo(host, port, af, 0, 0, gai_flags)
-    except socket.gaierror, e:
-        raise ValueError("Bad host or port: \"%s\" \"%s\": %s" % (host, port, str(e)))
-    if not addrs:
-        raise ValueError("Bad host or port: \"%s\" \"%s\"" % (host, port))
-
-    # Convert the result of socket.getaddrinfo (which is a 2-tuple for IPv4 and
-    # a 4-tuple for IPv6) into a (host, port) 2-tuple.
-    host, port = socket.getnameinfo(addrs[0][4], socket.NI_NUMERICHOST | socket.NI_NUMERICSERV)
-    return host, int(port)
-
-def canonical_ip(host, port, af=0):
-    """Convert an IP address to a canonical format. Identical to resolve_to_ip,
-    except that the host param must already be an IP address."""
-    return resolve_to_ip(host, port, af, gai_flags=socket.AI_NUMERICHOST)
-
-def format_addr(addr):
-    host, port = addr
-    host_str = u""
-    port_str = u""
-    if host is not None:
-        # Numeric IPv6 address?
-        try:
-            addrs = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP, socket.AI_NUMERICHOST)
-            af = addrs[0][0]
-        except socket.gaierror, e:
-            af = 0
-        if af == socket.AF_INET6:
-            host_str = u"[%s]" % host
-        else:
-            host_str = u"%s" % host
-    if port is not None:
-        if not (0 < port <= 65535):
-            raise ValueError("port must be between 1 and 65535 (is %d)" % port)
-        port_str = u":%d" % port
-
-    if not host_str and not port_str:
-        raise ValueError("host and port may not both be None")
-    return u"%s%s" % (host_str, port_str)
-
-def read_client_registrations(body, defhost=None, defport=None):
-    """Yield client registrations (as Endpoints) from an encoded registration
-    message body. The message format is one registration per line, with each
-    line being encoded as application/x-www-form-urlencoded. The key "client" is
-    required and contains the client address and port (perhaps filled in by
-    defhost and defport). The key "client-transport" is optional and defaults to
-    "websocket".
-    Example:
-      client=1.2.3.4:9000&client-transport=websocket
-      client=1.2.3.4:9090&client-transport=obfs3|websocket
-    """
-    for line in body.splitlines():
-        qs = urlparse.parse_qs(line, keep_blank_values=True, strict_parsing=True)
-        # Get the unique value associated with the given key in qs. If the key
-        # is absent or appears more than once, raise ValueError.
-        def get_unique(key, default=None):
-            try:
-                vals = qs[key]
-            except KeyError:
-                if default is None:
-                    raise ValueError("missing %r key" % key)
-                vals = (default,)
-            if len(vals) != 1:
-                raise ValueError("more than one %r key" % key)
-            return vals[0]
-        addr = parse_addr_spec(get_unique("client"), defhost, defport)
-        transport = get_unique("client-transport", DEFAULT_CLIENT_TRANSPORT)
-        yield Endpoint(addr, transport)
-
-
-class Transport(namedtuple("Transport", "inner outer")):
-    @classmethod
-    def parse(cls, transport):
-        if isinstance(transport, cls):
-            return transport
-        elif type(transport) == str:
-            if "|" in transport:
-                inner, outer = transport.rsplit("|", 1)
-            else:
-                inner, outer = "", transport
-            return cls(inner, outer)
-        else:
-            raise ValueError("could not parse transport: %s" % transport)
-
-    def __init__(self, inner, outer):
-        if not outer:
-            raise ValueError("outer (proxy) part of transport must be non-empty: %s" % str(self))
-
-    def __str__(self):
-        return "%s|%s" % (self.inner, self.outer) if self.inner else self.outer
-
-
-class Endpoint(namedtuple("Endpoint", "addr transport")):
-    @classmethod
-    def parse(cls, spec, transport, defhost = None, defport = None):
-        host, port = parse_addr_spec(spec, defhost, defport)
-        return cls((host, port), Transport.parse(transport))
-
-
-def skip_space(pos, line):
-    """Skip a (possibly empty) sequence of space characters (the ASCII character
-    '\x20' exactly). Returns a pair (pos, num_skipped)."""
-    begin = pos
-    while pos < len(line) and line[pos] == "\x20":
-        pos += 1
-    return pos, pos - begin
-
-TOKEN_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-")
-def get_token(pos, line):
-    begin = pos
-    while pos < len(line) and line[pos] in TOKEN_CHARS:
-        pos += 1
-    if begin == pos:
-        raise ValueError("No token found at position %d" % pos)
-    return pos, line[begin:pos]
-
-def get_quoted_string(pos, line):
-    chars = []
-    if not (pos < len(line) and line[pos] == '"'):
-        raise ValueError("Expected '\"' at beginning of quoted string.")
-    pos += 1
-    while pos < len(line) and line[pos] != '"':
-        if line[pos] == '\\':
-            pos += 1
-            if not (pos < len(line)):
-                raise ValueError("End of line after backslash in quoted string")
-        chars.append(line[pos])
-        pos += 1
-    if not (pos < len(line) and line[pos] == '"'):
-        raise ValueError("Expected '\"' at end of quoted string.")
-    pos += 1
-    return pos, "".join(chars)
-
-def parse_transaction(line):
-    """A transaction is a command followed by zero or more key-value pairs. Like so:
-      COMMAND KEY="VALUE" KEY="\"ESCAPED\" VALUE"
-    Values must be quoted. Any byte value may be escaped with a backslash.
-    Returns a pair: (COMMAND, ((KEY1, VALUE1), (KEY2, VALUE2), ...)).
-    """
-    pos = 0
-    pos, skipped = skip_space(pos, line)
-    pos, command = get_token(pos, line)
-
-    pairs = []
-    while True:
-        pos, skipped = skip_space(pos, line)
-        if not (pos < len(line)):
-            break
-        if skipped == 0:
-            raise ValueError("Expected space before key-value pair")
-        pos, key = get_token(pos, line)
-        if not (pos < len(line) and line[pos] == '='):
-            raise ValueError("No '=' found after key")
-        pos += 1
-        pos, value = get_quoted_string(pos, line)
-        pairs.append((key, value))
-    return command, tuple(pairs)
-
-def param_first(key, params):
-    """Search 'params' for 'key' and return the first value that
-    occurs. If 'key' was not found, return None."""
-    for k, v in params:
-        if key == k:
-            return v
-    return None
-
-def param_getlist(key, params):
-    """Search 'params' for 'key' and return a list with its values. If
-    'key' did not appear in 'params', return the empty list."""
-    result = []
-    for k, v in params:
-        if key == k:
-            result.append(v)
-    return result
-
-def quote_string(s):
-    chars = []
-    for c in s:
-        if c == "\\":
-            c = "\\\\"
-        elif c == "\"":
-            c = "\\\""
-        chars.append(c)
-    return "\"" + "".join(chars) + "\""
-
-def render_transaction(command, *params):
-    parts = [command]
-    for key, value in params:
-        parts.append("%s=%s" % (key, quote_string(value)))
-    return " ".join(parts)
-
-def fac_socket(facilitator_addr):
-    return socket.create_connection(facilitator_addr, 1.0).makefile()
-
-def transact(f, command, *params):
-    transaction = render_transaction(command, *params)
-    print >> f, transaction
-    f.flush()
-    line = f.readline()
-    if not (len(line) > 0 and line[-1] == '\n'):
-        raise ValueError("No newline at end of string returned by facilitator")
-    return parse_transaction(line[:-1])
-
-def put_reg(facilitator_addr, client_addr, transport):
-    """Send a registration to the facilitator using a one-time socket. Returns
-    true iff the command was successful. transport is a transport string such as
-    "websocket" or "obfs3|websocket"."""
-    f = fac_socket(facilitator_addr)
-    params = [("CLIENT", format_addr(client_addr))]
-    params.append(("TRANSPORT", transport))
-    try:
-        command, params = transact(f, "PUT", *params)
-    finally:
-        f.close()
-    return command == "OK"
-
-def get_reg(facilitator_addr, proxy_addr, proxy_transport_list):
-    """
-    Get a client registration for proxy proxy_addr from the
-    facilitator at facilitator_addr using a one-time
-    socket. proxy_transport_list is a list containing the transport names that
-    the flashproxy supports.
-
-    Returns a dict with keys "client", "client-transport", "relay",
-    and "relay-transport" if successful, or a dict with the key "client"
-    mapped to the value "" if there are no registrations available for
-    proxy_addr. Raises an exception otherwise."""
-    f = fac_socket(facilitator_addr)
-
-    # Form a list (in transact() format) with the transports that we
-    # should send to the facilitator.  Then pass that list to the
-    # transact() function.
-    # For example, PROXY-TRANSPORT=obfs2 PROXY-TRANSPORT=obfs3.
-    transports = [("PROXY-TRANSPORT", tp) for tp in proxy_transport_list]
-
-    try:
-        command, params = transact(f, "GET", ("FROM", format_addr(proxy_addr)), *transports)
-    finally:
-        f.close()
-    response = {}
-    check_back_in = param_first("CHECK-BACK-IN", params)
-    if check_back_in is not None:
-        try:
-            float(check_back_in)
-        except ValueError:
-            raise ValueError("Facilitator returned non-numeric polling interval.")
-        response["check-back-in"] = check_back_in
-    if command == "NONE":
-        response["client"] = ""
-        return response
-    elif command == "OK":
-        client_spec = param_first("CLIENT", params)
-        client_transport = param_first("CLIENT-TRANSPORT", params)
-        relay_spec = param_first("RELAY", params)
-        relay_transport = param_first("RELAY-TRANSPORT", params)
-        if not client_spec:
-            raise ValueError("Facilitator did not return CLIENT")
-        if not client_transport:
-            raise ValueError("Facilitator did not return CLIENT-TRANSPORT")
-        if not relay_spec:
-            raise ValueError("Facilitator did not return RELAY")
-        if not relay_transport:
-            raise ValueError("Facilitator did not return RELAY-TRANSPORT")
-        # Check the syntax returned by the facilitator.
-        client = parse_addr_spec(client_spec)
-        relay = parse_addr_spec(relay_spec)
-        response["client"] = format_addr(client)
-        response["client-transport"] = client_transport
-        response["relay"] = format_addr(relay)
-        response["relay-transport"] = relay_transport
-        return response
-    else:
-        raise ValueError("Facilitator response was not \"OK\"")
-
-def put_reg_base64(b64):
-    """Attempt to add a registration by running a facilitator-reg program
-    locally."""
-    # Padding is optional, but the python base64 functions can't
-    # handle lack of padding. Add it here. Assumes correct encoding.
-    mod = len(b64) % 4
-    if mod != 0:
-        b64 += (4 - mod) * "="
-    p = subprocess.Popen(["facilitator-reg"], stdin=subprocess.PIPE)
-    stdout, stderr = p.communicate(b64)
-    return p.returncode == 0
diff --git a/facilitator/facilitator b/facilitator/facilitator
index a2dd56a..39ffe2a 100755
--- a/facilitator/facilitator
+++ b/facilitator/facilitator
@@ -9,8 +9,10 @@ import threading
 import time
 from collections import defaultdict
 
-import fac
-from fac import Transport, Endpoint
+from flashproxy import fac
+from flashproxy import proc
+from flashproxy.reg import Transport, Endpoint
+from flashproxy.util import parse_addr_spec, format_addr, canonical_ip
 
 LISTEN_ADDRESS = "127.0.0.1"
 DEFAULT_LISTEN_PORT = 9002
@@ -241,7 +243,7 @@ class Handler(SocketServer.StreamRequestHandler):
                 if buflen >= READLINE_MAX_LENGTH:
                     raise socket.error("readline: refusing to buffer %d bytes (last read was %d bytes)" % (buflen, len(data)))
 
-    @fac.catch_epipe
+    @proc.catch_epipe
     def handle(self):
         num_lines = 0
         while True:
@@ -290,7 +292,7 @@ class Handler(SocketServer.StreamRequestHandler):
         if proxy_spec is None:
             return self.error(u"GET missing FROM param")
         try:
-            proxy_addr = fac.canonical_ip(*fac.parse_addr_spec(proxy_spec, defport=0))
+            proxy_addr = canonical_ip(*parse_addr_spec(proxy_spec, defport=0))
         except ValueError, e:
             return self.error(u"syntax error in proxy address %s: %s" % (safe_str(repr(proxy_spec)), safe_str(repr(str(e)))))
 
@@ -309,9 +311,9 @@ class Handler(SocketServer.StreamRequestHandler):
             log(u"proxy (%s) gets client '%s' (supported transports: %s) (num relays: %s) (remaining regs: %d/%d)" %
                 (safe_str(repr(proxy_spec)), safe_str(repr(client_reg.addr)), transport_list, num_relays(), num_unhandled_regs(), num_regs()))
             print >> self.wfile, fac.render_transaction("OK",
-                ("CLIENT", fac.format_addr(client_reg.addr)),
+                ("CLIENT", format_addr(client_reg.addr)),
                 ("CLIENT-TRANSPORT", client_reg.transport.outer),
-                ("RELAY", fac.format_addr(relay_reg.addr)),
+                ("RELAY", format_addr(relay_reg.addr)),
                 ("RELAY-TRANSPORT", relay_reg.transport.outer),
                 ("CHECK-BACK-IN", str(check_back_in)))
         else:
@@ -356,7 +358,7 @@ class Handler(SocketServer.StreamRequestHandler):
         self.send_ok()
         return True
 
-    finish = fac.catch_epipe(SocketServer.StreamRequestHandler.finish)
+    finish = proc.catch_epipe(SocketServer.StreamRequestHandler.finish)
 
 class Server(SocketServer.ThreadingMixIn, SocketServer.TCPServer):
     allow_reuse_address = True
@@ -422,7 +424,7 @@ def parse_relay_file(servers, fp):
             transport_spec, addr_spec = line.strip().split()
         except ValueError, e:
             raise ValueError("Wrong line format: %s." % repr(line))
-        addr = fac.parse_addr_spec(addr_spec, defport=DEFAULT_RELAY_PORT)
+        addr = parse_addr_spec(addr_spec, defport=DEFAULT_RELAY_PORT)
         transport = Transport.parse(transport_spec)
         if transport.outer not in options.outer_transports:
             raise ValueError(u"Unrecognized transport: %s" % transport)
@@ -495,7 +497,7 @@ obfs2|websocket 1.4.6.1:4123\
 
     server = Server(addrinfo[4], Handler)
 
-    log(u"start on %s" % fac.format_addr(addrinfo[4]))
+    log(u"start on %s" % format_addr(addrinfo[4]))
     log(u"using IPv4 relays %s" % str(RELAYS[socket.AF_INET]._endpoints))
     log(u"using IPv6 relays %s" % str(RELAYS[socket.AF_INET6]._endpoints))
 
@@ -512,7 +514,7 @@ obfs2|websocket 1.4.6.1:4123\
     if options.privdrop_username is not None:
         log(u"dropping privileges to those of user %s" % options.privdrop_username)
         try:
-            fac.drop_privs(options.privdrop_username)
+            proc.drop_privs(options.privdrop_username)
         except BaseException, e:
             print >> sys.stderr, "Can't drop privileges:", str(e)
             sys.exit(1)
diff --git a/facilitator/facilitator-email-poller b/facilitator/facilitator-email-poller
index 2efceec..0eef115 100755
--- a/facilitator/facilitator-email-poller
+++ b/facilitator/facilitator-email-poller
@@ -16,7 +16,9 @@ import sys
 import tempfile
 import time
 
-import fac
+from flashproxy import fac
+from flashproxy import proc
+from flashproxy.util import parse_addr_spec
 
 from hashlib import sha1
 from M2Crypto import SSL
@@ -209,7 +211,7 @@ Failed to open password file "%s": %s.\
 """ % (options.password_filename, str(e))
     sys.exit(1)
 try:
-    if not fac.check_perms(password_file.fileno()):
+    if not proc.check_perms(password_file.fileno()):
         print >> sys.stderr, "Refusing to run with group- or world-readable password file. Try"
         print >> sys.stderr, "\tchmod 600 %s" % options.password_filename
         sys.exit(1)
@@ -221,7 +223,7 @@ try:
         if not res:
             raise ValueError("could not find email or password on line %s" % (lineno0+1))
         (imap_addr_spec, email_addr, email_password) = res.groups()
-        imap_addr = fac.parse_addr_spec(
+        imap_addr = parse_addr_spec(
             imap_addr_spec or "", DEFAULT_IMAP_HOST, DEFAULT_IMAP_PORT)
         break
     else:
@@ -255,7 +257,7 @@ if options.daemonize:
 if options.privdrop_username is not None:
     log(u"dropping privileges to those of user %s" % options.privdrop_username)
     try:
-        fac.drop_privs(options.privdrop_username)
+        proc.drop_privs(options.privdrop_username)
     except BaseException, e:
         print >> sys.stderr, "Can't drop privileges:", str(e)
         sys.exit(1)
diff --git a/facilitator/facilitator-reg-daemon b/facilitator/facilitator-reg-daemon
index cab7403..f5e592f 100755
--- a/facilitator/facilitator-reg-daemon
+++ b/facilitator/facilitator-reg-daemon
@@ -8,7 +8,9 @@ import sys
 import threading
 import time
 
-import fac
+from flashproxy import fac
+from flashproxy import proc
+from flashproxy.util import format_addr
 
 from M2Crypto import RSA
 
@@ -98,7 +100,7 @@ class Handler(SocketServer.StreamRequestHandler):
                 raise socket.error("refusing to buffer %d bytes (last read was %d bytes)" % (buflen, len(data)))
         return self.buffer
 
-    @fac.catch_epipe
+    @proc.catch_epipe
     def handle(self):
         try:
             b64_ciphertext = self.read_input()
@@ -109,7 +111,7 @@ class Handler(SocketServer.StreamRequestHandler):
             ciphertext = b64_ciphertext.decode("base64")
             plaintext = rsa.private_decrypt(ciphertext, RSA.pkcs1_oaep_padding)
             for client_reg in fac.read_client_registrations(plaintext):
-                log(u"registering %s" % safe_str(fac.format_addr(client_reg.addr)))
+                log(u"registering %s" % safe_str(format_addr(client_reg.addr)))
                 if not fac.put_reg(FACILITATOR_ADDR, client_reg.addr, client_reg.transport):
                     print >> self.wfile, "FAIL"
                     break
@@ -120,7 +122,7 @@ class Handler(SocketServer.StreamRequestHandler):
             print >> self.wfile, "FAIL"
             raise
 
-    finish = fac.catch_epipe(SocketServer.StreamRequestHandler.finish)
+    finish = proc.catch_epipe(SocketServer.StreamRequestHandler.finish)
 
 class Server(SocketServer.ThreadingMixIn, SocketServer.TCPServer):
     allow_reuse_address = True
@@ -164,7 +166,7 @@ def main():
         print >> sys.stderr, "Failed to open private key file \"%s\": %s." % (options.key_filename, str(e))
         sys.exit(1)
     try:
-        if not fac.check_perms(key_file.fileno()):
+        if not proc.check_perms(key_file.fileno()):
             print >> sys.stderr, "Refusing to run with group- or world-readable private key file. Try"
             print >> sys.stderr, "\tchmod 600 %s" % options.key_filename
             sys.exit(1)
@@ -183,7 +185,7 @@ def main():
 
     server = Server(addrinfo[4], Handler)
 
-    log(u"start on %s" % fac.format_addr(addrinfo[4]))
+    log(u"start on %s" % format_addr(addrinfo[4]))
 
     if options.daemonize:
         log(u"daemonizing")
@@ -198,7 +200,7 @@ def main():
     if options.privdrop_username is not None:
         log(u"dropping privileges to those of user %s" % options.privdrop_username)
         try:
-            fac.drop_privs(options.privdrop_username)
+            proc.drop_privs(options.privdrop_username)
         except BaseException, e:
             print >> sys.stderr, "Can't drop privileges:", str(e)
             sys.exit(1)
diff --git a/facilitator/facilitator-test.py b/facilitator/facilitator-test.py
index d63e0b5..fd4ac88 100755
--- a/facilitator/facilitator-test.py
+++ b/facilitator/facilitator-test.py
@@ -9,8 +9,9 @@ import sys
 import time
 import unittest
 
-import fac
-from fac import Transport, Endpoint
+from flashproxy import fac
+from flashproxy.reg import Transport, Endpoint
+from flashproxy.util import format_addr
 
 # Import the facilitator program as a module.
 import imp
@@ -178,19 +179,6 @@ class EndpointsTest(unittest.TestCase):
 
 class FacilitatorTest(unittest.TestCase):
 
-    def test_transport_parse(self):
-        self.assertEquals(Transport.parse("a"), Transport("", "a"))
-        self.assertEquals(Transport.parse("|a"), Transport("", "a"))
-        self.assertEquals(Transport.parse("a|b|c"), Transport("a|b","c"))
-        self.assertEquals(Transport.parse(Transport("a|b","c")), Transport("a|b","c"))
-        self.assertRaises(ValueError, Transport, "", "")
-        self.assertRaises(ValueError, Transport, "a", "")
-        self.assertRaises(ValueError, Transport.parse, "")
-        self.assertRaises(ValueError, Transport.parse, "|")
-        self.assertRaises(ValueError, Transport.parse, "a|")
-        self.assertRaises(ValueError, Transport.parse, ["a"])
-        self.assertRaises(ValueError, Transport.parse, [Transport("a", "b")])
-
     def test_parse_relay_file(self):
         fp = StringIO()
         fp.write("websocket 0.0.1.0:1\n")
@@ -201,6 +189,7 @@ class FacilitatorTest(unittest.TestCase):
         parse_relay_file(servers, fp)
         self.assertEquals(servers[af]._endpoints, {('0.0.1.0', 1): Transport('', 'websocket')})
 
+
 class FacilitatorProcTest(unittest.TestCase):
     IPV4_CLIENT_ADDR = ("1.1.1.1", 9000)
     IPV6_CLIENT_ADDR = ("[11::11]", 9000)
@@ -214,8 +203,8 @@ class FacilitatorProcTest(unittest.TestCase):
 
     def setUp(self):
         self.relay_file = tempfile.NamedTemporaryFile()
-        self.relay_file.write("%s %s\n" % (RELAY_TP, fac.format_addr(self.IPV4_RELAY_ADDR)))
-        self.relay_file.write("%s %s\n" % (RELAY_TP, fac.format_addr(self.IPV6_RELAY_ADDR)))
+        self.relay_file.write("%s %s\n" % (RELAY_TP, format_addr(self.IPV4_RELAY_ADDR)))
+        self.relay_file.write("%s %s\n" % (RELAY_TP, format_addr(self.IPV6_RELAY_ADDR)))
         self.relay_file.flush()
         self.relay_file.seek(0)
         fn = os.path.join(os.path.dirname(__file__), "./facilitator")
@@ -261,7 +250,7 @@ class FacilitatorProcTest(unittest.TestCase):
         fac.put_reg(FACILITATOR_ADDR, self.IPV4_CLIENT_ADDR, CLIENT_TP)
         fac.put_reg(FACILITATOR_ADDR, self.IPV6_CLIENT_ADDR, CLIENT_TP)
         reg = fac.get_reg(FACILITATOR_ADDR, self.IPV4_PROXY_ADDR, PROXY_TPS)
-        self.assertEqual(reg["client"], fac.format_addr(self.IPV4_CLIENT_ADDR))
+        self.assertEqual(reg["client"], format_addr(self.IPV4_CLIENT_ADDR))
 
     def test_af_v4_v6(self):
         """Test that IPv4 proxies do not get IPv6 clients."""
@@ -280,15 +269,15 @@ class FacilitatorProcTest(unittest.TestCase):
         fac.put_reg(FACILITATOR_ADDR, self.IPV4_CLIENT_ADDR, CLIENT_TP)
         fac.put_reg(FACILITATOR_ADDR, self.IPV6_CLIENT_ADDR, CLIENT_TP)
         reg = fac.get_reg(FACILITATOR_ADDR, self.IPV6_PROXY_ADDR, PROXY_TPS)
-        self.assertEqual(reg["client"], fac.format_addr(self.IPV6_CLIENT_ADDR))
+        self.assertEqual(reg["client"], format_addr(self.IPV6_CLIENT_ADDR))
 
     def test_fields(self):
         """Test that facilitator responses contain all the required fields."""
         fac.put_reg(FACILITATOR_ADDR, self.IPV4_CLIENT_ADDR, CLIENT_TP)
         reg = fac.get_reg(FACILITATOR_ADDR, self.IPV4_PROXY_ADDR, PROXY_TPS)
-        self.assertEqual(reg["client"], fac.format_addr(self.IPV4_CLIENT_ADDR))
+        self.assertEqual(reg["client"], format_addr(self.IPV4_CLIENT_ADDR))
         self.assertEqual(reg["client-transport"], CLIENT_TP)
-        self.assertEqual(reg["relay"], fac.format_addr(self.IPV4_RELAY_ADDR))
+        self.assertEqual(reg["relay"], format_addr(self.IPV4_RELAY_ADDR))
         self.assertEqual(reg["relay-transport"], RELAY_TP)
         self.assertGreater(int(reg["check-back-in"]), 0)
 
@@ -323,117 +312,5 @@ class FacilitatorProcTest(unittest.TestCase):
 #         """Test that the facilitator rejects hostnames."""
 #         self.fail()
 
-class ParseAddrSpecTest(unittest.TestCase):
-    def test_ipv4(self):
-        self.assertEqual(fac.parse_addr_spec("192.168.0.1:9999"), ("192.168.0.1", 9999))
-
-    def test_ipv6(self):
-        self.assertEqual(fac.parse_addr_spec("[12::34]:9999"), ("12::34", 9999))
-
-    def test_defhost_defport_ipv4(self):
-        self.assertEqual(fac.parse_addr_spec("192.168.0.2:8888", defhost="192.168.0.1", defport=9999), ("192.168.0.2", 8888))
-        self.assertEqual(fac.parse_addr_spec("192.168.0.2:", defhost="192.168.0.1", defport=9999), ("192.168.0.2", 9999))
-        self.assertEqual(fac.parse_addr_spec("192.168.0.2", defhost="192.168.0.1", defport=9999), ("192.168.0.2", 9999))
-        self.assertEqual(fac.parse_addr_spec(":8888", defhost="192.168.0.1", defport=9999), ("192.168.0.1", 8888))
-        self.assertEqual(fac.parse_addr_spec(":", defhost="192.168.0.1", defport=9999), ("192.168.0.1", 9999))
-        self.assertEqual(fac.parse_addr_spec("", defhost="192.168.0.1", defport=9999), ("192.168.0.1", 9999))
-
-    def test_defhost_defport_ipv6(self):
-        self.assertEqual(fac.parse_addr_spec("[1234::2]:8888", defhost="1234::1", defport=9999), ("1234::2", 8888))
-        self.assertEqual(fac.parse_addr_spec("[1234::2]:", defhost="1234::1", defport=9999), ("1234::2", 9999))
-        self.assertEqual(fac.parse_addr_spec("[1234::2]", defhost="1234::1", defport=9999), ("1234::2", 9999))
-        self.assertEqual(fac.parse_addr_spec(":8888", defhost="1234::1", defport=9999), ("1234::1", 8888))
-        self.assertEqual(fac.parse_addr_spec(":", defhost="1234::1", defport=9999), ("1234::1", 9999))
-        self.assertEqual(fac.parse_addr_spec("", defhost="1234::1", defport=9999), ("1234::1", 9999))
-
-    def test_canonical_ip_noresolve(self):
-        """Test that canonical_ip does not do DNS resolution by default."""
-        self.assertRaises(ValueError, fac.canonical_ip, *fac.parse_addr_spec("example.com:80"))
-
-class ParseTransactionTest(unittest.TestCase):
-    def test_empty_string(self):
-        self.assertRaises(ValueError, fac.parse_transaction, "")
-
-    def test_correct(self):
-        self.assertEqual(fac.parse_transaction("COMMAND"), ("COMMAND", ()))
-        self.assertEqual(fac.parse_transaction("COMMAND X=\"\""), ("COMMAND", (("X", ""),)))
-        self.assertEqual(fac.parse_transaction("COMMAND X=\"ABC\""), ("COMMAND", (("X", "ABC"),)))
-        self.assertEqual(fac.parse_transaction("COMMAND X=\"\\A\\B\\C\""), ("COMMAND", (("X", "ABC"),)))
-        self.assertEqual(fac.parse_transaction("COMMAND X=\"\\\\\\\"\""), ("COMMAND", (("X", "\\\""),)))
-        self.assertEqual(fac.parse_transaction("COMMAND X=\"ABC\" Y=\"DEF\""), ("COMMAND", (("X", "ABC"), ("Y", "DEF"))))
-        self.assertEqual(fac.parse_transaction("COMMAND KEY-NAME=\"ABC\""), ("COMMAND", (("KEY-NAME", "ABC"),)))
-        self.assertEqual(fac.parse_transaction("COMMAND KEY_NAME=\"ABC\""), ("COMMAND", (("KEY_NAME", "ABC"),)))
-
-    def test_missing_command(self):
-        self.assertRaises(ValueError, fac.parse_transaction, "X=\"ABC\"")
-        self.assertRaises(ValueError, fac.parse_transaction, " X=\"ABC\"")
-
-    def test_missing_space(self):
-        self.assertRaises(ValueError, fac.parse_transaction, "COMMAND/X=\"ABC\"")
-        self.assertRaises(ValueError, fac.parse_transaction, "COMMAND X=\"ABC\"Y=\"DEF\"")
-
-    def test_bad_quotes(self):
-        self.assertRaises(ValueError, fac.parse_transaction, "COMMAND X=\"")
-        self.assertRaises(ValueError, fac.parse_transaction, "COMMAND X=\"ABC")
-        self.assertRaises(ValueError, fac.parse_transaction, "COMMAND X=\"ABC\" Y=\"ABC")
-        self.assertRaises(ValueError, fac.parse_transaction, "COMMAND X=\"ABC\\")
-
-    def test_truncated(self):
-        self.assertRaises(ValueError, fac.parse_transaction, "COMMAND X=")
-
-    def test_newline(self):
-        self.assertRaises(ValueError, fac.parse_transaction, "COMMAND X=\"ABC\" \nY=\"DEF\"")
-
-class ReadClientRegistrationsTest(unittest.TestCase):
-    def testSingle(self):
-        l = list(fac.read_client_registrations(""))
-        self.assertEqual(len(l), 0)
-        l = list(fac.read_client_registrations("client=1.2.3.4:1111"))
-        self.assertEqual(len(l), 1)
-        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
-        l = list(fac.read_client_registrations("client=1.2.3.4:1111\n"))
-        self.assertEqual(len(l), 1)
-        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
-        l = list(fac.read_client_registrations("foo=bar&client=1.2.3.4:1111&baz=quux"))
-        self.assertEqual(len(l), 1)
-        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
-        l = list(fac.read_client_registrations("foo=b%3dar&client=1.2.3.4%3a1111"))
-        self.assertEqual(len(l), 1)
-        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
-        l = list(fac.read_client_registrations("client=%5b1::2%5d:3333"))
-        self.assertEqual(len(l), 1)
-        self.assertEqual(l[0].addr, ("1::2", 3333))
-
-    def testDefaultAddress(self):
-        l = list(fac.read_client_registrations("client=:1111&transport=websocket", defhost="1.2.3.4"))
-        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
-        l = list(fac.read_client_registrations("client=1.2.3.4:&transport=websocket", defport=1111))
-        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
-
-    def testDefaultTransport(self):
-        l = list(fac.read_client_registrations("client=1.2.3.4:1111"))
-        self.assertEqual(l[0].transport, "websocket")
-
-    def testMultiple(self):
-        l = list(fac.read_client_registrations("client=1.2.3.4:1111&foo=bar\nfoo=bar&client=5.6.7.8:2222"))
-        self.assertEqual(len(l), 2)
-        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
-        self.assertEqual(l[1].addr, ("5.6.7.8", 2222))
-        l = list(fac.read_client_registrations("client=1.2.3.4:1111&foo=bar\nfoo=bar&client=%5b1::2%5d:3333"))
-        self.assertEqual(len(l), 2)
-        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
-        self.assertEqual(l[1].addr, ("1::2", 3333))
-
-    def testInvalid(self):
-        # Missing "client".
-        with self.assertRaises(ValueError):
-            list(fac.read_client_registrations("foo=bar"))
-        # More than one "client".
-        with self.assertRaises(ValueError):
-            list(fac.read_client_registrations("client=1.2.3.4:1111&foo=bar&client=5.6.7.8:2222"))
-        # Single client with bad syntax.
-        with self.assertRaises(ValueError):
-            list(fac.read_client_registrations("client=1.2.3.4,1111"))
-
 if __name__ == "__main__":
     unittest.main()
diff --git a/facilitator/facilitator.cgi b/facilitator/facilitator.cgi
index 42a888e..addcaf4 100755
--- a/facilitator/facilitator.cgi
+++ b/facilitator/facilitator.cgi
@@ -6,7 +6,7 @@ import socket
 import sys
 import urllib
 
-import fac
+from flashproxy import fac
 
 FACILITATOR_ADDR = ("127.0.0.1", 9002)
 
diff --git a/flashproxy-client b/flashproxy-client
index e54e554..f2cf04b 100755
--- a/flashproxy-client
+++ b/flashproxy-client
@@ -1129,14 +1129,14 @@ def main():
     default_remote_port = DEFAULT_REMOTE_PORT
 
     if len(args) == 0:
-        local_addr = (None, default_local_port)
-        remote_addr = (None, default_remote_port)
+        local_addr = ("", default_local_port)
+        remote_addr = ("", default_remote_port)
     elif len(args) == 1:
-        local_addr = parse_addr_spec(args[0], defport=default_local_port)
-        remote_addr = (None, default_remote_port)
+        local_addr = parse_addr_spec(args[0], defhost="", defport=default_local_port)
+        remote_addr = ("", default_remote_port)
     elif len(args) == 2:
-        local_addr = parse_addr_spec(args[0], defport=default_local_port)
-        remote_addr = parse_addr_spec(args[1], defport=default_remote_port)
+        local_addr = parse_addr_spec(args[0], defhost="", defport=default_local_port)
+        remote_addr = parse_addr_spec(args[1], defhost="", defport=default_remote_port)
     else:
         usage(sys.stderr)
         sys.exit(1)
diff --git a/flashproxy-reg-appspot b/flashproxy-reg-appspot
index 516fcc9..7738e43 100755
--- a/flashproxy-reg-appspot
+++ b/flashproxy-reg-appspot
@@ -19,7 +19,7 @@ except ImportError:
     # Defer the error reporting so that --help works even without M2Crypto.
     SSL = None
 
-DEFAULT_REMOTE_ADDRESS = None
+DEFAULT_REMOTE_ADDRESS = ""
 DEFAULT_REMOTE_PORT = 9000
 DEFAULT_TRANSPORT = "websocket"
 
diff --git a/flashproxy-reg-email b/flashproxy-reg-email
index ab8d4cb..d286af8 100755
--- a/flashproxy-reg-email
+++ b/flashproxy-reg-email
@@ -20,7 +20,7 @@ except ImportError:
     RSA = None
     SSL = None
 
-DEFAULT_REMOTE_ADDRESS = None
+DEFAULT_REMOTE_ADDRESS = ""
 DEFAULT_REMOTE_PORT = 9000
 DEFAULT_EMAIL_ADDRESS = "flashproxyreg.a at gmail.com"
 # dig MX gmail.com
diff --git a/flashproxy-reg-url b/flashproxy-reg-url
index c45bc26..0f92d44 100755
--- a/flashproxy-reg-url
+++ b/flashproxy-reg-url
@@ -15,7 +15,7 @@ except ImportError:
     # Defer the error reporting so that --help works even without M2Crypto.
     RSA = None
 
-DEFAULT_REMOTE_ADDRESS = None
+DEFAULT_REMOTE_ADDRESS = ""
 DEFAULT_REMOTE_PORT = 9000
 DEFAULT_FACILITATOR_URL = "https://fp-facilitator.org/"
 DEFAULT_TRANSPORT = "websocket"
diff --git a/flashproxy/fac.py b/flashproxy/fac.py
new file mode 100644
index 0000000..0686f54
--- /dev/null
+++ b/flashproxy/fac.py
@@ -0,0 +1,224 @@
+import socket
+import subprocess
+import urlparse
+
+from flashproxy import reg
+from flashproxy.util import parse_addr_spec, format_addr
+
+DEFAULT_CLIENT_TRANSPORT = "websocket"
+
+def read_client_registrations(body, defhost=None, defport=None):
+    """Yield client registrations (as Endpoints) from an encoded registration
+    message body. The message format is one registration per line, with each
+    line being encoded as application/x-www-form-urlencoded. The key "client" is
+    required and contains the client address and port (perhaps filled in by
+    defhost and defport). The key "client-transport" is optional and defaults to
+    "websocket".
+    Example:
+      client=1.2.3.4:9000&client-transport=websocket
+      client=1.2.3.4:9090&client-transport=obfs3|websocket
+    """
+    for line in body.splitlines():
+        qs = urlparse.parse_qs(line, keep_blank_values=True, strict_parsing=True)
+        # Get the unique value associated with the given key in qs. If the key
+        # is absent or appears more than once, raise ValueError.
+        def get_unique(key, default=None):
+            try:
+                vals = qs[key]
+            except KeyError:
+                if default is None:
+                    raise ValueError("missing %r key" % key)
+                vals = (default,)
+            if len(vals) != 1:
+                raise ValueError("more than one %r key" % key)
+            return vals[0]
+        addr = parse_addr_spec(get_unique("client"), defhost, defport)
+        transport = get_unique("client-transport", DEFAULT_CLIENT_TRANSPORT)
+        yield reg.Endpoint(addr, transport)
+
+def skip_space(pos, line):
+    """Skip a (possibly empty) sequence of space characters (the ASCII character
+    '\x20' exactly). Returns a pair (pos, num_skipped)."""
+    begin = pos
+    while pos < len(line) and line[pos] == "\x20":
+        pos += 1
+    return pos, pos - begin
+
+TOKEN_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-")
+def get_token(pos, line):
+    begin = pos
+    while pos < len(line) and line[pos] in TOKEN_CHARS:
+        pos += 1
+    if begin == pos:
+        raise ValueError("No token found at position %d" % pos)
+    return pos, line[begin:pos]
+
+def get_quoted_string(pos, line):
+    chars = []
+    if not (pos < len(line) and line[pos] == '"'):
+        raise ValueError("Expected '\"' at beginning of quoted string.")
+    pos += 1
+    while pos < len(line) and line[pos] != '"':
+        if line[pos] == '\\':
+            pos += 1
+            if not (pos < len(line)):
+                raise ValueError("End of line after backslash in quoted string")
+        chars.append(line[pos])
+        pos += 1
+    if not (pos < len(line) and line[pos] == '"'):
+        raise ValueError("Expected '\"' at end of quoted string.")
+    pos += 1
+    return pos, "".join(chars)
+
+def parse_transaction(line):
+    """A transaction is a command followed by zero or more key-value pairs. Like so:
+      COMMAND KEY="VALUE" KEY="\"ESCAPED\" VALUE"
+    Values must be quoted. Any byte value may be escaped with a backslash.
+    Returns a pair: (COMMAND, ((KEY1, VALUE1), (KEY2, VALUE2), ...)).
+    """
+    pos = 0
+    pos, skipped = skip_space(pos, line)
+    pos, command = get_token(pos, line)
+
+    pairs = []
+    while True:
+        pos, skipped = skip_space(pos, line)
+        if not (pos < len(line)):
+            break
+        if skipped == 0:
+            raise ValueError("Expected space before key-value pair")
+        pos, key = get_token(pos, line)
+        if not (pos < len(line) and line[pos] == '='):
+            raise ValueError("No '=' found after key")
+        pos += 1
+        pos, value = get_quoted_string(pos, line)
+        pairs.append((key, value))
+    return command, tuple(pairs)
+
+def param_first(key, params):
+    """Search 'params' for 'key' and return the first value that
+    occurs. If 'key' was not found, return None."""
+    for k, v in params:
+        if key == k:
+            return v
+    return None
+
+def param_getlist(key, params):
+    """Search 'params' for 'key' and return a list with its values. If
+    'key' did not appear in 'params', return the empty list."""
+    result = []
+    for k, v in params:
+        if key == k:
+            result.append(v)
+    return result
+
+def quote_string(s):
+    chars = []
+    for c in s:
+        if c == "\\":
+            c = "\\\\"
+        elif c == "\"":
+            c = "\\\""
+        chars.append(c)
+    return "\"" + "".join(chars) + "\""
+
+def render_transaction(command, *params):
+    parts = [command]
+    for key, value in params:
+        parts.append("%s=%s" % (key, quote_string(value)))
+    return " ".join(parts)
+
+def fac_socket(facilitator_addr):
+    return socket.create_connection(facilitator_addr, 1.0).makefile()
+
+def transact(f, command, *params):
+    transaction = render_transaction(command, *params)
+    print >> f, transaction
+    f.flush()
+    line = f.readline()
+    if not (len(line) > 0 and line[-1] == '\n'):
+        raise ValueError("No newline at end of string returned by facilitator")
+    return parse_transaction(line[:-1])
+
+def put_reg(facilitator_addr, client_addr, transport):
+    """Send a registration to the facilitator using a one-time socket. Returns
+    true iff the command was successful. transport is a transport string such as
+    "websocket" or "obfs3|websocket"."""
+    f = fac_socket(facilitator_addr)
+    params = [("CLIENT", format_addr(client_addr))]
+    params.append(("TRANSPORT", transport))
+    try:
+        command, params = transact(f, "PUT", *params)
+    finally:
+        f.close()
+    return command == "OK"
+
+def get_reg(facilitator_addr, proxy_addr, proxy_transport_list):
+    """
+    Get a client registration for proxy proxy_addr from the
+    facilitator at facilitator_addr using a one-time
+    socket. proxy_transport_list is a list containing the transport names that
+    the flashproxy supports.
+
+    Returns a dict with keys "client", "client-transport", "relay",
+    and "relay-transport" if successful, or a dict with the key "client"
+    mapped to the value "" if there are no registrations available for
+    proxy_addr. Raises an exception otherwise."""
+    f = fac_socket(facilitator_addr)
+
+    # Form a list (in transact() format) with the transports that we
+    # should send to the facilitator.  Then pass that list to the
+    # transact() function.
+    # For example, PROXY-TRANSPORT=obfs2 PROXY-TRANSPORT=obfs3.
+    transports = [("PROXY-TRANSPORT", tp) for tp in proxy_transport_list]
+
+    try:
+        command, params = transact(f, "GET", ("FROM", format_addr(proxy_addr)), *transports)
+    finally:
+        f.close()
+    response = {}
+    check_back_in = param_first("CHECK-BACK-IN", params)
+    if check_back_in is not None:
+        try:
+            float(check_back_in)
+        except ValueError:
+            raise ValueError("Facilitator returned non-numeric polling interval.")
+        response["check-back-in"] = check_back_in
+    if command == "NONE":
+        response["client"] = ""
+        return response
+    elif command == "OK":
+        client_spec = param_first("CLIENT", params)
+        client_transport = param_first("CLIENT-TRANSPORT", params)
+        relay_spec = param_first("RELAY", params)
+        relay_transport = param_first("RELAY-TRANSPORT", params)
+        if not client_spec:
+            raise ValueError("Facilitator did not return CLIENT")
+        if not client_transport:
+            raise ValueError("Facilitator did not return CLIENT-TRANSPORT")
+        if not relay_spec:
+            raise ValueError("Facilitator did not return RELAY")
+        if not relay_transport:
+            raise ValueError("Facilitator did not return RELAY-TRANSPORT")
+        # Check the syntax returned by the facilitator.
+        client = parse_addr_spec(client_spec)
+        relay = parse_addr_spec(relay_spec)
+        response["client"] = format_addr(client)
+        response["client-transport"] = client_transport
+        response["relay"] = format_addr(relay)
+        response["relay-transport"] = relay_transport
+        return response
+    else:
+        raise ValueError("Facilitator response was not \"OK\"")
+
+def put_reg_base64(b64):
+    """Attempt to add a registration by running a facilitator-reg program
+    locally."""
+    # Padding is optional, but the python base64 functions can't
+    # handle lack of padding. Add it here. Assumes correct encoding.
+    mod = len(b64) % 4
+    if mod != 0:
+        b64 += (4 - mod) * "="
+    p = subprocess.Popen(["facilitator-reg"], stdin=subprocess.PIPE)
+    stdout, stderr = p.communicate(b64)
+    return p.returncode == 0
diff --git a/flashproxy/proc.py b/flashproxy/proc.py
new file mode 100644
index 0000000..4a008b2
--- /dev/null
+++ b/flashproxy/proc.py
@@ -0,0 +1,47 @@
+import errno
+import os
+import socket
+import stat
+import pwd
+
+DEFAULT_CLIENT_TRANSPORT = "websocket"
+
+# Return true iff the given fd is readable, writable, and executable only by its
+# owner.
+def check_perms(fd):
+    mode = os.fstat(fd)[0]
+    return (mode & (stat.S_IRWXG | stat.S_IRWXO)) == 0
+
+# Drop privileges by switching ID to that of the given user.
+# http://stackoverflow.com/questions/2699907/dropping-root-permissions-in-python/2699996#2699996
+# https://www.securecoding.cert.org/confluence/display/seccode/POS36-C.+Observe+correct+revocation+order+while+relinquishing+privileges
+# https://www.securecoding.cert.org/confluence/display/seccode/POS37-C.+Ensure+that+privilege+relinquishment+is+successful
+def drop_privs(username):
+    uid = pwd.getpwnam(username).pw_uid
+    gid = pwd.getpwnam(username).pw_gid
+    os.setgroups([])
+    os.setgid(gid)
+    os.setuid(uid)
+    try:
+        os.setuid(0)
+    except OSError:
+        pass
+    else:
+        raise AssertionError("setuid(0) succeeded after attempting to drop privileges")
+
+# A decorator to ignore "broken pipe" errors.
+def catch_epipe(fn):
+    def ret(self, *args):
+        try:
+            return fn(self, *args)
+        except socket.error, e:
+            try:
+                err_num = e.errno
+            except AttributeError:
+                # Before Python 2.6, exception can be a pair.
+                err_num, errstr = e
+            except:
+                raise
+            if err_num != errno.EPIPE:
+                raise
+    return ret
diff --git a/flashproxy/reg.py b/flashproxy/reg.py
new file mode 100644
index 0000000..0551f06
--- /dev/null
+++ b/flashproxy/reg.py
@@ -0,0 +1,31 @@
+from collections import namedtuple
+
+from flashproxy.util import parse_addr_spec
+
+class Transport(namedtuple("Transport", "inner outer")):
+    @classmethod
+    def parse(cls, transport):
+        if isinstance(transport, cls):
+            return transport
+        elif type(transport) == str:
+            if "|" in transport:
+                inner, outer = transport.rsplit("|", 1)
+            else:
+                inner, outer = "", transport
+            return cls(inner, outer)
+        else:
+            raise ValueError("could not parse transport: %s" % transport)
+
+    def __init__(self, inner, outer):
+        if not outer:
+            raise ValueError("outer (proxy) part of transport must be non-empty: %s" % str(self))
+
+    def __str__(self):
+        return "%s|%s" % (self.inner, self.outer) if self.inner else self.outer
+
+
+class Endpoint(namedtuple("Endpoint", "addr transport")):
+    @classmethod
+    def parse(cls, spec, transport, defhost = None, defport = None):
+        host, port = parse_addr_spec(spec, defhost, defport)
+        return cls((host, port), Transport.parse(transport))
diff --git a/flashproxy/test/test_fac.py b/flashproxy/test/test_fac.py
new file mode 100644
index 0000000..e7dfa00
--- /dev/null
+++ b/flashproxy/test/test_fac.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python
+
+import unittest
+
+from flashproxy.fac import parse_transaction, read_client_registrations
+
+class ParseTransactionTest(unittest.TestCase):
+    def test_empty_string(self):
+        self.assertRaises(ValueError, parse_transaction, "")
+
+    def test_correct(self):
+        self.assertEqual(parse_transaction("COMMAND"), ("COMMAND", ()))
+        self.assertEqual(parse_transaction("COMMAND X=\"\""), ("COMMAND", (("X", ""),)))
+        self.assertEqual(parse_transaction("COMMAND X=\"ABC\""), ("COMMAND", (("X", "ABC"),)))
+        self.assertEqual(parse_transaction("COMMAND X=\"\\A\\B\\C\""), ("COMMAND", (("X", "ABC"),)))
+        self.assertEqual(parse_transaction("COMMAND X=\"\\\\\\\"\""), ("COMMAND", (("X", "\\\""),)))
+        self.assertEqual(parse_transaction("COMMAND X=\"ABC\" Y=\"DEF\""), ("COMMAND", (("X", "ABC"), ("Y", "DEF"))))
+        self.assertEqual(parse_transaction("COMMAND KEY-NAME=\"ABC\""), ("COMMAND", (("KEY-NAME", "ABC"),)))
+        self.assertEqual(parse_transaction("COMMAND KEY_NAME=\"ABC\""), ("COMMAND", (("KEY_NAME", "ABC"),)))
+
+    def test_missing_command(self):
+        self.assertRaises(ValueError, parse_transaction, "X=\"ABC\"")
+        self.assertRaises(ValueError, parse_transaction, " X=\"ABC\"")
+
+    def test_missing_space(self):
+        self.assertRaises(ValueError, parse_transaction, "COMMAND/X=\"ABC\"")
+        self.assertRaises(ValueError, parse_transaction, "COMMAND X=\"ABC\"Y=\"DEF\"")
+
+    def test_bad_quotes(self):
+        self.assertRaises(ValueError, parse_transaction, "COMMAND X=\"")
+        self.assertRaises(ValueError, parse_transaction, "COMMAND X=\"ABC")
+        self.assertRaises(ValueError, parse_transaction, "COMMAND X=\"ABC\" Y=\"ABC")
+        self.assertRaises(ValueError, parse_transaction, "COMMAND X=\"ABC\\")
+
+    def test_truncated(self):
+        self.assertRaises(ValueError, parse_transaction, "COMMAND X=")
+
+    def test_newline(self):
+        self.assertRaises(ValueError, parse_transaction, "COMMAND X=\"ABC\" \nY=\"DEF\"")
+
+class ReadClientRegistrationsTest(unittest.TestCase):
+    def testSingle(self):
+        l = list(read_client_registrations(""))
+        self.assertEqual(len(l), 0)
+        l = list(read_client_registrations("client=1.2.3.4:1111"))
+        self.assertEqual(len(l), 1)
+        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
+        l = list(read_client_registrations("client=1.2.3.4:1111\n"))
+        self.assertEqual(len(l), 1)
+        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
+        l = list(read_client_registrations("foo=bar&client=1.2.3.4:1111&baz=quux"))
+        self.assertEqual(len(l), 1)
+        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
+        l = list(read_client_registrations("foo=b%3dar&client=1.2.3.4%3a1111"))
+        self.assertEqual(len(l), 1)
+        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
+        l = list(read_client_registrations("client=%5b1::2%5d:3333"))
+        self.assertEqual(len(l), 1)
+        self.assertEqual(l[0].addr, ("1::2", 3333))
+
+    def testDefaultAddress(self):
+        l = list(read_client_registrations("client=:1111&transport=websocket", defhost="1.2.3.4"))
+        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
+        l = list(read_client_registrations("client=1.2.3.4:&transport=websocket", defport=1111))
+        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
+
+    def testDefaultTransport(self):
+        l = list(read_client_registrations("client=1.2.3.4:1111"))
+        self.assertEqual(l[0].transport, "websocket")
+
+    def testMultiple(self):
+        l = list(read_client_registrations("client=1.2.3.4:1111&foo=bar\nfoo=bar&client=5.6.7.8:2222"))
+        self.assertEqual(len(l), 2)
+        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
+        self.assertEqual(l[1].addr, ("5.6.7.8", 2222))
+        l = list(read_client_registrations("client=1.2.3.4:1111&foo=bar\nfoo=bar&client=%5b1::2%5d:3333"))
+        self.assertEqual(len(l), 2)
+        self.assertEqual(l[0].addr, ("1.2.3.4", 1111))
+        self.assertEqual(l[1].addr, ("1::2", 3333))
+
+    def testInvalid(self):
+        # Missing "client".
+        with self.assertRaises(ValueError):
+            list(read_client_registrations("foo=bar"))
+        # More than one "client".
+        with self.assertRaises(ValueError):
+            list(read_client_registrations("client=1.2.3.4:1111&foo=bar&client=5.6.7.8:2222"))
+        # Single client with bad syntax.
+        with self.assertRaises(ValueError):
+            list(read_client_registrations("client=1.2.3.4,1111"))
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/flashproxy/test/test_reg.py b/flashproxy/test/test_reg.py
new file mode 100644
index 0000000..6b0e196
--- /dev/null
+++ b/flashproxy/test/test_reg.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python
+
+import unittest
+
+from flashproxy.reg import Transport
+
+class TransportTest(unittest.TestCase):
+
+    def test_transport_parse(self):
+        self.assertEquals(Transport.parse("a"), Transport("", "a"))
+        self.assertEquals(Transport.parse("|a"), Transport("", "a"))
+        self.assertEquals(Transport.parse("a|b|c"), Transport("a|b","c"))
+        self.assertEquals(Transport.parse(Transport("a|b","c")), Transport("a|b","c"))
+        self.assertRaises(ValueError, Transport, "", "")
+        self.assertRaises(ValueError, Transport, "a", "")
+        self.assertRaises(ValueError, Transport.parse, "")
+        self.assertRaises(ValueError, Transport.parse, "|")
+        self.assertRaises(ValueError, Transport.parse, "a|")
+        self.assertRaises(ValueError, Transport.parse, ["a"])
+        self.assertRaises(ValueError, Transport.parse, [Transport("a", "b")])
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/flashproxy/test/test_util.py b/flashproxy/test/test_util.py
new file mode 100644
index 0000000..af4c2e6
--- /dev/null
+++ b/flashproxy/test/test_util.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python
+
+import unittest
+
+from flashproxy.util import parse_addr_spec, canonical_ip
+
+class ParseAddrSpecTest(unittest.TestCase):
+    def test_ipv4(self):
+        self.assertEqual(parse_addr_spec("192.168.0.1:9999"), ("192.168.0.1", 9999))
+
+    def test_ipv6(self):
+        self.assertEqual(parse_addr_spec("[12::34]:9999"), ("12::34", 9999))
+
+    def test_defhost_defport_ipv4(self):
+        self.assertEqual(parse_addr_spec("192.168.0.2:8888", defhost="192.168.0.1", defport=9999), ("192.168.0.2", 8888))
+        self.assertEqual(parse_addr_spec("192.168.0.2:", defhost="192.168.0.1", defport=9999), ("192.168.0.2", 9999))
+        self.assertEqual(parse_addr_spec("192.168.0.2", defhost="192.168.0.1", defport=9999), ("192.168.0.2", 9999))
+        self.assertEqual(parse_addr_spec(":8888", defhost="192.168.0.1", defport=9999), ("192.168.0.1", 8888))
+        self.assertEqual(parse_addr_spec(":", defhost="192.168.0.1", defport=9999), ("192.168.0.1", 9999))
+        self.assertEqual(parse_addr_spec("", defhost="192.168.0.1", defport=9999), ("192.168.0.1", 9999))
+
+    def test_defhost_defport_ipv6(self):
+        self.assertEqual(parse_addr_spec("[1234::2]:8888", defhost="1234::1", defport=9999), ("1234::2", 8888))
+        self.assertEqual(parse_addr_spec("[1234::2]:", defhost="1234::1", defport=9999), ("1234::2", 9999))
+        self.assertEqual(parse_addr_spec("[1234::2]", defhost="1234::1", defport=9999), ("1234::2", 9999))
+        self.assertEqual(parse_addr_spec(":8888", defhost="1234::1", defport=9999), ("1234::1", 8888))
+        self.assertEqual(parse_addr_spec(":", defhost="1234::1", defport=9999), ("1234::1", 9999))
+        self.assertEqual(parse_addr_spec("", defhost="1234::1", defport=9999), ("1234::1", 9999))
+
+    def test_canonical_ip_noresolve(self):
+        """Test that canonical_ip does not do DNS resolution by default."""
+        self.assertRaises(ValueError, canonical_ip, *parse_addr_spec("example.com:80"))
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/flashproxy/util.py b/flashproxy/util.py
index 47bd87a..b069bf7 100644
--- a/flashproxy/util.py
+++ b/flashproxy/util.py
@@ -2,6 +2,27 @@ import re
 import socket
 
 def parse_addr_spec(spec, defhost = None, defport = None):
+    """Parse a host:port specification and return a 2-tuple ("host", port) as
+    understood by the Python socket functions.
+    >>> parse_addr_spec("192.168.0.1:9999")
+    ('192.168.0.1', 9999)
+
+    If defhost or defport are given, those parts of the specification may be
+    omitted; if so, they will be filled in with defaults.
+    >>> parse_addr_spec("192.168.0.2:8888", defhost="192.168.0.1", defport=9999)
+    ('192.168.0.2', 8888)
+    >>> parse_addr_spec(":8888", defhost="192.168.0.1", defport=9999)
+    ('192.168.0.1', 8888)
+    >>> parse_addr_spec("192.168.0.2", defhost="192.168.0.1", defport=9999)
+    ('192.168.0.2', 9999)
+    >>> parse_addr_spec("192.168.0.2:", defhost="192.168.0.1", defport=9999)
+    ('192.168.0.2', 9999)
+    >>> parse_addr_spec(":", defhost="192.168.0.1", defport=9999)
+    ('192.168.0.1', 9999)
+    >>> parse_addr_spec("", defhost="192.168.0.1", defport=9999)
+    ('192.168.0.1', 9999)
+
+    IPv6 addresses must be enclosed in square brackets."""
     host = None
     port = None
     af = 0
@@ -29,24 +50,61 @@ def parse_addr_spec(spec, defhost = None, defport = None):
             af = 0
     host = host or defhost
     port = port or defport
-    if port is not None:
-        port = int(port)
-    return host, port
+    if host is None or port is None:
+        raise ValueError("Bad address specification \"%s\"" % spec)
+    return host, int(port)
 
-def format_addr(addr):
-    host, port = addr
-    if not host:
-        return u":%d" % port
-    # Numeric IPv6 address?
+def resolve_to_ip(host, port, af=0, gai_flags=0):
+    """Resolves a host string to an IP address in canonical format.
+
+    Note: in many cases this is not necessary since the consumer of the address
+    can probably accept host names directly.
+
+    :param: host string to resolve; may be a DNS name or an IP address.
+    :param: port of the host
+    :param: af address family, default unspecified. set to socket.AF_INET or
+        socket.AF_INET6 to force IPv4 or IPv6 name resolution.
+    :returns: (IP address in canonical format, port)
+    """
+    # Forward-resolve the name into an addrinfo struct. Real DNS resolution is
+    # done only if resolve is true; otherwise the address must be numeric.
     try:
-        addrs = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP, socket.AI_NUMERICHOST)
-        af = addrs[0][0]
+        addrs = socket.getaddrinfo(host, port, af, 0, 0, gai_flags)
     except socket.gaierror, e:
-        af = 0
-    if af == socket.AF_INET6:
-        result = u"[%s]" % host
-    else:
-        result = "%s" % host
+        raise ValueError("Bad host or port: \"%s\" \"%s\": %s" % (host, port, str(e)))
+    if not addrs:
+        raise ValueError("Bad host or port: \"%s\" \"%s\"" % (host, port))
+
+    # Convert the result of socket.getaddrinfo (which is a 2-tuple for IPv4 and
+    # a 4-tuple for IPv6) into a (host, port) 2-tuple.
+    host, port = socket.getnameinfo(addrs[0][4], socket.NI_NUMERICHOST | socket.NI_NUMERICSERV)
+    return host, int(port)
+
+def canonical_ip(host, port, af=0):
+    """Convert an IP address to a canonical format. Identical to resolve_to_ip,
+    except that the host param must already be an IP address."""
+    return resolve_to_ip(host, port, af, gai_flags=socket.AI_NUMERICHOST)
+
+def format_addr(addr):
+    host, port = addr
+    host_str = u""
+    port_str = u""
+    if host is not None:
+        # Numeric IPv6 address?
+        try:
+            addrs = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP, socket.AI_NUMERICHOST)
+            af = addrs[0][0]
+        except socket.gaierror, e:
+            af = 0
+        if af == socket.AF_INET6:
+            host_str = u"[%s]" % host
+        else:
+            host_str = u"%s" % host
     if port is not None:
-        result += u":%d" % port
-    return result
+        if not (0 < port <= 65535):
+            raise ValueError("port must be between 1 and 65535 (is %d)" % port)
+        port_str = u":%d" % port
+
+    if not host_str and not port_str:
+        raise ValueError("host and port may not both be None")
+    return u"%s%s" % (host_str, port_str)





More information about the tor-commits mailing list