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

[Patch] Newsgroups support



Hi,

I've added newsgroups support to mixminion, patch included.

The client now supports "news:group1,group2,group3"; type
destinations, the server now has "news" capability and forwards
incoming newsgroup postings to a configurable mail2news gateway,
e.g. mail2news@xxxxxxxxx -- a template for the configuration is
included below the patch.
Crosspostings to more than three groups are dropped. Additional
headers are not yet allowed, but might be added quite easily.

BTW: why are there two NEWS_TYPE definitions, 102 and 104?

Feedback is welcome.

Especially I'd like somebody else to have a look into the
following, because I probably have missed something with the
(configuration parsing?) part:
* I had to add desc.has_section("Delivery/NEWS") before doing
get("Version"), and this is not needed for e.g. Delivery/MBOX.
* If this check is not included for Delivery/NEWS, there are
errors (see below) with the client, If the check is included,
both errors seem to not occur.

===== Beginn =====

Index: Common.py
===================================================================
--- Common.py   (revision 2)
+++ Common.py   (working copy)
@@ -1532,6 +1532,19 @@
     return m is None
 
 #----------------------------------------------------------------------
+
+# A single newsgroup is a sequence of dot-separated atoms.
+_SINGLE_NEWSGROUP_PAT = r"(?:%s)(?:\.(?:%s))*" % (_ATOM_PAT, _ATOM_PAT)
+# A list of newsgroups is a sequence of comma-separated newsgroups.
+_NEWSGROUPS_PAT = r"\A%s(,%s)*\Z" % (_SINGLE_NEWSGROUP_PAT, _SINGLE_NEWSGROUP_PAT)
+NEWSGROUPS_RE = re.compile(_NEWSGROUPS_PAT)
+
+def isNewsgroups(s):
+    """Return true iff s is a valid sequence of newsgroup names"""
+    n = NEWSGROUPS_RE.match(s)
+    return n is not None
+
+#----------------------------------------------------------------------
 # Signal handling
 
 def waitForChildren(onceOnly=0, blocking=1):
Index: ClientDirectory.py
===================================================================
--- ClientDirectory.py  (revision 2)
+++ ClientDirectory.py  (working copy)
@@ -33,8 +33,8 @@
      ceilDiv, createPrivateDir, formatDate, formatFnameTime, openUnique, \
      previousMidnight, readPickled, readPossiblyGzippedFile, \
      replaceFile, tryUnlink, writePickled, floorDiv, isSMTPMailbox
-from mixminion.Packet import MBOX_TYPE, SMTP_TYPE, DROP_TYPE, FRAGMENT_TYPE, \
-     parseMBOXInfo, parseRelayInfoByType, parseSMTPInfo, ParseError, \
+from mixminion.Packet import MBOX_TYPE, NEWS_TYPE, SMTP_TYPE, DROP_TYPE, FRAGMENT_TYPE, \
+     parseMBOXInfo, parseNEWSInfo, parseRelayInfoByType, parseSMTPInfo, ParseError, \
      ServerSideFragmentedMessage
 from mixminion.ThreadUtils import RWLock, DummyLock
 
@@ -1659,7 +1659,7 @@
 
 # What exit type names do we know about?
 KNOWN_STRING_EXIT_TYPES = [
-    "mbox", "smtp", "drop"
+    "mbox", "news", "smtp", "drop"
 ]
 
 # Map from (type, nickname) to 1 for servers we've already warned about.
@@ -1802,6 +1802,18 @@
             if exitKB > msec['Maximum-Size']:
                 raise UIError("Message to long for server %s to deliver."%
                               nickname)
+        elif self.exitType in ('news', NEWS_TYPE):
+            if not desc.has_section("Delivery/NEWS"):
+                raise UIError("Server %s doesn't support NEWS"%nickname)
+            nsec = desc['Delivery/NEWS']
+            if not nsec.get("Version"):
+                raise UIError("Server %s doesn't support NEWS"%nickname)
+            if needFrom and not nsec['Allow-From']:
+                raise UIError("Server %s doesn't support user-supplied From"%
+                              nickname)
+            if exitKB > nsec['Maximum-Size']:
+                raise UIError("Message to long for server %s to deliver."%
+                              nickname)
         elif self.exitType in ('drop', DROP_TYPE):
             # everybody supports 'drop'.
             pass
@@ -1852,6 +1864,8 @@
             rt = DROP_TYPE
         elif self.exitType == 'mbox':
             rt = MBOX_TYPE
+        elif self.exitType == 'news':
+            rt = NEWS_TYPE
         else:
             assert type(self.exitType) == types.IntType
             rt = self.exitType
@@ -1908,6 +1922,9 @@
             return ExitAddress('mbox', parseMBOXInfo(mbox).pack(), server)
         else:
             return ExitAddress('mbox', parseMBOXInfo(val).pack(), None)
+    elif tp == 'news':
+        # May raise ParseError
+        return ExitAddress('news', parseNEWSInfo(val).pack(), None)
     elif tp == 'smtp':
         # May raise ParseError
         return ExitAddress('smtp', parseSMTPInfo(val).pack(), None)
Index: Packet.py
===================================================================
--- Packet.py   (revision 2)
+++ Packet.py   (working copy)
@@ -24,7 +24,7 @@
             'SWAP_FWD_HOST_TYPE', 'SingletonPayload',
             'Subheader', 'TAG_LEN', 'TextEncodedMessage',
             'parseHeader', 'parseIPV4Info', 'parseMMTPHostInfo',
-            'parseMBOXInfo', 'parsePacket', 'parseMessageAndHeaders',
+            'parseMBOXInfo', 'parseNEWSInfo', 'parsePacket', 'parseMessageAndHeaders',
             'parsePayload', 'parseRelayInfoByType', 'parseReplyBlock',
             'parseReplyBlocks', 'parseSMTPInfo', 'parseSubheader',
             'parseTextEncodedMessages', 'parseTextReplyBlocks',
@@ -39,8 +39,8 @@
 import zlib
 from socket import inet_ntoa, inet_aton
 from mixminion.Common import MixError, MixFatalError, encodeBase64, \
-     floorDiv, formatBase64, formatTime, isSMTPMailbox, LOG, armorText, \
-     unarmorText, isPlausibleHostname
+     floorDiv, formatBase64, formatTime, isSMTPMailbox, isNewsgroups, \
+     LOG, armorText, unarmorText, isPlausibleHostname
 from mixminion.Crypto import sha1
 
 if sys.version_info[:3] < (2,2,0):
@@ -728,6 +728,23 @@
         """Return the external representation of this routing info."""
         return self.user
 
+def parseNEWSInfo(s):
+    """Convert the encoding of an News exitinfo into an NEWSInfo address"""
+    if not isNewsgroups(s):
+        raise ParseError("Invalid newsgroups %r" % s)
+    return NEWSInfo(s)
+
+class NEWSInfo:
+    """Represents the exitinfo for a NEWS hop.
+
+       Fields: user (a user identifier)"""
+    def __init__(self, newsgroups):
+        self.newsgroups = newsgroups
+
+    def pack(self):
+        """Return the external representation of this routing info."""
+        return self.newsgroups
+
 #----------------------------------------------------------------------
 # Ascii-encoded packets
 #
Index: ClientAPI.py
===================================================================
--- ClientAPI.py        (revision 2)
+++ ClientAPI.py        (working copy)
@@ -663,8 +663,8 @@
 class MsgDest(_Encodeable):
     """A MsgDest is the address for a mssage.  It may be either a SURBMsgDest,
        to indate a message that we will send with one or more SURBs, or an
-       AddrMsgDest, to indicate an email address, mbox address, or other
-       Type III exit address.
+       AddrMsgDest, to indicate an email address, mbox address, newsgroups,
+       or other Type III exit address.
     """
     def __init__(self):
         pass
@@ -678,6 +678,7 @@
        of the format:
               mbox:<mailboxname>@<server>
            OR smtp:<email address>
+           OR news:<group1>,<group2>,<group3> (up to three groups)
            OR <email address> (smtp is implicit)
            OR drop
            OR 0x<routing type>:<routing info>
Index: server/Modules.py
===================================================================
--- server/Modules.py   (revision 2)
+++ server/Modules.py   (working copy)
@@ -38,7 +38,7 @@
 import mixminion.server.PacketHandler
 from mixminion.Config import ConfigError
 from mixminion.Common import LOG, MixError, ceilDiv, createPrivateDir, \
-     encodeBase64, floorDiv, isPrintingAscii, isSMTPMailbox, previousMidnight,\
+     encodeBase64, floorDiv, isPrintingAscii, isSMTPMailbox, isNewsgroups, previousMidnight,\
      readFile, waitForChildren
 from mixminion.Packet import ParseError, CompressedDataTooLong, uncompressData
 
@@ -47,6 +47,9 @@
 DELIVER_FAIL_RETRY = 2
 DELIVER_FAIL_NORETRY = 3
 
+# Maximum number of newsgroups per message
+MAX_NEWSGROUPS = 3
+
 class DeliveryModule:
     """Abstract base for modules; delivery modules should implement
        the methods in this class.
@@ -318,6 +321,7 @@
         self.queues = {}
 
         self.registerModule(MBoxModule())
+        self.registerModule(NewsModule())
         self.registerModule(DropModule())
         self.registerModule(DirectSMTPModule())
         self.registerModule(MixmasterSMTPModule())
@@ -574,7 +578,7 @@
         # There are two concerns with making fragment config match up with
         # the other delivery modules: first, we need to make sure that if
         # we defragment, we have some way to reassemble fragmented messages.
-        deliverySecs = [ 'Delivery/MBOX', 'Delivery/SMTP',
+        deliverySecs = [ 'Delivery/MBOX', 'Delivery/NEWS', 'Delivery/SMTP',
                          'Delivery/SMTP-Via-Mixmaster' ]
         enabled = [ config.get(s,{}).get("Enabled") for s in deliverySecs ]
 
@@ -1000,6 +1004,15 @@
             fromAddr = self.returnAddress
 
         morelines = []
+        if mixminion.Packet.NEWS_TYPE in self.getExitTypes():
+            newsgroups = packet.getAddress()
+            if isNewsgroups(newsgroups):
+                 if newsgroups.count(',') > MAX_NEWSGROUPS-1:
+                      LOG.warn("Dropping excessive crossposting: %s" % newsgroups)
+                      return None
+                 morelines.append("Newsgroups: %s\n" % newsgroups)
+            else:
+                 LOG.warn("Invalid newsgroups: %s" % newsgroups)
         if headers.has_key("IN-REPLY-TO"):
             morelines.append("In-Reply-To: %s\n" % headers['IN-REPLY-TO'])
         if headers.has_key("REFERENCES"):
@@ -1042,6 +1055,122 @@
         self.header = "".join(header)
 
 #----------------------------------------------------------------------
+class NewsModule(DeliveryModule, MailBase):
+    """Implementation for newsgroup delivery: sends messages, via SMTP,
+       to a mail2news gateway, setting the newsgroups header.
+    """
+    ##
+    # Fields:
+    #   gateway: mail2news gateway
+    #   server: the name of our SMTP server
+    #   returnAddress: the address we use in our 'From' line
+    #   contact: the contact address we mention in our boilerplate
+    #   nickname: our server nickname; for use in our boilerplate
+    #   addr: our IP address, or "<Unknown IP>": for use in our boilerplate.
+    def __init__(self):
+        DeliveryModule.__init__(self)
+        self.maxMessageSize = None
+
+    def getRetrySchedule(self):
+        return self.retrySchedule
+
+    def getConfigSyntax(self):
+        # FFFF There should be some way to say that fields are required
+        # FFFF if the module is enabled.
+        cfg = { 'Enabled' : ('REQUIRE',  "boolean", "no"),
+                'Retry': ('ALLOW', "intervalList",
+                          "7 hours for 6 days"),
+                'Gateway' : ('ALLOW', None, None),
+                'RemoveContact' : ('ALLOW', None, None),
+                'SMTPServer' : ('ALLOW', None, None),
+                'SendmailCommand' : ('ALLOW', "command", None),
+                'Advertise' : ('ALLOW', "boolean", "yes")
+              }
+        cfg.update(MailBase.COMMON_OPTIONS)
+        return { "Delivery/NEWS" : cfg }
+
+    def validateConfig(self, config, lines, contents):
+        sec = config['Delivery/NEWS']
+        if not sec.get('Enabled'):
+            return
+        for field in ['Gateway', 'ReturnAddress', 'RemoveContact']:
+            if not sec.get(field):
+                raise ConfigError("Missing field %s in [Delivery/NEWS]"%field)
+        for field in ['ReturnAddress', 'RemoveContact']:
+            if not isSMTPMailbox(sec[field]):
+                LOG.warn("Value of %s (%s) doesn't look like an email address",
+                         field, sec[field])
+        if (sec['SMTPServer'] is not None and
+            sec['SendmailCommand'] is not None):
+            raise ConfigError("Cannot specify both SMTPServer and SendmailCommand")
+
+        config.validateRetrySchedule("Delivery/NEWS")
+
+    def configure(self, config, moduleManager):
+        if not config['Delivery/NEWS'].get("Enabled", 0):
+            moduleManager.disableModule(self)
+            return
+
+        sec = config['Delivery/NEWS']
+        self.advertise = sec.get('Advertise') #DOCDOC
+        self.cfgSection = sec.copy() #DOCDOC
+        self.gateway = sec['Gateway']
+        self.returnAddress = sec['ReturnAddress']
+        self.contact = sec['RemoveContact']
+        self.retrySchedule = sec['Retry']
+        self.allowFromAddr = sec['AllowFromAddress']
+        # validate should have caught these.
+        assert (self.returnAddress and self.contact)
+
+        self.nickname = config['Server']['Nickname']
+        if not self.nickname:
+            self.nickname = socket.gethostname()
+        self.addr = config['Incoming/MMTP'].get('IP', "<Unknown IP>")
+        self.maxMessageSize = _cleanMaxSize(sec['MaximumSize'],
+                                            "Delivery/NEWS")
+
+        # These fields are needed by MailBase
+        self.initializeHeaders(sec)
+        self.fromTag = "[Anon]"
+
+        # Enable module
+        moduleManager.enableModule(self)
+
+    def getServerInfoBlock(self):
+        if not self.advertise:
+            return ""
+
+        if self.allowFromAddr:
+            allowFrom = "yes"
+        else:
+            allowFrom = "no"
+        return """\
+                  [Delivery/NEWS]
+                  Version: 0.1
+                  Maximum-Size: %s
+                  Allow-From: %s
+               """ % (floorDiv(self.maxMessageSize,1024), allowFrom)
+
+    def getName(self):
+        return "NEWS"
+
+    def getExitTypes(self):
+        return [ mixminion.Packet.NEWS_TYPE ]
+
+    def processMessage(self, packet): #message, tag, exitType, address):
+        # Determine that message's address;
+        assert packet.getExitType() == mixminion.Packet.NEWS_TYPE
+        LOG.debug("Received NEWS message")
+
+        # Generate the boilerplate (FFFF Make this more configurable)
+        msg = self._formatEmailMessage(self.gateway, packet)
+        if not msg:
+            return DELIVER_FAIL_NORETRY
+
+        # Deliver the message
+        return sendSMTPMessage(self.cfgSection, [self.gateway], self.returnAddress, msg)
+
+#----------------------------------------------------------------------
 class MBoxModule(DeliveryModule, MailBase):
     """Implementation for MBOX delivery: sends messages, via SMTP, to
        addresses from a local file.  The file must have the format
Index: server/ServerMain.py
===================================================================
--- server/ServerMain.py        (revision 2)
+++ server/ServerMain.py        (working copy)
@@ -844,11 +844,12 @@
                 mixminion.server.Pinger.HEARTBEAT_INTERVAL))
             # FFFF if we aren't using a LOCKING_IS_COURSE database, we will
             # FFFF still want this to happen in another thread.
+            recomputeInterval = self.config['Pinging']['RecomputeInterval'].getSeconds();
             self.scheduleEvent(RecurringEvent(
-                now+60,
+                now+recomputeInterval,
                 lambda self=self: self.pingLog.calculateAll(
                   os.path.join(self.config.getWorkDir(), "pinger", "status")),
-                self.config['Pinging']['RecomputeInterval'].getSeconds()))
+                recomputeInterval))
 
         # Makes next update get scheduled.
         nextUpdate = self.updateDirectoryClient(reschedulePings=0)
Index: server/ServerKeys.py
===================================================================
--- server/ServerKeys.py        (revision 2)
+++ server/ServerKeys.py        (working copy)
@@ -926,7 +926,7 @@
     elif not config_im['Enabled'] and info_im.get('Version'):
         warn("Incoming MMTP published but not enabled.")
 
-    for section in ('Outgoing/MMTP', 'Delivery/MBOX', 'Delivery/SMTP'):
+    for section in ('Outgoing/MMTP', 'Delivery/MBOX', 'Delivery/NEWS', 'Delivery/SMTP'):
         info_out = info[section].get('Version')
         config_out = (config[section].get('Enabled') and
                       config[section].get('Advertise',1))
@@ -934,7 +934,7 @@
             config_out = (config['Delivery/SMTP-Via-Mixmaster'].get("Enabled")
                  and config['Delivery/SMTP-Via-Mixmaster'].get("Advertise", 1))
         if info_out and not config_out:
-            warn("%s published, but not enabled.", section)
+            warn("%s published, but not enabled/advertised.", section)
         if config_out and not info_out:
             warn("%s enabled, but not published.", section)
 
Index: ServerInfo.py
===================================================================
--- ServerInfo.py       (revision 2)
+++ ServerInfo.py       (working copy)
@@ -157,6 +157,11 @@
                      "Maximum-Size": ("REQUIRE", "int", "32"),
                      "Allow-From": ("REQUIRE", "boolean", "yes"),
                      },
+        "Delivery/NEWS" : {
+                     "Version": ("REQUIRE", None, None),
+                     "Maximum-Size": ("REQUIRE", "int", "32"),
+                     "Allow-From": ("REQUIRE", "boolean", "yes"),
+                     },
         "Delivery/SMTP" : {
                      "Version": ("REQUIRE", None, None),
                      "Maximum-Size": ("REQUIRE", "int", "32"),
@@ -182,6 +187,7 @@
          "Outgoing/MMTP" : ("Version", "0.1"),
          "Delivery/Fragmented" : ("Version", "0.1"),
          "Delivery/MBOX" : ("Version", "0.1"),
+         "Delivery/NEWS" : ("Version", "0.1"),
          "Delivery/SMTP" : ("Version", "0.1"),
          }
 
@@ -432,6 +438,9 @@
             return caps
         if self['Delivery/MBOX'].get('Version'):
             caps.append('mbox')
+        if self.has_section("Delivery/NEWS"):
+            if self['Delivery/NEWS'].get('Version'):
+                caps.append('news')
         if self['Delivery/SMTP'].get('Version'):
             caps.append('smtp')
         # XXXX This next check is highly bogus.
Index: ClientMain.py
===================================================================
--- ClientMain.py       (revision 2)
+++ ClientMain.py       (working copy)
@@ -29,7 +29,7 @@
      isSMTPMailbox, readFile, stringContains, succeedingMidnight, writeFile, \
      previousMidnight
 from mixminion.Packet import encodeMailHeaders, ParseError, parseMBOXInfo, \
-     parseReplyBlocks, parseSMTPInfo, parseTextEncodedMessages, \
+     parseReplyBlocks, parseSMTPInfo, parseNEWSInfo, parseTextEncodedMessages, \
      parseTextReplyBlocks, ReplyBlock, parseMessageAndHeaders, \
      CompressedDataTooLong
 
===== Ende =====

Configuration template:

===== Beginn =====

[Delivery/NEWS]
Enabled: yes
Gateway: mail2news@xxxxxxxxx
ReturnAddress: <"From:" address to use>
RemoveContact: <Address to use as a contact>
#SendmailCommand: sendmail -i -t
SMTPServer: localhost
Retry: every 7 hours for 6 days
MaximumSize: 100K
AllowFromAddress: yes

===== Ende =====

Error when leaving out desc.has_section("Delivery/NEWS")
from ClientDirectory.py:

===== Beginn =====

$ mixminion send -v -t news:group
Mixminion version 0.0.8alpha3
This software is for testing purposes only.  Anonymity is not guaranteed.
Nov 01 22:54:35 [INFO] Setting entropy source to '/dev/urandom'
Nov 01 22:54:35 [DEBUG] Configuring client
Nov 01 22:54:35 [DEBUG] Configuring server list
Nov 01 22:54:35 [DEBUG] Directory is up to date.
Nov 01 22:54:35 [WARN] This software is newer than any version on the recommended list.
Traceback (most recent call last):
  File "/usr/bin/mixminion", line 9, in ?
    mixminion.Main.main(sys.argv)
  File "/usr/lib/python2.4/site-packages/mixminion/Main.py", line 335, in main
    func(commandStr, args[2:])
  File "/usr/lib/python2.4/site-packages/mixminion/ClientMain.py", line 1249, in runClient
    parser.parsePath()
  File "/usr/lib/python2.4/site-packages/mixminion/ClientMain.py", line 1060, in parsePath
    self.startAt, self.endAt)
  File "/usr/lib/python2.4/site-packages/mixminion/ClientDirectory.py", line 1414, in validatePath
    warnUnrecommended)
  File "/usr/lib/python2.4/site-packages/mixminion/ClientDirectory.py", line 1466, in _validatePath
    if exitAddress.isSupportedByServer(desc):
  File "/usr/lib/python2.4/site-packages/mixminion/ClientDirectory.py", line 1746, in isSupportedByServer
    self.checkSupportedByServer(desc,verbose=0)
  File "/usr/lib/python2.4/site-packages/mixminion/ClientDirectory.py", line 1808, in checkSupportedByServer
    nsec = desc['Delivery/NEWS']
  File "/usr/lib/python2.4/site-packages/mixminion/Config.py", line 932, in __getitem__
    return self._sections[sec]
KeyError: 'Delivery/NEWS'

===== Ende =====

Error when leaving out desc.has_section("Delivery/NEWS")
from ServerInfo.py:

===== Beginn =====

$ mixminion list-server -v -F caps
Mixminion version 0.0.8alpha3
This software is for testing purposes only.  Anonymity is not guaranteed.
Nov 01 22:56:21 [INFO] Setting entropy source to '/dev/urandom'
Nov 01 22:56:21 [DEBUG] Configuring server list
Nov 01 22:56:21 [DEBUG] Directory is up to date.
Nov 01 22:56:21 [WARN] This software is newer than any version on the recommended list.
Traceback (most recent call last):
  File "/usr/bin/mixminion", line 9, in ?
    mixminion.Main.main(sys.argv)
  File "/usr/lib/python2.4/site-packages/mixminion/Main.py", line 335, in main
    func(commandStr, args[2:])
  File "/usr/lib/python2.4/site-packages/mixminion/ClientMain.py", line 1622, in listServers
    featureMap = directory.getFeatureMap(features,goodOnly=goodOnly)
  File "/usr/lib/python2.4/site-packages/mixminion/ClientDirectory.py", line 1059, in getFeatureMap
    d[feature] = str(sd.getFeature(sec,ent))
  File "/usr/lib/python2.4/site-packages/mixminion/ServerInfo.py", line 528, in getFeature
    return " ".join(self.getCaps())
  File "/usr/lib/python2.4/site-packages/mixminion/ServerInfo.py", line 442, in getCaps
    if self['Delivery/NEWS'].get('Version'):
  File "/usr/lib/python2.4/site-packages/mixminion/Config.py", line 932, in __getitem__
    return self._sections[sec]
KeyError: 'Delivery/NEWS'

===== Ende =====
Ciao

Tobias
-- 
mbox:admin@tainaron

Attachment: pgpLwZuQKEMQX.pgp
Description: PGP signature