summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorIsis Lovecruft <isis@torproject.org>2013-02-17 15:23:54 +0000
committerIsis Lovecruft <isis@torproject.org>2013-02-17 15:23:54 +0000
commit66d60a58ef752f9c9692dd8bdb0f61a5c1dfdb17 (patch)
tree016980342b8e2610f0010efddcab489a8d30db0a /src
parent364200c25e28d4996024dcf2f0644f5f20cde9ae (diff)
parent8d2fdc800de469420005f9c5deee4c342950ccc9 (diff)
Merge branch 'feature/check-recipient' into develop
Diffstat (limited to 'src')
-rw-r--r--src/leap/__init__.py12
-rw-r--r--src/leap/mx/__init__.py8
-rw-r--r--src/leap/mx/alias_resolver.py275
-rw-r--r--src/leap/mx/couchdb.py118
-rw-r--r--src/leap/mx/exceptions.py23
-rw-r--r--src/leap/mx/runner.py83
-rw-r--r--src/leap/mx/tests/__init__.py17
-rw-r--r--src/leap/mx/util/__init__.py12
-rw-r--r--src/leap/mx/util/config.py221
-rw-r--r--src/leap/mx/util/log.py143
-rw-r--r--src/leap/mx/util/net.py126
-rw-r--r--src/leap/mx/util/storage.py42
-rw-r--r--src/leap/mx/util/version.py95
13 files changed, 1175 insertions, 0 deletions
diff --git a/src/leap/__init__.py b/src/leap/__init__.py
new file mode 100644
index 0000000..8b30f3d
--- /dev/null
+++ b/src/leap/__init__.py
@@ -0,0 +1,12 @@
+# -*- encoding: utf-8 -*-
+"""
+leap/__init__.py
+----------------
+Module intialization file for leap.
+"""
+
+from leap.mx.util import version
+
+__all__ = ['mx']
+__author__ = version.getAuthors()
+__version__ = version.getVersion()
diff --git a/src/leap/mx/__init__.py b/src/leap/mx/__init__.py
new file mode 100644
index 0000000..27fadca
--- /dev/null
+++ b/src/leap/mx/__init__.py
@@ -0,0 +1,8 @@
+#-*- encoding: utf-8 -*-
+"""
+leap/mx/__init__.py
+-------------------
+Module intialization file for leap.mx .
+"""
+
+__all__ = ['alias_resolver', 'couchdb', 'exceptions', 'runner', 'util']
diff --git a/src/leap/mx/alias_resolver.py b/src/leap/mx/alias_resolver.py
new file mode 100644
index 0000000..e079b80
--- /dev/null
+++ b/src/leap/mx/alias_resolver.py
@@ -0,0 +1,275 @@
+#!/usr/bin/env python
+# -*- encoding: utf-8 -*-
+'''
+alias_resolver.py
+=================
+Classes for resolving postfix aliases.
+
+@authors: Isis Agora Lovecruft
+@version: 0.0.1-beta
+@license: see included LICENSE file
+@copyright: copyright 2013 Isis Agora Lovecruft
+
+TODO:
+
+ o Look into using twisted.protocols.postfix.policies classes for
+ controlling concurrent connections and throttling resource consumption.
+
+ o alias.ProcessAlias()
+
+## have uuid -> get gpg keyid
+
+alias.ProcessAlias('/path/to/mail_reciever', *args)
+
+'''
+
+import os
+import uuid
+
+try:
+ from twisted.internet import address, defer, reactor
+ from twisted.mail import maildir, alias
+ from twisted.protocols import postfix
+except ImportError:
+ print "This software requires Twisted. Please see the README file"
+ print "for instructions on getting required dependencies."
+
+from leap.mx.util import net, log, config, exceptions
+
+
+def createUUID(alias):
+ """
+ Creates Universal Unique ID by taking the SHA-1 HASH of an email alias:
+
+ >>> uuid.uuid5(uuid.NAMESPACE_URL, "isis@leap.se")
+ UUID('7194878e-4aea-563f-85a4-4f58519f3c4f')
+
+ @param alias: An email address alias.
+ @returns: A :class:`uuid.UUID` containing attributes specifying the UUID.
+ """
+ return uuid.uuid5(uuid.NAMESPACE_URL, str(alias))
+
+class StatusCodes(object):
+ """
+ The Postfix manual states:
+
+ The request completion status is one of OK, RETRY, NOKEY (lookup
+ failed because the key was not found), BAD (malformed request) or DENY
+ (the table is not approved for proxy read or update access).
+
+ Other SMTP codes: http://www.greenend.org.uk/rjk/tech/smtpreplies.html
+ """
+ OK = "OK Others might say 'HELLA AWESOME'...but we're not convinced."
+ RETRY = "RETRY Server is busy plotting revolution; requests might take a while."
+ BAD = "BAD bad Leroy Brown, baddest man in the whole...er. Malformed request."
+ NOKEY = "NOKEY Couldn't find your keys, sorry. Did you check in the sofa?"
+ DEFER = "DEFER_IF_LOCAL xxx fill me in"
+ DENY = "DENY no gurlz aloud in teh tree house."
+ FAIL = "FAIL xxx fill me in"
+
+ fakeSMTPCodes = { '250': OK,
+ '300': RETRY,
+ '500': BAD,
+ '550': NOKEY,
+ '552': DEFER,
+ '553': DENY,
+ '554': FAIL, }
+
+ def __init__(self, status_code=None):
+ """xxx fill me in"""
+ if status_code:
+ self.get(status_code)
+
+ def get(self, status_code=None)
+ """xxx fill me in"""
+ if status_code:
+ if isinstance(status_code, str):
+ return status_code, getattr(self, status_code.upper(), None)
+ elif isinstance(status_code, int):
+ for k, v in self.fake_smtp_codes.items():
+ ## we want to return None if it's 550
+ if k == str(status_code) and k != '550':
+ return status_code, v
+ log.debug("%s" % self.NOKEY)
+ return None, ''
+
+
+class AliasResolver(postfix.PostfixTCPMapServer):
+ """
+ Resolve postfix aliases, similarly to using "$ postmap -q <alias>".
+
+ This class starts a simple LineReceiver server which listens for a string
+ specifying an alias to look up, :param:`key`, and which will be used to
+ query the local Postfix server. You can test it with:
+
+ $ ./alias_resolver.py &
+ $ /usr/bin/postmap -q <key> tcp:localhost:1347
+
+ Resources:
+ http://www.postfix.org/proxymap.8.html
+ https://www.iana.org/assignments/smtp-enhanced-status-codes/
+ """
+ def __init__(self, *args, **kwargs):
+ """Create a server which listens for Postfix aliases to resolve."""
+ super(postfix.PostfixTCPMapServer, self).__init__(*args, **kwargs)
+ self.status_codes = StatusCodes()
+
+ def sendCode(self, code, message=None):
+ """Send an SMTP-like code with a message."""
+ if not message:
+ message = self.status_codes.get(code)
+ self.sendLine('%3.3d %s' % (code, message or ''))
+
+ def do_get(self, key):
+ """Make a query to resolve an alias."""
+ if key is None:
+ self.sendCode(500)
+ log.warn("Command 'get' takes one parameter.")
+ else:
+ d = defer.maybeDeferred(self.factory.get, key)
+ d.addCallbacks(self._cbGot, self._cbNot)
+ d.addErrback(log.err)
+
+ @defer.inlineCallbacks
+ def do_put(self, keyAndValue):
+ """Add a key and value to the database, provided it does not exist."""
+ if keyAndValue is None:
+ self.sendCode(500)
+ log.warn("Command 'put' takes two parameters.")
+ else:
+ try:
+ key, value = keyAndValue.split(None, 1)
+ except ValueError:
+ self.sendCode(500)
+ log.warn("Command 'put' takes two parameters.")
+ else:
+ alreadyThere = yield self.do_query(key)
+ if alreadyThere is None:
+ d = defer.maybeDeferred(self.factory.put, key, value)
+ d.addCallbacks(self._cbPut, self._cbPout)
+ d.addCallbacks(log.err)
+ else:
+ self.sendCode(553)
+
+ @defer.inlineCallbacks
+ def do_delete(self, key):
+ """
+ Delete an alias from the mapping database.
+
+ xxx not sure if this is a good idea...
+ """
+ raise NotImplemented
+
+ def check_recipient_access(self, key):
+ """Make a query to resolve an alias."""
+ self.do_get(self, key)
+
+ def virtual_alias_map(self, key):
+ """
+ Get the Universal Unique ID for the alias address. If
+ virtual_transport is True, then suffix the UUID with a domain.
+
+ xxx I don't think we actually need couchdb for this, the UUID is an
+ identifier, not an authenticator. And the SHA1 should always be the
+ same, so unless it's considered to expensive to compute (less than
+ querying a database, I would presume), it seems silly to do this.
+
+ Instead, we should query CouchDB with the UUID to get the GPG keyid.
+ """
+ ## xxx need email address parser
+ client_id = createUUID(key)
+
+ if self.virtual_transport:
+ return client.get_urn() + '@example.com'
+ else:
+ return client.get_urn()
+
+ def _cbGot(self, value):
+ """Callback for self.get()"""
+ if value is None:
+ self.sendCode(550)
+ else:
+ self.sendCode(250, quote(value))
+
+ def _cbNot(self, fail):
+ """Errback for self.get()"""
+ self.sendCode(554, fail.getErrorMessage())
+
+ def _cbPut(self, value):
+ """xxx fill me in"""
+ pass
+
+ def _cbPout(self, fail):
+ """xxx fill me in"""
+ pass
+
+
+class AliasResolverFactory(postfix.PostfixTCPMapDeferringDictServerFactory):
+ """
+ A Factory for creating :class:`AliasResolver` servers, which handles inputs
+ and outputs, and keeps an in-memory mapping of Postfix aliases in the form
+ of a dictionary.
+
+ xxx fill me in
+ """
+ protocol = AliasResolver
+
+ def __init__(self, addr='127.0.0.1', port=4242, timeout=120, data=None):
+ """
+ Create a Factory which returns :class:`AliasResolver` servers.
+
+ @param addr: A string giving the IP address of this server.
+ Default: '127.0.0.1'
+ @param port: An integer that specifies the port number to listen
+ on. Default: 4242
+ @param timeout: An integer specifying the number of seconds to wait
+ until we should time out. Default: 120
+ @param data: A dict to use to initialise or update the alias mapping.
+ """
+ super(postfix.PostfixTCPMapDeferringDictServerFactory,
+ self).__init__(data=data)
+ self.timeout = timeout
+ self.noisy = True if config.advanced.noisy else False
+
+ try:
+ assert isinstance(port, int), "Port number must be an integer"
+ assert isinstance(timeout, int), "Timeout must be an integer"
+ except AssertionError, ae:
+ raise SystemExit(ae.message)
+
+ if net.checkIPaddress(addr):
+ self.addr = address._IPAddress('TCP', addr, int(port))
+ else:
+ log.msg("Using default address: 127.0.0.1:%s" % port)
+ self.addr = address._IPAddress('TCP', '127.0.0.1', int(port))
+
+ log.msg("To configure Postfix to query this alias_resolver,")
+ log.msg("you should do:")
+ log.msg(" $ postconf -e 'check_recipient_access = tcp:%s:%d"
+ % (addr, port))
+
+ def buildProtocol(self):
+ """
+ Create an instance of the :class:`AliasResolver` server.
+ """
+ proto = self.protocol()
+ proto.timeout = self.timeout
+ proto.factory = self
+ return proto
+
+ def get(self, *args, **kwargs):
+ """
+ xxx connect me to the couchdb
+ """
+ pass
+
+ def put(self, *args, **kwargs):
+ """
+ xxx connect me to the couchdb
+ """
+ pass
+
+
+if __name__ == "__main__":
+
+ print "To test alias_resolver.py, please use /test/test_alias_resolver.py"
diff --git a/src/leap/mx/couchdb.py b/src/leap/mx/couchdb.py
new file mode 100644
index 0000000..b5d4127
--- /dev/null
+++ b/src/leap/mx/couchdb.py
@@ -0,0 +1,118 @@
+# -*- encoding: utf-8 -*-
+'''
+couchdb.py
+==========
+Classes for working with CouchDB or BigCouch instances which store email alias
+maps, user UUIDs, and GPG keyIDs.
+
+@authors: Isis Agora Lovecruft
+@version: 0.0.1-beta
+@license: see included LICENSE file
+'''
+
+try:
+ from paisley import client
+except ImportError:
+ print "This software requires paisley. Please see the README file"
+ print "for instructions on getting required dependencies."
+
+try:
+ from twisted.internet import defer
+except ImportError:
+ print "This software requires Twisted. Please see the README file"
+ print "for instructions on getting required dependencies."
+
+from leap.mx.util import log
+
+
+class ConnectedCouchDB(client.CouchDB):
+ """
+ Connect to a CouchDB instance.
+
+ ## xxx will we need to open CouchDB documents and views?
+ ## yes, these are in a _design document
+ """
+ def __init__(self, host, port, dbName=None, username=None,
+ password=None, *args, **kwargs):
+ """
+ Connect to a CouchDB instance.
+
+ @param host: A hostname string for the CouchDB server.
+ @param port: The port of the CouchDB server, as an integer.
+ @param dbName: (optional) The default database to connect to.
+ @param username: (optional) The username for authorization.
+ @param password: (optional) The password for authorization.
+ @returns: A :class:`twisted.internet.defer.Deferred` representing the
+ the client connection to the CouchDB instance.
+ """
+ super(client.CouchDB, self).__init__(host, port,
+ dbName=dbName,
+ username=username,
+ password=password,
+ *args, **kwargs)
+ if dbName:
+ self.bindToDB(dbName)
+ else:
+ databases = self.listDB()
+ log.msg("Available databases: %s" % databases)
+
+ def queryByEmailOrAlias(self, alias, dbDoc="User",
+ view="by_email_or_alias"):
+ """
+ Check to see if a particular email or alias exists.
+
+ @param alias: A string representing the email or alias to check.
+ @param dbDoc: The CouchDB document to open.
+ @param view: The view of the CouchDB document to use.
+ """
+ assert isinstance(alias, str), "Email or alias queries must be string"
+
+ ## Prepend a forward slash, in case we forgot it:
+ if not alias.startswith('/'):
+ alias = '/' + alias
+
+ d = self.openDoc(dbDoc)
+ d.addCallbacks(self.openView, log.err, (view))
+ d.addCallbacks(self.get, log.err, (alias))
+ d.addCallbacks(self.parseResult, log.err)
+
+ @d.addCallback
+ def show_answer(result):
+ log.msg("Query: %s" % alias)
+ log.msg("Answer: %s" % alias)
+
+ return d
+
+ def query(self, uri):
+ """
+ Query a CouchDB instance that we are connected to.
+ """
+ try:
+ self.checkURI(uri) ## xxx write checkURI()
+ ## xxx we might be able to use self._parseURI()
+ except SchemeNotSupported, sns: ## xxx where in paisley is this?
+ log.exception(sns) ## xxx need log.exception()
+
+ d = self.get(uri)
+ @d.addCallback
+ def parse_answer(answer):
+ return answer
+
+ return answer
+
+ @defer.inlineCallbacks
+ def listUsersAndEmails(self, limit=1000, reverse=False):
+ """
+ List all users and email addresses, up to the given limit.
+ """
+ query = "/users/_design/User/_view/by_email_or_alias/?reduce=false"
+ answer = yield self.query(query, limit=limit, reverse=reverse)
+
+ if answer:
+ parsed = yield self.parseResult(answer)
+ if parsed:
+ log.msg("%s" % parsed)
+ else:
+ log.msg("No answer from database, perhaps there are no users.")
+ else:
+ log.msg("Problem querying CouchDB instance...")
diff --git a/src/leap/mx/exceptions.py b/src/leap/mx/exceptions.py
new file mode 100644
index 0000000..63b946c
--- /dev/null
+++ b/src/leap/mx/exceptions.py
@@ -0,0 +1,23 @@
+#! -*- encoding: utf-8 -*-
+"""
+Custom exceptions for leap_mx.
+
+@authors: Isis Lovecruft, <isis@leap.se> 0x2cdb8b35
+@version: 0.0.1
+@license: see included LICENSE file
+"""
+
+
+class MissingConfig(Exception):
+ """Raised when the config file cannot be found."""
+ def __init__(self, message=None, config_file=None):
+ if message:
+ return
+ else:
+ self.message = "Cannot locate config file"
+ if config_file:
+ self.message += " %s" % config_file
+ self.message += "."
+
+class UnsupportedOS(Exception):
+ """Raised when we're not *nix or *BSD."""
diff --git a/src/leap/mx/runner.py b/src/leap/mx/runner.py
new file mode 100644
index 0000000..daf956e
--- /dev/null
+++ b/src/leap/mx/runner.py
@@ -0,0 +1,83 @@
+#-*- coding: utf-8 -*-
+"""
+runner
+------
+A module containing application and daemon process utilities.
+
+@author Isis Agora Lovecruft <isis@leap.se>, 0x2cdb8b35
+@version 0.0.1
+
+"""
+
+from os import path as ospath
+
+import re
+
+
+class CheckRequirements(ImportError):
+ """
+ Raised when we're missing something from requirements.pip.
+ """
+ def __init__(self, package_name, pipfile, message=None):
+ """
+ Display an error message with instructions for obtaining missing
+ dependencies.
+
+ @param message: A string describing the error.
+ @param missing: A string indicating which dependency is missing.
+ @param pipfile: The path and filename of the pip requirements file,
+ relative to the top-level repository directory.
+ """
+ if message:
+ self.message = message
+ return self
+
+ self.package_name = package_name
+ self.pipfile = pipfile
+ self.dependencies = self.__read_pip_requirements__()
+ self.missing = []
+
+ for package, version in self.dependencies:
+ pkg = package.lower() if package == "Twisted" else package
+ try:
+ __import__(pkg)
+ except ImportError:
+ self.missing.append(package)
+
+ if len(self.missing) > 0:
+ self.message = self.package_name + " requires "
+ elif len(self.missing) <= 0:
+ return None
+
+ if len(self.missing) >= 1:
+ for missed in self.missing[:-1]:
+ self.message += missed + ", "
+ self.message += "and "
+
+ if len(self.missing) == 1:
+ self.message += self.missing[0] + "."
+ self.message += "\nPlease see %s for ".format(self.pipfile)
+ self.message += "instruction on installing dependencies."
+ raise self(self.message)
+
+ def __read_pip_requirements__(self, file=None):
+ """
+ Check the pip requirements file to determine our dependencies.
+
+ @param file: The full path of the pip requirements.txt file.
+ @returns: A list of tuple(package_name, package_version).
+ """
+ if not file:
+ file = self.pipfile
+
+ requirement = re.compile('[^0-9=><]+')
+ dependencies = []
+
+ with open(file) as pipfile:
+ for line in pipfile.readlines():
+ shortened = line.strip()
+ matched = requirement.match(shortened)
+ package_name = matched.group()
+ package_version = shortened.split(package_name, 1)[1]
+ dependencies.append((package_name, package_version))
+ return dependencies
diff --git a/src/leap/mx/tests/__init__.py b/src/leap/mx/tests/__init__.py
new file mode 100644
index 0000000..0416769
--- /dev/null
+++ b/src/leap/mx/tests/__init__.py
@@ -0,0 +1,17 @@
+#-*- encoding: utf-8 -*-
+"""
+leap/mx/tests/__init__.py
+-------------------------
+Module intialization file for leap.mx.tests, a module containing unittesting
+code, using twisted.trial, for testing leap_mx.
+
+@authors: Isis Agora Lovecruft, <isis@leap.se> 0x2cdb8b35
+@license: AGPLv3, see included LICENSE file
+@copyright: © 2013 Isis Lovecruft, see COPYLEFT file
+"""
+
+__all__ = ['test_alias_resolver']
+
+def run():
+ """xxx fill me in"""
+ pass
diff --git a/src/leap/mx/util/__init__.py b/src/leap/mx/util/__init__.py
new file mode 100644
index 0000000..c4a93b8
--- /dev/null
+++ b/src/leap/mx/util/__init__.py
@@ -0,0 +1,12 @@
+#-*- encoding: utf-8 -*-
+"""
+leap/mx/util/__init__.py
+------------------------
+Module intialization file for leap.mx.util.
+"""
+
+import version
+version = version.Version()
+
+__all__ = ['config', 'log', 'net', 'storage', 'version']
+
diff --git a/src/leap/mx/util/config.py b/src/leap/mx/util/config.py
new file mode 100644
index 0000000..f655ca9
--- /dev/null
+++ b/src/leap/mx/util/config.py
@@ -0,0 +1,221 @@
+#! -*- encoding: utf-8 -*-
+"""
+Config file utilities.
+
+This module has an :attr:`config_filename`, which can be used to set the
+filename outside of function calls:
+
+ >>> from leap.mx.util import config
+ >>> config.config_filename = "blahblah.yaml"
+
+If not set anywhere, it will default to using the top level repository
+directory, i.e. "/.../leap_mx/leap_mx.conf", and will create that file with
+the default settings if it does not exist.
+
+The config file can be loaded/created with :func:`config.loadConfig`:
+
+ >>> config.loadConfig()
+
+Once the config file is loaded, this module presents a highly object-oriented
+interface, so that sections taken from the config file become attribute of
+this module, and the name of their respective settings become attributes of
+the section names. Like this:
+
+ >>> print config.basic.postfix_port
+ 465
+
+@authors: Isis Lovecruft, <isis@leap.se> 0x2cdb8b35
+@version: 0.0.1
+@license: see included LICENSE file
+"""
+
+from os import path as ospath
+
+import sys
+import yaml
+
+from leap.mx.util import version, storage
+from leap.mx.exceptions import MissingConfig, UnsupportedOS
+
+
+filename = None
+config_version = None
+basic = storage.Storage()
+couch = storage.Storage()
+advanced = storage.Storage()
+
+PLATFORMS = {'LINUX': sys.platform.startswith("linux"),
+ 'OPENBSD': sys.platform.startswith("openbsd"),
+ 'FREEBSD': sys.platform.startswith("freebsd"),
+ 'NETBSD': sys.platform.startswith("netbsd"),
+ 'DARWIN': sys.platform.startswith("darwin"),
+ 'SOLARIS': sys.platform.startswith("sunos"),
+ 'WINDOWS': sys.platform.startswith("win32")}
+
+def getClientPlatform(platform_name=None):
+ """
+ Determine the client's operating system platform. Optionally, if
+ :param:`platform_name` is given, check that this is indeed the platform
+ we're operating on.
+
+ @param platform_name: A string, upper-, lower-, or mixed case, of one
+ of the keys in the :attr:`leap.util.version.PLATFORMS`
+ dictionary. E.g. 'Linux' or 'OPENBSD', etc.
+ @returns: A string specifying the platform name, and the boolean test
+ used to determine it.
+ """
+ for name, test in PLATFORMS.items():
+ if not platform_name or platform_name.upper() == name:
+ if test:
+ return name, test
+
+def _create_config_file(conffile):
+ """
+ Create the config file if it doesn't exist.
+
+ @param conffile: The full path to the config file to write to.
+ """
+ with open(conffile, 'w+') as conf:
+ conf.write("""
+#
+# mx.conf
+# =======
+# Configurable options for the leap_mx encrypting mail exchange.
+#
+# This file follows YAML markup format: http://yaml.org/spec/1.2/spec.html
+# Keep in mind that indentation matters.
+#
+
+basic:
+ # Whether or not to log to file:
+ enable_logfile: True
+ # The name of the logfile:
+ logfile: mx.log
+ # Where is the spoolfile of messages to encrypt?:
+ spoolfile: /var/mail/encrypt_me
+couch:
+ # The couch username for authentication to a CouchDB instance:
+ user: admin
+ # The couch username's password:
+ passwd: passwd
+ # The CouchDB hostname or IP address to connect to:
+ host: couchdb.example.com
+ # The CouchDB port to connect to:
+ port: 7001
+advanced:
+ # Which port on localhost should postfix send check_recipient queries to?:
+ check_recipient_access_port: 1347
+ # Which port on localhost should postfix ask for UUIDs?:
+ virtual_alias_map_port: 1348
+ # Enable debugging output in the logger:
+ debug: True
+ # Print enough things really fast to make you look super 1337:
+ noisy: False
+config_version: 0.0.2
+
+""")
+ conf.flush()
+ assert ospath.isfile(conffile), "Config file %s not created!" % conffile
+
+def _get_config_location(config_filename=None,
+ use_dot_config_directory=False):
+ """
+ Get the full path and filename of the config file.
+ """
+ platform = getClientPlatform()[0]
+
+ ## If not given, default to the application's name + '.conf'
+ if not config_filename:
+ if not filename:
+ config_filename = "mx.conf"
+ else:
+ config_filename = filename
+
+ ## Oh hell, it could be said only to beguile:
+ ## That windoze users are capable of editing a .conf file.
+ ## Also, what maddened wingnut would be so fool
+ ## To run a mail exchange on a windoze nodule?
+ ## I'm ignoring these loons for now. And pardon if I seem jaded,
+ ## But srsly, this and that solaris sh*t should be deprecated.
+ if not platform.endswith('LINUX') and not platform.endswith('BSD'):
+ raise UnsupportedOS("Sorry, your operating system isn't supported.")
+
+ where = None
+ if use_dot_config_directory:
+ ## xxx only install/import this in *nix
+ from xdg import BaseDirectory
+
+ dot_config_dirs = BaseDirectory.xdg_config_dirs
+ for dir in dot_config_dirs:
+ our_dir = ospath.join(dir, package_name)
+ if ospath.isdir(our_dir):
+ if config_filename in os.listdir(our_dir):
+ where = ospath.abspath(our_dir)
+ ## Use repo dir instead:
+ if not where:
+ where = version.getRepoDir()
+
+ conffile = ospath.join(where, config_filename)
+ try:
+ with open(conffile) as cf: pass
+ except IOError:
+ _create_config_file(conffile)
+ finally:
+ return conffile
+
+def loadConfig(file=None):
+ """
+ Some of this is taken from OONI config code for now, and so this should be
+ refacotored, along with the leap_client config code, so that we have
+ similarly structured config files. It is perhaps desirable to also use
+ soledad as a backend for remote setup and maintainance, and thus this code
+ will need to hook into u1db (and potentially "pysqlcipher").
+
+ Excuse the yaml for now, I just wanted something that works.
+
+ @param file: (optional) If provided, use this filename.
+ """
+ if not file:
+ file = _get_config_location()
+
+ if ospath.isfile(file):
+ with open(file, 'a+') as conf:
+ config_contents = '\n'.join(conf.readlines())
+ cfg = yaml.safe_load(config_contents)
+
+ ## These become objects with their keys loaded as attributes:
+ ##
+ ## from leap.util import config
+ ## config.basic.foo = bar
+ ##
+ try:
+ for k, v in cfg['basic'].items():
+ basic[k] = v
+ except (AttributeError, KeyError): pass
+
+ try:
+ for k, v in cfg['advanced'].items():
+ advanced[k] = v
+ except (AttributeError, KeyError): pass
+
+ try:
+ for k, v in cfg['couch'].items():
+ couch[k] = v
+ except (AttributeError, KeyError): pass
+
+ if 'config_version' in cfg:
+ config_version = cfg['config_version']
+ else:
+ config_version = 'unknown'
+
+ return basic, couch, advanced, config_version
+ else:
+ raise MissingConfig("Could not load config file.")
+
+
+## This is the name of the config file to use:
+## If not set, it defaults to 'leap_mx/leap_mx.conf'
+if not filename:
+ filename = _get_config_location()
+else:
+ filename = _get_config_location(config_filename=filename)
diff --git a/src/leap/mx/util/log.py b/src/leap/mx/util/log.py
new file mode 100644
index 0000000..f31684d
--- /dev/null
+++ b/src/leap/mx/util/log.py
@@ -0,0 +1,143 @@
+# -*- encoding: utf-8 -*-
+'''
+log.py
+------
+Logging for leap_mx.
+
+@authors: Isis Agora Lovecruft, <isis@leap.se> 0x2cdb8b35
+@licence: see included LICENSE file
+@copyright: 2013 Isis Agora Lovecruft
+'''
+
+from datetime import datetime
+from functools import wraps
+
+import logging
+import os
+import sys
+import time
+import traceback
+
+from twisted.python import log as txlog
+from twisted.python import util as txutil
+from twisted.python import logfile as txlogfile
+from twisted.python.failure import Failure
+
+from leap.mx.util import version, config
+
+
+class InvalidTimestampFormat(Exception):
+ pass
+
+class UnprefixedLogfile(txlog.FileLogObserver):
+ """Logfile with plain messages, without timestamp prefixes."""
+ def emit(self, eventDict):
+ text = txlog.textFromEventDict(eventDict)
+ if text is None:
+ return
+
+ txutil.untilConcludes(self.write, "%s\n" % text)
+ txutil.untilConcludes(self.flush)
+
+
+def utcDateNow():
+ """The current date for UTC time."""
+ return datetime.utcnow()
+
+def utcTimeNow():
+ """Seconds since epoch in UTC time, as type float."""
+ return time.mktime(time.gmtime())
+
+def dateToTime(date):
+ """Convert datetime to seconds since epoch."""
+ return time.mktime(date.timetuple())
+
+def prettyDateNow():
+ """Pretty string for the local time."""
+ return datetime.now().ctime()
+
+def utcPrettyDateNow():
+ """Pretty string for UTC."""
+ return datetime.utcnow().ctime()
+
+def timeToPrettyDate(time_val):
+ """Convert seconds since epoch to date."""
+ return time.ctime(time_val)
+
+def start(logfilename=None, logfiledir=None):
+ """
+ Start logging to stdout, and optionally to a logfile as well.
+
+ @param logfile: The full path of the filename to store logs in.
+ """
+ txlog.startLoggingWithObserver(UnprefixedLogfile(sys.stdout).emit)
+
+ if logfilename and logfiledir:
+ if not os.path.isdir(logfiledir):
+ os.makedirs(logfiledir)
+ daily_logfile = txlogfile.DailyLogFile(logfilename, logfiledir)
+ txlog.addObserver(txlog.FileLogObserver(daily_logfile).emit)
+
+ txlog.msg("Starting %s, version %s, on %s UTC" % (version.getPackageName(),
+ version.getVersion(),
+ utcPrettyDateNow()))
+ txlog.msg("Authors: %s" % version.getAuthors())
+
+def msg(msg, *arg, **kwarg):
+ """Log a message at the INFO level."""
+ print "[*] %s" % msg
+
+def debug(msg, *arg, **kwarg):
+ """Log a message at the DEBUG level."""
+ if config.advanced.debug:
+ print "[d] %s" % msg
+
+def warn(msg, *arg, **kwarg):
+ """Log a message at the WARN level."""
+ if config.basic.show_warnings:
+ txlog.logging.captureWarnings('true')
+ print "[#] %s" % msg
+
+def err(msg, *arg, **kwarg):
+ """Log a message at the ERROR level."""
+ print "[!] %s" % msg
+
+def fail(*failure):
+ """Log a message at the CRITICAL level."""
+ logging.critical(failure)
+ ## xxx should we take steps to exit here?
+
+def exception(error):
+ """
+ Catch an exception and print only the error message, then continue normal
+ program execution.
+
+ @param error: Can be error messages printed to stdout and to the
+ logfile, or can be a twisted.python.failure.Failure instance.
+ """
+ if isinstance(error, Failure):
+ error.printTraceback()
+ else:
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ traceback.print_exception(exc_type, exc_value, exc_traceback)
+
+def catch(func):
+ """
+ Quick wrapper to add around test methods for debugging purposes,
+ catches the given Exception. Use like so:
+
+ >>> @log.catch
+ def foo(bar):
+ if bar == 'baz':
+ raise Exception("catch me no matter what I am")
+ >>> foo("baz")
+ [!] catch me no matter what I am
+
+ """
+ @wraps(func)
+ def _catch(*args, **kwargs):
+ try:
+ func(*args, **kwargs)
+ except Exception, exc:
+ exception(exc)
+ return _catch
diff --git a/src/leap/mx/util/net.py b/src/leap/mx/util/net.py
new file mode 100644
index 0000000..64dbc90
--- /dev/null
+++ b/src/leap/mx/util/net.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python
+# -*- encoding: utf-8 -*-
+'''
+net.py
+-------
+Utilities for networking.
+
+@authors: Isis Agora Lovecruft, <isis@leap.se> 0x2cdb8b35
+@license: see included LICENSE file
+@copyright: 2013 Isis Agora Lovecruft
+'''
+
+import ipaddr
+import sys
+import socket
+
+from random import randint
+
+from leap.mx.util import log
+
+
+PLATFORMS = {'LINUX': sys.platform.startswith("linux"),
+ 'OPENBSD': sys.platform.startswith("openbsd"),
+ 'FREEBSD': sys.platform.startswith("freebsd"),
+ 'NETBSD': sys.platform.startswith("netbsd"),
+ 'DARWIN': sys.platform.startswith("darwin"),
+ 'SOLARIS': sys.platform.startswith("sunos"),
+ 'WINDOWS': sys.platform.startswith("win32")}
+
+
+class UnsupportedPlatform(Exception):
+ """Support for this platform is not currently available."""
+
+class IfaceError(Exception):
+ """Could not find default network interface."""
+
+class PermissionsError(SystemExit):
+ """This test requires admin or root privileges to run. Exiting..."""
+
+
+def checkIPaddress(addr):
+ """
+ Check that a given string is a valid IPv4 or IPv6 address.
+
+ @param addr: Any string defining an IP address, i.e. '1.2.3.4' or '::1'.
+ @returns: True if :param:`addr` defines a valid IPAddress, else False.
+ """
+ import ipaddr
+
+ try:
+ check = ipaddr.IPAddress(addr)
+ except ValueError, ve:
+ log.warn(ve.message)
+ return False
+ else:
+ return True
+
+def getClientPlatform(platform_name=None):
+ for name, test in PLATFORMS.items():
+ if not platform_name or platform_name.upper() == name:
+ if test:
+ return name, test
+
+def getPosixIfaces():
+ from twisted.internet.test import _posixifaces
+ log.msg("Attempting to discover network interfaces...")
+ ifaces = _posixifaces._interfaces()
+ return ifaces
+
+def getWindowsIfaces():
+ from twisted.internet.test import _win32ifaces
+ log.msg("Attempting to discover network interfaces...")
+ ifaces = _win32ifaces._interfaces()
+ return ifaces
+
+def getIfaces(platform_name=None):
+ client, test = getClientPlatform(platform_name)
+ if client:
+ if client == ('LINUX' or 'DARWIN') or client[-3:] == 'BSD':
+ return getPosixIfaces()
+ elif client == 'WINDOWS':
+ return getWindowsIfaces()
+ ## XXX fixme figure out how to get iface for Solaris
+ else:
+ raise UnsupportedPlatform
+ else:
+ raise UnsupportedPlatform
+
+def getRandomUnusedPort(addr=None):
+ free = False
+ while not free:
+ port = randint(1024, 65535)
+ s = socket.socket()
+ try:
+ s.bind((addr, port))
+ free = True
+ except:
+ pass
+ s.close()
+ return port
+
+def getNonLoopbackIfaces(platform_name=None):
+ try:
+ ifaces = getIfaces(platform_name)
+ except UnsupportedPlatform, up:
+ log.err(up)
+
+ if not ifaces:
+ log.msg("Unable to discover network interfaces...")
+ return None
+ else:
+ found = [{i[0]: i[2]} for i in ifaces if i[0] != 'lo']
+ log.debug("Found non-loopback interfaces: %s" % found)
+ for iface in ifaces:
+ try:
+ interface = checkInterfaces(found)
+ except IfaceError, ie:
+ log.err(ie)
+ return None
+ else:
+ return interfaces
+
+
+def getLocalAddress():
+ default_iface = getDefaultIface()
+ return default_iface.ipaddr
diff --git a/src/leap/mx/util/storage.py b/src/leap/mx/util/storage.py
new file mode 100644
index 0000000..c4c797a
--- /dev/null
+++ b/src/leap/mx/util/storage.py
@@ -0,0 +1,42 @@
+
+class Storage(dict):
+ """
+ A Storage object is like a dictionary except `obj.foo` can be used
+ in addition to `obj['foo']`.
+
+ >>> o = Storage(a=1)
+ >>> o.a
+ 1
+ >>> o['a']
+ 1
+ >>> o.a = 2
+ >>> o['a']
+ 2
+ >>> del o.a
+ >>> o.a
+ None
+ """
+ def __getattr__(self, key):
+ try:
+ return self[key]
+ except KeyError, k:
+ return None
+
+ def __setattr__(self, key, value):
+ self[key] = value
+
+ def __delattr__(self, key):
+ try:
+ del self[key]
+ except KeyError, k:
+ raise AttributeError, k
+
+ def __repr__(self):
+ return '<Storage ' + dict.__repr__(self) + '>'
+
+ def __getstate__(self):
+ return dict(self)
+
+ def __setstate__(self, value):
+ for (k, v) in value.items():
+ self[k] = v
diff --git a/src/leap/mx/util/version.py b/src/leap/mx/util/version.py
new file mode 100644
index 0000000..c32166f
--- /dev/null
+++ b/src/leap/mx/util/version.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python
+# -*- encoding: utf-8 -*-
+'''
+version.py
+----------
+Version information for leap_mx.
+
+@authors: Isis Agora Lovecruft, <isis@leap.se> 0x2cdb8b35
+@licence: see included LICENSE file
+@copyright: 2013 Isis Agora Lovecruft
+'''
+
+from os import getcwd
+from os import path as ospath
+
+import sys
+
+
+class Version(object):
+ def __init__(self):
+ self.name = 'leap_mx'
+ self.version = '0.0.2'
+ self.pipfile = ospath.join(self.getRepoDir(),
+ 'pkg/mx-requirements.pip')
+ self.authors = [
+ ('Isis Agora Lovecruft', '<isis@leap.se>', '0x2cdb8b35'),
+ ]
+ self.git_url = 'https://github.com/isislovecruft/leap_mx/'
+ self.website = 'https://leap.se'
+
+ def getPackageName(self):
+ """Returns the application name."""
+ return self.name
+
+ def getPipfile(self):
+ """Returns the full path of the pip requirements.txt file."""
+ return self.pipfile
+
+ def getVersion(self):
+ """Returns a version the application name and version number."""
+ return self.version
+
+ def getAuthors(self):
+ credits = str()
+ for author in self.authors:
+ credits += " ".join(author)
+ return credits
+
+ def getRepoDir(self):
+ """Get the top-level repository directory."""
+ here = getcwd()
+ base = here.rsplit(self.name, 1)[0]
+ repo = ospath.join(base, self.name)
+ return repo
+
+ def __make_text__(self, extra_text=None):
+ splitr = "-" * len(self.version.__str__())
+ header = ["\n%s\n" % self.version.__str__(),
+ "%s\n" % splitr]
+ footer = ["Website: \t%s\n" % self.website,
+ "Github: \t%s\n" % self.git_url,
+ "\n"]
+ contacts = ["\t%s, %s %s\n"
+ % (a[0], a[1], a[2]) for a in self.authors]
+ contacts.insert(0, "Authors: ")
+
+ with_contacts = header + contacts
+
+ if extra_text is not None:
+ if isinstance(extra_text, iter):
+ with_contacts.extend((e for e in extra_text))
+ elif isinstance(extra_text, str):
+ with_contacts.append(extra_text)
+ else:
+ print "Couldn't add extra text..."
+
+ text = with_contacts + footer
+ return text
+
+ def __update_version__(self):
+ repo = self.getRepoDir()
+ self.version_file = ospath.join(repo, 'VERSION')
+ version_text = self.__make_text__()
+
+ with open(self.version_file, 'w+') as fh:
+ fh.writelines((line for line in version_text))
+ fh.flush()
+ fh.truncate()
+
+
+if __name__ == "__main__":
+ print "Generating new VERSION file..."
+ vrsn = Version()
+ vrsn.__update_version__()
+ print "Done."