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