[Author Prev][Author Next][Thread Prev][Thread Next][Author Index][Thread Index]

[minion-cvs] Break and fix server repeatedly until DNS-based routing...



Update of /home/minion/cvsroot/src/minion/lib/mixminion
In directory moria.mit.edu:/tmp/cvs-serv21642/lib/mixminion

Modified Files:
	BuildMessage.py ClientDirectory.py ClientMain.py Config.py 
	NetUtils.py Packet.py ServerInfo.py __init__.py test.py 
Log Message:
Break and fix server repeatedly until DNS-based routing works.  Hurray.

TODO:
 - Reflect state of work
 - Defer client-side fragment reassembly

setup.py
 - Bump version to 0.0.6alpha2

BuildMessage.py
 - Choose relay types and routing info based on server capabilities -- don't
   just assume that IPV4 is right for everyone.

ClientDirectory.py
 - Document everything.
 - Remove spurious isSURB field from ExitAddress
 - Fix theoretical bug that would crash path generation with non-mixminion
   servers.
 - Deprecate unadorned '*' in paths.
 - Note bug with path length checking.

ClientMain.py
 - Deprecate -H; use -P foo instead.
 - Make list-servers work

Config.py, NetUtils.py:
 - Refactor _parseIP and _parseIP6 validation functions into NetUtils.

Config.py
 - Add documentation

NetUtils.py
 - Add documentation
 - Debug getIP
 - Add function to detect static IP4 or IP6 addresses.

Packet.py
 - Debug parseRelayInfoByType
 - Documentation

ServerInfo.py:
 - Documentation
 - Disable hostname-based routing with 0.0.6alpha1 servers.  (I don't
   want to break Tonga and peertech.)
 
test.py:
 - Add tests for DNS functionality
 - Add tests for DNS farm functionality
 - Add tests for new ServerInfo methods

DNSFarm.py
 - Add documentation
 - Make DNSCache.shutdown() more failsafe, and make shutdown(wait=1) not
   deadlock the server.
 - Add special-case test to skip hostname lookup for static IP addresses

MMTPServer.py
 - Make sure that we get real RelayPacket objects.
 - Choose whether to use IP4 or IP6 connections in MMTPClientConnection

PacketHandler.py
 - Accept HOST relay types and MMTPHostInfo routinginfo.

ServerConfig.py:
 - Remove obsolete __DEBUG_GC option.

ServerMain:
 - Change same-server detection mechanism to only look at key ID.

ServerQueue.py:
 - Add assert to catch weird bug case.
 



Index: BuildMessage.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/BuildMessage.py,v
retrieving revision 1.60
retrieving revision 1.61
diff -u -d -r1.60 -r1.61
--- BuildMessage.py	19 Oct 2003 03:12:01 -0000	1.60
+++ BuildMessage.py	10 Nov 2003 04:12:20 -0000	1.61
@@ -258,7 +258,8 @@
     header = _buildHeader(path, headerSecrets, exitType, tag+exitInfo,
                           paddingPRNG=Crypto.getCommonPRNG())
 
-    # XXXX007 switch to Host info.
+    # XXXX007 switch to Host info.  We need to use IPV4 for reply blocks
+    # XXXX007 for now, since we don't know which servers will support HOST.
     return ReplyBlock(header, expiryTime,
                       SWAP_FWD_IPV4_TYPE,
                       path[0].getIPV4Info().pack(), sharedKey), secrets, tag
@@ -308,7 +309,8 @@
     err = 0 # 0: no error. 1: 1st leg too big. 2: 1st leg okay, 2nd too big.
     if path1 is not None:
         try:
-            _getRouting(path1, SWAP_FWD_IPV4_TYPE, path2[0].getRoutingInfo().pack())
+            rt,ri = path1[-1].getRoutingFor(path2[0],swap=1)
+            _getRouting(path1, rt, ri)
         except MixError:
             err = 1
     # Add a dummy tag as needed to last exitinfo.
@@ -457,7 +459,7 @@
 
 #----------------------------------------------------------------------
 def _buildPacket(payload, exitType, exitInfo,
-                path1, path2, paddingPRNG=None, paranoia=0):
+                 path1, path2, paddingPRNG=None, paranoia=0):
     """Helper method to create a message.
 
     The following fields must be set:
@@ -511,8 +513,7 @@
         path1exittype = reply.routingType
         path1exitinfo = reply.routingInfo
     else:
-        path1exittype = SWAP_FWD_IPV4_TYPE
-        path1exitinfo = path2[0].getRoutingInfo().pack()
+        path1exittype, path1exitinfo = path1[-1].getRoutingFor(path2[0],swap=1)
 
     # Generate secrets for path1.
     secrets1 = [ secretRNG.getBytes(SECRET_LEN) for _ in path1 ]
@@ -700,8 +701,9 @@
        Raises MixError if the routing info is too big to fit into a single
        header. """
     # Construct a list 'routing' of exitType, exitInfo.
-    routing = [ (FWD_IPV4_TYPE, node.getRoutingInfo().pack()) for
-                node in path[1:] ]
+    routing = []
+    for i in xrange(len(path)-1):
+        routing.append(path[i].getRoutingFor(path[i+1],swap=0))
     routing.append((exitType, exitInfo))
 
     # sizes[i] is number of bytes added to header for subheader i.

Index: ClientDirectory.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/ClientDirectory.py,v
retrieving revision 1.13
retrieving revision 1.14
diff -u -d -r1.13 -r1.14
--- ClientDirectory.py	9 Nov 2003 23:28:10 -0000	1.13
+++ ClientDirectory.py	10 Nov 2003 04:12:20 -0000	1.14
@@ -21,7 +21,7 @@
 import types
 import urllib2
 
-import mixminion.ClientMain #XXXX
+import mixminion.ClientMain #XXXX -- it would be better not to need this.
 import mixminion.Config
 import mixminion.Crypto
 import mixminion.NetUtils
@@ -377,8 +377,18 @@
             self.byNickname.setdefault(nn, []).append((info, where))
 
     def getFeatureMap(self, features, at=None, goodOnly=0):
-        """DOCDOC
-           Returns a dict from nickname to (va,vu) to feature to value."""
+        """Given a list of feature names (see Config.resolveFeatureName for
+           more on features, returns a dict mapping server nicknames to maps
+           from (valid-after,valid-until) tuples to maps from feature to
+           value.
+
+           That is: { nickname : { (time1,time2) : { feature : val } } }
+
+           If 'at' is provided, use only server descriptors that are valid at
+           the time 'at'.
+
+           If 'goodOnly' is true, use only recommended servers.
+        """
         result = {}
         if not self.fullServerList:
             return {}
@@ -449,6 +459,10 @@
         return "/".join(r)
 
     def getNameByRelay(self, routingType, routingInfo):
+        """Given a routingType, routingInfo (as string) tuple, return the
+           nickname of the corresponding server.  If no such server is
+           known, return a string representation of the routingInfo.
+        """
         routingInfo = mixminion.Packet.parseRelayInfoByType(
             routingType, routingInfo)
         nn = self.getNicknameByKeyID(routingInfo.keyinfo)
@@ -458,7 +472,10 @@
             return nn
 
     def getLiveServers(self, startAt=None, endAt=None):
-        """DOCDOC"""
+        """Return a list of all server desthat are live from startAt through
+           endAt.  The list is in the standard (ServerInfo,where) format,
+           as returned by __find.
+           """
         if startAt is None:
             startAt = time.time()
         if endAt is None:
@@ -467,7 +484,6 @@
 
     def clean(self, now=None):
         """Remove all expired or superseded descriptors from DIR/servers."""
-
         if now is None:
             now = time.time()
         cutoff = now - 600
@@ -553,7 +569,20 @@
     def generatePaths(self, nPaths, pathSpec, exitAddress, 
                       startAt=None, endAt=None,
                       prng=None):
-        """Return a list of pairs of lists of serverinfo DOCDOC."""
+        """Generate a list of paths for delivering packets to a given
+           exit address, using a given path spec.  Each path is returned
+           as a tuple of lists of ServerInfo.
+
+                nPaths -- the number of paths to generate.  (You need
+                   to generate multiple paths at once when you want them
+                   to converge at the same exit server -- for example,
+                   for delivering server-side fragmented messages.)
+                pathSpec -- A PathSpecifier object.
+                exitAddress -- An ExitAddress object.
+                startAt, endAt -- A duration of time over which the
+                   paths must remain valid.
+        """
+        assert pathSpec.isReply == exitAddress.isReply
 
         if prng is None:
             prng = mixminion.Crypto.getCommonPRNG()
@@ -593,7 +622,7 @@
             path = self.getPath(p, startAt=startAt, endAt=endAt)
             path1,path2 = path[:n1], path[n1:]
             paths.append( (path1,path2) )
-            if exitAddress.isReply or exitAddress.isSURB:
+            if pathSpec.isReply or pathSpec.isSURB:
                 LOG.info("Selected path is %s",
                          ",".join([s.getNickname() for s in path]))
             else:
@@ -616,8 +645,7 @@
            All servers are chosen to be valid continuously from
            startAt to endAt.
 
-           The path selection algorithm perfers to choose without
-           replacement it it can.
+           The path selection algorithm is described in path-spec.txxt
         """
         # Fill in startAt, endAt, prng if not provided
         if startAt is None:
@@ -660,15 +688,20 @@
             # ...and see if there are any relays left that aren't adjacent?
             candidates = []
             for c in relays:
+                # Avoid same-server hops
                 if ((prev and c.hasSameNicknameAs(prev)) or
-                    (next and c.hasSameNicknameAs(next)) or
-                    (prev and not prev.canRelayTo(c)) or
-                    ((not prev) and not c.canStartAt()) or
+                    (next and c.hasSameNicknameAs(next))):
+                    continue
+                # Avoid hops that can't relay to one another.
+                if ((prev and not prev.canRelayTo(c)) or
                     (next and not c.canRelayTo(next))):
                     continue
+                # Avoid first hops that we can't deliver to.
+                if (not prev) and not c.canStartAt():
+                    continue
                 candidates.append(c)                    
             if candidates:
-                # Good.  There are.
+                # Good.  There aresome okay servers/
                 servers[i] = prng.pick(candidates)
             else:
                 # Nope.  Can we duplicate a relay?
@@ -694,8 +727,13 @@
 
     def validatePath(self, pathSpec, exitAddress, startAt=None, endAt=None,
                      warnUnrecommended=1):
-        """DOCDOC 
-           takes pathspec; raises UIError or does nothing."""
+        """Given a PathSpecifier and an ExitAddress, check whether any
+           valid paths can satisfy the spec for delivery to the address.
+           Raise UIError if no such path exists; else returns.
+
+           If warnUnrecommended is true, give a warning if the user has
+           requested any unrecommended servers.
+           """
         if startAt is None: startAt = time.time()
         if endAt is None: endAt = startAt+self.DEFAULT_REQUIRED_LIFETIME
 
@@ -784,10 +822,19 @@
                      "on the recommended list.")
 
 #----------------------------------------------------------------------
-def compressServerList(featureMap, ignoreGaps=0, terse=0):
-    """DOCDOC
-        featureMap is nickname -> va,vu -> feature -> value .
-        result is  same format, but time is compressed.
+def compressFeatureMap(featureMap, ignoreGaps=0, terse=0):
+    """Given a feature map as returned by ClientDirectory.getFeatureMap,
+       compress the data from each server's server descriptors.  The
+       default behavior is:  if a server has two server descriptors such
+       that one becomes valid immediately after the other becomes invalid,
+       and they have the same features, compress the two entries into one.
+
+       If ignoreGaps is true, the requirement for sequential lifetimes is
+       omitted.
+
+       If terse is true, server descriptors are compressed even if their
+       features don't match.  If a feature has different values at different
+       times, they are concatenated with ' / '.
     """
     result = {}
     for nickname in featureMap.keys():
@@ -799,7 +846,7 @@
                 r.append((va,vu,features))
                 continue
             lastva, lastvu, lastfeatures = r[-1]
-            if (ignoreGaps or lastvu == va) and lastfeatures == features:
+            if (ignoreGaps or lastva <= va <= lastvu) and lastfeatures == features:
                 r[-1] = lastva, vu, features
             else:
                 r.append((va,vu,features))
@@ -823,21 +870,26 @@
     return result
 
 def formatFeatureMap(features, featureMap, showTime=0, cascade=0, sep=" "):
-    # No cascade:
-    # nickname:time1: value value value
-    # nickname:time2: value value value
+    """Given a list of features (by name; see Config.resolveFeatureName) and
+       a featureMap as returned by ClientDirectory.getFeatureMap or
+       compressFeatureMap, formats the map for display to an end users.
+       Returns a list of strings suitable for printing on separate lines.
 
-    # Cascade=1:
-    # nickname:
-    #     time1: value value value
-    #     time2: value value value
+       If 'showTime' is false, omit descriptor validity times from the
+       output.
 
-    # Cascade = 2:
-    # nickname:
-    #     time:
-    #       feature:value
-    #       feature:value
-    #       feature:value
+       'cascade' is an integer between 0 and 2.  Its values generate the
+       following output formats:
+           0 -- Put nickname, time, and feature values on one line.
+                If there are multiple times for a given nickname,
+                generate multiple lines.  This format is best for grep.
+           1 -- Put nickname on its own line; put time and feature lists
+                one per line.
+           2 -- Put nickname, time, and each feature value on its own line.
+
+       'sep' is used to concatenate feauture values when putting them on
+       the same line.
+       """
     nicknames = [ (nn.lower(), nn) for nn in featureMap.keys() ]
     nicknames.sort()
     lines = []
@@ -872,14 +924,40 @@
 
 #----------------------------------------------------------------------
 
+# What exit type names do we know about?
 KNOWN_STRING_EXIT_TYPES = [
     "mbox", "smtp", "drop"
 ]
 
 class ExitAddress:
-    #FFFF Perhaps this crams too much into ExitAddress.
+    """An ExitAddress represents the target of a Mixminion message or SURB.
+       It also encodes other properties off the message that must be known to
+       choose the exit hop (including fragmentation and message size).
+    """
+    ## Fields:
+    # exitType, exitAddress: None (for a reply message), or the delivery
+    #     routing type and routing info for the address.
+    # isReply: boolean: is target address a SURB or set of SURBs?
+    # lastHop: None, or the nickname of a server that must be used as the
+    #     last hop of the path.
+    # isSSFragmented: boolean: Is the message going to be fragmented and
+    #     reassembled at the exit server?
+    # nFragments: How many fragments are going to be assembled at the exit
+    #     server?
+    # exitSize: How large (in bytes) will the message be at the exit server?
+    # headers: A map from header name to value.
     def __init__(self,exitType=None,exitAddress=None,lastHop=None,isReply=0, 
-                 isSURB=0,isSSFragmented=0):
+                 isSSFragmented=0):
+        """Create a new ExitAddress.
+            exitType,exitAddress -- the routing type and routing info
+               for the delivery (if not a reply)
+            lastHop -- the nickname of the last hop in the path, if the
+               exit address is specific to a single hop.
+            isReply -- true iff this message is a reply   
+            isSSFragmented -- true iff this message is fragmented for
+               server-side reassembly.
+        """
+        #FFFF Perhaps this crams too much into ExitAddress.
         if isReply:
             assert exitType is None
             assert exitAddress is None
@@ -898,40 +976,53 @@
         self.exitAddress = exitAddress
         self.lastHop = lastHop
         self.isReply = isReply
-        self.isSURB = isSURB
         self.isSSFragmented = isSSFragmented #server-side frag reassembly only.
         self.nFragments = self.exitSize = 0
         self.headers = {}
     def getFragmentedMessagePrefix(self):
+        """Return the prefix to be prepended to server-side fragmented
+           messages"""
         routingType, routingInfo, _ = self.getRouting()
         return mixminion.Packet.ServerSideFragmentedMessage(
             routingType, routingInfo, "").pack()
         
     def setFragmented(self, isSSFragmented, nFragments):
+        """Set the fragmentation parameters of this exit address
+        """
         self.isSSFragmented = isSSFragmented
         self.nFragments = nFragments
     def hasPayload(self):
+        """Return true iff this exit type requires a payload"""
         return self.exitType not in ('drop', DROP_TYPE)
     def setExitSize(self, exitSize):
+        """Set the size of the message at the exit."""
         self.exitSize = exitSize
     def setHeaders(self, headers):
+        """Set the headers of the message at the exit."""
         self.headers = headers
     def getLastHop(self):
+        """Return the forced last hop of this exit address (or None)"""
         return self.lastHop
     def isSupportedByServer(self, desc):
+        """Return true iff the server described by 'desc' supports this
+           exit type."""
         try:
             self.checkSupportedByServer(desc,verbose=0)
             return 1
         except UIError:
             return 0
     def checkSupportedByServer(self, desc,verbose=1):
+        """Check whether the server described by 'desc' supports this
+           exit type. Returns if yes, raises a UIError if no.  If
+           'verbose' is true, give warnings for iffy cases."""
+        
         if self.isReply:
             return
         nickname = desc.getNickname()
 
         if self.headers:
-            #XXXX006 remove this eventually.
-            sware = desc['Server'].get("Software")
+            #XXXX007 remove this eventually.
+            sware = desc['Server'].get("Software","")
             if (sware.startswith("Mixminion 0.0.4") or 
                 sware.startswith("Mixminion 0.0.5alpha1")):
                 raise UIError("Server %s is running old software that doesn't support exit headers.", nickname)
@@ -973,17 +1064,22 @@
                      nickname, self.getPrettyExitType())
 
     def getPrettyExitType(self):
+        """Return a human-readable representation of the exit type."""
         if type(self.exitType) == types.IntType:
             return "0x%04X"%self.exitType
         else:
             return self.exitType
 
     def isServerRelative(self):
+        """Return true iff the exit type's addresses are specific to a
+           given exit hop."""
         return self.exitType in ('mbox', MBOX_TYPE)
             
     def getExitServers(self, directory, startAt=None, endAt=None):
-        """DOCDOC
-           Return a list of all exit servers that might work."""
+        """Given a ClientDirectory and a time range, return a list of
+           server descriptors for all servers that might work for this
+           exit address.
+           """
         assert self.lastHop is None
         liveServers = directory.getLiveServers(startAt, endAt)
         result = [ desc for desc in liveServers
@@ -991,7 +1087,8 @@
         return result
 
     def getRouting(self):
-        """DOCDOC"""
+        """Return a routingType, routingInfo, last-hop-nickname tuple for
+           this exit address."""
         ri = self.exitAddress
         if self.isSSFragmented:
             rt = FRAGMENT_TYPE
@@ -1018,7 +1115,6 @@
            OR drop
            OR 0x<routing type>:<routing info>
     """
-    # ???? Should this should get refactored into clientmodules, or someplace?
     if s.lower() == 'drop':
         return ExitAddress('drop',"")
     elif s.lower() == 'test':
@@ -1053,16 +1149,28 @@
         raise ParseError("Unrecognized address type: %s"%s)
 
 class PathElement:
+    """A PathElement is a single user-specified component of a path. This
+       is an abstract class; it's only used to describe the interface."""
     def validate(self, directory, start, end):
+        """Check whether this path element could be valid; if not, raise
+           UIError."""
         raise NotImplemented()
     def getFixedServer(self, directory, start, end):
+        """If this element describes a single fixed server, look up
+           and return the ServerInfo for that server."""
         raise NotImplemented()
     def getServerNames(self):
+        """Return a list containing either names of servers for this
+           path element, or None for randomly chosen servers.
+        """
         raise NotImplemented()
     def getMinLength(self):
+        """Return the fewest number of servers that this element might
+           contain."""
         raise NotImplemented()
 
 class ServerPathElement(PathElement):
+    """A path element for a single server specified by filename or nickname"""
     def __init__(self, nickname):
         self.nickname = nickname
     def validate(self, directory, start, end):
@@ -1076,8 +1184,11 @@
         return 1
     def __repr__(self):
         return "ServerPathElement(%r)"%self.nickname
+    def __str__(self):
+        return self.nickname
 
 class DescriptorPathElement(PathElement):
+    """A path element for a single server descriptor"""
     def __init__(self, desc):
         self.desc = desc
     def validate(self, directory, start, end):
@@ -1092,8 +1203,13 @@
         return 1
     def __repr__(self):
         return "DescriptorPathElement(%r)"%self.desc
+    def __str__(self):
+        return self.desc.getNickname()
 
 class RandomServersPathElement(PathElement):
+    """A path element for randomly chosen servers.  If 'n' is set, exactly
+       n servers are chosen.  If 'approx' is set, approximately 'approx'
+       servers are chosen."""
     def __init__(self, n=None, approx=None):
         assert not (n and approx)
         assert n is None or approx is None
@@ -1111,6 +1227,7 @@
             n = int(prng.getNormal(self.approx,1.5)+0.5)
         return [ None ] * n
     def getMinLength(self):
+        #XXXX006 need getAvgLength too, probably.  Ugh.
         if self.n is not None: 
             return self.n
         else:
@@ -1121,9 +1238,29 @@
             return "RandomServersPathElement(n=%r)"%self.n
         else:
             return "RandomServersPathElement(approx=%r)"%self.approx
+    def __str__(self):
+        if self.n == 1:
+            return "?"
+        elif self.n > 1:
+            return "*%d"%self.n
+        else:
+            assert self.approx
+            return "~%d"%self.approx
 
 #----------------------------------------------------------------------
 class PathSpecifier:
+    """A PathSpecifer represents a user-provided description of a path.
+       It's generated by parsePath.
+    """
+    ## Fields:
+    # path1, path2: Two lists containing PathElements for the two
+    #     legs of the path.
+    # isReply: boolean: Is this a path for a reply? (If so, path2
+    #     should be empty.)
+    # isSURB: boolean: Is this a path for a SURB? (If so, path1
+    #     should be empty.)
+    # lateSplit: boolean: Does the path have an explicit swap point,
+    #     or do we split it in two _after_ generating it?
     def __init__(self, path1, path2, isReply, isSURB, lateSplit):
         if isSURB:
             assert path2 and not path1
@@ -1140,33 +1277,36 @@
         self.lateSplit=lateSplit
 
     def getFixedLastServer(self,directory,startAt,endAt):
-        """DOCDOC"""
+        """If there is a fixed exit server on the path, return a descriptor
+           for it; else return None."""
         if self.path2:
             return self.path2[-1].getFixedServer(directory,startAt,endAt)
         else:
             return None
 
+    def __str__(self):
+        p1s = map(str,self.path1)
+        p2s = map(str,self.path2)
+        if self.isSURB or self.isReply or self.lateSplit:
+            return ",".join(p1s+p2s)
+        else:
+            return "%s:%s"%(",".join(p1s), ",".join(p2s))
+
 #----------------------------------------------------------------------
+WARN_STAR = 1 #XXXX007 remove
+
 def parsePath(config, path, nHops=None, isReply=0, isSURB=0,
               defaultNHops=None):
-    """DOCDOC ; Returns a pathSpecifier.  This documentation is no longer
-       even vaguely accurate.
-
-       Resolve a path as specified on the command line.  Returns a
-       (path-leg-1, path-leg-2) tuple, where each leg is a list of ServerInfo.
+    """Resolve a path as specified on the command line.  Returns a
+       PathSpecifier object.
 
-       directory -- the ClientDirectory to use.
        config -- unused for now.
        path -- the path, in a format described below.  If the path is
-          None, all servers are chosen as if the path were '*'.
-       address -- the address to deliver the message to; if it specifies
-          an exit node, the exit node is appended to the second leg of the
-          path and does not count against the number of hops.  If 'address'
-          is None, the exit node must support relay.
+          None, all servers are chosen as if the path were '*<nHops>'.
        nHops -- the number of hops to use.  Defaults to defaultNHops.
        startAt/endAt -- A time range during which all servers must be valid.
-       halfPath -- If true, we generate only the second leg of the path
-          and leave the first leg empty.
+       isSURB -- Boolean: is this a path for a reply block?
+       isReply -- Boolean: is this a path for a reply?
        defaultNHops -- The default path length to use when we encounter a
           wildcard in the path.  Defaults to 6.
 
@@ -1186,7 +1326,7 @@
        that number of randomly chosen servers:
              'foo,bar,*2,quux'.
        You can use a star without a number to specify a fill point
-       where randomly-selected servers will be added:
+       where randomly-selected servers will be added:  {DEPRECATED}
              'foo,bar,*,quux'.
        Finally, you can use a tilde followed by a number to specify an
        approximate number of servers to add.  (The actual number will be
@@ -1201,7 +1341,7 @@
     """
     halfPath = isReply or isSURB
     if not path:
-        path = '*'
+        path = "*%d"%(nHops or defaultNHops or 6)
     # Break path into a list of entries of the form:
     #        Nickname
     #     or "<swap>"
@@ -1266,6 +1406,10 @@
         extraHops = max(myNHops-approxHops, 0)
         pathEntries[starPos:starPos+1] =[RandomServersPathElement(n=extraHops)]
 
+        if WARN_STAR:
+            LOG.warn("'*' without a number is deprecated.  Try '*%d' instead.",
+                     extraHops)
+
     # Figure out how long the first leg should be.
     lateSplit = 0
     if "<swap>" in pathEntries:
@@ -1287,6 +1431,9 @@
 
     # Split the path into 2 legs.
     path1, path2 = pathEntries[:firstLegLen], pathEntries[firstLegLen:]
+
+    # XXXX006 when checking lengths, if the specifier is something like ~5,
+    # XXXX006 we should convert it to something more like *2,~3.
     if not lateSplit and not halfPath:
         if len(path1)+len(path2) < 2:
             raise UIError("The path must have at least 2 hops")

Index: ClientMain.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/ClientMain.py,v
retrieving revision 1.128
retrieving revision 1.129
diff -u -d -r1.128 -r1.129
--- ClientMain.py	7 Nov 2003 10:43:18 -0000	1.128
+++ ClientMain.py	10 Nov 2003 04:12:20 -0000	1.129
@@ -153,12 +153,8 @@
 #UserDir: ~/.mixminion
 
 [Security]
-## Default length of forward message paths.
-#PathLength: 4
 ## Address to use by default when generating reply blocks
 #SURBAddress: <your address here>
-## Default length of paths for reply blocks
-#SURBPathLength: 3
 ## Deault reply block lifetime
 #SURBLifetime: 7 days
 
@@ -771,6 +767,12 @@
             elif o in ('--no-queue',):
                 self.forceNoQueue = 1
 
+        if self.nHops and not self.path:
+            LOG.warn("-H/--hops is deprecated; use -P '*%d' instead",
+                     self.nHops)
+        elif self.nHops:
+            LOG.warn("-H/--hops is deprecated")
+
     def init(self):
         """Configure objects and initialize subsystems as specified by the
            command line."""
@@ -1272,7 +1274,7 @@
 
     # Collapse consecutive server descriptors with matching features.
     if showTime < 2:
-        featureMap = mixminion.ClientDirectory.compressServerList(
+        featureMap = mixminion.ClientDirectory.compressFeatureMap(
             featureMap, ignoreGaps=(not showTime), terse=(not showTime))
 
     # Now display the result.

Index: Config.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/Config.py,v
retrieving revision 1.64
retrieving revision 1.65
diff -u -d -r1.64 -r1.65
--- Config.py	7 Nov 2003 10:43:18 -0000	1.64
+++ Config.py	10 Nov 2003 04:12:20 -0000	1.65
@@ -66,6 +66,7 @@
 
 import mixminion.Common
 import mixminion.Crypto
+import mixminion.NetUtils
 
 from mixminion.Common import MixError, LOG, ceilDiv, englishSequence, \
      formatBase64, isPrintingAscii, stripSpace, stringContains, UIError
@@ -167,6 +168,8 @@
     return ilist
 
 def _unparseIntervalList(lst):
+    """Helper function: given an interval list, converts it back to the
+       expected format."""
     if lst == []:
         return ""
     r = [ (lst[0], 1) ]
@@ -225,75 +228,25 @@
             idx += 1
             size >>= 10
 
-# Regular expression to match a dotted quad.
-_ip_re = re.compile(r'^\d+\.\d+\.\d+\.\d+$')
-
 def _parseIP(ip):
     """Validation function.  Converts a config value to an IP address.
        Raises ConfigError on failure."""
-    i = ip.strip()
-
-    # inet_aton is a bit more permissive about spaces and incomplete
-    # IP's than we want to be.  Thus we use a regex to catch the cases
-    # it doesn't.
-    if not _ip_re.match(i):
-        raise ConfigError("Invalid IP %r" % i)
     try:
-        socket.inet_aton(i)
-    except socket.error:
-        raise ConfigError("Invalid IP %r" % i)
-
-    return i
-
-_IP6_CHARS="01233456789ABCDEFabcdef:."
+        return mixminion.NetUtils.normalizeIP4(ip)
+    except ValueError, e:
+        raise ConfigError(str(e))
 
 def _parseIP6(ip6):
-    """DOCDOC"""
-    ip = ip6.strip()
-    bad = ip6.translate(mixminion.Common._ALLCHARS, _IP6_CHARS)
-    if bad:
-        raise ConfigError("Invalid characters %r in address %r"%(bad,ip))
-    if len(ip) < 2:
-        raise ConfigError("IPv6 address %r is too short"%ip)
-        
-    items = ip.split(":")
-    if not items:
-        raise ConfigError("Empty IPv6 address")
-    if items[:2] == ["",""]:
-        del items[0]
-    if items[-2:] == ["",""]:
-        del items[-1]
-    foundNils = 0
-    foundWords = 0 # 16-bit words
-
-    for item in items:
-        if item == "":
-            foundNils += 1
-        elif '.' in item:
-            _parseIP(item)
-            if item is not items[-1]:
-                raise ConfigError("Embedded IPv4 address %r must appear at end of IPv6 address %r"%(item,ip))
-            foundWords += 2
-        else:
-            try:
-                val = string.atoi(item,16)
-            except ValueError:
-                raise ConfigError("IPv6 word %r did not parse"%item)
-            if not (0 <= val <= 0xFFFF):
-                raise ConfigError("IPv6 word %r out of range"%item)
-            foundWords += 1
-            
-    if foundNils > 1:
-        raise ConfigError("Too many ::'s in IPv6 address %r"%ip)
-    elif foundNils == 0 and foundWords < 8:
-        raise ConfigError("IPv6 address %r is too short"%ip)
-    elif foundWords > 8:
-        raise ConfigError("IPv6 address %r is too long"%ip)
-            
-    return ip
+    """Validation function.  Converts a config value to an IP address.
+       Raises ConfigError on failure."""
+    try:
+        return mixminion.NetUtils.normalizeIP6(ip6)
+    except ValueError, e:
+        raise ConfigError(str(e))
 
 def _parseHost(host):
-    """DOCDOC"""
+    """Validation function.  Checks a config value as a valid hostname.
+       Raises ConfigError on failure."""
     host = host.strip()
     if not mixminion.Common.isPlausibleHostname(host):
         raise ConfigError("%r doesn't look like a valid hostname",host)
@@ -553,6 +506,8 @@
     return sections
 
 def _readRestrictedConfigFile(contents):
+    """Same interface as _readConfigFile, but only supports the restrictd
+       file format as used by directories and descriptors."""
     # List of (heading, [(key, val, lineno), ...])
     sections = []
     # [(key, val, lineno)] for the current section.
@@ -617,10 +572,21 @@
     lines.append("") # so the last line ends with \n
     return "\n".join(lines)
 
-
 def resolveFeatureName(name, klass):
-    """DOCDOC"""
-    #XXXX006 this should be case insensitive.
+    """Given a feature name and a subclass of _ConfigFile, check whether
+       the feature exists, and return a sec/name tuple that, when passed to
+       _ConfigFile.getFeature, gives the value of the appropriate feature.
+       Raises a UIError if the feature name is invalid.
+
+       A feature is either: a special string handled by the class (like
+       'caps' for ServerInfo), a special string handled outside the class
+       (like 'status' for ClientDirectory), a Section:Entry string, or an
+       Entry string.  (If the Entry string is not unique within a section,
+       raises UIError.)  All features are case-insensitive.
+
+       Example features are: 'caps', 'status', 'Incoming/MMTP:Version',
+         'hostname'.
+       """
     syn = klass._syntax
     name = name.lower()
     if klass._features.has_key(name):
@@ -681,6 +647,8 @@
     #         unrecognized key, or do we simply generate a warning?
     #     _restrictSections is 1/0: do we raise a ConfigError when we see an
     #         unrecognized section, or do we simply generate a warning?
+    #     _features is a map from lowercase feature name to 1 for
+    #         features that should be handled by getFeature.
 
     ## Validation rules:
     # A key without a corresponding entry in _syntax gives an error.
@@ -719,7 +687,7 @@
         }
 
     _syntax = None
-    _features = {}
+    _features = {} 
     _restrictFormat = 0
     _restrictKeys = 1
     _restrictSections = 1
@@ -890,7 +858,8 @@
         return contents
 
     def getFeature(self,sec,name):
-        """DOCDOC"""
+        """Given a sec/name pair returned by resolveFeatureName, return a
+           string value of that feature for the class."""
         assert sec not in ("+","-")
         parseType = self._syntax[sec].get(name)[1]
         _, unparseFn = self.CODING_FNS.get(parseType, (None,str))
@@ -959,9 +928,9 @@
                        'SURBAddress' : ('ALLOW', None, None),
                        'SURBPathLength' : ('ALLOW', "int", "4"),
                        'SURBLifetime' : ('ALLOW', "interval", "7 days"),
-                       'ForwardPath' : ('ALLOW', None, "*"),
-                       'ReplyPath' : ('ALLOW', None, "*"),
-                       'SURBPath' : ('ALLOW', None, "*"),
+                       'ForwardPath' : ('ALLOW', None, "*6"),
+                       'ReplyPath' : ('ALLOW', None, "*4"),
+                       'SURBPath' : ('ALLOW', None, "*4"),
                        },
         'Network' : { 'ConnectionTimeout' : ('ALLOW', "interval", None) }
         }
@@ -1008,4 +977,4 @@
     # in configure_trng and configureShredCommand, respectively.
 
     # Host is checked in setupTrustedUIDs.
-
+    

Index: NetUtils.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/NetUtils.py,v
retrieving revision 1.2
retrieving revision 1.3
diff -u -d -r1.2 -r1.3
--- NetUtils.py	20 Oct 2003 18:19:19 -0000	1.2
+++ NetUtils.py	10 Nov 2003 04:12:20 -0000	1.3
@@ -8,14 +8,22 @@
 __all__ = [ ]
 
 import errno
+import re
 import select
 import signal
 import socket
+import string
 import time
-from mixminion.Common import LOG, TimeoutError
+from mixminion.Common import LOG, TimeoutError, _ALLCHARS
 
 #======================================================================
+# Global vars
+
+# When we get IPv4 and IPv6 addresses for the same host, which do we use?
 PREFER_INET4 = 1
+
+# Local copies of socket.AF_INET4 and socket.AF_INET6.  (AF_INET6 may be
+#  unsupported.)
 AF_INET = socket.AF_INET
 try:
     AF_INET6 = socket.AF_INET6
@@ -32,7 +40,6 @@
 #======================================================================
 if hasattr(socket, 'getaddrinfo'):
     def getIPs(name):
-        """DOCDOC"""
         r = []
         ai = socket.getaddrinfo(name,None)
         now = time.time()
@@ -45,8 +52,18 @@
     def getIPs(name):
         addr = socket.gethostbyname(name)
         return [ (AF_INET, addr, time.time()) ]
-        
+
+getIPs.__doc__ = \
+     """Resolve the hostname 'name' and return a list of answers.  Each
+        answer is a 3-tuple of the form: (Family, Address, Time), where
+        Family is AF_INET or AF_INET6, Address is an IPv4 or IPv6 address,
+        and Time is the time at which the answer was returned.  Raise
+        a subclass of socket.error if no answers are found."""
+
 def getIP(name, preferIP4=PREFER_INET4):
+    """Resolve the hostname 'name' and return the 'best' answer.  An
+       answer is either a 3-tuple as returned by getIPs, or a 3-tuple of
+       ('NOENT', reason, Time) if no answers were found."""
     try:
         r = getIPs(name)
         inet4 = [ addr for addr in r if addr[0] == AF_INET ]
@@ -54,12 +71,14 @@
         if not (inet4 or inet6):
             LOG.error("getIP returned no inet addresses!")
             return ("NOENT", "No inet addresses returned", time.time())
+        best4=best6=None
         if inet4: best4=inet4[0]
         if inet6: best6=inet6[0]
         if preferIP4:
             res = best4 or best6
         else:
             res = best6 or best4
+        assert res
         protoname = (res[0] == AF_INET) and "inet" or "inet6"
         LOG.trace("Result for getIP(%r): %s:%s (%d others dropped)",
                   name,protoname,res[1],len(r)-1)
@@ -75,7 +94,8 @@
 _SOCKETS_SUPPORT_TIMEOUT = hasattr(socket.SocketType, "settimeout")
 
 def connectWithTimeout(sock,dest,timeout=None):
-    """DOCDOC; sock must be blocking."""
+    """Same as sock.connect, but timeout after 'timeout' seconds.  This
+       functionality is built-in to Python2.3 and later."""
     if timeout is None:
         return sock.connect(dest)
     elif _SOCKETS_SUPPORT_TIMEOUT:
@@ -119,16 +139,22 @@
 _PREV_DEFAULT_TIMEOUT = None
 
 def setAlarmTimeout(timeout):
+    """Begin a timeout with signal.alarm"""
     if hasattr(signal, 'alarm'):
+        # Windows doesn't have signal.alarm.
         def sigalrmHandler(sig,_): pass
         signal.signal(signal.SIGALRM, sigalrmHandler)
         signal.alarm(timeout)
 
 def clearAlarmTimeout(timeout):
+    """End a timeout set with signal.alarm"""
     if hasattr(signal, 'alarm'):
         signal.alarm(0)
 
 def setGlobalTimeout(timeout,noalarm=0):
+    """Set the global connection timeout to 'timeout' -- either with
+       signal.alarm or socket.setdefaulttimeout, whiche ever we support.
+       (If noalarm is true, don't use signal.alarm.)"""
     global _PREV_DEFAULT_TIMEOUT
     assert timeout > 0
     if _SOCKETS_SUPPORT_TIMEOUT:
@@ -138,6 +164,7 @@
         setAlarmTimeout(timeout)
 
 def exceptionIsTimeout(ex):
+    """Return true iff ex is likely to be a timeout."""
     if isinstance(ex, socket.error):
         if ex[0] in IN_PROGRESS_ERRNOS:
             return 1
@@ -146,6 +173,7 @@
     return 0
 
 def unsetGlobalTimeout(noalarm=0):
+    """Clear the global timeout."""
     global _PREV_DEFAULT_TIMEOUT
     if _SOCKETS_SUPPORT_TIMEOUT:
         socket.setdefaulttimeout(_PREV_DEFAULT_TIMEOUT)
@@ -156,7 +184,8 @@
 _PROTOCOL_SUPPORT = None
 
 def getProtocolSupport():
-    """DOCDOC"""
+    """Return a 2-tuple of booleans: do we support IPv4, and do we
+      support IPv6?"""
     global _PROTOCOL_SUPPORT
     if _PROTOCOL_SUPPORT is not None:
         return _PROTOCOL_SUPPORT
@@ -176,5 +205,96 @@
         if s is not None:
             s.close()
             
-    _PROTOCOL_SUPPORT = res
+    _PROTOCOL_SUPPORT = tuple(res)
     return res
+
+#----------------------------------------------------------------------
+
+# Regular expression to match a dotted quad.
+_ip_re = re.compile(r'^\d+\.\d+\.\d+\.\d+$')
+
+def normalizeIP4(ip):
+    """If IP is an IPv4 address, return it in canonical form.  Raise
+       ValueError if it isn't."""
+    
+    i = ip.strip()
+
+    # inet_aton is a bit more permissive about spaces and incomplete
+    # IP's than we want to be.  Thus we use a regex to catch the cases
+    # it doesn't.
+    if not _ip_re.match(i):
+        raise ValueError("Invalid IP %r" % i)
+    try:
+        socket.inet_aton(i)
+    except socket.error:
+        raise ValueError("Invalid IP %r" % i)
+
+    return i
+
+_IP6_CHARS="01233456789ABCDEFabcdef:."
+
+def normalizeIP6(ip6):
+    """If IP is an IPv6 address, return it in canonical form.  Raise
+       ValueError if it isn't."""
+    ip = ip6.strip()
+    bad = ip6.translate(_ALLCHARS, _IP6_CHARS)
+    if bad:
+        raise ValueError("Invalid characters %r in address %r"%(bad,ip))
+    if len(ip) < 2:
+        raise ValueError("IPv6 address %r is too short"%ip)
+        
+    items = ip.split(":")
+    if not items:
+        raise ValueError("Empty IPv6 address")
+    if items[:2] == ["",""]:
+        del items[0]
+    if items[-2:] == ["",""]:
+        del items[-1]
+    foundNils = 0
+    foundWords = 0 # 16-bit words
+
+    for item in items:
+        if item == "":
+            foundNils += 1
+        elif '.' in item:
+            normalizeIP4(item)
+            if item is not items[-1]:
+                raise ValueError("Embedded IPv4 address %r must appear at end of IPv6 address %r"%(item,ip))
+            foundWords += 2
+        else:
+            try:
+                val = string.atoi(item,16)
+            except ValueError:
+                raise ValueError("IPv6 word %r did not parse"%item)
+            if not (0 <= val <= 0xFFFF):
+                raise ValueError("IPv6 word %r out of range"%item)
+            foundWords += 1
+            
+    if foundNils > 1:
+        raise ValueError("Too many ::'s in IPv6 address %r"%ip)
+    elif foundNils == 0 and foundWords < 8:
+        raise ValueError("IPv6 address %r is too short"%ip)
+    elif foundWords > 8:
+        raise ValueError("IPv6 address %r is too long"%ip)
+            
+    return ip
+
+def nameIsStaticIP(name):
+    """If 'name' is a static IPv4 or IPv6 address, return a 3-tuple as getIP
+       would return.  Else return None."""
+    name = name.strip()
+    if ':' in name:
+        try:
+            val = normalizeIP6(name)
+            return (AF_INET6, val, time.time())
+        except ValueError, e:
+            return None
+    elif name and name[0].isdigit():
+        try:
+            val = normalizeIP4(name)
+            return (AF_INET, val, time.time())
+        except ValueError, e:
+            return None
+    else:
+        return None
+            

Index: Packet.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/Packet.py,v
retrieving revision 1.63
retrieving revision 1.64
diff -u -d -r1.63 -r1.64
--- Packet.py	19 Oct 2003 03:12:02 -0000	1.63
+++ Packet.py	10 Nov 2003 04:12:20 -0000	1.64
@@ -12,18 +12,20 @@
 __all__ = [ 'compressData', 'CompressedDataTooLong', 'DROP_TYPE',
             'ENC_FWD_OVERHEAD', 'ENC_SUBHEADER_LEN',
             'encodeMailHeaders', 'encodeMessageHeaders',
-            'FRAGMENT_PAYLOAD_OVERHEAD', 'FWD_IPV4_TYPE', 'FragmentPayload',
+            'FRAGMENT_PAYLOAD_OVERHEAD', 'FWD_HOST_TYPE', 'FWD_IPV4_TYPE',
+            'FragmentPayload',
             'FRAGMENT_MESSAGEID_LEN', 'FRAGMENT_TYPE', 
             'HEADER_LEN', 'IPV4Info', 'MAJOR_NO', 'MBOXInfo',
             'MBOX_TYPE', 'MINOR_NO', 'MIN_EXIT_TYPE',
             'MIN_SUBHEADER_LEN', 'MMTPHostInfo', 'Packet',
             'OAEP_OVERHEAD', 'PAYLOAD_LEN', 'ParseError', 'ReplyBlock',
             'ReplyBlock', 'SECRET_LEN', 'SINGLETON_PAYLOAD_OVERHEAD',
-            'SMTPInfo', 'SMTP_TYPE', 'SWAP_FWD_IPV4_TYPE', 'SingletonPayload',
+            'SMTPInfo', 'SMTP_TYPE', 'SWAP_FWD_IPV4_TYPE',
+            'SWAP_FWD_HOST_TYPE', 'SingletonPayload',
             'Subheader', 'TAG_LEN', 'TextEncodedMessage',
             'parseHeader', 'parseIPV4Info', 'parseMMTPHostInfo',
             'parseMBOXInfo', 'parsePacket', 'parseMessageAndHeaders',
-            'parsePayload', 'parseReplyBlock',
+            'parsePayload', 'parseRelayInfoByType', 'parseReplyBlock',
             'parseReplyBlocks', 'parseSMTPInfo', 'parseSubheader',
             'parseTextEncodedMessages', 'parseTextReplyBlocks', 
             'uncompressData'            
@@ -556,15 +558,15 @@
 # Routing info
 
 def parseRelayInfoByType(routingType,routingInfo):
-    """DOCDOC: Returns rt, (IPV4Info/MMTPHostInfo)."""
-    if routingType in (mixminion.Packet.FWD_IPV4_TYPE,
-                       mixminion.Packet.SWAP_FWD_IPV4_TYPE):
-        parseFn = mixminion.Packet.parseIPV4Info
-        parsedType = mixminion.Packet.IPV4Info
-    elif routingType in (mixminion.Packet.FWD_HOST_TYPE,
-                         mixminion.Packet.SWAP_FWD_HOST_TYPE):
-        parseFn = mixminion.Packet.parseMMTPHost
-        parsedType = mixminion.Packet.MMTPHostInfo
+    """Parse the routingInfo contained in the string 'routinginfo',
+       according to the type in 'routingType'.  Only relay types are
+       supported."""
+    if routingType in (FWD_IPV4_TYPE, SWAP_FWD_IPV4_TYPE):
+        parseFn = parseIPV4Info
+        parsedType = IPV4Info
+    elif routingType in (FWD_HOST_TYPE, SWAP_FWD_HOST_TYPE):
+        parseFn = parseMMTPHostInfo
+        parsedType = MMTPHostInfo
     else:
         raise MixFatalError("Unrecognized relay type 0x%04X"%routingType)
     if type(routingInfo) == types.StringType:
@@ -578,7 +580,7 @@
 
 def parseIPV4Info(s):
     """Converts routing info for an IPV4 address into an IPV4Info object,
-       suitable for use by FWD or SWAP_FWD modules."""
+       suitable for use by FWD_IPV4 or SWAP_FWD_IPV4 modules."""
     if len(s) != 4+2+DIGEST_LEN:
         raise ParseError("IPV4 information with wrong length (%d)" % len(s))
     try:
@@ -589,11 +591,13 @@
     return IPV4Info(ip, port, keyinfo)
 
 class IPV4Info:
-    """An IPV4Info object represents the routinginfo for a FWD or
-       SWAP_FWD hop.
+    """An IPV4Info object represents the routinginfo for a FWD_IPV4 or
+       SWAP_FWD_IPV4 hop.  This kind of routing is only used with older
+       servers that don't support hostname-based routing.
 
        Fields: ip (a dotted quad string), port (an int from 0..65535),
        and keyinfo (a digest)."""
+    #XXXX007/8 phase this out.
     def __init__(self, ip, port, keyinfo):
         """Construct a new IPV4Info"""
         assert 0 <= port <= 65535
@@ -630,7 +634,8 @@
 MMTP_HOST_PAT = "!H%ds" % DIGEST_LEN
 
 def parseMMTPHostInfo(s):
-    """DOCDOC"""
+    """Converts routing info for a hostname address into an MMTPHostInfo
+    object, suitable for use by FWD_HOST or SWAP_FWD_HOST modules."""
     if len(s) < 2+DIGEST_LEN+1:
         raise ParseError("Routing information is too short.")
     try:
@@ -643,7 +648,11 @@
     return MMTPHostInfo(s[2+DIGEST_LEN:], port, keyinfo)
 
 class MMTPHostInfo:
-    """DOCDOC"""
+    """An MMTPHostInfo object represents the routinginfo for a FWD_HOST or
+       SWAP_FWD_HOST hop.
+
+       Fields: hostname, port (an int from 0..65535), and keyinfo (a
+       digest)."""
     def __init__(self, hostname, port, keyinfo):
         assert 0 <= port <= 65535
         self.hostname = hostname.lower()

Index: ServerInfo.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/ServerInfo.py,v
retrieving revision 1.60
retrieving revision 1.61
diff -u -d -r1.60 -r1.61
--- ServerInfo.py	7 Nov 2003 07:03:28 -0000	1.60
+++ ServerInfo.py	10 Nov 2003 04:12:20 -0000	1.61
@@ -250,11 +250,17 @@
         return self['Server']['Digest']
 
     def getIP(self):
-        """Returns this server's IP address"""
+        """Returns this server's IP address.  (Returns None for servers
+           running version 0.0.7 or later.)"""
         return self['Incoming/MMTP'].get('IP')
 
     def getHostname(self):
-        """DOCDOC"""
+        """Return this server's Hostname. (Returns None for servers running
+           version 0.0.5 or earlier.)"""
+        #XXXX006 remove this.  0.0.6alpha1 could crash when it got hostnames.
+        #XXXX006 Sadly, some people installed it anyway.
+        if self['Server'].get("Software","").startswith("0.0.6alpha1"):
+            return None
         return self['Incoming/MMTP'].get("Hostname")
 
     def getPort(self):
@@ -269,37 +275,55 @@
         """Returns a hash of this server's MMTP key"""
         return mixminion.Crypto.sha1(
             mixminion.Crypto.pk_encode_public_key(self['Server']['Identity']))
-        #return self['Incoming/MMTP']['Key-Digest']
 
     def getIPV4Info(self):
         """Returns a mixminion.Packet.IPV4Info object for routing messages
-           to this server."""
-        return IPV4Info(self.getIP(), self.getPort(), self.getKeyDigest())
+           to this server.  (Returns None for servers running version 0.0.5
+           or earlier.)"""
+        ip = self.getIP()
+        if ip is None: return None
+        return IPV4Info(ip, self.getPort(), self.getKeyDigest())
 
     def getMMTPHostInfo(self):
-        """DOCDOC"""
-        return MMTPHostInfo(get.getHostname(), self.getPort(), self.getKeyDigest())
+        """Returns a mixminion.Packet.MMTPHostInfo object for routing messages
+           to this server.  (Returns None for servers running version 0.0.7
+           or later.)"""
+        host = self.getHostname()
+        if host is None: return None
+        return MMTPHostInfo(host, self.getPort(), self.getKeyDigest())
     
     def getRoutingInfo(self):
-        return self.getIPV4Info()
+        """Return whichever of MMTPHostInfo or IPV4 info is best for
+           delivering to this server (assuming that the sending host
+           supports both."""
+        if self.getHostname():
+            return self.getMMTPHostInfo()
+        else:
+            return self.getIPV4Info()
 
     def getIdentity(self):
+        """Return this server's public identity key."""
         return self['Server']['Identity']
 
     def getIncomingMMTPProtocols(self):
+        """Return a list of the MMTP versions supported by this this server
+           for incoming packets."""
         inc = self['Incoming/MMTP']
         if not inc.get("Version"):
             return []
         return [ s.strip() for s in inc["Protocols"].split(",") ]
 
     def getOutgoingMMTPProtocols(self):
+        """Return a list of the MMTP versions supported by this this server
+           for outgoing packets."""
         inc = self['Outgoing/MMTP']
         if not inc.get("Version"):
             return []
         return [ s.strip() for s in inc["Protocols"].split(",") ]
 
     def canRelayTo(self, otherDesc):
-        """DOCDOC"""
+        """Return true iff this server can relay packets to the server
+           described by otherDesc."""
         if self.hasSameNicknameAs(otherDesc):
             return 1
         myOutProtocols = self.getOutgoingMMTPProtocols()
@@ -310,7 +334,8 @@
         return 0
 
     def canStartAt(self):
-        """DOCDOC"""
+        """Return true iff this server is one we (that is, this
+           version of Mixminion) can send packets to directly."""
         myInProtocols = self.getIncomingMMTPProtocols()
         for out in mixminion.MMTPClient.BlockingClientConnection.PROTOCOL_VERSIONS:
             if out in myInProtocols:
@@ -318,8 +343,10 @@
         return 0
 
     def getRoutingFor(self, otherDesc, swap=0):
-        """DOCDOC"""
-        #XXXX006 use this!
+        """Return a 2-tuple of (routingType, routingInfo) for relaying
+           a packet from this server to the server described by
+           otherDesc.  If swap is true, the relay is at a crossover
+           point."""
         assert self.canRelayTo(otherDesc)
         assert 0 <= swap <= 1
         if self.getHostname() and otherDesc.getHostname():
@@ -334,7 +361,8 @@
         return rt, ri
         
     def getCaps(self):
-        # FFFF refactor this once we have client addresses.
+        """Return a list of strings to describe this servers abilities in
+           a concise human-readable format."""
         caps = []
         if not self['Incoming/MMTP'].get('Version'):
             return caps
@@ -350,11 +378,12 @@
         return caps
 
     def isSameDescriptorAs(self, other):
-        """DOCDOC"""
+        """Return true iff this is the same server descriptor as other."""
         return self.getDigest() == other.getDigest()
 
     def hasSameNicknameAs(self, other):
-        """DOCDOC"""
+        """Return true iff this server descriptor has the same nickname as
+           other."""
         return self.getNickname().lower() == other.getNickname().lower()
 
     def isValidated(self):
@@ -419,7 +448,7 @@
         return valid.isEmpty()
 
     def getFeature(self,sec,name):
-        """DOCDOC"""
+        """Overrides getFeature from _ConfigFile."""
         if sec == '-':
             if name in ("caps", "capabilities"):
                 return " ".join(self.getCaps())

Index: __init__.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/__init__.py,v
retrieving revision 1.48
retrieving revision 1.49
diff -u -d -r1.48 -r1.49
--- __init__.py	5 Sep 2003 21:59:49 -0000	1.48
+++ __init__.py	10 Nov 2003 04:12:20 -0000	1.49
@@ -7,7 +7,7 @@
    """
 
 # This version string is generated from setup.py; don't edit it.
-__version__ = "0.0.6alpha1"
+__version__ = "0.0.6alpha2"
 # This 5-tuple encodes the version number for comparison.  Don't edit it.
 # The first 3 numbers are the version number; the 4th is:
 #          0 for alpha
@@ -18,7 +18,7 @@
 # The 4th or 5th number may be a string.  If so, it is not meant to
 #   succeed or precede any other sub-version with the same a.b.c version
 #   number.
-version_info = (0, 0, 6, 0, 1)
+version_info = (0, 0, 6, 0, 2)
 __all__ = [ 'server', 'directory' ]
 
 def version_tuple_to_string(t):

Index: test.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/test.py,v
retrieving revision 1.163
retrieving revision 1.164
diff -u -d -r1.163 -r1.164
--- test.py	8 Nov 2003 05:57:38 -0000	1.163
+++ test.py	10 Nov 2003 04:12:20 -0000	1.164
@@ -116,10 +116,10 @@
             return i
     return last
 
-def floatEq(f1,f2):
+def floatEq(f1,f2,tolerance=.00001):
     """Return true iff f1 is very close to f2."""
     if min(f1, f2) != 0:
-        return abs(f1-f2)/min(f1,f2) < .00001
+        return abs(f1-f2)/min(f1,f2) < tolerance
     else:
         return abs(f1-f2) < .00001
 
@@ -129,6 +129,22 @@
     return "file:%s"%fname
 
 #----------------------------------------------------------------------
+# DNS override
+def overrideDNS(overrideDict,delay=0):
+    """DOCDOC"""
+    def getIPs_replacement(addr,overrideDict=overrideDict,delay=delay):
+        v = overrideDict.get(addr)
+        if delay: time.sleep(delay)
+        if v is None:
+            raise socket.error, "not there"
+        elif '.' in v:
+            return [ (mixminion.NetUtils.AF_INET, v, time.time()) ]
+        else:
+            return [ (mixminion.NetUtils.AF_INET6, v, time.time()) ]
+
+    replaceAttribute(mixminion.NetUtils, "getIPs", getIPs_replacement)
+
+#----------------------------------------------------------------------
 # RSA key caching functionality
 
 # Map from (n, bits) to a RSA key with bits bits.  Used to cache RSA keys
@@ -176,9 +192,9 @@
        'assertFoo' functions."""
     def __init__(self, *args, **kwargs):
         unittest.TestCase.__init__(self, *args, **kwargs)
-    def assertFloatEq(self, f1, f2):
+    def assertFloatEq(self, f1, f2, tolerance=.00001):
         """Fail unless f1 and f2 are very close to one another."""
-        if not floatEq(f1, f2):
+        if not floatEq(f1, f2, tolerance):
             self.fail("%s != %s" % (f1, f2))
     def assertLongStringEq(self, s1, s2):
         """Fail unless the string s1 equals the string s2.  If they aren't 
@@ -1339,6 +1355,9 @@
         self.assertEquals(IPV4Info("18.244.0.188", 48099, ri[-20:]).pack(),
                           ri)
 
+        self.assertEquals(inf, parseRelayInfoByType(FWD_IPV4_TYPE,inf.pack()))
+        self.assertEquals(inf, parseRelayInfoByType(SWAP_FWD_IPV4_TYPE,inf.pack()))
+
         self.failUnlessRaises(ParseError, parseIPV4Info, ri[:-1])
         self.failUnlessRaises(ParseError, parseIPV4Info, ri+"x")
 
@@ -1353,6 +1372,9 @@
         self.assertEquals(MMTPHostInfo("the.hostname.is.here", 0x3055,
                                        keyid).pack(), ri)
 
+        self.assertEquals(inf, parseRelayInfoByType(FWD_HOST_TYPE,inf.pack()))
+        self.assertEquals(inf, parseRelayInfoByType(SWAP_FWD_HOST_TYPE,inf.pack()))
+
         self.failUnlessRaises(ParseError, parseMMTPHostInfo, "z")
         self.failUnlessRaises(ParseError, parseMMTPHostInfo, "\x30\x55"+keyid)
         self.failUnlessRaises(ParseError, parseMMTPHostInfo,
@@ -1662,6 +1684,78 @@
         h[0].close()
 
 #----------------------------------------------------------------------
+class NetUtilTests(TestCase):
+    def testGetIP(self):
+        overridedict = {}
+        if hasattr(socket, 'getaddrinfo'):
+            def override_getaddrinfo(name,port,overridedict=overridedict):
+                v = overridedict.get(name)
+                if v:
+                    r = []
+                    for addr in v:
+                        if '.' in addr:
+                            r.append( (mixminion.NetUtils.AF_INET, -1, -1, name, (addr,port) ) )
+                        else:
+                            r.append( (mixminion.NetUtils.AF_INET6, -1, -1, name, (addr,port)) )
+                    return r
+                else:
+                    raise socket.error, "foo"
+            replaceAttribute(socket, "getaddrinfo", override_getaddrinfo)
+        else:
+            def override_gethostbyname(name,overridedict=overridedict):
+                v = overridedict.get(name)
+                if v:
+                    return v[0]
+                else:
+                    raise socket.error, "foo"
+            replaceAttribute(socket, "gethostbyname", override_gethostbyname)
+
+        overridedict['revolving.restaurant'] = [ '30.1.0.50', "00FE::3" ]
+        now = time.time()
+        try:
+            r = mixminion.NetUtils.getIPs('revolving.restaurant')
+
+            self.assertEquals((socket.AF_INET, "30.1.0.50"), r[0][:2])
+            self.assertFloatEq(now, r[0][2])
+
+            if hasattr(socket, 'getaddrinfo'):
+                self.assertEquals(2, len(r))
+                self.assertEquals((mixminion.NetUtils.AF_INET6, "00FE::3"), r[1][:2])
+                self.assertFloatEq(now, r[1][2])
+                self.assertEquals((socket.AF_INET, "30.1.0.50"),
+                       mixminion.NetUtils.getIP("revolving.restaurant",1)[:2])
+                self.assertEquals((mixminion.NetUtils.AF_INET6, "00FE::3"),
+                       mixminion.NetUtils.getIP("revolving.restaurant",0)[:2])
+            else:
+                self.assertEquals(1, len(r))
+                self.assertEquals((socket.AF_INET, "30.1.0.50"),
+                       mixminion.NetUtils.getIP("revolving.restaurant",0)[:2])
+                self.assertEquals((socket.AF_INET, "30.1.0.50"),
+                       mixminion.NetUtils.getIP("revolving.restaurant",1)[:2])
+
+            self.assertRaises(socket.error, mixminion.NetUtils.getIPs,
+                              "nowhere.nowhen.nohow")
+            self.assertEquals(("NOENT", "foo"),
+                      mixminion.NetUtils.getIP("nowhere.nowhen.nohow")[:2])
+        finally:
+            undoReplacedAttributes()
+
+    def testGetProtocolSupport(self):
+        ps = mixminion.NetUtils.getProtocolSupport()
+        self.assertEquals(len(ps),2)
+        self.assertEquals(ps[0], 1) # IPv4 support is required.
+        self.assert_(ps[1] in (0,1))
+
+    def testNameIsStaticIP(self):
+        nisi = mixminion.NetUtils.nameIsStaticIP
+        from mixminion.NetUtils import AF_INET, AF_INET6
+        self.assertEquals(nisi("foo"), None)
+        self.assertEquals(nisi("18.244.0.0")[:2], (AF_INET, "18.244.0.0"))
+        self.assertEquals(nisi("::F00f")[:2], (AF_INET6, "::F00f"))
+        self.assertEquals(nisi("4starts-with-digit.tld"), None)
+        self.assertEquals(nisi("bogus-with-a-colon:wow"), None)
+
+#----------------------------------------------------------------------
 
 # Dummy PRNG class that just returns 0-valued bytes.  We use this to make
 # generated padding predictable in our BuildMessage tests below.
@@ -1689,6 +1783,10 @@
         return IPV4Info(self.addr, self.port, self.keyid)
     def getIPV4Info(self):
         return self.getRoutingInfo()
+    def getRoutingFor(self,other,swap):
+        tp = [FWD_IPV4_TYPE,SWAP_FWD_IPV4_TYPE][swap]
+        return (tp, other.getRoutingInfo().pack())
+                
 
 class BuildMessageTests(TestCase):
     def setUp(self):
@@ -2709,11 +2807,12 @@
             # (We temporarily override the setting from 'BuildMessage',
             #  not Packet; BuildMessage has already imported a copy of this
             #  constant.)
-            save = mixminion.BuildMessage.SWAP_FWD_IPV4_TYPE
-            mixminion.BuildMessage.SWAP_FWD_IPV4_TYPE = 50
+            global SWAP_FWD_IPV4_TYPE# override the copy used by FakeServerInfo
+            save = SWAP_FWD_IPV4_TYPE
+            SWAP_FWD_IPV4_TYPE = 50
             m_x = bfm(zPayload, 500, "", [self.server1], [self.server2])
         finally:
-            mixminion.BuildMessage.SWAP_FWD_IPV4_TYPE = save
+            SWAP_FWD_IPV4_TYPE = save
         self.failUnlessRaises(ContentError, self.sp1.processMessage, m_x)
 
         # Subhead with bad length
@@ -2724,7 +2823,6 @@
         m_x = pk_encrypt("f"*(256-42), self.pk1)+m[256:]
         self.failUnlessRaises(ContentError, self.sp1.processMessage, m_x)
 
-
         # Bad IPV4 info
         subh_real = pk_decrypt(m[:256], self.pk1)
         subh = parseSubheader(subh_real)
@@ -4458,6 +4556,11 @@
         eq(info['Server']['Published'], loaded['Server']['Published'])
         eq(info.isValidated(), loaded.isValidated())
 
+        # Other functionality.
+        eq(info.getIPV4Info(), IPV4Info("192.168.0.1", 48099, info.getKeyDigest()))
+        eq(info.getMMTPHostInfo(), MMTPHostInfo("Theserver", 48099, info.getKeyDigest()))
+        eq(info.getMMTPHostInfo(), info.getRoutingInfo())
+        self.assert_(info.canStartAt())
 
         #XXXX006 this is a workaround to deal with the fact that we've
         #XXXX006 opened a fragment DB just to configure the server. Not
@@ -4563,11 +4666,21 @@
         eq(info3['Incoming/MMTP']['IP'], "192.168.100.3")
         self.assert_('smtp' in info3.getCaps())
 
+        # Check routing info
+        self.assert_(info.canRelayTo(info))
+        self.assert_(info.getRoutingFor(info), info.getRoutingInfo())
+        self.assert_(info.canRelayTo(info3))
+        self.assert_(not info3.canRelayTo(info)) # info3 has no outgoing/mmtp
+        self.assertEquals(info.getRoutingFor(info3)[1],
+                          info3.getRoutingInfo().pack())
+        #XXXX006 Test negative (IPv4) cases, somehow.
+
         key3.regenerateServerDescriptor(conf2, identity)
         info3 = key3.getServerDescriptor()
         eq(info3['Incoming/MMTP']['Hostname'], "Theserver4")
         eq(info3['Incoming/MMTP']['IP'], "192.168.100.4")
 
+
     def test_directory(self):
         eq = self.assertEquals
         examples = getExampleServerDescriptors()
@@ -5765,9 +5878,62 @@
             # Test getTLSContext
             keyring._getTLSContext()
 
+#----------------------------------------------------------------------
+class DNSFarmTests(TestCase):
+    def testDNSCache(self):
+        import mixminion.server.DNSFarm
+        cache = mixminion.server.DNSFarm.DNSCache()
+        receiveDict = {}
+        lock = threading.RLock()
+        def callback(name,val,receiveDict=receiveDict,lock=lock):
+            lock.acquire()
+            receiveDict[name]=val
+            lock.release()
+        try:
+            DELAY = 0.2
+            overrideDNS({'foo'    : '10.2.4.11',
+                         'bar'    : '18:0FFF::4:1',
+                         'baz.com': '10.99.22.8'},
+                        delay=DELAY)
+            self.assertEquals(None, cache.getNonblocking("foo"))
+            start = time.time()
+            cache.lookup('foo',callback)
+            cache.lookup('bar',callback)
+            self.assertEquals(cache.getNonblocking("bar"),
+                              mixminion.server.DNSFarm.PENDING)
+            time.sleep(DELAY/4)
+            cache.lookup('baz.com',callback)
+            cache.lookup('nowhere.noplace',callback)
+            cache.lookup('1.2.3.4', callback)
 
+            while len(receiveDict)<5:
+                time.sleep(DELAY/4)
+            self.assertEquals(receiveDict['foo'][:2],
+                              (socket.AF_INET, '10.2.4.11'))
+            self.assertEquals(receiveDict['bar'][:2],
+                              (mixminion.NetUtils.AF_INET6, '18:0FFF::4:1'))
+            self.assertFloatEq(receiveDict['foo'][2]-start, DELAY, .3)
+            self.assertFloatEq(receiveDict['bar'][2]-start, DELAY, .3)
+            self.assertEquals(receiveDict['nowhere.noplace'][0], "NOENT")
+            self.assertEquals(cache.getNonblocking("foo"),
+                              receiveDict['foo'])
+            self.assertEquals(cache.getNonblocking("baz.com")[:2],
+                              (socket.AF_INET, '10.99.22.8'))
+            self.assertFloatEq(receiveDict['baz.com'][2]-start, DELAY*1.25, .3)
+            cache.cleanCache(receiveDict['foo'][2]+
+                             mixminion.server.DNSFarm.MAX_ENTRY_TTL+.001)
+            self.assertEquals(cache.getNonblocking('foo'), None)
+            self.assertEquals(cache.getNonblocking('nowhere.noplace'),
+                              receiveDict['nowhere.noplace'])
 
+            self.assertEquals(receiveDict['1.2.3.4'][:2],
+                              (socket.AF_INET, '1.2.3.4'))
 
+            cache.shutdown(wait=1)
+            self.assertEquals(5, len(receiveDict))
+        finally:
+            undoReplacedAttributes()
+            
 #----------------------------------------------------------------------
 
 class ServerMainTests(TestCase):
@@ -5950,7 +6116,6 @@
 # variable to hold the latest instance of FakeBCC.
 BCC_INSTANCE = None
 
-
 class ClientUtilTests(TestCase):
     def testEncryptedFiles(self):
         CU = mixminion.ClientUtils
@@ -6351,6 +6516,9 @@
                 return paths[0]
             else:
                 return paths
+
+        #XXXX007 remove
+        mixminion.ClientDirectory.WARN_STAR = 0
         
         paddr = mixminion.ClientDirectory.parseAddress
         email = paddr("smtp:lloyd@dobler.com")
@@ -6766,6 +6934,7 @@
 
         replaceAttribute(mixminion.MMTPClient, "BlockingClientConnection",
                          FakeBCC)
+        overrideDNS({'alice' : "10.0.0.100"})
         try:
             client.sendForwardMessage(
                 directory,
@@ -6774,8 +6943,9 @@
                 "You only give me your information.",
                 time.time(), time.time()+300)
             bcc = BCC_INSTANCE
+
             # first hop is alice
-            self.assertEquals(bcc.addr, "10.0.0.9")
+            self.assertEquals(bcc.addr, "10.0.0.100")
             self.assertEquals(bcc.port, 48099)
             self.assertEquals(0, bcc.connected)
             self.assertEquals(1, len(bcc.packets))
@@ -7001,7 +7171,7 @@
     tc = loader.loadTestsFromTestCase
 
     if 0:
-        suite.addTest(tc(ClientDirectoryTests))
+        suite.addTest(tc(PacketTests))
         return suite
     testClasses = [MiscTests,
                    MinionlibCryptoTests,
@@ -7018,6 +7188,8 @@
                    FragmentTests,
                    QueueTests,
                    EventStatsTests,
+                   NetUtilTests,
+                   DNSFarmTests,
            
                    ModuleTests,
                    ClientDirectoryTests,