Repository: mail-in-a-box/mailinabox Branch: main Commit: de4ec82a5afa Files: 110 Total size: 832.9 KB Directory structure: gitextract_y6gyh9lx/ ├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── Vagrantfile ├── api/ │ ├── docs/ │ │ ├── generate-docs.sh │ │ └── template.hbs │ └── mailinabox.yml ├── conf/ │ ├── dovecot-mailboxes.conf │ ├── fail2ban/ │ │ ├── filter.d/ │ │ │ ├── dovecotimap.conf │ │ │ ├── miab-management-daemon.conf │ │ │ ├── miab-munin.conf │ │ │ ├── miab-owncloud.conf │ │ │ ├── miab-postfix-submission.conf │ │ │ └── miab-roundcube.conf │ │ └── jails.conf │ ├── ios-profile.xml │ ├── mailinabox.service │ ├── mozilla-autoconfig.xml │ ├── mta-sts.txt │ ├── munin.service │ ├── nginx-alldomains.conf │ ├── nginx-primaryonly.conf │ ├── nginx-ssl.conf │ ├── nginx-top.conf │ ├── nginx.conf │ ├── postfix_outgoing_mail_header_filters │ ├── sieve-spam.txt │ ├── www_default.html │ └── zpush/ │ ├── autodiscover_config.php │ ├── backend_caldav.php │ ├── backend_carddav.php │ ├── backend_combined.php │ └── backend_imap.php ├── management/ │ ├── auth.py │ ├── backup.py │ ├── cli.py │ ├── csr_country_codes.tsv │ ├── daemon.py │ ├── daily_tasks.sh │ ├── dns_update.py │ ├── email_administrator.py │ ├── mail_log.py │ ├── mailconfig.py │ ├── mfa.py │ ├── munin_start.sh │ ├── ssl_certificates.py │ ├── status_checks.py │ ├── templates/ │ │ ├── aliases.html │ │ ├── custom-dns.html │ │ ├── external-dns.html │ │ ├── index.html │ │ ├── login.html │ │ ├── mail-guide.html │ │ ├── mfa.html │ │ ├── munin.html │ │ ├── ssl.html │ │ ├── sync-guide.html │ │ ├── system-backup.html │ │ ├── system-status.html │ │ ├── users.html │ │ ├── web.html │ │ └── welcome.html │ ├── utils.py │ ├── web_update.py │ └── wsgi.py ├── pyproject.toml ├── security.md ├── setup/ │ ├── bootstrap.sh │ ├── dkim.sh │ ├── dns.sh │ ├── firstuser.sh │ ├── functions.sh │ ├── mail-dovecot.sh │ ├── mail-postfix.sh │ ├── mail-users.sh │ ├── management.sh │ ├── migrate.py │ ├── munin.sh │ ├── network-checks.sh │ ├── nextcloud.sh │ ├── preflight.sh │ ├── questions.sh │ ├── spamassassin.sh │ ├── ssl.sh │ ├── start.sh │ ├── system.sh │ ├── web.sh │ ├── webmail.sh │ └── zpush.sh ├── tests/ │ ├── fail2ban.py │ ├── pip-requirements.txt │ ├── test_dns.py │ ├── test_mail.py │ ├── test_smtp_server.py │ ├── tls.py │ └── tls_results.txt └── tools/ ├── archive_conf_files.sh ├── dns_update ├── editconf.py ├── mail.py ├── owncloud-restore.sh ├── owncloud-unlockadmin.sh ├── parse-nginx-log-bootstrap-accesses.py ├── readable_bash.py ├── ssl_cleanup └── web_update ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # EditorConfig helps developers define and maintain consistent # coding styles between different editors and IDEs # editorconfig.org root = true [*] indent_style = space indent_size = 4 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [Makefile] indent_style = tab indent_size = 4 [Vagrantfile] indent_size = 2 [*.rb] indent_size = 2 [*.py] indent_style = tab [*.js] indent_size = 2 ================================================ FILE: .gitignore ================================================ *~ tests/__pycache__/ management/__pycache__/ tools/__pycache__/ externals/ .env .vagrant api/docs/api-docs.html *.code-workspace ================================================ FILE: CHANGELOG.md ================================================ CHANGELOG ========= Version 74 (January 4, 2026) ---------------------------- * Updated Roundcube to 1.6.12, fixing a security vulnerability. * Updated zpush to version 2.7.6. * Fixed fail2ban filter for Nextcloud. * Fixed Thunderbird auto configuration. * Updated links in the control panel. Version 73 (July 11, 2025) -------------------------- Mail: * Quotas for mail storage can now be set per user in the control panel. * Autoconfig now includes POP3 and CardDAV/CalDAV. Backups: * Fix for S3-compatible backups (other than AWS S3 itself). Control Panel: * Backup status is added to the status checks. * S3 backup credentials can now be stored in environment variables. * Fix for when an AAAA record is set up the box's own IP address. * Fix for when logged out of the control panel. * Fix link to Z-Push client compatibility list. Setup: * The Ubuntu version check is updated. Other: * Code cleanup using the Ruff Python linter. * Other minor changes. Version 72 (June 3, 2025) ------------------------- Upgrades * Roundcube upgraded to version 1.6.11, fixing a security vulnerability. Control Panel * A warning during daily tasks related to no TLS certificates being expired is fixed. Version 71 (January 4, 2025) ---------------------------- (Version 71a was posted on January 6, 2025 and fixes a setup regression.) Upgrades * Roundcube upgraded to version 1.6.9. * Z-Push upgraded to version 2.7.5. Automated Maintenance * Daily automated tasks are now run at 1am in the box's timezone and full backups are now restricted to running only on Saturdays and Sundays at that time. * Backups now exclude the owncloud-backup folder so that we're not backing up backups. * Old TLS certificates are now automatically deleted to improve control panel performance. Setup * Fixed broken setup if SSH was configured to listen on multiple ports. * Ubuntu MOTD advertisements are now disabled. * Fixed missing Roundcube dependency package if NextCloud isn't installed. Control Panel * Improved status checks for secondary nameservers. * Spamhaus is now queried for the box's IPv6 address also. * DSA and EC private keys are now accepted for TLS certificates. * Timeouts for loading slow control panel pages are reduced. And other minor fixes. Version 70 (August 15, 2024) ---------------------------- * Roundcube is updated to version 1.6.8 fixing security vulnerabilities. Version 69 (July 20, 2024) -------------------------- Package updates: * Nextcloud is updated to 26.0.13. * Z-Push is updated to 2.7.3. Other updates: * Fixed an error generating the weekly statistics. * Fixed file permissions when setting up Nextcloud. * Added an undocumented option to proxy websockets. * Internal improvements to the code to make it more reliable and readable. Version 69a (July 21, 2024) and 69b (July 23, 2024) correct setup failures. Version 68 (April 1, 2024) -------------------------- Package updates: * Roundcube updated to version 1.6.6. * Nextcloud is updated to version 26.0.12. Mail: * Updated postfix's configuration to guard against SMTP smuggling to the long-term fix (https://www.postfix.org/smtp-smuggling.html). Control Panel: * Improved reporting of Spamhaus response codes. * Improved detection of SSH port. * Fixed an error if last saved status check results were corrupted. * Other minor fixes. Other: * fail2ban is updated to see "HTTP/2.0" requests to munin also. * Internal improvements to the code to make it more reliable and readable. Version 67 (December 22, 2023) ------------------------------ * Guard against a newly published vulnerability called SMTP Smuggling. See https://sec-consult.com/blog/detail/smtp-smuggling-spoofing-e-mails-worldwide/. Version 66 (December 17, 2023) ------------------------------ * Some users reported an error installing Mail-in-a-Box related to the virtualenv command. This is hopefully fixed. * Roundcube is updated to 1.6.5 fixing a security vulnerability. * For Mail-in-a-Box developers, a new setup variable is added to pull the source code from a different repository. Version 65 (October 27, 2023) ----------------------------- * Roundcube updated to 1.6.4 fixing a security vulnerability. * zpush.sh updated to version 2.7.1. * Fixed a typo in the control panel. Version 64 (September 2, 2023) ------------------------------ * Fixed broken installation when upgrading from Mail-in-a-Box version 56 (Nextcloud 22) and earlier because of an upstream packaging issue. * Fixed backups to work with the latest duplicity package which was not backwards compatible. * Fixed setting B2 as a backup target with a slash in the application key. * Turned off OpenDMARC diagnostic reports sent in response to incoming mail. * Fixed some crashes when using an unreleased version of Mail-in-a-Box. * Added z-push administration scripts. Version 63 (July 27, 2023) -------------------------- * Nextcloud updated to 25.0.7. Version 62 (May 20, 2023) ------------------------- Package updates: * Nextcloud updated to 23.0.12 (and its apps also updated). * Roundcube updated to 1.6.1. * Z-Push to 2.7.0, which has compatibility for Ubuntu 22.04, so it works again. Mail: * Roundcube's password change page is now working again. Control panel: * Allow setting the backup location's S3 region name for non-AWS S3-compatible backup hosts. * Control panel pages can be opened in a new tab/window and bookmarked and browser history navigation now works. * Add a Copy button to put the rsync backup public key on clipboard. * Allow secondary DNS xfr: items added in the control panel to be hostnames too. * Fixed issue where sshkeygen fails when IPv6 is disabled. * Fixed issue opening munin reports. * Fixed report formatting in status emails sent to the administrator. Version 61.1 (January 28, 2023) ------------------------------- * Fixed rsync backups not working with the default port. * Reverted "Improve error messages in the management tools when external command-line tools are run." because of the possibility of user secrets being included in error messages. * Fix for TLS certificate SHA fingerprint not being displayed during setup. Version 61 (January 21, 2023) ----------------------------- System: * fail2ban didn't start after setup. Mail: * Disable Roundcube password plugin since it was corrupting the user database. Control panel: * Fix changing existing backup settings when the rsync type is used. * Allow setting a custom port for rsync backups. * Fixes to DNS lookups during status checks when there are timeouts, enforce timeouts better. * A new check is added to ensure fail2ban is running. * Fixed a color. * Improve error messages in the management tools when external command-line tools are run. Version 60.1 (October 30, 2022) ------------------------------- * A setup issue where the DNS server nsd isn't running at the end of setup is (hopefully) fixed. * Nextcloud is updated to 23.0.10 (contacts to 4.2.2, calendar to 3.5.1). Version 60 (October 11, 2022) ----------------------------- This is the first release for Ubuntu 22.04. **Before upgrading**, you must **first upgrade your existing Ubuntu 18.04 box to Mail-in-a-Box v0.51 or later**, if you haven't already done so. That may not be possible after Ubuntu 18.04 reaches its end of life in April 2023, so please complete the upgrade well before then. (If you are not using Nextcloud's contacts or calendar, you can migrate to the latest version of Mail-in-a-Box from any previous version.) For complete upgrade instructions, see: https://discourse.mailinabox.email/t/version-60-for-ubuntu-22-04-is-about-to-be-released/9558 No major features of Mail-in-a-Box have changed in this release, although some minor fixes were made. With the newer version of Ubuntu the following software packages we use are updated: * dovecot is upgraded to 2.3.16, postfix to 3.6.4, opendmark to 1.4 (which adds ARC-Authentication-Results headers), and spampd to 2.53 (alleviating a mail delivery rate limiting bug). * Nextcloud is upgraded to 23.0.4 (contacts to 4.2.0, calendar to 3.5.0). * Roundcube is upgraded to 1.6.0. * certbot is upgraded to 1.21 (via the Ubuntu repository instead of a PPA). * fail2ban is upgraded to 0.11.2. * nginx is upgraded to 1.18. * PHP is upgraded from 7.2 to 8.0. Also: * Roundcube's login session cookie was tightened. Existing sessions may require a manual logout. * Moved Postgrey's database under $STORAGE_ROOT. Version 57a (June 19, 2022) --------------------------- * The Backblaze backups fix posted in Version 57 was incomplete. It's now fixed. Version 57 (June 12, 2022) -------------------------- Setup: * Fixed issue upgrading from Mail-in-a-Box v0.40-v0.50 because of a changed URL that Nextcloud is downloaded from. Backups: * Fixed S3 backups which broke with duplicity 0.8.23. * Fixed Backblaze backups which broke with latest b2sdk package by rolling back its version. Control panel: * Fixed spurious changes in system status checks messages by sorting DNSSEC DS records. * Fixed fail2ban lockout over IPv6 from excessive loads of the system status checks. * Fixed an incorrect IPv6 system status check message. Version 56 (January 19, 2022) ----------------------------- Software updates: * Roundcube updated to 1.5.2 (from 1.5.0), and the persistent_login and CardDAV (to 4.3.0 from 3.0.3) plugins are updated. * Nextcloud updated to 20.0.14 (from 20.0.8), contacts to 4.0.7 (from 3.5.1), and calendar to 3.0.4 (from 2.2.0). Setup: * Fixed failed setup if a previous attempt failed while updating Nextcloud. Control panel: * Fixed a crash if a custom DNS entry is not under a zone managed by the box. * Fix DNSSEC instructions typo. Other: * Set systemd journald log retention to 10 days (from no limit) to reduce disk usage. * Fixed log processing for submission lines that have a sasl_sender or other extra information. * Fix DNS secondary nameserver refresh failure retry period. Version 55 (October 18, 2021) ----------------------------- Mail: * "SMTPUTF8" is now disabled in Postfix. Because Dovecot still does not support SMTPUTF8, incoming mail to internationalized addresses was bouncing. This fixes incoming mail to internationalized domains (which was probably working prior to v0.40), but it will prevent sending outbound mail to addresses with internationalized local-parts. * Upgraded to Roundcube 1.5. Control panel: * The control panel menus are now hidden before login, but now non-admins can log in to access the mail and contacts/calendar instruction pages. * The login form now disables browser autocomplete in the two-factor authentication code field. * After logging in, the default page is now a fast-loading welcome page rather than the slow-loading system status checks page. * The backup retention period option now displays for B2 backup targets. * The DNSSEC DS record recommendations are cleaned up and now recommend changing records that use SHA1. * The Munin monitoring pages no longer require a separate HTTP basic authentication login and can be used if two-factor authentication is turned on. * Control panel logins are now tied to a session backend that allows true logouts (rather than an encrypted cookie). * Failed logins no longer directly reveal whether the email address corresponds to a user account. * Browser dark mode now inverts the color scheme. Other: * Fail2ban's IPv6 support is enabled. * The mail log tool now doesn't crash if there are email addresses in log messages with invalid UTF-8 characters. * Additional nsd.conf files can be placed in /etc/nsd.conf.d. v0.54 (June 20, 2021) --------------------- Mail: * Forwarded mail using mail filter rules (in Roundcube; "sieve" rules) stopped re-writing the envelope address at some point, causing forwarded mail to often be marked as spam by the final recipient. These forwards will now re-write the envelope as the Mail-in-a-Box user receiving the mail to comply with SPF/DMARC rules. * Sending mail is now possible on port 465 with the "SSL" or "TLS" option in mail clients, and this is now the recommended setting. Port 587 with STARTTLS remains available but should be avoided when configuring new mail clients. * Roundcube's login cookie is updated to use a new encryption algorithm (AES-256-CBC instead of DES-EDE-CBC). DNS: * The ECDSAP256SHA256 DNSSEC algorithm is now available. If a DS record is set for any of your domain names that have DNS hosted on your box, you will be prompted by status checks to update the DS record at your convenience. * Null MX records are added for domains that do not serve mail. Contacts/calendar: * Updated Nextcloud to 20.0.8, contacts to 3.5.1, calendar to 2.2.0 (#1960). Control panel: * Fixed a crash in the status checks. * Small wording improvements. Setup: * Minor improvements to the setup scripts. v0.53a (May 8, 2021) -------------------- The download URL for Z-Push has been revised because the old URL stopped working. v0.53 (April 12, 2021) ---------------------- Software updates: * Upgraded Roundcube to version 1.4.11 addressing a security issue, and its desktop notifications plugin. * Upgraded Z-Push (for Exchange/ActiveSync) to version 2.6.2. Control panel: * Backblaze B2 is now a supported backup protocol. * Fixed an issue in the daily mail reports. * Sort the Custom DNS by zone and qname, and add an option to go back to the old sort order (creation order). Mail: * Enable sending DMARC failure reports to senders that request them. Setup: * Fixed error when upgrading from Nextcloud 13. v0.52 (January 31, 2021) ------------------------ Software updates: * Upgraded Roundcube to version 1.4.10. * Upgraded Z-Push to 2.6.1. Mail: * Incoming emails with SPF/DKIM/DMARC failures now get a higher spam score, and these messages are more likely to appear in the junk folder, since they are often spam/phishing. * Fixed the MTA-STS policy file's line endings. Control panel: * A new Download button in the control panel's External DNS page can be used to download the required DNS records in zonefile format. * Fixed the problem when the control panel would report DNS entries as Not Set by increasing a bind query limit. * Fixed a control panel startup bug on some systems. * Improved an error message on a DNS lookup timeout. * A typo was fixed. DNS: * The TTL for NS records has been increased to 1 day to comply with some registrar requirements. System: * Nextcloud's photos, dashboard, and activity apps are disabled since we only support contacts and calendar. v0.51 (November 14, 2020) ------------------------- Software updates: * Upgraded Nextcloud from 17.0.6 to 20.0.1 (with Contacts from 3.3.0 to 3.4.1 and Calendar from 2.0.3 to 2.1.2) * Upgraded Roundcube to version 1.4.9. Mail: * The MTA-STA max_age value was increased to the normal one week. Control panel: * Two-factor authentication can now be enabled for logins to the control panel. However, keep in mind that many online services (including domain name registrars, cloud server providers, and TLS certificate providers) may allow an attacker to take over your account or issue a fraudulent TLS certificate with only access to your email address, and this new two-factor authentication does not protect access to your inbox. It therefore remains very important that user accounts with administrative email addresses have strong passwords. * TLS certificate expiry dates are now shown in ISO8601 format for clarity. v0.50 (September 25, 2020) -------------------------- Setup: * When upgrading from versions before v0.40, setup will now warn that ownCloud/Nextcloud data cannot be migrated rather than failing the installation. Mail: * An MTA-STS policy for incoming mail is now published (in DNS and over HTTPS) when the primary hostname and email address domain both have a signed TLS certificate installed, allowing senders to know that an encrypted connection should be enforced. * The per-IP connection limit to the IMAP server has been doubled to allow more devices to connect at once, especially with multiple users behind a NAT. DNS: * autoconfig and autodiscover subdomains and CalDAV/CardDAV SRV records are no longer generated for domains that don't have user accounts since they are unnecessary. * IPv6 addresses can now be specified for secondary DNS nameservers in the control panel. TLS: * TLS certificates are now provisioned in groups by parent domain to limit easy domain enumeration and make provisioning more resilient to errors for particular domains. Control panel: * The control panel API is now fully documented at https://mailinabox.email/api-docs.html. * User passwords can now have spaces. * Status checks for automatic subdomains have been moved into the section for the parent domain. * Typo fixed. Web: * The default web page served on fresh installations now adds the `noindex` meta tag. * The HSTS header is revised to also be sent on non-success responses. v0.48 (August 26, 2020) ----------------------- Security fixes: * Roundcube is updated to version 1.4.8 fixing additional cross-site scripting (XSS) vulnerabilities. v0.47 (July 29, 2020) --------------------- Security fixes: * Roundcube is updated to version 1.4.7 fixing a cross-site scripting (XSS) vulnerability with HTML messages with malicious svg/namespace (CVE-2020-15562) (https://roundcube.net/news/2020/07/05/security-updates-1.4.7-1.3.14-and-1.2.11). * SSH connections are now rate-limited at the firewall level (in addition to fail2ban). v0.46 (June 11, 2020) --------------------- Security fixes: * Roundcube is updated to version 1.4.6 (https://roundcube.net/news/2020/06/02/security-updates-1.4.5-and-1.3.12). v0.45 (May 16, 2020) -------------------- Security fixes: * Fix missing brute force login protection for Roundcube logins. Software updates: * Upgraded Roundcube from 1.4.2 to 1.4.4. * Upgraded Nextcloud from 17.0.2 to 17.0.6 (with Contacts from 3.1.6 to 3.3.0 and Calendar from 1.7.1 to v2.0.3) * Upgraded Z-Push to 2.5.2. System: * Nightly backups now occur on a random minute in the 3am hour (in the system time zone). The minute is chosen during Mail-in-a-Box installation/upgrade and remains the same until the next upgrade. * Fix for mail log statistics report on leap days. * Fix Mozilla autoconfig useGlobalPreferredServer setting. Web: * Add a new hidden feature to set nginx alias in www/custom.yaml. Setup: * Improved error handling. v0.44 (February 15, 2020) ------------------------- System: * TLS settings have been upgraded following Mozilla's recommendations for servers. TLS1.2 and 1.3 are now the only supported protocols for web, IMAP, and SMTP (submission). * Fixed an issue starting services when Mail-in-a-Box isn't on the root filesystem. * Changed some performance options affecting Roundcube and Nextcloud. Software updates: * Upgraded Nextcloud from 15.0.8 to 17.0.2 (with Contacts from 3.1.1 to 3.1.6 and Calendar from 1.6.5 to 1.7.1) * Upgraded Z-Push to 2.5.1. * Upgraded Roundcube from 1.3.10 to 1.4.2 and changed the default skin (theme) to Elastic. Control panel: * The Custom DNS list of records is now sorted. * The emails that report TLS provisioning results now has a less scary subject line. Mail: * Fetching of updated whitelist for greylisting was fetching each day instead of every month. * OpenDKIM signing has been changed to 'relaxed' mode so that some old mail lists that forward mail can do so. DNS: * Automatic autoconfig.* subdomains can now be suppressed with custom DNS records. * DNS zone transfer now works with IPv6 addresses. Setup: * An Ubuntu package source was missing on systems where it defaults off. v0.43 (September 1, 2019) ------------------------- Security fixes: * A security issue was discovered in rsync backups. If you have enabled rsync backups, the file `id_rsa_miab` may have been copied to your backup destination. This file can be used to access your backup destination. If the file was copied to your backup destination, we recommend that you delete the file on your backup destination, delete `/root/.ssh/id_rsa_miab` on your Mail-in-a-Box, then re-run Mail-in-a-Box setup, and re-configure your SSH public key at your backup destination according to the instructions in the Mail-in-a-Box control panel. * Brute force attack prevention was missing for the managesieve service. Setup: * Nextcloud was not upgraded properly after restoring Mail-in-a-Box from a backup from v0.40 or earlier. Mail: * Upgraded Roundcube to 1.3.10. * Fetch an updated whitelist for greylisting on a monthly basis to reduce the number of delayed incoming emails. Control panel: * When using secondary DNS, it is now possible to specify a subnet range with the `xfr:` option. * Fixed an issue when the secondary DNS option is used and the secondary DNS hostname resolves to multiple IP addresses. * Fix a bug in how a backup configuration error is shown. v0.42b (August 3, 2019) ----------------------- Changes: * Decreased the minimum supported RAM to 502 Mb. * Improved mail client autoconfiguration. * Added support for S3-compatible backup services besides Amazon S3. * Fixed the control panel login page to let LastPass save passwords. * Fixed an error in the user privileges API. * Silenced some spurious messages. Software updates: * Upgraded Roundcube from 1.3.8 to 1.3.9. * Upgraded Nextcloud from 14.0.6 to 15.0.8 (with Contacts from 2.1.8 to 3.1.1 and Calendar from 1.6.4 to 1.6.5). * Upgraded Z-Push from 2.4.4 to 2.5.0. Note that v0.42 (July 4, 2019) was pulled shortly after it was released to fix a Nextcloud upgrade issue. v0.41 (February 26, 2019) ------------------------- System: * Missing brute force login attack prevention (fail2ban) filters which stopped working on Ubuntu 18.04 were added back. * Upgrades would fail if Mail-in-a-Box moved to a different directory in `systemctl link`. Mail: * Incoming messages addressed to more than one local user were rejected because of a bug in spampd packaged by Ubuntu 18.04. A workaround was added. Contacts/Calendar: * Upgraded Nextcloud from 13.0.6 to 14.0.6. * Upgraded Contacts from 2.1.5 to 2.1.8. * Upgraded Calendar from 1.6.1 to 1.6.4. v0.40 (January 12, 2019) ------------------------ This is the first release for Ubuntu 18.04. This version and versions going forward can **only** be installed on Ubuntu 18.04; however, upgrades of existing Ubuntu 14.04 boxes to the latest version supporting Ubuntu 14.04 (v0.30) continue to work as normal. When **upgrading**, you **must first upgrade your existing Ubuntu 14.04 Mail-in-a-Box box** to the latest release supporting Ubuntu 14.04 --- that's v0.30 --- before you migrate to Ubuntu 18.04. If you are running an older version of Mail-in-a-Box which has an old version of ownCloud or Nextcloud, you will *not* be able to upgrade your data because older versions of ownCloud and Nextcloud that are required to perform the upgrade *cannot* be run on Ubuntu 18.04. To upgrade from Ubuntu 14.04 to Ubuntu 18.04, you **must create a fresh Ubuntu 18.04 machine** before installing this version. In-place upgrades of servers are not supported. Since Ubuntu's support for Ubuntu 14.04 has almost ended, everyone is encouraged to create a new Ubuntu 18.04 machine and migrate to it. For complete upgrade instructions, see: https://discourse.mailinabox.email/t/mail-in-a-box-version-v0-40-and-moving-to-ubuntu-18-04/4289 The changelog for this release follows. Setup: * Mail-in-a-Box now targets Ubuntu 18.04 LTS, which will have support from Ubuntu through 2022. * Some of the system packages updated in virtue of using Ubuntu 18.04 include postfix (2.11=>3.3) nsd (4.0=>4.1), nginx (1.4=>1.14), PHP (7.0=>7.2), Python (3.4=>3.6), fail2ban (0.8=>0.10), Duplicity (0.6=>0.7). * [Unofficial Bash Strict Mode](http://redsymbol.net/articles/unofficial-bash-strict-mode/) is turned on for setup, which might catch previously uncaught issues during setup. Mail: * IMAP server-side full text search is no longer supported because we were using a custom-built `dovecot-lucene` package that we are no longer maintaining. * Sending email is now disabled on port 25 --- you must log in to port 587 to send email, per the long-standing mail instructions. * Greylisting may delay more emails from new senders. We were using a custom-built postgrey package previously that whitelisted sending domains in dnswl.org, but we are no longer maintaining that package. v0.30 (January 9, 2019) ----------------------- Setup: * Update to Roundcube 1.3.8 and the CardDAV plugin to 3.0.3. * Add missing rsyslog package to install line since some OS images don't have it installed by default. * A log file for nsd was added. Control Panel: * The users page now documents that passwords should only have ASCII characters to prevent character encoding mismatches between clients and the server. * The users page no longer shows user mailbox sizes because this was extremely slow for very large mailboxes. * The Mail-in-a-Box version is now shown in the system status checks even when the new-version check is disabled. * The alises page now warns that alises should not be used to forward mail off of the box. Mail filters within Roundcube are better for that. * The explanation of greylisting has been improved. v0.29 (October 25, 2018) ------------------------ * Starting with v0.28, TLS certificate provisioning wouldn't work on new boxes until the mailinabox setup command was run a second time because of a problem with the non-interactive setup. * Update to Nextcloud 13.0.6. * Update to Roundcube 1.3.7. * Update to Z-Push 2.4.4. * Backup dates listed in the control panel now use an internationalized format. v0.28 (July 30, 2018) --------------------- System: * We now use EFF's `certbot` to provision TLS certificates (from Let's Encrypt) instead of our home-grown ACME library. Contacts/Calendar: * Fix for Mac OS X autoconfig of the calendar. Setup: * Installing Z-Push broke because of what looks like a change or problem in their git server HTTPS certificate. That's fixed. v0.27 (June 14, 2018) --------------------- Mail: * A report of box activity, including sent/received mail totals and logins by user, is now emailed to the box's administrator user each week. * Update Roundcube to version 1.3.6 and Z-Push to version 2.3.9. Control Panel: * The undocumented feature for proxying web requests to another server now sets X-Forwarded-For. v0.26c (February 13, 2018) -------------------------- Setup: * Upgrades from v0.21c (February 1, 2017) or earlier were broken because the intermediate versions of ownCloud used in setup were no longer available from ownCloud. * Some download errors had no output --- there is more output on error now. Control Panel: * The background service for the control panel was not restarting on updates, leaving the old version running. This was broken in v0.26 and is now fixed. * Installing your own TLS/SSL certificate had been broken since v0.24 because the new version of openssl became stricter about CSR generation parameters. * Fixed password length help text. Contacts/Calendar: * Upgraded Nextcloud from 12.0.3 to 12.0.5. v0.26b (January 25, 2018) ------------------------- * Fix new installations which broke at the step of asking for the user's desired email address, which was broken by v0.26's changes related to the control panel. * Fix the provisioning of TLS certificates by pinning a Python package we rely on (acme) to an earlier version because our code isn't yet compatible with its current version. * Reduce munin's log_level from debug to warning to prevent massive log files. v0.26 (January 18, 2018) ------------------------ Security: * HTTPS, IMAP, and POP's TLS settings have been updated to Mozilla's intermediate cipher list recommendation. Some extremely old devices that use less secure TLS ciphers may no longer be able to connect to IMAP/POP. * Updated web HSTS header to use longer six month duration. Mail: * Adding attachments in Roundcube broke after the last update for some users after rebooting because a temporary directory was deleted on reboot. The temporary directory is now moved from /tmp to /var so that it is persistent. * `X-Spam-Score` header is added to incoming mail. Control panel: * RSASHA256 is now used for DNSSEC for .lv domains. * Some documentation/links improvements. Installer: * We now run `apt-get autoremove` at the start of setup to clear out old packages, especially old kernels that take up a lot of space. On the first run, this step may take a long time. * We now fetch Z-Push from its tagged git repository, fixing an installation problem. * Some old PHP5 packages are removed from setup, fixing an installation bug where Apache would get installed. * Python 3 packages for the control panel are now installed using a virtualenv to prevent installation errors due to conflicts in the cryptography/openssl packages between OS-installed packages and pip-installed packages. v0.25 (November 15, 2017) ------------------------- This update is a security update addressing [CVE-2017-16651, a vulnerability in Roundcube webmail that allows logged-in users to access files on the local filesystem](https://roundcube.net/news/2017/11/08/security-updates-1.3.3-1.2.7-and-1.1.10). Mail: * Update to Roundcube 1.3.3. Control Panel: * Allow custom DNS records to be set for DNS wildcard subdomains (i.e. `*`). v0.24 (October 3, 2017) ----------------------- System: * Install PHP7 via a PPA. Switch to the on-demand process manager. Mail: * Updated to [Roundcube 1.3.1](https://roundcube.net/news/2017/06/26/roundcube-webmail-1.3.0-released), but unfortunately dropping the Vacation plugin because it has not been supported by its author and is not compatible with Roundcube 1.3, and updated the persistent login plugin. * Updated to [Z-Push 2.3.8](http://download.z-push.org/final/2.3/z-push-2.3.8.txt). * Dovecot now uses stronger 2048 bit DH params for better forward secrecy. Nextcloud: * Nextcloud updated to 12.0.3, using PHP7. Control Panel: * Nameserver (NS) records can now be set on custom domains. * Fix an erroneous status check error due to IPv6 address formatting. * Aliases for administrative addresses can now be set to send mail to +tag administrative addresses. v0.23a (May 31, 2017) --------------------- Corrects a problem in the new way third-party assets are downloaded during setup for the control panel, since v0.23. v0.23 (May 30, 2017) -------------------- Mail: * The default theme for Roundcube was changed to the nicer Larry theme. * Exchange/ActiveSync support has been replaced with z-push 2.3.6 from z-push.org (rather than z-push-contrib). ownCloud (now Nextcloud): * ownCloud is replaced with Nextcloud 10.0.5. * Fixed an error in Owncloud/Nextcloud setup not updating domain when changing hostname. Control Panel/Management: * Fix an error in the control panel showing rsync backup status. * Fix an error in the control panel related to IPv6 addresses. * TLS certificates for internationalized domain names can now be provisioned from Let's Encrypt automatically. * Third-party assets used in the control panel (jQuery/Bootstrap) are now downloaded during setup and served from the box rather than from a CDN. DNS: * Add support for custom CAA records. v0.22 (April 2, 2017) --------------------- Mail: * The CardDAV plugin has been added to Roundcube so that your ownCloud contacts are available in webmail. * Upgraded to Roundcube 1.2.4 and updated the persistent login plugin. * Allow larger messages to be checked by SpamAssassin. * Dovecot's vsz memory limit has been increased proportional to system memory. * Newly set user passwords must be at least eight characters. ownCloud: * Upgraded to ownCloud 9.1.4. Control Panel/Management: * The status checks page crashed when the mailinabox.email website was down - that's fixed. * Made nightly re-provisioning of TLS certificates less noisy. * Fixed bugs in rsync backup method and in the list of recent backups. * Fixed incorrect status checks errors about IPv6 addresses. * Fixed incorrect status checks errors for secondary nameservers if round-robin custom A records are set. * The management mail_log.py tool has been rewritten. DNS: * Added support for DSA, ED25519, and custom SSHFP records. System: * The SSH fail2ban jail was not activated. Installation: * At the end of installation, the SHA256 -- rather than SHA1 -- hash of the system's TLS certificate is shown. v0.21c (February 1, 2017) ------------------------- Installations and upgrades started failing about 10 days ago with the error "ImportError: No module named 'packaging'" after an upstream package (Python's setuptools) was updated by its maintainers. The updated package conflicted with Ubuntu 14.04's version of another package (Python's pip). This update upgrades both packages to remove the conflict. If you already encountered the error during installation or upgrade of Mail-in-a-Box, this update may not correct the problem on your existing system. See https://discourse.mailinabox.email/t/v0-21c-release-fixes-python-package-installation-issue/1881 for help if the problem persists after upgrading to this version of Mail-in-a-Box. v0.21b (December 4, 2016) ------------------------- This update corrects a first-time installation issue introduced in v0.21 caused by the new Exchange/ActiveSync feature. v0.21 (November 30, 2016) ------------------------- This version updates ownCloud, which may include security fixes, and makes some other smaller improvements. Mail: * Header privacy filters were improperly running on the contents of forwarded email --- that's fixed. * We have another go at fixing a long-standing issue with training the spam filter (because of a file permissions issue). * Exchange/ActiveSync will now use your display name set in Roundcube in the From: line of outgoing email. ownCloud: * Updated ownCloud to version 9.1.1. Control panel: * Backups can now be made using rsync-over-ssh! * Status checks failed if the system doesn't support iptables or doesn't have ufw installed. * Added support for SSHFP records when sshd listens on non-standard ports. * Recommendations for TLS certificate providers were removed now that everyone mostly uses Let's Encrypt. System: * Ubuntu's "Upgrade to 16.04" notice is suppressed since you should not do that. * Lowered memory requirements to 512MB, display a warning if system memory is below 768MB. v0.20 (September 23, 2016) -------------------------- ownCloud: * Updated to ownCloud to 8.2.7. Control Panel: * Fixed a crash that occurs when there are IPv6 DNS records due to a bug in dnspython 1.14.0. * Improved the wonky low disk space check. v0.19b (August 20, 2016) ------------------------ This update corrects a security issue introduced in v0.18. * A remote code execution vulnerability is corrected in how the munin system monitoring graphs are generated for the control panel. The vulnerability involves an administrative user visiting a carefully crafted URL. v0.19a (August 18, 2016) ------------------------ This update corrects a security issue in v0.19. * fail2ban won't start if Roundcube had not yet been used - new installations probably do not have fail2ban running. v0.19 (August 13, 2016) ----------------------- Mail: * Roundcube is updated to version 1.2.1. * SSLv3 and RC4 are now no longer supported in incoming and outgoing mail (SMTP port 25). Control panel: * The users and aliases APIs are now documented on their control panel pages. * The HSTS header was missing. * New status checks were added for the ufw firewall. DNS: * Add SRV records for CardDAV/CalDAV to facilitate autoconfiguration (e.g. in DavDroid, whose latest version didn't seem to work to configure with entering just a hostname). System: * fail2ban jails added for SMTP submission, Roundcube, ownCloud, the control panel, and munin. * Mail-in-a-Box can now be installed on the i686 architecture. v0.18c (June 2, 2016) --------------------- * Domain aliases (and misconfigured aliases/catch-alls with non-existent local targets) would accept mail and deliver it to new mailbox folders on disk even if the target address didn't correspond with an existing mail user, instead of rejecting the mail. This issue was introduced in v0.18. * The Munin Monitoring link in the control panel now opens a new window. * Added an undocumented before-backup script. v0.18b (May 16, 2016) --------------------- * Fixed a Roundcube user accounts issue introduced in v0.18. v0.18 (May 15, 2016) -------------------- ownCloud: * Updated to ownCloud to 8.2.3 Mail: * Roundcube is updated to version 1.1.5 and the Roundcube login screen now says "[hostname] Webmail" instead of "Mail-in-a-Box/Roundcube webmail". * Fixed a long-standing issue with training the spam filter not working (because of a file permissions issue). Control panel: * Munin system monitoring graphs are now zoomable. * When a reboot is required (due to Ubuntu security updates automatically installed), a Reboot Box button now appears on the System Status Checks page of the control panel. * It is now possible to add SRV and secondary MX records in the Custom DNS page. * Other minor fixes. System: * The fail2ban recidive jail, which blocks long-duration brute force attacks, now no longer sends the administrator emails (which were not helpful). Setup: * The system hostname is now set during setup. * A swap file is now created if system memory is less than 2GB, 5GB of free disk space is available, and if no swap file yet exists. * We now install Roundcube from the official GitHub repository instead of our own mirror, which we had previously created to solve problems with SourceForge. * DKIM was incorrectly set up on machines where "localhost" was defined as something other than "127.0.0.1". v0.17c (April 1, 2016) ---------------------- This update addresses some minor security concerns and some installation issues. ownCloud: * Block web access to the configuration parameters (config.php). There is no immediate impact (see [#776](https://github.com/mail-in-a-box/mailinabox/pull/776)), although advanced users may want to take note. Mail: * Roundcube html5_notifier plugin updated from version 0.6 to 0.6.2 to fix Roundcube getting stuck for some people. Control panel: * Prevent click-jacking of the management interface by adding HTTP headers. * Failed login no longer reveals whether an account exists on the system. Setup: * Setup dialogs did not appear correctly when connecting to SSH using Putty on Windows. * We now install Roundcube from our own mirror because Sourceforge's downloads experience frequent intermittent unavailability. v0.17b (March 1, 2016) ---------------------- ownCloud moved their source code to a new location, breaking our installation script. v0.17 (February 25, 2016) ------------------------- Mail: * Roundcube updated to version 1.1.4. * When there's a problem delivering an outgoing message, a new 'warning' bounce will come after 3 hours and the box will stop trying after 2 days (instead of 5). * On multi-homed machines, Postfix now binds to the right network interface when sending outbound mail so that SPF checks on the receiving end will pass. * Mail sent from addresses on subdomains of other domains hosted by this box would not be DKIM-signed and so would fail DMARC checks by recipients, since version v0.15. Control panel: * TLS certificate provisioning would crash if DNS propagation was in progress and a challenge failed; might have shown the wrong error when provisioning fails. * Backup times were displayed with the wrong time zone. * Thresholds for displaying messages when the system is running low on memory have been reduced from 30% to 20% for a warning and from 15% to 10% for an error. * Other minor fixes. System: * Backups to some AWS S3 regions broke in version 0.15 because we reverted the version of boto. That's now fixed. * On low-usage systems, don't hold backups for quite so long by taking a full backup more often. * Nightly status checks might fail on systems not configured with a default Unicode locale. * If domains need a TLS certificate and the user hasn't installed one yet using Let's Encrypt, the administrator would get a nightly email with weird interactive text asking them to agree to Let's Encrypt's ToS. Now just say that the provisioning can't be done automatically. * Reduce the number of background processes used by the management daemon to lower memory consumption. Setup: * The first screen now warns users not to install on a machine used for other things. v0.16 (January 30, 2016) ------------------------ This update primarily adds automatic SSL (now "TLS") certificate provisioning from Let's Encrypt (https://letsencrypt.org/). Control Panel: * The SSL certificates (now referred to as "TLS certificates") page now supports provisioning free certificates from Let's Encrypt. * Report free memory usage. * Fix a crash when the git directory is not checked out to a tag. * When IPv6 is enabled, check that all domains (besides the system hostname) resolve over IPv6. * When a domain doesn't resolve to the box, don't bother checking if the TLS certificate is valid. * Remove rounded border on the menu bar. Other: * The Sieve port is now open so tools like the Thunderbird Sieve extension can be used to edit mail filters. * .be domains now offer DNSSEC options supported by the TLD * The daily backup will now email the administrator if there is a problem. * Expiring TLS certificates are now automatically renewed via Let's Encrypt. * File ownership for installed Roundcube files is fixed. * Typos fixed. v0.15a (January 9, 2016) ------------------------ Mail: * Sending mail through Exchange/ActiveSync (Z-Push) had been broken since v0.14 in some setups. This is now fixed. v0.15 (January 1, 2016) ----------------------- Mail: * Updated Roundcube to version 1.1.3. * Auto-create aliases for abuse@, as required by RFC2142. * The DANE TLSA record is changed to use the certificate subject public key rather than the whole certificate, which means the record remains valid after certificate changes (so long as the private key remains the same, which it does for us). Control panel: * When IPv6 is enabled, check that system services are accessible over IPv6 too, that the box's hostname resolves over IPv6, and that reverse DNS is setup correctly for IPv6. * Explanatory text for setting up secondary nameserver is added/fixed. * DNS checks now have a timeout in case a DNS server is not responding, so the checks don't stall indefinitely. * Better messages if external DNS is used and, weirdly, custom secondary nameservers are set. * Add POP to the mail client settings documentation. * The box's IP address is added to the fail2ban whitelist so that the status checks don't trigger the machine banning itself, which results in the status checks showing services down even though they are running. * For SSL certificates, rather than asking you what country you are in during setup, ask at the time a CSR is generated. The default system self-signed certificate now omits a country in the subject (it was never needed). The CSR_COUNTRY Mail-in-a-Box setting is dropped entirely. System: * Nightly backups and system status checks are now moved to 3am in the system's timezone. * fail2ban's recidive jail is now active, which guards against persistent brute force login attacks over long periods of time. * Setup (first run only) now asks for your timezone to set the system time. * The Exchange/ActiveSync server is now taken offline during nightly backups (along with SMTP and IMAP). * The machine's random number generator (/dev/urandom) is now seeded with Ubuntu Pollinate and a blocking read on /dev/random. * DNSSEC key generation during install now uses /dev/urandom (instead of /dev/random), which is faster. * The $STORAGE_ROOT/ssl directory is flattened by a migration script and the system SSL certificate path is now a symlink to the actual certificate. * If ownCloud sends out email, it will use the box's administrative address now (admin@yourboxname). * Z-Push (Exchange/ActiveSync) logs now exclude warnings and are now rotated to save disk space. * Fix pip command that might have not installed all necessary Python packages. * The control panel and backup would not work on Google Compute Engine because GCE installs a conflicting boto package. * Added a new command `management/backup.py --restore` to restore files from a backup to a target directory (command line arguments are passed to `duplicity restore`). v0.14 (November 4, 2015) ------------------------ Mail: * Spamassassin's network-based tests (Pyzor, others) and DKIM tests are now enabled. (Pyzor had always been installed but was not active due to a misconfiguration.) * Moving spam out of the Spam folder and into Trash would incorrectly train Spamassassin that those messages were not spam. * Automatically create the Sent and Archive folders for new users. * The HTML5_Notifier plugin for Roundcube is now included, which when turned on in Roundcube settings provides desktop notifications for new mail. * The Exchange/ActiveSync backend Z-Push has been updated to fix a problem with CC'd emails not being sent to the CC recipients. Calender/Contacts: * CalDAV/CardDAV and Exchange/ActiveSync for calendar/contacts wasn't working in some network configurations. Web: * When a new domain is added to the box, rather than applying a new self-signed certificate for that domain, the SSL certificate for the box's primary hostname will be used instead. * If a custom DNS record is set on a domain or 'www'+domain, web would not be served for that domain. If the custom DNS record is just the box's IP address, that's a configuration mistake, but allow it and let web continue to be served. * Accommodate really long domain names by increasing an nginx setting. Control panel: * Added an option to check for new Mail-in-a-Box versions within status checks. It is off by default so that boxes don't "phone home" without permission. * Added a random password generator on the users page to simplify creating new accounts. * When S3 backup credentials are set, the credentials are now no longer ever sent back from the box to the client, for better security. * Fixed the jumpiness when a modal is displayed. * Focus is put into the login form fields when the login form is displayed. * Status checks now include a warning if a custom DNS record has been set on a domain that would normally serve web and as a result that domain no longer is serving web. * Status checks now check that secondary nameservers, if specified, are actually serving the domains. * Some errors in the control panel when there is invalid data in the database or an improperly named archived user account have been suppressed. * Added subresource integrity attributes to all remotely-sourced resources (i.e. via CDNs) to guard against CDNs being used as an attack vector. System: * Tweaks to fail2ban settings. * Fixed a spurious warning while installing munin. v0.13b (August 30, 2015) ------------------------ Another ownCloud 8.1.1 issue was found. New installations left ownCloud improperly setup ("You are accessing the server from an untrusted domain."). Upgrading to this version will fix that. v0.13a (August 23, 2015) ------------------------ Note: v0.13 (no 'a', August 19, 2015) was pulled immediately due to an ownCloud bug that prevented upgrades. v0.13a works around that problem. Mail: * Outbound mail headers (the Received: header) are tweaked to possibly improve deliverability. * Some MIME messages would hang Roundcube due to a missing package. * The users permitted to send as an alias can now be different from where an alias forwards to. DNS: * The secondary nameservers option in the control panel now accepts more than one nameserver and a special xfr:IP format to specify zone-transfer-only IP addresses. * A TLSA record is added for HTTPS for DNSSEC-aware clients that support it. System: * Backups can now be turned off, or stored in Amazon S3, through new control panel options. * Munin was not working on machines confused about their hostname and had lots of errors related to PANGO, NTP peers and network interfaces that were not up. * ownCloud updated to version 8.1.1 (with upgrade work-around), its memcached caching enabled. * When upgrading, network checks like blocked port 25 are now skipped. * Tweaks to the intrusion detection rules for IMAP. * Mail-in-a-Box's setup is a lot quieter, hiding lots of irrelevant messages. Control panel: * SSL certificate checks were failing on OVH/OpenVZ servers due to missing /dev/stdin. * Improve the sort order of the domains in the status checks. * Some links in the control panel were only working in Chrome. v0.12c (July 19, 2015) ---------------------- v0.12c was posted to work around the current Sourceforge.net outage: pyzor's remote server is now hard-coded rather than accessing a file hosted on Sourceforge, and roundcube is now downloaded from a Mail-in-a-Box mirror rather than from Sourceforge. v0.12b (July 4, 2015) --------------------- This version corrects a minor regression in v0.12 related to creating aliases targeting multiple addresses. v0.12 (July 3, 2015) -------------------- This is a minor update to v0.11, which was a major update. Please read v0.11's advisories. * The administrator@ alias was incorrectly created starting with v0.11. If your first install was v0.11, check that the administrator@ alias forwards mail to you. * Intrusion detection rules (fail2ban) are relaxed (i.e. less is blocked). * SSL certificates could not be installed for the new automatic 'www.' redirect domains. * PHP's default character encoding is changed from no default to UTF8. The effect of this change is unclear but should prevent possible future text conversion issues. * User-installed SSL private keys in the BEGIN PRIVATE KEY format were not accepted. * SSL certificates with SAN domains with IDNA encoding were broken in v0.11. * Some IDNA functionality was using IDNA 2003 rather than IDNA 2008. v0.11b (June 29, 2015) ---------------------- v0.11b was posted shortly after the initial posting of v0.11 to correct a missing dependency for the new PPA. v0.11 (June 29, 2015) --------------------- Advisories: * Users can no longer spoof arbitrary email addresses in outbound mail. When sending mail, the email address configured in your mail client must match the SMTP login username being used, or the email address must be an alias with the SMTP login username listed as one of the alias's targets. * This update replaces your DKIM signing key with a stronger key. Because of DNS caching/propagation, mail sent within a few hours after this update could be marked as spam by recipients. If you use External DNS, you will need to update your DNS records. * The box will now install software from a new Mail-in-a-Box PPA on Launchpad.net, where we are distributing two of our own packages: a patched postgrey and dovecot-lucene. Mail: * Greylisting will now let some reputable senders pass through immediately. * Searching mail (via IMAP) will now be much faster using the dovecot lucene full text search plugin. * Users can no longer spoof arbitrary email addresses in outbound mail (see above). * Fix for deleting admin@ and postmaster@ addresses. * Roundcube is updated to version 1.1.2, plugins updated. * Exchange/ActiveSync autoconfiguration was not working on all devices (e.g. iPhone) because of a case-sensitive URL. * The DKIM signing key has been increased to 2048 bits, from 1024, replacing the existing key. Web: * 'www' subdomains now automatically redirect to their parent domain (but you'll need to install an SSL certificate). * OCSP no longer uses Google Public DNS. * The installed PHP version is no longer exposed through HTTP response headers, for better security. DNS: * Default IPv6 AAAA records were missing since version 0.09. Control panel: * Resetting a user's password now forces them to log in again everywhere. * Status checks were not working if an ssh server was not installed. * SSL certificate validation now uses the Python cryptography module in some places where openssl was used. * There is a new tab to show the installed version of Mail-in-a-Box and to fetch the latest released version. System: * The munin system monitoring tool is now installed and accessible at /admin/munin. * ownCloud updated to version 8.0.4. The ownCloud installation step now is resilient to download problems. The ownCloud configuration file is now stored in STORAGE_ROOT to fix loss of data when moving STORAGE_ROOT to a new machine. * The setup scripts now run `apt-get update` prior to installing anything to ensure the apt database is in sync with the packages actually available. v0.10 (June 1, 2015) -------------------- * SMTP Submission (port 587) began offering the insecure SSLv3 protocol due to a misconfiguration in the previous version. * Roundcube now allows persistent logins using Roundcube-Persistent-Login-Plugin. * ownCloud is updated to version 8.0.3. * SPF records for non-mail domains were tightened. * The minimum greylisting delay has been reduced from 5 minutes to 3 minutes. * Users and aliases weren't working if they were entered with any uppercase letters. Now only lowercase is allowed. * After installing an SSL certificate from the control panel, the page wasn't being refreshed. * Backups broke if the box's hostname was changed after installation. * Dotfiles (i.e. .svn) stored in ownCloud Files were not accessible from ownCloud's mobile/desktop clients. * Fix broken install on OVH VPS's. v0.09 (May 8, 2015) ------------------- Mail: * Spam checking is now performed on messages larger than the previous limit of 64KB. * POP3S is now enabled (port 995). * Roundcube is updated to version 1.1.1. * Minor security improvements (more mail headers with user agent info are anonymized; crypto settings were tightened). ownCloud: * Downloading files you uploaded to ownCloud broke because of a change in ownCloud 8. DNS: * Internationalized Domain Names (IDNs) should now work in email. If you had custom DNS or custom web settings for internationalized domains, check that they are still working. * It is now possible to set multiple TXT and other types of records on the same domain in the control panel. * The custom DNS API was completely rewritten to support setting multiple records of the same type on a domain. Any existing client code using the DNS API will have to be rewritten. (Existing code will just get 404s back.) * On some systems the `nsd` service failed to start if network interfaces were not ready. System / Control Panel: * In order to guard against misconfiguration that can lead to domain control validation hijacking, email addresses that begin with admin, administrator, postmaster, hostmaster, and webmaster can no longer be used for (new) mail user accounts, and aliases for these addresses may direct mail only to the box's administrator(s). * Backups now use duplicity's built-in gpg symmetric AES256 encryption rather than my home-brewed encryption. Old backups will be incorporated inside the first backup after this update but then deleted from disk (i.e. your backups from the previous few days will be backed up). * There was a race condition between backups and the new nightly status checks. * The control panel would sometimes lock up with an unnecessary loading indicator. * You can no longer delete your own account from the control panel. Setup: * All Mail-in-a-Box release tags are now signed on github, instructions for verifying the signature are added to the README, and the integrity of some packages downloaded during setup is now verified against a SHA1 hash stored in the tag itself. * Bugs in first user account creation were fixed. v0.08 (April 1, 2015) --------------------- Mail: * The Roundcube vacation_sieve plugin by @arodier is now installed to make it easier to set vacation auto-reply messages from within Roundcube. * Authentication-Results headers for DMARC, added in v0.07, were mistakenly added for outbound mail --- that's now removed. * The Trash folder is now created automatically for new mail accounts, addressing a Roundcube error. DNS: * Custom DNS TXT records were not always working and they can now override the default SPF, DKIM, and DMARC records. System: * ownCloud updated to version 8.0.2. * Brute-force SSH and IMAP login attempts are now prevented by properly configuring fail2ban. * Status checks are run each night and any changes from night to night are emailed to the box administrator (the first user account). Control panel: * The new check that system services are running mistakenly checked that the Dovecot Managesieve service is publicly accessible. Although the service binds to the public network interface we don't open the port in ufw. On some machines it seems that ufw blocks the connection from the status checks (which seems correct) and on some machines (mine) it doesn't, which is why I didn't notice the problem. * The current backup chain will now try to predict how many days until it is deleted (always at least 3 days after the next full backup). * The list of aliases that forward to a user are removed from the Mail Users page because when there are many alises it is slow and times-out. * Some status check errors are turned into warnings, especially those that might not apply if External DNS is used. v0.07 (February 28, 2015) ------------------------- Mail: * If the box manages mail for a domain and a subdomain of that domain, outbound mail from the subdomain was not DKIM-signed and would therefore fail DMARC tests on the receiving end, possibly result in the mail heading into spam folders. * Auto-configuration for Mozilla Thunderbird, Evolution, KMail, and Kontact is now available. * Domains that only have a catch-all alias or domain alias no longer automatically create/require admin@ and postmaster@ addresses since they'll forward anyway. * Roundcube is updated to version 1.1.0. * Authentication-Results headers for DMARC are now added to incoming mail. DNS: * If a custom CNAME record is set on a 'www' subdomain, the default A/AAAA records were preventing the CNAME from working. * If a custom DNS A record overrides one provided by the box, the a corresponding default IPv6 record by the box is removed since it will probably be incorrect. * Internationalized domain names (IDNs) are now supported for DNS and web, but email is not yet tested. Web: * Static websites now deny access to certain dot (.) files and directories which typically have sensitive info: .ht*, .svn*, .git*, .hg*, .bzr*. * The nginx server no longer reports its version and OS for better privacy. * The HTTP->HTTPS redirect is now more efficient. * When serving a 'www.' domain, reuse the SSL certificate for the parent domain if it covers the 'www' subdomain too * If a custom DNS CNAME record is set on a domain, don't offer to put a website on that domain. (Same logic already applies to custom A/AAAA records.) Control panel: * Status checks now check that system services are actually running by pinging each port that should have something running on it. * The status checks are now parallelized so they may be a little faster. * The status check for MX records now allow any priority, in case an unusual setup is required. * The interface for setting website domain-specific directories is simplified. * The mail guide now says that to use Outlook, Outlook 2007 or later on Windows 7 and later is required. * External DNS settings now skip the special "_secondary_nameserver" key which is used for storing secondary NS information. Setup: * Install cron if it isn't already installed. * Fix a units problem in the minimum memory check. * If you override the STORAGE_ROOT, your setting will now persist if you re-run setup. * Hangs due to apt wanting the user to resolve a conflict should now be fixed (apt will just clobber the problematic file now). v0.06 (January 4, 2015) ----------------------- Mail: * Set better default system limits to accommodate boxes handling mail for 20+ users. Contacts/calendar: * Update to ownCloud to 7.0.4. * Contacts syncing via ActiveSync wasn't working. Control panel: * New control panel for setting custom DNS settings (without having to use the API). * Status checks showed a false positive for Spamhause blacklists and for secondary DNS in some cases. * Status checks would fail to load if openssh-sever was not pre-installed, but openssh-server is not required. * The local DNS cache is cleared before running the status checks using 'rncd' now rather than restarting 'bind9', which should be faster and wont interrupt other services. * Multi-domain and wildcard certificate can now be installed through the control panel. * The DNS API now allows the setting of SRV records. Misc: * IPv6 configuration error in postgrey, nginx. * Missing dependency on sudo. v0.05 (November 18, 2014) ------------------------- Mail: * The maximum size of outbound mail sent via webmail and Exchange/ActiveSync has been increased to 128 MB, the same as when using SMTP. * Spam is no longer wrapped as an attachment inside a scary Spamassassin explanation. The original message is simply moved straight to the Spam folder unchanged. * There is a new iOS/Mac OS X Configuration Profile link in the control panel which makes it easier to configure IMAP/SMTP/CalDAV/CardDAV on iOS devices and Macs. * "Domain aliases" can now be configured in the control panel. * Updated to [Roundcube 1.0.3](http://trac.roundcube.net/wiki/Changelog). * IMAP/SMTP is now recommended even on iOS devices as Exchange/ActiveSync is terribly buggy. Control panel: * Installing an SSL certificate for the primary hostname would cause problems until a restart (services needed to be restarted). * Installing SSL certificates would fail if /tmp was on a different filesystem. * Better error messages when installing a SSL certificate fails. * The local DNS cache is now cleared each time the system status checks are run. * Documented how to use +tag addressing. * Minor UI tweaks. Other: * Updated to [ownCloud 7.0.3](http://owncloud.org/changelog/). * The ownCloud API is now exposed properly. * DNSSEC now works on `.guide` domains now too (RSASHA256). v0.04 (October 15, 2014) ------------------------ Breaking changes: * On-disk backups are now retained for a minimum of 3 days instead of 14. Beyond that the user is responsible for making off-site copies. * IMAP no longer supports the legacy SSLv3 protocol. SSLv3 is now known to be insecure. I don't believe any modern devices will be affected by this. HTTPS and SMTP submission already had SSLv3 disabled. Control panel: * The control panel has a new page for installing SSL certificates. * The control panel has a new page for hosting static websites. * The control panel now shows mailbox sizes on disk. * It is now possible to create catch-all aliases from the control panel. * Many usability improvements in the control panel. DNS: * Custom DNS A/AAAA records on subdomains were ignored. * It is now possible to set up a secondary DNS server. * DNS zones were updating even when nothing changed. * Strict SPF and DMARC settings are now set on all subdomains not used for mail. Security: * DNSSEC is now supported for the .email TLD which required a different key algorithm. * Nginx and Postfix now use 2048 bits of DH parameters instead of 1024. Other: * Spam filter learning by dragging mail in and out of the Spam folder should hopefully be working now. * Some things were broken if the machine had an IPv6 address. * Other things were broken if the machine was on a non-utf8 locale. * No longer implementing webfinger. * Removes apache before installing nginx, in case it has been installed by distro. v0.03 (September 24, 2014) -------------------------- * Update existing installs of Roundcube. * Disabled catch-alls pending figuring out how to get users to take precedence. * Z-Push was not working because in v0.02 we had accidentally moved to a different version. * Z-Push is now locked to a specific commit so it doesn't change on us accidentally. * The start script is now symlinked to /usr/local/bin/mailinabox. v0.02 (September 21, 2014) -------------------------- * Open the firewall to an alternative SSH port if set. * Fixed missing dependencies. * Set Z-Push to use sync command with ownCloud. * Support more concurrent connections for z-push. * In the status checks, handle wildcard certificates. * Show the status of backups in the control panel. * The control panel can now update a user's password. * Some usability improvements in the control panel. * Warn if a SSL cert is expiring in 30 days. * Use SHA2 to generate CSRs. * Better logic for determining when to take a full backup. * Reduce DNS TTL, not that it seems to really matter. * Add SSHFP DNS records. * Add an API for setting custom DNS records * Update to ownCloud 7.0.2. * Some things were broken if the machine had an IPv6 address. * Use a dialogs library to ask users questions during setup. * Other fixes. v0.01 (August 19, 2014) ----------------------- First versioned release after a year of unversioned development. ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Mail-in-a-Box Code of Conduct Mail-in-a-Box is an open source community project about working, as a group, to empower ourselves and others to have control over our own digital communications. Just as we hope to increase technological diversity on the Internet through decentralization, we also believe that diverse viewpoints and voices among our community members foster innovation and creative solutions to the challenges we face. We are committed to providing a safe, welcoming, and harassment-free space for collaboration, for everyone, without regard to age, disability, economic situation, ethnicity, gender identity and expression, language fluency, level of knowledge or experience, nationality, personal appearance, race, religion, sexual identity and orientation, or any other attribute. Community comes first. This policy supersedes all other project goals. The maintainers of Mail-in-a-Box share the dual responsibility of leading by example and enforcing these policies as necessary to maintain an open and welcoming environment. All community members should be excellent to each other. ## Scope This Code of Conduct applies to all places where Mail-in-a-Box community activity is occurring, including on GitHub, in discussion forums, on Slack, on social media, and in real life. The Code of Conduct applies not only on websites/at events run by the Mail-in-a-Box community (e.g. our GitHub organization, our Slack team) but also at any other location where the Mail-in-a-Box community is present (e.g. in issues of other GitHub organizations where Mail-in-a-Box community members are discussing problems related to Mail-in-a-Box, or real-life professional conferences), or whenever a Mail-in-a-Box community member is representing Mail-in-a-Box to the public at large or acting on behalf of Mail-in-a-Box. This code does not apply to activity on a server running Mail-in-a-Box software, unless your server is hosting a service for the Mail-in-a-Box community at large. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Showing empathy towards other community members * Making room for new and quieter voices Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory/unwelcome comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Aggressive and micro-aggressive behavior, such as unconstructive criticism, providing corrections that do not improve the conversation (sometimes referred to as "well actually"s), repeatedly interrupting or talking over someone else, feigning surprise at someone's lack of knowledge or awareness about a topic, or subtle prejudice (for example, comments like "That's so easy my grandmother could do it.", which is prejudicial toward grandmothers). * Other conduct which could reasonably be considered inappropriate in a professional setting * Retaliating against anyone who reports a violation of this code. We will not tolerate harassment. Harassment is any unwelcome or hostile behavior towards another person for any reason. This includes, but is not limited to, offensive verbal comments related to personal characteristics or choices, sexual images or comments, deliberate intimidation, bullying, stalking, following, harassing photography or recording, sustained disruption of discussion or events, nonconsensual publication of private comments, inappropriate physical contact, or unwelcome sexual attention. Conduct need not be intentional to be harassment. ## Enforcement We will remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not consistent with this Code of Conduct. We may ban, temporarily or permanently, any contributor for violating this code, when appropriate. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project lead, [Joshua Tauberer](https://razor.occams.info/). All reports will be treated confidentially, impartially, consistently, and swiftly. Because the need for confidentiality for all parties involved in an enforcement action outweighs the goals of openness, limited information will be shared with the Mail-in-a-Box community regarding enforcement actions that have taken place. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant, version 1.4](http://contributor-covenant.org/version/1/4) and the code of conduct of [Code for DC](http://codefordc.org/resources/codeofconduct.html). ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Mail-in-a-Box is an open source project. Your contributions and pull requests are welcome. ## Development To start developing Mail-in-a-Box, [clone the repository](https://github.com/mail-in-a-box/mailinabox) and familiarize yourself with the code. $ git clone https://github.com/mail-in-a-box/mailinabox ### Vagrant and VirtualBox We recommend you use [Vagrant](https://www.vagrantup.com/intro/getting-started/install.html) and [VirtualBox](https://www.virtualbox.org/wiki/Downloads) for development. Please install them first. With Vagrant set up, the following should boot up Mail-in-a-Box inside a virtual machine: $ vagrant up --provision _If you're seeing an error message about your *IP address being listed in the Spamhaus Block List*, simply uncomment the `export SKIP_NETWORK_CHECKS=1` line in `Vagrantfile`. It's normal, you're probably using a dynamic IP address assigned by your Internet provider–they're almost all listed._ ### Modifying your `hosts` file After a while, Mail-in-a-Box will be available at `192.168.56.4` (unless you changed that in your `Vagrantfile`). To be able to use the web-based bits, we recommend to add a hostname to your `hosts` file: $ echo "192.168.56.4 mailinabox.lan" | sudo tee -a /etc/hosts You should now be able to navigate to https://mailinabox.lan/admin using your browser. There should be an initial admin user with the name `me@mailinabox.lan` and the password `12345678`. ### Making changes Your working copy of Mail-in-a-Box will be mounted inside your VM at `/vagrant`. Any change you make locally will appear inside your VM automatically. Running `vagrant up --provision` again will repeat the installation with your modifications. Alternatively, you can also ssh into the VM using: $ vagrant ssh Once inside the VM, you can re-run individual parts of the setup like in this example: vm$ cd /vagrant vm$ sudo setup/owncloud.sh # replace with script you'd like to re-run ### Tests Mail-in-a-Box needs more tests. If you're still looking for a way to help out, writing and contributing tests would be a great start! ## Public domain This project is in the public domain. Copyright and related rights in the work worldwide are waived through the [CC0 1.0 Universal public domain dedication][CC0]. See the LICENSE file in this directory. All contributions to this project must be released under the same CC0 wavier. By submitting a pull request or patch, you are agreeing to comply with this waiver of copyright interest. [CC0]: http://creativecommons.org/publicdomain/zero/1.0/ ## Code of Conduct This project has a [Code of Conduct](CODE_OF_CONDUCT.md). Please review it when joining our community. ================================================ FILE: LICENSE ================================================ Creative Commons Legal Code CC0 1.0 Universal CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. Statement of Purpose The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; ii. moral rights retained by the original author(s) and/or performer(s); iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; v. rights protecting the extraction, dissemination, use and reuse of data in a Work; vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 4. Limitations and Disclaimers. a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. ================================================ FILE: README.md ================================================ Mail-in-a-Box ============= By [@JoshData](https://github.com/JoshData) and [contributors](https://github.com/mail-in-a-box/mailinabox/graphs/contributors). Mail-in-a-Box helps individuals take back control of their email by defining a one-click, easy-to-deploy SMTP+everything else server: a mail server in a box. **Please see [https://mailinabox.email](https://mailinabox.email) for the project's website and setup guide!** * * * Our goals are to: * Make deploying a good mail server easy. * Promote [decentralization](http://redecentralize.org/), innovation, and privacy on the web. * Have automated, auditable, and [idempotent](https://web.archive.org/web/20190518072631/https://sharknet.us/2014/02/01/automated-configuration-management-challenges-with-idempotency/) configuration. * **Not** make a totally unhackable, NSA-proof server. * **Not** make something customizable by power users. Additionally, this project has a [Code of Conduct](CODE_OF_CONDUCT.md), which supersedes the goals above. Please review it when joining our community. In The Box ---------- Mail-in-a-Box turns a fresh Ubuntu 22.04 LTS 64-bit machine into a working mail server by installing and configuring various components. It is a one-click email appliance. There are no user-configurable setup options. It "just works." The components installed are: * SMTP ([postfix](http://www.postfix.org/)), IMAP ([Dovecot](http://dovecot.org/)), CardDAV/CalDAV ([Nextcloud](https://nextcloud.com/)), and Exchange ActiveSync ([z-push](http://z-push.org/)) servers * Webmail ([Roundcube](http://roundcube.net/)), mail filter rules (thanks to Roundcube and Dovecot), and email client autoconfig settings (served by [nginx](http://nginx.org/)) * Spam filtering ([spamassassin](https://spamassassin.apache.org/)) and greylisting ([postgrey](http://postgrey.schweikert.ch/)) * DNS ([nsd4](https://www.nlnetlabs.nl/projects/nsd/)) with [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), DKIM ([OpenDKIM](http://www.opendkim.org/)), [DMARC](https://en.wikipedia.org/wiki/DMARC), [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC), [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), [MTA-STS](https://tools.ietf.org/html/rfc8461), and [SSHFP](https://tools.ietf.org/html/rfc4255) policy records automatically set * TLS certificates are automatically provisioned using [Let's Encrypt](https://letsencrypt.org/) for protecting https and all of the other services on the box * Backups ([duplicity](http://duplicity.nongnu.org/)), firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page)), and basic system monitoring ([munin](http://munin-monitoring.org/)) It also includes system management tools: * Comprehensive health monitoring that checks each day that services are running, ports are open, TLS certificates are valid, and DNS records are correct * A control panel for adding/removing mail users, aliases, custom DNS records, configuring backups, etc. * An API for all of the actions on the control panel Internationalized domain names are supported and configured easily (but SMTPUTF8 is not supported, unfortunately). It also supports static website hosting since the box is serving HTTPS anyway. (To serve a website for your domains elsewhere, just add a custom DNS "A" record in you Mail-in-a-Box's control panel to point domains to another server.) For more information on how Mail-in-a-Box handles your privacy, see the [security details page](security.md). Installation ------------ See the [setup guide](https://mailinabox.email/guide.html) for detailed, user-friendly instructions. For experts, start with a completely fresh (really, I mean it) Ubuntu 22.04 LTS 64-bit machine. On the machine... Clone this repository and checkout the tag corresponding to the most recent release (which you can find in the tags or releases lists on GitHub): $ git clone https://github.com/mail-in-a-box/mailinabox $ cd mailinabox $ git checkout TAGNAME Begin the installation. $ sudo setup/start.sh The installation will install, uninstall, and configure packages to turn the machine into a working, good mail server. For help, DO NOT contact Josh directly --- I don't do tech support by email or tweet (no exceptions). Post your question on the [discussion forum](https://discourse.mailinabox.email/) instead, where maintainers and Mail-in-a-Box users may be able to help you. Note that while we want everything to "just work," we can't control the rest of the Internet. Other mail services might block or spam-filter email sent from your Mail-in-a-Box. This is a challenge faced by everyone who runs their own mail server, with or without Mail-in-a-Box. See our discussion forum for tips about that. Contributing and Development ---------------------------- Mail-in-a-Box is an open source project. Your contributions and pull requests are welcome. See [CONTRIBUTING](CONTRIBUTING.md) to get started. The Acknowledgements -------------------- This project was inspired in part by the ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) blog post by Drew Crawford, [Sovereign](https://github.com/sovereign/sovereign) by Alex Payne, and conversations with @shevski, @konklone, and @GregElin. Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/) and [Modoboa](https://github.com/tonioo/modoboa). The History ----------- * In 2007 I wrote a relatively popular Mozilla Thunderbird extension that added client-side SPF and DKIM checks to mail to warn users about possible phishing: [add-on page](https://addons.mozilla.org/en-us/thunderbird/addon/sender-verification-anti-phish/), [source](https://github.com/JoshData/thunderbird-spf). * In August 2013 I began Mail-in-a-Box by combining my own mail server configuration with the setup in ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) and making the setup steps reproducible with bash scripts. * Mail-in-a-Box was a semifinalist in the 2014 [Knight News Challenge](https://www.newschallenge.org/challenge/2014/submissions/mail-in-a-box), but it was not selected as a winner. * Mail-in-a-Box hit the front page of Hacker News in [April](https://news.ycombinator.com/item?id=7634514) 2014, [September](https://news.ycombinator.com/item?id=8276171) 2014, [May](https://news.ycombinator.com/item?id=9624267) 2015, and [November](https://news.ycombinator.com/item?id=13050500) 2016. * FastCompany mentioned Mail-in-a-Box a [roundup of privacy projects](http://www.fastcompany.com/3047645/your-own-private-cloud) on June 26, 2015. ================================================ FILE: Vagrantfile ================================================ # -*- mode: ruby -*- # vi: set ft=ruby : Vagrant.configure("2") do |config| config.vm.box = "ubuntu/jammy64" # Network config: Since it's a mail server, the machine must be connected # to the public web. However, we currently don't want to expose SSH since # the machine's box will let anyone log into it. So instead we'll put the # machine on a private network. config.vm.hostname = "mailinabox.lan" config.vm.network "private_network", ip: "192.168.56.4" config.vm.provision :shell, :inline => <<-SH # Set environment variables so that the setup script does # not ask any questions during provisioning. We'll let the # machine figure out its own public IP. export NONINTERACTIVE=1 export PUBLIC_IP=auto export PUBLIC_IPV6=auto export PRIMARY_HOSTNAME=auto #export SKIP_NETWORK_CHECKS=1 # Start the setup script. cd /vagrant setup/start.sh SH end ================================================ FILE: api/docs/generate-docs.sh ================================================ #!/usr/bin/env sh # Requirements: # - Node.js # - redoc-cli (`npm install redoc-cli -g`) redoc-cli bundle ../mailinabox.yml \ -t template.hbs \ -o api-docs.html \ --templateOptions.metaDescription="Mail-in-a-Box HTTP API" \ --title="Mail-in-a-Box HTTP API" \ --options.expandSingleSchemaField \ --options.hideSingleRequestSampleTab \ --options.jsonSampleExpandLevel=10 \ --options.hideDownloadButton \ --options.theme.logo.maxHeight=180px \ --options.theme.logo.maxWidth=180px \ --options.theme.colors.primary.main="#C52" \ --options.theme.typography.fontSize=16px \ --options.theme.typography.fontFamily="Raleway, sans-serif" \ --options.theme.typography.headings.fontFamily="Ubuntu, Arial, sans-serif" \ --options.theme.typography.code.fontSize=15px \ --options.theme.typography.code.fontFamily='"Source Code Pro", monospace' ================================================ FILE: api/docs/template.hbs ================================================ {{title}} {{{redocHead}}} {{{redocHTML}}} ================================================ FILE: api/mailinabox.yml ================================================ openapi: 3.0.3 info: title: Mail-in-a-Box description: | Mail-in-a-Box API HTTP specification. # Introduction This API is documented in [**OpenAPI format**](http://spec.openapis.org/oas/v3.0.3). ([View the full HTTP specification](https://raw.githubusercontent.com/mail-in-a-box/mailinabox/api-spec/api/mailinabox.yml).) All endpoints are relative to `https://{host}/admin` and are secured with [`Basic Access` authentication](https://en.wikipedia.org/wiki/Basic_access_authentication). If you have multi-factor authentication enabled, authentication with a `user:password` combination will fail unless a valid OTP is supplied via the `x-auth-token` header. Authentication via a `user:user_key` pair is possible without the header being present. contact: name: Mail-in-a-Box support url: https://mailinabox.email/ license: name: CC0 1.0 Universal url: https://creativecommons.org/publicdomain/zero/1.0/legalcode version: 0.51.0 x-logo: url: https://mailinabox.email/static/logo.png altText: Mail-in-a-Box logo externalDocs: description: Find out more about Mail-in-a-box. url: https://mailinabox.email/ servers: - url: https://{host}/admin variables: host: default: box.example.com description: The API hostname. security: - basicAuth: [] tags: - name: User description: Endpoints related to user authentication. - name: Mail description: | Mail operations, which include getting all users, getting all aliases, adding/updating/removing users and aliases and getting all mail domains. - name: DNS description: | DNS operations, which include adding custom records, adding a secondary nameserver and viewing all DNS records. - name: SSL description: | TLS (SSL) Certificates operations, which include checking certificate status and installing custom certificates. - name: Web description: | Static web hosting operations, which include getting domain information and updating domain root directories. - name: MFA description: | Manage multi-factor authentication schemes. Currently, only TOTP is supported. - name: System description: | System operations, which include system status checks, new version checks and reboot status. paths: /login: post: tags: - User summary: Exchange a username and password for a session API key. description: | Returns user information and a session API key. Authenticate a user by supplying the auth token as a base64 encoded string in format `email:password` using basic authentication headers. If successful, a long-lived `api_key` is returned which can be used for subsequent requests to the API in place of the password. operationId: login x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/login" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/MeResponse' examples: invalid: value: reason: Incorrect username or password status: invalid ok: value: api_key: 1a2b3c4d5e6f7g8h9i0j email: user@example.com privileges: - admin status: ok /logout: post: tags: - User summary: Invalidates a session API key. description: | Invalidates a session API key so that it cannot be used after this API call. operationId: logout x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/logout" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/LogoutResponse' /system/status: post: tags: - System summary: Get system status description: | Returns an array of statuses which can include headings. operationId: getSystemStatus x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/system/status" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/SystemStatusResponse' example: - type: heading text: System extra: [] - type: warning text: This domain's DNSSEC DS record is not set extra: - monospace: false text: 'Digest Type: 2 / SHA-25' 403: description: Forbidden content: text/html: schema: type: string /system/version: get: tags: - System summary: Get system version description: Returns installed Mail-in-a-Box version. operationId: getSystemVersion x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/system/version" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/SystemVersionResponse' example: v0.46 403: description: Forbidden content: text/html: schema: type: string /system/latest-upstream-version: post: tags: - System summary: Get system upstream version description: Returns Mail-in-a-Box upstream version. operationId: getSystemUpstreamVersion x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/system/latest-upstream-version" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/SystemVersionUpstreamResponse' example: v0.47 403: description: Forbidden content: text/html: schema: type: string /system/updates: get: tags: - System summary: Get system updates description: Returns system (apt) updates. operationId: getSystemUpdates x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/system/updates" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/SystemUpdatesResponse' example: | libgnutls30 (3.5.18-1ubuntu1.4) libxau6 (1:1.0.8-1ubuntu1) 403: description: Forbidden content: text/html: schema: type: string /system/update-packages: post: tags: - System summary: Update system packages description: Updates system (apt) packages. operationId: updateSystemPackages x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/system/update-packages" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/SystemUpdatePackagesResponse' example: | Calculating upgrade... The following packages will be upgraded: cloud-init grub-common 403: description: Forbidden content: text/html: schema: type: string /system/privacy: get: tags: - System summary: Get system privacy status description: | Returns system privacy (new-version check) status. Response: - `true`: Private, new-version checks will not be performed - `false`: Not private, new-version checks will be performed operationId: getSystemPrivacyStatus x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/system/privacy" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/SystemPrivacyStatusResponse' 403: description: Forbidden content: text/html: schema: type: string post: tags: - System summary: Update system privacy description: | Updates system privacy (new-version checks). Request: - `value: private`: Disable new version checks - `value: off`: Enable new version checks operationId: updateSystemPrivacy requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/SystemPrivacyUpdateRequest' examples: enable: summary: Enable new version checks value: value: 'off' disable: summary: Disable new version checks value: value: private x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/system/privacy" \ -d "value=" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/SystemPrivacyUpdateResponse' example: OK 400: description: Bad request content: text/html: schema: type: string 403: description: Forbidden content: text/html: schema: type: string /system/reboot: get: tags: - System summary: Get system reboot status description: | Returns the system reboot status. Response: - `true`: A reboot is required - `false`: A reboot is not required operationId: getSystemRebootStatus x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/system/reboot" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/SystemRebootStatusResponse' 403: description: Forbidden content: text/html: schema: type: string post: tags: - System summary: Reboot system description: Reboots the system. operationId: rebootSystem x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/system/reboot" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/SystemRebootResponse' example: No reboot is required, so it is not allowed. 403: description: Forbidden content: text/html: schema: type: string /system/backup/status: get: tags: - System summary: Get system backup status description: | Returns the system backup status. If the list of backups is empty, this implies no backups have been made yet. operationId: getSystemBackupStatus x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/system/backup/status" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/SystemBackupStatusResponse' 403: description: Forbidden content: text/html: schema: type: string /system/backup/config: get: tags: - System summary: Get system backup config description: Returns the system backup config. operationId: getSystemBackupConfig x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/system/backup/config" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/SystemBackupConfigResponse' 403: description: Forbidden content: text/html: schema: type: string post: tags: - System summary: Update system backup config description: Updates the system backup config. operationId: updateSystemBackupConfig requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/SystemBackupConfigUpdateRequest' examples: s3: summary: S3 backup value: target: s3://s3.eu-central-1.amazonaws.com/box-example-com target_user: ACCESS_KEY target_pass: SECRET_ACCESS_KEY minAge: 3 local: summary: Local backup value: target: local target_user: '' target_pass: '' minAge: 3 rsync: summary: Rsync backup value: target: rsync://username@box.example.com//backups/box.example.com target_user: '' target_pass: '' minAge: 3 off: summary: Disable backups value: target: 'off' target_user: '' target_pass: '' minAge: 0 x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/system/backup/config" \ -d "target=" \ -d "target_user=" \ -d "target_pass=" \ -d "min_age=" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/SystemBackupConfigUpdateResponse' example: OK 400: description: Bad request content: text/html: schema: type: string 403: description: Forbidden content: text/html: schema: type: string /ssl/status: get: tags: - SSL summary: Get SSL status description: Returns the SSL status for all domains. operationId: getSSLStatus x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/ssl/status" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/SSLStatusResponse' 403: description: Forbidden content: text/html: schema: type: string /ssl/csr/{domain}: post: tags: - SSL summary: Generate SSL CSR description: | Generates a Certificate Signing Request (CSR) for a domain & country code. operationId: generateSSLCSR parameters: - in: path name: domain schema: $ref: '#/components/schemas/Hostname' required: true description: Domain to generate CSR for. requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/SSLCSRGenerateRequest' example: countrycode: 'GB' x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/ssl/csr/" \ -d "countrycode=" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/SSLCSRGenerateResponse' example: | -----BEGIN CERTIFICATE REQUEST----- MIICaDCCAVACAQAwIzELMAkGA1UEBhMCQlMxFDASBgNVBAMMC2V4YW1wbGUuY29t ... JmFDQESSfUxLPHLC660Wnf3GmrP/duZHpPC+qTe8b1AlQ7zDT3cOaAQ+Mb0= -----END CERTIFICATE REQUEST----- 400: description: Bad request content: text/html: schema: type: string 403: description: Forbidden content: text/html: schema: type: string /ssl/install: post: tags: - SSL summary: Install SSL certificate description: | Installs a custom certificate. The chain certificate is optional. operationId: installSSLCertificate requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/SSLCertificateInstallRequest' example: domain: example.com cert: CERT_STRING chain: CHAIN_STRING x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/ssl/install" \ -d "domain=" \ -d "cert=" \ -d "chain=" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/SSLCertificateInstallResponse' example: OK 400: description: Bad request content: text/html: schema: type: string 403: description: Forbidden content: text/html: schema: type: string /ssl/provision: post: tags: - SSL summary: Provision SSL certificates description: | Provisions certificates for all domains. operationId: provisionSSLCertificates x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/ssl/provision" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/SSLCertificatesProvisionResponse' 403: description: Forbidden content: text/html: schema: type: string /dns/secondary-nameserver: get: tags: - DNS summary: Get DNS secondary nameserver description: | Returns a list of nameserver hostnames. operationId: getDnsSecondaryNameserver x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/dns/secondary-nameserver" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/DNSSecondaryNameserverResponse' 403: description: Forbidden content: text/html: schema: type: string post: tags: - DNS summary: Add DNS secondary nameserver description: | Adds one or more secondary nameservers. operationId: addDnsSecondaryNameserver requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/DNSSecondaryNameserverAddRequest' example: hostnames: ns2.hostingcompany.com, ns3.hostingcompany.com x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/dns/secondary-nameserver" \ -d "hostnames=" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/DNSSecondaryNameserverAddResponse' example: 'updated DNS: example.com' 400: description: Bad request content: text/html: schema: type: string example: Could not resolve the IP address of badhostname 403: description: Forbidden content: text/html: schema: type: string /dns/zones: get: tags: - DNS summary: Get DNS zones description: Returns an array of all managed top-level domains. operationId: getDnsZones x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/dns/zones" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/DNSZonesResponse' 403: description: Forbidden content: text/html: schema: type: string /dns/zonefile/{zone}: parameters: - in: path name: zone schema: $ref: '#/components/schemas/Hostname' required: true description: Hostname get: tags: - DNS summary: Get DNS zonefile description: Returns a DNS zone file for a hostname. operationId: getDnsZonefile x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/dns/zonefile/" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/DNSZonefileResponse' 403: description: Forbidden content: text/html: schema: type: string /dns/update: post: tags: - DNS summary: Update DNS description: Updates the DNS. Involves creating zone files and restarting `nsd`. operationId: updateDns requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/DNSUpdateRequest' example: force: 1 x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/dns/update" \ -d "force=" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/DNSUpdateResponse' 400: description: Bad request content: text/html: schema: type: string 403: description: Forbidden content: text/html: schema: type: string /dns/custom: get: tags: - DNS summary: Get DNS custom records description: Returns all custom DNS records. operationId: getDnsCustomRecords x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/dns/custom" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/DNSCustomRecordsResponse' 403: description: Forbidden content: text/html: schema: type: string /dns/custom/{qname}/{rtype}: parameters: - in: path name: qname schema: $ref: '#/components/schemas/Hostname' required: true description: DNS record query name - in: path name: rtype schema: $ref: '#/components/schemas/DNSRecordType' required: true description: Record type get: tags: - DNS summary: Get DNS custom records description: Returns all custom records for the specified query name and type. operationId: getDnsCustomRecordsForQNameAndType x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/dns/custom//" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/DNSCustomRecordsResponse' 403: description: Forbidden content: text/html: schema: type: string post: tags: - DNS summary: Add DNS custom record description: Adds a custom DNS record for the specified query name and type. operationId: addDnsCustomRecord requestBody: $ref: '#/components/requestBodies/DNSCustomRecordRequest' x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/dns/custom//" \ -H "Content-Type: text/plain" \ --data-raw "" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/DNSCustomRecordUpsertResponse' example: 'updated DNS: example.com' 400: description: Bad request content: text/html: schema: type: string example: "'badhostname' does not appear to be an IPv4 or IPv6 address" 403: description: Forbidden content: text/html: schema: type: string put: tags: - DNS summary: Update DNS custom record description: Updates an existing DNS custom record value for the specified qname and type. operationId: updateDnsCustomRecord requestBody: $ref: '#/components/requestBodies/DNSCustomRecordRequest' x-codeSamples: - lang: curl source: | curl -x PUT "https://{host}/admin/dns/custom//" \ -H "Content-Type: text/plain" \ --data-raw "" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/DNSCustomRecordUpsertResponse' example: 'updated DNS: example.com' 400: description: Bad request content: text/html: schema: type: string example: "'badhostname' does not appear to be an IPv4 or IPv6 address" 403: description: Forbidden content: text/html: schema: type: string delete: tags: - DNS summary: Remove DNS custom record description: Removes a DNS custom record for the specified domain, type & value. operationId: removeDnsCustomRecord requestBody: $ref: '#/components/requestBodies/DNSCustomRecordRequest' x-codeSamples: - lang: curl source: | curl -X DELETE "https://{host}/admin/dns/custom//" \ -H "Content-Type: text/plain" \ --data-raw "" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/DNSCustomRecordRemoveResponse' example: 'updated DNS: example.com' 400: description: Bad request content: text/html: schema: type: string example: badhostname is not a domain name or a subdomain of a domain name managed by this box 403: description: Forbidden content: text/html: schema: type: string /dns/custom/{qname}: parameters: - in: path name: qname schema: $ref: '#/components/schemas/Hostname' required: true description: DNS query name. get: tags: - DNS summary: Get DNS custom A records description: Returns all custom A records for the specified query name. operationId: getDnsCustomARecordsForQName x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/dns/custom/" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/DNSCustomRecordsResponse' 403: description: Forbidden content: text/html: schema: type: string post: tags: - DNS summary: Add DNS custom A record description: Adds a custom DNS A record for the specified query name. operationId: addDnsCustomARecord requestBody: $ref: '#/components/requestBodies/DNSCustomRecordRequest' x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/dns/custom/" \ -H "Content-Type: text/plain" \ --data-raw "" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/DNSCustomRecordUpsertResponse' example: 'updated DNS: example.com' 400: description: Bad request content: text/html: schema: type: string example: "'badhostname' does not appear to be an IPv4 or IPv6 address" 403: description: Forbidden content: text/html: schema: type: string put: tags: - DNS summary: Update DNS custom A record description: Updates an existing DNS custom A record value for the specified qname. operationId: updateDnsCustomARecord requestBody: $ref: '#/components/requestBodies/DNSCustomRecordRequest' x-codeSamples: - lang: curl source: | curl -x PUT "https://{host}/admin/dns/custom/" \ -H "Content-Type: text/plain" \ --data-raw "" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/DNSCustomRecordUpsertResponse' example: 'updated DNS: example.com' 400: description: Bad request content: text/html: schema: type: string example: "'badhostname' does not appear to be an IPv4 or IPv6 address" 403: description: Forbidden content: text/html: schema: type: string delete: tags: - DNS summary: Remove DNS custom A record description: Removes a DNS custom A record for the specified domain & value. operationId: removeDnsCustomARecord requestBody: $ref: '#/components/requestBodies/DNSCustomRecordRequest' x-codeSamples: - lang: curl source: | curl -X DELETE "https://{host}/admin/dns/custom/" \ -H "Content-Type: text/plain" \ --data-raw "" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/DNSCustomRecordRemoveResponse' example: 'updated DNS: example.com' 400: description: Bad request content: text/html: schema: type: string example: badhostname is not a domain name or a subdomain of a domain name managed by this box 403: description: Forbidden content: text/html: schema: type: string /dns/dump: get: tags: - DNS summary: Get DNS dump description: Returns all DNS records. operationId: getDnsDump x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/dns/dump" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/DNSDumpResponse' example: - - example1.com - - explanation: Required. Specifies the hostname (and priority) of the machine that handles @example.com mail. qname: example1.com rtype: MX value: 10 box.example1.com. - - example2.com - - explanation: Required. Specifies the hostname (and priority) of the machine that handles @example.com mail. qname: example2.com rtype: MX value: 10 box.example2.com. 403: description: Forbidden content: text/html: schema: type: string /mail/users: get: tags: - Mail summary: Get mail users description: Returns all mail users. operationId: getMailUsers parameters: - in: query name: format schema: $ref: '#/components/schemas/MailUsersResponseFormat' description: The format of the response. x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/mail/users?format=" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/MailUsersResponse' text/html: schema: $ref: '#/components/schemas/MailUsersSimpleResponse' example: | user1@example.com user2@example.com 403: description: Forbidden content: text/html: schema: type: string /mail/users/add: post: tags: - Mail summary: Add mail user description: Adds a new mail user. operationId: addMailUser requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/MailUserAddRequest' examples: normal: summary: Normal user value: email: user@example.com password: s3curE_pa5Sw0rD privileges: '' admin: summary: Admin user value: email: user@example.com password: s3curE_pa5Sw0rD privileges: admin x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/mail/users/add" \ -d "email=" \ -d "password=" \ -d "privileges=" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/MailUserAddResponse' example: | mail user added updated DNS: OpenDKIM configuration 400: description: Bad request content: text/html: schema: type: string example: Invalid email address 403: description: Forbidden content: text/html: schema: type: string /mail/users/remove: post: tags: - Mail summary: Remove mail user description: Removes an existing mail user. operationId: removeMailUser requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/MailUserRemoveRequest' example: email: user@example.com x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/mail/users/remove" \ -d "email=" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/MailUserRemoveResponse' example: OK 400: description: Bad request content: text/html: schema: type: string example: That's not a user (invalid@example.com) 403: description: Forbidden content: text/html: schema: type: string /mail/users/privileges/add: post: tags: - Mail summary: Add mail user privilege description: Adds a privilege to an existing mail user. operationId: addMailUserPrivilege requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/MailUserAddPrivilegeRequest' example: email: user@example.com privilege: admin x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/mail/users/privileges/add" \ -d "email=" \ -d "privilege=" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/MailUserAddPrivilegeResponse' example: OK 400: description: Bad request content: text/html: schema: type: string example: That's not a user (invalid@example.com) 403: description: Forbidden content: text/html: schema: type: string /mail/users/privileges/remove: post: tags: - Mail summary: Remove mail user privilege description: Removes a privilege from an existing mail user. operationId: removeMailUserPrivilege requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/MailUserRemovePrivilegeRequest' example: email: user@example.com privilege: admin x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/mail/users/privileges/remove" \ -d "email=" \ -d "privilege=" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/MailUserRemovePrivilegeResponse' example: OK 400: description: Bad request content: text/html: schema: type: string example: That's not a user (invalid@example.com) 403: description: Forbidden content: text/html: schema: type: string /mail/users/password: post: tags: - Mail summary: Set mail user password description: Sets a password for an existing mail user. operationId: setMailUserPassword requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/MailUserSetPasswordRequest' example: email: user@example.com password: s3curE_pa5Sw0rD x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/mail/users/password" \ -d "email=" \ -d "password=" \ -u ":" \ responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/MailUserSetPasswordResponse' example: OK 400: description: Bad request content: text/html: schema: type: string example: Passwords must be at least eight characters 403: description: Forbidden content: text/html: schema: type: string /mail/users/privileges: get: tags: - Mail summary: Get mail user privileges description: Returns all privileges for an existing mail user. operationId: getMailUserPrivileges parameters: - in: query name: email schema: $ref: '#/components/schemas/Email' description: The email you want to get privileges for. x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/mail/users/privileges?email=" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/MailUserPrivilegesResponse' example: admin 403: description: Forbidden content: text/html: schema: type: string /mail/domains: get: tags: - Mail summary: Get mail domains description: Returns all mail domains. operationId: getMailDomains x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/mail/domains" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/MailDomainsResponse' example: | example1.com example2.com 403: description: Forbidden content: text/html: schema: type: string /mail/aliases: get: tags: - Mail summary: Get mail aliases description: Returns all mail aliases. operationId: getMailAliases parameters: - in: query name: format schema: $ref: '#/components/schemas/MailAliasesResponseFormat' description: The format of the response. x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/mail/aliases?format=" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: type: array items: $ref: '#/components/schemas/MailAliasByDomain' text/html: schema: $ref: '#/components/schemas/MailAliasesSimpleResponse' example: | abuse@example.com administrator@example.com admin@example.com administrator@example.com 403: description: Forbidden content: text/html: schema: type: string /mail/aliases/add: post: tags: - Mail summary: Upsert mail alias description: | Adds or updates a mail alias. If updating, you need to set `update_if_exists: 1`. operationId: upsertMailAlias requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/MailAliasUpsertRequest' examples: regular: summary: Regular alias value: update_if_exists: 0 address: user@example.com forwards_to: user2@example.com permitted_senders: catchall: summary: Catch-all value: update_if_exists: 0 address: '@example.com' forwards_to: user@otherexample.com permitted_senders: domainalias: summary: Domain alias value: update_if_exists: 0 address: '@example.com' forwards_to: '@otherexample.com' permitted_senders: update: summary: Update existing alias value: update_if_exists: 1 address: user@example.com forwards_to: user2@example.com permitted_senders: user3@example.com, user4@example.com x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/mail/aliases/add" \ -d "update_if_exists=" \ -d "address=" \ -d "forwards_to=" \ -d "permitted_senders=" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/MailAliasUpsertResponse' example: alias updated 400: description: Bad request content: text/html: schema: type: string example: Invalid email address (invalid@example.com) 403: description: Forbidden content: text/html: schema: type: string /mail/aliases/remove: post: tags: - Mail summary: Remove mail alias description: Removes a mail alias. operationId: removeMailAlias requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/MailAliasRemoveRequest' example: address: user@example.com x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/mail/aliases/remove" \ -d "address=" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/MailAliasRemoveResponse' example: alias removed 400: description: Bad request content: text/html: schema: type: string example: That's not an alias (invalid@example) 403: description: Forbidden content: text/html: schema: type: string /web/domains: get: tags: - Web summary: Get web domains description: Returns all static web domains. operationId: getWebDomains x-codeSamples: - lang: curl source: | curl -X GET "https://{host}/admin/web/domains" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: type: array items: $ref: '#/components/schemas/WebDomain' 403: description: Forbidden content: text/html: schema: type: string /web/update: post: tags: - Web summary: Update web description: Updates static websites, used for updating domain root directories. operationId: updateWeb x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/web/update" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/WebUpdateResponse' example: web updated 403: description: Forbidden content: text/html: schema: type: string /mfa/status: post: tags: - MFA summary: Retrieve MFA status for you or another user description: Retrieves which type of MFA is used and configuration operationId: mfaStatus x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/mfa/status" \ -u ":" responses: 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/MfaStatusResponse' 403: description: Forbidden content: text/html: schema: type: string /mfa/totp/enable: post: tags: - MFA summary: Enable TOTP authentication description: Enables TOTP authentication for the currently logged-in admin user operationId: mfaTotpEnable x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/mfa/totp/enable" \ -d "code=123456" \ -d "secret=" \ -u ":" requestBody: required: true content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/MfaEnableRequest' responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/MfaEnableSuccessResponse' 400: description: Bad request content: text/html: schema: type: string 403: description: Forbidden content: text/html: schema: type: string /mfa/disable: post: tags: - MFA summary: Disable multi-factor authentication for you or another user description: Disables multi-factor authentication for the currently logged-in admin user or another user if a 'user' parameter is submitted. Either disables all multi-factor authentication methods or the method corresponding to the optional property `mfa_id`. operationId: mfaTotpDisable requestBody: required: false content: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/MfaDisableRequest' x-codeSamples: - lang: curl source: | curl -X POST "https://{host}/admin/mfa/totp/disable" \ -u ":" responses: 200: description: Successful operation content: text/html: schema: $ref: '#/components/schemas/MfaDisableSuccessResponse' 403: description: Forbidden content: text/html: schema: type: string components: securitySchemes: basicAuth: type: http scheme: basic description: | Credentials can be supplied using the `Authorization` header in format `Authorization: Basic {access-token}`. The `access-token` is comprised of the Base64 encoding of `username:password`. The `username` is the mail user's email address, and `password` can either be the mail user's password, or the `api_key` returned from the `login` operation. When using `curl`, you can supply user credentials using the `-u` or `--user` parameter. requestBodies: DNSCustomRecordRequest: required: true content: text/plain: schema: type: string example: '1.2.3.4' description: The value of the DNS record. example: '1.2.3.4' schemas: MailUsersResponseFormat: type: string enum: - text - json example: json description: Response format (`application/json` or `text/html`). MailAliasesResponseFormat: type: string enum: - text - json example: json description: Response format (`application/json` or `text/html`). MailUserSetPasswordResponse: type: string example: OK description: Mail user set password response. MailUserRemoveResponse: type: string example: OK description: Mail user remove response. MailUserAddResponse: type: string example: | mail user added updated DNS: OpenDKIM configuration description: | Mail user add response. Can include information about operations related to adding new users, like updating DNS. MailUserAddPrivilegeResponse: type: string example: OK description: Mail user add admin privilege response. MailUserRemovePrivilegeResponse: type: string example: OK description: Mail user remove admin privilege response. MailUsersSimpleResponse: type: string example: | user1@example.com user2@example.com description: Get mail users text format response. MailUserPrivilegesResponse: $ref: '#/components/schemas/MailUserPrivilege' description: Mail user privileges response. example: admin MailDomainsResponse: type: string example: | example1.com example2.com description: Mail domains response. MailUsersResponse: type: array items: $ref: '#/components/schemas/MailUserByDomain' description: Get mail aliases JSON format response. MailUserByDomain: type: object required: - domain - users properties: domain: $ref: '#/components/schemas/Hostname' users: type: array items: $ref: '#/components/schemas/MailUser' description: Mail users by domain. MailUser: type: object required: - email - privileges - status properties: email: $ref: '#/components/schemas/Email' privileges: type: array items: $ref: '#/components/schemas/MailUserPrivilege' status: $ref: '#/components/schemas/MailUserStatus' mailbox: type: string example: /home/user-data/mail/mailboxes/example.com/user description: Mail user details. MailAliasesSimpleResponse: type: string example: | abuse@example.com administrator@example.com admin@example.com administrator@example.com description: Get mail aliases text format response. MailAliasByDomain: type: object required: - domain - aliases properties: domain: $ref: '#/components/schemas/Hostname' aliases: type: array items: $ref: '#/components/schemas/MailAlias' description: Mail aliases by domain. MailAlias: type: object required: - address - address_display - forwards_to - permitted_senders - required properties: address: $ref: '#/components/schemas/Email' address_display: $ref: '#/components/schemas/Email' forwards_to: type: array items: $ref: '#/components/schemas/Email' permitted_senders: type: array nullable: true items: $ref: '#/components/schemas/Email' required: type: boolean example: true description: Mail alias details. MailAliasUpsertResponse: type: string example: alias updated description: Mail alias add/update response. MailAliasUpsertRequest: type: object required: - update_if_exists - address - forwards_to - permitted_senders properties: update_if_exists: type: integer format: int32 minimum: 0 maximum: 1 example: 1 description: Set to `1` when updating an alias. address: $ref: '#/components/schemas/Email' forwards_to: type: string example: user1@example.com, user2@example.com description: | If adding a regular or catch-all alias, the format needs to be `user@example.com`. Multiple address can be separated by newlines or commas. If adding a domain alias, the format needs to be `@example.com`. permitted_senders: type: string nullable: true example: user1@example.com, user2@example.com description: | Mail users that can send mail claiming to be from any address on the alias domain. Multiple address can be separated by newlines or commas. Leave empty to allow any mail user listed in `forwards_to` to send mail claiming to be from any address on the alias domain. description: Mail alias upsert request. MailAliasRemoveResponse: type: string example: alias removed description: Mail alias remove response. MailAliasRemoveRequest: type: object required: - address properties: address: $ref: '#/components/schemas/Email' description: Mail aliases remove request. DNSRecordType: enum: - A - AAAA - CAA - CNAME - TXT - MX - SRV - SSHFP - NS example: MX description: DNS record type. DNSDumpResponse: type: array items: $ref: '#/components/schemas/DNSDumpDomains' description: DNS dump response. DNSDumpDomains: type: array items: oneOf: - $ref: '#/components/schemas/Hostname' - $ref: '#/components/schemas/DNSDumpDomainRecords' description: | A list of records per domain. The first item in the list is the domain and the second item is the list of records. DNSDumpDomainRecords: type: array items: $ref: '#/components/schemas/DNSDumpDomainRecord' description: List of domain records. DNSDumpDomainRecord: type: object required: - explanation - qname - type - value properties: explanation: type: string example: Required. Specifies the hostname (and priority) of the machine that handles @example.com mail qname: $ref: '#/components/schemas/Hostname' rtype: $ref: '#/components/schemas/DNSRecordType' value: type: string example: 10 example.com. description: Domain DNS record details. DNSCustomRecord: type: object required: - qname - rtype - value properties: qname: $ref: '#/components/schemas/Hostname' rtype: $ref: '#/components/schemas/DNSRecordType' value: type: string example: 10 example.com. description: Custom DNS record detail detail. DNSCustomRecordsResponse: type: array items: $ref: '#/components/schemas/DNSCustomRecord' description: Custom DNS records response. DNSZonesResponse: type: array items: $ref: '#/components/schemas/Hostname' description: DNS zones response. DNSZonefileResponse: type: string DNSSecondaryNameserverResponse: type: object required: - hostnames properties: hostnames: type: array items: type: string example: ns1.example.com description: Secondary nameserver/s response. DNSCustomRecordRemoveResponse: type: string example: 'updated DNS: example.com' description: Custom DNS record remove response. DNSCustomRecordUpsertResponse: type: string example: 'updated DNS: example.com' description: Custom DNS record add response. DNSUpdateRequest: type: object required: - force properties: force: type: integer format: int32 minimum: 0 maximum: 1 example: 1 description: Force an update even if mailinabox detects no changes are required. description: DNS update request. DNSUpdateResponse: type: string example: | updated DNS: example1.com,example2.com description: DNS update response. DNSSecondaryNameserverAddRequest: type: object required: - hostnames properties: hostnames: type: string description: Hostnames separated with commas or spaces. example: ns2.hostingcompany.com, ns3.hostingcompany.com description: Secondary nameserver/s add request. DNSSecondaryNameserverAddResponse: type: string example: 'updated DNS: example.com' description: Secondary nameserver/s add response. SystemPrivacyUpdateRequest: type: object required: - value properties: value: $ref: '#/components/schemas/SystemPrivacyStatus' description: Update system privacy request. SystemPrivacyStatus: type: string enum: - private - 'off' example: private description: System privacy status. MailUserSetPasswordRequest: type: object required: - email - password properties: email: $ref: '#/components/schemas/Email' password: type: string format: password description: Mail user set password request. MailUserAddRequest: type: object required: - email - password - privileges properties: email: $ref: '#/components/schemas/Email' password: type: string format: password privileges: $ref: '#/components/schemas/MailUserPrivilege' description: Mail user add request. MailUserRemoveRequest: type: object required: - email properties: email: $ref: '#/components/schemas/Email' description: Mail user remove request. MailUserStatus: type: string enum: - active - inactive example: active description: Mail user status. MailUserPrivilege: type: string enum: - admin - '' example: admin description: Mail user privilege. MailUserAddPrivilegeRequest: type: object required: - email - privilege properties: email: $ref: '#/components/schemas/Email' privilege: $ref: '#/components/schemas/MailUserPrivilege' description: Mail user add privilege request. MailUserRemovePrivilegeRequest: type: object required: - email - privilege properties: email: $ref: '#/components/schemas/Email' privilege: $ref: '#/components/schemas/MailUserPrivilege' description: Mail user remove privilege request. SSLCSRGenerateRequest: type: object required: - countrycode properties: countrycode: type: string example: GB description: Generate SSL CSR request. SSLCSRGenerateResponse: type: string example: | -----BEGIN CERTIFICATE REQUEST----- MIICaDCCAVACAQAwIzELMAkGA1UEBhMCQlMxFDASBgNVBAMMC2V4YW1wbGUuY29t MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3K6dwLM2Nk8kVhIBaZmp eY6y7O0T3jrexEKlW839TVYdcH+K35V1NxilbMFKMuHeowGwFyyiqOy/OUYNeq+T Rz3s4b1qG2p01dwlsXHHYmXLYTAhvqvY+CU5ksieuZbyHRTwbHViQ0xtRXwoVCnj CkN7kJVpkLfVN0/BG6NBFpv/JI8F+hwp+IHdkC1gUXRrLJNC79ERqFP8HoqdQWNw OGGFaOe2aQhvj2zt8wFncyKVc40UKVbSzGGzdL2MPiAJHgZ2lmeY1xDyX1lOt12R IFPwtxmbxaxYaVfe2hxl7m88xV3OjYcKgwVYDusk2XJ37cGew5g+NbBvzEeEUpF9 5wIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAD7UPC3/Nkgpn53mT9puUonYdJg9 SD8vvTK/N78CzoEgPNyq+bYbqlcvVPKIdItf9TMiqfOSvW3e3NvkRisYle8Qp+0C 8pafXBvQ9eHt5CFeJn4sH9GnxeflOZT/P9Jnp71KtZQvOobirX4GgEWs79g+/NHb Zyf8rbadt9HruNhKA5nlP8cn7Rdc/iuJU8MVSQszI1s1DEcXMPxr6iqb2g87/ifH lWcK59kvRJkCcPhPzjpUy9NulucH4WFA/WqKeDNFS/oC+upV5w8EDEcfnenJFG+N JmFDQESSfUxLPHLC660Wnf3GmrP/duZHpPC+qTe8b1AlQ7zDT3cOaAQ+Mb0= -----END CERTIFICATE REQUEST----- description: Generate SSL CSR response. SSLCertificateInstallRequest: type: object required: - domain - cert - chain properties: domain: $ref: '#/components/schemas/Hostname' cert: type: string description: TLS/SSL certificate. example: | -----BEGIN CERTIFICATE----- MIICaDCCAVACAQAwIzELMAkGA1UEBhMCQlMxFDASBgNVBAMMC2V4YW1wbGUuY29t MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3K6dwLM2Nk8kVhIBaZmp eY6y7O0T3jrexEKlW839TVYdcH+K35V1NxilbMFKMuHeowGwFyyiqOy/OUYNeq+T Rz3s4b1qG2p01dwlsXHHYmXLYTAhvqvY+CU5ksieuZbyHRTwbHViQ0xtRXwoVCnj CkN7kJVpkLfVN0/BG6NBFpv/JI8F+hwp+IHdkC1gUXRrLJNC79ERqFP8HoqdQWNw OGGFaOe2aQhvj2zt8wFncyKVc40UKVbSzGGzdL2MPiAJHgZ2lmeY1xDyX1lOt12R IFPwtxmbxaxYaVfe2hxl7m88xV3OjYcKgwVYDusk2XJ37cGew5g+NbBvzEeEUpF9 5wIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAD7UPC3/Nkgpn53mT9puUonYdJg9 SD8vvTK/N78CzoEgPNyq+bYbqlcvVPKIdItf9TMiqfOSvW3e3NvkRisYle8Qp+0C 8pafXBvQ9eHt5CFeJn4sH9GnxeflOZT/P9Jnp71KtZQvOobirX4GgEWs79g+/NHb Zyf8rbadt9HruNhKA5nlP8cn7Rdc/iuJU8MVSQszI1s1DEcXMPxr6iqb2g87/ifH lWcK59kvRJkCcPhPzjpUy9NulucH4WFA/WqKeDNFS/oC+upV5w8EDEcfnenJFG+N JmFDQESSfUxLPHLC660Wnf3GmrP/duZHpPC+qTe8b1AlQ7zDT3cOaAQ+Mb0= -----END CERTIFICATE----- chain: type: string description: TLS/SSL intermediate chain (if provided, else empty string). example: | -----BEGIN CERTIFICATE----- MIICaDCCAVACAQAwIzELMAkGA1UEBhMCQlMxFDASBgNVBAMMC2V4YW1wbGUuY29t MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3K6dwLM2Nk8kVhIBaZmp eY6y7O0T3jrexEKlW839TVYdcH+K35V1NxilbMFKMuHeowGwFyyiqOy/OUYNeq+T Rz3s4b1qG2p01dwlsXHHYmXLYTAhvqvY+CU5ksieuZbyHRTwbHViQ0xtRXwoVCnj CkN7kJVpkLfVN0/BG6NBFpv/JI8F+hwp+IHdkC1gUXRrLJNC79ERqFP8HoqdQWNw OGGFaOe2aQhvj2zt8wFncyKVc40UKVbSzGGzdL2MPiAJHgZ2lmeY1xDyX1lOt12R IFPwtxmbxaxYaVfe2hxl7m88xV3OjYcKgwVYDusk2XJ37cGew5g+NbBvzEeEUpF9 5wIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAD7UPC3/Nkgpn53mT9puUonYdJg9 SD8vvTK/N78CzoEgPNyq+bYbqlcvVPKIdItf9TMiqfOSvW3e3NvkRisYle8Qp+0C 8pafXBvQ9eHt5CFeJn4sH9GnxeflOZT/P9Jnp71KtZQvOobirX4GgEWs79g+/NHb Zyf8rbadt9HruNhKA5nlP8cn7Rdc/iuJU8MVSQszI1s1DEcXMPxr6iqb2g87/ifH lWcK59kvRJkCcPhPzjpUy9NulucH4WFA/WqKeDNFS/oC+upV5w8EDEcfnenJFG+N JmFDQESSfUxLPHLC660Wnf3GmrP/duZHpPC+qTe8b1AlQ7zDT3cOaAQ+Mb0= -----END CERTIFICATE----- description: Install certificate request. `chain` can be an empty string. SSLCertificateInstallResponse: type: string example: OK description: Install certificate response. SSLCertificatesProvisionResponse: type: object required: - requests properties: requests: type: array items: type: object required: - log - result - domains properties: log: type: array items: type: string example: - 'The domain name does not resolve to this machine: [Not Set] (A), [Not Set] (AAAA).' result: type: string enum: - installed - error - skipped example: installed domains: type: array items: $ref: '#/components/schemas/Hostname' description: SSL certificates provision response. SystemPrivacyStatusResponse: type: boolean description: | System privacy status response. - `true`: Private, new-version checks will not be performed - `false`: Not private, new-version checks will be performed example: false SystemVersionResponse: type: string description: System version response. example: v0.46 SystemVersionUpstreamResponse: type: string description: System version upstream response. example: v0.47 SystemUpdatesResponse: type: string description: System updates response. example: | libgnutls30 (3.5.18-1ubuntu1.4) libxau6 (1:1.0.8-1ubuntu1) SystemUpdatePackagesResponse: type: string example: | Reading package lists... Building dependency tree... Reading state information... Calculating upgrade... The following packages will be upgraded: cloud-init grub-common grub-pc grub-pc-bin grub2-common libgnutls30 libldap-2.4-2 libldap-common libxau6 linux-firmware python3-distupgrade qemu-guest-agent sosreport ubuntu-release-upgrader-core 14 upgraded, 0 newly installed, 0 to remove and 0 not upgraded. Need to get 79.9 MB of archives. After this operation, 3893 kB of additional disk space will be used. Get:1 http://archive.ubuntu.com/ubuntu bionic-updates/main amd64 libgnutls30 amd64 3.5.18-1ubuntu1.4 [645 kB] Preconfiguring packages ... Fetched 79.9 MB in 2s (52.4 MB/s) (Reading database ... 48457 files and directories currently installed.) description: System update packages response. SystemPrivacyUpdateResponse: type: string example: OK description: System privacy update response. SystemRebootStatusResponse: type: boolean description: | System reboot status response. - `true`: A reboot is required - `false`: A reboot is not required example: true SystemRebootResponse: type: string example: No reboot is required, so it is not allowed. description: System reboot response. SystemStatusResponse: type: array items: $ref: '#/components/schemas/StatusEntry' description: System status response. StatusEntry: type: object required: - type - text - extra properties: type: $ref: '#/components/schemas/StatusEntryType' text: type: string example: This domain"s DNSSEC DS record is not set extra: type: array items: $ref: '#/components/schemas/StatusEntryExtra' description: System status entry. StatusEntryType: type: string enum: - heading - ok - warning - error example: warning description: System status entry type. StatusEntryExtra: type: object required: - monospace - text properties: monospace: type: boolean example: false text: type: string example: 'Digest Type: 2 / SHA-256' description: System entry extra information. SystemBackupConfigUpdateRequest: type: object required: - target - target_user - target_pass - min_age properties: target: type: string format: hostname example: s3://s3.eu-central-1.amazonaws.com/box-example-com target_user: type: string example: username target_pass: type: string example: password format: password min_age: type: integer format: int32 minimum: 1 example: 3 description: Backup config update request. SystemBackupConfigUpdateResponse: type: string example: OK description: Backup config update response. SystemBackupConfigResponse: type: object required: - enc_pw_file - file_target_directory - min_age_in_days - ssh_pub_key - target properties: enc_pw_file: type: string example: /home/user-data/backup/secret_key.txt file_target_directory: type: string example: /home/user-data/backup/encrypted min_age_in_days: type: integer format: int32 minimum: 1 example: 3 ssh_pub_key: type: string example: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDb root@box.example.com\n target: type: string format: hostname example: s3://s3.eu-central-1.amazonaws.com/box-example-com target_user: type: string target_pass: type: string description: Backup config response. SystemBackupStatusResponse: type: object required: - unmatched_file_size properties: backups: type: array items: $ref: '#/components/schemas/SystemBackupStatus' unmatched_file_size: type: integer format: int32 example: 0 error: type: string example: Something is wrong with the backup description: Backup status response. Lists the status for all backups. SystemBackupStatus: type: object required: - date - date_delta - date_str - full - size - volumes properties: date: type: string format: date-time example: 20200801T023706Z date_delta: type: string example: 15 hours, 40 minutes date_str: type: string example: 2020-08-01 03:37:06 BST deleted_in: type: string example: approx. 6 days full: type: boolean example: false size: type: integer format: int32 example: 125332 volumes: type: integer format: int32 example: 1 description: Backup status details. SSLStatusResponse: type: object required: - can_provision - status properties: can_provision: type: array items: type: string status: type: array items: $ref: '#/components/schemas/SSLStatus' description: SSL status response for all relevant domains. SSLStatus: type: object required: - domain - status - text properties: domain: $ref: '#/components/schemas/Hostname' status: $ref: '#/components/schemas/SSLStatusType' text: type: string example: Signed & valid. The certificate expires in 87 days on 10/28/20. description: SSL status details for domain. SSLStatusType: type: string enum: - success - danger - not-applicable example: success description: SSL status type. Email: type: string format: email example: user@example.com description: Email format. Hostname: type: string format: hostname example: example.com description: Hostname format. MeResponse: type: object required: - status properties: api_key: type: string example: 12345abcde email: $ref: '#/components/schemas/Email' privileges: type: array items: $ref: '#/components/schemas/MailUserPrivilege' reason: type: string example: Incorrect username or password status: $ref: '#/components/schemas/MeAuthStatus' description: Me (user) response. MeAuthStatus: type: string enum: - ok - invalid example: invalid description: Me (user) authentication result. WebDomain: type: object required: - custom_root - domain - root - ssl_certificate - static_enabled properties: custom_root: type: string example: /home/user-data/www/example.com domain: $ref: '#/components/schemas/Hostname' root: type: string example: /home/user-data/www/default ssl_certificate: type: array minItems: 2 maxItems: 2 uniqueItems: true items: oneOf: - type: string example: No certificate installed. - type: string enum: - danger - success example: danger static_enabled: type: boolean example: true description: Web domain details. WebUpdateResponse: type: string example: web updated description: Web update response. MfaStatusResponse: type: object properties: enabled_mfa: type: object properties: id: type: string type: type: string label: type: string nullable: true new_mfa: type: object properties: type: type: string secret: type: string qr_code_base64: type: string MfaEnableRequest: type: object required: - secret - code properties: secret: type: string code: type: string label: type: string MfaEnableSuccessResponse: type: string MfaDisableRequest: type: object properties: mfa_id: type: string nullable: true MfaDisableSuccessResponse: type: string LogoutResponse: type: object properties: status: type: string ================================================ FILE: conf/dovecot-mailboxes.conf ================================================ ## NOTE: This file is automatically generated by Mail-in-a-Box. ## Do not edit this file. It is continually updated by ## Mail-in-a-Box and your changes will be lost. ## ## Mail-in-a-Box machines are not meant to be modified. ## If you modify any system configuration you are on ## your own --- please do not ask for help from us. namespace inbox { # Automatically create & subscribe some folders. # * Create and subscribe the INBOX folder. # * Our sieve rule for spam expects that the Spam folder exists. # * Z-Push must be configured with the same settings in conf/zpush/backend_imap.php (#580). # MUA notes: # * Roundcube will show an error if the user tries to delete a message before the Trash folder exists (#359). # * K-9 mail will poll every 90 seconds if a Drafts folder does not exist. # * Apple's OS X Mail app will create 'Sent Messages' if it doesn't see a folder with the \Sent flag (#571, #573) and won't be able to archive messages unless 'Archive' exists (#581). # * Thunderbird's default in its UI is 'Archives' (plural) but it will configure new accounts to use whatever we say here (#581). # auto: # 'create' will automatically create this mailbox. # 'subscribe' will both create and subscribe to the mailbox. # special_use is a space separated list of IMAP SPECIAL-USE # attributes as specified by RFC 6154: # \All \Archive \Drafts \Flagged \Junk \Sent \Trash mailbox INBOX { auto = subscribe } mailbox Spam { special_use = \Junk auto = subscribe } mailbox Drafts { special_use = \Drafts auto = subscribe } mailbox Sent { special_use = \Sent auto = subscribe } mailbox Trash { special_use = \Trash auto = subscribe } mailbox Archive { special_use = \Archive auto = subscribe } # dovevot's standard mailboxes configuration file marks two sent folders # with the \Sent attribute, just in case clients don't agree about which # they're using. We'll keep that, plus add Junk as an alternative for Spam. # These are not auto-created. mailbox "Sent Messages" { special_use = \Sent } mailbox Junk { special_use = \Junk } } ================================================ FILE: conf/fail2ban/filter.d/dovecotimap.conf ================================================ # Fail2Ban filter Dovecot authentication and pop3/imap/managesieve server # For Mail-in-a-Box [INCLUDES] before = common.conf [Definition] _daemon = (auth|dovecot(-auth)?|auth-worker) failregex = ^%(__prefix_line)s(pop3|imap|managesieve)-login: (Info: )?(Aborted login|Disconnected)(: Inactivity)? \(((no auth attempts|auth failed, \d+ attempts)( in \d+ secs)?|tried to use (disabled|disallowed) \S+ auth)\):( user=<\S*>,)?( method=\S+,)? rip=, lip=(\d{1,3}\.){3}\d{1,3}(, TLS( handshaking)?(: Disconnected)?)?(, session=<\S+>)?\s*$ ignoreregex = # DEV Notes: # * the first regex is essentially a copy of pam-generic.conf # * Probably doesn't do dovecot sql/ldap backends properly # # Author: Martin Waschbuesch # Daniel Black (rewrote with begin and end anchors) # Mail-in-a-Box (swapped session=...) ================================================ FILE: conf/fail2ban/filter.d/miab-management-daemon.conf ================================================ # Fail2Ban filter Mail-in-a-Box management daemon [INCLUDES] before = common.conf [Definition] _daemon = mailinabox failregex = Mail-in-a-Box Management Daemon: Failed login attempt from ip - timestamp .* ignoreregex = ================================================ FILE: conf/fail2ban/filter.d/miab-munin.conf ================================================ [INCLUDES] before = common.conf [Definition] failregex= - .*GET /admin/munin/.* HTTP/\d+\.\d+\" 401.* ignoreregex = ================================================ FILE: conf/fail2ban/filter.d/miab-owncloud.conf ================================================ [INCLUDES] before = common.conf [Definition] _groupsre = (?:(?:,?\s*"\w+":(?:"[^"]+"|\w+))*) failregex = ^\{%(_groupsre)s,?\s*"remoteAddr":""%(_groupsre)s,?\s*"message":"Login failed: ^\{%(_groupsre)s,?\s*"remoteAddr":""%(_groupsre)s,?\s*"message":"Trusted domain error. datepattern = ,?\s*"time"\s*:\s*"%%Y-%%m-%%d[T ]%%H:%%M:%%S(%%z)?" ================================================ FILE: conf/fail2ban/filter.d/miab-postfix-submission.conf ================================================ [INCLUDES] before = common.conf [Definition] failregex=postfix/submission/smtpd.*warning.*\[\]: .* authentication (failed|aborted) ignoreregex = ================================================ FILE: conf/fail2ban/filter.d/miab-roundcube.conf ================================================ [INCLUDES] before = common.conf [Definition] failregex = IMAP Error: Login failed for .*? from \. AUTHENTICATE.* ignoreregex = ================================================ FILE: conf/fail2ban/jails.conf ================================================ # Fail2Ban configuration file for Mail-in-a-Box. Do not edit. # This file is re-generated on updates. [DEFAULT] # Whitelist our own IP addresses. 127.0.0.1/8 is the default. But our status checks # ping services over the public interface so we should whitelist that address of # ours too. The string is substituted during installation. ignoreip = 127.0.0.1/8 PUBLIC_IP ::1 PUBLIC_IPV6 [dovecot] enabled = true filter = dovecotimap logpath = /var/log/mail.log findtime = 30 maxretry = 20 [miab-management] enabled = true filter = miab-management-daemon port = http,https logpath = /var/log/syslog maxretry = 20 findtime = 30 [miab-munin] enabled = true port = http,https filter = miab-munin logpath = /var/log/nginx/access.log maxretry = 20 findtime = 30 [miab-owncloud] enabled = true port = http,https filter = miab-owncloud logpath = STORAGE_ROOT/owncloud/nextcloud.log maxretry = 20 findtime = 120 [miab-postfix465] enabled = true port = 465 filter = miab-postfix-submission logpath = /var/log/mail.log maxretry = 20 findtime = 30 [miab-postfix587] enabled = true port = 587 filter = miab-postfix-submission logpath = /var/log/mail.log maxretry = 20 findtime = 30 [miab-roundcube] enabled = true port = http,https filter = miab-roundcube logpath = /var/log/roundcubemail/errors.log maxretry = 20 findtime = 30 [recidive] enabled = true maxretry = 10 action = iptables-allports[name=recidive] # In the recidive section of jail.conf the action contains: # # action = iptables-allports[name=recidive] # sendmail-whois-lines[name=recidive, logpath=/var/log/fail2ban.log] # # The last line on the action will sent an email to the configured address. This mail will # notify the administrator that someone has been repeatedly triggering one of the other jails. # By default we don't configure this address and no action is required from the admin anyway. # So the notification is omitted. This will prevent message appearing in the mail.log that mail # can't be delivered to fail2ban@$HOSTNAME. [postfix-sasl] enabled = true [sshd] enabled = true maxretry = 7 bantime = 3600 ================================================ FILE: conf/ios-profile.xml ================================================ PayloadContent CalDAVAccountDescription PRIMARY_HOSTNAME calendar CalDAVHostName PRIMARY_HOSTNAME CalDAVPort 443 CalDAVUseSSL PayloadDescription PRIMARY_HOSTNAME (Mail-in-a-Box) PayloadDisplayName PRIMARY_HOSTNAME calendar PayloadIdentifier email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.CalDAV PayloadOrganization PayloadType com.apple.caldav.account PayloadUUID UUID1 PayloadVersion 1 EmailAccountDescription PRIMARY_HOSTNAME mail EmailAccountType EmailTypeIMAP IncomingMailServerAuthentication EmailAuthPassword IncomingMailServerHostName PRIMARY_HOSTNAME IncomingMailServerPortNumber 993 IncomingMailServerUseSSL OutgoingMailServerAuthentication EmailAuthPassword OutgoingMailServerHostName PRIMARY_HOSTNAME OutgoingMailServerPortNumber 465 OutgoingMailServerUseSSL OutgoingPasswordSameAsIncomingPassword PayloadDescription PRIMARY_HOSTNAME (Mail-in-a-Box) PayloadDisplayName PRIMARY_HOSTNAME mail PayloadIdentifier email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.E-Mail PayloadOrganization PayloadType com.apple.mail.managed PayloadUUID UUID2 PayloadVersion 1 PreventAppSheet PreventMove SMIMEEnabled CardDAVAccountDescription PRIMARY_HOSTNAME contacts CardDAVHostName PRIMARY_HOSTNAME CardDAVPort 443 CardDAVPrincipalURL /cloud/remote.php/carddav/addressbooks/ CardDAVUseSSL PayloadDescription PRIMARY_HOSTNAME (Mail-in-a-Box) PayloadDisplayName PRIMARY_HOSTNAME contacts PayloadIdentifier email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.carddav PayloadOrganization PayloadType com.apple.carddav.account PayloadUUID UUID3 PayloadVersion 1 PayloadDescription PRIMARY_HOSTNAME (Mail-in-a-Box) PayloadDisplayName PRIMARY_HOSTNAME PayloadIdentifier email.mailinabox.mobileconfig.PRIMARY_HOSTNAME PayloadOrganization PayloadRemovalDisallowed PayloadType Configuration PayloadUUID UUID4 PayloadVersion 1 ================================================ FILE: conf/mailinabox.service ================================================ [Unit] Description=Mail-in-a-Box System Management Service After=multi-user.target [Service] Type=idle IgnoreSIGPIPE=False ExecStart=/usr/local/lib/mailinabox/start [Install] WantedBy=multi-user.target ================================================ FILE: conf/mozilla-autoconfig.xml ================================================ PRIMARY_HOSTNAME PRIMARY_HOSTNAME PRIMARY_HOSTNAME (Mail-in-a-Box) PRIMARY_HOSTNAME PRIMARY_HOSTNAME 993 SSL %EMAILADDRESS% password-cleartext PRIMARY_HOSTNAME 995 SSL %EMAILADDRESS% password-cleartext PRIMARY_HOSTNAME 465 SSL %EMAILADDRESS% password-cleartext true false PRIMARY_HOSTNAME website. %EMAILADDRESS% basic https://PRIMARY_HOSTNAME/.well-known/carddav %EMAILADDRESS% basic https://PRIMARY_HOSTNAME/.well-known/caldav %EMAILADDRESS% ================================================ FILE: conf/mta-sts.txt ================================================ version: STSv1 mode: MODE mx: PRIMARY_HOSTNAME max_age: 604800 ================================================ FILE: conf/munin.service ================================================ [Unit] Description=Munin System Monitoring Startup Script After=multi-user.target [Service] Type=idle ExecStart=/usr/local/lib/mailinabox/munin_start.sh [Install] WantedBy=multi-user.target ================================================ FILE: conf/nginx-alldomains.conf ================================================ # Expose this directory as static files. root $ROOT; index index.html index.htm; location = /robots.txt { log_not_found off; access_log off; } location = /favicon.ico { log_not_found off; access_log off; } location = /mailinabox.mobileconfig { alias /var/lib/mailinabox/mobileconfig.xml; } location = /.well-known/autoconfig/mail/config-v1.1.xml { alias /var/lib/mailinabox/mozilla-autoconfig.xml; } location = /mail/config-v1.1.xml { alias /var/lib/mailinabox/mozilla-autoconfig.xml; } location = /.well-known/mta-sts.txt { alias /var/lib/mailinabox/mta-sts.txt; } # Roundcube Webmail configuration. rewrite ^/mail$ /mail/ redirect; rewrite ^/mail/$ /mail/index.php; location /mail/ { index index.php; alias /usr/local/lib/roundcubemail/; } location ~ /mail/config/.* { # A ~-style location is needed to give this precedence over the next block. return 403; } location ~ /mail/.*\.php { # note: ~ has precedence over a regular location block include fastcgi_params; fastcgi_split_path_info ^/mail(/.*)()$; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name; fastcgi_pass php-fpm; # Outgoing mail also goes through this endpoint, so increase the maximum # file upload limit to match the corresponding Postfix limit. client_max_body_size 128M; } # Z-Push (Microsoft Exchange ActiveSync) location /Microsoft-Server-ActiveSync { include /etc/nginx/fastcgi_params; fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/index.php; fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc"; fastcgi_read_timeout 630; fastcgi_pass php-fpm; # Outgoing mail also goes through this endpoint, so increase the maximum # file upload limit to match the corresponding Postfix limit. client_max_body_size 128M; } location ~* ^/autodiscover/autodiscover.xml$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/autodiscover/autodiscover.php; fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc"; fastcgi_pass php-fpm; } # ADDITIONAL DIRECTIVES HERE # Disable viewing dotfiles (.htaccess, .svn, .git, etc.) # This block is placed at the end. Nginx's precedence rules means this block # takes precedence over all non-regex matches and only regex matches that # come after it (i.e. none of those, since this is the last one.) That means # we're blocking dotfiles in the static hosted sites but not the FastCGI- # handled locations for Nextcloud (which serves user-uploaded files that might # have this pattern, see #414) or some of the other services. location ~ /\.(ht|svn|git|hg|bzr) { log_not_found off; access_log off; deny all; } ================================================ FILE: conf/nginx-primaryonly.conf ================================================ # Control Panel # Proxy /admin to our Python based control panel daemon. It is # listening on IPv4 only so use an IP address and not 'localhost'. location /admin/assets { alias /usr/local/lib/mailinabox/vendor/assets; } rewrite ^/admin$ /admin/; rewrite ^/admin/munin$ /admin/munin/ redirect; location /admin/ { proxy_pass http://127.0.0.1:10222/; proxy_set_header X-Forwarded-For $remote_addr; add_header X-Frame-Options "DENY"; add_header X-Content-Type-Options nosniff; add_header Content-Security-Policy "frame-ancestors 'none';"; } # Nextcloud configuration. rewrite ^/cloud$ /cloud/ redirect; rewrite ^/cloud/$ /cloud/index.php; rewrite ^/cloud/(contacts|calendar|files)$ /cloud/index.php/apps/$1/ redirect; rewrite ^(/cloud/core/doc/[^\/]+/)$ $1/index.html; rewrite ^(/cloud/oc[sm]-provider)/$ $1/index.php redirect; location /cloud/ { alias /usr/local/lib/owncloud/; location ~ ^/cloud/(build|tests|config|lib|3rdparty|templates|data|README)/ { deny all; } location ~ ^/cloud/(?:\.|autotest|occ|issue|indie|db_|console) { deny all; } # Enable paths for service and cloud federation discovery # Resolves warning in Nextcloud Settings panel location ~ ^/cloud/(oc[sm]-provider)?/([^/]+\.php)$ { index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$1/$2; fastcgi_pass php-fpm; } } location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ { # note: ~ has precedence over a regular location block # Accept URLs like: # /cloud/index.php/apps/files/ # /cloud/index.php/apps/files/ajax/scan.php (it's really index.php; see 6fdef379adfdeac86cc2220209bdf4eb9562268d) # /cloud/ocs/v1.php/apps/files_sharing/api/v1 (see #240) # /cloud/remote.php/webdav/yourfilehere... include fastcgi_params; fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$2; fastcgi_param SCRIPT_NAME $1$2; fastcgi_param PATH_INFO $3; fastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on; fastcgi_param MOD_X_ACCEL_REDIRECT_PREFIX /owncloud-xaccel; fastcgi_read_timeout 630; fastcgi_pass php-fpm; client_max_body_size 1G; fastcgi_buffers 64 4K; } location ^~ /owncloud-xaccel/ { # This directory is for MOD_X_ACCEL_REDIRECT_ENABLED. Nextcloud sends the full file # path on disk as a subdirectory under this virtual path. # We must only allow 'internal' redirects within nginx so that the filesystem # is not exposed to the world. internal; alias /; } location ~ ^/((caldav|carddav|webdav).*)$ { # Z-Push doesn't like getting a redirect, and a plain rewrite didn't work either. # Properly proxying like this seems to work fine. proxy_pass https://127.0.0.1/cloud/remote.php/$1; } rewrite ^/.well-known/host-meta /cloud/public.php?service=host-meta last; rewrite ^/.well-known/host-meta.json /cloud/public.php?service=host-meta-json last; rewrite ^/.well-known/carddav /cloud/remote.php/carddav/ redirect; rewrite ^/.well-known/caldav /cloud/remote.php/caldav/ redirect; # This addresses those service discovery issues mentioned in: # https://docs.nextcloud.com/server/23/admin_manual/issues/general_troubleshooting.html#service-discovery rewrite ^/.well-known/webfinger /cloud/index.php/.well-known/webfinger redirect; rewrite ^/.well-known/nodeinfo /cloud/index.php/.well-known/nodeinfo redirect; # ADDITIONAL DIRECTIVES HERE ================================================ FILE: conf/nginx-ssl.conf ================================================ # We track the Mozilla "intermediate" compatibility TLS recommendations. # Note that these settings are repeated in the SMTP and IMAP configuration. # ssl_protocols has moved to nginx.conf in bionic, check there for enabled protocols. ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; ssl_dhparam STORAGE_ROOT/ssl/dh2048.pem; # as recommended by http://nginx.org/en/docs/http/configuring_https_servers.html ssl_session_cache shared:SSL:50m; ssl_session_timeout 1d; # Buffer size of 1400 bytes fits in one MTU. # nginx 1.5.9+ ONLY ssl_buffer_size 1400; resolver 127.0.0.1 valid=86400; resolver_timeout 10; # h/t https://gist.github.com/konklone/6532544 ================================================ FILE: conf/nginx-top.conf ================================================ ## NOTE: This file is automatically generated by Mail-in-a-Box. ## Do not edit this file. It is continually updated by ## Mail-in-a-Box and your changes will be lost. ## ## Mail-in-a-Box machines are not meant to be modified. ## If you modify any system configuration you are on ## your own --- please do not ask for help from us. upstream php-fpm { server unix:/var/run/php/php8.0-fpm.sock; } ================================================ FILE: conf/nginx.conf ================================================ ## $HOSTNAME # Redirect all HTTP to HTTPS *except* the ACME challenges (Let's Encrypt TLS certificate # domain validation challenges) path, which must be served over HTTP per the ACME spec # (due to some Apache vulnerability). server { listen 80; listen [::]:80; server_name $HOSTNAME; root /tmp/invalid-path-nothing-here; # Improve privacy: Hide version an OS information on # error pages and in the "Server" HTTP-Header. server_tokens off; location / { # Redirect using the 'return' directive and the built-in # variable '$request_uri' to avoid any capturing, matching # or evaluation of regular expressions. return 301 https://$HOSTNAME$request_uri; } location /.well-known/acme-challenge/ { # This path must be served over HTTP for ACME domain validation. # We map this to a special path where our TLS cert provisioning # tool knows to store challenge response files. alias $STORAGE_ROOT/ssl/lets_encrypt/webroot/.well-known/acme-challenge/; } } # The secure HTTPS server. server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name $HOSTNAME; # Improve privacy: Hide version an OS information on # error pages and in the "Server" HTTP-Header. server_tokens off; ssl_certificate $SSL_CERTIFICATE; ssl_certificate_key $SSL_KEY; # ADDITIONAL DIRECTIVES HERE } ================================================ FILE: conf/postfix_outgoing_mail_header_filters ================================================ # Remove the first line of the Received: header. Note that we cannot fully remove the Received: header # because OpenDKIM requires that a header be present when signing outbound mail. The first line is # where the user's home IP address would be. /^\s*Received:[^\n]*(.*)/ REPLACE Received: from authenticated-user (PRIMARY_HOSTNAME [PUBLIC_IP])$1 # Remove other typically private information. /^\s*User-Agent:/ IGNORE /^\s*X-Enigmail:/ IGNORE /^\s*X-Mailer:/ IGNORE /^\s*X-Originating-IP:/ IGNORE /^\s*X-Pgp-Agent:/ IGNORE # The Mime-Version header can leak the user agent too, e.g. in Mime-Version: 1.0 (Mac OS X Mail 8.1 \(2010.6\)). /^\s*(Mime-Version:\s*[0-9\.]+)\s.+/ REPLACE $1 ================================================ FILE: conf/sieve-spam.txt ================================================ require ["regex", "fileinto", "imap4flags"]; if allof (header :regex "X-Spam-Status" "^Yes") { fileinto "Spam"; stop; } ================================================ FILE: conf/www_default.html ================================================ this is a mail-in-a-box

this is a mail-in-a-box

take control of your email at https://mailinabox.email/

================================================ FILE: conf/zpush/autodiscover_config.php ================================================ ================================================ FILE: conf/zpush/backend_caldav.php ================================================ ================================================ FILE: conf/zpush/backend_carddav.php ================================================ ================================================ FILE: conf/zpush/backend_combined.php ================================================ array( 'i' => array( 'name' => 'BackendIMAP', ), 'c' => array( 'name' => 'BackendCalDAV', ), 'd' => array( 'name' => 'BackendCardDAV', ), ), 'delimiter' => '/', 'folderbackend' => array( SYNC_FOLDER_TYPE_INBOX => 'i', SYNC_FOLDER_TYPE_DRAFTS => 'i', SYNC_FOLDER_TYPE_WASTEBASKET => 'i', SYNC_FOLDER_TYPE_SENTMAIL => 'i', SYNC_FOLDER_TYPE_OUTBOX => 'i', SYNC_FOLDER_TYPE_TASK => 'c', SYNC_FOLDER_TYPE_APPOINTMENT => 'c', SYNC_FOLDER_TYPE_CONTACT => 'd', SYNC_FOLDER_TYPE_NOTE => 'c', SYNC_FOLDER_TYPE_JOURNAL => 'c', SYNC_FOLDER_TYPE_OTHER => 'i', SYNC_FOLDER_TYPE_USER_MAIL => 'i', SYNC_FOLDER_TYPE_USER_APPOINTMENT => 'c', SYNC_FOLDER_TYPE_USER_CONTACT => 'd', SYNC_FOLDER_TYPE_USER_TASK => 'c', SYNC_FOLDER_TYPE_USER_JOURNAL => 'c', SYNC_FOLDER_TYPE_USER_NOTE => 'c', SYNC_FOLDER_TYPE_UNKNOWN => 'i', ), 'rootcreatefolderbackend' => 'i', ); } } ?> ================================================ FILE: conf/zpush/backend_imap.php ================================================ true))); define('IMAP_FROM_SQL_QUERY', "SELECT name, email FROM identities i INNER JOIN users u ON i.user_id = u.user_id WHERE u.username = '#username' AND i.standard = 1 AND i.del = 0 AND i.name <> ''"); define('IMAP_FROM_SQL_FIELDS', serialize(array('name', 'email'))); define('IMAP_FROM_SQL_FROM', '#name <#email>'); define('IMAP_FROM_SQL_FULLNAME', '#name'); // not used define('IMAP_FROM_LDAP_SERVER', ''); define('IMAP_FROM_LDAP_SERVER_PORT', '389'); define('IMAP_FROM_LDAP_USER', 'cn=zpush,ou=servers,dc=zpush,dc=org'); define('IMAP_FROM_LDAP_PASSWORD', 'password'); define('IMAP_FROM_LDAP_BASE', 'dc=zpush,dc=org'); define('IMAP_FROM_LDAP_QUERY', '(mail=#username@#domain)'); define('IMAP_FROM_LDAP_FIELDS', serialize(array('givenname', 'sn', 'mail'))); define('IMAP_FROM_LDAP_FROM', '#givenname #sn <#mail>'); define('IMAP_FROM_LDAP_FULLNAME', '#givenname #sn'); define('IMAP_SMTP_METHOD', 'sendmail'); global $imap_smtp_params; $imap_smtp_params = array('host' => 'ssl://127.0.0.1', 'port' => 465, 'auth' => true, 'username' => 'imap_username', 'password' => 'imap_password'); define('MAIL_MIMEPART_CRLF', "\r\n"); define('IMAP_MEETING_USE_CALDAV', true); ?> ================================================ FILE: management/auth.py ================================================ import base64, hmac, json, secrets from datetime import timedelta from expiringdict import ExpiringDict import utils from mailconfig import get_mail_password, get_mail_user_privileges from mfa import get_hash_mfa_state, validate_auth_mfa DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' class AuthService: def __init__(self): self.auth_realm = DEFAULT_AUTH_REALM self.key_path = DEFAULT_KEY_PATH self.max_session_duration = timedelta(days=2) self.init_system_api_key() self.sessions = ExpiringDict(max_len=64, max_age_seconds=self.max_session_duration.total_seconds()) def init_system_api_key(self): """Write an API key to a local file so local processes can use the API""" with open(self.key_path, encoding='utf-8') as file: self.key = file.read() def authenticate(self, request, env, login_only=False, logout=False): """Test if the HTTP Authorization header's username matches the system key, a session key, or if the username/password passed in the header matches a local user. Returns a tuple of the user's email address and list of user privileges (e.g. ('my@email', []) or ('my@email', ['admin']); raises a ValueError on login failure. If the user used the system API key, the user's email is returned as None since this key is not associated with a user.""" def parse_http_authorization_basic(header): def decode(s): return base64.b64decode(s.encode('ascii')).decode('ascii') if " " not in header: return None, None scheme, credentials = header.split(maxsplit=1) if scheme != 'Basic': return None, None credentials = decode(credentials) if ":" not in credentials: return None, None username, password = credentials.split(':', maxsplit=1) return username, password username, password = parse_http_authorization_basic(request.headers.get('Authorization', '')) if username in {None, ""}: msg = "Authorization header invalid." raise ValueError(msg) if username.strip() == "" and password.strip() == "": msg = "No email address, password, session key, or API key provided." raise ValueError(msg) # If user passed the system API key, grant administrative privs. This key # is not associated with a user. if username == self.key and not login_only: return (None, ["admin"]) # If the password corresponds with a session token for the user, grant access for that user. if self.get_session(username, password, "login", env) and not login_only: sessionid = password session = self.sessions[sessionid] if logout: # Clear the session. del self.sessions[sessionid] else: # Re-up the session so that it does not expire. self.sessions[sessionid] = session # If no password was given, but a username was given, we're missing some information. elif password.strip() == "": msg = "Enter a password." raise ValueError(msg) else: # The user is trying to log in with a username and a password # (and possibly a MFA token). On failure, an exception is raised. self.check_user_auth(username, password, request, env) # Get privileges for authorization. This call should never fail because by this # point we know the email address is a valid user --- unless the user has been # deleted after the session was granted. On error the call will return a tuple # of an error message and an HTTP status code. privs = get_mail_user_privileges(username, env) if isinstance(privs, tuple): raise ValueError(privs[0]) # Return the authorization information. return (username, privs) def check_user_auth(self, email, pw, request, env): # Validate a user's login email address and password. If MFA is enabled, # check the MFA token in the X-Auth-Token header. # # On login failure, raises a ValueError with a login error message. On # success, nothing is returned. # Authenticate. try: # Get the hashed password of the user. Raise a ValueError if the # email address does not correspond to a user. But wrap it in the # same exception as if a password fails so we don't easily reveal # if an email address is valid. pw_hash = get_mail_password(email, env) # Use 'doveadm pw' to check credentials. doveadm will return # a non-zero exit status if the credentials are no good, # and check_call will raise an exception in that case. utils.shell('check_call', [ "/usr/bin/doveadm", "pw", "-p", pw, "-t", pw_hash, ]) except: # Login failed. msg = "Incorrect email address or password." raise ValueError(msg) # If MFA is enabled, check that MFA passes. status, hints = validate_auth_mfa(email, request, env) if not status: # Login valid. Hints may have more info. raise ValueError(",".join(hints)) def create_user_password_state_token(self, email, env): # Create a token that changes if the user's password or MFA options change # so that sessions become invalid if any of that information changes. msg = get_mail_password(email, env).encode("utf8") # Add to the message the current MFA state, which is a list of MFA information. # Turn it into a string stably. msg += b" " + json.dumps(get_hash_mfa_state(email, env), sort_keys=True).encode("utf8") # Make a HMAC using the system API key as a hash key. hash_key = self.key.encode('ascii') return hmac.new(hash_key, msg, digestmod="sha256").hexdigest() def create_session_key(self, username, env, type=None): # Create a new session. token = secrets.token_hex(32) self.sessions[token] = { "email": username, "password_token": self.create_user_password_state_token(username, env), "type": type, } return token def get_session(self, user_email, session_key, session_type, env): if session_key not in self.sessions: return None session = self.sessions[session_key] if session_type == "login" and session["email"] != user_email: return None if session["type"] != session_type: return None if session["password_token"] != self.create_user_password_state_token(session["email"], env): return None return session ================================================ FILE: management/backup.py ================================================ #!/usr/local/lib/mailinabox/env/bin/python # This script performs a backup of all user data: # 1) System services are stopped. # 2) STORAGE_ROOT/backup/before-backup is executed if it exists. # 3) An incremental encrypted backup is made using duplicity. # 4) The stopped services are restarted. # 5) STORAGE_ROOT/backup/after-backup is executed if it exists. import os, os.path, re, datetime, sys import dateutil.parser, dateutil.relativedelta, dateutil.tz from datetime import date import rtyaml from exclusiveprocess import Lock from utils import load_environment, shell, wait_for_service import operator def backup_status(env): # If backups are disabled, return no status. config = get_backup_config(env) if config["target"] == "off": return { } # Query duplicity to get a list of all full and incremental # backups available. backups = { } now = datetime.datetime.now(dateutil.tz.tzlocal()) backup_root = os.path.join(env["STORAGE_ROOT"], 'backup') backup_cache_dir = os.path.join(backup_root, 'cache') def reldate(date, ref, clip): if ref < date: return clip rd = dateutil.relativedelta.relativedelta(ref, date) if rd.years > 1: return "%d years, %d months" % (rd.years, rd.months) if rd.years == 1: return "%d year, %d months" % (rd.years, rd.months) if rd.months > 1: return "%d months, %d days" % (rd.months, rd.days) if rd.months == 1: return "%d month, %d days" % (rd.months, rd.days) if rd.days >= 7: return "%d days" % rd.days if rd.days > 1: return "%d days, %d hours" % (rd.days, rd.hours) if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours) return "%d hours, %d minutes" % (rd.hours, rd.minutes) # Get duplicity collection status and parse for a list of backups. def parse_line(line): keys = line.strip().split() date = dateutil.parser.parse(keys[1]).astimezone(dateutil.tz.tzlocal()) return { "date": keys[1], "date_str": date.strftime("%Y-%m-%d %X") + " " + now.tzname(), "date_delta": reldate(date, now, "the future?"), "full": keys[0] == "full", "size": 0, # collection-status doesn't give us the size "volumes": int(keys[2]), # number of archive volumes for this backup (not really helpful) } code, collection_status = shell('check_output', [ "/usr/bin/duplicity", "collection-status", "--archive-dir", backup_cache_dir, "--gpg-options", "'--cipher-algo=AES256'", "--log-fd", "1", *get_duplicity_additional_args(env), get_duplicity_target_url(config) ], get_duplicity_env_vars(env), trap=True) if code != 0: # Command failed. This is likely due to an improperly configured remote # destination for the backups or the last backup job terminated unexpectedly. raise Exception("Something is wrong with the backup: " + collection_status) for line in collection_status.split('\n'): if line.startswith((" full", " inc")): backup = parse_line(line) backups[backup["date"]] = backup # Look at the target directly to get the sizes of each of the backups. There is more than one file per backup. # Starting with duplicity in Ubuntu 18.04, "signatures" files have dates in their # filenames that are a few seconds off the backup date and so don't line up # with the list of backups we have. Track unmatched files so we know how much other # space is used for those. unmatched_file_size = 0 for fn, size in list_target_files(config): m = re.match(r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P\d+T\d+Z)\.to)\.(?P\d+T\d+Z)\.", fn) if not m: continue # not a part of a current backup chain key = m.group("date") if key in backups: backups[key]["size"] += size else: unmatched_file_size += size # Ensure the rows are sorted reverse chronologically. # This is relied on by should_force_full() and the next step. backups = sorted(backups.values(), key = operator.itemgetter("date"), reverse=True) # Get the average size of incremental backups, the size of the # most recent full backup, and the date of the most recent # backup and the most recent full backup. incremental_count = 0 incremental_size = 0 first_date = None first_full_size = None first_full_date = None for bak in backups: if first_date is None: first_date = dateutil.parser.parse(bak["date"]) if bak["full"]: first_full_size = bak["size"] first_full_date = dateutil.parser.parse(bak["date"]) break incremental_count += 1 incremental_size += bak["size"] # When will the most recent backup be deleted? It won't be deleted if the next # backup is incremental, because the increments rely on all past increments. # So first guess how many more incremental backups will occur until the next # full backup. That full backup frees up this one to be deleted. But, the backup # must also be at least min_age_in_days old too. deleted_in = None if incremental_count > 0 and incremental_size > 0 and first_full_size is not None: # How many days until the next incremental backup? First, the part of # the algorithm based on increment sizes: est_days_to_next_full = (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count) est_time_of_next_full = first_date + datetime.timedelta(days=est_days_to_next_full) # ...And then the part of the algorithm based on full backup age: est_time_of_next_full = min(est_time_of_next_full, first_full_date + datetime.timedelta(days=config["min_age_in_days"]*10+1)) # It still can't be deleted until it's old enough. est_deleted_on = max(est_time_of_next_full, first_date + datetime.timedelta(days=config["min_age_in_days"])) deleted_in = "approx. %d days" % round((est_deleted_on-now).total_seconds()/60/60/24 + .5) # When will a backup be deleted? Set the deleted_in field of each backup. saw_full = False for bak in backups: if deleted_in: # The most recent increment in a chain and all of the previous backups # it relies on are deleted at the same time. bak["deleted_in"] = deleted_in if bak["full"]: # Reset when we get to a full backup. A new chain start *next*. saw_full = True deleted_in = None elif saw_full and not deleted_in: # We're now on backups prior to the most recent full backup. These are # free to be deleted as soon as they are min_age_in_days old. deleted_in = reldate(now, dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]), "on next daily backup") bak["deleted_in"] = deleted_in return { "backups": backups, "unmatched_file_size": unmatched_file_size, } def should_force_full(config, env): # Force a full backup when the total size of the increments # since the last full backup is greater than half the size # of that full backup. inc_size = 0 # Check if day of week is a weekend day weekend = date.today().weekday()>=5 for bak in backup_status(env)["backups"]: if not bak["full"]: # Scan through the incremental backups cumulating # size... inc_size += bak["size"] else: # ...until we reach the most recent full backup. # Return if we should to a full backup, which is based # on whether it is a weekend day, the size of the # increments relative to the full backup, as well as # the age of the full backup. if weekend: if inc_size > .5*bak["size"]: return True if dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]*10+1) < datetime.datetime.now(dateutil.tz.tzlocal()): return True return False # If we got here there are no (full) backups, so make one. return True def get_passphrase(env): # Get the encryption passphrase. secret_key.txt is 2048 random # bits base64-encoded and with line breaks every 65 characters. # gpg will only take the first line of text, so sanity check that # that line is long enough to be a reasonable passphrase. It # only needs to be 43 base64-characters to match AES256's key # length of 32 bytes. backup_root = os.path.join(env["STORAGE_ROOT"], 'backup') with open(os.path.join(backup_root, 'secret_key.txt'), encoding="utf-8") as f: passphrase = f.readline().strip() if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!") return passphrase def get_duplicity_target_url(config): target = config["target"] if get_target_type(config) == "s3": from urllib.parse import urlsplit, urlunsplit target = list(urlsplit(target)) # Although we store the S3 hostname in the target URL, # duplicity no longer accepts it in the target URL. The hostname in # the target URL must be the bucket name. The hostname is passed # via get_duplicity_additional_args. Move the first part of the # path (the bucket name) into the hostname URL component, and leave # the rest for the path. (The S3 region name is also stored in the # hostname part of the URL, in the username portion, which we also # have to drop here). target[1], target[2] = target[2].lstrip('/').split('/', 1) target = urlunsplit(target) return target def get_duplicity_additional_args(env): config = get_backup_config(env) if get_target_type(config) == 'rsync': # Extract a port number for the ssh transport. Duplicity accepts the # optional port number syntax in the target, but it doesn't appear to act # on it, so we set the ssh port explicitly via the duplicity options. from urllib.parse import urlsplit try: port = urlsplit(config["target"]).port except ValueError: port = 22 if port is None: port = 22 return [ f"--ssh-options='-i /root/.ssh/id_rsa_miab -p {port}'", f"--rsync-options='-e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p {port} -i /root/.ssh/id_rsa_miab\"'", ] if get_target_type(config) == 's3': # See note about hostname in get_duplicity_target_url. # The region name, which is required by some non-AWS endpoints, # is saved inside the username portion of the URL. from urllib.parse import urlsplit, urlunsplit target = urlsplit(config["target"]) endpoint_url = urlunsplit(("https", target.hostname, '', '', '')) args = ["--s3-endpoint-url", endpoint_url] if target.username: # region name is stuffed here args += ["--s3-region-name", target.username] return args return [] def get_duplicity_env_vars(env): config = get_backup_config(env) env = { "PASSPHRASE" : get_passphrase(env) } if get_target_type(config) == 's3': env["AWS_ACCESS_KEY_ID"] = config["target_user"] env["AWS_SECRET_ACCESS_KEY"] = config["target_pass"] env["AWS_REQUEST_CHECKSUM_CALCULATION"] = "WHEN_REQUIRED" env["AWS_RESPONSE_CHECKSUM_VALIDATION"] = "WHEN_REQUIRED" return env def get_target_type(config): return config["target"].split(":")[0] def perform_backup(full_backup): env = load_environment() # Create an global exclusive lock so that the backup script # cannot be run more than one. Lock(die=True).forever() config = get_backup_config(env) backup_root = os.path.join(env["STORAGE_ROOT"], 'backup') backup_cache_dir = os.path.join(backup_root, 'cache') backup_dir = os.path.join(backup_root, 'encrypted') # Are backups disabled? if config["target"] == "off": return # On the first run, always do a full backup. Incremental # will fail. Otherwise do a full backup when the size of # the increments since the most recent full backup are # large. try: full_backup = full_backup or should_force_full(config, env) except Exception as e: # This was the first call to duplicity, and there might # be an error already. print(e) sys.exit(1) # Stop services. def service_command(service, command, quit=None): # Execute silently, but if there is an error then display the output & exit. code, ret = shell('check_output', ["/usr/sbin/service", service, command], capture_stderr=True, trap=True) if code != 0: print(ret) if quit: sys.exit(code) service_command("php8.0-fpm", "stop", quit=True) service_command("postfix", "stop", quit=True) service_command("dovecot", "stop", quit=True) service_command("postgrey", "stop", quit=True) # Execute a pre-backup script that copies files outside the homedir. # Run as the STORAGE_USER user, not as root. Pass our settings in # environment variables so the script has access to STORAGE_ROOT. pre_script = os.path.join(backup_root, 'before-backup') if os.path.exists(pre_script): shell('check_call', ['su', env['STORAGE_USER'], '-c', pre_script, config["target"]], env=env) # Run a backup of STORAGE_ROOT (but excluding the backups themselves!). # --allow-source-mismatch is needed in case the box's hostname is changed # after the first backup. See #396. try: shell('check_call', [ "/usr/bin/duplicity", "full" if full_backup else "incr", "--verbosity", "warning", "--no-print-statistics", "--archive-dir", backup_cache_dir, "--exclude", backup_root, "--exclude", os.path.join(env["STORAGE_ROOT"], "owncloud-backup"), "--volsize", "250", "--gpg-options", "'--cipher-algo=AES256'", "--allow-source-mismatch", *get_duplicity_additional_args(env), env["STORAGE_ROOT"], get_duplicity_target_url(config), ], get_duplicity_env_vars(env)) finally: # Start services again. service_command("postgrey", "start", quit=False) service_command("dovecot", "start", quit=False) service_command("postfix", "start", quit=False) service_command("php8.0-fpm", "start", quit=False) # Remove old backups. This deletes all backup data no longer needed # from more than 3 days ago. shell('check_call', [ "/usr/bin/duplicity", "remove-older-than", "%dD" % config["min_age_in_days"], "--verbosity", "error", "--archive-dir", backup_cache_dir, "--force", *get_duplicity_additional_args(env), get_duplicity_target_url(config) ], get_duplicity_env_vars(env)) # From duplicity's manual: # "This should only be necessary after a duplicity session fails or is # aborted prematurely." # That may be unlikely here but we may as well ensure we tidy up if # that does happen - it might just have been a poorly timed reboot. shell('check_call', [ "/usr/bin/duplicity", "cleanup", "--verbosity", "error", "--archive-dir", backup_cache_dir, "--force", *get_duplicity_additional_args(env), get_duplicity_target_url(config) ], get_duplicity_env_vars(env)) # Change ownership of backups to the user-data user, so that the after-bcakup # script can access them. if get_target_type(config) == 'file': shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir]) # Execute a post-backup script that does the copying to a remote server. # Run as the STORAGE_USER user, not as root. Pass our settings in # environment variables so the script has access to STORAGE_ROOT. post_script = os.path.join(backup_root, 'after-backup') if os.path.exists(post_script): shell('check_call', ['su', env['STORAGE_USER'], '-c', post_script, config["target"]], env=env) # Our nightly cron job executes system status checks immediately after this # backup. Since it checks that dovecot and postfix are running, block for a # bit (maximum of 10 seconds each) to give each a chance to finish restarting # before the status checks might catch them down. See #381. wait_for_service(25, True, env, 10) wait_for_service(993, True, env, 10) def run_duplicity_verification(): env = load_environment() backup_root = os.path.join(env["STORAGE_ROOT"], 'backup') config = get_backup_config(env) backup_cache_dir = os.path.join(backup_root, 'cache') shell('check_call', [ "/usr/bin/duplicity", "--verbosity", "info", "verify", "--compare-data", "--archive-dir", backup_cache_dir, "--exclude", backup_root, "--exclude", os.path.join(env["STORAGE_ROOT"], "owncloud-backup"), *get_duplicity_additional_args(env), get_duplicity_target_url(config), env["STORAGE_ROOT"], ], get_duplicity_env_vars(env)) def run_duplicity_restore(args): env = load_environment() config = get_backup_config(env) backup_cache_dir = os.path.join(env["STORAGE_ROOT"], 'backup', 'cache') shell('check_call', [ "/usr/bin/duplicity", "restore", "--archive-dir", backup_cache_dir, *get_duplicity_additional_args(env), get_duplicity_target_url(config), *args], get_duplicity_env_vars(env)) def print_duplicity_command(): import shlex env = load_environment() config = get_backup_config(env) backup_cache_dir = os.path.join(env["STORAGE_ROOT"], 'backup', 'cache') for k, v in get_duplicity_env_vars(env).items(): print(f"export {k}={shlex.quote(v)}") print("duplicity", "{command}", shlex.join([ "--archive-dir", backup_cache_dir, *get_duplicity_additional_args(env), get_duplicity_target_url(config) ])) def list_target_files(config): import urllib.parse try: target = urllib.parse.urlparse(config["target"]) except ValueError: return "invalid target" if target.scheme == "file": return [(fn, os.path.getsize(os.path.join(target.path, fn))) for fn in os.listdir(target.path)] if target.scheme == "rsync": rsync_fn_size_re = re.compile(r'.* ([^ ]*) [^ ]* [^ ]* (.*)') rsync_target = '{host}:{path}' # Strip off any trailing port specifier because it's not valid in rsync's # DEST syntax. Explicitly set the port number for the ssh transport. user_host, *_ = target.netloc.rsplit(':', 1) try: port = target.port except ValueError: port = 22 if port is None: port = 22 target_path = target.path if not target_path.endswith('/'): target_path += '/' target_path = target_path.removeprefix('/') rsync_command = [ 'rsync', '-e', f'/usr/bin/ssh -i /root/.ssh/id_rsa_miab -oStrictHostKeyChecking=no -oBatchMode=yes -p {port}', '--list-only', '-r', rsync_target.format( host=user_host, path=target_path) ] code, listing = shell('check_output', rsync_command, trap=True, capture_stderr=True) if code == 0: ret = [] for l in listing.split('\n'): match = rsync_fn_size_re.match(l) if match: ret.append( (match.groups()[1], int(match.groups()[0].replace(',',''))) ) return ret if 'Permission denied (publickey).' in listing: reason = "Invalid user or check you correctly copied the SSH key." elif 'No such file or directory' in listing: reason = f"Provided path {target_path} is invalid." elif 'Network is unreachable' in listing: reason = f"The IP address {target.hostname} is unreachable." elif 'Could not resolve hostname' in listing: reason = f"The hostname {target.hostname} cannot be resolved." else: reason = ("Unknown error." "Please check running 'management/backup.py --verify'" "from mailinabox sources to debug the issue.") msg = f"Connection to rsync host failed: {reason}" raise ValueError(msg) if target.scheme == "s3": import boto3.s3 from botocore.exceptions import ClientError # separate bucket from path in target bucket = target.path[1:].split('/')[0] path = '/'.join(target.path[1:].split('/')[1:]) + '/' # If no prefix is specified, set the path to '', otherwise boto won't list the files if path == '/': path = '' if bucket == "": msg = "Enter an S3 bucket name." raise ValueError(msg) # connect to the region & bucket try: if config['target_user'] == "" and config['target_pass'] == "": s3 = boto3.client('s3', endpoint_url=f'https://{target.hostname}') else: s3 = boto3.client('s3', \ endpoint_url=f'https://{target.hostname}', \ aws_access_key_id=config['target_user'], \ aws_secret_access_key=config['target_pass']) bucket_objects = s3.list_objects_v2(Bucket=bucket, Prefix=path)['Contents'] backup_list = [(key['Key'][len(path):], key['Size']) for key in bucket_objects] except ClientError as e: raise ValueError(e) return backup_list if target.scheme == 'b2': from b2sdk.v1 import InMemoryAccountInfo, B2Api from b2sdk.v1.exception import NonExistentBucket info = InMemoryAccountInfo() b2_api = B2Api(info) # Extract information from target b2_application_keyid = target.netloc[:target.netloc.index(':')] b2_application_key = urllib.parse.unquote(target.netloc[target.netloc.index(':')+1:target.netloc.index('@')]) b2_bucket = target.netloc[target.netloc.index('@')+1:] try: b2_api.authorize_account("production", b2_application_keyid, b2_application_key) bucket = b2_api.get_bucket_by_name(b2_bucket) except NonExistentBucket: msg = "B2 Bucket does not exist. Please double check your information!" raise ValueError(msg) return [(key.file_name, key.size) for key, _ in bucket.ls()] raise ValueError(config["target"]) def backup_set_custom(env, target, target_user, target_pass, min_age): config = get_backup_config(env, for_save=True) # min_age must be an int if isinstance(min_age, str): min_age = int(min_age) config["target"] = target config["target_user"] = target_user config["target_pass"] = target_pass config["min_age_in_days"] = min_age # Validate. try: if config["target"] not in {"off", "local"}: # these aren't supported by the following function, which expects a full url in the target key, # which is what is there except when loading the config prior to saving list_target_files(config) except ValueError as e: return str(e) write_backup_config(env, config) return "OK" def get_backup_config(env, for_save=False, for_ui=False): backup_root = os.path.join(env["STORAGE_ROOT"], 'backup') # Defaults. config = { "min_age_in_days": 3, "target": "local", } # Merge in anything written to custom.yaml. try: with open(os.path.join(backup_root, 'custom.yaml'), encoding="utf-8") as f: custom_config = rtyaml.load(f) if not isinstance(custom_config, dict): raise ValueError # caught below config.update(custom_config) except: pass # When updating config.yaml, don't do any further processing on what we find. if for_save: return config # When passing this back to the admin to show the current settings, do not include # authentication details. The user will have to re-enter it. if for_ui: for field in ("target_user", "target_pass"): config.pop(field, None) # helper fields for the admin config["file_target_directory"] = os.path.join(backup_root, 'encrypted') config["enc_pw_file"] = os.path.join(backup_root, 'secret_key.txt') if config["target"] == "local": # Expand to the full URL. config["target"] = "file://" + config["file_target_directory"] ssh_pub_key = os.path.join('/root', '.ssh', 'id_rsa_miab.pub') if os.path.exists(ssh_pub_key): with open(ssh_pub_key, encoding="utf-8") as f: config["ssh_pub_key"] = f.read() return config def write_backup_config(env, newconfig): backup_root = os.path.join(env["STORAGE_ROOT"], 'backup') with open(os.path.join(backup_root, 'custom.yaml'), "w", encoding="utf-8") as f: f.write(rtyaml.dump(newconfig)) if __name__ == "__main__": if sys.argv[-1] == "--verify": # Run duplicity's verification command to check a) the backup files # are readable, and b) report if they are up to date. run_duplicity_verification() elif sys.argv[-1] == "--list": # List the saved backup files. for fn, size in list_target_files(get_backup_config(load_environment())): print(f"{fn}\t{size}") elif sys.argv[-1] == "--status": # Show backup status. ret = backup_status(load_environment()) print(rtyaml.dump(ret["backups"])) print("Storage for unmatched files:", ret["unmatched_file_size"]) elif len(sys.argv) >= 2 and sys.argv[1] == "--restore": # Run duplicity restore. Rest of command line passed as arguments # to duplicity. The restore path should be specified. run_duplicity_restore(sys.argv[2:]) elif sys.argv[-1] == "--duplicity-command": print_duplicity_command() else: # Perform a backup. Add --full to force a full backup rather than # possibly performing an incremental backup. full_backup = "--full" in sys.argv perform_backup(full_backup) ================================================ FILE: management/cli.py ================================================ #!/usr/bin/python3 # # This is a command-line script for calling management APIs # on the Mail-in-a-Box control panel backend. The script # reads /var/lib/mailinabox/api.key for the backend's # root API key. This file is readable only by root, so this # tool can only be used as root. import sys, getpass, urllib.request, urllib.error, json, csv import contextlib def mgmt(cmd, data=None, is_json=False): # The base URL for the management daemon. (Listens on IPv4 only.) mgmt_uri = 'http://127.0.0.1:10222' setup_key_auth(mgmt_uri) req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None) try: response = urllib.request.urlopen(req) except urllib.error.HTTPError as e: if e.code == 401: with contextlib.suppress(Exception): print(e.read().decode("utf8")) print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr) elif hasattr(e, 'read'): print(e.read().decode('utf8'), file=sys.stderr) else: print(e, file=sys.stderr) sys.exit(1) resp = response.read().decode('utf8') if is_json: resp = json.loads(resp) return resp def read_password(): while True: first = getpass.getpass('password: ') if len(first) < 8: print("Passwords must be at least eight characters.") continue second = getpass.getpass(' (again): ') if first != second: print("Passwords not the same. Try again.") continue break return first def setup_key_auth(mgmt_uri): with open('/var/lib/mailinabox/api.key', encoding='utf-8') as f: key = f.read().strip() auth_handler = urllib.request.HTTPBasicAuthHandler() auth_handler.add_password( realm='Mail-in-a-Box Management Server', uri=mgmt_uri, user=key, passwd='') opener = urllib.request.build_opener(auth_handler) urllib.request.install_opener(opener) if len(sys.argv) < 2: print("""Usage: {cli} user (lists users) {cli} user add user@domain.com [password] {cli} user password user@domain.com [password] {cli} user remove user@domain.com {cli} user make-admin user@domain.com {cli} user quota user@domain [new-quota] (get or set user quota) {cli} user remove-admin user@domain.com {cli} user admins (lists admins) {cli} user mfa show user@domain.com (shows MFA devices for user, if any) {cli} user mfa disable user@domain.com [id] (disables MFA for user) {cli} alias (lists aliases) {cli} alias add incoming.name@domain.com sent.to@other.domain.com {cli} alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com' {cli} alias remove incoming.name@domain.com Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login. """.format( cli="management/cli.py" )) elif sys.argv[1] == "user" and len(sys.argv) == 2: # Dump a list of users, one per line. Mark admins with an asterisk. users = mgmt("/mail/users?format=json", is_json=True) for domain in users: for user in domain["users"]: if user['status'] == 'inactive': continue print(user['email'], end='') if "admin" in user['privileges']: print("*", end='') print() elif sys.argv[1] == "user" and sys.argv[2] in {"add", "password"}: if len(sys.argv) < 5: email = input('email: ') if len(sys.argv) < 4 else sys.argv[3] pw = read_password() else: email, pw = sys.argv[3:5] if sys.argv[2] == "add": print(mgmt("/mail/users/add", { "email": email, "password": pw })) elif sys.argv[2] == "password": print(mgmt("/mail/users/password", { "email": email, "password": pw })) elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4: print(mgmt("/mail/users/remove", { "email": sys.argv[3] })) elif sys.argv[1] == "user" and sys.argv[2] in {"make-admin", "remove-admin"} and len(sys.argv) == 4: action = 'add' if sys.argv[2] == 'make-admin' else 'remove' print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" })) elif sys.argv[1] == "user" and sys.argv[2] == "admins": # Dump a list of admin users. users = mgmt("/mail/users?format=json", is_json=True) for domain in users: for user in domain["users"]: if "admin" in user['privileges']: print(user['email']) elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 4: # Get a user's quota print(mgmt(f"/mail/users/quota?text=1&email={sys.argv[3]}")) elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 5: # Set a user's quota users = mgmt("/mail/users/quota", { "email": sys.argv[3], "quota": sys.argv[4] }) elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]: # Show MFA status for a user. status = mgmt("/mfa/status", { "user": sys.argv[4] }, is_json=True) W = csv.writer(sys.stdout) W.writerow(["id", "type", "label"]) for mfa in status["enabled_mfa"]: W.writerow([mfa["id"], mfa["type"], mfa["label"]]) elif sys.argv[1] == "user" and len(sys.argv) in {5, 6} and sys.argv[2:4] == ["mfa", "disable"]: # Disable MFA (all or a particular device) for a user. print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None })) elif sys.argv[1] == "alias" and len(sys.argv) == 2: print(mgmt("/mail/aliases")) elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5: print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] })) elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4: print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] })) else: print("Invalid command-line arguments.") sys.exit(1) ================================================ FILE: management/csr_country_codes.tsv ================================================ # This list is derived from https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2. # The columns are ISO_3166-1_alpha-2 code, display name, Wikipedia page name. # The top 21 countries by number of Internet users are grouped first, see # https://en.wikipedia.org/wiki/List_of_countries_by_number_of_Internet_users. CN China IN India US United States JP Japan BR Brazil RU Russian Federation Russia DE Germany NG Nigeria GB United Kingdom FR France MX Mexico EG Egypt KR South Korea VN Vietnam ID Indonesia PH Philippines TR Turkey IT Italy PK Pakistan ES Spain CA Canada AD Andorra AE United Arab Emirates AF Afghanistan AG Antigua and Barbuda AI Anguilla AL Albania AM Armenia AO Angola AQ Antarctica AR Argentina AS American Samoa AT Austria AU Australia AW Aruba AX Åland Islands AZ Azerbaijan BA Bosnia and Herzegovina BB Barbados BD Bangladesh BE Belgium BF Burkina Faso BG Bulgaria BH Bahrain BI Burundi BJ Benin BL Saint Barthélemy BM Bermuda BN Brunei BO Bolivia BQ Bonaire, Sint Eustatius and Saba Caribbean Netherlands BS Bahamas The Bahamas BT Bhutan BV Bouvet Island BW Botswana BY Belarus BZ Belize CC Cocos (Keeling) Islands CD Congo, the Democratic Republic of the Democratic Republic of the Congo CF Central African Republic CG Congo Republic of the Congo CH Switzerland CI Côte d'Ivoire CK Cook Islands CL Chile CM Cameroon CO Colombia CR Costa Rica CU Cuba CV Cabo Verde CW Curaçao CX Christmas Island CY Cyprus CZ Czech Republic DJ Djibouti DK Denmark DM Dominica DO Dominican Republic DZ Algeria EC Ecuador EE Estonia EH Western Sahara ER Eritrea ET Ethiopia FI Finland FJ Fiji FK Falkland Islands (Malvinas) Falkland Islands FM Federated States of Micronesia FO Faroe Islands GA Gabon GD Grenada GE Georgia Georgia (country) GF French Guiana GG Guernsey GH Ghana GI Gibraltar GL Greenland GM Gambia The Gambia GN Guinea GP Guadeloupe GQ Equatorial Guinea GR Greece GS South Georgia and the South Sandwich Islands GT Guatemala GU Guam GW Guinea-Bissau GY Guyana HK Hong Kong HM Heard Island and McDonald Islands HN Honduras HR Croatia HT Haiti HU Hungary IE Ireland Republic of Ireland IL Israel IM Isle of Man IO British Indian Ocean Territory IQ Iraq IR Iran IS Iceland JE Jersey JM Jamaica JO Jordan KE Kenya KG Kyrgyzstan KH Cambodia KI Kiribati KM Comoros KN Saint Kitts and Nevis KP North Korea KW Kuwait KY Cayman Islands KZ Kazakhstan LA Laos LB Lebanon LC Saint Lucia LI Liechtenstein LK Sri Lanka LR Liberia LS Lesotho LT Lithuania LU Luxembourg LV Latvia LY Libya MA Morocco MC Monaco MD Moldova ME Montenegro MF Saint Martin (French part) Collectivity of Saint Martin MG Madagascar MH Marshall Islands MK Macedonia Republic of Macedonia ML Mali MM Myanmar MN Mongolia MO Macao Macau MP Northern Mariana Islands MQ Martinique MR Mauritania MS Montserrat MT Malta MU Mauritius MV Maldives MW Malawi MY Malaysia MZ Mozambique NA Namibia NC New Caledonia NE Niger NF Norfolk Island NI Nicaragua NL Netherlands NO Norway NP Nepal NR Nauru NU Niue NZ New Zealand OM Oman PA Panama PE Peru PF French Polynesia PG Papua New Guinea PL Poland PM Saint Pierre and Miquelon PN Pitcairn Pitcairn Islands PR Puerto Rico PS Palestine State of Palestine PT Portugal PW Palau PY Paraguay QA Qatar RE Réunion RO Romania RS Serbia RW Rwanda SA Saudi Arabia SB Solomon Islands SC Seychelles SD Sudan SE Sweden SG Singapore SH Saint Helena, Ascension and Tristan da Cunha SI Slovenia SJ Svalbard and Jan Mayen SK Slovakia SL Sierra Leone SM San Marino SN Senegal SO Somalia SR Suriname SS South Sudan ST Sao Tome and Principe SV El Salvador SX Sint Maarten (Dutch part) Sint Maarten SY Syria SZ Swaziland TC Turks and Caicos Islands TD Chad TF French Southern Territories French Southern and Antarctic Lands TG Togo TH Thailand TJ Tajikistan TK Tokelau TL Timor-Leste East Timor TM Turkmenistan TN Tunisia TO Tonga TT Trinidad and Tobago TV Tuvalu TW Taiwan TZ Tanzania UA Ukraine UG Uganda UM United States Minor Outlying Islands UY Uruguay UZ Uzbekistan VA Vatican City VC Saint Vincent and the Grenadines VE Venezuela VG Virgin Islands, British British Virgin Islands VI Virgin Islands, U.S. United States Virgin Islands VU Vanuatu WF Wallis and Futuna WS Samoa YE Yemen YT Mayotte ZA South Africa ZM Zambia ZW Zimbabwe ================================================ FILE: management/daemon.py ================================================ #!/usr/local/lib/mailinabox/env/bin/python3 # # The API can be accessed on the command line, e.g. use `curl` like so: # curl --user $(', methods=['GET', 'POST', 'PUT', 'DELETE']) @app.route('/dns/custom//', methods=['GET', 'POST', 'PUT', 'DELETE']) @authorized_personnel_only def dns_set_record(qname, rtype="A"): from dns_update import do_dns_update, set_custom_dns_record try: # Normalize. rtype = rtype.upper() # Read the record value from the request BODY, which must be # ASCII-only. Not used with GET. value = request.stream.read().decode("ascii", "ignore").strip() if request.method == "GET": # Get the existing records matching the qname and rtype. return dns_get_records(qname, rtype) if request.method in {"POST", "PUT"}: # There is a default value for A/AAAA records. if rtype in {"A", "AAAA"} and value == "": value = request.environ.get("HTTP_X_FORWARDED_FOR") # normally REMOTE_ADDR but we're behind nginx as a reverse proxy # Cannot add empty records. if value == '': return ("No value for the record provided.", 400) if request.method == "POST": # Add a new record (in addition to any existing records # for this qname-rtype pair). action = "add" elif request.method == "PUT": # In REST, PUT is supposed to be idempotent, so we'll # make this action set (replace all records for this # qname-rtype pair) rather than add (add a new record). action = "set" elif request.method == "DELETE": if value == '': # Delete all records for this qname-type pair. value = None else: # Delete just the qname-rtype-value record exactly. pass action = "remove" if set_custom_dns_record(qname, rtype, value, action, env): return do_dns_update(env) or "Something isn't right." return "OK" except ValueError as e: return (str(e), 400) @app.route('/dns/dump') @authorized_personnel_only def dns_get_dump(): from dns_update import build_recommended_dns return json_response(build_recommended_dns(env)) @app.route('/dns/zonefile/') @authorized_personnel_only def dns_get_zonefile(zone): from dns_update import get_dns_zonefile return Response(get_dns_zonefile(zone, env), status=200, mimetype='text/plain') # SSL @app.route('/ssl/status') @authorized_personnel_only def ssl_get_status(): from ssl_certificates import get_certificates_to_provision from web_update import get_web_domains_info, get_web_domains # What domains can we provision certificates for? What unexpected problems do we have? provision, cant_provision = get_certificates_to_provision(env, show_valid_certs=False) # What's the current status of TLS certificates on all of the domain? domains_status = get_web_domains_info(env) domains_status = [ { "domain": d["domain"], "status": d["ssl_certificate"][0], "text": d["ssl_certificate"][1] + (" " + cant_provision[d["domain"]] if d["domain"] in cant_provision else "") } for d in domains_status ] # Warn the user about domain names not hosted here because of other settings. for domain in set(get_web_domains(env, exclude_dns_elsewhere=False)) - set(get_web_domains(env)): domains_status.append({ "domain": domain, "status": "not-applicable", "text": "The domain's website is hosted elsewhere.", }) return json_response({ "can_provision": utils.sort_domains(provision, env), "status": domains_status, }) @app.route('/ssl/csr/', methods=['POST']) @authorized_personnel_only def ssl_get_csr(domain): from ssl_certificates import create_csr ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem')) return create_csr(domain, ssl_private_key, request.form.get('countrycode', ''), env) @app.route('/ssl/install', methods=['POST']) @authorized_personnel_only def ssl_install_cert(): from web_update import get_web_domains from ssl_certificates import install_cert domain = request.form.get('domain') ssl_cert = request.form.get('cert') ssl_chain = request.form.get('chain') if domain not in get_web_domains(env): return "Invalid domain name." return install_cert(domain, ssl_cert, ssl_chain, env) @app.route('/ssl/provision', methods=['POST']) @authorized_personnel_only def ssl_provision_certs(): from ssl_certificates import provision_certificates requests = provision_certificates(env, limit_domains=None) return json_response({ "requests": requests }) # multi-factor auth @app.route('/mfa/status', methods=['POST']) @authorized_personnel_only def mfa_get_status(): # Anyone accessing this route is an admin, and we permit them to # see the MFA status for any user if they submit a 'user' form # field. But we don't include provisioning info since a user can # only provision for themselves. email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request try: resp = { "enabled_mfa": get_public_mfa_state(email, env) } if email == request.user_email: resp.update({ "new_mfa": { "totp": provision_totp(email, env) } }) except ValueError as e: return (str(e), 400) return json_response(resp) @app.route('/mfa/totp/enable', methods=['POST']) @authorized_personnel_only def totp_post_enable(): secret = request.form.get('secret') token = request.form.get('token') label = request.form.get('label') if not isinstance(token, str): return ("Bad Input", 400) try: validate_totp_secret(secret) enable_mfa(request.user_email, "totp", secret, token, label, env) except ValueError as e: return (str(e), 400) return "OK" @app.route('/mfa/disable', methods=['POST']) @authorized_personnel_only def totp_post_disable(): # Anyone accessing this route is an admin, and we permit them to # disable the MFA status for any user if they submit a 'user' form # field. email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request try: result = disable_mfa(email, request.form.get('mfa-id') or None, env) # convert empty string to None except ValueError as e: return (str(e), 400) if result: # success return "OK" # error return ("Invalid user or MFA id.", 400) # WEB @app.route('/web/domains') @authorized_personnel_only def web_get_domains(): from web_update import get_web_domains_info return json_response(get_web_domains_info(env)) @app.route('/web/update', methods=['POST']) @authorized_personnel_only def web_update(): from web_update import do_web_update return do_web_update(env) # System @app.route('/system/version', methods=["GET"]) @authorized_personnel_only def system_version(): from status_checks import what_version_is_this try: return what_version_is_this(env) except Exception as e: return (str(e), 500) @app.route('/system/latest-upstream-version', methods=["POST"]) @authorized_personnel_only def system_latest_upstream_version(): from status_checks import get_latest_miab_version try: return get_latest_miab_version() except Exception as e: return (str(e), 500) @app.route('/system/status', methods=["POST"]) @authorized_personnel_only def system_status(): from status_checks import run_checks class WebOutput: def __init__(self): self.items = [] def add_heading(self, heading): self.items.append({ "type": "heading", "text": heading, "extra": [] }) def print_ok(self, message): self.items.append({ "type": "ok", "text": message, "extra": [] }) def print_error(self, message): self.items.append({ "type": "error", "text": message, "extra": [] }) def print_warning(self, message): self.items.append({ "type": "warning", "text": message, "extra": [] }) def print_line(self, message, monospace=False): self.items[-1]["extra"].append({ "text": message, "monospace": monospace }) output = WebOutput() # Create a temporary pool of processes for the status checks with multiprocessing.pool.Pool(processes=5) as pool: run_checks(False, env, output, pool) pool.close() pool.join() return json_response(output.items) @app.route('/system/updates') @authorized_personnel_only def show_updates(): from status_checks import list_apt_updates return "".join( "{} ({})\n".format(p["package"], p["version"]) for p in list_apt_updates()) @app.route('/system/update-packages', methods=["POST"]) @authorized_personnel_only def do_updates(): utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"]) return utils.shell("check_output", ["/usr/bin/apt-get", "-y", "upgrade"], env={ "DEBIAN_FRONTEND": "noninteractive" }) @app.route('/system/reboot', methods=["GET"]) @authorized_personnel_only def needs_reboot(): from status_checks import is_reboot_needed_due_to_package_installation if is_reboot_needed_due_to_package_installation(): return json_response(True) return json_response(False) @app.route('/system/reboot', methods=["POST"]) @authorized_personnel_only def do_reboot(): # To keep the attack surface low, we don't allow a remote reboot if one isn't necessary. from status_checks import is_reboot_needed_due_to_package_installation if is_reboot_needed_due_to_package_installation(): return utils.shell("check_output", ["/sbin/shutdown", "-r", "now"], capture_stderr=True) return "No reboot is required, so it is not allowed." @app.route('/system/backup/status') @authorized_personnel_only def backup_status(): from backup import backup_status try: return json_response(backup_status(env)) except Exception as e: return json_response({ "error": str(e) }) @app.route('/system/backup/config', methods=["GET"]) @authorized_personnel_only def backup_get_custom(): from backup import get_backup_config return json_response(get_backup_config(env, for_ui=True)) @app.route('/system/backup/config', methods=["POST"]) @authorized_personnel_only def backup_set_custom(): from backup import backup_set_custom return json_response(backup_set_custom(env, request.form.get('target', ''), request.form.get('target_user', ''), request.form.get('target_pass', ''), request.form.get('min_age', '') )) @app.route('/system/privacy', methods=["GET"]) @authorized_personnel_only def privacy_status_get(): config = utils.load_settings(env) return json_response(config.get("privacy", True)) @app.route('/system/privacy', methods=["POST"]) @authorized_personnel_only def privacy_status_set(): config = utils.load_settings(env) config["privacy"] = (request.form.get('value') == "private") utils.write_settings(config, env) return "OK" # MUNIN @app.route('/munin/') @authorized_personnel_only def munin_start(): # Munin pages, static images, and dynamically generated images are served # outside of the AJAX API. We'll start with a 'start' API that sets a cookie # that subsequent requests will read for authorization. (We don't use cookies # for the API to avoid CSRF vulnerabilities.) response = make_response("OK") response.set_cookie("session", auth_service.create_session_key(request.user_email, env, type='cookie'), max_age=60*30, secure=True, httponly=True, samesite="Strict") # 30 minute duration return response def check_request_cookie_for_admin_access(): session = auth_service.get_session(None, request.cookies.get("session", ""), "cookie", env) if not session: return False privs = get_mail_user_privileges(session["email"], env) if not isinstance(privs, list): return False return "admin" in privs def authorized_personnel_only_via_cookie(f): @wraps(f) def g(*args, **kwargs): if not check_request_cookie_for_admin_access(): return Response("Unauthorized", status=403, mimetype='text/plain', headers={}) return f(*args, **kwargs) return g @app.route('/munin/') @authorized_personnel_only_via_cookie def munin_static_file(filename=""): # Proxy the request to static files. if filename == "": filename = "index.html" return send_from_directory("/var/cache/munin/www", filename) @app.route('/munin/cgi-graph/') @authorized_personnel_only_via_cookie def munin_cgi(filename): """ Relay munin cgi dynazoom requests /usr/lib/munin/cgi/munin-cgi-graph is a perl cgi script in the munin package that is responsible for generating binary png images _and_ associated HTTP headers based on parameters in the requesting URL. All output is written to stdout which munin_cgi splits into response headers and binary response data. munin-cgi-graph reads environment variables to determine what it should do. It expects a path to be in the env-var PATH_INFO, and a querystring to be in the env-var QUERY_STRING. munin-cgi-graph has several failure modes. Some write HTTP Status headers and others return nonzero exit codes. Situating munin_cgi between the user-agent and munin-cgi-graph enables keeping the cgi script behind mailinabox's auth mechanisms and avoids additional support infrastructure like spawn-fcgi. """ COMMAND = 'su munin --preserve-environment --shell=/bin/bash -c /usr/lib/munin/cgi/munin-cgi-graph' # su changes user, we use the munin user here # --preserve-environment retains the environment, which is where Popen's `env` data is # --shell=/bin/bash ensures the shell used is bash # -c "/usr/lib/munin/cgi/munin-cgi-graph" passes the command to run as munin # "%s" is a placeholder for where the request's querystring will be added if filename == "": return ("a path must be specified", 404) query_str = request.query_string.decode("utf-8", 'ignore') env = {'PATH_INFO': f'/{filename}/', 'REQUEST_METHOD': 'GET', 'QUERY_STRING': query_str} code, binout = utils.shell('check_output', COMMAND.split(" ", 5), # Using a maxsplit of 5 keeps the last arguments together env=env, return_bytes=True, trap=True) if code != 0: # nonzero returncode indicates error app.logger.error("munin_cgi: munin-cgi-graph returned nonzero exit code, %s", code) return ("error processing graph image", 500) # /usr/lib/munin/cgi/munin-cgi-graph returns both headers and binary png when successful. # A double-Windows-style-newline always indicates the end of HTTP headers. headers, image_bytes = binout.split(b'\r\n\r\n', 1) response = make_response(image_bytes) for line in headers.splitlines(): name, value = line.decode("utf8").split(':', 1) response.headers[name] = value if 'Status' in response.headers and '404' in response.headers['Status']: app.logger.warning("munin_cgi: munin-cgi-graph returned 404 status code. PATH_INFO=%s", env['PATH_INFO']) return response def log_failed_login(request): # We need to figure out the ip to list in the message, all our calls are routed # through nginx who will put the original ip in X-Forwarded-For. # During setup we call the management interface directly to determine the user # status. So we can't always use X-Forwarded-For because during setup that header # will not be present. ip = request.headers.getlist("X-Forwarded-For")[0] if request.headers.getlist("X-Forwarded-For") else request.remote_addr # We need to add a timestamp to the log message, otherwise /dev/log will eat the "duplicate" # message. app.logger.warning("Mail-in-a-Box Management Daemon: Failed login attempt from ip %s - timestamp %s", ip, time.time()) # APP if __name__ == '__main__': if "DEBUG" in os.environ: # Turn on Flask debugging. app.debug = True if not app.debug: app.logger.addHandler(utils.create_syslog_handler()) #app.logger.info('API key: ' + auth_service.key) # Start the application server. Listens on 127.0.0.1 (IPv4 only). app.run(port=10222) ================================================ FILE: management/daily_tasks.sh ================================================ #!/bin/bash # This script is run daily (at 3am each night). # Set character encoding flags to ensure that any non-ASCII # characters don't cause problems. See setup/start.sh and # the management daemon startup script. export LANGUAGE=en_US.UTF-8 export LC_ALL=en_US.UTF-8 export LANG=en_US.UTF-8 export LC_TYPE=en_US.UTF-8 # On Mondays, i.e. once a week, send the administrator a report of total emails # sent and received so the admin might notice server abuse. if [ "$(date "+%u")" -eq 1 ]; then management/mail_log.py -t week | management/email_administrator.py "Mail-in-a-Box Usage Report" fi # Take a backup. management/backup.py 2>&1 | management/email_administrator.py "Backup Status" # Provision any new certificates for new domains or domains with expiring certificates. management/ssl_certificates.py -q 2>&1 | management/email_administrator.py "TLS Certificate Provisioning Result" # Run status checks and email the administrator if anything changed. management/status_checks.py --show-changes 2>&1 | management/email_administrator.py "Status Checks Change Notice" ================================================ FILE: management/dns_update.py ================================================ #!/usr/local/lib/mailinabox/env/bin/python # Creates DNS zone files for all of the domains of all of the mail users # and mail aliases and restarts nsd. ######################################################################## import sys, os, os.path, datetime, re, hashlib, base64 import ipaddress import rtyaml import dns.resolver from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains, get_ssh_port from ssl_certificates import get_ssl_certificates, check_certificate # From https://stackoverflow.com/questions/3026957/how-to-validate-a-domain-name-using-regex-php/16491074#16491074 # This regular expression matches domain names according to RFCs, it also accepts fqdn with an leading dot, # underscores, as well as asterisks which are allowed in domain names but not hostnames (i.e. allowed in # DNS but not in URLs), which are common in certain record types like for DKIM. DOMAIN_RE = r"^(?!\-)(?:[*][.])?(?:[a-zA-Z\d\-_]{0,62}[a-zA-Z\d_]\.){1,126}(?!\d+)[a-zA-Z\d_]{1,63}(\.?)$" def get_dns_domains(env): # Add all domain names in use by email users and mail aliases, any # domains we serve web for (except www redirects because that would # lead to infinite recursion here) and ensure PRIMARY_HOSTNAME is in the list. from mailconfig import get_mail_domains from web_update import get_web_domains domains = set() domains |= set(get_mail_domains(env)) domains |= set(get_web_domains(env, include_www_redirects=False)) domains.add(env['PRIMARY_HOSTNAME']) return domains def get_dns_zones(env): # What domains should we create DNS zones for? Never create a zone for # a domain & a subdomain of that domain. domains = get_dns_domains(env) # Exclude domains that are subdomains of other domains we know. Proceed # by looking at shorter domains first. zone_domains = set() for domain in sorted(domains, key=len): for d in zone_domains: if domain.endswith("." + d): # We found a parent domain already in the list. break else: # 'break' did not occur: there is no parent domain. zone_domains.add(domain) # Make a nice and safe filename for each domain. zonefiles = [[domain, safe_domain_name(domain) + ".txt"] for domain in zone_domains] # Sort the list so that the order is nice and so that nsd.conf has a # stable order so we don't rewrite the file & restart the service # meaninglessly. zone_order = sort_domains([ zone[0] for zone in zonefiles ], env) zonefiles.sort(key = lambda zone : zone_order.index(zone[0]) ) return zonefiles def do_dns_update(env, force=False): # Write zone files. os.makedirs('/etc/nsd/zones', exist_ok=True) zonefiles = [] updated_domains = [] for (domain, zonefile, records) in build_zones(env): # The final set of files will be signed. zonefiles.append((domain, zonefile + ".signed")) # See if the zone has changed, and if so update the serial number # and write the zone file. if not write_nsd_zone(domain, "/etc/nsd/zones/" + zonefile, records, env, force): # Zone was not updated. There were no changes. continue # Mark that we just updated this domain. updated_domains.append(domain) # Sign the zone. # # Every time we sign the zone we get a new result, which means # we can't sign a zone without bumping the zone's serial number. # Thus we only sign a zone if write_nsd_zone returned True # indicating the zone changed, and thus it got a new serial number. # write_nsd_zone is smart enough to check if a zone's signature # is nearing expiration and if so it'll bump the serial number # and return True so we get a chance to re-sign it. sign_zone(domain, zonefile, env) # Write the main nsd.conf file. if write_nsd_conf(zonefiles, list(get_custom_dns_config(env)), env): # Make sure updated_domains contains *something* if we wrote an updated # nsd.conf so that we know to restart nsd. if len(updated_domains) == 0: updated_domains.append("DNS configuration") # Tell nsd to reload changed zone files. if len(updated_domains) > 0: # 'reconfig' is needed if there are added or removed zones, but # it may not reload existing zones, so we call 'reload' too. If # nsd isn't running, nsd-control fails, so in that case revert # to restarting nsd to make sure it is running. Restarting nsd # should also refresh everything. try: shell('check_call', ["/usr/sbin/nsd-control", "reconfig"]) shell('check_call', ["/usr/sbin/nsd-control", "reload"]) except: shell('check_call', ["/usr/sbin/service", "nsd", "restart"]) # Write the OpenDKIM configuration tables for all of the mail domains. from mailconfig import get_mail_domains if write_opendkim_tables(get_mail_domains(env), env): # Settings changed. Kick opendkim. shell('check_call', ["/usr/sbin/service", "opendkim", "restart"]) if len(updated_domains) == 0: # If this is the only thing that changed? updated_domains.append("OpenDKIM configuration") # Clear bind9's DNS cache so our own DNS resolver is up to date. # (ignore errors with trap=True) shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True) if len(updated_domains) == 0: # if nothing was updated (except maybe OpenDKIM's files), don't show any output return "" return "updated DNS: " + ",".join(updated_domains) + "\n" ######################################################################## def build_zones(env): # What domains (and their zone filenames) should we build? domains = get_dns_domains(env) zonefiles = get_dns_zones(env) # Create a dictionary of domains to a set of attributes for each # domain, such as whether there are mail users at the domain. from mailconfig import get_mail_domains from web_update import get_web_domains mail_domains = set(get_mail_domains(env)) mail_user_domains = set(get_mail_domains(env, users_only=True)) # i.e. will log in for mail, Nextcloud web_domains = set(get_web_domains(env)) auto_domains = web_domains - set(get_web_domains(env, include_auto=False)) domains |= auto_domains # www redirects not included in the initial list, see above # Add ns1/ns2+PRIMARY_HOSTNAME which must also have A/AAAA records # when the box is acting as authoritative DNS server for its domains. for ns in ("ns1", "ns2"): d = ns + "." + env["PRIMARY_HOSTNAME"] domains.add(d) auto_domains.add(d) domains = { domain: { "user": domain in mail_user_domains, "mail": domain in mail_domains, "web": domain in web_domains, "auto": domain in auto_domains, } for domain in domains } # For MTA-STS, we'll need to check if the PRIMARY_HOSTNAME certificate is # singned and valid. Check that now rather than repeatedly for each domain. domains[env["PRIMARY_HOSTNAME"]]["certificate-is-valid"] = is_domain_cert_signed_and_valid(env["PRIMARY_HOSTNAME"], env) # Load custom records to add to zones. additional_records = list(get_custom_dns_config(env)) # Build DNS records for each zone. for domain, zonefile in zonefiles: # Build the records to put in the zone. records = build_zone(domain, domains, additional_records, env) yield (domain, zonefile, records) def build_zone(domain, domain_properties, additional_records, env, is_zone=True): records = [] # For top-level zones, define the authoritative name servers. # # Normally we are our own nameservers. Some TLDs require two distinct IP addresses, # so we allow the user to override the second nameserver definition so that # secondary DNS can be set up elsewhere. # # 'False' in the tuple indicates these records would not be used if the zone # is managed outside of the box. if is_zone: # Obligatory NS record to ns1.PRIMARY_HOSTNAME. records.append((None, "NS", "ns1.{}.".format(env["PRIMARY_HOSTNAME"]), False)) # NS record to ns2.PRIMARY_HOSTNAME or whatever the user overrides. # User may provide one or more additional nameservers secondary_ns_list = get_secondary_dns(additional_records, mode="NS") \ or ["ns2." + env["PRIMARY_HOSTNAME"]] records.extend((None, "NS", secondary_ns+'.', False) for secondary_ns in secondary_ns_list) # In PRIMARY_HOSTNAME... if domain == env["PRIMARY_HOSTNAME"]: # Set the A/AAAA records. Do this early for the PRIMARY_HOSTNAME so that the user cannot override them # and we can provide different explanatory text. records.append((None, "A", env["PUBLIC_IP"], "Required. Sets the IP address of the box.")) if env.get("PUBLIC_IPV6"): records.append((None, "AAAA", env["PUBLIC_IPV6"], "Required. Sets the IPv6 address of the box.")) # Add a DANE TLSA record for SMTP. records.append(("_25._tcp", "TLSA", build_tlsa_record(env), "Recommended when DNSSEC is enabled. Advertises to mail servers connecting to the box that mandatory encryption should be used.")) # Add a DANE TLSA record for HTTPS, which some browser extensions might make use of. records.append(("_443._tcp", "TLSA", build_tlsa_record(env), "Optional. When DNSSEC is enabled, provides out-of-band HTTPS certificate validation for a few web clients that support it.")) # Add a SSHFP records to help SSH key validation. One per available SSH key on this system. records.extend((None, "SSHFP", value, "Optional. Provides an out-of-band method for verifying an SSH key before connecting. Use 'VerifyHostKeyDNS yes' (or 'VerifyHostKeyDNS ask') when connecting with ssh.") for value in build_sshfp_records()) # Add DNS records for any subdomains of this domain. We should not have a zone for # both a domain and one of its subdomains. if is_zone: # don't recurse when we're just loading data for a subdomain subdomains = [d for d in domain_properties if d.endswith("." + domain)] for subdomain in subdomains: subdomain_qname = subdomain[0:-len("." + domain)] subzone = build_zone(subdomain, domain_properties, additional_records, env, is_zone=False) for child_qname, child_rtype, child_value, child_explanation in subzone: if child_qname is None: child_qname = subdomain_qname else: child_qname += "." + subdomain_qname records.append((child_qname, child_rtype, child_value, child_explanation)) has_rec_base = list(records) # clone current state def has_rec(qname, rtype, prefix=None): return any(rec[0] == qname and rec[1] == rtype and (prefix is None or rec[2].startswith(prefix)) for rec in has_rec_base) # The user may set other records that don't conflict with our settings. # Don't put any TXT records above this line, or it'll prevent any custom TXT records. for qname, rtype, value in filter_custom_records(domain, additional_records): # Don't allow custom records for record types that override anything above. # But allow multiple custom records for the same rtype --- see how has_rec_base is used. if has_rec(qname, rtype): continue # The "local" keyword on A/AAAA records are short-hand for our own IP. # This also flags for web configuration that the user wants a website here. if rtype == "A" and value == "local": value = env["PUBLIC_IP"] if rtype == "AAAA" and value == "local": if "PUBLIC_IPV6" in env: value = env["PUBLIC_IPV6"] else: continue records.append((qname, rtype, value, "(Set by user.)")) # Add A/AAAA defaults if not overridden by the user's custom settings (and not otherwise configured). # Any CNAME or A record on the qname overrides A and AAAA. But when we set the default A record, # we should not cause the default AAAA record to be skipped because it thinks a custom A record # was set. So set has_rec_base to a clone of the current set of DNS settings, and don't update # during this process. has_rec_base = list(records) a_expl = f"Required. May have a different value. Sets the IP address that {domain} resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery." if domain_properties[domain]["auto"]: if domain.startswith(("ns1.", "ns2.")): a_expl = False # omit from 'External DNS' page since this only applies if box is its own DNS server if domain.startswith("www."): a_expl = f"Optional. Sets the IP address that {domain} resolves to so that the box can provide a redirect to the parent domain." if domain.startswith("mta-sts."): a_expl = "Optional. MTA-STS Policy Host serving /.well-known/mta-sts.txt." if domain.startswith("autoconfig."): a_expl = "Provides email configuration autodiscovery support for Thunderbird Autoconfig." if domain.startswith("autodiscover."): a_expl = "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover." defaults = [ (None, "A", env["PUBLIC_IP"], a_expl), (None, "AAAA", env.get('PUBLIC_IPV6'), f"Optional. Sets the IPv6 address that {domain} resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)"), ] for qname, rtype, value, explanation in defaults: if value is None or value.strip() == "": continue # skip IPV6 if not set if not is_zone and qname == "www": continue # don't create any default 'www' subdomains on what are themselves subdomains # Set the default record, but not if: # (1) there is not a user-set record of the same type already # (2) there is not a CNAME record already, since you can't set both and who knows what takes precedence # (2) there is not an A record already (if this is an A record this is a dup of (1), and if this is an AAAA record then don't set a default AAAA record if the user sets a custom A record, since the default wouldn't make sense and it should not resolve if the user doesn't provide a new AAAA record) if not has_rec(qname, rtype) and not has_rec(qname, "CNAME") and not has_rec(qname, "A"): records.append((qname, rtype, value, explanation)) # Don't pin the list of records that has_rec checks against anymore. has_rec_base = records if domain_properties[domain]["mail"]: # The MX record says where email for the domain should be delivered: Here! if not has_rec(None, "MX", prefix="10 "): records.append((None, "MX", "10 {}.".format(env["PRIMARY_HOSTNAME"]), f"Required. Specifies the hostname (and priority) of the machine that handles @{domain} mail.")) # SPF record: Permit the box ('mx', see above) to send mail on behalf of # the domain, and no one else. # Skip if the user has set a custom SPF record. if not has_rec(None, "TXT", prefix="v=spf1 "): records.append((None, "TXT", 'v=spf1 mx -all', f"Recommended. Specifies that only the box is permitted to send @{domain} mail.")) # Append the DKIM TXT record to the zone as generated by OpenDKIM. # Skip if the user has set a DKIM record already. opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt') with open(opendkim_record_file, encoding="utf-8") as orf: m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S) val = "".join(re.findall(r'"([^"]+)"', m.group(2))) if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "): records.append((m.group(1), "TXT", val, f"Recommended. Provides a way for recipients to verify that this machine sent @{domain} mail.")) # Append a DMARC record. # Skip if the user has set a DMARC record already. if not has_rec("_dmarc", "TXT", prefix="v=DMARC1; "): records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine;', f"Recommended. Specifies that mail that does not originate from the box but claims to be from @{domain} or which does not have a valid DKIM signature is suspect and should be quarantined by the recipient's mail system.")) if domain_properties[domain]["user"]: # Add CardDAV/CalDAV SRV records on the non-primary hostname that points to the primary hostname # for autoconfiguration of mail clients (so only domains hosting user accounts need it). # The SRV record format is priority (0, whatever), weight (0, whatever), port, service provider hostname (w/ trailing dot). if domain != env["PRIMARY_HOSTNAME"]: for dav in ("card", "cal"): qname = "_" + dav + "davs._tcp" if not has_rec(qname, "SRV"): records.append((qname, "SRV", "0 0 443 " + env["PRIMARY_HOSTNAME"] + ".", "Recommended. Specifies the hostname of the server that handles CardDAV/CalDAV services for email addresses on this domain.")) # If this is a domain name that there are email addresses configured for, i.e. "something@" # this domain name, then the domain name is a MTA-STS (https://tools.ietf.org/html/rfc8461) # Policy Domain. # # A "_mta-sts" TXT record signals the presence of a MTA-STS policy. The id field helps clients # cache the policy. It should be stable so we don't update DNS unnecessarily but change when # the policy changes. It must be at most 32 letters and numbers, so we compute a hash of the # policy file. # # The policy itself is served at the "mta-sts" (no underscore) subdomain over HTTPS. Therefore # the TLS certificate used by Postfix for STARTTLS must be a valid certificate for the MX # domain name (PRIMARY_HOSTNAME) *and* the TLS certificate used by nginx for HTTPS on the mta-sts # subdomain must be valid certificate for that domain. Do not set an MTA-STS policy if either # certificate in use is not valid (e.g. because it is self-signed and a valid certificate has not # yet been provisioned). Since we cannot provision a certificate without A/AAAA records, we # always set them (by including them in the www domains) --- only the TXT records depend on there # being valid certificates. mta_sts_records = [ ] if domain_properties[domain]["mail"] \ and domain_properties[env["PRIMARY_HOSTNAME"]]["certificate-is-valid"] \ and is_domain_cert_signed_and_valid("mta-sts." + domain, env): # Compute an up-to-32-character hash of the policy file. We'll take a SHA-1 hash of the policy # file (20 bytes) and encode it as base-64 (28 bytes, using alphanumeric alternate characters # instead of '+' and '/' which are not allowed in an MTA-STS policy id) but then just take its # first 20 characters, which is more than sufficient to change whenever the policy file changes # (and ensures any '=' padding at the end of the base64 encoding is dropped). with open("/var/lib/mailinabox/mta-sts.txt", "rb") as f: mta_sts_policy_id = base64.b64encode(hashlib.sha1(f.read()).digest(), altchars=b"AA").decode("ascii")[0:20] mta_sts_records.extend([ ("_mta-sts", "TXT", "v=STSv1; id=" + mta_sts_policy_id, "Optional. Part of the MTA-STS policy for incoming mail. If set, a MTA-STS policy must also be published.") ]) # Enable SMTP TLS reporting (https://tools.ietf.org/html/rfc8460) if the user has set a config option. # Skip if the rules below if the user has set a custom _smtp._tls record. if env.get("MTA_STS_TLSRPT_RUA") and not has_rec("_smtp._tls", "TXT", prefix="v=TLSRPTv1;"): mta_sts_records.append(("_smtp._tls", "TXT", "v=TLSRPTv1; rua=" + env["MTA_STS_TLSRPT_RUA"], "Optional. Enables MTA-STS reporting.")) for qname, rtype, value, explanation in mta_sts_records: if not has_rec(qname, rtype): records.append((qname, rtype, value, explanation)) # Add no-mail-here records for any qname that has an A or AAAA record # but no MX record. This would include domain itself if domain is a # non-mail domain and also may include qnames from custom DNS records. # Do this once at the end of generating a zone. if is_zone: qnames_with_a = {qname for (qname, rtype, value, explanation) in records if rtype in {"A", "AAAA"}} qnames_with_mx = {qname for (qname, rtype, value, explanation) in records if rtype == "MX"} for qname in qnames_with_a - qnames_with_mx: # Mark this domain as not sending mail with hard-fail SPF and DMARC records. d = (qname+"." if qname else "") + domain if not has_rec(qname, "TXT", prefix="v=spf1 "): records.append((qname, "TXT", 'v=spf1 -all', f"Recommended. Prevents use of this domain name for outbound mail by specifying that no servers are valid sources for mail from @{d}. If you do send email from this domain name you should either override this record such that the SPF rule does allow the originating server, or, take the recommended approach and have the box handle mail for this domain (simply add any receiving alias at this domain name to make this machine treat the domain name as one of its mail domains).")) if not has_rec("_dmarc" + ("."+qname if qname else ""), "TXT", prefix="v=DMARC1; "): records.append(("_dmarc" + ("."+qname if qname else ""), "TXT", 'v=DMARC1; p=reject;', f"Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @{d}.")) # And with a null MX record (https://explained-from-first-principles.com/email/#null-mx-record) if not has_rec(qname, "MX"): records.append((qname, "MX", '0 .', "Recommended. Prevents use of this domain name for incoming mail.")) # Sort the records. The None records *must* go first in the nsd zone file. Otherwise it doesn't matter. records.sort(key = lambda rec : list(reversed(rec[0].split(".")) if rec[0] is not None else "")) return records def is_domain_cert_signed_and_valid(domain, env): cert = get_ssl_certificates(env).get(domain) if not cert: return False # no certificate provisioned cert_status = check_certificate(domain, cert['certificate'], cert['private-key']) return cert_status[0] == 'OK' ######################################################################## def build_tlsa_record(env): # A DANE TLSA record in DNS specifies that connections on a port # must use TLS and the certificate must match a particular criteria. # # Thanks to http://blog.huque.com/2012/10/dnssec-and-certificates.html # and https://community.letsencrypt.org/t/please-avoid-3-0-1-and-3-0-2-dane-tlsa-records-with-le-certificates/7022 # for explaining all of this! Also see https://tools.ietf.org/html/rfc6698#section-2.1 # and https://github.com/mail-in-a-box/mailinabox/issues/268#issuecomment-167160243. # # There are several criteria. We used to use "3 0 1" criteria, which # meant to pin a leaf (3) certificate (0) with SHA256 hash (1). But # certificates change, and especially as we move to short-lived certs # they change often. The TLSA record handily supports the criteria of # a leaf certificate (3)'s subject public key (1) with SHA256 hash (1). # The subject public key is the public key portion of the private key # that generated the CSR that generated the certificate. Since we # generate a private key once the first time Mail-in-a-Box is set up # and reuse it for all subsequent certificates, the TLSA record will # remain valid indefinitely. from ssl_certificates import load_cert_chain, load_pem from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat fn = os.path.join(env["STORAGE_ROOT"], "ssl", "ssl_certificate.pem") cert = load_pem(load_cert_chain(fn)[0]) subject_public_key = cert.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) # We could have also loaded ssl_private_key.pem and called priv_key.public_key().public_bytes(...) pk_hash = hashlib.sha256(subject_public_key).hexdigest() # Specify the TLSA parameters: # 3: Match the (leaf) certificate. (No CA, no trust path needed.) # 1: Match its subject public key. # 1: Use SHA256. return "3 1 1 " + pk_hash def build_sshfp_records(): # The SSHFP record is a way for us to embed this server's SSH public # key fingerprint into the DNS so that remote hosts have an out-of-band # method to confirm the fingerprint. See RFC 4255 and RFC 6594. This # depends on DNSSEC. # # On the client side, set SSH's VerifyHostKeyDNS option to 'ask' to # include this info in the key verification prompt or 'yes' to trust # the SSHFP record. # # See https://github.com/xelerance/sshfp for inspiriation. algorithm_number = { "ssh-rsa": 1, "ssh-dss": 2, "ecdsa-sha2-nistp256": 3, "ssh-ed25519": 4, } # Get our local fingerprints by running ssh-keyscan. The output looks # like the known_hosts file: hostname, keytype, fingerprint. The order # of the output is arbitrary, so sort it to prevent spurious updates # to the zone file (that trigger bumping the serial number). However, # if SSH has been configured to listen on a nonstandard port, we must # specify that port to sshkeyscan. port = get_ssh_port() # If nothing returned, SSH is probably not installed. if not port: return keys = shell("check_output", ["ssh-keyscan", "-4", "-t", "rsa,dsa,ecdsa,ed25519", "-p", str(port), "localhost"]) keys = sorted(keys.split("\n")) for key in keys: if key.strip() == "" or key[0] == "#": continue try: _host, keytype, pubkey = key.split(" ") yield "%d %d ( %s )" % ( algorithm_number[keytype], 2, # specifies we are using SHA-256 on next line hashlib.sha256(base64.b64decode(pubkey)).hexdigest().upper(), ) except: # Lots of things can go wrong. Don't let it disturb the DNS # zone. pass ######################################################################## def write_nsd_zone(domain, zonefile, records, env, force): # On the $ORIGIN line, there's typically a ';' comment at the end explaining # what the $ORIGIN line does. Any further data after the domain confuses # ldns-signzone, however. It used to say '; default zone domain'. # # The SOA contact address for all of the domains on this system is hostmaster # @ the PRIMARY_HOSTNAME. Hopefully that's legit. # # For the refresh through TTL fields, a good reference is: # https://www.ripe.net/publications/docs/ripe-203 # # A hash of the available DNSSEC keys are added in a comment so that when # the keys change we force a re-generation of the zone which triggers # re-signing it. zone = """ $ORIGIN {domain}. $TTL 86400 ; default time to live @ IN SOA ns1.{primary_domain}. hostmaster.{primary_domain}. ( __SERIAL__ ; serial number 7200 ; Refresh (secondary nameserver update interval) 3600 ; Retry (when refresh fails, how often to try again, should be lower than the refresh) 1209600 ; Expire (when refresh fails, how long secondary nameserver will keep records around anyway) 86400 ; Negative TTL (how long negative responses are cached) ) """ # Replace replacement strings. zone = zone.format(domain=domain, primary_domain=env["PRIMARY_HOSTNAME"]) # Add records. for subdomain, querytype, value, _explanation in records: if subdomain: zone += subdomain zone += "\tIN\t" + querytype + "\t" if querytype == "TXT": # Divide into 255-byte max substrings. v2 = "" while len(value) > 0: s = value[0:255] value = value[255:] s = s.replace('\\', '\\\\') # escape backslashes s = s.replace('"', '\\"') # escape quotes s = '"' + s + '"' # wrap in quotes v2 += s + " " value = v2 zone += value + "\n" # Append a stable hash of DNSSEC signing keys in a comment. zone += f"\n; DNSSEC signing keys hash: {hash_dnssec_keys(domain, env)}\n" # DNSSEC requires re-signing a zone periodically. That requires # bumping the serial number even if no other records have changed. # We don't see the DNSSEC records yet, so we have to figure out # if a re-signing is necessary so we can prematurely bump the # serial number. force_bump = False if not os.path.exists(zonefile + ".signed"): # No signed file yet. Shouldn't normally happen unless a box # is going from not using DNSSEC to using DNSSEC. force_bump = True else: # We've signed the domain. Check if we are close to the expiration # time of the signature. If so, we'll force a bump of the serial # number so we can re-sign it. with open(zonefile + ".signed", encoding="utf-8") as f: signed_zone = f.read() expiration_times = re.findall(r"\sRRSIG\s+SOA\s+\d+\s+\d+\s\d+\s+(\d{14})", signed_zone) if len(expiration_times) == 0: # weird force_bump = True else: # All of the times should be the same, but if not choose the soonest. expiration_time = min(expiration_times) expiration_time = datetime.datetime.strptime(expiration_time, "%Y%m%d%H%M%S") if expiration_time - datetime.datetime.now() < datetime.timedelta(days=3): # We're within three days of the expiration, so bump serial & resign. force_bump = True # Set the serial number. serial = datetime.datetime.now().strftime("%Y%m%d00") if os.path.exists(zonefile): # If the zone already exists, is different, and has a later serial number, # increment the number. with open(zonefile, encoding="utf-8") as f: existing_zone = f.read() m = re.search(r"(\d+)\s*;\s*serial number", existing_zone) if m: # Clear out the serial number in the existing zone file for the # purposes of seeing if anything *else* in the zone has changed. existing_serial = m.group(1) existing_zone = existing_zone.replace(m.group(0), "__SERIAL__ ; serial number") # If the existing zone is the same as the new zone (modulo the serial number), # there is no need to update the file. Unless we're forcing a bump. if zone == existing_zone and not force_bump and not force: return False # If the existing serial is not less than a serial number # based on the current date plus 00, increment it. Otherwise, # the serial number is less than our desired new serial number # so we'll use the desired new number. if existing_serial >= serial: serial = str(int(existing_serial) + 1) zone = zone.replace("__SERIAL__", serial) # Write the zone file. with open(zonefile, "w", encoding="utf-8") as f: f.write(zone) return True # file is updated def get_dns_zonefile(zone, env): for domain, fn in get_dns_zones(env): if zone == domain: break else: msg = f"{zone} is not a domain name that corresponds to a zone." raise ValueError(msg) nsd_zonefile = "/etc/nsd/zones/" + fn with open(nsd_zonefile, encoding="utf-8") as f: return f.read() ######################################################################## def write_nsd_conf(zonefiles, additional_records, env): # Write the list of zones to a configuration file. nsd_conf_file = "/etc/nsd/nsd.conf.d/zones.conf" nsdconf = "" # Append the zones. for domain, zonefile in zonefiles: nsdconf += f""" zone: name: {domain} zonefile: {zonefile} """ # If custom secondary nameservers have been set, allow zone transfers # and, if not a subnet, notifies to them. for ipaddr in get_secondary_dns(additional_records, mode="xfr"): if "/" not in ipaddr: nsdconf += f"\n\tnotify: {ipaddr} NOKEY" nsdconf += f"\n\tprovide-xfr: {ipaddr} NOKEY\n" # Check if the file is changing. If it isn't changing, # return False to flag that no change was made. if os.path.exists(nsd_conf_file): with open(nsd_conf_file, encoding="utf-8") as f: if f.read() == nsdconf: return False # Write out new contents and return True to signal that # configuration changed. with open(nsd_conf_file, "w", encoding="utf-8") as f: f.write(nsdconf) return True ######################################################################## def find_dnssec_signing_keys(domain, env): # For key that we generated (one per algorithm)... d = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec') keyconfs = [f for f in os.listdir(d) if f.endswith(".conf")] for keyconf in keyconfs: # Load the file holding the KSK and ZSK key filenames. keyconf_fn = os.path.join(d, keyconf) keyinfo = load_env_vars_from_file(keyconf_fn) # Skip this key if the conf file has a setting named DOMAINS, # holding a comma-separated list of domain names, and if this # domain is not in the list. This allows easily disabling a # key by setting "DOMAINS=" or "DOMAINS=none", other than # deleting the key's .conf file, which might result in the key # being regenerated next upgrade. Keys should be disabled if # they are not needed to reduce the DNSSEC query response size. if "DOMAINS" in keyinfo and domain not in [dd.strip() for dd in keyinfo["DOMAINS"].split(",")]: continue for keytype in ("KSK", "ZSK"): yield keytype, keyinfo[keytype] def hash_dnssec_keys(domain, env): # Create a stable (by sorting the items) hash of all of the private keys # that will be used to sign this domain. keydata = [] for keytype, keyfn in sorted(find_dnssec_signing_keys(domain, env)): oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ".private") keydata.extend((keytype, keyfn)) with open(oldkeyfn, encoding="utf-8") as fr: keydata.append( fr.read() ) keydata = "".join(keydata).encode("utf8") return hashlib.sha1(keydata).hexdigest() def sign_zone(domain, zonefile, env): # Sign the zone with all of the keys that were generated during # setup so that the user can choose which to use in their DS record at # their registrar, and also to support migration to newer algorithms. # In order to use the key files generated at setup which are for # the domain _domain_, we have to re-write the files and place # the actual domain name in it, so that ldns-signzone works. # # Patch each key, storing the patched version in /tmp for now. # Each key has a .key and .private file. Collect a list of filenames # for all of the keys (and separately just the key-signing keys). all_keys = [] ksk_keys = [] for keytype, keyfn in find_dnssec_signing_keys(domain, env): newkeyfn = '/tmp/' + keyfn.replace("_domain_", domain) for ext in (".private", ".key"): # Copy the .key and .private files to /tmp to patch them up. # # Use os.umask and open().write() to securely create a copy that only # we (root) can read. oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ext) with open(oldkeyfn, encoding="utf-8") as fr: keydata = fr.read() keydata = keydata.replace("_domain_", domain) prev_umask = os.umask(0o77) # ensure written file is not world-readable try: with open(newkeyfn + ext, "w", encoding="utf-8") as fw: fw.write(keydata) finally: os.umask(prev_umask) # other files we write should be world-readable # Put the patched key filename base (without extension) into the list of keys we'll sign with. all_keys.append(newkeyfn) if keytype == "KSK": ksk_keys.append(newkeyfn) # Do the signing. expiry_date = (datetime.datetime.now() + datetime.timedelta(days=30)).strftime("%Y%m%d") shell('check_call', ["/usr/bin/ldns-signzone", # expire the zone after 30 days "-e", expiry_date, # use NSEC3 "-n", # zonefile to sign "/etc/nsd/zones/" + zonefile, # keys to sign with (order doesn't matter -- it'll figure it out) *all_keys ] ) # Create a DS record based on the patched-up key files. The DS record is specific to the # zone being signed, so we can't use the .ds files generated when we created the keys. # The DS record points to the KSK only. Write this next to the zone file so we can # get it later to give to the user with instructions on what to do with it. # # Generate a DS record for each key. There are also several possible hash algorithms that may # be used, so we'll pre-generate all for each key. One DS record per line. Only one # needs to actually be deployed at the registrar. We'll select the preferred one # in the status checks. with open("/etc/nsd/zones/" + zonefile + ".ds", "w", encoding="utf-8") as f: for key in ksk_keys: for digest_type in ('1', '2', '4'): rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds", "-n", # output to stdout "-" + digest_type, # 1=SHA1, 2=SHA256, 4=SHA384 key + ".key" ]) f.write(rr_ds) # Remove the temporary patched key files. for fn in all_keys: os.unlink(fn + ".private") os.unlink(fn + ".key") ######################################################################## def write_opendkim_tables(domains, env): # Append a record to OpenDKIM's KeyTable and SigningTable for each domain # that we send mail from (zones and all subdomains). opendkim_key_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private') if not os.path.exists(opendkim_key_file): # Looks like OpenDKIM is not installed. return False config = { # The SigningTable maps email addresses to a key in the KeyTable that # specifies signing information for matching email addresses. Here we # map each domain to a same-named key. # # Elsewhere we set the DMARC policy for each domain such that mail claiming # to be From: the domain must be signed with a DKIM key on the same domain. # So we must have a separate KeyTable entry for each domain. "SigningTable": "".join( f"*@{domain} {domain}\n" for domain in domains ), # The KeyTable specifies the signing domain, the DKIM selector, and the # path to the private key to use for signing some mail. Per DMARC, the # signing domain must match the sender's From: domain. "KeyTable": "".join( f"{domain} {domain}:mail:{opendkim_key_file}\n" for domain in domains ), } did_update = False for filename, content in config.items(): # Don't write the file if it doesn't need an update. if os.path.exists("/etc/opendkim/" + filename): with open("/etc/opendkim/" + filename, encoding="utf-8") as f: if f.read() == content: continue # The contents needs to change. with open("/etc/opendkim/" + filename, "w", encoding="utf-8") as f: f.write(content) did_update = True # Return whether the files changed. If they didn't change, there's # no need to kick the opendkim process. return did_update ######################################################################## def get_custom_dns_config(env, only_real_records=False): try: with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), encoding="utf-8") as f: custom_dns = rtyaml.load(f) if not isinstance(custom_dns, dict): raise ValueError # caught below except: return [ ] for qname, value in custom_dns.items(): if qname == "_secondary_nameserver" and only_real_records: continue # skip fake record # Short form. Mapping a domain name to a string is short-hand # for creating A records. if isinstance(value, str): values = [("A", value)] # A mapping creates multiple records. elif isinstance(value, dict): values = value.items() # No other type of data is allowed. else: raise ValueError for rtype, value2 in values: if isinstance(value2, str): yield (qname, rtype, value2) elif isinstance(value2, list): for value3 in value2: yield (qname, rtype, value3) # No other type of data is allowed. else: raise ValueError def filter_custom_records(domain, custom_dns_iter): for qname, rtype, value in custom_dns_iter: # We don't count the secondary nameserver config (if present) as a record - that would just be # confusing to users. Instead it is accessed/manipulated directly via (get/set)_custom_dns_config. if qname == "_secondary_nameserver": continue # Is this record for the domain or one of its subdomains? # If `domain` is None, return records for all domains. if domain is not None and qname != domain and not qname.endswith("." + domain): continue # Turn the fully qualified domain name in the YAML file into # our short form (None => domain, or a relative QNAME) if # domain is not None. if domain is not None: qname = None if qname == domain else qname[0:len(qname) - len("." + domain)] yield (qname, rtype, value) def write_custom_dns_config(config, env): # We get a list of (qname, rtype, value) triples. Convert this into a # nice dictionary format for storage on disk. from collections import OrderedDict config = list(config) dns = OrderedDict() seen_qnames = set() # Process the qnames in the order we see them. for qname in [rec[0] for rec in config]: if qname in seen_qnames: continue seen_qnames.add(qname) records = [(rec[1], rec[2]) for rec in config if rec[0] == qname] if len(records) == 1 and records[0][0] == "A": dns[qname] = records[0][1] else: dns[qname] = OrderedDict() seen_rtypes = set() # Process the rtypes in the order we see them. for rtype in [rec[0] for rec in records]: if rtype in seen_rtypes: continue seen_rtypes.add(rtype) values = [rec[1] for rec in records if rec[0] == rtype] if len(values) == 1: values = values[0] dns[qname][rtype] = values # Write. config_yaml = rtyaml.dump(dns) with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w", encoding="utf-8") as f: f.write(config_yaml) def set_custom_dns_record(qname, rtype, value, action, env): # validate qname for zone, _fn in get_dns_zones(env): # It must match a zone apex or be a subdomain of a zone # that we are otherwise hosting. if qname == zone or qname.endswith("."+zone): break else: # No match. if qname != "_secondary_nameserver": msg = f"{qname} is not a domain name or a subdomain of a domain name managed by this box." raise ValueError(msg) # validate rtype rtype = rtype.upper() if value is not None and qname != "_secondary_nameserver": if not re.search(DOMAIN_RE, qname): msg = "Invalid name." raise ValueError(msg) if rtype in {"A", "AAAA"}: if value != "local": # "local" is a special flag for us v = ipaddress.ip_address(value) # raises a ValueError if there's a problem if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.") if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.") elif rtype in {"CNAME", "NS"}: if rtype == "NS" and qname == zone: msg = "NS records can only be set for subdomains." raise ValueError(msg) # ensure value has a trailing dot if not value.endswith("."): value += "." if not re.search(DOMAIN_RE, value): msg = "Invalid value." raise ValueError(msg) elif rtype in {"CNAME", "TXT", "SRV", "MX", "SSHFP", "CAA"}: # anything goes pass else: msg = f"Unknown record type '{rtype}'." raise ValueError(msg) # load existing config config = list(get_custom_dns_config(env)) # update newconfig = [] made_change = False needs_add = True for _qname, _rtype, _value in config: if action == "add": if (_qname, _rtype, _value) == (qname, rtype, value): # Record already exists. Bail. return False elif action == "set": if (_qname, _rtype) == (qname, rtype): if _value == value: # Flag that the record already exists, don't # need to add it. needs_add = False else: # Drop any other values for this (qname, rtype). made_change = True continue elif action == "remove": if (_qname, _rtype, _value) == (qname, rtype, value): # Drop this record. made_change = True continue if value is None and (_qname, _rtype) == (qname, rtype): # Drop all qname-rtype records. made_change = True continue else: raise ValueError("Invalid action: " + action) # Preserve this record. newconfig.append((_qname, _rtype, _value)) if action in {"add", "set"} and needs_add and value is not None: newconfig.append((qname, rtype, value)) made_change = True if made_change: # serialize & save write_custom_dns_config(newconfig, env) return made_change ######################################################################## def get_secondary_dns(custom_dns, mode=None): resolver = dns.resolver.get_default_resolver() resolver.timeout = 10 resolver.lifetime = 10 values = [] for qname, _rtype, value in custom_dns: if qname != '_secondary_nameserver': continue for hostname in value.split(" "): hostname = hostname.strip() if mode is None: # Just return the setting. values.append(hostname) continue # If the entry starts with "xfr:" only include it in the zone transfer settings. if hostname.startswith("xfr:"): if mode != "xfr": continue hostname = hostname[4:] # If is a hostname, before including in zone xfr lines, # resolve to an IP address. # It may not resolve to IPv6, so don't throw an exception if it # doesn't. Skip the entry if there is a DNS error. if mode == "xfr": try: ipaddress.ip_interface(hostname) # test if it's an IP address or CIDR notation values.append(hostname) except ValueError: try: response = dns.resolver.resolve(hostname+'.', "A", raise_on_no_answer=False) values.extend(map(str, response)) except dns.exception.DNSException: pass try: response = dns.resolver.resolve(hostname+'.', "AAAA", raise_on_no_answer=False) values.extend(map(str, response)) except dns.exception.DNSException: pass else: values.append(hostname) return values def set_secondary_dns(hostnames, env): if len(hostnames) > 0: # Validate that all hostnames are valid and that all zone-xfer IP addresses are valid. resolver = dns.resolver.get_default_resolver() resolver.timeout = 5 resolver.lifetime = 5 for item in hostnames: if not item.startswith("xfr:"): # Resolve hostname. try: resolver.resolve(item, "A") except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout): try: resolver.resolve(item, "AAAA") except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout): msg = f"Could not resolve the IP address of {item}." raise ValueError(msg) else: # Validate IP address. try: if "/" in item[4:]: ipaddress.ip_network(item[4:]) # raises a ValueError if there's a problem else: ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem except ValueError: msg = f"'{item[4:]}' is not an IPv4 or IPv6 address or subnet." raise ValueError(msg) # Set. set_custom_dns_record("_secondary_nameserver", "A", " ".join(hostnames), "set", env) else: # Clear. set_custom_dns_record("_secondary_nameserver", "A", None, "set", env) # Apply. return do_dns_update(env) def get_custom_dns_records(custom_dns, qname, rtype): for qname1, rtype1, value in custom_dns: if qname1 == qname and rtype1 == rtype: yield value ######################################################################## def build_recommended_dns(env): ret = [] for (domain, _zonefile, records) in build_zones(env): # remove records that we don't display records = [r for r in records if r[3] is not False] # put Required at the top, then Recommended, then everythiing else records.sort(key = lambda r : 0 if r[3].startswith("Required.") else (1 if r[3].startswith("Recommended.") else 2)) # expand qnames for i in range(len(records)): qname = domain if records[i][0] is None else records[i][0] + "." + domain records[i] = { "qname": qname, "rtype": records[i][1], "value": records[i][2], "explanation": records[i][3], } # return ret.append((domain, records)) return ret if __name__ == "__main__": from utils import load_environment env = load_environment() if sys.argv[-1] == "--lint": write_custom_dns_config(get_custom_dns_config(env), env) elif sys.argv[-1] == "--update": do_dns_update(env, force=True) else: for _zone, records in build_recommended_dns(env): for record in records: print("; " + record['explanation']) print(record['qname'], record['rtype'], record['value'], sep="\t") print() ================================================ FILE: management/email_administrator.py ================================================ #!/usr/local/lib/mailinabox/env/bin/python # Reads in STDIN. If the stream is not empty, mail it to the system administrator. import sys import html import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText # In Python 3.6: #from email.message import Message from utils import load_environment # Load system environment info. env = load_environment() # Process command line args. subject = sys.argv[1] # Administrator's email address. admin_addr = "administrator@" + env['PRIMARY_HOSTNAME'] # Read in STDIN. content = sys.stdin.read().strip() # If there's nothing coming in, just exit. if content == "": sys.exit(0) # create MIME message msg = MIMEMultipart('alternative') # In Python 3.6: #msg = Message() msg['From'] = '"{}" <{}>'.format(env['PRIMARY_HOSTNAME'], admin_addr) msg['To'] = admin_addr msg['Subject'] = "[{}] {}".format(env['PRIMARY_HOSTNAME'], subject) content_html = f'
{html.escape(content)}
' msg.attach(MIMEText(content, 'plain')) msg.attach(MIMEText(content_html, 'html')) # In Python 3.6: #msg.set_content(content) #msg.add_alternative(content_html, "html") # send smtpclient = smtplib.SMTP('127.0.0.1', 25) smtpclient.ehlo() smtpclient.sendmail( admin_addr, # MAIL FROM admin_addr, # RCPT TO msg.as_string()) smtpclient.quit() ================================================ FILE: management/mail_log.py ================================================ #!/usr/local/lib/mailinabox/env/bin/python import argparse import datetime import gzip import os.path import re import shutil import tempfile import textwrap from collections import defaultdict, OrderedDict import dateutil.parser import time from dateutil.relativedelta import relativedelta import utils LOG_FILES = ( '/var/log/mail.log.6.gz', '/var/log/mail.log.5.gz', '/var/log/mail.log.4.gz', '/var/log/mail.log.3.gz', '/var/log/mail.log.2.gz', '/var/log/mail.log.1', '/var/log/mail.log', ) TIME_DELTAS = OrderedDict([ ('all', datetime.timedelta(weeks=52)), ('month', datetime.timedelta(weeks=4)), ('2weeks', datetime.timedelta(days=14)), ('week', datetime.timedelta(days=7)), ('2days', datetime.timedelta(days=2)), ('day', datetime.timedelta(days=1)), ('12hours', datetime.timedelta(hours=12)), ('6hours', datetime.timedelta(hours=6)), ('hour', datetime.timedelta(hours=1)), ('30min', datetime.timedelta(minutes=30)), ('10min', datetime.timedelta(minutes=10)), ('5min', datetime.timedelta(minutes=5)), ('min', datetime.timedelta(minutes=1)), ('today', datetime.datetime.now() - datetime.datetime.now().replace(hour=0, minute=0, second=0)) ]) END_DATE = NOW = datetime.datetime.now() START_DATE = None VERBOSE = False # List of strings to filter users with FILTERS = None # What to show (with defaults) SCAN_OUT = True # Outgoing email SCAN_IN = True # Incoming email SCAN_DOVECOT_LOGIN = True # Dovecot Logins SCAN_GREY = False # Greylisted email SCAN_BLOCKED = False # Rejected email def scan_files(collector): """ Scan files until they run out or the earliest date is reached """ stop_scan = False for fn in LOG_FILES: tmp_file = None if not os.path.exists(fn): continue if fn[-3:] == '.gz': tmp_file = tempfile.NamedTemporaryFile() with gzip.open(fn, 'rb') as f: shutil.copyfileobj(f, tmp_file) if VERBOSE: print("Processing file", fn, "...") fn = tmp_file.name if tmp_file else fn for line in readline(fn): if scan_mail_log_line(line.strip(), collector) is False: if stop_scan: return stop_scan = True else: stop_scan = False def scan_mail_log(env): """ Scan the system's mail log files and collect interesting data This function scans the 2 most recent mail log files in /var/log/. Args: env (dict): Dictionary containing MiaB settings """ collector = { "scan_count": 0, # Number of lines scanned "parse_count": 0, # Number of lines parsed (i.e. that had their contents examined) "scan_time": time.time(), # The time in seconds the scan took "sent_mail": OrderedDict(), # Data about email sent by users "received_mail": OrderedDict(), # Data about email received by users "logins": OrderedDict(), # Data about login activity "postgrey": {}, # Data about greylisting of email addresses "rejected": OrderedDict(), # Emails that were blocked "known_addresses": None, # Addresses handled by the Miab installation "other-services": set(), } try: import mailconfig collector["known_addresses"] = (set(mailconfig.get_mail_users(env)) | {alias[0] for alias in mailconfig.get_mail_aliases(env)}) except ImportError: pass print(f"Scanning logs from {START_DATE:%Y-%m-%d %H:%M:%S} to {END_DATE:%Y-%m-%d %H:%M:%S}" ) # Scan the lines in the log files until the date goes out of range scan_files(collector) if not collector["scan_count"]: print("No log lines scanned...") return collector["scan_time"] = time.time() - collector["scan_time"] print("{scan_count} Log lines scanned, {parse_count} lines parsed in {scan_time:.2f} " "seconds\n".format(**collector)) # Print Sent Mail report if collector["sent_mail"]: msg = "Sent email" print_header(msg) data = OrderedDict(sorted(collector["sent_mail"].items(), key=email_sort)) print_user_table( data.keys(), data=[ ("sent", [u["sent_count"] for u in data.values()]), ("hosts", [len(u["hosts"]) for u in data.values()]), ], sub_data=[ ("sending hosts", [u["hosts"] for u in data.values()]), ], activity=[ ("sent", [u["activity-by-hour"] for u in data.values()]), ], earliest=[u["earliest"] for u in data.values()], latest=[u["latest"] for u in data.values()], ) accum = defaultdict(int) data = collector["sent_mail"].values() for h in range(24): accum[h] = sum(d["activity-by-hour"][h] for d in data) print_time_table( ["sent"], [accum] ) # Print Received Mail report if collector["received_mail"]: msg = "Received email" print_header(msg) data = OrderedDict(sorted(collector["received_mail"].items(), key=email_sort)) print_user_table( data.keys(), data=[ ("received", [u["received_count"] for u in data.values()]), ], activity=[ ("sent", [u["activity-by-hour"] for u in data.values()]), ], earliest=[u["earliest"] for u in data.values()], latest=[u["latest"] for u in data.values()], ) accum = defaultdict(int) for h in range(24): accum[h] = sum(d["activity-by-hour"][h] for d in data.values()) print_time_table( ["received"], [accum] ) # Print login report if collector["logins"]: msg = "User logins per hour" print_header(msg) data = OrderedDict(sorted(collector["logins"].items(), key=email_sort)) # Get a list of all of the protocols seen in the logs in reverse count order. all_protocols = defaultdict(int) for u in data.values(): for protocol_name, count in u["totals_by_protocol"].items(): all_protocols[protocol_name] += count all_protocols = [k for k, v in sorted(all_protocols.items(), key=lambda kv : -kv[1])] print_user_table( data.keys(), data=[ (protocol_name, [ round(u["totals_by_protocol"][protocol_name] / (u["latest"]-u["earliest"]).total_seconds() * 60*60, 1) if (u["latest"]-u["earliest"]).total_seconds() > 0 else 0 # prevent division by zero for u in data.values()]) for protocol_name in all_protocols ], sub_data=[ ("Protocol and Source", [[ f"{protocol_name} {host}: {count} times" for (protocol_name, host), count in sorted(u["totals_by_protocol_and_host"].items(), key=lambda kv:-kv[1]) ] for u in data.values()]) ], activity=[ (protocol_name, [u["activity-by-hour"][protocol_name] for u in data.values()]) for protocol_name in all_protocols ], earliest=[u["earliest"] for u in data.values()], latest=[u["latest"] for u in data.values()], numstr=lambda n : str(round(n, 1)), ) accum = { protocol_name: defaultdict(int) for protocol_name in all_protocols } for h in range(24): for protocol_name in all_protocols: accum[protocol_name][h] = sum(d["activity-by-hour"][protocol_name][h] for d in data.values()) print_time_table( all_protocols, [accum[protocol_name] for protocol_name in all_protocols] ) if collector["postgrey"]: msg = "Greylisted Email {:%Y-%m-%d %H:%M:%S} and {:%Y-%m-%d %H:%M:%S}" print_header(msg.format(START_DATE, END_DATE)) print(textwrap.fill( "The following mail was greylisted, meaning the emails were temporarily rejected. " "Legitimate senders must try again after three minutes.", width=80, initial_indent=" ", subsequent_indent=" " ), end='\n\n') data = OrderedDict(sorted(collector["postgrey"].items(), key=email_sort)) users = [] received = [] senders = [] sender_clients = [] delivered_dates = [] for recipient in data: sorted_recipients = sorted(data[recipient].items(), key=lambda kv: kv[1][0] or kv[1][1]) for (client_address, sender), (first_date, delivered_date) in sorted_recipients: if first_date: users.append(recipient) received.append(first_date) senders.append(sender) delivered_dates.append(delivered_date) sender_clients.append(client_address) print_user_table( users, data=[ ("received", received), ("sender", senders), ("delivered", [str(d) or "no retry yet" for d in delivered_dates]), ("sending host", sender_clients) ], delimit=True, ) if collector["rejected"]: msg = "Blocked Email {:%Y-%m-%d %H:%M:%S} and {:%Y-%m-%d %H:%M:%S}" print_header(msg.format(START_DATE, END_DATE)) data = OrderedDict(sorted(collector["rejected"].items(), key=email_sort)) rejects = [] if VERBOSE: for user_data in data.values(): user_rejects = [] for date, sender, message in user_data["blocked"]: if len(sender) > 64: sender = sender[:32] + "…" + sender[-32:] user_rejects.extend((f'{date} - {sender} ', f' {message}')) rejects.append(user_rejects) print_user_table( data.keys(), data=[ ("blocked", [len(u["blocked"]) for u in data.values()]), ], sub_data=[ ("blocked emails", rejects), ], earliest=[u["earliest"] for u in data.values()], latest=[u["latest"] for u in data.values()], ) if collector["other-services"] and VERBOSE and False: print_header("Other services") print("The following unknown services were found in the log file.") print(" ", *sorted(collector["other-services"]), sep='\n│ ') def scan_mail_log_line(line, collector): """ Scan a log line and extract interesting data """ m = re.match(r"(\w+[\s]+\d+ \d+:\d+:\d+) ([\w]+ )?([\w\-/]+)[^:]*: (.*)", line) if not m: return True date, _system, service, log = m.groups() collector["scan_count"] += 1 # print() # print("date:", date) # print("host:", system) # print("service:", service) # print("log:", log) # Replaced the dateutil parser for a less clever way of parser that is roughly 4 times faster. # date = dateutil.parser.parse(date) # strptime fails on Feb 29 with ValueError: day is out of range for month if correct year is not provided. # See https://bugs.python.org/issue26460 date = datetime.datetime.strptime(str(NOW.year) + ' ' + date, '%Y %b %d %H:%M:%S') # if log date in future, step back a year if date > NOW: date = date.replace(year = NOW.year - 1) #print("date:", date) # Check if the found date is within the time span we are scanning if date > END_DATE: # Don't process, and halt return False if date < START_DATE: # Don't process, but continue return True if service == "postfix/submission/smtpd": if SCAN_OUT: scan_postfix_submission_line(date, log, collector) elif service == "postfix/lmtp": if SCAN_IN: scan_postfix_lmtp_line(date, log, collector) elif service.endswith("-login"): if SCAN_DOVECOT_LOGIN: scan_dovecot_login_line(date, log, collector, service[:4]) elif service == "postgrey": if SCAN_GREY: scan_postgrey_line(date, log, collector) elif service == "postfix/smtpd": if SCAN_BLOCKED: scan_postfix_smtpd_line(date, log, collector) elif service in {"postfix/qmgr", "postfix/pickup", "postfix/cleanup", "postfix/scache", "spampd", "postfix/anvil", "postfix/master", "opendkim", "postfix/lmtp", "postfix/tlsmgr", "anvil"}: # nothing to look at return True else: collector["other-services"].add(service) return True collector["parse_count"] += 1 return True def scan_postgrey_line(date, log, collector): """ Scan a postgrey log line and extract interesting data """ m = re.match(r"action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), " r"client_address=(.*), sender=(.*), recipient=(.*)", log) if m: action, reason, client_name, client_address, sender, user = m.groups() if user_match(user): # Might be useful to group services that use a lot of mail different servers on sub # domains like 1.domein.com # if '.' in client_name: # addr = client_name.split('.') # if len(addr) > 2: # client_name = '.'.join(addr[1:]) key = (client_address if client_name == 'unknown' else client_name, sender) rep = collector["postgrey"].setdefault(user, {}) if action == "greylist" and reason == "new": rep[key] = (date, rep[key][1] if key in rep else None) elif action == "pass": rep[key] = (rep[key][0] if key in rep else None, date) def scan_postfix_smtpd_line(date, log, collector): """ Scan a postfix smtpd log line and extract interesting data """ # Check if the incoming mail was rejected m = re.match(r"NOQUEUE: reject: RCPT from .*?: (.*?); from=<(.*?)> to=<(.*?)>", log) if m: message, sender, user = m.groups() # skip this, if reported in the greylisting report if "Recipient address rejected: Greylisted" in message: return # only log mail to known recipients if user_match(user) and (collector["known_addresses"] is None or user in collector["known_addresses"]): data = collector["rejected"].get( user, { "blocked": [], "earliest": None, "latest": None, } ) # simplify this one m = re.search( r"Client host \[(.*?)\] blocked using zen.spamhaus.org; (.*)", message ) if m: message = "ip blocked: " + m.group(2) else: # simplify this one too m = re.search( r"Sender address \[.*@(.*)\] blocked using dbl.spamhaus.org; (.*)", message ) if m: message = "domain blocked: " + m.group(2) if data["earliest"] is None: data["earliest"] = date data["latest"] = date data["blocked"].append((date, sender, message)) collector["rejected"][user] = data def scan_dovecot_login_line(date, log, collector, protocol_name): """ Scan a dovecot login log line and extract interesting data """ m = re.match(r"Info: Login: user=<(.*?)>, method=PLAIN, rip=(.*?),", log) if m: # TODO: CHECK DIT user, host = m.groups() if user_match(user): add_login(user, date, protocol_name, host, collector) def add_login(user, date, protocol_name, host, collector): # Get the user data, or create it if the user is new data = collector["logins"].get( user, { "earliest": None, "latest": None, "totals_by_protocol": defaultdict(int), "totals_by_protocol_and_host": defaultdict(int), "activity-by-hour": defaultdict(lambda : defaultdict(int)), } ) if data["earliest"] is None: data["earliest"] = date data["latest"] = date data["totals_by_protocol"][protocol_name] += 1 data["totals_by_protocol_and_host"][protocol_name, host] += 1 if host not in {"127.0.0.1", "::1"} or True: data["activity-by-hour"][protocol_name][date.hour] += 1 collector["logins"][user] = data def scan_postfix_lmtp_line(date, log, collector): """ Scan a postfix lmtp log line and extract interesting data It is assumed that every log of postfix/lmtp indicates an email that was successfully received by Postfix. """ m = re.match(r"([A-Z0-9]+): to=<(\S+)>, .* Saved", log) if m: _, user = m.groups() if user_match(user): # Get the user data, or create it if the user is new data = collector["received_mail"].get( user, { "received_count": 0, "earliest": None, "latest": None, "activity-by-hour": defaultdict(int), } ) data["received_count"] += 1 data["activity-by-hour"][date.hour] += 1 if data["earliest"] is None: data["earliest"] = date data["latest"] = date collector["received_mail"][user] = data def scan_postfix_submission_line(date, log, collector): """ Scan a postfix submission log line and extract interesting data Lines containing a sasl_method with the values PLAIN or LOGIN are assumed to indicate a sent email. """ # Match both the 'plain' and 'login' sasl methods, since both authentication methods are # allowed by Dovecot. Exclude trailing comma after the username when additional fields # follow after. m = re.match(r"([A-Z0-9]+): client=(\S+), sasl_method=(PLAIN|LOGIN), sasl_username=(\S+)(?%d} " % max(2, max_len) for i, d in enumerate(data): lines[i] += base.format(d[h]) lines.insert(0, "┬ totals by time of day:") lines.append("└" + (len(lines[-1]) - 2) * "─") if do_print: print("\n".join(lines)) return None return lines def print_user_table(users, data=None, sub_data=None, activity=None, latest=None, earliest=None, delimit=False, numstr=str): str_temp = "{:<32} " lines = [] data = data or [] col_widths = len(data) * [0] col_left = len(data) * [False] vert_pos = 0 do_accum = all(isinstance(n, (int, float)) for _, d in data for n in d) data_accum = len(data) * ([0] if do_accum else [" "]) last_user = None for row, user in enumerate(users): if delimit: if last_user and last_user != user: lines.append(len(lines[-1]) * "…") last_user = user line = "{:<32} ".format(user[:31] + "…" if len(user) > 32 else user) for col, (l, d) in enumerate(data): if isinstance(d[row], str): col_str = str_temp.format(d[row][:31] + "…" if len(d[row]) > 32 else d[row]) col_left[col] = True elif isinstance(d[row], datetime.datetime): col_str = f"{d[row]!s:<20}" col_left[col] = True else: temp = f"{{:>{max(5, len(l) + 1, len(str(d[row])) + 1)}}}" col_str = temp.format(str(d[row])) col_widths[col] = max(col_widths[col], len(col_str)) line += col_str if do_accum: data_accum[col] += d[row] try: if None not in [latest, earliest]: # noqa: PLR6201 vert_pos = len(line) e = earliest[row] l = latest[row] timespan = relativedelta(l, e) if timespan.months: temp = " │ {:0.1f} months" line += temp.format(timespan.months + timespan.days / 30.0) elif timespan.days: temp = " │ {:0.1f} days" line += temp.format(timespan.days + timespan.hours / 24.0) elif (e.hour, e.minute) == (l.hour, l.minute): temp = " │ {:%H:%M}" line += temp.format(e) else: temp = " │ {:%H:%M} - {:%H:%M}" line += temp.format(e, l) except KeyError: pass lines.append(line.rstrip()) try: if VERBOSE: if sub_data is not None: for l, d in sub_data: if d[row]: lines.extend(('┬', f'│ {l}', '├─%s─' % (len(l) * '─'), '│')) max_len = 0 for v in list(d[row]): lines.append(f"│ {v}") max_len = max(max_len, len(v)) lines.append("└" + (max_len + 1) * "─") if activity is not None: lines.extend(print_time_table( [label for label, _ in activity], [data[row] for _, data in activity], do_print=False )) except KeyError: pass header = str_temp.format("") for col, (l, _) in enumerate(data): if col_left[col]: header += l.ljust(max(5, len(l) + 1, col_widths[col])) else: header += l.rjust(max(5, len(l) + 1, col_widths[col])) if None not in [latest, earliest]: # noqa: PLR6201 header += " │ timespan " lines.insert(0, header.rstrip()) table_width = max(len(l) for l in lines) t_line = table_width * "─" b_line = table_width * "─" if vert_pos: t_line = t_line[:vert_pos + 1] + "┼" + t_line[vert_pos + 2:] b_line = b_line[:vert_pos + 1] + ("┬" if VERBOSE else "┼") + b_line[vert_pos + 2:] lines.insert(1, t_line) lines.append(b_line) # Print totals data_accum = [numstr(a) for a in data_accum] footer = str_temp.format("Totals:" if do_accum else " ") for row, (l, _) in enumerate(data): temp = "{:>%d}" % max(5, len(l) + 1) footer += temp.format(data_accum[row]) try: if None not in [latest, earliest]: # noqa: PLR6201 max_l = max(latest) min_e = min(earliest) timespan = relativedelta(max_l, min_e) if timespan.days: temp = " │ {:0.2f} days" footer += temp.format(timespan.days + timespan.hours / 24.0) elif (min_e.hour, min_e.minute) == (max_l.hour, max_l.minute): temp = " │ {:%H:%M}" footer += temp.format(min_e) else: temp = " │ {:%H:%M} - {:%H:%M}" footer += temp.format(min_e, max_l) except KeyError: pass lines.append(footer) print("\n".join(lines)) def print_header(msg): print('\n' + msg) print("═" * len(msg), '\n') if __name__ == "__main__": try: env_vars = utils.load_environment() except FileNotFoundError: env_vars = {} parser = argparse.ArgumentParser( description="Scan the mail log files for interesting data. By default, this script " "shows today's incoming and outgoing mail statistics. This script was (" "re)written for the Mail-in-a-box email server." "https://github.com/mail-in-a-box/mailinabox", add_help=False ) # Switches to determine what to parse and what to ignore parser.add_argument("-r", "--received", help="Scan for received emails.", action="store_true") parser.add_argument("-s", "--sent", help="Scan for sent emails.", action="store_true") parser.add_argument("-l", "--logins", help="Scan for user logins to IMAP/POP3.", action="store_true") parser.add_argument("-g", "--grey", help="Scan for greylisted emails.", action="store_true") parser.add_argument("-b", "--blocked", help="Scan for blocked emails.", action="store_true") parser.add_argument("-t", "--timespan", choices=TIME_DELTAS.keys(), default='today', metavar='