diff --git a/.gitignore b/.gitignore index 4152b0b..1012a61 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Created by https://www.gitignore.io site.retry +*.bak .vagrant .idea @@ -66,5 +67,3 @@ target/ env/ *.deb - - diff --git a/.travis.yml b/.travis.yml index 8ab0903..341f80f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ install: before_script: - ./pylint-check.py script: + - mkdir -p ../logs - py.test -v --cov postmaster --cov-report term-missing tests/ after_success: - coveralls diff --git a/config.py b/config.py index 1fff187..eb38172 100644 --- a/config.py +++ b/config.py @@ -17,6 +17,7 @@ class BaseConfiguration(object): SQLALCHEMY_DATABASE_URI = 'mysql://root:vagrant@localhost:3306/servermail' basedir = path.abspath(path.dirname(__file__)) SQLALCHEMY_MIGRATE_REPO = path.join(basedir, 'db/migrations') + LOG_LOCATION = '/opt/postmaster/logs/postmaster.log' class TestConfiguration(BaseConfiguration): diff --git a/docs/ChangeLog.md b/docs/ChangeLog.md index 898a731..5c8252c 100644 --- a/docs/ChangeLog.md +++ b/docs/ChangeLog.md @@ -1,5 +1,9 @@ ### Unreleased +BACKWARDS INCOMPATIBILITIES / NOTES: + +* 'Log File' config option is now baked into the application config and cannot be set in the api / webui / database. Use `python manage.py setlogfile ` or edit config.py to change the log file location. See [GH-128]. + Features: * Added the ability to install PostMaster via a deb package [GH-111] diff --git a/docs/Configuration/CommandLineConfiguration.md b/docs/Configuration/CommandLineConfiguration.md index 491fbb6..e69d36b 100644 --- a/docs/Configuration/CommandLineConfiguration.md +++ b/docs/Configuration/CommandLineConfiguration.md @@ -1,11 +1,11 @@ ### Before You Start 1. Logged into the server hosting PostMaster as root or as an administrator, enter the Python virtual environment (if necessary, replace the path with your install location): - + Linux: source /opt/postmaster/env/bin/activate Windows: C:\PostMaster\env\Scripts\activate.ps1 - + 2. Once you've entered the Python virtual environment, navigate to the location of manage.py (replace /opt/postmaster/git with your install location): cd /opt/postmaster/git @@ -22,6 +22,8 @@ Use the following commands to restore the proper permissions on the PostMaster f ### Command Line Commands +**setlogfile** sets the location of the logfile. The default is `/opt/postmaster/logs/postmaster.log`. + **setdburi** sets the MySQL database URI that PostMaster uses to connect to the MySQL server used by your mail server. **createdb** runs a database migration, and configures the default configuration settings if they are missing on the database specified using the "setdburi" command. diff --git a/docs/Configuration/ConfigurationsPage.md b/docs/Configuration/ConfigurationsPage.md index ea35866..7470e08 100644 --- a/docs/Configuration/ConfigurationsPage.md +++ b/docs/Configuration/ConfigurationsPage.md @@ -1,12 +1,8 @@ **Minimum Password Length** specifies the minimum password length that all mail users and administrators must adhere to. -**Login Auditing** determines whether login and logout events are recorded in the log file. In order to enable this, the "Log File" setting must be configured. +**Login Auditing** determines whether login and logout events are recorded in the log file. **Mail Database Auditing** determines whether changes to domains, users, aliases, administrators, and configuration settings should be recorded in the log file. -In order to enable this, the "Log File" setting must be configured. - -**Log File** specifies a file path to where the log file should be. The path can be relative or absolute, but the file location must be writable by the web server. -When configuring this setting, "Mail Database Auditing" will be turned on automatically. **Enable LDAP Authentication** determines whether Active Directory LDAP authentication is turned on or off. In order to enable LDAP authentication, the "AD Server LDAP String", "AD Domain", and "AD PostMaster Group" must be configured. diff --git a/docs/Installation/WindowsServer2012R2.md b/docs/Installation/WindowsServer2012R2.md index 7a284ce..f86904c 100644 --- a/docs/Installation/WindowsServer2012R2.md +++ b/docs/Installation/WindowsServer2012R2.md @@ -160,20 +160,24 @@ To start the migration, run the following command: python manage.py generatekey -27. You may now exit the Python virtual environment: +27. By deafult, PostMaster logs to a Linux based path, run the following command to change the log to the text file created in step 11: + + python manage.py setlogfile "$env:SystemDrive\PostMaster\logs\postmaster.log" + +28. You may now exit the Python virtual environment: deactivate -28. Restart IIS to make sure all the changes take effect: +29. Restart IIS to make sure all the changes take effect: iisreset -29. At this point it is highly recommended that you implement SSL before using PostMaster in production. +30. At this point it is highly recommended that you implement SSL before using PostMaster in production. -29. PostMaster should now be running. Simply use the username "admin" and the password "PostMaster" to login. +31. PostMaster should now be running. Simply use the username "admin" and the password "PostMaster" to login. You can change your username and password from Manage -> Administrators. -30. Please keep in mind that the C:\PostMaster\git\db\migrations folder should be backed up after installation/updates. +32. Please keep in mind that the C:\PostMaster\git\db\migrations folder should be backed up after installation/updates. This is because PostMaster uses database migrations to safely upgrade the database schema, and this folder contains auto-generated database migration scripts that allow you to revert back if a database migration ever failed. If this folder is missing, PostMaster can't tell what state your database is in, and therefore, cannot revert back. diff --git a/manage.py b/manage.py index 845728a..2637d8d 100644 --- a/manage.py +++ b/manage.py @@ -77,5 +77,17 @@ def setdburi(uri): else: print(line.rstrip()) + +@manager.command +def setlogfile(filepath): + """Replaces the BaseConfiguration LOG_LOCATION in config.py with one supplied""" + base_config_set = False + for line in fileinput.input('config.py', inplace=True, backup='.bak'): + if not base_config_set and 'LOG_LOCATION' in line: + print(sub(r'(?<=LOG_LOCATION = \')(.+)(?=\')', filepath, line.rstrip())) + base_config_set = True + else: + print(line.rstrip()) + if __name__ == "__main__": manager.run() diff --git a/postmaster/apiv1/utils.py b/postmaster/apiv1/utils.py index deb0da7..53c145e 100644 --- a/postmaster/apiv1/utils.py +++ b/postmaster/apiv1/utils.py @@ -9,7 +9,7 @@ from mmap import mmap from json import loads from ..errors import ValidationError -from postmaster import db +from postmaster import app from postmaster.models import Configs @@ -26,24 +26,14 @@ def is_file_writeable(file): def is_config_update_valid(setting, value, valid_value_regex): """ A helper function for the update_config function on the /configs/ PUT route. - A bool is returned based on if the users input is valid. + A bool is returned based on if the user's input is valid. """ if match(valid_value_regex, value): - - if setting == 'Log File': - if not is_file_writeable(value): - raise ValidationError('The specified log path is not writable') - else: - # Enables Mail Database Auditing when the log file is set - mail_db_auditing = Configs.query.filter_by(setting='Mail Database Auditing').first() - mail_db_auditing.value = 'True' - db.session.add(mail_db_auditing) - - elif setting == 'Login Auditing' or setting == 'Mail Database Auditing': - log_file = Configs.query.filter_by(setting='Log File').first().value - - if not log_file: - raise ValidationError('The log file must be set before auditing can be enabled') + if setting == 'Login Auditing' or setting == 'Mail Database Auditing': + log_path = app.config.get('LOG_LOCATION') + if not log_path or not is_file_writeable(log_path): + raise ValidationError('The log could not be written to "{0}". ' + 'Verify that the path exists and is writeable.'.format(os.path.abspath(log_path))) elif setting == 'Enable LDAP Authentication': ldap_string = Configs.query.filter_by(setting='AD Server LDAP String').first().value @@ -66,11 +56,12 @@ def is_config_update_valid(setting, value, valid_value_regex): raise ValidationError('An invalid setting value was supplied') + def get_logs_dict(numLines=50, reverseOrder=False): """ Returns the JSON formatted log file as a dict """ - logPath = Configs.query.filter_by(setting='Log File').first().value + logPath = app.config.get('LOG_LOCATION') if logPath and os.path.exists(logPath): logFile = open(logPath, mode='r+') diff --git a/postmaster/static/js/configs.js b/postmaster/static/js/configs.js index 98ac87d..49cf085 100644 --- a/postmaster/static/js/configs.js +++ b/postmaster/static/js/configs.js @@ -2,7 +2,6 @@ function configEventListeners () { var configBoolItems = $('a.configBool'); var configTextItems = $('a.configText'); - var configLogFile = $('a.configLogFile'); configBoolItems.unbind(); configBoolItems.tooltip(); @@ -22,19 +21,6 @@ function configEventListeners () { $(this).html(filterText(value)); } }); - - configLogFile.unbind(); - configLogFile.tooltip(); - configLogFile.editable({ - success: function () { - // Sets the Mail Database Auditing to True in the UI - $('td:contains("Mail Database Auditing")').next('td').children('a').text('True'); - addStatusMessage('success', 'The setting was changed successfully'); - }, - display: function (value) { - $(this).html(filterText(value)); - } - }); } @@ -61,10 +47,7 @@ function fillInTable () { 'Enable LDAP Authentication' ]; - if (item.setting == 'Log File') { - var cssClass = 'configLogFile' - } - else if($.inArray(item.setting, boolConfigItems) != -1) { + if($.inArray(item.setting, boolConfigItems) != -1) { var cssClass = 'configBool' } else { diff --git a/postmaster/utils.py b/postmaster/utils.py index 9a56590..d7454bd 100644 --- a/postmaster/utils.py +++ b/postmaster/utils.py @@ -9,9 +9,9 @@ from re import search, sub, IGNORECASE from json import dumps from datetime import datetime -from os import getcwd +from os import path from wtforms.validators import StopValidation as WtfStopValidation -from postmaster import db, models, bcrypt +from postmaster import app, db, models, bcrypt from postmaster.errors import ValidationError @@ -32,11 +32,10 @@ def login_auditing_enabled(): def json_logger(category, admin, message): - """ - Takes a category (typically error or audit), a log message and the responsible + """ Takes a category (typically error or audit), a log message and the responsible user. It then appends it with an ISO 8601 UTC timestamp to a JSON formatted log file """ - log_path = models.Configs.query.filter_by(setting='Log File').first().value + log_path = app.config.get('LOG_LOCATION') if log_path and ((category == 'error') or (category == 'audit' and maildb_auditing_enabled()) or (category == 'auth' and login_auditing_enabled())): @@ -52,10 +51,8 @@ def json_logger(category, admin, message): sort_keys=True))) log_file.close() except IOError: - raise ValidationError( - 'The log could not be written to "{0}". \ - Verify that the path exists and is writeable.'.format( - getcwd().replace('\\', '/') + '/' + log_path)) + raise ValidationError('The log could not be written to "{0}". ' + 'Verify that the path exists and is writeable.'.format(path.abspath(log_path))) def add_default_configuration_settings(): @@ -83,12 +80,6 @@ def add_default_configuration_settings(): mail_db_auditing.regex = '^(True|False)$' db.session.add(mail_db_auditing) - if not models.Configs.query.filter_by(setting='Log File').first(): - log_file = models.Configs() - log_file.setting = 'Log File' - log_file.regex = '^(.+)$' - db.session.add(log_file) - if not models.Configs.query.filter_by(setting='Enable LDAP Authentication').first(): ldap_auth = models.Configs() ldap_auth.setting = 'Enable LDAP Authentication' diff --git a/tests/api/test_api_functions.py b/tests/api/test_api_functions.py index 52835e6..911555d 100644 --- a/tests/api/test_api_functions.py +++ b/tests/api/test_api_functions.py @@ -1,9 +1,11 @@ import string import random import json -from postmaster import db +from mock import patch +from postmaster import app, db from postmaster.models import Configs + class TestMailDbFunctions: def test_aliases_get(self, loggedin_client): @@ -321,8 +323,8 @@ def test_configs_get_all(self, loggedin_client): assert rv.status_code == 200 def test_configs_update_pass(self, loggedin_client): - rv = loggedin_client.put("/api/v1/configs/2", data=json.dumps( - {"value": "True"})) + rv = loggedin_client.put("/api/v1/configs/7", data=json.dumps( + {"value": "An Admin Group"})) assert rv.status_code == 200 def test_configs_update_fail(self, loggedin_client): @@ -331,6 +333,36 @@ def test_configs_update_fail(self, loggedin_client): assert rv.status_code == 400 assert 'An invalid setting value was supplied' in rv.data + @patch('os.access', return_value=False) + def test_configs_enable_login_auditing_log_write_fail(self, mock_os_access, loggedin_client): + rv = loggedin_client.put("/api/v1/configs/2", data=json.dumps( + {"value": "True"})) + assert rv.status_code == 400 + assert 'The log could not be written to' in rv.data + + @patch('os.access', return_value=True) + def test_configs_enable_login_auditing_log_write_pass(self, mock_os_access, loggedin_client): + rv = loggedin_client.put("/api/v1/configs/2", data=json.dumps( + {"value": "True"})) + assert rv.status_code == 200 + + @patch('os.access', return_value=False) + def test_configs_enable_maildb_auditing_log_write_fail(self, mock_os_access, loggedin_client): + rv = loggedin_client.put("/api/v1/configs/3", data=json.dumps( + {"value": "True"})) + assert rv.status_code == 400 + assert 'The log could not be written to' in rv.data + + @patch('os.access', return_value=True) + def test_configs_enable_maildb_auditing_log_write_pass(self, mock_os_access, loggedin_client, tmpdir): + log_file = tmpdir.join('postmaster.log') + app.config['LOG_LOCATION'] = str(log_file) + rv = loggedin_client.put("/api/v1/configs/3", data=json.dumps( + {"value": "True"})) + # Clean up the temp directory created by the test + tmpdir.remove() + assert rv.status_code == 200 + def test_configs_min_pwd_update_pass(self, loggedin_client): rv = loggedin_client.put("/api/v1/configs/1", data=json.dumps( {"value": "7"})) @@ -342,15 +374,6 @@ def test_configs_min_pwd_update_fail(self, loggedin_client): assert rv.status_code == 400 assert 'An invalid minimum password length was supplied.' in rv.data - def test_configs_update_log_file_fail(self, loggedin_client): - """ Tests the update_config function (PUT route for configs) when a new log file - path is specified but isn't writeable. A return value of an error is expected. - """ - rv = loggedin_client.put("/api/v1/configs/4", data=json.dumps( - {"value": "s0m3NonExistentDir/new_logfile.txt"})) - assert rv.status_code == 400 - assert 'The specified log path is not writable' in rv.data - def test_configs_update_enable_ldap_no_server(self, loggedin_client): """ Tests the update_config function (PUT route for configs) when LDAP is set to enabled but an LDAP server is not configured. A return value of an error is expected. @@ -362,7 +385,7 @@ def test_configs_update_enable_ldap_no_server(self, loggedin_client): db.session.add(ldap_string) db.session.commit() - rv = loggedin_client.put("/api/v1/configs/5", data=json.dumps( + rv = loggedin_client.put("/api/v1/configs/4", data=json.dumps( {"value": "True"})) # Reverts to the previous AD Server LDAP String @@ -384,7 +407,7 @@ def test_configs_update_empty_ldap_server_when_ldap_enabled(self, loggedin_clien db.session.add(ldap_enabled) db.session.commit() - rv = loggedin_client.put("/api/v1/configs/6", data=json.dumps( + rv = loggedin_client.put("/api/v1/configs/5", data=json.dumps( {"value": ""})) # Reverts to the previous state @@ -394,45 +417,3 @@ def test_configs_update_empty_ldap_server_when_ldap_enabled(self, loggedin_clien assert rv.status_code == 400 assert 'LDAP authentication must be disabled when deleting LDAP configuration items' in rv.data - - def test_configs_update_auditing_with_no_log_file_fail(self, loggedin_client): - """ Tests the update_config function (PUT route for configs) to make sure - audit settings cannot be set when the log file path is not set. - """ - # Sets Login Auditing to False - login_auditing = Configs.query.filter_by(setting='Login Auditing').first() - old_login_auditing_value = login_auditing.value - login_auditing.value = 'False' - db.session.add(login_auditing) - # Sets Mail Database Auditing to False - mail_db_auditing = Configs.query.filter_by(setting='Mail Database Auditing').first() - old_mail_db_auditing = mail_db_auditing.value - mail_db_auditing.value = 'False' - db.session.add(mail_db_auditing) - # Sets the Log File to None - log_file = Configs.query.filter_by(setting='Log File').first() - old_log_file_value = log_file.value - log_file.value = None - db.session.add(log_file) - db.session.commit() - - # Attempts to enable Login Auditing - login_auditing_rv = loggedin_client.put("/api/v1/configs/2", data=json.dumps( - {"value": "True"})) - # Attempts to enable Mail Database Auditing - mail_db_auditing_rv = loggedin_client.put("/api/v1/configs/3", data=json.dumps( - {"value": "True"})) - - # Reverts changes made to the database previously - login_auditing.value = old_login_auditing_value - mail_db_auditing.value = old_mail_db_auditing - log_file.value = old_log_file_value - db.session.add(login_auditing) - db.session.add(mail_db_auditing) - db.session.add(log_file) - db.session.commit() - - assert login_auditing_rv.status_code == 400 - assert 'The log file must be set before auditing can be enabled' in login_auditing_rv.data - assert mail_db_auditing_rv.status_code == 400 - assert 'The log file must be set before auditing can be enabled' in mail_db_auditing_rv.data diff --git a/tests/conftest.py b/tests/conftest.py index c7c804b..a568b2e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,10 +10,8 @@ def initialize(): db.drop_all() db.create_all() add_default_configuration_settings() - config_maildb_auditing = models.Configs.query.filter_by(setting='Mail Database Auditing').first() - config_maildb_auditing.value = 'True' - config_log_path = models.Configs.query.filter_by(setting='Log File').first() - config_log_path.value = 'postmaster.log' + admin2 = models.Admins().from_json( + {'username': 'admin2', 'password': 'PostMaster2', 'name': 'Some Admin'}) enable_ldap_auth = models.Configs.query.filter_by(setting='Enable LDAP Authentication').first() enable_ldap_auth.value = 'True' ldap_server = models.Configs.query.filter_by(setting='AD Server LDAP String').first() @@ -24,8 +22,7 @@ def initialize(): ldap_admin_group.value = 'PostMaster Admins' try: - db.session.add(config_maildb_auditing) - db.session.add(config_log_path) + db.session.add(admin2) db.session.add(enable_ldap_auth) db.session.add(ldap_server) db.session.add(domain) @@ -76,8 +73,14 @@ def initialize(): return False -# Create a fresh database -initialize() + +# Reinitialize the database before each test +@pytest.yield_fixture(autouse=True) +def run_before_tests(): + # Code that runs before each test + initialize() + # A test function will be run at this point + yield @pytest.fixture(scope='module') diff --git a/tests/utils/test_utils_functions.py b/tests/utils/test_utils_functions.py index 3ab7b5e..22a8b82 100644 --- a/tests/utils/test_utils_functions.py +++ b/tests/utils/test_utils_functions.py @@ -1,5 +1,6 @@ import functools import pytest +from json import load from mockldap import MockLdap from mock import patch from postmaster import app @@ -17,11 +18,29 @@ def test_login_auditing_enabled(self): result = login_auditing_enabled() assert isinstance(result, bool) - def test_get_logs_dict(self): + def test_get_logs_dict(self, tmpdir): + # Staging a fake log file + log_file = tmpdir.join('postmaster.log') + log_file.write('{"admin": "admin", "category": "audit", "message": "The alias \\"rawr@postmaster.com\\" was created successfully", "timestamp": "2016-07-01T00:41:06.330000Z"}\n' + '{"admin": "admin", "category": "audit", "message": "The alias \\"rawr2@postmaster.com\\" was created successfully", "timestamp": "2016-07-01T00:42:06.330000Z"}\n') + app.config['LOG_LOCATION'] = str(log_file) result = get_logs_dict() + # Clean up the temp directory created by the test + tmpdir.remove() assert isinstance(result, dict) assert 'items' in result + def test_json_logger(self, tmpdir): + log_file = tmpdir.join('postmaster.log') + app.config['LOG_LOCATION'] = str(log_file) + json_logger('error', 'admin', 'This is a test error message') + log_contents = load(log_file) + # Clean up the temp directory created by the test + tmpdir.remove() + assert log_contents['admin'] == 'admin' + assert log_contents['message'] == 'This is a test error message' + assert log_contents['timestamp'] is not None + @patch('os.access', return_value=True) def test_is_file_writeable_existing_file(self, mock_access): """ Tests the is_file_writeable function when a file exists and is writable.