diff --git a/INSTALL.md b/INSTALL.md index dcbc275..5898d51 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -14,7 +14,7 @@ These instructions are based on an installation on an Ubuntu 14.04 LTS virtual m ## Install GPG-Mailgate ### Requirements -- Python 2.X is already installed (GPG-Mailgate is not Python 3 compatible) +- Python 3.X is already installed - Postfix is already installed and configured. It is recommended that you have already tested your configuration so we can exclude this as a main cause of problems - GnuPG is already installed and configured @@ -39,11 +39,13 @@ These instructions are based on an installation on an Ubuntu 14.04 LTS virtual m chown nobody:nogroup /usr/local/bin/gpg-mailgate.py chmod u+x /usr/local/bin/gpg-mailgate.py -5. Place the `GnuPG` directory in `/usr/local/lib/python2.7/dist-packages` (replace 2.7 with your Python 2 version) +5. Place the `GnuPG` directory in `/usr/local/lib/python3.x/dist-packages` (replace 3.x with your Python version) 6. Configure `/etc/gpg-mailgate.conf` based on the provided `gpg-mailgate.conf.sample`. Change the settings according to your configuration. If you follow this guide and have a standard configuration for postfix, you don't need to change much. -7. Add the following to the end of `/etc/postfix/master.cf` +7. Configure logging by copying `gpg-lacre-logging.conf.sample` to `/etc/gpg-lacre-logging.conf` and editing it according to your needs. The path to this file is included in `[logging]` section of `gpg-mailgate.conf` file, so if you place it somewhere else, make sure to update the path too. See also: [Configuration file format](https://docs.python.org/3/library/logging.config.html#configuration-file-format). + +8. Add the following to the end of `/etc/postfix/master.cf` gpg-mailgate unix - n n - - pipe flags= user=nobody argv=/usr/local/bin/gpg-mailgate.py ${recipient} @@ -60,15 +62,15 @@ These instructions are based on an installation on an Ubuntu 14.04 LTS virtual m If you use Postfix versions from 2.5 onwards, it is recommended to change `${recipient}` to `${original_recipient}` in line two of the lines above. -8. Add the following line to `/etc/postfix/main.cf` +9. Add the following line to `/etc/postfix/main.cf` content_filter = gpg-mailgate -9. Optional: GPG can automatically download new public keys for automatic signature verification. To enable automatic create the file `/var/gpgmailgate/.gnupg/gpg.conf`. Add the following line to the file: +10. Optional: GPG can automatically download new public keys for automatic signature verification. To enable automatic create the file `/var/gpgmailgate/.gnupg/gpg.conf`. Add the following line to the file: keyserver-options auto-key-retrieve -10. Restart Postfix +11. Restart Postfix You are now ready to go. To add a public key for encryption just use the following command: @@ -112,10 +114,10 @@ You also can remove a private key by using the following command. Replace `user@ - A webserver is installed and reachable - The webserver is able to handle PHP scripts - MySQL is installed -- Python 2.X is already installed +- Python 3.X is already installed ### Installation -All files you need can be found in the [gpg-mailgate-web] (gpg-mailgate-web/) directory. +All files you need can be found in the [gpg-mailgate-web](gpg-mailgate-web/) directory. 1. Install the Python-mysqldb and Python-markdown modules: @@ -127,7 +129,7 @@ All files you need can be found in the [gpg-mailgate-web] (gpg-mailgate-web/) di 4. Edit the config file located at `/etc/gpg-mailgate.conf`. Set `enabled = yes` in `[database]` and fill in the necessary settings for the database connection. -5. Copy the files located in the [public_html] (gpg-mailgate-web/public_html) directory onto your webserver. They can also be placed in a subdirectory on your webserver. +5. Copy the files located in the [public_html](gpg-mailgate-web/public_html) directory onto your webserver. They can also be placed in a subdirectory on your webserver. 6. On your webserver move the `config.sample.php` file to `config.php` and edit the configuration file. @@ -135,7 +137,7 @@ All files you need can be found in the [gpg-mailgate-web] (gpg-mailgate-web/) di mkdir -p /var/gpgmailgate/cron_templates -8. Copy the templates found in the [cron_templates] (cron_templates/) directory into the newly created directory and transfer ownership: +8. Copy the templates found in the [cron_templates](cron_templates/) directory into the newly created directory and transfer ownership: chown -R nobody:nogroup /var/gpgmailgate/cron_templates @@ -151,7 +153,7 @@ All files you need can be found in the [gpg-mailgate-web] (gpg-mailgate-web/) di 11. Test your installation. ### GPG-Mailgate-Web as keyserver -GPG-Mailgate-Web can also be used as a keyserver. For more information have a look at GPG-Mailgate-Web's [readme] (gpg-mailgate-web/README). +GPG-Mailgate-Web can also be used as a keyserver. For more information have a look at GPG-Mailgate-Web's [readme](gpg-mailgate-web/README). ## Install Register-handler ### Requirements @@ -168,7 +170,7 @@ GPG-Mailgate-Web can also be used as a keyserver. For more information have a lo mkdir -p /var/gpgmailgate/register_templates -3. Copy the templates found in the [register_templates] (register_templates/) directory into the newly created directory and transfer ownership: +3. Copy the templates found in the [register_templates](register_templates/) directory into the newly created directory and transfer ownership: chown -R nobody:nogroup /var/gpgmailgate/register_templates diff --git a/gpg-lacre-logging.conf.sample b/gpg-lacre-logging.conf.sample new file mode 100644 index 0000000..c3d458c --- /dev/null +++ b/gpg-lacre-logging.conf.sample @@ -0,0 +1,57 @@ +# Example configuration for Lacre logging. If you don't intend to change the +# log format, you can just keep this file unchanged. + +# HANDLERS: +# +# Two main targets for log entries are defined here: syslog and a plain text +# log file. They are available as "handlers" named "syslog" and "lacrelog" +# respectively. + +[loggers] +keys=root + +[logger_root] +level=NOTSET +# Comma-separated handler names, see HANDLERS note at the top. +handlers=syslog + +[handlers] +# Comma-separated handler names, see HANDLERS note at the top. +keys=syslog + +[formatters] +keys=postfixfmt + +# +# By default, include messages from all log levels up to DEBUG. +# However, productive systems may use something less verbose, like +# WARN or even ERROR. +# +[handler_lacrelog] +class=FileHandler +level=DEBUG +formatter=postfixfmt +args=('test/logs/lacre.log', 'a+') + +# You may want to change the second argument (handlers.SysLogHandler.LOG_MAIL) +# to change the syslog facility used to record messages from Lacre. +# +# Options you can consider are "localX" facilities, available under names from +# handlers.SysLogHandler.LOG_LOCAL0 to handlers.SysLogHandler.LOG_LOCAL7. +# +# Please refer to your syslog configuration for details on how to separate +# records from different facilities. +[handler_syslog] +class=handlers.SysLogHandler +level=INFO +formatter=postfixfmt +args=('/dev/log', handlers.SysLogHandler.LOG_MAIL) + +# +# Default Postfix log format. +# +[formatter_postfixfmt] +format=%(asctime)s %(module)s[%(process)d]: %(message)s +datefmt=%b %e %H:%M:%S +style=% +validate=True diff --git a/gpg-mailgate-web/cron.py b/gpg-mailgate-web/cron.py index 45fded7..48c02e0 100644 --- a/gpg-mailgate-web/cron.py +++ b/gpg-mailgate-web/cron.py @@ -30,20 +30,9 @@ import os from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart -# Environment variable name we read to retrieve configuration path. This is to -# enable non-root users to set up and run GPG Mailgate and to make the software -# testable. -CONFIG_PATH_ENV = "GPG_MAILGATE_CONFIG" - -def appendLog(msg): - print(msg) - if 'logging' in cfg and 'file' in cfg['logging']: - if cfg['logging'].get('file') == "syslog": - syslog.syslog(syslog.LOG_INFO | syslog.LOG_MAIL, msg) - else: - logfile = open(cfg['logging']['file'], 'a') - logfile.write(msg + "\n") - logfile.close() +import logging +import lacre +import lacre.config as conf def load_file(name): f = open(name) @@ -52,32 +41,34 @@ def load_file(name): return data def authenticate_maybe(smtp): - if 'smtp' in cfg and 'enabled' in cfg['smtp'] and cfg['smtp']['enabled'] == 'true': - smtp.connect(cfg['smtp']['host'],cfg['smtp']['port']) + if conf.config_item_equals('smtp', 'enabled', 'true'): + LOG.debug(f"Connecting to {conf.get_item('smtp', 'host')}:{conf.get_item('smtp', 'port')}") + smtp.connect(conf.get_item('smtp', 'host'), conf.get_item('smtp', 'port')) smtp.ehlo() - if 'starttls' in cfg['smtp'] and cfg['smtp']['starttls'] == 'true': + if conf.config_item_equals('smtp', 'starttls', 'true'): + LOG.debug("StartTLS enabled") smtp.starttls() smtp.ehlo() - smtp.login(cfg['smtp']['username'], cfg['smtp']['password']) + smtp.login(conf.get_item('smtp', 'username'), conf.get_item('smtp', 'password')) def send_msg( mailsubject, messagefile, recipients = None ): - mailbody = load_file( cfg['cron']['mail_templates'] + "/" + messagefile) + mailbody = load_file( conf.get_item('cron', 'mail_templates') + "/" + messagefile).read() msg = MIMEMultipart("alternative") - msg["From"] = cfg['cron']['notification_email'] + msg["From"] = conf.get_item('cron', 'notification_email') msg["To"] = recipients msg["Subject"] = mailsubject msg.attach(MIMEText(mailbody, 'plain')) msg.attach(MIMEText(markdown.markdown(mailbody), 'html')) - - if 'relay' in cfg and 'host' in cfg['relay'] and 'enc_port' in cfg['relay']: - relay = (cfg['relay']['host'], int(cfg['relay']['enc_port'])) + + if conf.config_item_set('relay', 'host') and conf.config_item_set('relay', 'enc_port'): + relay = (conf.get_item('relay', 'host'), int(conf.get_item('relay', 'enc_port'))) smtp = smtplib.SMTP(relay[0], relay[1]) authenticate_maybe(smtp) - smtp.sendmail( cfg['cron']['notification_email'], recipients, msg.as_string() ) + smtp.sendmail( conf.get_item('cron', 'notification_email'), recipients, msg.as_string() ) else: - appendLog("Could not send mail due to wrong configuration") + LOG.info("Could not send mail due to wrong configuration") def setup_db_connection(url): engine = sqlalchemy.create_engine(url) @@ -98,49 +89,52 @@ def define_db_schema(): # Read configuration from /etc/gpg-mailgate.conf -_cfg = RawConfigParser() -_cfg.read(os.getenv(CONFIG_PATH_ENV, '/etc/gpg-mailgate.conf')) -cfg = dict() -for sect in _cfg.sections(): - cfg[sect] = dict() - for (name, value) in _cfg.items(sect): - cfg[sect][name] = value +conf.load_config() -if 'database' in cfg and 'enabled' in cfg['database'] and cfg['database']['enabled'] == 'yes' and 'url' in cfg['database']: - (engine, conn) = setup_db_connection(cfg["database"]["url"]) +lacre.init_logging(conf.get_item('logging', 'config')) +LOG = logging.getLogger(__name__) + + +if conf.config_item_equals('database', 'enabled', 'yes') and conf.config_item_set('database', 'url'): + (engine, conn) = setup_db_connection(conf.get_item("database", "url")) (gpgmw_keys) = define_db_schema() selq = select(gpgmw_keys.c.publickey, gpgmw_keys.c.id, gpgmw_keys.c.email)\ .where(and_(gpgmw_keys.c.status == 0, gpgmw_keys.c.confirm == ""))\ .limit(100) + LOG.debug(f"Retrieving keys to be processed: {selq}") result_set = conn.execute(selq) for row in result_set: # delete any other public keys associated with this confirmed email address delq = delete(gpgmw_keys).where(and_(gpgmw_keys.c.email == row[2], gpgmw_keys.c.id != row[1])) + LOG.debug(f"Deleting public keys associated with confirmed email: {delq}") conn.execute(delq) - GnuPG.delete_key(cfg['gpg']['keyhome'], row[2]) - appendLog('Deleted key for <' + row[2] + '> via import request') + GnuPG.delete_key(conf.get_item('gpg', 'keyhome'), row[2]) + LOG.info('Deleted key for <' + row[2] + '> via import request') if row[0].strip(): # we have this so that user can submit blank key to remove any encryption if GnuPG.confirm_key(row[0], row[2]): - GnuPG.add_key(cfg['gpg']['keyhome'], row[0]) # import the key to gpg + GnuPG.add_key(conf.get_item('gpg', 'keyhome'), row[0]) # import the key to gpg modq = gpgmw_keys.update().where(gpgmw_keys.c.id == row[1]).values(status = 1) + LOG.debug(f"Key imported, updating key: {modq}") conn.execute(modq) # mark key as accepted - appendLog('Imported key from <' + row[2] + '>') - if 'send_email' in cfg['cron'] and cfg['cron']['send_email'] == 'yes': + LOG.warning('Imported key from <' + row[2] + '>') + if conf.config_item_equals('cron', 'send_email', 'yes'): send_msg( "PGP key registration successful", "registrationSuccess.md", row[2] ) else: delq = delete(gpgmw_keys).where(gpgmw_keys.c.id == row[1]) + LOG.debug(f"Cannot confirm key, deleting it: {delq}") conn.execute(delq) # delete key - appendLog('Import confirmation failed for <' + row[2] + '>') - if 'send_email' in cfg['cron'] and cfg['cron']['send_email'] == 'yes': + LOG.warning('Import confirmation failed for <' + row[2] + '>') + if conf.config_item_equals('cron', 'send_email', 'yes'): send_msg( "PGP key registration failed", "registrationError.md", row[2] ) else: # delete key so we don't continue processing it delq = delete(gpgmw_keys).where(gpgmw_keys.c.id == row[1]) + LOG.debug(f"Deleting key: {delq}") conn.execute(delq) - if 'send_email' in cfg['cron'] and cfg['cron']['send_email'] == 'yes': + if conf.config_item_equals('cron', 'send_email', 'yes'): send_msg( "PGP key deleted", "keyDeleted.md", row[2]) # delete keys @@ -148,9 +142,11 @@ if 'database' in cfg and 'enabled' in cfg['database'] and cfg['database']['enabl stat2_result_set = conn.execute(stat2q) for row in stat2_result_set: - GnuPG.delete_key(cfg['gpg']['keyhome'], row[0]) + GnuPG.delete_key(conf.get_item('gpg', 'keyhome'), row[0]) delq = delete(gpgmw_keys).where(gpgmw_keys.c.id == row[1]) + LOG.debug(f"Deleting keys that have already been processed: {delq}") conn.execute(delq) - appendLog('Deleted key for <' + row[0] + '>') + LOG.info('Deleted key for <' + row[0] + '>') else: print("Warning: doing nothing since database settings are not configured!") + LOG.error("Warning: doing nothing since database settings are not configured!") diff --git a/gpg-mailgate.conf.sample b/gpg-mailgate.conf.sample index fad9a79..9467be7 100644 --- a/gpg-mailgate.conf.sample +++ b/gpg-mailgate.conf.sample @@ -66,9 +66,9 @@ notification_email = gpg-mailgate@yourdomain.tld mail_templates = /var/gpgmailgate/cron_templates [logging] -# For logging to syslog. 'file = syslog', otherwise use path to the file. -file = syslog -verbose = yes +# path to the logging configuration; see documentation for details: +# https://docs.python.org/3/library/logging.config.html#logging-config-fileformat +config = /etc/gpg-lacre-logging.conf [relay] # the relay settings to use for Postfix diff --git a/gpg-mailgate.py b/gpg-mailgate.py index 02e1c80..7d45855 100755 --- a/gpg-mailgate.py +++ b/gpg-mailgate.py @@ -39,44 +39,18 @@ import traceback from M2Crypto import BIO, Rand, SMIME, X509 from email.mime.message import MIMEMessage -# Environment variable name we read to retrieve configuration path. This is to -# enable non-root users to set up and run GPG Mailgate and to make the software -# testable. -CONFIG_PATH_ENV = "GPG_MAILGATE_CONFIG" - -# Read configuration from /etc/gpg-mailgate.conf -_cfg = RawConfigParser() -_cfg.read(os.getenv(CONFIG_PATH_ENV, '/etc/gpg-mailgate.conf')) -cfg = dict() -for sect in _cfg.sections(): - cfg[sect] = dict() - for (name, value) in _cfg.items(sect): - cfg[sect][name] = value - -def log( msg ): - if 'logging' in cfg and 'file' in cfg['logging']: - if cfg['logging'].get('file') == "syslog": - syslog.syslog(syslog.LOG_INFO | syslog.LOG_MAIL, msg) - else: - logfile = open(cfg['logging']['file'], 'a') - logfile.write(msg + "\n") - logfile.close() - -verbose = 'logging' in cfg and 'verbose' in cfg['logging'] and cfg['logging'].get('verbose') == 'yes' - -# Read e-mail from stdin -raw = sys.stdin.read() -raw_message = email.message_from_string( raw ) -from_addr = raw_message['From'] -to_addrs = sys.argv[1:] +import logging +import lacre +import lacre.config as conf def gpg_encrypt( raw_message, recipients ): + global LOG - if not get_bool_from_cfg('gpg', 'keyhome'): - log("No valid entry for gpg keyhome. Encryption aborted.") + if not conf.config_item_set('gpg', 'keyhome'): + LOG.error("No valid entry for gpg keyhome. Encryption aborted.") return recipients - keys = GnuPG.public_keys( cfg['gpg']['keyhome'] ) + keys = GnuPG.public_keys( conf.get_item('gpg', 'keyhome') ) for fingerprint in keys: keys[fingerprint] = sanitize_case_sense(keys[fingerprint]) @@ -86,17 +60,17 @@ def gpg_encrypt( raw_message, recipients ): for to in recipients: # Check if recipient is in keymap - if get_bool_from_cfg('enc_keymap', to): - log("Encrypt keymap has key '%s'" % cfg['enc_keymap'][to] ) + if conf.config_item_set('enc_keymap', to): + LOG.info("Encrypt keymap has key '%s'" % conf.get_item('enc_keymap', to) ) # Check we've got a matching key! - if cfg['enc_keymap'][to] in keys: - gpg_to.append( (to, cfg['enc_keymap'][to]) ) + if conf.get_item('enc_keymap', to) in keys: + gpg_to.append( (to, conf.get_item('enc_keymap', to)) ) continue else: - log("Key '%s' in encrypt keymap not found in keyring for email address '%s'." % (cfg['enc_keymap'][to], to)) + LOG.info("Key '%s' in encrypt keymap not found in keyring for email address '%s'." % (conf.get_item('enc_keymap', to), to)) # Check if key in keychain is present - if to in keys.values() and not get_bool_from_cfg('default', 'enc_keymap_only', 'yes'): + if to in keys.values() and not conf.config_item_equals('default', 'enc_keymap_only', 'yes'): gpg_to.append( (to, to) ) continue @@ -104,23 +78,22 @@ def gpg_encrypt( raw_message, recipients ): splitted_to = to.split('@') if len(splitted_to) > 1: domain = splitted_to[1] - if get_bool_from_cfg('enc_domain_keymap', domain): - log("Encrypt domain keymap has key '%s'" % cfg['enc_dec_keymap'][domain] ) + if conf.config_item_set('enc_domain_keymap', domain): + LOG.info("Encrypt domain keymap has key '%s'" % conf.get_item('enc_dec_keymap', domain) ) # Check we've got a matching key! - if cfg['enc_domain_keymap'][domain] in keys: - log("Using default domain key for recipient '%s'" % to) - gpg_to.append( (to, cfg['enc_domain_keymap'][domain]) ) + if conf.get_item('enc_domain_keymap', domain) in keys: + LOG.info("Using default domain key for recipient '%s'" % to) + gpg_to.append( (to, conf.get_item('enc_domain_keymap', domain)) ) continue else: - log("Key '%s' in encrypt domain keymap not found in keyring for email address '%s'." % (cfg['enc_domain_keymap'][domain], to)) + LOG.info("Key '%s' in encrypt domain keymap not found in keyring for email address '%s'." % (conf.get_item('enc_domain_keymap', domain), to)) # At this point no key has been found - if verbose: - log("Recipient (%s) not in PGP domain list for encrypting." % to) + LOG.debug("Recipient (%s) not in PGP domain list for encrypting." % to) ungpg_to.append(to) - if gpg_to != list(): - log("Encrypting email to: %s" % ' '.join( x[0] for x in gpg_to )) + if gpg_to: + LOG.info("Encrypting email to: %s" % ' '.join( x[0] for x in gpg_to )) # Getting PGP style for recipient gpg_to_smtp_mime = list() @@ -131,30 +104,30 @@ def gpg_encrypt( raw_message, recipients ): for rcpt in gpg_to: # Checking pre defined styles in settings first - if get_bool_from_cfg('pgp_style', rcpt[0], 'mime'): + if conf.config_item_equals('pgp_style', rcpt[0], 'mime'): gpg_to_smtp_mime.append(rcpt[0]) gpg_to_cmdline_mime.extend(rcpt[1].split(',')) - elif get_bool_from_cfg('pgp_style', rcpt[0], 'inline'): + elif conf.config_item_equals('pgp_style', rcpt[0], 'inline'): gpg_to_smtp_inline.append(rcpt[0]) gpg_to_cmdline_inline.extend(rcpt[1].split(',')) else: # Log message only if an unknown style is defined - if get_bool_from_cfg('pgp_style', rcpt[0]): - log("Style %s for recipient %s is not known. Use default as fallback." % (cfg['pgp_style'][rcpt[0]], rcpt[0])) + if conf.config_item_set('pgp_style', rcpt[0]): + LOG.info("Style %s for recipient %s is not known. Use default as fallback." % (conf.get_item("pgp_style", rcpt[0]), rcpt[0])) # If no style is in settings defined for recipient, use default from settings - if get_bool_from_cfg('default', 'mime_conversion', 'yes'): + if conf.config_item_equals('default', 'mime_conversion', 'yes'): gpg_to_smtp_mime.append(rcpt[0]) gpg_to_cmdline_mime.extend(rcpt[1].split(',')) else: gpg_to_smtp_inline.append(rcpt[0]) gpg_to_cmdline_inline.extend(rcpt[1].split(',')) - if gpg_to_smtp_mime != list(): + if gpg_to_smtp_mime: # Encrypt mail with PGP/MIME raw_message_mime = copy.deepcopy(raw_message) - if get_bool_from_cfg('default', 'add_header', 'yes'): + if conf.config_item_equals('default', 'add_header', 'yes'): raw_message_mime['X-GPG-Mailgate'] = 'Encrypted by GPG Mailgate' if 'Content-Transfer-Encoding' in raw_message_mime: @@ -167,11 +140,11 @@ def gpg_encrypt( raw_message, recipients ): send_msg( raw_message_mime.as_string(), gpg_to_smtp_mime ) - if gpg_to_smtp_inline != list(): + if gpg_to_smtp_inline: # Encrypt mail with PGP/INLINE raw_message_inline = copy.deepcopy(raw_message) - if get_bool_from_cfg('default', 'add_header', 'yes'): + if conf.config_item_equals('default', 'add_header', 'yes'): raw_message_inline['X-GPG-Mailgate'] = 'Encrypted by GPG Mailgate' if 'Content-Transfer-Encoding' in raw_message_inline: @@ -247,21 +220,20 @@ def encrypt_all_payloads_mime( message, gpg_to_cmdline ): return [ submsg1, encrypt_payload(submsg2, gpg_to_cmdline, check_nested) ] def encrypt_payload( payload, gpg_to_cmdline, check_nested = True ): + global LOG raw_payload = payload.get_payload(decode=True) if check_nested and b"-----BEGIN PGP MESSAGE-----" in raw_payload and b"-----END PGP MESSAGE-----" in raw_payload: - if verbose: - log("Message is already pgp encrypted. No nested encryption needed.") + LOG.debug("Message is already pgp encrypted. No nested encryption needed.") return payload - # No check is needed for cfg['gpg']['keyhome'] as this is already done in method gpg_encrypt - gpg = GnuPG.GPGEncryptor( cfg['gpg']['keyhome'], gpg_to_cmdline, payload.get_content_charset() ) + # No check is needed for conf.get_item('gpg', 'keyhome') as this is already done in method gpg_encrypt + gpg = GnuPG.GPGEncryptor( conf.get_item('gpg', 'keyhome'), gpg_to_cmdline, payload.get_content_charset() ) gpg.update( raw_payload ) encrypted_data, returncode = gpg.encrypt() - if verbose: - log("Return code from encryption=%d (0 indicates success)." % returncode) + LOG.debug("Return code from encryption=%d (0 indicates success)." % returncode) if returncode != 0: - log("Encrytion failed with return code %d. Encryption aborted." % returncode) + LOG.info("Encrytion failed with return code %d. Encryption aborted." % returncode) return payload payload.set_payload( encrypted_data ) @@ -281,12 +253,13 @@ def encrypt_payload( payload, gpg_to_cmdline, check_nested = True ): return payload def smime_encrypt( raw_message, recipients ): + global LOG - if not get_bool_from_cfg('smime', 'cert_path'): - log("No valid path for S/MIME certs found in config file. S/MIME encryption aborted.") + if not conf.config_item_set('smime', 'cert_path'): + LOG.info("No valid path for S/MIME certs found in config file. S/MIME encryption aborted.") return recipients - cert_path = cfg['smime']['cert_path']+"/" + cert_path = conf.get_item('smime', 'cert_path')+"/" s = SMIME.SMIME() sk = X509.X509_Stack() smime_to = list() @@ -297,15 +270,14 @@ def smime_encrypt( raw_message, recipients ): if not (cert_and_email is None): (to_cert, normal_email) = cert_and_email - if verbose: - log("Found cert " + to_cert + " for " + addr + ": " + normal_email) + LOG.debug("Found cert " + to_cert + " for " + addr + ": " + normal_email) smime_to.append(addr) x509 = X509.load_cert(to_cert, format=X509.FORMAT_PEM) sk.push(x509) else: unsmime_to.append(addr) - if smime_to != list(): + if smime_to: s.set_x509_stack(sk) s.set_cipher(SMIME.Cipher('aes_192_cbc')) p7 = s.encrypt( BIO.MemoryBuffer( raw_message.as_string() ) ) @@ -320,22 +292,21 @@ def smime_encrypt( raw_message, recipients ): if raw_message['Subject']: out.write('Subject: '+ raw_message['Subject'] + '\n') - if get_bool_from_cfg('default', 'add_header', 'yes'): + if conf.config_item_equals('default', 'add_header', 'yes'): out.write('X-GPG-Mailgate: Encrypted by GPG Mailgate\n') s.write(out, p7) - if verbose: - log("Sending message from " + from_addr + " to " + str(smime_to)) + LOG.debug("Sending message from " + from_addr + " to " + str(smime_to)) send_msg(out.read(), smime_to) - if unsmime_to != list(): - if verbose: - log("Unable to find valid S/MIME certificates for " + str(unsmime_to)) + if unsmime_to: + LOG.debug("Unable to find valid S/MIME certificates for " + str(unsmime_to)) return unsmime_to def get_cert_for_email( to_addr, cert_path ): + global LOG files_in_directory = os.listdir(cert_path) for filename in files_in_directory: @@ -343,7 +314,7 @@ def get_cert_for_email( to_addr, cert_path ): if not os.path.isfile(file_path): continue - if get_bool_from_cfg('default', 'mail_case_insensitive', 'yes'): + if conf.config_item_equals('default', 'mail_case_insensitive', 'yes'): if filename.lower() == to_addr: return (file_path, to_addr) else: @@ -353,26 +324,14 @@ def get_cert_for_email( to_addr, cert_path ): multi_email = re.match('^([^\+]+)\+([^@]+)@(.*)$', to_addr) if multi_email: fixed_up_email = "%s@%s" % (multi_email.group(1), multi_email.group(3)) - if verbose: - log("Multi-email %s converted to %s" % (to_addr, fixed_up_email)) + LOG.debug("Multi-email %s converted to %s" % (to_addr, fixed_up_email)) return get_cert_for_email(fixed_up_email) return None -def get_bool_from_cfg( section, key = None, evaluation = None ): - - if not (key is None) and not (evaluation is None): - return section in cfg and cfg[section].get(key) == evaluation - - elif not (key is None) and (evaluation is None): - return section in cfg and not (cfg[section].get(key) is None) - - else: - return section in cfg - def sanitize_case_sense( address ): - if get_bool_from_cfg('default', 'mail_case_insensitive', 'yes'): + if conf.config_item_equals('default', 'mail_case_insensitive', 'yes'): address = address.lower() else: if isinstance(address, str): @@ -406,22 +365,24 @@ def get_first_payload( payloads ): return payloads def send_msg( message, recipients ): + global LOG recipients = [_f for _f in recipients if _f] if recipients: - if not (get_bool_from_cfg('relay', 'host') and get_bool_from_cfg('relay', 'port')): - log("Missing settings for relay. Sending email aborted.") + if not (conf.config_item_set('relay', 'host') and conf.config_item_set('relay', 'port')): + LOG.warning("Missing settings for relay. Sending email aborted.") return None - log("Sending email to: <%s>" % '> <'.join( recipients )) - relay = (cfg['relay']['host'], int(cfg['relay']['port'])) + LOG.info("Sending email to: <%s>" % '> <'.join( recipients )) + relay = (conf.get_item('relay', 'host'), int(conf.get_item('relay', 'port'))) smtp = smtplib.SMTP(relay[0], relay[1]) - if 'relay' in cfg and 'starttls' in cfg['relay'] and cfg['relay']['starttls'] == 'yes': + if conf.config_item_equals('relay', 'starttls', 'yes'): smtp.starttls() smtp.sendmail( from_addr, recipients, message ) else: - log("No recipient found") + LOG.info("No recipient found") def sort_recipients( raw_message, from_addr, to_addrs ): + global LOG recipients_left = list() for recipient in to_addrs: @@ -430,37 +391,46 @@ def sort_recipients( raw_message, from_addr, to_addrs ): # There is no need for nested encryption first_payload = get_first_payload(raw_message) if first_payload.get_content_type() == 'application/pkcs7-mime': - if verbose: - log("Message is already encrypted with S/MIME. Encryption aborted.") + LOG.debug("Message is already encrypted with S/MIME. Encryption aborted.") send_msg(raw_message.as_string(), recipients_left) return first_payload = first_payload.get_payload(decode=True) if b"-----BEGIN PGP MESSAGE-----" in first_payload and b"-----END PGP MESSAGE-----" in first_payload: - if verbose: - log("Message is already encrypted as PGP/INLINE. Encryption aborted.") + LOG.debug("Message is already encrypted as PGP/INLINE. Encryption aborted.") send_msg(raw_message.as_string(), recipients_left) return if raw_message.get_content_type() == 'multipart/encrypted': - if verbose: - log("Message is already encrypted. Encryption aborted.") + LOG.debug("Message is already encrypted. Encryption aborted.") send_msg(raw_message.as_string(), recipients_left) return # Encrypt mails for recipients with known public PGP keys recipients_left = gpg_encrypt(raw_message, recipients_left) - if recipients_left == list(): + if not recipients_left: return # Encrypt mails for recipients with known S/MIME certificate recipients_left = smime_encrypt(raw_message, recipients_left) - if recipients_left == list(): + if not recipients_left: return # Send out mail to recipients which are left send_msg(raw_message.as_string(), recipients_left) +conf.load_config() +lacre.init_logging(conf.get_item('logging', 'config')) + + +LOG = logging.getLogger(__name__) + +# Read e-mail from stdin +raw = sys.stdin.read() +raw_message = email.message_from_string( raw ) +from_addr = raw_message['From'] +to_addrs = sys.argv[1:] + # Let's start sort_recipients(raw_message, from_addr, to_addrs) diff --git a/lacre/__init__.py b/lacre/__init__.py new file mode 100644 index 0000000..44fb255 --- /dev/null +++ b/lacre/__init__.py @@ -0,0 +1,36 @@ +"""Lacre --- the Postfix mail filter encrypting incoming email +""" + +import logging +import logging.config + +# Following structure configures logging iff a file-based configuration cannot +# be performed. It only sets up a syslog handler, so that the admin has at +# least some basic information. +FAIL_OVER_LOGGING_CONFIG = { + 'version': 1, + 'formatters': { + 'sysfmt': { + 'format': '%(asctime)s %(module)s %(message)s', + 'datefmt': '%Y-%m-%d %H:%M:%S' + }, + }, + 'handlers': { + 'syslog': { + 'class': 'logging.handlers.SysLogHandler', + 'level': 'INFO', + 'formatter': 'sysfmt' + } + }, + 'root': { + 'level': 'INFO', + 'handlers': ['syslog'] + } +} + +def init_logging(config_filename): + if config_filename is not None: + logging.config.fileConfig(config_filename) + else: + logging.config.dictConfig(FAIL_OVER_LOGGING_CONFIG) + logging.warning('Lacre logging configuration missing, using syslog as default') diff --git a/lacre/config.py b/lacre/config.py new file mode 100644 index 0000000..0b5fb77 --- /dev/null +++ b/lacre/config.py @@ -0,0 +1,70 @@ +"""Lacre configuration + +Routines defined here are responsible for processing configuration. +""" + +from configparser import RawConfigParser + +import os + + +# Environment variable name we read to retrieve configuration path. This is to +# enable non-root users to set up and run GPG Mailgate and to make the software +# testable. +CONFIG_PATH_ENV = "GPG_MAILGATE_CONFIG" + +# Global dict to keep configuration parameters. It's hidden behind several +# utility functions to make it easy to replace it with ConfigParser object in +# the future. +cfg = dict() + +def load_config() -> dict: + """Parses configuration file. + + If environment variable identified by CONFIG_PATH_ENV + variable is set, its value is taken as a configuration file + path. Otherwise, the default is taken + ('/etc/gpg-mailgate.conf'). + """ + configFile = os.getenv(CONFIG_PATH_ENV, '/etc/gpg-mailgate.conf') + + parser = read_config(configFile) + + global cfg + cfg = copy_to_dict(parser) + return cfg + +def read_config(fileName) -> RawConfigParser: + cp = RawConfigParser() + cp.read(fileName) + + return cp + +def copy_to_dict(confParser) -> dict: + config = dict() + + for sect in confParser.sections(): + config[sect] = dict() + for (name, value) in confParser.items(sect): + config[sect][name] = value + + return config + +def get_item(section, key, empty_value = None): + global cfg + if config_item_set(section, key): + return cfg[section][key] + else: + return empty_value + +def has_section(section) -> bool: + global cfg + return section in cfg + +def config_item_set(section, key) -> bool: + global cfg + return section in cfg and (key in cfg[section]) and not (cfg[section][key] is None) + +def config_item_equals(section, key, value) -> bool: + global cfg + return section in cfg and key in cfg[section] and cfg[section][key] == value diff --git a/register-handler.py b/register-handler.py index f4c1e57..6475a5f 100644 --- a/register-handler.py +++ b/register-handler.py @@ -7,37 +7,28 @@ from M2Crypto import BIO, Rand, SMIME, X509 from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart -# Read configuration from /etc/gpg-mailgate.conf -_cfg = RawConfigParser() -_cfg.read('/etc/gpg-mailgate.conf') -cfg = dict() -for sect in _cfg.sections(): - cfg[sect] = dict() - for (name, value) in _cfg.items(sect): - cfg[sect][name] = value +import logging -def log(msg): - if 'logging' in cfg and 'file' in cfg['logging']: - if cfg['logging']['file'] == "syslog": - syslog.syslog(syslog.LOG_INFO | syslog.LOG_MAIL, msg) - else: - logfile = open(cfg['logging']['file'], 'a') - logfile.write(msg + "\n") - logfile.close() - -CERT_PATH = cfg['smime']['cert_path']+"/" +import lacre +import lacre.config as conf def send_msg( message, from_addr, recipients = None ): - - if 'relay' in cfg and 'host' in cfg['relay'] and 'enc_port' in cfg['relay']: - relay = (cfg['relay']['host'], int(cfg['relay']['enc_port'])) + if conf.config_item_set('relay', 'host') and conf.config_item_set('relay', 'enc_port'): + relay = (conf.get_item('relay', 'host'), int(conf.get_item('relay', 'enc_port'))) smtp = smtplib.SMTP(relay[0], relay[1]) smtp.sendmail( from_addr, recipients, message.as_string() ) else: - log("Could not send mail due to wrong configuration") + LOG.info("Could not send mail due to wrong configuration") if __name__ == "__main__": # try: + conf.load_config() + lacre.init_logging(conf.get_item('logging', 'config')) + + LOG = logging.getLogger(__name__) + + CERT_PATH = conf.get_item('smime', 'cert_path') + '/' + # Read e-mail from stdin raw = sys.stdin.read() register_msg = email.message_from_string( raw ) @@ -63,18 +54,18 @@ if __name__ == "__main__": break if sign_part == None: - log("Unable to find PKCS7 signature or public PGP key in registration email") + LOG.info("Unable to find PKCS7 signature or public PGP key in registration email") - failure_msg = file( cfg['mailregister']['mail_templates'] + "/registrationError.md").read() + failure_msg = file( conf.get_item('mailregister', 'mail_templates') + "/registrationError.md").read() msg = MIMEMultipart("alternative") - msg["From"] = cfg['mailregister']['register_email'] + msg["From"] = conf.get_item('mailregister', 'register_email') msg["To"] = from_addr msg["Subject"] = "S/MIME / OpenPGP registration failed" msg.attach(MIMEText(failure_msg, 'plain')) msg.attach(MIMEText(markdown.markdown(failure_msg), 'html')) - send_msg(msg, cfg['mailregister']['register_email'], [from_addr]) + send_msg(msg, conf.get_item('mailregister', 'register_email'), [from_addr]) sys.exit(0) if sign_type == 'smime': @@ -105,42 +96,42 @@ if __name__ == "__main__": # format in user-specific data # sending success mail only for S/MIME as GPGMW handles this on its own - success_msg = file(cfg['mailregister']['mail_templates']+"/registrationSuccess.md").read() + success_msg = file(conf.get_item('mailregister', 'mail_templates')+"/registrationSuccess.md").read() success_msg = success_msg.replace("[:FROMADDRESS:]", from_addr) msg = MIMEMultipart("alternative") - msg["From"] = cfg['mailregister']['register_email'] + msg["From"] = conf.get_item('mailregister', 'register_email') msg["To"] = from_addr msg["Subject"] = "S/MIME certificate registration succeeded" msg.attach(MIMEText(success_msg, 'plain')) msg.attach(MIMEText(markdown.markdown(success_msg), 'html')) - send_msg(msg, cfg['mailregister']['register_email'], [from_addr]) + send_msg(msg, conf.get_item('mailregister', 'register_email'), [from_addr]) - log("S/MIME Registration succeeded") + LOG.info("S/MIME Registration succeeded") elif sign_type == 'pgp': # send POST to gpg-mailgate webpanel sig = sign_part payload = {'email': from_addr, 'key': sig} - r = requests.post(cfg['mailregister']['webpanel_url'], data=payload) + r = requests.post(conf.get_item('mailregister', 'webpanel_url'), data=payload) if r.status_code != 200: - log("Could not hand registration over to GPGMW. Error: %s" % r.status_code) - error_msg = file(cfg['mailregister']['mail_templates']+"/gpgmwFailed.md").read() + LOG.info("Could not hand registration over to GPGMW. Error: %s" % r.status_code) + error_msg = open(conf.get_item('mailregister', 'mail_templates')+"/gpgmwFailed.md").read() error_msg = error_msg.replace("[:FROMADDRESS:]", from_addr) msg = MIMEMultipart("alternative") - msg["From"] = cfg['mailregister']['register_email'] + msg["From"] = conf.get_item('mailregister', 'register_email') msg["To"] = from_addr msg["Subject"] = "PGP key registration failed" msg.attach(MIMEText(error_msg, 'plain')) msg.attach(MIMEText(markdown.markdown(error_msg), 'html')) - send_msg(msg, cfg['mailregister']['register_email'], [from_addr]) + send_msg(msg, conf.get_item('mailregister', 'register_email'), [from_addr]) else: - log("PGP registration is handed over to GPGMW") + LOG.info("PGP registration is handed over to GPGMW") # except: -# log("Registration exception") +# LOG.info("Registration exception") # sys.exit(0) diff --git a/test/e2e.ini b/test/e2e.ini index 134a005..ba32999 100644 --- a/test/e2e.ini +++ b/test/e2e.ini @@ -35,6 +35,7 @@ e2e_log: test/logs/e2e.log e2e_log_format: %(asctime)s %(pathname)s:%(lineno)d %(levelname)s [%(funcName)s] %(message)s e2e_log_datefmt: %Y-%m-%d %H:%M:%S lacre_log: test/logs/gpg-mailgate.log +log_config: test/gpg-lacre-log.ini [case-1] descr: Clear text message to a user without a key diff --git a/test/e2e_test.py b/test/e2e_test.py index f937fa9..2898528 100644 --- a/test/e2e_test.py +++ b/test/e2e_test.py @@ -33,11 +33,10 @@ RELAY_SCRIPT = "test/relay.py" CONFIG_FILE = "test/gpg-mailgate.conf" def build_config(config): - cp = configparser.ConfigParser() + cp = configparser.RawConfigParser() cp.add_section("logging") - cp.set("logging", "file", config["log_file"]) - cp.set("logging", "verbose", "yes") + cp.set("logging", "config", config["log_config"]) cp.add_section("gpg") cp.set("gpg", "keyhome", config["gpg_keyhome"]) @@ -147,7 +146,7 @@ write_test_config(config_path, port = config.get("relay", "port"), gpg_keyhome = config.get("dirs", "keys"), smime_certpath = config.get("dirs", "certs"), - log_file = config.get("tests", "lacre_log")) + log_config = config.get("tests", "log_config")) for case_no in range(1, config.getint("tests", "cases")+1): case_name = f"case-{case_no}" diff --git a/test/gpg-lacre-log.ini b/test/gpg-lacre-log.ini new file mode 100644 index 0000000..ef33cfd --- /dev/null +++ b/test/gpg-lacre-log.ini @@ -0,0 +1,24 @@ +[loggers] +keys=root + +[logger_root] +level=NOTSET +handlers=lacrelog + +[handlers] +keys=lacrelog + +[formatters] +keys=postfixfmt + +[handler_lacrelog] +class=FileHandler +level=DEBUG +formatter=postfixfmt +args=('test/logs/lacre.log', 'a+') + +[formatter_postfixfmt] +format=%(asctime)s %(module)s[%(process)d]: %(message)s +datefmt=%b %e %H:%M:%S +style=% +validate=True diff --git a/test/relay.py b/test/relay.py index a8e3db7..7e850dc 100644 --- a/test/relay.py +++ b/test/relay.py @@ -11,6 +11,8 @@ import sys import socket +import logging + EXIT_UNAVAILABLE = 1 @@ -44,12 +46,17 @@ def serve(port): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: s.bind(localhost_at(port)) + logging.info(f"Listening on localhost, port {port}") s.listen(1) + logging.info("Listening...") except socket.error as e: print("Cannot connect", e) + logging.error(f"Cannot connect {e}") sys.exit(EXIT_UNAVAILABLE) + logging.debug("About to accept a connection...") (conn, addr) = s.accept() + logging.debug(f"Accepting connection from {conn}") conn.sendall(welcome(b"TEST SERVER")) receive_and_confirm(conn) # Ignore HELO/EHLO @@ -70,14 +77,24 @@ def serve(port): conn.close() + logging.debug(f"Received {len(message)} characters of data") + # Trim EOM marker as we're only interested in the message body. return message[:-len(EOM)] def error(msg, exit_code): + logging.error(msg) print("ERROR: %s" % (msg)) sys.exit(exit_code) +# filename is relative to where we run the tests from, i.e. the project root +# directory +logging.basicConfig(filename = 'test/logs/relay.log', + format = '%(asctime)s %(pathname)s:%(lineno)d %(levelname)s [%(funcName)s] %(message)s', + datefmt = '%Y-%m-%d %H:%M:%S', + level = logging.DEBUG) + if len(sys.argv) < 2: error("Usage: relay.py PORT_NUMBER", EXIT_UNAVAILABLE) diff --git a/test/schema.py b/test/schema.py index c0c40d7..5e99760 100644 --- a/test/schema.py +++ b/test/schema.py @@ -31,60 +31,60 @@ conn = test_db.connect() # Populate the database with dummy data conn.execute(gpgmw_keys.insert(), [ - {"id": 1, "email": "alice@disposlab", "publickey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\ -\ -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\ -pw==\ -=Tbwz\ + {"id": 1, "email": "alice@disposlab", "publickey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\ +\n\ +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\n\ +pw==\n\ +=Tbwz\n\ -----END PGP PUBLIC KEY BLOCK-----\ ", "status": 0, "confirm": "", "time": None}, - {"id": 2, "email": "bob@disposlab", "publickey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\ -\ -mDMEYdTFkRYJKwYBBAHaRw8BAQdA2tgdP1pMt3cv3XAW7ov5AFn74mMZvyTksp9Q\ -eO1PkpK0GkJvYiBGb29iYXIgPGJvYkBkaXNwb3NsYWI+iJYEExYIAD4WIQQZz0tH\ -7MnEevqE1L2W85/aDjG7ZwUCYdTFkQIbAwUJA8JnAAULCQgHAgYVCgkICwIEFgID\ -AQIeAQIXgAAKCRCW85/aDjG7ZxVnAP49t7BU2H+/WCpa3fCAlMEcik82sU4p+U9D\ -pMsbjawwYgEA1SbA5CF835cMjoEufy1h+2M4T9gI/0X2lk8OAtwwggm4OARh1MXg\ -EgorBgEEAZdVAQUBAQdAUVNKx2OsGtNdRsnl3J/uv6obkUC0KcO4ikdRs+iejlMD\ -AQgHiHgEGBYIACAWIQQZz0tH7MnEevqE1L2W85/aDjG7ZwUCYdTF4AIbDAAKCRCW\ -85/aDjG7Z039APwLGP5ibqCC9yIr4YVbdWff1Ch+2C91MR2ObF93Up9+ogD8D2zd\ -OjjB6xRD0Q2FN+alsNGCtdutAs18AZ5l33RMzws=\ -=wWoq\ + {"id": 2, "email": "bob@disposlab", "publickey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\ +\n\ +mDMEYdTFkRYJKwYBBAHaRw8BAQdA2tgdP1pMt3cv3XAW7ov5AFn74mMZvyTksp9Q\n\ +eO1PkpK0GkJvYiBGb29iYXIgPGJvYkBkaXNwb3NsYWI+iJYEExYIAD4WIQQZz0tH\n\ +7MnEevqE1L2W85/aDjG7ZwUCYdTFkQIbAwUJA8JnAAULCQgHAgYVCgkICwIEFgID\n\ +AQIeAQIXgAAKCRCW85/aDjG7ZxVnAP49t7BU2H+/WCpa3fCAlMEcik82sU4p+U9D\n\ +pMsbjawwYgEA1SbA5CF835cMjoEufy1h+2M4T9gI/0X2lk8OAtwwggm4OARh1MXg\n\ +EgorBgEEAZdVAQUBAQdAUVNKx2OsGtNdRsnl3J/uv6obkUC0KcO4ikdRs+iejlMD\n\ +AQgHiHgEGBYIACAWIQQZz0tH7MnEevqE1L2W85/aDjG7ZwUCYdTF4AIbDAAKCRCW\n\ +85/aDjG7Z039APwLGP5ibqCC9yIr4YVbdWff1Ch+2C91MR2ObF93Up9+ogD8D2zd\n\ +OjjB6xRD0Q2FN+alsNGCtdutAs18AZ5l33RMzws=\n\ +=wWoq\n\ -----END PGP PUBLIC KEY BLOCK-----\ ", "status": 0, "confirm": "", "time": None}, {"id": 3, "email": "cecil@lacre.io", "publickey": "RUBBISH", "status": 0, "confirm": "", "time": None}