[
  {
    "path": ".editorconfig",
    "content": "# EditorConfig helps developers define and maintain consistent\n# coding styles between different editors and IDEs\n# editorconfig.org\n\nroot = true\n\n[*]\nindent_style = space\nindent_size = 4\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n\n[Makefile]\nindent_style = tab\nindent_size = 4\n\n[Vagrantfile]\nindent_size = 2\n\n[*.rb]\nindent_size = 2\n\n[*.py]\nindent_style = tab\n\n[*.js]\nindent_size = 2\n\n"
  },
  {
    "path": ".gitignore",
    "content": "*~\ntests/__pycache__/\nmanagement/__pycache__/\ntools/__pycache__/\nexternals/\n.env\n.vagrant\napi/docs/api-docs.html\n*.code-workspace\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "CHANGELOG\n=========\n\nVersion 74 (January 4, 2026)\n----------------------------\n\n* Updated Roundcube to 1.6.12, fixing a security vulnerability.\n* Updated zpush to version 2.7.6.\n* Fixed fail2ban filter for Nextcloud.\n* Fixed Thunderbird auto configuration.\n* Updated links in the control panel.\n\nVersion 73 (July 11, 2025)\n--------------------------\n\nMail:\n\n* Quotas for mail storage can now be set per user in the control panel.\n* Autoconfig now includes POP3 and CardDAV/CalDAV.\n\nBackups:\n\n* Fix for S3-compatible backups (other than AWS S3 itself).\n\nControl Panel:\n\n* Backup status is added to the status checks.\n* S3 backup credentials can now be stored in environment variables.\n* Fix for when an AAAA record is set up the box's own IP address.\n* Fix for when logged out of the control panel.\n* Fix link to Z-Push client compatibility list.\n\nSetup:\n\n* The Ubuntu version check is updated.\n\nOther:\n\n* Code cleanup using the Ruff Python linter.\n* Other minor changes.\n\nVersion 72 (June 3, 2025)\n-------------------------\n\nUpgrades\n\n* Roundcube upgraded to version 1.6.11, fixing a security vulnerability.\n\nControl Panel\n\n* A warning during daily tasks related to no TLS certificates being expired is fixed.\n\nVersion 71 (January 4, 2025)\n----------------------------\n\n(Version 71a was posted on January 6, 2025 and fixes a setup regression.)\n\nUpgrades\n\n* Roundcube upgraded to version 1.6.9.\n* Z-Push upgraded to version 2.7.5.\n\nAutomated Maintenance\n\n* 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.\n* Backups now exclude the owncloud-backup folder so that we're not backing up backups.\n* Old TLS certificates are now automatically deleted to improve control panel performance.\n\nSetup\n\n* Fixed broken setup if SSH was configured to listen on multiple ports.\n* Ubuntu MOTD advertisements are now disabled.\n* Fixed missing Roundcube dependency package if NextCloud isn't installed.\n\nControl Panel\n\n* Improved status checks for secondary nameservers.\n* Spamhaus is now queried for the box's IPv6 address also.\n* DSA and EC private keys are now accepted for TLS certificates.\n* Timeouts for loading slow control panel pages are reduced.\n\nAnd other minor fixes.\n\nVersion 70 (August 15, 2024)\n----------------------------\n\n* Roundcube is updated to version 1.6.8 fixing security vulnerabilities.\n\nVersion 69 (July 20, 2024)\n--------------------------\n\nPackage updates:\n\n* Nextcloud is updated to 26.0.13.\n* Z-Push is updated to 2.7.3.\n\nOther updates:\n\n* Fixed an error generating the weekly statistics.\n* Fixed file permissions when setting up Nextcloud.\n* Added an undocumented option to proxy websockets.\n* Internal improvements to the code to make it more reliable and readable.\n\nVersion 69a (July 21, 2024) and 69b (July 23, 2024) correct setup failures.\n\nVersion 68 (April 1, 2024)\n--------------------------\n\nPackage updates:\n\n* Roundcube updated to version 1.6.6.\n* Nextcloud is updated to version 26.0.12.\n\nMail:\n\n* Updated postfix's configuration to guard against SMTP smuggling to the long-term fix (https://www.postfix.org/smtp-smuggling.html).\n\nControl Panel:\n\n* Improved reporting of Spamhaus response codes.\n* Improved detection of SSH port.\n* Fixed an error if last saved status check results were corrupted.\n* Other minor fixes.\n\nOther:\n\n* fail2ban is updated to see \"HTTP/2.0\" requests to munin also.\n* Internal improvements to the code to make it more reliable and readable.\n\nVersion 67 (December 22, 2023)\n------------------------------\n\n* Guard against a newly published vulnerability called SMTP Smuggling. See https://sec-consult.com/blog/detail/smtp-smuggling-spoofing-e-mails-worldwide/.\n\nVersion 66 (December 17, 2023)\n------------------------------\n\n* Some users reported an error installing Mail-in-a-Box related to the virtualenv command. This is hopefully fixed.\n* Roundcube is updated to 1.6.5 fixing a security vulnerability.\n* For Mail-in-a-Box developers, a new setup variable is added to pull the source code from a different repository.\n\nVersion 65 (October 27, 2023)\n-----------------------------\n\n* Roundcube updated to 1.6.4 fixing a security vulnerability.\n* zpush.sh updated to version 2.7.1.\n* Fixed a typo in the control panel.\n\nVersion 64 (September 2, 2023)\n------------------------------\n\n* Fixed broken installation when upgrading from Mail-in-a-Box version 56 (Nextcloud 22) and earlier because of an upstream packaging issue.\n* Fixed backups to work with the latest duplicity package which was not backwards compatible.\n* Fixed setting B2 as a backup target with a slash in the application key.\n* Turned off OpenDMARC diagnostic reports sent in response to incoming mail.\n* Fixed some crashes when using an unreleased version of Mail-in-a-Box.\n* Added z-push administration scripts.\n\nVersion 63 (July 27, 2023)\n--------------------------\n\n* Nextcloud updated to 25.0.7.\n\nVersion 62 (May 20, 2023)\n-------------------------\n\nPackage updates:\n\n* Nextcloud updated to 23.0.12 (and its apps also updated).\n* Roundcube updated to 1.6.1.\n* Z-Push to 2.7.0, which has compatibility for Ubuntu 22.04, so it works again.\n\nMail:\n\n* Roundcube's password change page is now working again.\n\nControl panel:\n\n* Allow setting the backup location's S3 region name for non-AWS S3-compatible backup hosts.\n* Control panel pages can be opened in a new tab/window and bookmarked and browser history navigation now works.\n* Add a Copy button to put the rsync backup public key on clipboard.\n* Allow secondary DNS xfr: items added in the control panel to be hostnames too.\n* Fixed issue where sshkeygen fails when IPv6 is disabled.\n* Fixed issue opening munin reports.\n* Fixed report formatting in status emails sent to the administrator.\n\nVersion 61.1 (January 28, 2023)\n-------------------------------\n\n* Fixed rsync backups not working with the default port.\n* 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.\n* Fix for TLS certificate SHA fingerprint not being displayed during setup.\n\nVersion 61 (January 21, 2023)\n-----------------------------\n\nSystem:\n\n* fail2ban didn't start after setup.\n\nMail:\n\n* Disable Roundcube password plugin since it was corrupting the user database.\n\nControl panel:\n\n* Fix changing existing backup settings when the rsync type is used.\n* Allow setting a custom port for rsync backups.\n* Fixes to DNS lookups during status checks when there are timeouts, enforce timeouts better.\n* A new check is added to ensure fail2ban is running.\n* Fixed a color.\n* Improve error messages in the management tools when external command-line tools are run.\n\nVersion 60.1 (October 30, 2022)\n-------------------------------\n\n* A setup issue where the DNS server nsd isn't running at the end of setup is (hopefully) fixed.\n* Nextcloud is updated to 23.0.10 (contacts to 4.2.2, calendar to 3.5.1).\n\nVersion 60 (October 11, 2022)\n-----------------------------\n\nThis is the first release for Ubuntu 22.04.\n\n**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.)\n\nFor complete upgrade instructions, see:\n\nhttps://discourse.mailinabox.email/t/version-60-for-ubuntu-22-04-is-about-to-be-released/9558\n\nNo major features of Mail-in-a-Box have changed in this release, although some minor fixes were made.\n\nWith the newer version of Ubuntu the following software packages we use are updated:\n\n* 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).\n* Nextcloud is upgraded to 23.0.4 (contacts to 4.2.0, calendar to 3.5.0).\n* Roundcube is upgraded to 1.6.0.\n* certbot is upgraded to 1.21 (via the Ubuntu repository instead of a PPA).\n* fail2ban is upgraded to 0.11.2.\n* nginx is upgraded to 1.18.\n* PHP is upgraded from 7.2 to 8.0.\n\nAlso:\n\n* Roundcube's login session cookie was tightened. Existing sessions may require a manual logout.\n* Moved Postgrey's database under $STORAGE_ROOT.\n\nVersion 57a (June 19, 2022)\n---------------------------\n\n* The Backblaze backups fix posted in Version 57 was incomplete. It's now fixed.\n\nVersion 57 (June 12, 2022)\n--------------------------\n\nSetup:\n\n* Fixed issue upgrading from Mail-in-a-Box v0.40-v0.50 because of a changed URL that Nextcloud is downloaded from.\n\nBackups:\n\n* Fixed S3 backups which broke with duplicity 0.8.23.\n* Fixed Backblaze backups which broke with latest b2sdk package by rolling back its version.\n\nControl panel:\n\n* Fixed spurious changes in system status checks messages by sorting DNSSEC DS records.\n* Fixed fail2ban lockout over IPv6 from excessive loads of the system status checks.\n* Fixed an incorrect IPv6 system status check message.\n\nVersion 56 (January 19, 2022)\n-----------------------------\n\nSoftware updates:\n\n* 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.\n* 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).\n\nSetup:\n\n* Fixed failed setup if a previous attempt failed while updating Nextcloud.\n\nControl panel:\n\n* Fixed a crash if a custom DNS entry is not under a zone managed by the box.\n* Fix DNSSEC instructions typo.\n\nOther:\n\n* Set systemd journald log retention to 10 days (from no limit) to reduce disk usage.\n* Fixed log processing for submission lines that have a sasl_sender or other extra information.\n* Fix DNS secondary nameserver refresh failure retry period.\n\nVersion 55 (October 18, 2021)\n-----------------------------\n\nMail:\n\n* \"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.\n* Upgraded to Roundcube 1.5.\n\nControl panel:\n\n* 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.\n* The login form now disables browser autocomplete in the two-factor authentication code field.\n* After logging in, the default page is now a fast-loading welcome page rather than the slow-loading system status checks page.\n* The backup retention period option now displays for B2 backup targets.\n* The DNSSEC DS record recommendations are cleaned up and now recommend changing records that use SHA1.\n* The Munin monitoring pages no longer require a separate HTTP basic authentication login and can be used if two-factor authentication is turned on.\n* Control panel logins are now tied to a session backend that allows true logouts (rather than an encrypted cookie).\n* Failed logins no longer directly reveal whether the email address corresponds to a user account.\n* Browser dark mode now inverts the color scheme.\n\nOther:\n\n* Fail2ban's IPv6 support is enabled.\n* The mail log tool now doesn't crash if there are email addresses in log messages with invalid UTF-8 characters.\n* Additional nsd.conf files can be placed in /etc/nsd.conf.d.\n\nv0.54 (June 20, 2021)\n---------------------\n\nMail:\n\n* 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.\n* 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.\n* Roundcube's login cookie is updated to use a new encryption algorithm (AES-256-CBC instead of DES-EDE-CBC).\n\nDNS:\n\n* 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.\n* Null MX records are added for domains that do not serve mail.\n\nContacts/calendar:\n\n* Updated Nextcloud to 20.0.8, contacts to 3.5.1, calendar to 2.2.0 (#1960).\n\nControl panel:\n\n* Fixed a crash in the status checks.\n* Small wording improvements.\n\nSetup:\n\n* Minor improvements to the setup scripts.\n\nv0.53a (May 8, 2021)\n--------------------\n\nThe download URL for Z-Push has been revised because the old URL stopped working.\n\nv0.53 (April 12, 2021)\n----------------------\n\nSoftware updates:\n\n* Upgraded Roundcube to version 1.4.11 addressing a security issue, and its desktop notifications plugin.\n* Upgraded Z-Push (for Exchange/ActiveSync) to version 2.6.2.\n\nControl panel:\n\n* Backblaze B2 is now a supported backup protocol.\n* Fixed an issue in the daily mail reports.\n* Sort the Custom DNS by zone and qname, and add an option to go back to the old sort order (creation order).\n\nMail:\n\n* Enable sending DMARC failure reports to senders that request them.\n\nSetup:\n\n* Fixed error when upgrading from Nextcloud 13.\n\nv0.52 (January 31, 2021)\n------------------------\n\nSoftware updates:\n\n* Upgraded Roundcube to version 1.4.10.\n* Upgraded Z-Push to 2.6.1.\n\nMail:\n\n* 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.\n* Fixed the MTA-STS policy file's line endings.\n\nControl panel:\n\n* A new Download button in the control panel's External DNS page can be used to download the required DNS records in zonefile format.\n* Fixed the problem when the control panel would report DNS entries as Not Set by increasing a bind query limit.\n* Fixed a control panel startup bug on some systems.\n* Improved an error message on a DNS lookup timeout.\n* A typo was fixed.\n\nDNS:\n\n* The TTL for NS records has been increased to 1 day to comply with some registrar requirements.\n\nSystem:\n\n* Nextcloud's photos, dashboard, and activity apps are disabled since we only support contacts and calendar.\n\nv0.51 (November 14, 2020)\n-------------------------\n\nSoftware updates:\n\n* 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)\n* Upgraded Roundcube to version 1.4.9.\n\nMail:\n\n* The MTA-STA max_age value was increased to the normal one week.\n\nControl panel:\n\n* 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.\n* TLS certificate expiry dates are now shown in ISO8601 format for clarity.\n\nv0.50 (September 25, 2020)\n--------------------------\n\nSetup:\n\n* When upgrading from versions before v0.40, setup will now warn that ownCloud/Nextcloud data cannot be migrated rather than failing the installation.\n\nMail:\n\n* 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.\n* 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.\n\nDNS:\n\n* 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.\n* IPv6 addresses can now be specified for secondary DNS nameservers in the control panel.\n\nTLS:\n\n* 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.\n\nControl panel:\n\n* The control panel API is now fully documented at https://mailinabox.email/api-docs.html.\n* User passwords can now have spaces.\n* Status checks for automatic subdomains have been moved into the section for the parent domain.\n* Typo fixed.\n\nWeb:\n\n* The default web page served on fresh installations now adds the `noindex` meta tag.\n* The HSTS header is revised to also be sent on non-success responses.\n\nv0.48 (August 26, 2020)\n-----------------------\n\nSecurity fixes:\n\n* Roundcube is updated to version 1.4.8 fixing additional cross-site scripting (XSS) vulnerabilities.\n\nv0.47 (July 29, 2020)\n---------------------\n\nSecurity fixes:\n\n* 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).\n* SSH connections are now rate-limited at the firewall level (in addition to fail2ban).\n\nv0.46 (June 11, 2020)\n---------------------\n\nSecurity fixes:\n\n* Roundcube is updated to version 1.4.6 (https://roundcube.net/news/2020/06/02/security-updates-1.4.5-and-1.3.12).\n\nv0.45 (May 16, 2020)\n--------------------\n\nSecurity fixes:\n\n* Fix missing brute force login protection for Roundcube logins.\n\nSoftware updates:\n\n* Upgraded Roundcube from 1.4.2 to 1.4.4.\n* 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)\n* Upgraded Z-Push to 2.5.2.\n\nSystem:\n\n* 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.\n* Fix for mail log statistics report on leap days.\n* Fix Mozilla autoconfig useGlobalPreferredServer setting.\n\nWeb:\n\n* Add a new hidden feature to set nginx alias in www/custom.yaml.\n\nSetup:\n\n* Improved error handling.\n\nv0.44 (February 15, 2020)\n-------------------------\n\nSystem:\n\n* 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).\n* Fixed an issue starting services when Mail-in-a-Box isn't on the root filesystem.\n* Changed some performance options affecting Roundcube and Nextcloud.\n\nSoftware updates:\n\n* 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)\n* Upgraded Z-Push to 2.5.1.\n* Upgraded Roundcube from 1.3.10 to 1.4.2 and changed the default skin (theme) to Elastic.\n\nControl panel:\n\n* The Custom DNS list of records is now sorted.\n* The emails that report TLS provisioning results now has a less scary subject line.\n\nMail:\n\n* Fetching of updated whitelist for greylisting was fetching each day instead of every month.\n* OpenDKIM signing has been changed to 'relaxed' mode so that some old mail lists that forward mail can do so.\n\nDNS:\n\n* Automatic autoconfig.* subdomains can now be suppressed with custom DNS records.\n* DNS zone transfer now works with IPv6 addresses.\n\nSetup:\n\n* An Ubuntu package source was missing on systems where it defaults off.\n\nv0.43 (September 1, 2019)\n-------------------------\n\nSecurity fixes:\n\n* 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.\n* Brute force attack prevention was missing for the managesieve service.\n\nSetup:\n\n* Nextcloud was not upgraded properly after restoring Mail-in-a-Box from a backup from v0.40 or earlier.\n\nMail:\n\n* Upgraded Roundcube to 1.3.10.\n* Fetch an updated whitelist for greylisting on a monthly basis to reduce the number of delayed incoming emails.\n\nControl panel:\n\n* When using secondary DNS, it is now possible to specify a subnet range with the `xfr:` option.\n* Fixed an issue when the secondary DNS option is used and the secondary DNS hostname resolves to multiple IP addresses.\n* Fix a bug in how a backup configuration error is shown.\n\nv0.42b (August 3, 2019)\n-----------------------\n\nChanges:\n\n* Decreased the minimum supported RAM to 502 Mb.\n* Improved mail client autoconfiguration.\n* Added support for S3-compatible backup services besides Amazon S3.\n* Fixed the control panel login page to let LastPass save passwords.\n* Fixed an error in the user privileges API.\n* Silenced some spurious messages.\n\nSoftware updates:\n\n* Upgraded Roundcube from 1.3.8 to 1.3.9.\n* 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).\n* Upgraded Z-Push from 2.4.4 to 2.5.0.\n\nNote that v0.42 (July 4, 2019) was pulled shortly after it was released to fix a Nextcloud upgrade issue.\n\nv0.41 (February 26, 2019)\n-------------------------\n\nSystem:\n\n* Missing brute force login attack prevention (fail2ban) filters which stopped working on Ubuntu 18.04 were added back.\n* Upgrades would fail if Mail-in-a-Box moved to a different directory in `systemctl link`.\n\nMail:\n\n* 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.\n\nContacts/Calendar:\n\n* Upgraded Nextcloud from 13.0.6 to 14.0.6.\n* Upgraded Contacts from 2.1.5 to 2.1.8.\n* Upgraded Calendar from 1.6.1 to 1.6.4.\n\nv0.40 (January 12, 2019)\n------------------------\n\nThis 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.\n\nWhen **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.\n\nFor complete upgrade instructions, see:\n\nhttps://discourse.mailinabox.email/t/mail-in-a-box-version-v0-40-and-moving-to-ubuntu-18-04/4289\n\nThe changelog for this release follows.\n\nSetup:\n\n* Mail-in-a-Box now targets Ubuntu 18.04 LTS, which will have support from Ubuntu through 2022.\n* 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).\n* [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.\n\nMail:\n\n* 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.\n* Sending email is now disabled on port 25 --- you must log in to port 587 to send email, per the long-standing mail instructions.\n* 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.\n\nv0.30 (January 9, 2019)\n-----------------------\n\nSetup:\n\n* Update to Roundcube 1.3.8 and the CardDAV plugin to 3.0.3.\n* Add missing rsyslog package to install line since some OS images don't have it installed by default.\n* A log file for nsd was added.\n\nControl Panel:\n\n* The users page now documents that passwords should only have ASCII characters to prevent character encoding mismatches between clients and the server.\n* The users page no longer shows user mailbox sizes because this was extremely slow for very large mailboxes.\n* The Mail-in-a-Box version is now shown in the system status checks even when the new-version check is disabled.\n* 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.\n* The explanation of greylisting has been improved.\n\nv0.29 (October 25, 2018)\n------------------------\n\n* 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.\n* Update to Nextcloud 13.0.6.\n* Update to Roundcube 1.3.7.\n* Update to Z-Push 2.4.4.\n* Backup dates listed in the control panel now use an internationalized format.\n\nv0.28 (July 30, 2018)\n---------------------\n\nSystem:\n\n* We now use EFF's `certbot` to provision TLS certificates (from Let's Encrypt) instead of our home-grown ACME library.\n\nContacts/Calendar:\n\n* Fix for Mac OS X autoconfig of the calendar.\n\nSetup:\n\n* Installing Z-Push broke because of what looks like a change or problem in their git server HTTPS certificate. That's fixed.\n\nv0.27 (June 14, 2018)\n---------------------\n\nMail:\n\n* 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.\n* Update Roundcube to version 1.3.6 and Z-Push to version 2.3.9.\n\nControl Panel:\n\n* The undocumented feature for proxying web requests to another server now sets X-Forwarded-For.\n\nv0.26c (February 13, 2018)\n--------------------------\n\nSetup:\n\n* 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.\n* Some download errors had no output --- there is more output on error now.\n\nControl Panel:\n\n* 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.\n* Installing your own TLS/SSL certificate had been broken since v0.24 because the new version of openssl became stricter about CSR generation parameters.\n* Fixed password length help text.\n\nContacts/Calendar:\n\n* Upgraded Nextcloud from 12.0.3 to 12.0.5.\n\nv0.26b (January 25, 2018)\n-------------------------\n\n* 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.\n* 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.\n* Reduce munin's log_level from debug to warning to prevent massive log files.\n\nv0.26 (January 18, 2018)\n------------------------\n\nSecurity:\n\n* 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.\n* Updated web HSTS header to use longer six month duration.\n\nMail:\n\n* 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.\n* `X-Spam-Score` header is added to incoming mail.\n\nControl panel:\n\n* RSASHA256 is now used for DNSSEC for .lv domains.\n* Some documentation/links improvements.\n\nInstaller:\n\n* 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.\n* We now fetch Z-Push from its tagged git repository, fixing an installation problem.\n* Some old PHP5 packages are removed from setup, fixing an installation bug where Apache would get installed.\n* 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.\n\nv0.25 (November 15, 2017)\n-------------------------\n\nThis 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).\n\nMail:\n\n* Update to Roundcube 1.3.3.\n\nControl Panel:\n\n* Allow custom DNS records to be set for DNS wildcard subdomains (i.e. `*`).\n\nv0.24 (October 3, 2017)\n-----------------------\n\nSystem:\n\n* Install PHP7 via a PPA. Switch to the on-demand process manager.\n\nMail:\n\n* 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.\n* Updated to [Z-Push 2.3.8](http://download.z-push.org/final/2.3/z-push-2.3.8.txt).\n* Dovecot now uses stronger 2048 bit DH params for better forward secrecy.\n\nNextcloud:\n\n* Nextcloud updated to 12.0.3, using PHP7.\n\nControl Panel:\n\n* Nameserver (NS) records can now be set on custom domains.\n* Fix an erroneous status check error due to IPv6 address formatting.\n* Aliases for administrative addresses can now be set to send mail to +tag administrative addresses.\n\nv0.23a (May 31, 2017)\n---------------------\n\nCorrects a problem in the new way third-party assets are downloaded during setup for the control panel, since v0.23.\n\nv0.23 (May 30, 2017)\n--------------------\n\nMail:\n\n* The default theme for Roundcube was changed to the nicer Larry theme.\n* Exchange/ActiveSync support has been replaced with z-push 2.3.6 from z-push.org (rather than z-push-contrib).\n\nownCloud (now Nextcloud):\n\n* ownCloud is replaced with Nextcloud 10.0.5.\n* Fixed an error in Owncloud/Nextcloud setup not updating domain when changing hostname.\n\nControl Panel/Management:\n\n* Fix an error in the control panel showing rsync backup status.\n* Fix an error in the control panel related to IPv6 addresses.\n* TLS certificates for internationalized domain names can now be provisioned from Let's Encrypt automatically.\n* 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.\n\nDNS:\n\n* Add support for custom CAA records.\n\nv0.22 (April 2, 2017)\n---------------------\n\nMail:\n\n* The CardDAV plugin has been added to Roundcube so that your ownCloud contacts are available in webmail.\n* Upgraded to Roundcube 1.2.4 and updated the persistent login plugin.\n* Allow larger messages to be checked by SpamAssassin.\n* Dovecot's vsz memory limit has been increased proportional to system memory.\n* Newly set user passwords must be at least eight characters.\n\nownCloud:\n\n* Upgraded to ownCloud 9.1.4.\n\nControl Panel/Management:\n\n* The status checks page crashed when the mailinabox.email website was down - that's fixed.\n* Made nightly re-provisioning of TLS certificates less noisy.\n* Fixed bugs in rsync backup method and in the list of recent backups.\n* Fixed incorrect status checks errors about IPv6 addresses.\n* Fixed incorrect status checks errors for secondary nameservers if round-robin custom A records are set.\n* The management mail_log.py tool has been rewritten.\n\nDNS:\n\n* Added support for DSA, ED25519, and custom SSHFP records.\n\nSystem:\n\n* The SSH fail2ban jail was not activated.\n\nInstallation:\n\n* At the end of installation, the SHA256 -- rather than SHA1 -- hash of the system's TLS certificate is shown.\n\nv0.21c (February 1, 2017)\n-------------------------\n\nInstallations 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.\n\nIf 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.\n\nv0.21b (December 4, 2016)\n-------------------------\n\nThis update corrects a first-time installation issue introduced in v0.21 caused by the new Exchange/ActiveSync feature.\n\nv0.21 (November 30, 2016)\n-------------------------\n\nThis version updates ownCloud, which may include security fixes, and makes some other smaller improvements.\n\nMail:\n\n* Header privacy filters were improperly running on the contents of forwarded email --- that's fixed.\n* We have another go at fixing a long-standing issue with training the spam filter (because of a file permissions issue).\n* Exchange/ActiveSync will now use your display name set in Roundcube in the From: line of outgoing email.\n\nownCloud:\n\n* Updated ownCloud to version 9.1.1.\n\nControl panel:\n\n* Backups can now be made using rsync-over-ssh!\n* Status checks failed if the system doesn't support iptables or doesn't have ufw installed.\n* Added support for SSHFP records when sshd listens on non-standard ports.\n* Recommendations for TLS certificate providers were removed now that everyone mostly uses Let's Encrypt.\n\nSystem:\n\n* Ubuntu's \"Upgrade to 16.04\" notice is suppressed since you should not do that.\n* Lowered memory requirements to 512MB, display a warning if system memory is below 768MB.\n\nv0.20 (September 23, 2016)\n--------------------------\n\nownCloud:\n\n* Updated to ownCloud to 8.2.7.\n\nControl Panel:\n\n* Fixed a crash that occurs when there are IPv6 DNS records due to a bug in dnspython 1.14.0.\n* Improved the wonky low disk space check.\n\nv0.19b (August 20, 2016)\n------------------------\n\nThis update corrects a security issue introduced in v0.18.\n\n* 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.\n\nv0.19a (August 18, 2016)\n------------------------\n\nThis update corrects a security issue in v0.19.\n\n* fail2ban won't start if Roundcube had not yet been used - new installations probably do not have fail2ban running.\n\nv0.19 (August 13, 2016)\n-----------------------\n\nMail:\n\n* Roundcube is updated to version 1.2.1.\n* SSLv3 and RC4 are now no longer supported in incoming and outgoing mail (SMTP port 25).\n\nControl panel:\n\n* The users and aliases APIs are now documented on their control panel pages.\n* The HSTS header was missing.\n* New status checks were added for the ufw firewall.\n\nDNS:\n\n* 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).\n\nSystem:\n\n* fail2ban jails added for SMTP submission, Roundcube, ownCloud, the control panel, and munin.\n* Mail-in-a-Box can now be installed on the i686 architecture.\n\nv0.18c (June 2, 2016)\n---------------------\n\n* 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.\n* The Munin Monitoring link in the control panel now opens a new window.\n* Added an undocumented before-backup script.\n\nv0.18b (May 16, 2016)\n---------------------\n\n* Fixed a Roundcube user accounts issue introduced in v0.18.\n\nv0.18 (May 15, 2016)\n--------------------\n\nownCloud:\n\n* Updated to ownCloud to 8.2.3\n\nMail:\n\n* 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\".\n* Fixed a long-standing issue with training the spam filter not working (because of a file permissions issue).\n\nControl panel:\n\n* Munin system monitoring graphs are now zoomable.\n* 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.\n* It is now possible to add SRV and secondary MX records in the Custom DNS page.\n* Other minor fixes.\n\nSystem:\n\n* The fail2ban recidive jail, which blocks long-duration brute force attacks, now no longer sends the administrator emails (which were not helpful).\n\nSetup:\n\n* The system hostname is now set during setup.\n* 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.\n* We now install Roundcube from the official GitHub repository instead of our own mirror, which we had previously created to solve problems with SourceForge.\n* DKIM was incorrectly set up on machines where \"localhost\" was defined as something other than \"127.0.0.1\".\n\nv0.17c (April 1, 2016)\n----------------------\n\nThis update addresses some minor security concerns and some installation issues.\n\nownCloud:\n\n* 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.\n\nMail:\n\n* Roundcube html5_notifier plugin updated from version 0.6 to 0.6.2 to fix Roundcube getting stuck for some people.\n\nControl panel:\n\n* Prevent click-jacking of the management interface by adding HTTP headers.\n* Failed login no longer reveals whether an account exists on the system.\n\nSetup:\n\n* Setup dialogs did not appear correctly when connecting to SSH using Putty on Windows.\n* We now install Roundcube from our own mirror because Sourceforge's downloads experience frequent intermittent unavailability.\n\nv0.17b (March 1, 2016)\n----------------------\n\nownCloud moved their source code to a new location, breaking our installation script.\n\nv0.17 (February 25, 2016)\n-------------------------\n\nMail:\n\n* Roundcube updated to version 1.1.4.\n* 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).\n* 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.\n* 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.\n\nControl panel:\n\n* TLS certificate provisioning would crash if DNS propagation was in progress and a challenge failed; might have shown the wrong error when provisioning fails.\n* Backup times were displayed with the wrong time zone.\n* 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.\n* Other minor fixes.\n\nSystem:\n\n* Backups to some AWS S3 regions broke in version 0.15 because we reverted the version of boto. That's now fixed.\n* On low-usage systems, don't hold backups for quite so long by taking a full backup more often.\n* Nightly status checks might fail on systems not configured with a default Unicode locale.\n* 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.\n* Reduce the number of background processes used by the management daemon to lower memory consumption.\n\nSetup:\n\n* The first screen now warns users not to install on a machine used for other things.\n\nv0.16 (January 30, 2016)\n------------------------\n\nThis update primarily adds automatic SSL (now \"TLS\") certificate provisioning from Let's Encrypt (https://letsencrypt.org/).\n\nControl Panel:\n\n* The SSL certificates (now referred to as \"TLS certificates\") page now supports provisioning free certificates from Let's Encrypt.\n* Report free memory usage.\n* Fix a crash when the git directory is not checked out to a tag.\n* When IPv6 is enabled, check that all domains (besides the system hostname) resolve over IPv6.\n* When a domain doesn't resolve to the box, don't bother checking if the TLS certificate is valid.\n* Remove rounded border on the menu bar.\n\nOther:\n\n* The Sieve port is now open so tools like the Thunderbird Sieve extension can be used to edit mail filters.\n* .be domains now offer DNSSEC options supported by the TLD\n* The daily backup will now email the administrator if there is a problem.\n* Expiring TLS certificates are now automatically renewed via Let's Encrypt.\n* File ownership for installed Roundcube files is fixed.\n* Typos fixed.\n\nv0.15a (January 9, 2016)\n------------------------\n\nMail:\n\n* Sending mail through Exchange/ActiveSync (Z-Push) had been broken since v0.14 in some setups. This is now fixed.\n\nv0.15 (January 1, 2016)\n-----------------------\n\nMail:\n\n* Updated Roundcube to version 1.1.3.\n* Auto-create aliases for abuse@, as required by RFC2142.\n* 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).\n\nControl panel:\n\n* 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.\n* Explanatory text for setting up secondary nameserver is added/fixed.\n* DNS checks now have a timeout in case a DNS server is not responding, so the checks don't stall indefinitely.\n* Better messages if external DNS is used and, weirdly, custom secondary nameservers are set.\n* Add POP to the mail client settings documentation.\n* 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.\n* 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.\n\nSystem:\n\n* Nightly backups and system status checks are now moved to 3am in the system's timezone.\n* fail2ban's recidive jail is now active, which guards against persistent brute force login attacks over long periods of time.\n* Setup (first run only) now asks for your timezone to set the system time.\n* The Exchange/ActiveSync server is now taken offline during nightly backups (along with SMTP and IMAP).\n* The machine's random number generator (/dev/urandom) is now seeded with Ubuntu Pollinate and a blocking read on /dev/random.\n* DNSSEC key generation during install now uses /dev/urandom (instead of /dev/random), which is faster.\n* 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.\n* If ownCloud sends out email, it will use the box's administrative address now (admin@yourboxname).\n* Z-Push (Exchange/ActiveSync) logs now exclude warnings and are now rotated to save disk space.\n* Fix pip command that might have not installed all necessary Python packages.\n* The control panel and backup would not work on Google Compute Engine because GCE installs a conflicting boto package.\n* 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`).\n\nv0.14 (November 4, 2015)\n------------------------\n\nMail:\n\n* 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.)\n* Moving spam out of the Spam folder and into Trash would incorrectly train Spamassassin that those messages were not spam.\n* Automatically create the Sent and Archive folders for new users.\n* The HTML5_Notifier plugin for Roundcube is now included, which when turned on in Roundcube settings provides desktop notifications for new mail.\n* The Exchange/ActiveSync backend Z-Push has been updated to fix a problem with CC'd emails not being sent to the CC recipients.\n\nCalender/Contacts:\n\n* CalDAV/CardDAV and Exchange/ActiveSync for calendar/contacts wasn't working in some network configurations.\n\nWeb:\n\n* 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.\n* 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.\n* Accommodate really long domain names by increasing an nginx setting.\n\nControl panel:\n\n* 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.\n* Added a random password generator on the users page to simplify creating new accounts.\n* When S3 backup credentials are set, the credentials are now no longer ever sent back from the box to the client, for better security.\n* Fixed the jumpiness when a modal is displayed.\n* Focus is put into the login form fields when the login form is displayed.\n* 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.\n* Status checks now check that secondary nameservers, if specified, are actually serving the domains.\n* Some errors in the control panel when there is invalid data in the database or an improperly named archived user account have been suppressed.\n* Added subresource integrity attributes to all remotely-sourced resources (i.e. via CDNs) to guard against CDNs being used as an attack vector.\n\nSystem:\n\n* Tweaks to fail2ban settings.\n* Fixed a spurious warning while installing munin.\n\nv0.13b (August 30, 2015)\n------------------------\n\nAnother 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.\n\nv0.13a (August 23, 2015)\n------------------------\n\nNote: v0.13 (no 'a', August 19, 2015) was pulled immediately due to an ownCloud bug that prevented upgrades. v0.13a works around that problem.\n\nMail:\n\n* Outbound mail headers (the Received: header) are tweaked to possibly improve deliverability.\n* Some MIME messages would hang Roundcube due to a missing package.\n* The users permitted to send as an alias can now be different from where an alias forwards to.\n\nDNS:\n\n* 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.\n* A TLSA record is added for HTTPS for DNSSEC-aware clients that support it.\n\nSystem:\n\n* Backups can now be turned off, or stored in Amazon S3, through new control panel options.\n* 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.\n* ownCloud updated to version 8.1.1 (with upgrade work-around), its memcached caching enabled.\n* When upgrading, network checks like blocked port 25 are now skipped.\n* Tweaks to the intrusion detection rules for IMAP.\n* Mail-in-a-Box's setup is a lot quieter, hiding lots of irrelevant messages.\n\nControl panel:\n\n* SSL certificate checks were failing on OVH/OpenVZ servers due to missing /dev/stdin.\n* Improve the sort order of the domains in the status checks.\n* Some links in the control panel were only working in Chrome.\n\nv0.12c (July 19, 2015)\n----------------------\n\nv0.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.\n\nv0.12b (July 4, 2015)\n---------------------\n\nThis version corrects a minor regression in v0.12 related to creating aliases targeting multiple addresses.\n\nv0.12 (July 3, 2015)\n--------------------\n\nThis is a minor update to v0.11, which was a major update. Please read v0.11's advisories.\n\n* 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.\n* Intrusion detection rules (fail2ban) are relaxed (i.e. less is blocked).\n* SSL certificates could not be installed for the new automatic 'www.' redirect domains.\n* 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.\n* User-installed SSL private keys in the BEGIN PRIVATE KEY format were not accepted.\n* SSL certificates with SAN domains with IDNA encoding were broken in v0.11.\n* Some IDNA functionality was using IDNA 2003 rather than IDNA 2008.\n\nv0.11b (June 29, 2015)\n----------------------\n\nv0.11b was posted shortly after the initial posting of v0.11 to correct a missing dependency for the new PPA.\n\nv0.11 (June 29, 2015)\n---------------------\n\nAdvisories:\n* 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.\n* 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.\n* 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.\n\nMail:\n* Greylisting will now let some reputable senders pass through immediately.\n* Searching mail (via IMAP) will now be much faster using the dovecot lucene full text search plugin.\n* Users can no longer spoof arbitrary email addresses in outbound mail (see above).\n* Fix for deleting admin@ and postmaster@ addresses.\n* Roundcube is updated to version 1.1.2, plugins updated.\n* Exchange/ActiveSync autoconfiguration was not working on all devices (e.g. iPhone) because of a case-sensitive URL.\n* The DKIM signing key has been increased to 2048 bits, from 1024, replacing the existing key.\n\nWeb:\n* 'www' subdomains now automatically redirect to their parent domain (but you'll need to install an SSL certificate).\n* OCSP no longer uses Google Public DNS.\n* The installed PHP version is no longer exposed through HTTP response headers, for better security.\n\nDNS:\n* Default IPv6 AAAA records were missing since version 0.09.\n\nControl panel:\n* Resetting a user's password now forces them to log in again everywhere.\n* Status checks were not working if an ssh server was not installed.\n* SSL certificate validation now uses the Python cryptography module in some places where openssl was used.\n* There is a new tab to show the installed version of Mail-in-a-Box and to fetch the latest released version.\n\nSystem:\n* The munin system monitoring tool is now installed and accessible at /admin/munin.\n* 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.\n* 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.\n\n\nv0.10 (June 1, 2015)\n--------------------\n\n* SMTP Submission (port 587) began offering the insecure SSLv3 protocol due to a misconfiguration in the previous version.\n* Roundcube now allows persistent logins using Roundcube-Persistent-Login-Plugin.\n* ownCloud is updated to version 8.0.3.\n* SPF records for non-mail domains were tightened.\n* The minimum greylisting delay has been reduced from 5 minutes to 3 minutes.\n* Users and aliases weren't working if they were entered with any uppercase letters. Now only lowercase is allowed.\n* After installing an SSL certificate from the control panel, the page wasn't being refreshed.\n* Backups broke if the box's hostname was changed after installation.\n* Dotfiles (i.e. .svn) stored in ownCloud Files were not accessible from ownCloud's mobile/desktop clients.\n* Fix broken install on OVH VPS's.\n\n\nv0.09 (May 8, 2015)\n-------------------\n\nMail:\n\n* Spam checking is now performed on messages larger than the previous limit of 64KB.\n* POP3S is now enabled (port 995).\n* Roundcube is updated to version 1.1.1.\n* Minor security improvements (more mail headers with user agent info are anonymized; crypto settings were tightened).\n\nownCloud:\n\n* Downloading files you uploaded to ownCloud broke because of a change in ownCloud 8.\n\nDNS:\n\n* 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.\n* It is now possible to set multiple TXT and other types of records on the same domain in the control panel.\n* 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.)\n* On some systems the `nsd` service failed to start if network interfaces were not ready.\n\nSystem / Control Panel:\n\n* 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).\n* 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).\n* There was a race condition between backups and the new nightly status checks.\n* The control panel would sometimes lock up with an unnecessary loading indicator.\n* You can no longer delete your own account from the control panel.\n\nSetup:\n\n* 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.\n* Bugs in first user account creation were fixed.\n\nv0.08 (April 1, 2015)\n---------------------\n\nMail:\n\n* The Roundcube vacation_sieve plugin by @arodier is now installed to make it easier to set vacation auto-reply messages from within Roundcube.\n* Authentication-Results headers for DMARC, added in v0.07, were mistakenly added for outbound mail --- that's now removed.\n* The Trash folder is now created automatically for new mail accounts, addressing a Roundcube error.\n\nDNS:\n\n* Custom DNS TXT records were not always working and they can now override the default SPF, DKIM, and DMARC records.\n\nSystem:\n\n* ownCloud updated to version 8.0.2.\n* Brute-force SSH and IMAP login attempts are now prevented by properly configuring fail2ban.\n* Status checks are run each night and any changes from night to night are emailed to the box administrator (the first user account).\n\nControl panel:\n\n* 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.\n* 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).\n* 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.\n* Some status check errors are turned into warnings, especially those that might not apply if External DNS is used.\n\nv0.07 (February 28, 2015)\n-------------------------\n\nMail:\n\n* 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.\n* Auto-configuration for Mozilla Thunderbird, Evolution, KMail, and Kontact is now available.\n* 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.\n* Roundcube is updated to version 1.1.0.\n* Authentication-Results headers for DMARC are now added to incoming mail.\n\nDNS:\n\n* If a custom CNAME record is set on a 'www' subdomain, the default A/AAAA records were preventing the CNAME from working.\n* 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.\n* Internationalized domain names (IDNs) are now supported for DNS and web, but email is not yet tested.\n\nWeb:\n\n* Static websites now deny access to certain dot (.) files and directories which typically have sensitive info: .ht*, .svn*, .git*, .hg*, .bzr*.\n* The nginx server no longer reports its version and OS for better privacy.\n* The HTTP->HTTPS redirect is now more efficient.\n* When serving a 'www.' domain, reuse the SSL certificate for the parent domain if it covers the 'www' subdomain too\n* 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.)\n\nControl panel:\n\n* Status checks now check that system services are actually running by pinging each port that should have something running on it.\n* The status checks are now parallelized so they may be a little faster.\n* The status check for MX records now allow any priority, in case an unusual setup is required.\n* The interface for setting website domain-specific directories is simplified.\n* The mail guide now says that to use Outlook, Outlook 2007 or later on Windows 7 and later is required.\n* External DNS settings now skip the special \"_secondary_nameserver\" key which is used for storing secondary NS information.\n\nSetup:\n\n* Install cron if it isn't already installed.\n* Fix a units problem in the minimum memory check.\n* If you override the STORAGE_ROOT, your setting will now persist if you re-run setup.\n* Hangs due to apt wanting the user to resolve a conflict should now be fixed (apt will just clobber the problematic file now).\n\nv0.06 (January 4, 2015)\n-----------------------\n\nMail:\n\n* Set better default system limits to accommodate boxes handling mail for 20+ users.\n\nContacts/calendar:\n\n* Update to ownCloud to 7.0.4.\n* Contacts syncing via ActiveSync wasn't working.\n\nControl panel:\n\n* New control panel for setting custom DNS settings (without having to use the API).\n* Status checks showed a false positive for Spamhause blacklists and for secondary DNS in some cases.\n* Status checks would fail to load if openssh-sever was not pre-installed, but openssh-server is not required.\n* 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.\n* Multi-domain and wildcard certificate can now be installed through the control panel.\n* The DNS API now allows the setting of SRV records.\n\nMisc:\n\n* IPv6 configuration error in postgrey, nginx.\n* Missing dependency on sudo.\n\nv0.05 (November 18, 2014)\n-------------------------\n\nMail:\n\n* The maximum size of outbound mail sent via webmail and Exchange/ActiveSync has been increased to 128 MB, the same as when using SMTP.\n* 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.\n* 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.\n* \"Domain aliases\" can now be configured in the control panel.\n* Updated to [Roundcube 1.0.3](http://trac.roundcube.net/wiki/Changelog).\n* IMAP/SMTP is now recommended even on iOS devices as Exchange/ActiveSync is terribly buggy.\n\nControl panel:\n\n* Installing an SSL certificate for the primary hostname would cause problems until a restart (services needed to be restarted).\n* Installing SSL certificates would fail if /tmp was on a different filesystem.\n* Better error messages when installing a SSL certificate fails.\n* The local DNS cache is now cleared each time the system status checks are run.\n* Documented how to use +tag addressing.\n* Minor UI tweaks.\n\nOther:\n\n* Updated to [ownCloud 7.0.3](http://owncloud.org/changelog/).\n* The ownCloud API is now exposed properly.\n* DNSSEC now works on `.guide` domains now too (RSASHA256).\n\nv0.04 (October 15, 2014)\n------------------------\n\nBreaking changes:\n\n* 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.\n* 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.\n\nControl panel:\n\n* The control panel has a new page for installing SSL certificates.\n* The control panel has a new page for hosting static websites.\n* The control panel now shows mailbox sizes on disk.\n* It is now possible to create catch-all aliases from the control panel.\n* Many usability improvements in the control panel.\n\nDNS:\n\n* Custom DNS A/AAAA records on subdomains were ignored.\n* It is now possible to set up a secondary DNS server.\n* DNS zones were updating even when nothing changed.\n* Strict SPF and DMARC settings are now set on all subdomains not used for mail.\n\nSecurity:\n\n* DNSSEC is now supported for the .email TLD which required a different key algorithm.\n* Nginx and Postfix now use 2048 bits of DH parameters instead of 1024.\n\nOther:\n\n* Spam filter learning by dragging mail in and out of the Spam folder should hopefully be working now.\n* Some things were broken if the machine had an IPv6 address.\n* Other things were broken if the machine was on a non-utf8 locale.\n* No longer implementing webfinger.\n* Removes apache before installing nginx, in case it has been installed by distro.\n\nv0.03 (September 24, 2014)\n--------------------------\n\n* Update existing installs of Roundcube.\n* Disabled catch-alls pending figuring out how to get users to take precedence.\n* Z-Push was not working because in v0.02 we had accidentally moved to a different version.\n* Z-Push is now locked to a specific commit so it doesn't change on us accidentally.\n* The start script is now symlinked to /usr/local/bin/mailinabox.\n\nv0.02 (September 21, 2014)\n--------------------------\n\n* Open the firewall to an alternative SSH port if set.\n* Fixed missing dependencies.\n* Set Z-Push to use sync command with ownCloud.\n* Support more concurrent connections for z-push.\n* In the status checks, handle wildcard certificates.\n* Show the status of backups in the control panel.\n* The control panel can now update a user's password.\n* Some usability improvements in the control panel.\n* Warn if a SSL cert is expiring in 30 days.\n* Use SHA2 to generate CSRs.\n* Better logic for determining when to take a full backup.\n* Reduce DNS TTL, not that it seems to really matter.\n* Add SSHFP DNS records.\n* Add an API for setting custom DNS records\n* Update to ownCloud 7.0.2.\n* Some things were broken if the machine had an IPv6 address.\n* Use a dialogs library to ask users questions during setup.\n* Other fixes.\n\nv0.01 (August 19, 2014)\n-----------------------\n\nFirst versioned release after a year of unversioned development.\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Mail-in-a-Box Code of Conduct\n\nMail-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.\n\nWe 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.\n\nThe 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.\n\n## Scope\n\nThis 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.\n\nThis 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.\n\n## Our Standards\n\nExamples of behavior that contributes to creating a positive environment include:\n\n* Using welcoming and inclusive language\n* Being respectful of differing viewpoints and experiences\n* Gracefully accepting constructive criticism\n* Showing empathy towards other community members\n* Making room for new and quieter voices\n\nExamples of unacceptable behavior by participants include:\n\n* The use of sexualized language or imagery and unwelcome sexual attention or advances\n* Trolling, insulting/derogatory/unwelcome comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or electronic address, without explicit permission\n* 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).\n* Other conduct which could reasonably be considered inappropriate in a professional setting\n* Retaliating against anyone who reports a violation of this code.\n\nWe 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.\n\n## Enforcement\n\nWe 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.\n\nInstances 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.\n\nBecause 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.\n\n## Attribution\n\nThis 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).\n\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nMail-in-a-Box is an open source project. Your contributions and pull requests are welcome.\n\n## Development\n\nTo start developing Mail-in-a-Box, [clone the repository](https://github.com/mail-in-a-box/mailinabox) and familiarize yourself with the code.\n\n    $ git clone https://github.com/mail-in-a-box/mailinabox\n\n### Vagrant and VirtualBox\n\nWe 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.\n\nWith Vagrant set up, the following should boot up Mail-in-a-Box inside a virtual machine:\n\n    $ vagrant up --provision\n\n_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._\n\n### Modifying your `hosts` file\n\nAfter 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:\n\n    $ echo \"192.168.56.4 mailinabox.lan\" | sudo tee -a /etc/hosts\n\nYou 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`.\n\n### Making changes\n\nYour 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.\n\nRunning `vagrant up --provision` again will repeat the installation with your modifications.\n\nAlternatively, you can also ssh into the VM using:\n\n    $ vagrant ssh\n\nOnce inside the VM, you can re-run individual parts of the setup like in this example:\n\n    vm$ cd /vagrant\n    vm$ sudo setup/owncloud.sh # replace with script you'd like to re-run\n\n### Tests\n\nMail-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!\n\n## Public domain\n\nThis 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.\n\nAll 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.\n\n[CC0]: http://creativecommons.org/publicdomain/zero/1.0/\n\n## Code of Conduct\n\nThis project has a [Code of Conduct](CODE_OF_CONDUCT.md). Please review it when joining our community.\n"
  },
  {
    "path": "LICENSE",
    "content": "Creative Commons Legal Code\n\nCC0 1.0 Universal\n\n    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE\n    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN\n    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS\n    INFORMATION ON AN \"AS-IS\" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES\n    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS\n    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM\n    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED\n    HEREUNDER.\n\nStatement of Purpose\n\nThe laws of most jurisdictions throughout the world automatically confer\nexclusive Copyright and Related Rights (defined below) upon the creator\nand subsequent owner(s) (each and all, an \"owner\") of an original work of\nauthorship and/or a database (each, a \"Work\").\n\nCertain owners wish to permanently relinquish those rights to a Work for\nthe purpose of contributing to a commons of creative, cultural and\nscientific works (\"Commons\") that the public can reliably and without fear\nof later claims of infringement build upon, modify, incorporate in other\nworks, reuse and redistribute as freely as possible in any form whatsoever\nand for any purposes, including without limitation commercial purposes.\nThese owners may contribute to the Commons to promote the ideal of a free\nculture and the further production of creative, cultural and scientific\nworks, or to gain reputation or greater distribution for their Work in\npart through the use and efforts of others.\n\nFor these and/or other purposes and motivations, and without any\nexpectation of additional consideration or compensation, the person\nassociating CC0 with a Work (the \"Affirmer\"), to the extent that he or she\nis an owner of Copyright and Related Rights in the Work, voluntarily\nelects to apply CC0 to the Work and publicly distribute the Work under its\nterms, with knowledge of his or her Copyright and Related Rights in the\nWork and the meaning and intended legal effect of CC0 on those rights.\n\n1. Copyright and Related Rights. A Work made available under CC0 may be\nprotected by copyright and related or neighboring rights (\"Copyright and\nRelated Rights\"). Copyright and Related Rights include, but are not\nlimited to, the following:\n\n  i. the right to reproduce, adapt, distribute, perform, display,\n     communicate, and translate a Work;\n ii. moral rights retained by the original author(s) and/or performer(s);\niii. publicity and privacy rights pertaining to a person's image or\n     likeness depicted in a Work;\n iv. rights protecting against unfair competition in regards to a Work,\n     subject to the limitations in paragraph 4(a), below;\n  v. rights protecting the extraction, dissemination, use and reuse of data\n     in a Work;\n vi. database rights (such as those arising under Directive 96/9/EC of the\n     European Parliament and of the Council of 11 March 1996 on the legal\n     protection of databases, and under any national implementation\n     thereof, including any amended or successor version of such\n     directive); and\nvii. other similar, equivalent or corresponding rights throughout the\n     world based on applicable law or treaty, and any national\n     implementations thereof.\n\n2. Waiver. To the greatest extent permitted by, but not in contravention\nof, applicable law, Affirmer hereby overtly, fully, permanently,\nirrevocably and unconditionally waives, abandons, and surrenders all of\nAffirmer's Copyright and Related Rights and associated claims and causes\nof action, whether now known or unknown (including existing as well as\nfuture claims and causes of action), in the Work (i) in all territories\nworldwide, (ii) for the maximum duration provided by applicable law or\ntreaty (including future time extensions), (iii) in any current or future\nmedium and for any number of copies, and (iv) for any purpose whatsoever,\nincluding without limitation commercial, advertising or promotional\npurposes (the \"Waiver\"). Affirmer makes the Waiver for the benefit of each\nmember of the public at large and to the detriment of Affirmer's heirs and\nsuccessors, fully intending that such Waiver shall not be subject to\nrevocation, rescission, cancellation, termination, or any other legal or\nequitable action to disrupt the quiet enjoyment of the Work by the public\nas contemplated by Affirmer's express Statement of Purpose.\n\n3. Public License Fallback. Should any part of the Waiver for any reason\nbe judged legally invalid or ineffective under applicable law, then the\nWaiver shall be preserved to the maximum extent permitted taking into\naccount Affirmer's express Statement of Purpose. In addition, to the\nextent the Waiver is so judged Affirmer hereby grants to each affected\nperson a royalty-free, non transferable, non sublicensable, non exclusive,\nirrevocable and unconditional license to exercise Affirmer's Copyright and\nRelated Rights in the Work (i) in all territories worldwide, (ii) for the\nmaximum duration provided by applicable law or treaty (including future\ntime extensions), (iii) in any current or future medium and for any number\nof copies, and (iv) for any purpose whatsoever, including without\nlimitation commercial, advertising or promotional purposes (the\n\"License\"). The License shall be deemed effective as of the date CC0 was\napplied by Affirmer to the Work. Should any part of the License for any\nreason be judged legally invalid or ineffective under applicable law, such\npartial invalidity or ineffectiveness shall not invalidate the remainder\nof the License, and in such case Affirmer hereby affirms that he or she\nwill not (i) exercise any of his or her remaining Copyright and Related\nRights in the Work or (ii) assert any associated claims and causes of\naction with respect to the Work, in either case contrary to Affirmer's\nexpress Statement of Purpose.\n\n4. Limitations and Disclaimers.\n\n a. No trademark or patent rights held by Affirmer are waived, abandoned,\n    surrendered, licensed or otherwise affected by this document.\n b. Affirmer offers the Work as-is and makes no representations or\n    warranties of any kind concerning the Work, express, implied,\n    statutory or otherwise, including without limitation warranties of\n    title, merchantability, fitness for a particular purpose, non\n    infringement, or the absence of latent or other defects, accuracy, or\n    the present or absence of errors, whether or not discoverable, all to\n    the greatest extent permissible under applicable law.\n c. Affirmer disclaims responsibility for clearing rights of other persons\n    that may apply to the Work or any use thereof, including without\n    limitation any person's Copyright and Related Rights in the Work.\n    Further, Affirmer disclaims responsibility for obtaining any necessary\n    consents, permissions or other rights required for any use of the\n    Work.\n d. Affirmer understands and acknowledges that Creative Commons is not a\n    party to this document and has no duty or obligation with respect to\n    this CC0 or use of the Work.\n"
  },
  {
    "path": "README.md",
    "content": "Mail-in-a-Box\n=============\n\nBy [@JoshData](https://github.com/JoshData) and [contributors](https://github.com/mail-in-a-box/mailinabox/graphs/contributors).\n\nMail-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.\n\n**Please see [https://mailinabox.email](https://mailinabox.email) for the project's website and setup guide!**\n\n* * *\n\nOur goals are to:\n\n* Make deploying a good mail server easy.\n* Promote [decentralization](http://redecentralize.org/), innovation, and privacy on the web.\n* Have automated, auditable, and [idempotent](https://web.archive.org/web/20190518072631/https://sharknet.us/2014/02/01/automated-configuration-management-challenges-with-idempotency/) configuration.\n* **Not** make a totally unhackable, NSA-proof server.\n* **Not** make something customizable by power users.\n\nAdditionally, this project has a [Code of Conduct](CODE_OF_CONDUCT.md), which supersedes the goals above. Please review it when joining our community.\n\n\nIn The Box\n----------\n\nMail-in-a-Box turns a fresh Ubuntu 22.04 LTS 64-bit machine into a working mail server by installing and configuring various components.\n\nIt is a one-click email appliance. There are no user-configurable setup options. It \"just works.\"\n\nThe components installed are:\n\n* 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\n* Webmail ([Roundcube](http://roundcube.net/)), mail filter rules (thanks to Roundcube and Dovecot), and email client autoconfig settings (served by [nginx](http://nginx.org/))\n* Spam filtering ([spamassassin](https://spamassassin.apache.org/)) and greylisting ([postgrey](http://postgrey.schweikert.ch/))\n* 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\n* TLS certificates are automatically provisioned using [Let's Encrypt](https://letsencrypt.org/) for protecting https and all of the other services on the box\n* 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/))\n\nIt also includes system management tools:\n\n* Comprehensive health monitoring that checks each day that services are running, ports are open, TLS certificates are valid, and DNS records are correct\n* A control panel for adding/removing mail users, aliases, custom DNS records, configuring backups, etc.\n* An API for all of the actions on the control panel\n\nInternationalized domain names are supported and configured easily (but SMTPUTF8 is not supported, unfortunately).\n\nIt 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.)\n\nFor more information on how Mail-in-a-Box handles your privacy, see the [security details page](security.md).\n\n\nInstallation\n------------\n\nSee the [setup guide](https://mailinabox.email/guide.html) for detailed, user-friendly instructions.\n\nFor experts, start with a completely fresh (really, I mean it) Ubuntu 22.04 LTS 64-bit machine. On the machine...\n\nClone this repository and checkout the tag corresponding to the most recent release (which you can find in the tags or releases lists on GitHub):\n\n\t$ git clone https://github.com/mail-in-a-box/mailinabox\n\t$ cd mailinabox\n\t$ git checkout TAGNAME\n\nBegin the installation.\n\n\t$ sudo setup/start.sh\n\nThe installation will install, uninstall, and configure packages to turn the machine into a working, good mail server.\n\nFor help, DO NOT contact Josh directly --- I don't do tech support by email or tweet (no exceptions).\n\nPost 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.\n\nNote 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.\nThis 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.\n\n\nContributing and Development\n----------------------------\n\nMail-in-a-Box is an open source project. Your contributions and pull requests are welcome. See [CONTRIBUTING](CONTRIBUTING.md) to get started. \n\n\nThe Acknowledgements\n--------------------\n\nThis 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 <a href=\"https://twitter.com/shevski\" target=\"_blank\">@shevski</a>, <a href=\"https://github.com/konklone\" target=\"_blank\">@konklone</a>, and <a href=\"https://github.com/gregelin\" target=\"_blank\">@GregElin</a>.\n\nMail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/) and [Modoboa](https://github.com/tonioo/modoboa).\n\n\nThe History\n-----------\n\n* 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).\n* 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.\n* 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.\n* 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.\n* FastCompany mentioned Mail-in-a-Box a [roundup of privacy projects](http://www.fastcompany.com/3047645/your-own-private-cloud) on June 26, 2015.\n"
  },
  {
    "path": "Vagrantfile",
    "content": "# -*- mode: ruby -*-\n# vi: set ft=ruby :\n\nVagrant.configure(\"2\") do |config|\n  config.vm.box = \"ubuntu/jammy64\"\n\n  # Network config: Since it's a mail server, the machine must be connected\n  # to the public web. However, we currently don't want to expose SSH since\n  # the machine's box will let anyone log into it. So instead we'll put the\n  # machine on a private network.\n  config.vm.hostname = \"mailinabox.lan\"\n  config.vm.network \"private_network\", ip: \"192.168.56.4\"\n\n  config.vm.provision :shell, :inline => <<-SH\n    # Set environment variables so that the setup script does\n    # not ask any questions during provisioning. We'll let the\n    # machine figure out its own public IP.\n    export NONINTERACTIVE=1\n    export PUBLIC_IP=auto\n    export PUBLIC_IPV6=auto\n    export PRIMARY_HOSTNAME=auto\n    #export SKIP_NETWORK_CHECKS=1\n\n    # Start the setup script.\n    cd /vagrant\n    setup/start.sh\nSH\nend\n"
  },
  {
    "path": "api/docs/generate-docs.sh",
    "content": "#!/usr/bin/env sh\n\n# Requirements:\n# - Node.js\n# - redoc-cli (`npm install redoc-cli -g`)\n\nredoc-cli bundle ../mailinabox.yml \\\n  -t template.hbs \\\n  -o api-docs.html \\\n  --templateOptions.metaDescription=\"Mail-in-a-Box HTTP API\" \\\n  --title=\"Mail-in-a-Box HTTP API\" \\\n  --options.expandSingleSchemaField \\\n  --options.hideSingleRequestSampleTab \\\n  --options.jsonSampleExpandLevel=10 \\\n  --options.hideDownloadButton \\\n  --options.theme.logo.maxHeight=180px \\\n  --options.theme.logo.maxWidth=180px \\\n  --options.theme.colors.primary.main=\"#C52\" \\\n  --options.theme.typography.fontSize=16px \\\n  --options.theme.typography.fontFamily=\"Raleway, sans-serif\" \\\n  --options.theme.typography.headings.fontFamily=\"Ubuntu, Arial, sans-serif\" \\\n  --options.theme.typography.code.fontSize=15px \\\n  --options.theme.typography.code.fontFamily='\"Source Code Pro\", monospace'"
  },
  {
    "path": "api/docs/template.hbs",
    "content": "<!DOCTYPE html>\n<html>\n\n<head>\n  <meta charset=\"utf8\" />\n  <title>{{title}}</title>\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <meta name=\"description\" content=\"{{templateOptions.metaDescription}}\" />\n  <link rel=\"icon\" type=\"image/png\" href=\"https://mailinabox.email/static/logo_small.png\">\n  <link rel=\"apple-touch-icon\" type=\"image/png\" href=\"https://mailinabox.email/static/logo_small.png\">\n  <link href=\"https://fonts.googleapis.com/css?family=Raleway:400,700\" rel=\"stylesheet\" />\n  <link href=\"https://fonts.googleapis.com/css?family=Ubuntu:300\" rel=\"stylesheet\" />\n  <link href=\"https://fonts.googleapis.com/css?family=Source+Code+Pro:500\" rel=\"stylesheet\" />\n  <style>\n    body {\n      margin: 0;\n      padding: 0;\n    }\n\n    h1 {\n      color: #000 !important;\n    }\n  </style>\n  {{{redocHead}}}\n</head>\n\n<body>\n  {{{redocHTML}}}\n</body>\n\n</html>\n"
  },
  {
    "path": "api/mailinabox.yml",
    "content": "openapi: 3.0.3\ninfo:\n  title: Mail-in-a-Box\n  description: |\n    Mail-in-a-Box API HTTP specification.\n\n    # Introduction\n    This API is documented in [**OpenAPI format**](http://spec.openapis.org/oas/v3.0.3).\n    ([View the full HTTP specification](https://raw.githubusercontent.com/mail-in-a-box/mailinabox/api-spec/api/mailinabox.yml).)\n\n    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.\n  contact:\n    name: Mail-in-a-Box support\n    url: https://mailinabox.email/\n  license:\n    name: CC0 1.0 Universal\n    url: https://creativecommons.org/publicdomain/zero/1.0/legalcode\n  version: 0.51.0\n  x-logo:\n    url: https://mailinabox.email/static/logo.png\n    altText: Mail-in-a-Box logo\nexternalDocs:\n  description: Find out more about Mail-in-a-box.\n  url: https://mailinabox.email/\nservers:\n  - url: https://{host}/admin\n    variables:\n      host:\n        default: box.example.com\n        description: The API hostname.\nsecurity:\n  - basicAuth: []\ntags:\n  - name: User\n    description: Endpoints related to user authentication.\n  - name: Mail\n    description: |\n      Mail operations, which include getting all users, getting all aliases, adding/updating/removing users and aliases and getting all mail domains.\n  - name: DNS\n    description: |\n      DNS operations, which include adding custom records, adding a secondary nameserver and viewing all DNS records.\n  - name: SSL\n    description: |\n      TLS (SSL) Certificates operations, which include checking certificate status\n      and installing custom certificates.\n  - name: Web\n    description: |\n      Static web hosting operations, which include getting domain information and updating domain root directories.\n  - name: MFA\n    description: |\n      Manage multi-factor authentication schemes. Currently, only TOTP is supported.\n  - name: System\n    description: |\n      System operations, which include system status checks, new version checks\n      and reboot status.\npaths:\n  /login:\n    post:\n      tags:\n        - User\n      summary: Exchange a username and password for a session API key.\n      description: |\n        Returns user information and a session API key.\n\n        Authenticate a user by supplying the auth token as a base64 encoded string in\n        format `email:password` using basic authentication headers.\n\n        If successful, a long-lived `api_key` is returned which can be used for subsequent\n        requests to the API in place of the password.\n      operationId: login\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/login\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/MeResponse'\n              examples:\n                invalid:\n                  value:\n                    reason: Incorrect username or password\n                    status: invalid\n                ok:\n                  value:\n                    api_key: 1a2b3c4d5e6f7g8h9i0j\n                    email: user@example.com\n                    privileges:\n                      - admin\n                    status: ok\n  /logout:\n    post:\n      tags:\n        - User\n      summary: Invalidates a session API key.\n      description: |\n        Invalidates a session API key so that it cannot be used after this API call.\n      operationId: logout\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/logout\" \\\n              -u \"<email>:<session_key>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/LogoutResponse'\n  /system/status:\n    post:\n      tags:\n        - System\n      summary: Get system status\n      description: |\n        Returns an array of statuses which can include headings.\n      operationId: getSystemStatus\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/system/status\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/SystemStatusResponse'\n              example:\n                - type: heading\n                  text: System\n                  extra: []\n                - type: warning\n                  text: This domain's DNSSEC DS record is not set\n                  extra:\n                    - monospace: false\n                      text: 'Digest Type: 2 / SHA-25'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /system/version:\n    get:\n      tags:\n        - System\n      summary: Get system version\n      description: Returns installed Mail-in-a-Box version.\n      operationId: getSystemVersion\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/system/version\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/SystemVersionResponse'\n              example: v0.46\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /system/latest-upstream-version:\n    post:\n      tags:\n        - System\n      summary: Get system upstream version\n      description: Returns Mail-in-a-Box upstream version.\n      operationId: getSystemUpstreamVersion\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/system/latest-upstream-version\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/SystemVersionUpstreamResponse'\n              example: v0.47\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /system/updates:\n    get:\n      tags:\n        - System\n      summary: Get system updates\n      description: Returns system (apt) updates.\n      operationId: getSystemUpdates\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/system/updates\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/SystemUpdatesResponse'\n              example: |\n                libgnutls30 (3.5.18-1ubuntu1.4)\n                libxau6 (1:1.0.8-1ubuntu1)\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /system/update-packages:\n    post:\n      tags:\n        - System\n      summary: Update system packages\n      description: Updates system (apt) packages.\n      operationId: updateSystemPackages\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/system/update-packages\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/SystemUpdatePackagesResponse'\n              example: |\n                Calculating upgrade...\n                The following packages will be upgraded:\n                  cloud-init grub-common\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /system/privacy:\n    get:\n      tags:\n        - System\n      summary: Get system privacy status\n      description: |\n        Returns system privacy (new-version check) status.\n\n        Response:\n\n          - `true`: Private, new-version checks will not be performed\n          - `false`: Not private, new-version checks will be performed\n      operationId: getSystemPrivacyStatus\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/system/privacy\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/SystemPrivacyStatusResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n    post:\n      tags:\n        - System\n      summary: Update system privacy\n      description: |\n        Updates system privacy (new-version checks).\n\n        Request:\n\n          - `value: private`: Disable new version checks\n          - `value: off`: Enable new version checks\n      operationId: updateSystemPrivacy\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/SystemPrivacyUpdateRequest'\n            examples:\n              enable:\n                summary: Enable new version checks\n                value:\n                  value: 'off'\n              disable:\n                summary: Disable new version checks\n                value:\n                  value: private\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/system/privacy\" \\\n              -d \"value=<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/SystemPrivacyUpdateResponse'\n              example: OK\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /system/reboot:\n    get:\n      tags:\n        - System\n      summary: Get system reboot status\n      description: |\n        Returns the system reboot status.\n\n        Response:\n\n          - `true`: A reboot is required\n          - `false`: A reboot is not required\n      operationId: getSystemRebootStatus\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/system/reboot\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/SystemRebootStatusResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n    post:\n      tags:\n        - System\n      summary: Reboot system\n      description: Reboots the system.\n      operationId: rebootSystem\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/system/reboot\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/SystemRebootResponse'\n              example: No reboot is required, so it is not allowed.\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /system/backup/status:\n    get:\n      tags:\n        - System\n      summary: Get system backup status\n      description: |\n        Returns the system backup status.\n\n        If the list of backups is empty, this implies no backups have been made yet.\n      operationId: getSystemBackupStatus\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/system/backup/status\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/SystemBackupStatusResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /system/backup/config:\n    get:\n      tags:\n        - System\n      summary: Get system backup config\n      description: Returns the system backup config.\n      operationId: getSystemBackupConfig\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/system/backup/config\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/SystemBackupConfigResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n    post:\n      tags:\n        - System\n      summary: Update system backup config\n      description: Updates the system backup config.\n      operationId: updateSystemBackupConfig\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/SystemBackupConfigUpdateRequest'\n            examples:\n              s3:\n                summary: S3 backup\n                value:\n                  target: s3://s3.eu-central-1.amazonaws.com/box-example-com\n                  target_user: ACCESS_KEY\n                  target_pass: SECRET_ACCESS_KEY\n                  minAge: 3\n              local:\n                summary: Local backup\n                value:\n                  target: local\n                  target_user: ''\n                  target_pass: ''\n                  minAge: 3\n              rsync:\n                summary: Rsync backup\n                value:\n                  target: rsync://username@box.example.com//backups/box.example.com\n                  target_user: ''\n                  target_pass: ''\n                  minAge: 3\n              off:\n                summary: Disable backups\n                value:\n                  target: 'off'\n                  target_user: ''\n                  target_pass: ''\n                  minAge: 0\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/system/backup/config\" \\\n              -d \"target=<hostname>\" \\\n              -d \"target_user=<string>\" \\\n              -d \"target_pass=<password>\" \\\n              -d \"min_age=<integer>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/SystemBackupConfigUpdateResponse'\n              example: OK\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /ssl/status:\n    get:\n      tags:\n        - SSL\n      summary: Get SSL status\n      description: Returns the SSL status for all domains.\n      operationId: getSSLStatus\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/ssl/status\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/SSLStatusResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /ssl/csr/{domain}:\n    post:\n      tags:\n        - SSL\n      summary: Generate SSL CSR\n      description: |\n        Generates a Certificate Signing Request (CSR) for a domain & country code.\n      operationId: generateSSLCSR\n      parameters:\n        - in: path\n          name: domain\n          schema:\n            $ref: '#/components/schemas/Hostname'\n          required: true\n          description: Domain to generate CSR for.\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/SSLCSRGenerateRequest'\n            example:\n              countrycode: 'GB'\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/ssl/csr/<hostname>\" \\\n              -d \"countrycode=<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/SSLCSRGenerateResponse'\n              example: |\n                -----BEGIN CERTIFICATE REQUEST-----\n                MIICaDCCAVACAQAwIzELMAkGA1UEBhMCQlMxFDASBgNVBAMMC2V4YW1wbGUuY29t\n                ...\n                JmFDQESSfUxLPHLC660Wnf3GmrP/duZHpPC+qTe8b1AlQ7zDT3cOaAQ+Mb0=\n                -----END CERTIFICATE REQUEST-----\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /ssl/install:\n    post:\n      tags:\n        - SSL\n      summary: Install SSL certificate\n      description: |\n        Installs a custom certificate. The chain certificate is optional.\n      operationId: installSSLCertificate\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/SSLCertificateInstallRequest'\n            example:\n              domain: example.com\n              cert: CERT_STRING\n              chain: CHAIN_STRING\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/ssl/install\" \\\n              -d \"domain=<hostname>\" \\\n              -d \"cert=<string>\" \\\n              -d \"chain=<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/SSLCertificateInstallResponse'\n              example: OK\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /ssl/provision:\n    post:\n      tags:\n        - SSL\n      summary: Provision SSL certificates\n      description: |\n        Provisions certificates for all domains.\n      operationId: provisionSSLCertificates\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/ssl/provision\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/SSLCertificatesProvisionResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /dns/secondary-nameserver:\n    get:\n      tags:\n        - DNS\n      summary: Get DNS secondary nameserver\n      description: |\n        Returns a list of nameserver hostnames.\n      operationId: getDnsSecondaryNameserver\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/dns/secondary-nameserver\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/DNSSecondaryNameserverResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n    post:\n      tags:\n        - DNS\n      summary: Add DNS secondary nameserver\n      description: |\n        Adds one or more secondary nameservers.\n      operationId: addDnsSecondaryNameserver\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/DNSSecondaryNameserverAddRequest'\n            example:\n              hostnames: ns2.hostingcompany.com, ns3.hostingcompany.com\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/dns/secondary-nameserver\" \\\n              -d \"hostnames=<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/DNSSecondaryNameserverAddResponse'\n              example: 'updated DNS: example.com'\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: Could not resolve the IP address of badhostname\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /dns/zones:\n    get:\n      tags:\n        - DNS\n      summary: Get DNS zones\n      description: Returns an array of all managed top-level domains.\n      operationId: getDnsZones\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/dns/zones\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/DNSZonesResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /dns/zonefile/{zone}:\n    parameters:\n      - in: path\n        name: zone\n        schema:\n          $ref: '#/components/schemas/Hostname'\n        required: true\n        description: Hostname\n    get:\n      tags:\n        - DNS\n      summary: Get DNS zonefile\n      description: Returns a DNS zone file for a hostname.\n      operationId: getDnsZonefile\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/dns/zonefile/<zone>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/DNSZonefileResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /dns/update:\n    post:\n      tags:\n        - DNS\n      summary: Update DNS\n      description: Updates the DNS. Involves creating zone files and restarting `nsd`.\n      operationId: updateDns\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/DNSUpdateRequest'\n            example:\n              force: 1\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/dns/update\" \\\n              -d \"force=<integer>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/DNSUpdateResponse'\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /dns/custom:\n    get:\n      tags:\n        - DNS\n      summary: Get DNS custom records\n      description: Returns all custom DNS records.\n      operationId: getDnsCustomRecords\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/dns/custom\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/DNSCustomRecordsResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /dns/custom/{qname}/{rtype}:\n    parameters:\n      - in: path\n        name: qname\n        schema:\n          $ref: '#/components/schemas/Hostname'\n        required: true\n        description: DNS record query name\n      - in: path\n        name: rtype\n        schema:\n          $ref: '#/components/schemas/DNSRecordType'\n        required: true\n        description: Record type\n    get:\n      tags:\n        - DNS\n      summary: Get DNS custom records\n      description: Returns all custom records for the specified query name and type.\n      operationId: getDnsCustomRecordsForQNameAndType\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/dns/custom/<qname>/<rtype>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/DNSCustomRecordsResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n    post:\n      tags:\n        - DNS\n      summary: Add DNS custom record\n      description: Adds a custom DNS record for the specified query name and type.\n      operationId: addDnsCustomRecord\n      requestBody:\n        $ref: '#/components/requestBodies/DNSCustomRecordRequest'\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/dns/custom/<qname>/<rtype>\" \\\n              -H \"Content-Type: text/plain\" \\\n              --data-raw \"<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/DNSCustomRecordUpsertResponse'\n              example: 'updated DNS: example.com'\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: \"'badhostname' does not appear to be an IPv4 or IPv6 address\"\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n    put:\n      tags:\n        - DNS\n      summary: Update DNS custom record\n      description: Updates an existing DNS custom record value for the specified qname and type.\n      operationId: updateDnsCustomRecord\n      requestBody:\n        $ref: '#/components/requestBodies/DNSCustomRecordRequest'\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -x PUT \"https://{host}/admin/dns/custom/<qname>/<rtype>\" \\\n              -H \"Content-Type: text/plain\" \\\n              --data-raw \"<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/DNSCustomRecordUpsertResponse'\n              example: 'updated DNS: example.com'\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: \"'badhostname' does not appear to be an IPv4 or IPv6 address\"\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n    delete:\n      tags:\n        - DNS\n      summary: Remove DNS custom record\n      description: Removes a DNS custom record for the specified domain, type & value.\n      operationId: removeDnsCustomRecord\n      requestBody:\n        $ref: '#/components/requestBodies/DNSCustomRecordRequest'\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X DELETE \"https://{host}/admin/dns/custom/<qname>/<rtype>\" \\\n              -H \"Content-Type: text/plain\" \\\n              --data-raw \"<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/DNSCustomRecordRemoveResponse'\n              example: 'updated DNS: example.com'\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: badhostname is not a domain name or a subdomain of a domain name managed by this box\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /dns/custom/{qname}:\n    parameters:\n      - in: path\n        name: qname\n        schema:\n          $ref: '#/components/schemas/Hostname'\n        required: true\n        description: DNS query name.\n    get:\n      tags:\n        - DNS\n      summary: Get DNS custom A records\n      description: Returns all custom A records for the specified query name.\n      operationId: getDnsCustomARecordsForQName\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/dns/custom/<qname>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/DNSCustomRecordsResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n    post:\n      tags:\n        - DNS\n      summary: Add DNS custom A record\n      description: Adds a custom DNS A record for the specified query name.\n      operationId: addDnsCustomARecord\n      requestBody:\n        $ref: '#/components/requestBodies/DNSCustomRecordRequest'\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/dns/custom/<qname>\" \\\n              -H \"Content-Type: text/plain\" \\\n              --data-raw \"<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/DNSCustomRecordUpsertResponse'\n              example: 'updated DNS: example.com'\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: \"'badhostname' does not appear to be an IPv4 or IPv6 address\"\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n    put:\n      tags:\n        - DNS\n      summary: Update DNS custom A record\n      description: Updates an existing DNS custom A record value for the specified qname.\n      operationId: updateDnsCustomARecord\n      requestBody:\n        $ref: '#/components/requestBodies/DNSCustomRecordRequest'\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -x PUT \"https://{host}/admin/dns/custom/<qname>\" \\\n              -H \"Content-Type: text/plain\" \\\n              --data-raw \"<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/DNSCustomRecordUpsertResponse'\n              example: 'updated DNS: example.com'\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: \"'badhostname' does not appear to be an IPv4 or IPv6 address\"\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n    delete:\n      tags:\n        - DNS\n      summary: Remove DNS custom A record\n      description: Removes a DNS custom A record for the specified domain & value.\n      operationId: removeDnsCustomARecord\n      requestBody:\n        $ref: '#/components/requestBodies/DNSCustomRecordRequest'\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X DELETE \"https://{host}/admin/dns/custom/<qname>\" \\\n              -H \"Content-Type: text/plain\" \\\n              --data-raw \"<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/DNSCustomRecordRemoveResponse'\n              example: 'updated DNS: example.com'\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: badhostname is not a domain name or a subdomain of a domain name managed by this box\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /dns/dump:\n    get:\n      tags:\n        - DNS\n      summary: Get DNS dump\n      description: Returns all DNS records.\n      operationId: getDnsDump\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/dns/dump\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/DNSDumpResponse'\n              example:\n                - - example1.com\n                  - - explanation: Required. Specifies the hostname (and priority) of the machine that handles @example.com mail.\n                      qname: example1.com\n                      rtype: MX\n                      value: 10 box.example1.com.\n                - - example2.com\n                  - - explanation: Required. Specifies the hostname (and priority) of the machine that handles @example.com mail.\n                      qname: example2.com\n                      rtype: MX\n                      value: 10 box.example2.com.\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mail/users:\n    get:\n      tags:\n        - Mail\n      summary: Get mail users\n      description: Returns all mail users.\n      operationId: getMailUsers\n      parameters:\n        - in: query\n          name: format\n          schema:\n            $ref: '#/components/schemas/MailUsersResponseFormat'\n          description: The format of the response.\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/mail/users?format=<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/MailUsersResponse'\n            text/html:\n              schema:\n                $ref: '#/components/schemas/MailUsersSimpleResponse'\n              example: |\n                user1@example.com\n                user2@example.com\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mail/users/add:\n    post:\n      tags:\n        - Mail\n      summary: Add mail user\n      description: Adds a new mail user.\n      operationId: addMailUser\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/MailUserAddRequest'\n            examples:\n              normal:\n                summary: Normal user\n                value:\n                  email: user@example.com\n                  password: s3curE_pa5Sw0rD\n                  privileges: ''\n              admin:\n                summary: Admin user\n                value:\n                  email: user@example.com\n                  password: s3curE_pa5Sw0rD\n                  privileges: admin\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/mail/users/add\" \\\n              -d \"email=<email>\" \\\n              -d \"password=<password>\" \\\n              -d \"privileges=<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/MailUserAddResponse'\n              example: |\n                mail user added\n                updated DNS: OpenDKIM configuration\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: Invalid email address\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mail/users/remove:\n    post:\n      tags:\n        - Mail\n      summary: Remove mail user\n      description: Removes an existing mail user.\n      operationId: removeMailUser\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/MailUserRemoveRequest'\n            example:\n              email: user@example.com\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/mail/users/remove\" \\\n              -d \"email=<email>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/MailUserRemoveResponse'\n              example: OK\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: That's not a user (invalid@example.com)\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mail/users/privileges/add:\n    post:\n      tags:\n        - Mail\n      summary: Add mail user privilege\n      description: Adds a privilege to an existing mail user.\n      operationId: addMailUserPrivilege\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/MailUserAddPrivilegeRequest'\n            example:\n              email: user@example.com\n              privilege: admin\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/mail/users/privileges/add\" \\\n              -d \"email=<email>\" \\\n              -d \"privilege=<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/MailUserAddPrivilegeResponse'\n              example: OK\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: That's not a user (invalid@example.com)\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mail/users/privileges/remove:\n    post:\n      tags:\n        - Mail\n      summary: Remove mail user privilege\n      description: Removes a privilege from an existing mail user.\n      operationId: removeMailUserPrivilege\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/MailUserRemovePrivilegeRequest'\n            example:\n              email: user@example.com\n              privilege: admin\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/mail/users/privileges/remove\" \\\n              -d \"email=<email>\" \\\n              -d \"privilege=<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/MailUserRemovePrivilegeResponse'\n              example: OK\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: That's not a user (invalid@example.com)\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mail/users/password:\n    post:\n      tags:\n        - Mail\n      summary: Set mail user password\n      description: Sets a password for an existing mail user.\n      operationId: setMailUserPassword\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/MailUserSetPasswordRequest'\n            example:\n              email: user@example.com\n              password: s3curE_pa5Sw0rD\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/mail/users/password\" \\\n              -d \"email=<email>\" \\\n              -d \"password=<password>\" \\\n              -u \"<email>:<password>\" \\\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/MailUserSetPasswordResponse'\n              example: OK\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: Passwords must be at least eight characters\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mail/users/privileges:\n    get:\n      tags:\n        - Mail\n      summary: Get mail user privileges\n      description: Returns all privileges for an existing mail user.\n      operationId: getMailUserPrivileges\n      parameters:\n        - in: query\n          name: email\n          schema:\n            $ref: '#/components/schemas/Email'\n          description: The email you want to get privileges for.\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/mail/users/privileges?email=<email>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/MailUserPrivilegesResponse'\n              example: admin\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mail/domains:\n    get:\n      tags:\n        - Mail\n      summary: Get mail domains\n      description: Returns all mail domains.\n      operationId: getMailDomains\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/mail/domains\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/MailDomainsResponse'\n              example: |\n                example1.com\n                example2.com\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mail/aliases:\n    get:\n      tags:\n        - Mail\n      summary: Get mail aliases\n      description: Returns all mail aliases.\n      operationId: getMailAliases\n      parameters:\n        - in: query\n          name: format\n          schema:\n            $ref: '#/components/schemas/MailAliasesResponseFormat'\n          description: The format of the response.\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/mail/aliases?format=<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                type: array\n                items:\n                  $ref: '#/components/schemas/MailAliasByDomain'\n            text/html:\n              schema:\n                $ref: '#/components/schemas/MailAliasesSimpleResponse'\n                example: |\n                  abuse@example.com\tadministrator@example.com\n                  admin@example.com\tadministrator@example.com\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mail/aliases/add:\n    post:\n      tags:\n        - Mail\n      summary: Upsert mail alias\n      description: |\n        Adds or updates a mail alias. If updating, you need to set `update_if_exists: 1`.\n      operationId: upsertMailAlias\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/MailAliasUpsertRequest'\n            examples:\n              regular:\n                summary: Regular alias\n                value:\n                  update_if_exists: 0\n                  address: user@example.com\n                  forwards_to: user2@example.com\n                  permitted_senders:\n              catchall:\n                summary: Catch-all\n                value:\n                  update_if_exists: 0\n                  address: '@example.com'\n                  forwards_to: user@otherexample.com\n                  permitted_senders:\n              domainalias:\n                summary: Domain alias\n                value:\n                  update_if_exists: 0\n                  address: '@example.com'\n                  forwards_to: '@otherexample.com'\n                  permitted_senders:\n              update:\n                summary: Update existing alias\n                value:\n                  update_if_exists: 1\n                  address: user@example.com\n                  forwards_to: user2@example.com\n                  permitted_senders: user3@example.com, user4@example.com\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/mail/aliases/add\" \\\n              -d \"update_if_exists=<integer>\" \\\n              -d \"address=<email>\" \\\n              -d \"forwards_to=<string>\" \\\n              -d \"permitted_senders=<string>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/MailAliasUpsertResponse'\n              example: alias updated\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: Invalid email address (invalid@example.com)\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mail/aliases/remove:\n    post:\n      tags:\n        - Mail\n      summary: Remove mail alias\n      description: Removes a mail alias.\n      operationId: removeMailAlias\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/MailAliasRemoveRequest'\n            example:\n              address: user@example.com\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/mail/aliases/remove\" \\\n              -d \"address=<email>\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/MailAliasRemoveResponse'\n              example: alias removed\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n                example: That's not an alias (invalid@example)\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /web/domains:\n    get:\n      tags:\n        - Web\n      summary: Get web domains\n      description: Returns all static web domains.\n      operationId: getWebDomains\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X GET \"https://{host}/admin/web/domains\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                type: array\n                items:\n                  $ref: '#/components/schemas/WebDomain'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /web/update:\n    post:\n      tags:\n        - Web\n      summary: Update web\n      description: Updates static websites, used for updating domain root directories.\n      operationId: updateWeb\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/web/update\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/WebUpdateResponse'\n              example: web updated\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mfa/status:\n    post:\n      tags:\n        - MFA\n      summary: Retrieve MFA status for you or another user\n      description: Retrieves which type of MFA is used and configuration\n      operationId: mfaStatus\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/mfa/status\" \\\n              -u \"<email>:<password>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/MfaStatusResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mfa/totp/enable:\n    post:\n      tags:\n        - MFA\n      summary: Enable TOTP authentication\n      description: Enables TOTP authentication for the currently logged-in admin user\n      operationId: mfaTotpEnable\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/mfa/totp/enable\" \\\n              -d \"code=123456\" \\\n              -d \"secret=<string>\" \\\n              -u \"<email>:<password>\"\n      requestBody:\n        required: true\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/MfaEnableRequest'\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/MfaEnableSuccessResponse'\n        400:\n          description: Bad request\n          content:\n            text/html:\n              schema:\n                type: string\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\n  /mfa/disable:\n    post:\n      tags:\n        - MFA\n      summary: Disable multi-factor authentication for you or another user\n      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`.\n      operationId: mfaTotpDisable\n      requestBody:\n        required: false\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: '#/components/schemas/MfaDisableRequest'\n      x-codeSamples:\n        - lang: curl\n          source: |\n            curl -X POST \"https://{host}/admin/mfa/totp/disable\" \\\n              -u \"<email>:<user_key>\"\n      responses:\n        200:\n          description: Successful operation\n          content:\n            text/html:\n              schema:\n                $ref: '#/components/schemas/MfaDisableSuccessResponse'\n        403:\n          description: Forbidden\n          content:\n            text/html:\n              schema:\n                type: string\ncomponents:\n  securitySchemes:\n    basicAuth:\n      type: http\n      scheme: basic\n      description: |\n        Credentials can be supplied using the `Authorization` header in\n        format `Authorization: Basic {access-token}`.\n\n        The `access-token` is comprised of the Base64 encoding of `username:password`.\n        The `username` is the mail user's email address, and `password` can either be the mail user's\n        password, or the `api_key` returned from the `login` operation.\n\n        When using `curl`, you can supply user credentials using the `-u` or `--user` parameter.\n  requestBodies:\n    DNSCustomRecordRequest:\n      required: true\n      content:\n        text/plain:\n          schema:\n            type: string\n            example: '1.2.3.4'\n            description: The value of the DNS record.\n          example: '1.2.3.4'\n  schemas:\n    MailUsersResponseFormat:\n      type: string\n      enum:\n        - text\n        - json\n      example: json\n      description: Response format (`application/json` or `text/html`).\n    MailAliasesResponseFormat:\n      type: string\n      enum:\n        - text\n        - json\n      example: json\n      description: Response format (`application/json` or `text/html`).\n    MailUserSetPasswordResponse:\n      type: string\n      example: OK\n      description: Mail user set password response.\n    MailUserRemoveResponse:\n      type: string\n      example: OK\n      description: Mail user remove response.\n    MailUserAddResponse:\n      type: string\n      example: |\n        mail user added\n        updated DNS: OpenDKIM configuration\n      description: |\n        Mail user add response.\n\n        Can include information about operations related to adding new users, like updating DNS.\n    MailUserAddPrivilegeResponse:\n      type: string\n      example: OK\n      description: Mail user add admin privilege response.\n    MailUserRemovePrivilegeResponse:\n      type: string\n      example: OK\n      description: Mail user remove admin privilege response.\n    MailUsersSimpleResponse:\n      type: string\n      example: |\n        user1@example.com\n        user2@example.com\n      description: Get mail users text format response.\n    MailUserPrivilegesResponse:\n      $ref: '#/components/schemas/MailUserPrivilege'\n      description: Mail user privileges response.\n      example: admin\n    MailDomainsResponse:\n      type: string\n      example: |\n        example1.com\n        example2.com\n      description: Mail domains response.\n    MailUsersResponse:\n      type: array\n      items:\n        $ref: '#/components/schemas/MailUserByDomain'\n      description: Get mail aliases JSON format response.\n    MailUserByDomain:\n      type: object\n      required:\n        - domain\n        - users\n      properties:\n        domain:\n          $ref: '#/components/schemas/Hostname'\n        users:\n          type: array\n          items:\n            $ref: '#/components/schemas/MailUser'\n      description: Mail users by domain.\n    MailUser:\n      type: object\n      required:\n        - email\n        - privileges\n        - status\n      properties:\n        email:\n          $ref: '#/components/schemas/Email'\n        privileges:\n          type: array\n          items:\n            $ref: '#/components/schemas/MailUserPrivilege'\n        status:\n          $ref: '#/components/schemas/MailUserStatus'\n        mailbox:\n          type: string\n          example: /home/user-data/mail/mailboxes/example.com/user\n      description: Mail user details.\n    MailAliasesSimpleResponse:\n      type: string\n      example: |\n        abuse@example.com\tadministrator@example.com\n        admin@example.com\tadministrator@example.com\n      description: Get mail aliases text format response.\n    MailAliasByDomain:\n      type: object\n      required:\n        - domain\n        - aliases\n      properties:\n        domain:\n          $ref: '#/components/schemas/Hostname'\n        aliases:\n          type: array\n          items:\n            $ref: '#/components/schemas/MailAlias'\n      description: Mail aliases by domain.\n    MailAlias:\n      type: object\n      required:\n        - address\n        - address_display\n        - forwards_to\n        - permitted_senders\n        - required\n      properties:\n        address:\n          $ref: '#/components/schemas/Email'\n        address_display:\n          $ref: '#/components/schemas/Email'\n        forwards_to:\n          type: array\n          items:\n            $ref: '#/components/schemas/Email'\n        permitted_senders:\n          type: array\n          nullable: true\n          items:\n            $ref: '#/components/schemas/Email'\n        required:\n          type: boolean\n          example: true\n      description: Mail alias details.\n    MailAliasUpsertResponse:\n      type: string\n      example: alias updated\n      description: Mail alias add/update response.\n    MailAliasUpsertRequest:\n      type: object\n      required:\n        - update_if_exists\n        - address\n        - forwards_to\n        - permitted_senders\n      properties:\n        update_if_exists:\n          type: integer\n          format: int32\n          minimum: 0\n          maximum: 1\n          example: 1\n          description: Set to `1` when updating an alias.\n        address:\n          $ref: '#/components/schemas/Email'\n        forwards_to:\n          type: string\n          example: user1@example.com, user2@example.com\n          description: |\n            If adding a regular or catch-all alias, the format needs to be `user@example.com`.\n            Multiple address can be separated by newlines or commas.\n\n            If adding a domain alias, the format needs to be `@example.com`.\n        permitted_senders:\n          type: string\n          nullable: true\n          example: user1@example.com, user2@example.com\n          description: |\n            Mail users that can send mail claiming to be from any address on the alias domain.\n            Multiple address can be separated by newlines or commas.\n\n            Leave empty to allow any mail user listed in `forwards_to` to send mail claiming to be from any address on the alias domain.\n      description: Mail alias upsert request.\n    MailAliasRemoveResponse:\n      type: string\n      example: alias removed\n      description: Mail alias remove response.\n    MailAliasRemoveRequest:\n      type: object\n      required:\n        - address\n      properties:\n        address:\n          $ref: '#/components/schemas/Email'\n      description: Mail aliases remove request.\n    DNSRecordType:\n      enum:\n        - A\n        - AAAA\n        - CAA\n        - CNAME\n        - TXT\n        - MX\n        - SRV\n        - SSHFP\n        - NS\n      example: MX\n      description: DNS record type.\n    DNSDumpResponse:\n      type: array\n      items:\n        $ref: '#/components/schemas/DNSDumpDomains'\n      description: DNS dump response.\n    DNSDumpDomains:\n      type: array\n      items:\n        oneOf:\n          - $ref: '#/components/schemas/Hostname'\n          - $ref: '#/components/schemas/DNSDumpDomainRecords'\n      description: |\n        A list of records per domain.\n\n        The first item in the list is the domain and the second item is the list of records.\n    DNSDumpDomainRecords:\n      type: array\n      items:\n        $ref: '#/components/schemas/DNSDumpDomainRecord'\n      description: List of domain records.\n    DNSDumpDomainRecord:\n      type: object\n      required:\n        - explanation\n        - qname\n        - type\n        - value\n      properties:\n        explanation:\n          type: string\n          example: Required. Specifies the hostname (and priority) of the machine that handles @example.com mail\n        qname:\n          $ref: '#/components/schemas/Hostname'\n        rtype:\n          $ref: '#/components/schemas/DNSRecordType'\n        value:\n          type: string\n          example: 10 example.com.\n      description: Domain DNS record details.\n    DNSCustomRecord:\n      type: object\n      required:\n        - qname\n        - rtype\n        - value\n      properties:\n        qname:\n          $ref: '#/components/schemas/Hostname'\n        rtype:\n          $ref: '#/components/schemas/DNSRecordType'\n        value:\n          type: string\n          example: 10 example.com.\n      description: Custom DNS record detail detail.\n    DNSCustomRecordsResponse:\n      type: array\n      items:\n        $ref: '#/components/schemas/DNSCustomRecord'\n      description: Custom DNS records response.\n    DNSZonesResponse:\n      type: array\n      items:\n        $ref: '#/components/schemas/Hostname'\n      description: DNS zones response.\n    DNSZonefileResponse:\n      type: string\n    DNSSecondaryNameserverResponse:\n      type: object\n      required:\n        - hostnames\n      properties:\n        hostnames:\n          type: array\n          items:\n            type: string\n            example: ns1.example.com\n      description: Secondary nameserver/s response.\n    DNSCustomRecordRemoveResponse:\n      type: string\n      example: 'updated DNS: example.com'\n      description: Custom DNS record remove response.\n    DNSCustomRecordUpsertResponse:\n      type: string\n      example: 'updated DNS: example.com'\n      description: Custom DNS record add response.\n    DNSUpdateRequest:\n      type: object\n      required:\n        - force\n      properties:\n        force:\n          type: integer\n          format: int32\n          minimum: 0\n          maximum: 1\n          example: 1\n          description: Force an update even if mailinabox detects no changes are required.\n      description: DNS update request.\n    DNSUpdateResponse:\n      type: string\n      example: |\n        updated DNS: example1.com,example2.com\n      description: DNS update response.\n    DNSSecondaryNameserverAddRequest:\n      type: object\n      required:\n        - hostnames\n      properties:\n        hostnames:\n          type: string\n          description: Hostnames separated with commas or spaces.\n          example: ns2.hostingcompany.com, ns3.hostingcompany.com\n      description: Secondary nameserver/s add request.\n    DNSSecondaryNameserverAddResponse:\n      type: string\n      example: 'updated DNS: example.com'\n      description: Secondary nameserver/s add response.\n    SystemPrivacyUpdateRequest:\n      type: object\n      required:\n        - value\n      properties:\n        value:\n          $ref: '#/components/schemas/SystemPrivacyStatus'\n      description: Update system privacy request.\n    SystemPrivacyStatus:\n      type: string\n      enum:\n        - private\n        - 'off'\n      example: private\n      description: System privacy status.\n    MailUserSetPasswordRequest:\n      type: object\n      required:\n        - email\n        - password\n      properties:\n        email:\n          $ref: '#/components/schemas/Email'\n        password:\n          type: string\n          format: password\n      description: Mail user set password request.\n    MailUserAddRequest:\n      type: object\n      required:\n        - email\n        - password\n        - privileges\n      properties:\n        email:\n          $ref: '#/components/schemas/Email'\n        password:\n          type: string\n          format: password\n        privileges:\n          $ref: '#/components/schemas/MailUserPrivilege'\n      description: Mail user add request.\n    MailUserRemoveRequest:\n      type: object\n      required:\n        - email\n      properties:\n        email:\n          $ref: '#/components/schemas/Email'\n      description: Mail user remove request.\n    MailUserStatus:\n      type: string\n      enum:\n        - active\n        - inactive\n      example: active\n      description: Mail user status.\n    MailUserPrivilege:\n      type: string\n      enum:\n        - admin\n        - ''\n      example: admin\n      description: Mail user privilege.\n    MailUserAddPrivilegeRequest:\n      type: object\n      required:\n        - email\n        - privilege\n      properties:\n        email:\n          $ref: '#/components/schemas/Email'\n        privilege:\n          $ref: '#/components/schemas/MailUserPrivilege'\n      description: Mail user add privilege request.\n    MailUserRemovePrivilegeRequest:\n      type: object\n      required:\n        - email\n        - privilege\n      properties:\n        email:\n          $ref: '#/components/schemas/Email'\n        privilege:\n          $ref: '#/components/schemas/MailUserPrivilege'\n      description: Mail user remove privilege request.\n    SSLCSRGenerateRequest:\n      type: object\n      required:\n        - countrycode\n      properties:\n        countrycode:\n          type: string\n          example: GB\n      description: Generate SSL CSR request.\n    SSLCSRGenerateResponse:\n      type: string\n      example: |\n        -----BEGIN CERTIFICATE REQUEST-----\n        MIICaDCCAVACAQAwIzELMAkGA1UEBhMCQlMxFDASBgNVBAMMC2V4YW1wbGUuY29t\n        MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3K6dwLM2Nk8kVhIBaZmp\n        eY6y7O0T3jrexEKlW839TVYdcH+K35V1NxilbMFKMuHeowGwFyyiqOy/OUYNeq+T\n        Rz3s4b1qG2p01dwlsXHHYmXLYTAhvqvY+CU5ksieuZbyHRTwbHViQ0xtRXwoVCnj\n        CkN7kJVpkLfVN0/BG6NBFpv/JI8F+hwp+IHdkC1gUXRrLJNC79ERqFP8HoqdQWNw\n        OGGFaOe2aQhvj2zt8wFncyKVc40UKVbSzGGzdL2MPiAJHgZ2lmeY1xDyX1lOt12R\n        IFPwtxmbxaxYaVfe2hxl7m88xV3OjYcKgwVYDusk2XJ37cGew5g+NbBvzEeEUpF9\n        5wIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAD7UPC3/Nkgpn53mT9puUonYdJg9\n        SD8vvTK/N78CzoEgPNyq+bYbqlcvVPKIdItf9TMiqfOSvW3e3NvkRisYle8Qp+0C\n        8pafXBvQ9eHt5CFeJn4sH9GnxeflOZT/P9Jnp71KtZQvOobirX4GgEWs79g+/NHb\n        Zyf8rbadt9HruNhKA5nlP8cn7Rdc/iuJU8MVSQszI1s1DEcXMPxr6iqb2g87/ifH\n        lWcK59kvRJkCcPhPzjpUy9NulucH4WFA/WqKeDNFS/oC+upV5w8EDEcfnenJFG+N\n        JmFDQESSfUxLPHLC660Wnf3GmrP/duZHpPC+qTe8b1AlQ7zDT3cOaAQ+Mb0=\n        -----END CERTIFICATE REQUEST-----\n      description: Generate SSL CSR response.\n    SSLCertificateInstallRequest:\n      type: object\n      required:\n        - domain\n        - cert\n        - chain\n      properties:\n        domain:\n          $ref: '#/components/schemas/Hostname'\n        cert:\n          type: string\n          description: TLS/SSL certificate.\n          example: |\n            -----BEGIN CERTIFICATE-----\n            MIICaDCCAVACAQAwIzELMAkGA1UEBhMCQlMxFDASBgNVBAMMC2V4YW1wbGUuY29t\n            MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3K6dwLM2Nk8kVhIBaZmp\n            eY6y7O0T3jrexEKlW839TVYdcH+K35V1NxilbMFKMuHeowGwFyyiqOy/OUYNeq+T\n            Rz3s4b1qG2p01dwlsXHHYmXLYTAhvqvY+CU5ksieuZbyHRTwbHViQ0xtRXwoVCnj\n            CkN7kJVpkLfVN0/BG6NBFpv/JI8F+hwp+IHdkC1gUXRrLJNC79ERqFP8HoqdQWNw\n            OGGFaOe2aQhvj2zt8wFncyKVc40UKVbSzGGzdL2MPiAJHgZ2lmeY1xDyX1lOt12R\n            IFPwtxmbxaxYaVfe2hxl7m88xV3OjYcKgwVYDusk2XJ37cGew5g+NbBvzEeEUpF9\n            5wIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAD7UPC3/Nkgpn53mT9puUonYdJg9\n            SD8vvTK/N78CzoEgPNyq+bYbqlcvVPKIdItf9TMiqfOSvW3e3NvkRisYle8Qp+0C\n            8pafXBvQ9eHt5CFeJn4sH9GnxeflOZT/P9Jnp71KtZQvOobirX4GgEWs79g+/NHb\n            Zyf8rbadt9HruNhKA5nlP8cn7Rdc/iuJU8MVSQszI1s1DEcXMPxr6iqb2g87/ifH\n            lWcK59kvRJkCcPhPzjpUy9NulucH4WFA/WqKeDNFS/oC+upV5w8EDEcfnenJFG+N\n            JmFDQESSfUxLPHLC660Wnf3GmrP/duZHpPC+qTe8b1AlQ7zDT3cOaAQ+Mb0=\n            -----END CERTIFICATE-----\n        chain:\n          type: string\n          description: TLS/SSL intermediate chain (if provided, else empty string).\n          example: |\n            -----BEGIN CERTIFICATE-----\n            MIICaDCCAVACAQAwIzELMAkGA1UEBhMCQlMxFDASBgNVBAMMC2V4YW1wbGUuY29t\n            MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3K6dwLM2Nk8kVhIBaZmp\n            eY6y7O0T3jrexEKlW839TVYdcH+K35V1NxilbMFKMuHeowGwFyyiqOy/OUYNeq+T\n            Rz3s4b1qG2p01dwlsXHHYmXLYTAhvqvY+CU5ksieuZbyHRTwbHViQ0xtRXwoVCnj\n            CkN7kJVpkLfVN0/BG6NBFpv/JI8F+hwp+IHdkC1gUXRrLJNC79ERqFP8HoqdQWNw\n            OGGFaOe2aQhvj2zt8wFncyKVc40UKVbSzGGzdL2MPiAJHgZ2lmeY1xDyX1lOt12R\n            IFPwtxmbxaxYaVfe2hxl7m88xV3OjYcKgwVYDusk2XJ37cGew5g+NbBvzEeEUpF9\n            5wIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAD7UPC3/Nkgpn53mT9puUonYdJg9\n            SD8vvTK/N78CzoEgPNyq+bYbqlcvVPKIdItf9TMiqfOSvW3e3NvkRisYle8Qp+0C\n            8pafXBvQ9eHt5CFeJn4sH9GnxeflOZT/P9Jnp71KtZQvOobirX4GgEWs79g+/NHb\n            Zyf8rbadt9HruNhKA5nlP8cn7Rdc/iuJU8MVSQszI1s1DEcXMPxr6iqb2g87/ifH\n            lWcK59kvRJkCcPhPzjpUy9NulucH4WFA/WqKeDNFS/oC+upV5w8EDEcfnenJFG+N\n            JmFDQESSfUxLPHLC660Wnf3GmrP/duZHpPC+qTe8b1AlQ7zDT3cOaAQ+Mb0=\n            -----END CERTIFICATE-----\n      description: Install certificate request. `chain` can be an empty string.\n    SSLCertificateInstallResponse:\n      type: string\n      example: OK\n      description: Install certificate response.\n    SSLCertificatesProvisionResponse:\n      type: object\n      required:\n        - requests\n      properties:\n        requests:\n          type: array\n          items:\n            type: object\n            required:\n              - log\n              - result\n              - domains\n            properties:\n              log:\n                type: array\n                items:\n                  type: string\n                example:\n                  - 'The domain name does not resolve to this machine: [Not Set] (A), [Not Set] (AAAA).'\n              result:\n                type: string\n                enum:\n                  - installed\n                  - error\n                  - skipped\n                example: installed\n              domains:\n                type: array\n                items:\n                  $ref: '#/components/schemas/Hostname'\n      description: SSL certificates provision response.\n    SystemPrivacyStatusResponse:\n      type: boolean\n      description: |\n        System privacy status response.\n\n          - `true`: Private, new-version checks will not be performed\n          - `false`: Not private, new-version checks will be performed\n      example: false\n    SystemVersionResponse:\n      type: string\n      description: System version response.\n      example: v0.46\n    SystemVersionUpstreamResponse:\n      type: string\n      description: System version upstream response.\n      example: v0.47\n    SystemUpdatesResponse:\n      type: string\n      description: System updates response.\n      example: |\n        libgnutls30 (3.5.18-1ubuntu1.4)\n        libxau6 (1:1.0.8-1ubuntu1)\n    SystemUpdatePackagesResponse:\n      type: string\n      example: |\n        Reading package lists...\n        Building dependency tree...\n        Reading state information...\n        Calculating upgrade...\n        The following packages will be upgraded:\n          cloud-init grub-common grub-pc grub-pc-bin grub2-common libgnutls30\n          libldap-2.4-2 libldap-common libxau6 linux-firmware python3-distupgrade\n          qemu-guest-agent sosreport ubuntu-release-upgrader-core\n        14 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n        Need to get 79.9 MB of archives.\n        After this operation, 3893 kB of additional disk space will be used.\n        Get:1 http://archive.ubuntu.com/ubuntu bionic-updates/main amd64 libgnutls30 amd64 3.5.18-1ubuntu1.4 [645 kB]\n        Preconfiguring packages ...\n        Fetched 79.9 MB in 2s (52.4 MB/s)\n        (Reading database ... 48457 files and directories currently installed.)\n      description: System update packages response.\n    SystemPrivacyUpdateResponse:\n      type: string\n      example: OK\n      description: System privacy update response.\n    SystemRebootStatusResponse:\n      type: boolean\n      description: |\n        System reboot status response.\n\n          - `true`: A reboot is required\n          - `false`: A reboot is not required\n      example: true\n    SystemRebootResponse:\n      type: string\n      example: No reboot is required, so it is not allowed.\n      description: System reboot response.\n    SystemStatusResponse:\n      type: array\n      items:\n        $ref: '#/components/schemas/StatusEntry'\n      description: System status response.\n    StatusEntry:\n      type: object\n      required:\n        - type\n        - text\n        - extra\n      properties:\n        type:\n          $ref: '#/components/schemas/StatusEntryType'\n        text:\n          type: string\n          example: This domain\"s DNSSEC DS record is not set\n        extra:\n          type: array\n          items:\n            $ref: '#/components/schemas/StatusEntryExtra'\n      description: System status entry.\n    StatusEntryType:\n      type: string\n      enum:\n        - heading\n        - ok\n        - warning\n        - error\n      example: warning\n      description: System status entry type.\n    StatusEntryExtra:\n      type: object\n      required:\n        - monospace\n        - text\n      properties:\n        monospace:\n          type: boolean\n          example: false\n        text:\n          type: string\n          example: 'Digest Type: 2 / SHA-256'\n      description: System entry extra information.\n    SystemBackupConfigUpdateRequest:\n      type: object\n      required:\n        - target\n        - target_user\n        - target_pass\n        - min_age\n      properties:\n        target:\n          type: string\n          format: hostname\n          example: s3://s3.eu-central-1.amazonaws.com/box-example-com\n        target_user:\n          type: string\n          example: username\n        target_pass:\n          type: string\n          example: password\n          format: password\n        min_age:\n          type: integer\n          format: int32\n          minimum: 1\n          example: 3\n      description: Backup config update request.\n    SystemBackupConfigUpdateResponse:\n      type: string\n      example: OK\n      description: Backup config update response.\n    SystemBackupConfigResponse:\n      type: object\n      required:\n        - enc_pw_file\n        - file_target_directory\n        - min_age_in_days\n        - ssh_pub_key\n        - target\n      properties:\n        enc_pw_file:\n          type: string\n          example: /home/user-data/backup/secret_key.txt\n        file_target_directory:\n          type: string\n          example: /home/user-data/backup/encrypted\n        min_age_in_days:\n          type: integer\n          format: int32\n          minimum: 1\n          example: 3\n        ssh_pub_key:\n          type: string\n          example: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDb root@box.example.com\\n\n        target:\n          type: string\n          format: hostname\n          example: s3://s3.eu-central-1.amazonaws.com/box-example-com\n        target_user:\n          type: string\n        target_pass:\n          type: string\n      description: Backup config response.\n    SystemBackupStatusResponse:\n      type: object\n      required:\n        - unmatched_file_size\n      properties:\n        backups:\n          type: array\n          items:\n            $ref: '#/components/schemas/SystemBackupStatus'\n        unmatched_file_size:\n          type: integer\n          format: int32\n          example: 0\n        error:\n          type: string\n          example: Something is wrong with the backup\n      description: Backup status response. Lists the status for all backups.\n    SystemBackupStatus:\n      type: object\n      required:\n        - date\n        - date_delta\n        - date_str\n        - full\n        - size\n        - volumes\n      properties:\n        date:\n          type: string\n          format: date-time\n          example: 20200801T023706Z\n        date_delta:\n          type: string\n          example: 15 hours, 40 minutes\n        date_str:\n          type: string\n          example: 2020-08-01 03:37:06 BST\n        deleted_in:\n          type: string\n          example: approx. 6 days\n        full:\n          type: boolean\n          example: false\n        size:\n          type: integer\n          format: int32\n          example: 125332\n        volumes:\n          type: integer\n          format: int32\n          example: 1\n      description: Backup status details.\n    SSLStatusResponse:\n      type: object\n      required:\n        - can_provision\n        - status\n      properties:\n        can_provision:\n          type: array\n          items:\n            type: string\n        status:\n          type: array\n          items:\n            $ref: '#/components/schemas/SSLStatus'\n      description: SSL status response for all relevant domains.\n    SSLStatus:\n      type: object\n      required:\n        - domain\n        - status\n        - text\n      properties:\n        domain:\n          $ref: '#/components/schemas/Hostname'\n        status:\n          $ref: '#/components/schemas/SSLStatusType'\n        text:\n          type: string\n          example: Signed & valid. The certificate expires in 87 days on 10/28/20.\n      description: SSL status details for domain.\n    SSLStatusType:\n      type: string\n      enum:\n        - success\n        - danger\n        - not-applicable\n      example: success\n      description: SSL status type.\n    Email:\n      type: string\n      format: email\n      example: user@example.com\n      description: Email format.\n    Hostname:\n      type: string\n      format: hostname\n      example: example.com\n      description: Hostname format.\n    MeResponse:\n      type: object\n      required:\n        - status\n      properties:\n        api_key:\n          type: string\n          example: 12345abcde\n        email:\n          $ref: '#/components/schemas/Email'\n        privileges:\n          type: array\n          items:\n            $ref: '#/components/schemas/MailUserPrivilege'\n        reason:\n          type: string\n          example: Incorrect username or password\n        status:\n          $ref: '#/components/schemas/MeAuthStatus'\n      description: Me (user) response.\n    MeAuthStatus:\n      type: string\n      enum:\n        - ok\n        - invalid\n      example: invalid\n      description: Me (user) authentication result.\n    WebDomain:\n      type: object\n      required:\n        - custom_root\n        - domain\n        - root\n        - ssl_certificate\n        - static_enabled\n      properties:\n        custom_root:\n          type: string\n          example: /home/user-data/www/example.com\n        domain:\n          $ref: '#/components/schemas/Hostname'\n        root:\n          type: string\n          example: /home/user-data/www/default\n        ssl_certificate:\n          type: array\n          minItems: 2\n          maxItems: 2\n          uniqueItems: true\n          items:\n            oneOf:\n              - type: string\n                example: No certificate installed.\n              - type: string\n                enum:\n                  - danger\n                  - success\n                example: danger\n        static_enabled:\n          type: boolean\n          example: true\n      description: Web domain details.\n    WebUpdateResponse:\n      type: string\n      example: web updated\n      description: Web update response.\n    MfaStatusResponse:\n      type: object\n      properties:\n        enabled_mfa:\n          type: object\n          properties:\n            id:\n              type: string\n            type:\n              type: string\n            label:\n              type: string\n          nullable: true\n        new_mfa:\n          type: object\n          properties:\n            type:\n              type: string\n            secret:\n              type: string\n            qr_code_base64:\n              type: string\n    MfaEnableRequest:\n      type: object\n      required:\n        - secret\n        - code\n      properties:\n        secret:\n          type: string\n        code:\n          type: string\n        label:\n          type: string\n    MfaEnableSuccessResponse:\n      type: string\n    MfaDisableRequest:\n      type: object\n      properties:\n        mfa_id:\n          type: string\n          nullable: true\n    MfaDisableSuccessResponse:\n      type: string\n    LogoutResponse:\n      type: object\n      properties:\n        status:\n          type: string\n"
  },
  {
    "path": "conf/dovecot-mailboxes.conf",
    "content": "## NOTE: This file is automatically generated by Mail-in-a-Box.\n##       Do not edit this file. It is continually updated by\n##       Mail-in-a-Box and your changes will be lost.\n##\n##       Mail-in-a-Box machines are not meant to be modified.\n##       If you modify any system configuration you are on\n##       your own --- please do not ask for help from us.\n\nnamespace inbox {\n  # Automatically create & subscribe some folders.\n  # * Create and subscribe the INBOX folder.\n  # * Our sieve rule for spam expects that the Spam folder exists.\n  # * Z-Push must be configured with the same settings in conf/zpush/backend_imap.php (#580).\n\n  # MUA notes:\n  # * Roundcube will show an error if the user tries to delete a message before the Trash folder exists (#359).\n  # * K-9 mail will poll every 90 seconds if a Drafts folder does not exist.\n  # * 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).\n  # * Thunderbird's default in its UI is 'Archives' (plural) but it will configure new accounts to use whatever we say here (#581).\n\n  # auto:\n  # 'create' will automatically create this mailbox.\n  # 'subscribe' will both create and subscribe to the mailbox.\n\n  # special_use is a space separated list of IMAP SPECIAL-USE\n  # attributes as specified by RFC 6154:\n  # \\All \\Archive \\Drafts \\Flagged \\Junk \\Sent \\Trash\n\n  mailbox INBOX {\n    auto = subscribe\n  }\n  mailbox Spam {\n    special_use = \\Junk\n    auto = subscribe\n  }\n  mailbox Drafts {\n    special_use = \\Drafts\n    auto = subscribe\n  }\n  mailbox Sent {\n    special_use = \\Sent\n    auto = subscribe\n  }\n  mailbox Trash {\n    special_use = \\Trash\n    auto = subscribe\n  }\n  mailbox Archive {\n    special_use = \\Archive\n    auto = subscribe\n  }\n\n  # dovevot's standard mailboxes configuration file marks two sent folders\n  # with the \\Sent attribute, just in case clients don't agree about which\n  # they're using. We'll keep that, plus add Junk as an alternative for Spam.\n  # These are not auto-created.\n  mailbox \"Sent Messages\" {\n    special_use = \\Sent\n  }\n  mailbox Junk {\n    special_use = \\Junk\n  }\n}\n"
  },
  {
    "path": "conf/fail2ban/filter.d/dovecotimap.conf",
    "content": "# Fail2Ban filter Dovecot authentication and pop3/imap/managesieve server\n# For Mail-in-a-Box\n\n[INCLUDES]\n\nbefore = common.conf\n\n[Definition]\n\n_daemon = (auth|dovecot(-auth)?|auth-worker)\n\nfailregex = ^%(__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=<HOST>, lip=(\\d{1,3}\\.){3}\\d{1,3}(, TLS( handshaking)?(: Disconnected)?)?(, session=<\\S+>)?\\s*$\n\nignoreregex =\n\n# DEV Notes:\n# * the first regex is essentially a copy of pam-generic.conf\n# * Probably doesn't do dovecot sql/ldap backends properly\n#\n# Author: Martin Waschbuesch\n#         Daniel Black (rewrote with begin and end anchors)\n#         Mail-in-a-Box (swapped session=...)\n"
  },
  {
    "path": "conf/fail2ban/filter.d/miab-management-daemon.conf",
    "content": "# Fail2Ban filter Mail-in-a-Box management daemon\n\n[INCLUDES]\n\nbefore = common.conf\n\n[Definition]\n\n_daemon = mailinabox\n\nfailregex = Mail-in-a-Box Management Daemon: Failed login attempt from ip <HOST> - timestamp .*\nignoreregex =\n"
  },
  {
    "path": "conf/fail2ban/filter.d/miab-munin.conf",
    "content": "[INCLUDES]\n\nbefore = common.conf\n\n[Definition]\nfailregex=<HOST> - .*GET /admin/munin/.* HTTP/\\d+\\.\\d+\\\" 401.*\nignoreregex =\n"
  },
  {
    "path": "conf/fail2ban/filter.d/miab-owncloud.conf",
    "content": "[INCLUDES]\n\nbefore = common.conf\n\n[Definition]\n_groupsre = (?:(?:,?\\s*\"\\w+\":(?:\"[^\"]+\"|\\w+))*)\nfailregex = ^\\{%(_groupsre)s,?\\s*\"remoteAddr\":\"<HOST>\"%(_groupsre)s,?\\s*\"message\":\"Login failed:\n            ^\\{%(_groupsre)s,?\\s*\"remoteAddr\":\"<HOST>\"%(_groupsre)s,?\\s*\"message\":\"Trusted domain error.\ndatepattern = ,?\\s*\"time\"\\s*:\\s*\"%%Y-%%m-%%d[T ]%%H:%%M:%%S(%%z)?\"\n\n"
  },
  {
    "path": "conf/fail2ban/filter.d/miab-postfix-submission.conf",
    "content": "[INCLUDES]\n\nbefore = common.conf\n\n[Definition]\nfailregex=postfix/submission/smtpd.*warning.*\\[<HOST>\\]: .* authentication (failed|aborted)\nignoreregex =\n"
  },
  {
    "path": "conf/fail2ban/filter.d/miab-roundcube.conf",
    "content": "[INCLUDES]\n\nbefore = common.conf\n\n[Definition]\n\nfailregex = IMAP Error: Login failed for .*? from <HOST>\\. AUTHENTICATE.*\n\nignoreregex = \n"
  },
  {
    "path": "conf/fail2ban/jails.conf",
    "content": "# Fail2Ban configuration file for Mail-in-a-Box. Do not edit.\n# This file is re-generated on updates.\n\n[DEFAULT]\n# Whitelist our own IP addresses. 127.0.0.1/8 is the default. But our status checks\n# ping services over the public interface so we should whitelist that address of\n# ours too. The string is substituted during installation.\nignoreip = 127.0.0.1/8 PUBLIC_IP ::1 PUBLIC_IPV6\n\n[dovecot]\nenabled = true\nfilter  = dovecotimap\nlogpath = /var/log/mail.log\nfindtime = 30\nmaxretry = 20\n\n[miab-management]\nenabled = true\nfilter = miab-management-daemon\nport = http,https\nlogpath = /var/log/syslog\nmaxretry = 20\nfindtime = 30\n\n[miab-munin]\nenabled  = true\nport     = http,https\nfilter   = miab-munin\nlogpath  = /var/log/nginx/access.log\nmaxretry = 20\nfindtime = 30\n\n[miab-owncloud]\nenabled  = true\nport     = http,https\nfilter   = miab-owncloud\nlogpath  = STORAGE_ROOT/owncloud/nextcloud.log\nmaxretry = 20\nfindtime = 120\n\n[miab-postfix465]\nenabled  = true\nport     = 465\nfilter   = miab-postfix-submission\nlogpath  = /var/log/mail.log\nmaxretry = 20\nfindtime = 30\n\n[miab-postfix587]\nenabled  = true\nport     = 587\nfilter   = miab-postfix-submission\nlogpath  = /var/log/mail.log\nmaxretry = 20\nfindtime = 30\n\n[miab-roundcube]\nenabled  = true\nport     = http,https\nfilter   = miab-roundcube\nlogpath  = /var/log/roundcubemail/errors.log\nmaxretry = 20\nfindtime = 30\n\n[recidive]\nenabled  = true\nmaxretry = 10\naction   = iptables-allports[name=recidive]\n# In the recidive section of jail.conf the action contains:\n#\n# action   = iptables-allports[name=recidive]\n#            sendmail-whois-lines[name=recidive, logpath=/var/log/fail2ban.log]\n#\n# The last line on the action will sent an email to the configured address. This mail will\n# notify the administrator that someone has been repeatedly triggering one of the other jails.\n# By default we don't configure this address and no action is required from the admin anyway.\n# So the notification is omitted. This will prevent message appearing in the mail.log that mail\n# can't be delivered to fail2ban@$HOSTNAME.\n\n[postfix-sasl]\nenabled  = true\n\n[sshd]\nenabled = true\nmaxretry = 7\nbantime = 3600\n"
  },
  {
    "path": "conf/ios-profile.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<!--\n iOS/OS X Configuration Profile\n\n Mobileconfig for iOS/OS X users to setup IMAP, SMTP, Contacts & Calendar\n\n https://developer.apple.com/library/ios/featuredarticles/iPhoneConfigurationProfileRef/Introduction/Introduction.html\n-->\n<plist version=\"1.0\">\n<dict>\n  <key>PayloadContent</key>\n  <array>\n    <dict>\n      <key>CalDAVAccountDescription</key>\n      <string>PRIMARY_HOSTNAME calendar</string>\n      <key>CalDAVHostName</key>\n      <string>PRIMARY_HOSTNAME</string>\n      <key>CalDAVPort</key>\n      <real>443</real>\n      <key>CalDAVUseSSL</key>\n      <true/>\n      <key>PayloadDescription</key>\n      <string>PRIMARY_HOSTNAME (Mail-in-a-Box)</string>\n      <key>PayloadDisplayName</key>\n      <string>PRIMARY_HOSTNAME calendar</string>\n      <key>PayloadIdentifier</key>\n      <string>email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.CalDAV</string>\n      <key>PayloadOrganization</key>\n      <string></string>\n      <key>PayloadType</key>\n      <string>com.apple.caldav.account</string>\n      <key>PayloadUUID</key>\n      <string>UUID1</string>\n      <key>PayloadVersion</key>\n      <integer>1</integer>\n    </dict>\n    <dict>\n      <key>EmailAccountDescription</key>\n      <string>PRIMARY_HOSTNAME mail</string>\n      <key>EmailAccountType</key>\n      <string>EmailTypeIMAP</string>\n      <key>IncomingMailServerAuthentication</key>\n      <string>EmailAuthPassword</string>\n      <key>IncomingMailServerHostName</key>\n      <string>PRIMARY_HOSTNAME</string>\n      <key>IncomingMailServerPortNumber</key>\n      <integer>993</integer>\n      <key>IncomingMailServerUseSSL</key>\n      <true/>\n      <key>OutgoingMailServerAuthentication</key>\n      <string>EmailAuthPassword</string>\n      <key>OutgoingMailServerHostName</key>\n      <string>PRIMARY_HOSTNAME</string>\n      <key>OutgoingMailServerPortNumber</key>\n      <integer>465</integer>\n      <key>OutgoingMailServerUseSSL</key>\n      <true/>\n      <key>OutgoingPasswordSameAsIncomingPassword</key>\n      <true/>\n      <key>PayloadDescription</key>\n      <string>PRIMARY_HOSTNAME (Mail-in-a-Box)</string>\n      <key>PayloadDisplayName</key>\n      <string>PRIMARY_HOSTNAME mail</string>\n      <key>PayloadIdentifier</key>\n      <string>email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.E-Mail</string>\n      <key>PayloadOrganization</key>\n      <string></string>\n      <key>PayloadType</key>\n      <string>com.apple.mail.managed</string>\n      <key>PayloadUUID</key>\n      <string>UUID2</string>\n      <key>PayloadVersion</key>\n      <integer>1</integer>\n      <key>PreventAppSheet</key>\n      <false/>\n      <key>PreventMove</key>\n      <false/>\n      <key>SMIMEEnabled</key>\n      <false/>\n    </dict>\n    <dict>\n      <key>CardDAVAccountDescription</key>\n      <string>PRIMARY_HOSTNAME contacts</string>\n      <key>CardDAVHostName</key>\n      <string>PRIMARY_HOSTNAME</string>\n      <key>CardDAVPort</key>\n      <integer>443</integer>\n      <key>CardDAVPrincipalURL</key>\n      <string>/cloud/remote.php/carddav/addressbooks/</string>\n      <key>CardDAVUseSSL</key>\n      <true/>\n      <key>PayloadDescription</key>\n      <string>PRIMARY_HOSTNAME (Mail-in-a-Box)</string>\n      <key>PayloadDisplayName</key>\n      <string>PRIMARY_HOSTNAME contacts</string>\n      <key>PayloadIdentifier</key>\n      <string>email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.carddav</string>\n      <key>PayloadOrganization</key>\n      <string></string>\n      <key>PayloadType</key>\n      <string>com.apple.carddav.account</string>\n      <key>PayloadUUID</key>\n      <string>UUID3</string>\n      <key>PayloadVersion</key>\n      <integer>1</integer>\n    </dict>\n  </array>\n  <key>PayloadDescription</key>\n  <string>PRIMARY_HOSTNAME (Mail-in-a-Box)</string>\n  <key>PayloadDisplayName</key>\n  <string>PRIMARY_HOSTNAME</string>\n  <key>PayloadIdentifier</key>\n  <string>email.mailinabox.mobileconfig.PRIMARY_HOSTNAME</string>\n  <key>PayloadOrganization</key>\n  <string></string>\n  <key>PayloadRemovalDisallowed</key>\n  <false/>\n  <key>PayloadType</key>\n  <string>Configuration</string>\n  <key>PayloadUUID</key>\n  <string>UUID4</string>\n  <key>PayloadVersion</key>\n  <integer>1</integer>\n</dict>\n</plist>\n"
  },
  {
    "path": "conf/mailinabox.service",
    "content": "[Unit]\nDescription=Mail-in-a-Box System Management Service\nAfter=multi-user.target\n\n[Service]\nType=idle\nIgnoreSIGPIPE=False\nExecStart=/usr/local/lib/mailinabox/start\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "conf/mozilla-autoconfig.xml",
    "content": "<?xml version=\"1.0\"?>\n<clientConfig version=\"1.1\">\n    <emailProvider id=\"PRIMARY_HOSTNAME\">\n      <domain>PRIMARY_HOSTNAME</domain>\n      <domain purpose=\"mx\">PRIMARY_HOSTNAME</domain>\n\n      <displayName>PRIMARY_HOSTNAME (Mail-in-a-Box)</displayName>\n      <displayShortName>PRIMARY_HOSTNAME</displayShortName>\n\n      <incomingServer type=\"imap\">\n         <hostname>PRIMARY_HOSTNAME</hostname>\n         <port>993</port>\n         <socketType>SSL</socketType>\n         <username>%EMAILADDRESS%</username>\n         <authentication>password-cleartext</authentication>\n      </incomingServer>\n\n      <incomingServer type=\"pop3\">\n         <hostname>PRIMARY_HOSTNAME</hostname>\n         <port>995</port>\n         <socketType>SSL</socketType>\n         <username>%EMAILADDRESS%</username>\n         <authentication>password-cleartext</authentication>\n      </incomingServer>\n\n      <outgoingServer type=\"smtp\">\n         <hostname>PRIMARY_HOSTNAME</hostname>\n         <port>465</port>\n         <socketType>SSL</socketType>\n         <username>%EMAILADDRESS%</username>\n         <authentication>password-cleartext</authentication>\n         <addThisServer>true</addThisServer>\n         <useGlobalPreferredServer>false</useGlobalPreferredServer>\n      </outgoingServer>\n\n      <documentation url=\"https://PRIMARY_HOSTNAME/\">\n         <descr lang=\"en\">PRIMARY_HOSTNAME website.</descr>\n      </documentation>\n    </emailProvider>\n\n    <addressbook type=\"carddav\">\n      <username>%EMAILADDRESS%</username>\n      <authentication system=\"http\">basic</authentication>\n      <!-- Redirects to: https://PRIMARY_HOSTNAME/cloud/remote.php/carddav/ -->\n      <url>https://PRIMARY_HOSTNAME/.well-known/carddav</url>\n    </addressbook>\n\n    <calendar type=\"caldav\">\n      <username>%EMAILADDRESS%</username>\n      <authentication system=\"http\">basic</authentication>\n      <!-- Redirects to: https://PRIMARY_HOSTNAME/cloud/remote.php/caldav/ -->\n      <url>https://PRIMARY_HOSTNAME/.well-known/caldav</url>\n    </calendar>\n\n    <webMail>\n      <loginPage url=\"https://PRIMARY_HOSTNAME/mail/\" />\n      <loginPageInfo url=\"https://PRIMARY_HOSTNAME/mail/\" >\n        <username>%EMAILADDRESS%</username>\n        <usernameField id=\"rcmloginuser\" name=\"_user\" />\n        <passwordField id=\"rcmloginpwd\" name=\"_pass\" />\n        <loginButton id=\"rcmloginsubmit\" />\n      </loginPageInfo>\n    </webMail>\n\n    <clientConfigUpdate url=\"https://PRIMARY_HOSTNAME/.well-known/autoconfig/mail/config-v1.1.xml\" />\n\n</clientConfig>\n"
  },
  {
    "path": "conf/mta-sts.txt",
    "content": "version: STSv1\r\nmode: MODE\r\nmx: PRIMARY_HOSTNAME\r\nmax_age: 604800\r\n"
  },
  {
    "path": "conf/munin.service",
    "content": "[Unit]\nDescription=Munin System Monitoring Startup Script\nAfter=multi-user.target\n\n[Service]\nType=idle\nExecStart=/usr/local/lib/mailinabox/munin_start.sh\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "conf/nginx-alldomains.conf",
    "content": "\t# Expose this directory as static files.\n\troot $ROOT;\n\tindex index.html index.htm;\n\n\tlocation = /robots.txt {\n\t\tlog_not_found off;\n\t\taccess_log off;\n\t}\n\n\tlocation = /favicon.ico {\n\t\tlog_not_found off;\n\t\taccess_log off;\n\t}\n\n\tlocation = /mailinabox.mobileconfig {\n\t\talias /var/lib/mailinabox/mobileconfig.xml;\n\t}\n\tlocation = /.well-known/autoconfig/mail/config-v1.1.xml {\n\t\talias /var/lib/mailinabox/mozilla-autoconfig.xml;\n\t}\n\tlocation = /mail/config-v1.1.xml {\n\t\talias /var/lib/mailinabox/mozilla-autoconfig.xml;\n\t}\n\tlocation = /.well-known/mta-sts.txt {\n\t\talias /var/lib/mailinabox/mta-sts.txt;\n\t}\n\n\t# Roundcube Webmail configuration.\n\trewrite ^/mail$ /mail/ redirect;\n\trewrite ^/mail/$ /mail/index.php;\n\tlocation /mail/ {\n\t\tindex index.php;\n\t\talias /usr/local/lib/roundcubemail/;\n\t}\n\tlocation ~ /mail/config/.* {\n\t\t# A ~-style location is needed to give this precedence over the next block.\n\t\treturn 403;\n\t}\n\tlocation ~ /mail/.*\\.php {\n\t\t# note: ~ has precedence over a regular location block\n\t\tinclude fastcgi_params;\n\t\tfastcgi_split_path_info ^/mail(/.*)()$;\n\t\tfastcgi_index index.php;\n\t\tfastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name;\n\t\tfastcgi_pass php-fpm;\n\n\t\t# Outgoing mail also goes through this endpoint, so increase the maximum\n\t\t# file upload limit to match the corresponding Postfix limit.\n\t\tclient_max_body_size 128M;\n\t}\n\n\t# Z-Push (Microsoft Exchange ActiveSync)\n\tlocation /Microsoft-Server-ActiveSync {\n\t\tinclude /etc/nginx/fastcgi_params;\n\t\tfastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/index.php;\n\t\tfastcgi_param PHP_VALUE \"include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc\";\n\t\tfastcgi_read_timeout 630;\n\t\tfastcgi_pass php-fpm;\n\n\t\t# Outgoing mail also goes through this endpoint, so increase the maximum\n\t\t# file upload limit to match the corresponding Postfix limit.\n\t\tclient_max_body_size 128M;\n\t}\n\tlocation ~* ^/autodiscover/autodiscover.xml$ {\n\t\tinclude fastcgi_params;\n\t\tfastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/autodiscover/autodiscover.php;\n\t\tfastcgi_param PHP_VALUE \"include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc\";\n\t\tfastcgi_pass php-fpm;\n\t}\n\n\n\t# ADDITIONAL DIRECTIVES HERE\n\n\t# Disable viewing dotfiles (.htaccess, .svn, .git, etc.)\n\t# This block is placed at the end. Nginx's precedence rules means this block\n\t# takes precedence over all non-regex matches and only regex matches that\n\t# come after it (i.e. none of those, since this is the last one.) That means\n\t# we're blocking dotfiles in the static hosted sites but not the FastCGI-\n\t# handled locations for Nextcloud (which serves user-uploaded files that might\n\t# have this pattern, see #414) or some of the other services.\n\tlocation ~ /\\.(ht|svn|git|hg|bzr) {\n\t\tlog_not_found off;\n\t\taccess_log off;\n\t\tdeny all;\n\t}\n"
  },
  {
    "path": "conf/nginx-primaryonly.conf",
    "content": "\t# Control Panel\n\t# Proxy /admin to our Python based control panel daemon. It is\n\t# listening on IPv4 only so use an IP address and not 'localhost'.\n\tlocation /admin/assets {\n\t\talias /usr/local/lib/mailinabox/vendor/assets;\n\t}\n\trewrite ^/admin$ /admin/;\n\trewrite ^/admin/munin$ /admin/munin/ redirect;\n\tlocation /admin/ {\n\t\tproxy_pass http://127.0.0.1:10222/;\n\t\tproxy_set_header X-Forwarded-For $remote_addr;\n\t\tadd_header X-Frame-Options \"DENY\";\n\t\tadd_header X-Content-Type-Options nosniff;\n\t\tadd_header Content-Security-Policy \"frame-ancestors 'none';\";\n\t}\n\n\t# Nextcloud configuration.\n\trewrite ^/cloud$ /cloud/ redirect;\n\trewrite ^/cloud/$ /cloud/index.php;\n\trewrite ^/cloud/(contacts|calendar|files)$ /cloud/index.php/apps/$1/ redirect;\n\trewrite ^(/cloud/core/doc/[^\\/]+/)$ $1/index.html;\n\trewrite ^(/cloud/oc[sm]-provider)/$ $1/index.php redirect;\n\tlocation /cloud/ {\n\t\talias /usr/local/lib/owncloud/;\n\t\tlocation ~ ^/cloud/(build|tests|config|lib|3rdparty|templates|data|README)/ {\n\t\t\tdeny all;\n\t\t}\n\t\tlocation ~ ^/cloud/(?:\\.|autotest|occ|issue|indie|db_|console) {\n\t\t\tdeny all;\n\t\t}\n\t\t# Enable paths for service and cloud federation discovery\n\t\t# Resolves warning in Nextcloud Settings panel\n\t\tlocation ~ ^/cloud/(oc[sm]-provider)?/([^/]+\\.php)$ {\n\t\t\tindex index.php;\n\t\t\tinclude fastcgi_params;\n\t\t\tfastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$1/$2;\n\t\t\tfastcgi_pass php-fpm;\n\t\t}\n\t}\n\tlocation ~ ^(/cloud)((?:/ocs)?/[^/]+\\.php)(/.*)?$ {\n\t\t# note: ~ has precedence over a regular location block\n\t\t# Accept URLs like:\n\t\t# /cloud/index.php/apps/files/\n\t\t# /cloud/index.php/apps/files/ajax/scan.php (it's really index.php; see 6fdef379adfdeac86cc2220209bdf4eb9562268d)\n\t\t# /cloud/ocs/v1.php/apps/files_sharing/api/v1 (see #240)\n\t\t# /cloud/remote.php/webdav/yourfilehere...\n\t\tinclude fastcgi_params;\n\t\tfastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$2;\n\t\tfastcgi_param SCRIPT_NAME $1$2;\n\t\tfastcgi_param PATH_INFO $3;\n\t\tfastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on;\n\t\tfastcgi_param MOD_X_ACCEL_REDIRECT_PREFIX /owncloud-xaccel;\n\t\tfastcgi_read_timeout 630;\n\t\tfastcgi_pass php-fpm;\n\t\tclient_max_body_size 1G;\n\t\tfastcgi_buffers 64 4K;\n\t}\n\tlocation ^~ /owncloud-xaccel/ {\n\t\t# This directory is for MOD_X_ACCEL_REDIRECT_ENABLED. Nextcloud sends the full file\n\t\t# path on disk as a subdirectory under this virtual path.\n\t\t# We must only allow 'internal' redirects within nginx so that the filesystem\n\t\t# is not exposed to the world.\n\t\tinternal;\n\t\talias /;\n\t}\n\tlocation ~ ^/((caldav|carddav|webdav).*)$ {\n\t\t# Z-Push doesn't like getting a redirect, and a plain rewrite didn't work either.\n\t\t# Properly proxying like this seems to work fine.\n\t\tproxy_pass https://127.0.0.1/cloud/remote.php/$1;\n\t}\n\trewrite ^/.well-known/host-meta /cloud/public.php?service=host-meta last;\n\trewrite ^/.well-known/host-meta.json /cloud/public.php?service=host-meta-json last;\n\trewrite ^/.well-known/carddav /cloud/remote.php/carddav/ redirect;\n\trewrite ^/.well-known/caldav /cloud/remote.php/caldav/ redirect;\n\n\t# This addresses those service discovery issues mentioned in:\n\t# https://docs.nextcloud.com/server/23/admin_manual/issues/general_troubleshooting.html#service-discovery\n\trewrite ^/.well-known/webfinger /cloud/index.php/.well-known/webfinger redirect;\n\trewrite ^/.well-known/nodeinfo /cloud/index.php/.well-known/nodeinfo redirect;\n\n\t# ADDITIONAL DIRECTIVES HERE\n"
  },
  {
    "path": "conf/nginx-ssl.conf",
    "content": "# We track the Mozilla \"intermediate\" compatibility TLS recommendations.\n# Note that these settings are repeated in the SMTP and IMAP configuration.\n# ssl_protocols has moved to nginx.conf in bionic, check there for enabled protocols.\nssl_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;\nssl_dhparam STORAGE_ROOT/ssl/dh2048.pem;\n\n# as recommended by http://nginx.org/en/docs/http/configuring_https_servers.html\nssl_session_cache shared:SSL:50m;\nssl_session_timeout 1d;\n\n# Buffer size of 1400 bytes fits in one MTU.\n# nginx 1.5.9+ ONLY\nssl_buffer_size 1400; \n\nresolver 127.0.0.1 valid=86400;\nresolver_timeout 10;\n\n# h/t https://gist.github.com/konklone/6532544\n"
  },
  {
    "path": "conf/nginx-top.conf",
    "content": "## NOTE: This file is automatically generated by Mail-in-a-Box.\n##       Do not edit this file. It is continually updated by\n##       Mail-in-a-Box and your changes will be lost.\n##\n##       Mail-in-a-Box machines are not meant to be modified.\n##       If you modify any system configuration you are on\n##       your own --- please do not ask for help from us.\n\nupstream php-fpm {\n\tserver unix:/var/run/php/php8.0-fpm.sock;\n}\n\n"
  },
  {
    "path": "conf/nginx.conf",
    "content": "## $HOSTNAME\n\n# Redirect all HTTP to HTTPS *except* the ACME challenges (Let's Encrypt TLS certificate\n# domain validation challenges) path, which must be served over HTTP per the ACME spec\n# (due to some Apache vulnerability).\nserver {\n\tlisten 80;\n\tlisten [::]:80;\n\n\tserver_name $HOSTNAME;\n\troot /tmp/invalid-path-nothing-here;\n\n\t# Improve privacy: Hide version an OS information on\n\t# error pages and in the \"Server\" HTTP-Header.\n\tserver_tokens off;\n\n\tlocation / {\n\t\t# Redirect using the 'return' directive and the built-in\n\t\t# variable '$request_uri' to avoid any capturing, matching\n\t\t# or evaluation of regular expressions.\n\t\treturn 301 https://$HOSTNAME$request_uri;\n\t}\n\n\tlocation /.well-known/acme-challenge/ {\n\t\t# This path must be served over HTTP for ACME domain validation.\n\t\t# We map this to a special path where our TLS cert provisioning\n\t\t# tool knows to store challenge response files.\n\t\talias $STORAGE_ROOT/ssl/lets_encrypt/webroot/.well-known/acme-challenge/;\n\t}\n}\n\n# The secure HTTPS server.\nserver {\n\tlisten 443 ssl http2;\n\tlisten [::]:443 ssl http2;\n\n\tserver_name $HOSTNAME;\n\n\t# Improve privacy: Hide version an OS information on\n\t# error pages and in the \"Server\" HTTP-Header.\n\tserver_tokens off;\n\n\tssl_certificate $SSL_CERTIFICATE;\n\tssl_certificate_key $SSL_KEY;\n\n\t# ADDITIONAL DIRECTIVES HERE\n}\n"
  },
  {
    "path": "conf/postfix_outgoing_mail_header_filters",
    "content": "# Remove the first line of the Received: header. Note that we cannot fully remove the Received: header\n# because OpenDKIM requires that a header be present when signing outbound mail. The first line is\n# where the user's home IP address would be.\n/^\\s*Received:[^\\n]*(.*)/         REPLACE Received: from authenticated-user (PRIMARY_HOSTNAME [PUBLIC_IP])$1\n\n# Remove other typically private information.\n/^\\s*User-Agent:/        IGNORE\n/^\\s*X-Enigmail:/        IGNORE\n/^\\s*X-Mailer:/          IGNORE\n/^\\s*X-Originating-IP:/  IGNORE\n/^\\s*X-Pgp-Agent:/       IGNORE\n\n# 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\\)).\n/^\\s*(Mime-Version:\\s*[0-9\\.]+)\\s.+/  REPLACE $1\n"
  },
  {
    "path": "conf/sieve-spam.txt",
    "content": "require [\"regex\", \"fileinto\", \"imap4flags\"];\n\nif allof (header :regex \"X-Spam-Status\" \"^Yes\") {\n  fileinto \"Spam\";\n  stop;\n}\n\n"
  },
  {
    "path": "conf/www_default.html",
    "content": "<html>\n\t<head>\n\t\t<title>this is a mail-in-a-box</title>\n\t\t<meta name=\"robots\" content=\"noindex\">\n\t</head>\n\t<body>\n\t\t<h1>this is a mail-in-a-box</h1>\n\t\t<p>take control of your email at <a href=\"https://mailinabox.email/\">https://mailinabox.email/</a></p>\n\t</body>\n</html>\n"
  },
  {
    "path": "conf/zpush/autodiscover_config.php",
    "content": "<?php\n/***********************************************\n* File      :   config.php\n* Project   :   Z-Push\n* Descr     :   Autodiscover configuration file\n************************************************/\n\ndefine('TIMEZONE', '');\n\n// Defines the base path on the server\ndefine('BASE_PATH', dirname($_SERVER['SCRIPT_FILENAME']). '/');\n\ndefine('ZPUSH_HOST', 'PRIMARY_HOSTNAME');\n\ndefine('USE_FULLEMAIL_FOR_LOGIN', true);\n\ndefine('LOGFILEDIR', '/var/log/z-push/');\ndefine('LOGFILE', LOGFILEDIR . 'autodiscover.log');\ndefine('LOGERRORFILE', LOGFILEDIR . 'autodiscover-error.log');\ndefine('LOGLEVEL', LOGLEVEL_INFO);\ndefine('LOGUSERLEVEL', LOGLEVEL);\n$specialLogUsers = array();\n\n// the backend data provider\ndefine('BACKEND_PROVIDER', 'BackendCombined');\n?>\n"
  },
  {
    "path": "conf/zpush/backend_caldav.php",
    "content": "<?php\n/***********************************************\n* File      :   config.php\n* Project   :   Z-Push\n* Descr     :   CalDAV backend configuration file\n************************************************/\n\ndefine('CALDAV_PROTOCOL', 'https');\ndefine('CALDAV_SERVER', '127.0.0.1');\ndefine('CALDAV_PORT', '443');\ndefine('CALDAV_PATH', '/caldav/calendars/%u/');\ndefine('CALDAV_PERSONAL', 'PRINCIPAL');\ndefine('CALDAV_SUPPORTS_SYNC', false);\ndefine('CALDAV_MAX_SYNC_PERIOD', 2147483647);\n\n?>\n"
  },
  {
    "path": "conf/zpush/backend_carddav.php",
    "content": "<?php\n/***********************************************\n* File      :   config.php\n* Project   :   Z-Push\n* Descr     :   CardDAV backend configuration file\n************************************************/\n\n\ndefine('CARDDAV_PROTOCOL', 'https'); /* http or https */\ndefine('CARDDAV_SERVER', '127.0.0.1');\ndefine('CARDDAV_PORT', '443');\ndefine('CARDDAV_PATH', '/carddav/addressbooks/%u/');\ndefine('CARDDAV_DEFAULT_PATH', '/carddav/addressbooks/%u/contacts/'); /* subdirectory of the main path */\ndefine('CARDDAV_GAL_PATH', ''); /* readonly, searchable, not syncd */\ndefine('CARDDAV_GAL_MIN_LENGTH', 5);\ndefine('CARDDAV_CONTACTS_FOLDER_NAME', '%u Addressbook');\ndefine('CARDDAV_SUPPORTS_SYNC', false);\n\n// If the CardDAV server supports the FN attribute for searches\n// DAViCal supports it, but SabreDav, Nextcloud and SOGo don't\n// Setting this to true will search by FN. If false will search by sn, givenName and email\n// It's safe to leave it as false\ndefine('CARDDAV_SUPPORTS_FN_SEARCH', false);\n\n\n// If your carddav server needs to use file extension to recover a vcard.\n//    Davical needs it\n//    SOGo official demo online needs it, but some SOGo installation don't need it, so test it\ndefine('CARDDAV_URL_VCARD_EXTENSION', '.vcf');\n\n?>\n"
  },
  {
    "path": "conf/zpush/backend_combined.php",
    "content": "<?php\n/***********************************************\n* File      :   backend/combined/config.php\n* Project   :   Z-Push\n* Descr     :   configuration file for the\n*               combined backend.\n************************************************/\n\nclass BackendCombinedConfig {\n    public static function GetBackendCombinedConfig() {\n        return array(\n\t\t\t'backends' => array(\n\t\t\t\t'i' => array(\n\t\t\t\t\t'name' => 'BackendIMAP',\n\t\t\t\t),\n\t\t\t\t'c' => array(\n\t\t\t\t\t'name' => 'BackendCalDAV',\n\t\t\t\t),\n\t\t\t\t'd' => array(\n\t\t\t\t\t'name' => 'BackendCardDAV',\n\t\t\t\t),\n\t\t\t),\n\t\t\t'delimiter' => '/',\n\t\t\t'folderbackend' => array(\n\t\t\t\tSYNC_FOLDER_TYPE_INBOX => 'i',\n\t\t\t\tSYNC_FOLDER_TYPE_DRAFTS => 'i',\n\t\t\t\tSYNC_FOLDER_TYPE_WASTEBASKET => 'i',\n\t\t\t\tSYNC_FOLDER_TYPE_SENTMAIL => 'i',\n\t\t\t\tSYNC_FOLDER_TYPE_OUTBOX => 'i',\n\t\t\t\tSYNC_FOLDER_TYPE_TASK => 'c',\n\t\t\t\tSYNC_FOLDER_TYPE_APPOINTMENT => 'c',\n\t\t\t\tSYNC_FOLDER_TYPE_CONTACT => 'd',\n\t\t\t\tSYNC_FOLDER_TYPE_NOTE => 'c',\n\t\t\t\tSYNC_FOLDER_TYPE_JOURNAL => 'c',\n\t\t\t\tSYNC_FOLDER_TYPE_OTHER => 'i',\n\t\t\t\tSYNC_FOLDER_TYPE_USER_MAIL => 'i',\n\t\t\t\tSYNC_FOLDER_TYPE_USER_APPOINTMENT => 'c',\n\t\t\t\tSYNC_FOLDER_TYPE_USER_CONTACT => 'd',\n\t\t\t\tSYNC_FOLDER_TYPE_USER_TASK => 'c',\n\t\t\t\tSYNC_FOLDER_TYPE_USER_JOURNAL => 'c',\n\t\t\t\tSYNC_FOLDER_TYPE_USER_NOTE => 'c',\n\t\t\t\tSYNC_FOLDER_TYPE_UNKNOWN => 'i',\n\t\t\t),\n\t\t\t'rootcreatefolderbackend' => 'i',\n\t\t);\n    }\n}\n\n?>\n"
  },
  {
    "path": "conf/zpush/backend_imap.php",
    "content": "<?php\n/***********************************************\n* File      :   config.php\n* Project   :   Z-Push\n* Descr     :   IMAP backend configuration file\n************************************************/\n\ndefine('IMAP_SERVER', '127.0.0.1');\ndefine('IMAP_PORT', 993);\ndefine('IMAP_OPTIONS', '/ssl/norsh/novalidate-cert');\ndefine('IMAP_DEFAULTFROM', 'sql');\n\ndefine('SYSTEM_MIME_TYPES_MAPPING', '/etc/mime.types');\ndefine('IMAP_AUTOSEEN_ON_DELETE', false);\ndefine('IMAP_FOLDER_CONFIGURED', true);\ndefine('IMAP_FOLDER_PREFIX', '');\ndefine('IMAP_FOLDER_PREFIX_IN_INBOX', false);\n// see our conf/dovecot-mailboxes.conf file for IMAP special flags settings\ndefine('IMAP_FOLDER_INBOX', 'INBOX');\ndefine('IMAP_FOLDER_SENT', 'SENT');\ndefine('IMAP_FOLDER_DRAFT', 'DRAFTS');\ndefine('IMAP_FOLDER_TRASH', 'TRASH');\ndefine('IMAP_FOLDER_SPAM', 'SPAM');\ndefine('IMAP_FOLDER_ARCHIVE', 'ARCHIVE');\n\ndefine('IMAP_INLINE_FORWARD', true);\ndefine('IMAP_EXCLUDED_FOLDERS', '');\n\ndefine('IMAP_FROM_SQL_DSN', 'sqlite:STORAGE_ROOT/mail/roundcube/roundcube.sqlite');\ndefine('IMAP_FROM_SQL_USER', '');\ndefine('IMAP_FROM_SQL_PASSWORD', '');\ndefine('IMAP_FROM_SQL_OPTIONS', serialize(array(PDO::ATTR_PERSISTENT => true)));\ndefine('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 <> ''\");\ndefine('IMAP_FROM_SQL_FIELDS', serialize(array('name', 'email')));\ndefine('IMAP_FROM_SQL_FROM', '#name <#email>');\ndefine('IMAP_FROM_SQL_FULLNAME', '#name');\n\n// not used\ndefine('IMAP_FROM_LDAP_SERVER', '');\ndefine('IMAP_FROM_LDAP_SERVER_PORT', '389');\ndefine('IMAP_FROM_LDAP_USER', 'cn=zpush,ou=servers,dc=zpush,dc=org');\ndefine('IMAP_FROM_LDAP_PASSWORD', 'password');\ndefine('IMAP_FROM_LDAP_BASE', 'dc=zpush,dc=org');\ndefine('IMAP_FROM_LDAP_QUERY', '(mail=#username@#domain)');\ndefine('IMAP_FROM_LDAP_FIELDS', serialize(array('givenname', 'sn', 'mail')));\ndefine('IMAP_FROM_LDAP_FROM', '#givenname #sn <#mail>');\ndefine('IMAP_FROM_LDAP_FULLNAME', '#givenname #sn');\n\ndefine('IMAP_SMTP_METHOD', 'sendmail');\n\nglobal $imap_smtp_params;\n$imap_smtp_params = array('host' => 'ssl://127.0.0.1', 'port' => 465, 'auth' => true, 'username' => 'imap_username', 'password' => 'imap_password');\n\ndefine('MAIL_MIMEPART_CRLF', \"\\r\\n\");\ndefine('IMAP_MEETING_USE_CALDAV', true);\n\n?>\n"
  },
  {
    "path": "management/auth.py",
    "content": "import base64, hmac, json, secrets\nfrom datetime import timedelta\n\nfrom expiringdict import ExpiringDict\n\nimport utils\nfrom mailconfig import get_mail_password, get_mail_user_privileges\nfrom mfa import get_hash_mfa_state, validate_auth_mfa\n\nDEFAULT_KEY_PATH   = '/var/lib/mailinabox/api.key'\nDEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'\n\nclass AuthService:\n\tdef __init__(self):\n\t\tself.auth_realm = DEFAULT_AUTH_REALM\n\t\tself.key_path = DEFAULT_KEY_PATH\n\t\tself.max_session_duration = timedelta(days=2)\n\n\t\tself.init_system_api_key()\n\t\tself.sessions = ExpiringDict(max_len=64, max_age_seconds=self.max_session_duration.total_seconds())\n\n\tdef init_system_api_key(self):\n\t\t\"\"\"Write an API key to a local file so local processes can use the API\"\"\"\n\n\t\twith open(self.key_path, encoding='utf-8') as file:\n\t\t\tself.key = file.read()\n\n\tdef authenticate(self, request, env, login_only=False, logout=False):\n\t\t\"\"\"Test if the HTTP Authorization header's username matches the system key, a session key,\n\t\tor if the username/password passed in the header matches a local user.\n\t\tReturns a tuple of the user's email address and list of user privileges (e.g.\n\t\t('my@email', []) or ('my@email', ['admin']); raises a ValueError on login failure.\n\t\tIf the user used the system API key, the user's email is returned as None since\n\t\tthis key is not associated with a user.\"\"\"\n\n\t\tdef parse_http_authorization_basic(header):\n\t\t\tdef decode(s):\n\t\t\t\treturn base64.b64decode(s.encode('ascii')).decode('ascii')\n\t\t\tif \" \" not in header:\n\t\t\t\treturn None, None\n\t\t\tscheme, credentials = header.split(maxsplit=1)\n\t\t\tif scheme != 'Basic':\n\t\t\t\treturn None, None\n\t\t\tcredentials = decode(credentials)\n\t\t\tif \":\" not in credentials:\n\t\t\t\treturn None, None\n\t\t\tusername, password = credentials.split(':', maxsplit=1)\n\t\t\treturn username, password\n\n\t\tusername, password = parse_http_authorization_basic(request.headers.get('Authorization', ''))\n\t\tif username in {None, \"\"}:\n\t\t\tmsg = \"Authorization header invalid.\"\n\t\t\traise ValueError(msg)\n\n\t\tif username.strip() == \"\" and password.strip() == \"\":\n\t\t\tmsg = \"No email address, password, session key, or API key provided.\"\n\t\t\traise ValueError(msg)\n\n\t\t# If user passed the system API key, grant administrative privs. This key\n\t\t# is not associated with a user.\n\t\tif username == self.key and not login_only:\n\t\t\treturn (None, [\"admin\"])\n\n\t\t# If the password corresponds with a session token for the user, grant access for that user.\n\t\tif self.get_session(username, password, \"login\", env) and not login_only:\n\t\t\tsessionid = password\n\t\t\tsession = self.sessions[sessionid]\n\t\t\tif logout:\n\t\t\t\t# Clear the session.\n\t\t\t\tdel self.sessions[sessionid]\n\t\t\telse:\n\t\t\t\t# Re-up the session so that it does not expire.\n\t\t\t\tself.sessions[sessionid] = session\n\n\t\t# If no password was given, but a username was given, we're missing some information.\n\t\telif password.strip() == \"\":\n\t\t\tmsg = \"Enter a password.\"\n\t\t\traise ValueError(msg)\n\n\t\telse:\n\t\t\t# The user is trying to log in with a username and a password\n\t\t\t# (and possibly a MFA token). On failure, an exception is raised.\n\t\t\tself.check_user_auth(username, password, request, env)\n\n\t\t# Get privileges for authorization. This call should never fail because by this\n\t\t# point we know the email address is a valid user --- unless the user has been\n\t\t# deleted after the session was granted. On error the call will return a tuple\n\t\t# of an error message and an HTTP status code.\n\t\tprivs = get_mail_user_privileges(username, env)\n\t\tif isinstance(privs, tuple): raise ValueError(privs[0])\n\n\t\t# Return the authorization information.\n\t\treturn (username, privs)\n\n\tdef check_user_auth(self, email, pw, request, env):\n\t\t# Validate a user's login email address and password. If MFA is enabled,\n\t\t# check the MFA token in the X-Auth-Token header.\n\t\t#\n\t\t# On login failure, raises a ValueError with a login error message. On\n\t\t# success, nothing is returned.\n\n\t\t# Authenticate.\n\t\ttry:\n\t\t\t# Get the hashed password of the user. Raise a ValueError if the\n\t\t\t# email address does not correspond to a user. But wrap it in the\n\t\t\t# same exception as if a password fails so we don't easily reveal\n\t\t\t# if an email address is valid.\n\t\t\tpw_hash = get_mail_password(email, env)\n\n\t\t\t# Use 'doveadm pw' to check credentials. doveadm will return\n\t\t\t# a non-zero exit status if the credentials are no good,\n\t\t\t# and check_call will raise an exception in that case.\n\t\t\tutils.shell('check_call', [\n\t\t\t\t\"/usr/bin/doveadm\", \"pw\",\n\t\t\t\t\"-p\", pw,\n\t\t\t\t\"-t\", pw_hash,\n\t\t\t\t])\n\t\texcept:\n\t\t\t# Login failed.\n\t\t\tmsg = \"Incorrect email address or password.\"\n\t\t\traise ValueError(msg)\n\n\t\t# If MFA is enabled, check that MFA passes.\n\t\tstatus, hints = validate_auth_mfa(email, request, env)\n\t\tif not status:\n\t\t\t# Login valid. Hints may have more info.\n\t\t\traise ValueError(\",\".join(hints))\n\n\tdef create_user_password_state_token(self, email, env):\n\t\t# Create a token that changes if the user's password or MFA options change\n\t\t# so that sessions become invalid if any of that information changes.\n\t\tmsg = get_mail_password(email, env).encode(\"utf8\")\n\n\t\t# Add to the message the current MFA state, which is a list of MFA information.\n\t\t# Turn it into a string stably.\n\t\tmsg += b\" \" + json.dumps(get_hash_mfa_state(email, env), sort_keys=True).encode(\"utf8\")\n\n\t\t# Make a HMAC using the system API key as a hash key.\n\t\thash_key = self.key.encode('ascii')\n\t\treturn hmac.new(hash_key, msg, digestmod=\"sha256\").hexdigest()\n\n\tdef create_session_key(self, username, env, type=None):\n\t\t# Create a new session.\n\t\ttoken = secrets.token_hex(32)\n\t\tself.sessions[token] = {\n\t\t\t\"email\": username,\n\t\t\t\"password_token\": self.create_user_password_state_token(username, env),\n\t\t\t\"type\": type,\n\t\t}\n\t\treturn token\n\n\tdef get_session(self, user_email, session_key, session_type, env):\n\t\tif session_key not in self.sessions: return None\n\t\tsession = self.sessions[session_key]\n\t\tif session_type == \"login\" and session[\"email\"] != user_email: return None\n\t\tif session[\"type\"] != session_type: return None\n\t\tif session[\"password_token\"] != self.create_user_password_state_token(session[\"email\"], env): return None\n\t\treturn session\n"
  },
  {
    "path": "management/backup.py",
    "content": "#!/usr/local/lib/mailinabox/env/bin/python\n\n# This script performs a backup of all user data:\n# 1) System services are stopped.\n# 2) STORAGE_ROOT/backup/before-backup is executed if it exists.\n# 3) An incremental encrypted backup is made using duplicity.\n# 4) The stopped services are restarted.\n# 5) STORAGE_ROOT/backup/after-backup is executed if it exists.\n\nimport os, os.path, re, datetime, sys\nimport dateutil.parser, dateutil.relativedelta, dateutil.tz\nfrom datetime import date\nimport rtyaml\nfrom exclusiveprocess import Lock\n\nfrom utils import load_environment, shell, wait_for_service\nimport operator\n\ndef backup_status(env):\n\t# If backups are disabled, return no status.\n\tconfig = get_backup_config(env)\n\tif config[\"target\"] == \"off\":\n\t\treturn { }\n\n\t# Query duplicity to get a list of all full and incremental\n\t# backups available.\n\n\tbackups = { }\n\tnow = datetime.datetime.now(dateutil.tz.tzlocal())\n\tbackup_root = os.path.join(env[\"STORAGE_ROOT\"], 'backup')\n\tbackup_cache_dir = os.path.join(backup_root, 'cache')\n\n\tdef reldate(date, ref, clip):\n\t\tif ref < date: return clip\n\t\trd = dateutil.relativedelta.relativedelta(ref, date)\n\t\tif rd.years > 1: return \"%d years, %d months\" % (rd.years, rd.months)\n\t\tif rd.years == 1: return \"%d year, %d months\" % (rd.years, rd.months)\n\t\tif rd.months > 1: return \"%d months, %d days\" % (rd.months, rd.days)\n\t\tif rd.months == 1: return \"%d month, %d days\" % (rd.months, rd.days)\n\t\tif rd.days >= 7: return \"%d days\" % rd.days\n\t\tif rd.days > 1: return \"%d days, %d hours\" % (rd.days, rd.hours)\n\t\tif rd.days == 1: return \"%d day, %d hours\" % (rd.days, rd.hours)\n\t\treturn \"%d hours, %d minutes\" % (rd.hours, rd.minutes)\n\n\t# Get duplicity collection status and parse for a list of backups.\n\tdef parse_line(line):\n\t\tkeys = line.strip().split()\n\t\tdate = dateutil.parser.parse(keys[1]).astimezone(dateutil.tz.tzlocal())\n\t\treturn {\n\t\t\t\"date\": keys[1],\n\t\t\t\"date_str\": date.strftime(\"%Y-%m-%d %X\") + \" \" + now.tzname(),\n\t\t\t\"date_delta\": reldate(date, now, \"the future?\"),\n\t\t\t\"full\": keys[0] == \"full\",\n\t\t\t\"size\": 0, # collection-status doesn't give us the size\n\t\t\t\"volumes\": int(keys[2]), # number of archive volumes for this backup (not really helpful)\n\t\t}\n\n\tcode, collection_status = shell('check_output', [\n\t\t\"/usr/bin/duplicity\",\n\t\t\"collection-status\",\n\t\t\"--archive-dir\", backup_cache_dir,\n\t\t\"--gpg-options\", \"'--cipher-algo=AES256'\",\n\t\t\"--log-fd\", \"1\",\n\t\t*get_duplicity_additional_args(env),\n\t\tget_duplicity_target_url(config)\n\t\t],\n\t\tget_duplicity_env_vars(env),\n\t\ttrap=True)\n\tif code != 0:\n\t\t# Command failed. This is likely due to an improperly configured remote\n\t\t# destination for the backups or the last backup job terminated unexpectedly.\n\t\traise Exception(\"Something is wrong with the backup: \" + collection_status)\n\tfor line in collection_status.split('\\n'):\n\t\tif line.startswith((\" full\", \" inc\")):\n\t\t\tbackup = parse_line(line)\n\t\t\tbackups[backup[\"date\"]] = backup\n\n\t# Look at the target directly to get the sizes of each of the backups. There is more than one file per backup.\n\t# Starting with duplicity in Ubuntu 18.04, \"signatures\" files have dates in their\n\t# filenames that are a few seconds off the backup date and so don't line up\n\t# with the list of backups we have. Track unmatched files so we know how much other\n\t# space is used for those.\n\tunmatched_file_size = 0\n\tfor fn, size in list_target_files(config):\n\t\tm = re.match(r\"duplicity-(full|full-signatures|(inc|new-signatures)\\.(?P<incbase>\\d+T\\d+Z)\\.to)\\.(?P<date>\\d+T\\d+Z)\\.\", fn)\n\t\tif not m: continue # not a part of a current backup chain\n\t\tkey = m.group(\"date\")\n\t\tif key in backups:\n\t\t\tbackups[key][\"size\"] += size\n\t\telse:\n\t\t\tunmatched_file_size += size\n\n\t# Ensure the rows are sorted reverse chronologically.\n\t# This is relied on by should_force_full() and the next step.\n\tbackups = sorted(backups.values(), key = operator.itemgetter(\"date\"), reverse=True)\n\n\t# Get the average size of incremental backups, the size of the\n\t# most recent full backup, and the date of the most recent\n\t# backup and the most recent full backup.\n\tincremental_count = 0\n\tincremental_size = 0\n\tfirst_date = None\n\tfirst_full_size = None\n\tfirst_full_date = None\n\tfor bak in backups:\n\t\tif first_date is None:\n\t\t\tfirst_date = dateutil.parser.parse(bak[\"date\"])\n\t\tif bak[\"full\"]:\n\t\t\tfirst_full_size = bak[\"size\"]\n\t\t\tfirst_full_date = dateutil.parser.parse(bak[\"date\"])\n\t\t\tbreak\n\t\tincremental_count += 1\n\t\tincremental_size += bak[\"size\"]\n\n\t# When will the most recent backup be deleted? It won't be deleted if the next\n\t# backup is incremental, because the increments rely on all past increments.\n\t# So first guess how many more incremental backups will occur until the next\n\t# full backup. That full backup frees up this one to be deleted. But, the backup\n\t# must also be at least min_age_in_days old too.\n\tdeleted_in = None\n\tif incremental_count > 0 and incremental_size > 0 and first_full_size is not None:\n\t\t# How many days until the next incremental backup? First, the part of\n\t\t# the algorithm based on increment sizes:\n\t\test_days_to_next_full = (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count)\n\t\test_time_of_next_full = first_date + datetime.timedelta(days=est_days_to_next_full)\n\n\t\t# ...And then the part of the algorithm based on full backup age:\n\t\test_time_of_next_full = min(est_time_of_next_full, first_full_date + datetime.timedelta(days=config[\"min_age_in_days\"]*10+1))\n\n\t\t# It still can't be deleted until it's old enough.\n\t\test_deleted_on = max(est_time_of_next_full, first_date + datetime.timedelta(days=config[\"min_age_in_days\"]))\n\n\t\tdeleted_in = \"approx. %d days\" % round((est_deleted_on-now).total_seconds()/60/60/24 + .5)\n\n\t# When will a backup be deleted? Set the deleted_in field of each backup.\n\tsaw_full = False\n\tfor bak in backups:\n\t\tif deleted_in:\n\t\t\t# The most recent increment in a chain and all of the previous backups\n\t\t\t# it relies on are deleted at the same time.\n\t\t\tbak[\"deleted_in\"] = deleted_in\n\t\tif bak[\"full\"]:\n\t\t\t# Reset when we get to a full backup. A new chain start *next*.\n\t\t\tsaw_full = True\n\t\t\tdeleted_in = None\n\t\telif saw_full and not deleted_in:\n\t\t\t# We're now on backups prior to the most recent full backup. These are\n\t\t\t# free to be deleted as soon as they are min_age_in_days old.\n\t\t\tdeleted_in = reldate(now, dateutil.parser.parse(bak[\"date\"]) + datetime.timedelta(days=config[\"min_age_in_days\"]), \"on next daily backup\")\n\t\t\tbak[\"deleted_in\"] = deleted_in\n\n\treturn {\n\t\t\"backups\": backups,\n\t\t\"unmatched_file_size\": unmatched_file_size,\n\t}\n\ndef should_force_full(config, env):\n\t# Force a full backup when the total size of the increments\n\t# since the last full backup is greater than half the size\n\t# of that full backup.\n\tinc_size = 0\n\t# Check if day of week is a weekend day\n\tweekend = date.today().weekday()>=5\n\tfor bak in backup_status(env)[\"backups\"]:\n\t\tif not bak[\"full\"]:\n\t\t\t# Scan through the incremental backups cumulating\n\t\t\t# size...\n\t\t\tinc_size += bak[\"size\"]\n\t\telse:\n\t\t\t# ...until we reach the most recent full backup.\n\t\t\t# Return if we should to a full backup, which is based\n\t\t\t# on whether it is a weekend day, the size of the\n\t\t\t# increments relative to the full backup, as well as\n\t\t\t# the age of the full backup.\n\t\t\tif weekend:\n\t\t\t\tif inc_size > .5*bak[\"size\"]:\n\t\t\t\t\treturn True\n\t\t\t\tif dateutil.parser.parse(bak[\"date\"]) + datetime.timedelta(days=config[\"min_age_in_days\"]*10+1) < datetime.datetime.now(dateutil.tz.tzlocal()):\n\t\t\t\t\treturn True\n\t\t\treturn False\n\t# If we got here there are no (full) backups, so make one.\n\treturn True\n\ndef get_passphrase(env):\n\t# Get the encryption passphrase. secret_key.txt is 2048 random\n\t# bits base64-encoded and with line breaks every 65 characters.\n\t# gpg will only take the first line of text, so sanity check that\n\t# that line is long enough to be a reasonable passphrase. It\n\t# only needs to be 43 base64-characters to match AES256's key\n\t# length of 32 bytes.\n\tbackup_root = os.path.join(env[\"STORAGE_ROOT\"], 'backup')\n\twith open(os.path.join(backup_root, 'secret_key.txt'), encoding=\"utf-8\") as f:\n\t\tpassphrase = f.readline().strip()\n\tif len(passphrase) < 43: raise Exception(\"secret_key.txt's first line is too short!\")\n\n\treturn passphrase\n\ndef get_duplicity_target_url(config):\n\ttarget = config[\"target\"]\n\n\tif get_target_type(config) == \"s3\":\n\t\tfrom urllib.parse import urlsplit, urlunsplit\n\t\ttarget = list(urlsplit(target))\n\n\t\t# Although we store the S3 hostname in the target URL,\n\t\t# duplicity no longer accepts it in the target URL. The hostname in\n\t\t# the target URL must be the bucket name. The hostname is passed\n\t\t# via get_duplicity_additional_args. Move the first part of the\n\t\t# path (the bucket name) into the hostname URL component, and leave\n\t\t# the rest for the path. (The S3 region name is also stored in the\n\t\t# hostname part of the URL, in the username portion, which we also\n\t\t# have to drop here).\n\t\ttarget[1], target[2] = target[2].lstrip('/').split('/', 1)\n\n\t\ttarget = urlunsplit(target)\n\n\treturn target\n\ndef get_duplicity_additional_args(env):\n\tconfig = get_backup_config(env)\n\n\tif get_target_type(config) == 'rsync':\n\t\t# Extract a port number for the ssh transport.  Duplicity accepts the\n\t\t# optional port number syntax in the target, but it doesn't appear to act\n\t\t# on it, so we set the ssh port explicitly via the duplicity options.\n\t\tfrom urllib.parse import urlsplit\n\t\ttry:\n\t\t\tport = urlsplit(config[\"target\"]).port\n\t\texcept ValueError:\n\t\t\tport = 22\n\t\tif port is None:\n\t\t\tport = 22\n\n\t\treturn [\n\t\t\tf\"--ssh-options='-i /root/.ssh/id_rsa_miab -p {port}'\",\n\t\t\tf\"--rsync-options='-e \\\"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p {port} -i /root/.ssh/id_rsa_miab\\\"'\",\n\t\t]\n\tif get_target_type(config) == 's3':\n\t\t# See note about hostname in get_duplicity_target_url.\n\t\t# The region name, which is required by some non-AWS endpoints,\n\t\t# is saved inside the username portion of the URL.\n\t\tfrom urllib.parse import urlsplit, urlunsplit\n\t\ttarget = urlsplit(config[\"target\"])\n\t\tendpoint_url = urlunsplit((\"https\", target.hostname, '', '', ''))\n\t\targs = [\"--s3-endpoint-url\", endpoint_url]\n\t\tif target.username: # region name is stuffed here\n\t\t\targs += [\"--s3-region-name\", target.username]\n\t\treturn args\n\n\treturn []\n\ndef get_duplicity_env_vars(env):\n\tconfig = get_backup_config(env)\n\n\tenv = { \"PASSPHRASE\" : get_passphrase(env) }\n\n\tif get_target_type(config) == 's3':\n\t\tenv[\"AWS_ACCESS_KEY_ID\"] = config[\"target_user\"]\n\t\tenv[\"AWS_SECRET_ACCESS_KEY\"] = config[\"target_pass\"]\n\t\tenv[\"AWS_REQUEST_CHECKSUM_CALCULATION\"] = \"WHEN_REQUIRED\"\n\t\tenv[\"AWS_RESPONSE_CHECKSUM_VALIDATION\"] = \"WHEN_REQUIRED\"\n\n\treturn env\n\ndef get_target_type(config):\n\treturn config[\"target\"].split(\":\")[0]\n\ndef perform_backup(full_backup):\n\tenv = load_environment()\n\n\t# Create an global exclusive lock so that the backup script\n\t# cannot be run more than one.\n\tLock(die=True).forever()\n\n\tconfig = get_backup_config(env)\n\tbackup_root = os.path.join(env[\"STORAGE_ROOT\"], 'backup')\n\tbackup_cache_dir = os.path.join(backup_root, 'cache')\n\tbackup_dir = os.path.join(backup_root, 'encrypted')\n\n\t# Are backups disabled?\n\tif config[\"target\"] == \"off\":\n\t\treturn\n\n\t# On the first run, always do a full backup. Incremental\n\t# will fail. Otherwise do a full backup when the size of\n\t# the increments since the most recent full backup are\n\t# large.\n\ttry:\n\t\tfull_backup = full_backup or should_force_full(config, env)\n\texcept Exception as e:\n\t\t# This was the first call to duplicity, and there might\n\t\t# be an error already.\n\t\tprint(e)\n\t\tsys.exit(1)\n\n\t# Stop services.\n\tdef service_command(service, command, quit=None):\n\t\t# Execute silently, but if there is an error then display the output & exit.\n\t\tcode, ret = shell('check_output', [\"/usr/sbin/service\", service, command], capture_stderr=True, trap=True)\n\t\tif code != 0:\n\t\t\tprint(ret)\n\t\t\tif quit:\n\t\t\t\tsys.exit(code)\n\n\tservice_command(\"php8.0-fpm\", \"stop\", quit=True)\n\tservice_command(\"postfix\", \"stop\", quit=True)\n\tservice_command(\"dovecot\", \"stop\", quit=True)\n\tservice_command(\"postgrey\", \"stop\", quit=True)\n\n\t# Execute a pre-backup script that copies files outside the homedir.\n\t# Run as the STORAGE_USER user, not as root. Pass our settings in\n\t# environment variables so the script has access to STORAGE_ROOT.\n\tpre_script = os.path.join(backup_root, 'before-backup')\n\tif os.path.exists(pre_script):\n\t\tshell('check_call',\n\t\t\t['su', env['STORAGE_USER'], '-c', pre_script, config[\"target\"]],\n\t\t\tenv=env)\n\n\t# Run a backup of STORAGE_ROOT (but excluding the backups themselves!).\n\t# --allow-source-mismatch is needed in case the box's hostname is changed\n\t# after the first backup. See #396.\n\ttry:\n\t\tshell('check_call', [\n\t\t\t\"/usr/bin/duplicity\",\n\t\t\t\"full\" if full_backup else \"incr\",\n\t\t\t\"--verbosity\", \"warning\", \"--no-print-statistics\",\n\t\t\t\"--archive-dir\", backup_cache_dir,\n\t\t\t\"--exclude\", backup_root,\n\t\t\t\"--exclude\", os.path.join(env[\"STORAGE_ROOT\"], \"owncloud-backup\"),\n\t\t\t\"--volsize\", \"250\",\n\t\t\t\"--gpg-options\", \"'--cipher-algo=AES256'\",\n\t\t\t\"--allow-source-mismatch\",\n\t\t\t*get_duplicity_additional_args(env),\n\t\t\tenv[\"STORAGE_ROOT\"],\n\t\t\tget_duplicity_target_url(config),\n\t\t\t],\n\t\t\tget_duplicity_env_vars(env))\n\tfinally:\n\t\t# Start services again.\n\t\tservice_command(\"postgrey\", \"start\", quit=False)\n\t\tservice_command(\"dovecot\", \"start\", quit=False)\n\t\tservice_command(\"postfix\", \"start\", quit=False)\n\t\tservice_command(\"php8.0-fpm\", \"start\", quit=False)\n\n\t# Remove old backups. This deletes all backup data no longer needed\n\t# from more than 3 days ago.\n\tshell('check_call', [\n\t\t\"/usr/bin/duplicity\",\n\t\t\"remove-older-than\",\n\t\t\"%dD\" % config[\"min_age_in_days\"],\n\t\t\"--verbosity\", \"error\",\n\t\t\"--archive-dir\", backup_cache_dir,\n\t\t\"--force\",\n\t\t*get_duplicity_additional_args(env),\n\t\tget_duplicity_target_url(config)\n\t\t],\n\t\tget_duplicity_env_vars(env))\n\n\t# From duplicity's manual:\n\t# \"This should only be necessary after a duplicity session fails or is\n\t# aborted prematurely.\"\n\t# That may be unlikely here but we may as well ensure we tidy up if\n\t# that does happen - it might just have been a poorly timed reboot.\n\tshell('check_call', [\n\t\t\"/usr/bin/duplicity\",\n\t\t\"cleanup\",\n\t\t\"--verbosity\", \"error\",\n\t\t\"--archive-dir\", backup_cache_dir,\n\t\t\"--force\",\n\t\t*get_duplicity_additional_args(env),\n\t\tget_duplicity_target_url(config)\n\t\t],\n\t\tget_duplicity_env_vars(env))\n\n\t# Change ownership of backups to the user-data user, so that the after-bcakup\n\t# script can access them.\n\tif get_target_type(config) == 'file':\n\t\tshell('check_call', [\"/bin/chown\", \"-R\", env[\"STORAGE_USER\"], backup_dir])\n\n\t# Execute a post-backup script that does the copying to a remote server.\n\t# Run as the STORAGE_USER user, not as root. Pass our settings in\n\t# environment variables so the script has access to STORAGE_ROOT.\n\tpost_script = os.path.join(backup_root, 'after-backup')\n\tif os.path.exists(post_script):\n\t\tshell('check_call',\n\t\t\t['su', env['STORAGE_USER'], '-c', post_script, config[\"target\"]],\n\t\t\tenv=env)\n\n\t# Our nightly cron job executes system status checks immediately after this\n\t# backup. Since it checks that dovecot and postfix are running, block for a\n\t# bit (maximum of 10 seconds each) to give each a chance to finish restarting\n\t# before the status checks might catch them down. See #381.\n\twait_for_service(25, True, env, 10)\n\twait_for_service(993, True, env, 10)\n\ndef run_duplicity_verification():\n\tenv = load_environment()\n\tbackup_root = os.path.join(env[\"STORAGE_ROOT\"], 'backup')\n\tconfig = get_backup_config(env)\n\tbackup_cache_dir = os.path.join(backup_root, 'cache')\n\n\tshell('check_call', [\n\t\t\"/usr/bin/duplicity\",\n\t\t\"--verbosity\", \"info\",\n\t\t\"verify\",\n\t\t\"--compare-data\",\n\t\t\"--archive-dir\", backup_cache_dir,\n\t\t\"--exclude\", backup_root,\n\t\t\"--exclude\", os.path.join(env[\"STORAGE_ROOT\"], \"owncloud-backup\"),\n\t\t*get_duplicity_additional_args(env),\n\t\tget_duplicity_target_url(config),\n\t\tenv[\"STORAGE_ROOT\"],\n\t], get_duplicity_env_vars(env))\n\ndef run_duplicity_restore(args):\n\tenv = load_environment()\n\tconfig = get_backup_config(env)\n\tbackup_cache_dir = os.path.join(env[\"STORAGE_ROOT\"], 'backup', 'cache')\n\tshell('check_call', [\n\t\t\"/usr/bin/duplicity\",\n\t\t\"restore\",\n\t\t\"--archive-dir\", backup_cache_dir,\n\t\t*get_duplicity_additional_args(env),\n\t\tget_duplicity_target_url(config),\n\t\t*args],\n\t\tget_duplicity_env_vars(env))\n\ndef print_duplicity_command():\n\timport shlex\n\tenv = load_environment()\n\tconfig = get_backup_config(env)\n\tbackup_cache_dir = os.path.join(env[\"STORAGE_ROOT\"], 'backup', 'cache')\n\tfor k, v in get_duplicity_env_vars(env).items():\n\t\tprint(f\"export {k}={shlex.quote(v)}\")\n\tprint(\"duplicity\", \"{command}\", shlex.join([\n\t\t\"--archive-dir\", backup_cache_dir,\n\t\t*get_duplicity_additional_args(env),\n\t\tget_duplicity_target_url(config)\n\t\t]))\n\ndef list_target_files(config):\n\timport urllib.parse\n\ttry:\n\t\ttarget = urllib.parse.urlparse(config[\"target\"])\n\texcept ValueError:\n\t\treturn \"invalid target\"\n\n\tif target.scheme == \"file\":\n\t\treturn [(fn, os.path.getsize(os.path.join(target.path, fn))) for fn in os.listdir(target.path)]\n\n\tif target.scheme == \"rsync\":\n\t\trsync_fn_size_re = re.compile(r'.*    ([^ ]*) [^ ]* [^ ]* (.*)')\n\t\trsync_target = '{host}:{path}'\n\n\t\t# Strip off any trailing port specifier because it's not valid in rsync's\n\t\t# DEST syntax.  Explicitly set the port number for the ssh transport.\n\t\tuser_host, *_ = target.netloc.rsplit(':', 1)\n\t\ttry:\n\t\t\tport = target.port\n\t\texcept ValueError:\n\t\t\t port = 22\n\t\tif port is None:\n\t\t\tport = 22\n\n\t\ttarget_path = target.path\n\t\tif not target_path.endswith('/'):\n\t\t\ttarget_path += '/'\n\t\ttarget_path = target_path.removeprefix('/')\n\n\t\trsync_command = [ 'rsync',\n\t\t\t\t\t'-e',\n\t\t\t\t\tf'/usr/bin/ssh -i /root/.ssh/id_rsa_miab -oStrictHostKeyChecking=no -oBatchMode=yes -p {port}',\n\t\t\t\t\t'--list-only',\n\t\t\t\t\t'-r',\n\t\t\t\t\trsync_target.format(\n\t\t\t\t\t\thost=user_host,\n\t\t\t\t\t\tpath=target_path)\n\t\t\t\t]\n\n\t\tcode, listing = shell('check_output', rsync_command, trap=True, capture_stderr=True)\n\t\tif code == 0:\n\t\t\tret = []\n\t\t\tfor l in listing.split('\\n'):\n\t\t\t\tmatch = rsync_fn_size_re.match(l)\n\t\t\t\tif match:\n\t\t\t\t\tret.append( (match.groups()[1], int(match.groups()[0].replace(',',''))) )\n\t\t\treturn ret\n\t\tif 'Permission denied (publickey).' in listing:\n\t\t\treason = \"Invalid user or check you correctly copied the SSH key.\"\n\t\telif 'No such file or directory' in listing:\n\t\t\treason = f\"Provided path {target_path} is invalid.\"\n\t\telif 'Network is unreachable' in listing:\n\t\t\treason = f\"The IP address {target.hostname} is unreachable.\"\n\t\telif 'Could not resolve hostname' in listing:\n\t\t\treason = f\"The hostname {target.hostname} cannot be resolved.\"\n\t\telse:\n\t\t\treason = (\"Unknown error.\"\n\t\t\t\t\t\"Please check running 'management/backup.py --verify'\"\n\t\t\t\t\t\"from mailinabox sources to debug the issue.\")\n\t\tmsg = f\"Connection to rsync host failed: {reason}\"\n\t\traise ValueError(msg)\n\n\tif target.scheme == \"s3\":\n\t\timport boto3.s3\n\t\tfrom botocore.exceptions import ClientError\n\n\t\t# separate bucket from path in target\n\t\tbucket = target.path[1:].split('/')[0]\n\t\tpath = '/'.join(target.path[1:].split('/')[1:]) + '/'\n\n\t\t# If no prefix is specified, set the path to '', otherwise boto won't list the files\n\t\tif path == '/':\n\t\t\tpath = ''\n\n\t\tif bucket == \"\":\n\t\t\tmsg = \"Enter an S3 bucket name.\"\n\t\t\traise ValueError(msg)\n\n\t\t# connect to the region & bucket\n\t\ttry:\n\t\t\tif config['target_user'] == \"\" and config['target_pass'] == \"\":\n\t\t\t\ts3 = boto3.client('s3', endpoint_url=f'https://{target.hostname}')\n\t\t\telse:\n\t\t\t\ts3 = boto3.client('s3', \\\n\t\t\t\t\tendpoint_url=f'https://{target.hostname}', \\\n\t\t\t\t\taws_access_key_id=config['target_user'], \\\n\t\t\t\t\taws_secret_access_key=config['target_pass'])\n\t\t\tbucket_objects = s3.list_objects_v2(Bucket=bucket, Prefix=path)['Contents']\n\t\t\tbackup_list = [(key['Key'][len(path):], key['Size']) for key in bucket_objects]\n\t\texcept ClientError as e:\n\t\t\traise ValueError(e)\n\t\treturn backup_list\n\tif target.scheme == 'b2':\n\t\tfrom b2sdk.v1 import InMemoryAccountInfo, B2Api\n\t\tfrom b2sdk.v1.exception import NonExistentBucket\n\t\tinfo = InMemoryAccountInfo()\n\t\tb2_api = B2Api(info)\n\n\t\t# Extract information from target\n\t\tb2_application_keyid = target.netloc[:target.netloc.index(':')]\n\t\tb2_application_key = urllib.parse.unquote(target.netloc[target.netloc.index(':')+1:target.netloc.index('@')])\n\t\tb2_bucket = target.netloc[target.netloc.index('@')+1:]\n\n\t\ttry:\n\t\t\tb2_api.authorize_account(\"production\", b2_application_keyid, b2_application_key)\n\t\t\tbucket = b2_api.get_bucket_by_name(b2_bucket)\n\t\texcept NonExistentBucket:\n\t\t\tmsg = \"B2 Bucket does not exist. Please double check your information!\"\n\t\t\traise ValueError(msg)\n\t\treturn [(key.file_name, key.size) for key, _ in bucket.ls()]\n\n\traise ValueError(config[\"target\"])\n\n\ndef backup_set_custom(env, target, target_user, target_pass, min_age):\n\tconfig = get_backup_config(env, for_save=True)\n\n\t# min_age must be an int\n\tif isinstance(min_age, str):\n\t\tmin_age = int(min_age)\n\n\tconfig[\"target\"] = target\n\tconfig[\"target_user\"] = target_user\n\tconfig[\"target_pass\"] = target_pass\n\tconfig[\"min_age_in_days\"] = min_age\n\n\t# Validate.\n\ttry:\n\t\tif config[\"target\"] not in {\"off\", \"local\"}:\n\t\t\t# these aren't supported by the following function, which expects a full url in the target key,\n\t\t\t# which is what is there except when loading the config prior to saving\n\t\t\tlist_target_files(config)\n\texcept ValueError as e:\n\t\treturn str(e)\n\n\twrite_backup_config(env, config)\n\n\treturn \"OK\"\n\ndef get_backup_config(env, for_save=False, for_ui=False):\n\tbackup_root = os.path.join(env[\"STORAGE_ROOT\"], 'backup')\n\n\t# Defaults.\n\tconfig = {\n\t\t\"min_age_in_days\": 3,\n\t\t\"target\": \"local\",\n\t}\n\n\t# Merge in anything written to custom.yaml.\n\ttry:\n\t\twith open(os.path.join(backup_root, 'custom.yaml'), encoding=\"utf-8\") as f:\n\t\t\tcustom_config = rtyaml.load(f)\n\t\tif not isinstance(custom_config, dict): raise ValueError # caught below\n\t\tconfig.update(custom_config)\n\texcept:\n\t\tpass\n\n\t# When updating config.yaml, don't do any further processing on what we find.\n\tif for_save:\n\t\treturn config\n\n\t# When passing this back to the admin to show the current settings, do not include\n\t# authentication details. The user will have to re-enter it.\n\tif for_ui:\n\t\tfor field in (\"target_user\", \"target_pass\"):\n\t\t\tconfig.pop(field, None)\n\n\t# helper fields for the admin\n\tconfig[\"file_target_directory\"] = os.path.join(backup_root, 'encrypted')\n\tconfig[\"enc_pw_file\"] = os.path.join(backup_root, 'secret_key.txt')\n\tif config[\"target\"] == \"local\":\n\t\t# Expand to the full URL.\n\t\tconfig[\"target\"] = \"file://\" + config[\"file_target_directory\"]\n\tssh_pub_key = os.path.join('/root', '.ssh', 'id_rsa_miab.pub')\n\tif os.path.exists(ssh_pub_key):\n\t\twith open(ssh_pub_key, encoding=\"utf-8\") as f:\n\t\t\tconfig[\"ssh_pub_key\"] = f.read()\n\n\treturn config\n\ndef write_backup_config(env, newconfig):\n\tbackup_root = os.path.join(env[\"STORAGE_ROOT\"], 'backup')\n\twith open(os.path.join(backup_root, 'custom.yaml'), \"w\", encoding=\"utf-8\") as f:\n\t\tf.write(rtyaml.dump(newconfig))\n\nif __name__ == \"__main__\":\n\tif sys.argv[-1] == \"--verify\":\n\t\t# Run duplicity's verification command to check a) the backup files\n\t\t# are readable, and b) report if they are up to date.\n\t\trun_duplicity_verification()\n\n\telif sys.argv[-1] == \"--list\":\n\t\t# List the saved backup files.\n\t\tfor fn, size in list_target_files(get_backup_config(load_environment())):\n\t\t\tprint(f\"{fn}\\t{size}\")\n\n\telif sys.argv[-1] == \"--status\":\n\t\t# Show backup status.\n\t\tret = backup_status(load_environment())\n\t\tprint(rtyaml.dump(ret[\"backups\"]))\n\t\tprint(\"Storage for unmatched files:\", ret[\"unmatched_file_size\"])\n\n\telif len(sys.argv) >= 2 and sys.argv[1] == \"--restore\":\n\t\t# Run duplicity restore. Rest of command line passed as arguments\n\t\t# to duplicity. The restore path should be specified.\n\t\trun_duplicity_restore(sys.argv[2:])\n\n\telif sys.argv[-1] == \"--duplicity-command\":\n\t\tprint_duplicity_command()\n\n\telse:\n\t\t# Perform a backup. Add --full to force a full backup rather than\n\t\t# possibly performing an incremental backup.\n\t\tfull_backup = \"--full\" in sys.argv\n\t\tperform_backup(full_backup)\n"
  },
  {
    "path": "management/cli.py",
    "content": "#!/usr/bin/python3\n#\n# This is a command-line script for calling management APIs\n# on the Mail-in-a-Box control panel backend. The script\n# reads /var/lib/mailinabox/api.key for the backend's\n# root API key. This file is readable only by root, so this\n# tool can only be used as root.\n\nimport sys, getpass, urllib.request, urllib.error, json, csv\nimport contextlib\n\ndef mgmt(cmd, data=None, is_json=False):\n\t# The base URL for the management daemon. (Listens on IPv4 only.)\n\tmgmt_uri = 'http://127.0.0.1:10222'\n\n\tsetup_key_auth(mgmt_uri)\n\n\treq = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode(\"utf8\") if data else None)\n\ttry:\n\t\tresponse = urllib.request.urlopen(req)\n\texcept urllib.error.HTTPError as e:\n\t\tif e.code == 401:\n\t\t\twith contextlib.suppress(Exception):\n\t\t\t\tprint(e.read().decode(\"utf8\"))\n\t\t\tprint(\"The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.\", file=sys.stderr)\n\t\telif hasattr(e, 'read'):\n\t\t\tprint(e.read().decode('utf8'), file=sys.stderr)\n\t\telse:\n\t\t\tprint(e, file=sys.stderr)\n\t\tsys.exit(1)\n\tresp = response.read().decode('utf8')\n\tif is_json: resp = json.loads(resp)\n\treturn resp\n\ndef read_password():\n    while True:\n        first = getpass.getpass('password: ')\n        if len(first) < 8:\n            print(\"Passwords must be at least eight characters.\")\n            continue\n        second = getpass.getpass(' (again): ')\n        if first != second:\n            print(\"Passwords not the same. Try again.\")\n            continue\n        break\n    return first\n\ndef setup_key_auth(mgmt_uri):\n\twith open('/var/lib/mailinabox/api.key', encoding='utf-8') as f:\n\t\tkey = f.read().strip()\n\n\tauth_handler = urllib.request.HTTPBasicAuthHandler()\n\tauth_handler.add_password(\n\t\trealm='Mail-in-a-Box Management Server',\n\t\turi=mgmt_uri,\n\t\tuser=key,\n\t\tpasswd='')\n\topener = urllib.request.build_opener(auth_handler)\n\turllib.request.install_opener(opener)\n\nif len(sys.argv) < 2:\n\tprint(\"\"\"Usage:\n  {cli} user                                     (lists users)\n  {cli} user add user@domain.com [password]\n  {cli} user password user@domain.com [password]\n  {cli} user remove user@domain.com\n  {cli} user make-admin user@domain.com\n  {cli} user quota user@domain [new-quota]       (get or set user quota)\n  {cli} user remove-admin user@domain.com\n  {cli} user admins                              (lists admins)\n  {cli} user mfa show user@domain.com            (shows MFA devices for user, if any)\n  {cli} user mfa disable user@domain.com [id]    (disables MFA for user)\n  {cli} alias                                    (lists aliases)\n  {cli} alias add incoming.name@domain.com sent.to@other.domain.com\n  {cli} alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'\n  {cli} alias remove incoming.name@domain.com\n\nRemoving a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.\n\"\"\".format(\n\tcli=\"management/cli.py\"\n\t\t))\n\nelif sys.argv[1] == \"user\" and len(sys.argv) == 2:\n\t# Dump a list of users, one per line. Mark admins with an asterisk.\n\tusers = mgmt(\"/mail/users?format=json\", is_json=True)\n\tfor domain in users:\n\t\tfor user in domain[\"users\"]:\n\t\t\tif user['status'] == 'inactive': continue\n\t\t\tprint(user['email'], end='')\n\t\t\tif \"admin\" in user['privileges']:\n\t\t\t\tprint(\"*\", end='')\n\t\t\tprint()\n\nelif sys.argv[1] == \"user\" and sys.argv[2] in {\"add\", \"password\"}:\n\tif len(sys.argv) < 5:\n\t\temail = input('email: ') if len(sys.argv) < 4 else sys.argv[3]\n\t\tpw = read_password()\n\telse:\n\t\temail, pw = sys.argv[3:5]\n\n\tif sys.argv[2] == \"add\":\n\t\tprint(mgmt(\"/mail/users/add\", { \"email\": email, \"password\": pw }))\n\telif sys.argv[2] == \"password\":\n\t\tprint(mgmt(\"/mail/users/password\", { \"email\": email, \"password\": pw }))\n\nelif sys.argv[1] == \"user\" and sys.argv[2] == \"remove\" and len(sys.argv) == 4:\n\tprint(mgmt(\"/mail/users/remove\", { \"email\": sys.argv[3] }))\n\nelif sys.argv[1] == \"user\" and sys.argv[2] in {\"make-admin\", \"remove-admin\"} and len(sys.argv) == 4:\n\taction = 'add' if sys.argv[2] == 'make-admin' else 'remove'\n\tprint(mgmt(\"/mail/users/privileges/\" + action, { \"email\": sys.argv[3], \"privilege\": \"admin\" }))\n\nelif sys.argv[1] == \"user\" and sys.argv[2] == \"admins\":\n\t# Dump a list of admin users.\n\tusers = mgmt(\"/mail/users?format=json\", is_json=True)\n\tfor domain in users:\n\t\tfor user in domain[\"users\"]:\n\t\t\tif \"admin\" in user['privileges']:\n\t\t\t\tprint(user['email'])\n\nelif sys.argv[1] == \"user\" and sys.argv[2] == \"quota\" and len(sys.argv) == 4:\n\t# Get a user's quota\n\tprint(mgmt(f\"/mail/users/quota?text=1&email={sys.argv[3]}\"))\n\nelif sys.argv[1] == \"user\" and sys.argv[2] == \"quota\" and len(sys.argv) == 5:\n\t# Set a user's quota\n\tusers = mgmt(\"/mail/users/quota\", { \"email\": sys.argv[3], \"quota\": sys.argv[4] })\n\nelif sys.argv[1] == \"user\" and len(sys.argv) == 5 and sys.argv[2:4] == [\"mfa\", \"show\"]:\n\t# Show MFA status for a user.\n\tstatus = mgmt(\"/mfa/status\", { \"user\": sys.argv[4] }, is_json=True)\n\tW = csv.writer(sys.stdout)\n\tW.writerow([\"id\", \"type\", \"label\"])\n\tfor mfa in status[\"enabled_mfa\"]:\n\t\tW.writerow([mfa[\"id\"], mfa[\"type\"], mfa[\"label\"]])\n\nelif sys.argv[1] == \"user\" and len(sys.argv) in {5, 6} and sys.argv[2:4] == [\"mfa\", \"disable\"]:\n\t# Disable MFA (all or a particular device) for a user.\n\tprint(mgmt(\"/mfa/disable\", { \"user\": sys.argv[4], \"mfa-id\": sys.argv[5] if len(sys.argv) == 6 else None }))\n\nelif sys.argv[1] == \"alias\" and len(sys.argv) == 2:\n\tprint(mgmt(\"/mail/aliases\"))\n\nelif sys.argv[1] == \"alias\" and sys.argv[2] == \"add\" and len(sys.argv) == 5:\n\tprint(mgmt(\"/mail/aliases/add\", { \"address\": sys.argv[3], \"forwards_to\": sys.argv[4] }))\n\nelif sys.argv[1] == \"alias\" and sys.argv[2] == \"remove\" and len(sys.argv) == 4:\n\tprint(mgmt(\"/mail/aliases/remove\", { \"address\": sys.argv[3] }))\n\nelse:\n\tprint(\"Invalid command-line arguments.\")\n\tsys.exit(1)\n"
  },
  {
    "path": "management/csr_country_codes.tsv",
    "content": "# This list is derived from https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2.\n# The columns are ISO_3166-1_alpha-2 code, display name, Wikipedia page name.\n# The top 21 countries by number of Internet users are grouped first, see\n# https://en.wikipedia.org/wiki/List_of_countries_by_number_of_Internet_users.\nCN\tChina\t\nIN\tIndia\t\nUS\tUnited States\t\nJP\tJapan\t\nBR\tBrazil\t\nRU\tRussian Federation\tRussia\nDE\tGermany\t\nNG\tNigeria\t\nGB\tUnited Kingdom\t\nFR\tFrance\t\nMX\tMexico\t\nEG\tEgypt\t\nKR\tSouth Korea\nVN\tVietnam\nID\tIndonesia\t\nPH\tPhilippines\t\nTR\tTurkey\t\nIT\tItaly\t\nPK\tPakistan\t\nES\tSpain\t\nCA\tCanada\t\nAD\tAndorra\t\nAE\tUnited Arab Emirates\t\nAF\tAfghanistan\t\nAG\tAntigua and Barbuda\t\nAI\tAnguilla\t\nAL\tAlbania\t\nAM\tArmenia\t\nAO\tAngola\t\nAQ\tAntarctica\t\nAR\tArgentina\t\nAS\tAmerican Samoa\t\nAT\tAustria\t\nAU\tAustralia\t\nAW\tAruba\t\nAX\tÅland Islands\t\nAZ\tAzerbaijan\t\nBA\tBosnia and Herzegovina\t\nBB\tBarbados\t\nBD\tBangladesh\t\nBE\tBelgium\t\nBF\tBurkina Faso\t\nBG\tBulgaria\t\nBH\tBahrain\t\nBI\tBurundi\t\nBJ\tBenin\t\nBL\tSaint Barthélemy\t\nBM\tBermuda\t\nBN\tBrunei\nBO\tBolivia\nBQ\tBonaire, Sint Eustatius and Saba\tCaribbean Netherlands\nBS\tBahamas\tThe Bahamas\nBT\tBhutan\t\nBV\tBouvet Island\t\nBW\tBotswana\t\nBY\tBelarus\t\nBZ\tBelize\t\nCC\tCocos (Keeling) Islands\t\nCD\tCongo, the Democratic Republic of the\tDemocratic Republic of the Congo\nCF\tCentral African Republic\t\nCG\tCongo\tRepublic of the Congo\nCH\tSwitzerland\t\nCI\tCôte d'Ivoire\t\nCK\tCook Islands\t\nCL\tChile\t\nCM\tCameroon\t\nCO\tColombia\t\nCR\tCosta Rica\t\nCU\tCuba\t\nCV\tCabo Verde\t\nCW\tCuraçao\t\nCX\tChristmas Island\t\nCY\tCyprus\t\nCZ\tCzech Republic\t\nDJ\tDjibouti\t\nDK\tDenmark\t\nDM\tDominica\t\nDO\tDominican Republic\t\nDZ\tAlgeria\t\nEC\tEcuador\t\nEE\tEstonia\t\nEH\tWestern Sahara\t\nER\tEritrea\t\nET\tEthiopia\t\nFI\tFinland\t\nFJ\tFiji\t\nFK\tFalkland Islands (Malvinas)\tFalkland Islands\nFM\tFederated States of Micronesia\nFO\tFaroe Islands\t\nGA\tGabon\t\nGD\tGrenada\t\nGE\tGeorgia\tGeorgia (country)\nGF\tFrench Guiana\t\nGG\tGuernsey\t\nGH\tGhana\t\nGI\tGibraltar\t\nGL\tGreenland\t\nGM\tGambia\tThe Gambia\nGN\tGuinea\t\nGP\tGuadeloupe\t\nGQ\tEquatorial Guinea\t\nGR\tGreece\t\nGS\tSouth Georgia and the South Sandwich Islands\t\nGT\tGuatemala\t\nGU\tGuam\t\nGW\tGuinea-Bissau\t\nGY\tGuyana\t\nHK\tHong Kong\t\nHM\tHeard Island and McDonald Islands\t\nHN\tHonduras\t\nHR\tCroatia\t\nHT\tHaiti\t\nHU\tHungary\t\nIE\tIreland\tRepublic of Ireland\nIL\tIsrael\t\nIM\tIsle of Man\t\nIO\tBritish Indian Ocean Territory\t\nIQ\tIraq\t\nIR\tIran\nIS\tIceland\t\nJE\tJersey\t\nJM\tJamaica\t\nJO\tJordan\t\nKE\tKenya\t\nKG\tKyrgyzstan\t\nKH\tCambodia\t\nKI\tKiribati\t\nKM\tComoros\t\nKN\tSaint Kitts and Nevis\t\nKP\tNorth Korea\nKW\tKuwait\t\nKY\tCayman Islands\t\nKZ\tKazakhstan\t\nLA\tLaos\nLB\tLebanon\t\nLC\tSaint Lucia\t\nLI\tLiechtenstein\t\nLK\tSri Lanka\t\nLR\tLiberia\t\nLS\tLesotho\t\nLT\tLithuania\t\nLU\tLuxembourg\t\nLV\tLatvia\t\nLY\tLibya\t\nMA\tMorocco\t\nMC\tMonaco\t\nMD\tMoldova\nME\tMontenegro\t\nMF\tSaint Martin (French part)\tCollectivity of Saint Martin\nMG\tMadagascar\t\nMH\tMarshall Islands\t\nMK\tMacedonia\tRepublic of Macedonia\nML\tMali\t\nMM\tMyanmar\t\nMN\tMongolia\t\nMO\tMacao\tMacau\nMP\tNorthern Mariana Islands\t\nMQ\tMartinique\t\nMR\tMauritania\t\nMS\tMontserrat\t\nMT\tMalta\t\nMU\tMauritius\t\nMV\tMaldives\t\nMW\tMalawi\t\nMY\tMalaysia\t\nMZ\tMozambique\t\nNA\tNamibia\t\nNC\tNew Caledonia\t\nNE\tNiger\t\nNF\tNorfolk Island\t\nNI\tNicaragua\t\nNL\tNetherlands\t\nNO\tNorway\t\nNP\tNepal\t\nNR\tNauru\t\nNU\tNiue\t\nNZ\tNew Zealand\t\nOM\tOman\t\nPA\tPanama\t\nPE\tPeru\t\nPF\tFrench Polynesia\t\nPG\tPapua New Guinea\t\nPL\tPoland\t\nPM\tSaint Pierre and Miquelon\t\nPN\tPitcairn\tPitcairn Islands\nPR\tPuerto Rico\t\nPS\tPalestine\tState of Palestine\nPT\tPortugal\t\nPW\tPalau\t\nPY\tParaguay\t\nQA\tQatar\t\nRE\tRéunion\t\nRO\tRomania\t\nRS\tSerbia\t\nRW\tRwanda\t\nSA\tSaudi Arabia\t\nSB\tSolomon Islands\t\nSC\tSeychelles\t\nSD\tSudan\t\nSE\tSweden\t\nSG\tSingapore\t\nSH\tSaint Helena, Ascension and Tristan da Cunha\t\nSI\tSlovenia\t\nSJ\tSvalbard and Jan Mayen\t\nSK\tSlovakia\t\nSL\tSierra Leone\t\nSM\tSan Marino\t\nSN\tSenegal\t\nSO\tSomalia\t\nSR\tSuriname\t\nSS\tSouth Sudan\t\nST\tSao Tome and Principe\nSV\tEl Salvador\t\nSX\tSint Maarten (Dutch part)\tSint Maarten\nSY\tSyria\nSZ\tSwaziland\t\nTC\tTurks and Caicos Islands\t\nTD\tChad\t\nTF\tFrench Southern Territories\tFrench Southern and Antarctic Lands\nTG\tTogo\t\nTH\tThailand\t\nTJ\tTajikistan\t\nTK\tTokelau\t\nTL\tTimor-Leste\tEast Timor\nTM\tTurkmenistan\t\nTN\tTunisia\t\nTO\tTonga\t\nTT\tTrinidad and Tobago\t\nTV\tTuvalu\t\nTW\tTaiwan\t\nTZ\tTanzania\nUA\tUkraine\t\nUG\tUganda\t\nUM\tUnited States Minor Outlying Islands\t\nUY\tUruguay\t\nUZ\tUzbekistan\t\nVA\tVatican City\nVC\tSaint Vincent and the Grenadines\t\nVE\tVenezuela\nVG\tVirgin Islands, British\tBritish Virgin Islands\nVI\tVirgin Islands, U.S.\tUnited States Virgin Islands\nVU\tVanuatu\t\nWF\tWallis and Futuna\t\nWS\tSamoa\t\nYE\tYemen\t\nYT\tMayotte\t\nZA\tSouth Africa\t\nZM\tZambia\t\nZW\tZimbabwe\t\n"
  },
  {
    "path": "management/daemon.py",
    "content": "#!/usr/local/lib/mailinabox/env/bin/python3\n#\n# The API can be accessed on the command line, e.g. use `curl` like so:\n#    curl --user $(</var/lib/mailinabox/api.key): http://localhost:10222/mail/users\n#\n# During development, you can start the Mail-in-a-Box control panel\n# by running this script, e.g.:\n#\n# service mailinabox stop # stop the system process\n# DEBUG=1 management/daemon.py\n# service mailinabox start # when done debugging, start it up again\n\nimport os, os.path, re, json, time\nimport multiprocessing.pool\n\nfrom functools import wraps\n\nfrom flask import Flask, request, render_template, Response, send_from_directory, make_response\n\nimport auth, utils\nfrom mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user\nfrom mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege\nfrom mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias\nfrom mailconfig import get_mail_quota, set_mail_quota\nfrom mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa\nimport contextlib\n\nenv = utils.load_environment()\n\nauth_service = auth.AuthService()\n\n# We may deploy via a symbolic link, which confuses flask's template finding.\nme = __file__\nwith contextlib.suppress(OSError):\n\tme = os.readlink(__file__)\n\n# for generating CSRs we need a list of country codes\ncsr_country_codes = []\nwith open(os.path.join(os.path.dirname(me), \"csr_country_codes.tsv\"), encoding=\"utf-8\") as f:\n\tfor line in f:\n\t\tif line.strip() == \"\" or line.startswith(\"#\"): continue\n\t\tcode, name = line.strip().split(\"\\t\")[0:2]\n\t\tcsr_country_codes.append((code, name))\n\napp = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), \"templates\")))\n\n# Decorator to protect views that require a user with 'admin' privileges.\ndef authorized_personnel_only(viewfunc):\n\t@wraps(viewfunc)\n\tdef newview(*args, **kwargs):\n\t\t# Authenticate the passed credentials, which is either the API key or a username:password pair\n\t\t# and an optional X-Auth-Token token.\n\t\terror = None\n\t\tprivs = []\n\n\t\ttry:\n\t\t\temail, privs = auth_service.authenticate(request, env)\n\t\texcept ValueError as e:\n\t\t\t# Write a line in the log recording the failed login, unless no authorization header\n\t\t\t# was given which can happen on an initial request before a 403 response.\n\t\t\tif \"Authorization\" in request.headers:\n\t\t\t\tlog_failed_login(request)\n\n\t\t\t# Authentication failed.\n\t\t\terror = str(e)\n\n\t\t# Authorized to access an API view?\n\t\tif \"admin\" in privs:\n\t\t\t# Store the email address of the logged in user so it can be accessed\n\t\t\t# from the API methods that affect the calling user.\n\t\t\trequest.user_email = email\n\t\t\trequest.user_privs = privs\n\n\t\t\t# Call view func.\n\t\t\treturn viewfunc(*args, **kwargs)\n\n\t\tif not error:\n\t\t\terror = \"You are not an administrator.\"\n\n\t\t# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.\n\t\tstatus = 401\n\t\theaders = {\n\t\t\t'WWW-Authenticate': f'Basic realm=\"{auth_service.auth_realm}\"',\n\t\t\t'X-Reason': error,\n\t\t}\n\n\t\tif request.headers.get('X-Requested-With') == 'XMLHttpRequest':\n\t\t\t# Don't issue a 401 to an AJAX request because the user will\n\t\t\t# be prompted for credentials, which is not helpful.\n\t\t\tstatus = 403\n\t\t\theaders = None\n\n\t\tif request.headers.get('Accept') in {None, \"\", \"*/*\"}:\n\t\t\t# Return plain text output.\n\t\t\treturn Response(error+\"\\n\", status=status, mimetype='text/plain', headers=headers)\n\t\t# Return JSON output.\n\t\treturn Response(json.dumps({\n\t\t\t\"status\": \"error\",\n\t\t\t\"reason\": error,\n\t\t\t})+\"\\n\", status=status, mimetype='application/json', headers=headers)\n\n\treturn newview\n\n@app.errorhandler(401)\ndef unauthorized(error):\n\treturn auth_service.make_unauthorized_response()\n\ndef json_response(data, status=200):\n\treturn Response(json.dumps(data, indent=2, sort_keys=True)+'\\n', status=status, mimetype='application/json')\n\n###################################\n\n# Control Panel (unauthenticated views)\n\n@app.route('/')\ndef index():\n\t# Render the control panel. This route does not require user authentication\n\t# so it must be safe!\n\n\tno_users_exist = (len(get_mail_users(env)) == 0)\n\tno_admins_exist = (len(get_admins(env)) == 0)\n\n\timport boto3.s3\n\tbackup_s3_hosts = [(r, f\"s3.{r}.amazonaws.com\") for r in boto3.session.Session().get_available_regions('s3')]\n\n\n\treturn render_template('index.html',\n\t\thostname=env['PRIMARY_HOSTNAME'],\n\t\tstorage_root=env['STORAGE_ROOT'],\n\n\t\tno_users_exist=no_users_exist,\n\t\tno_admins_exist=no_admins_exist,\n\n\t\tbackup_s3_hosts=backup_s3_hosts,\n\t\tcsr_country_codes=csr_country_codes,\n\t)\n\n# Create a session key by checking the username/password in the Authorization header.\n@app.route('/login', methods=[\"POST\"])\ndef login():\n\t# Is the caller authorized?\n\ttry:\n\t\temail, privs = auth_service.authenticate(request, env, login_only=True)\n\texcept ValueError as e:\n\t\tif \"missing-totp-token\" in str(e):\n\t\t\treturn json_response({\n\t\t\t\t\"status\": \"missing-totp-token\",\n\t\t\t\t\"reason\": str(e),\n\t\t\t})\n\t\t# Log the failed login\n\t\tlog_failed_login(request)\n\t\treturn json_response({\n\t\t\t\"status\": \"invalid\",\n\t\t\t\"reason\": str(e),\n\t\t})\n\n\t# Return a new session for the user.\n\tresp = {\n\t\t\"status\": \"ok\",\n\t\t\"email\": email,\n\t\t\"privileges\": privs,\n\t\t\"api_key\": auth_service.create_session_key(email, env, type='login'),\n\t}\n\n\tapp.logger.info(\"New login session created for %s\", email)\n\n\t# Return.\n\treturn json_response(resp)\n\n@app.route('/logout', methods=[\"POST\"])\ndef logout():\n\ttry:\n\t\temail, _ = auth_service.authenticate(request, env, logout=True)\n\t\tapp.logger.info(\"%s logged out\", email)\n\texcept ValueError:\n\t\tpass\n\tfinally:\n\t\treturn json_response({ \"status\": \"ok\" })\n\n# MAIL\n\n@app.route('/mail/users')\n@authorized_personnel_only\ndef mail_users():\n\tif request.args.get(\"format\", \"\") == \"json\":\n\t\treturn json_response(get_mail_users_ex(env, with_archived=True))\n\treturn \"\".join(x+\"\\n\" for x in get_mail_users(env))\n\n@app.route('/mail/users/add', methods=['POST'])\n@authorized_personnel_only\ndef mail_users_add():\n\tquota = request.form.get('quota', '0')\n\ttry:\n\t\treturn add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), quota, env)\n\texcept ValueError as e:\n\t\treturn (str(e), 400)\n\n@app.route('/mail/users/quota', methods=['GET'])\n@authorized_personnel_only\ndef get_mail_users_quota():\n\temail = request.values.get('email', '')\n\tquota = get_mail_quota(email, env)\n\n\tif request.values.get('text'):\n\t\treturn quota\n\n\treturn json_response({\n\t\t\"email\": email,\n\t\t\"quota\": quota\n\t})\n\n@app.route('/mail/users/quota', methods=['POST'])\n@authorized_personnel_only\ndef mail_users_quota():\n\ttry:\n\t\treturn set_mail_quota(request.form.get('email', ''), request.form.get('quota'), env)\n\texcept ValueError as e:\n\t\treturn (str(e), 400)\n\n@app.route('/mail/users/password', methods=['POST'])\n@authorized_personnel_only\ndef mail_users_password():\n\ttry:\n\t\treturn set_mail_password(request.form.get('email', ''), request.form.get('password', ''), env)\n\texcept ValueError as e:\n\t\treturn (str(e), 400)\n\n@app.route('/mail/users/remove', methods=['POST'])\n@authorized_personnel_only\ndef mail_users_remove():\n\treturn remove_mail_user(request.form.get('email', ''), env)\n\n\n@app.route('/mail/users/privileges')\n@authorized_personnel_only\ndef mail_user_privs():\n\tprivs = get_mail_user_privileges(request.args.get('email', ''), env)\n\tif isinstance(privs, tuple): return privs # error\n\treturn \"\\n\".join(privs)\n\n@app.route('/mail/users/privileges/add', methods=['POST'])\n@authorized_personnel_only\ndef mail_user_privs_add():\n\treturn add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), \"add\", env)\n\n@app.route('/mail/users/privileges/remove', methods=['POST'])\n@authorized_personnel_only\ndef mail_user_privs_remove():\n\treturn add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), \"remove\", env)\n\n\n@app.route('/mail/aliases')\n@authorized_personnel_only\ndef mail_aliases():\n\tif request.args.get(\"format\", \"\") == \"json\":\n\t\treturn json_response(get_mail_aliases_ex(env))\n\treturn \"\".join(address+\"\\t\"+receivers+\"\\t\"+(senders or \"\")+\"\\n\" for address, receivers, senders, auto in get_mail_aliases(env))\n\n@app.route('/mail/aliases/add', methods=['POST'])\n@authorized_personnel_only\ndef mail_aliases_add():\n\treturn add_mail_alias(\n\t\trequest.form.get('address', ''),\n\t\trequest.form.get('forwards_to', ''),\n\t\trequest.form.get('permitted_senders', ''),\n\t\tenv,\n\t\tupdate_if_exists=(request.form.get('update_if_exists', '') == '1')\n\t\t)\n\n@app.route('/mail/aliases/remove', methods=['POST'])\n@authorized_personnel_only\ndef mail_aliases_remove():\n\treturn remove_mail_alias(request.form.get('address', ''), env)\n\n@app.route('/mail/domains')\n@authorized_personnel_only\ndef mail_domains():\n    return \"\".join(x+\"\\n\" for x in get_mail_domains(env))\n\n# DNS\n\n@app.route('/dns/zones')\n@authorized_personnel_only\ndef dns_zones():\n\tfrom dns_update import get_dns_zones\n\treturn json_response([z[0] for z in get_dns_zones(env)])\n\n@app.route('/dns/update', methods=['POST'])\n@authorized_personnel_only\ndef dns_update():\n\tfrom dns_update import do_dns_update\n\ttry:\n\t\treturn do_dns_update(env, force=request.form.get('force', '') == '1')\n\texcept Exception as e:\n\t\treturn (str(e), 500)\n\n@app.route('/dns/secondary-nameserver')\n@authorized_personnel_only\ndef dns_get_secondary_nameserver():\n\tfrom dns_update import get_custom_dns_config, get_secondary_dns\n\treturn json_response({ \"hostnames\": get_secondary_dns(get_custom_dns_config(env), mode=None) })\n\n@app.route('/dns/secondary-nameserver', methods=['POST'])\n@authorized_personnel_only\ndef dns_set_secondary_nameserver():\n\tfrom dns_update import set_secondary_dns\n\ttry:\n\t\treturn set_secondary_dns([ns.strip() for ns in re.split(r\"[, ]+\", request.form.get('hostnames') or \"\") if ns.strip() != \"\"], env)\n\texcept ValueError as e:\n\t\treturn (str(e), 400)\n\n@app.route('/dns/custom')\n@authorized_personnel_only\ndef dns_get_records(qname=None, rtype=None):\n\t# Get the current set of custom DNS records.\n\tfrom dns_update import get_custom_dns_config, get_dns_zones\n\trecords = get_custom_dns_config(env, only_real_records=True)\n\n\t# Filter per the arguments for the more complex GET routes below.\n\trecords = [r for r in records\n\t\tif (not qname or r[0] == qname)\n\t\tand (not rtype or r[1] == rtype) ]\n\n\t# Make a better data structure.\n\trecords = [\n        {\n                \"qname\": r[0],\n                \"rtype\": r[1],\n                \"value\": r[2],\n\t\t\"sort-order\": { },\n        } for r in records ]\n\n\t# To help with grouping by zone in qname sorting, label each record with which zone it is in.\n\t# There's an inconsistency in how we handle zones in get_dns_zones and in sort_domains, so\n\t# do this first before sorting the domains within the zones.\n\tzones = utils.sort_domains([z[0] for z in get_dns_zones(env)], env)\n\tfor r in records:\n\t\tfor z in zones:\n\t\t\tif r[\"qname\"] == z or r[\"qname\"].endswith(\".\" + z):\n\t\t\t\tr[\"zone\"] = z\n\t\t\t\tbreak\n\n\t# Add sorting information. The 'created' order follows the order in the YAML file on disk,\n\t# which tracs the order entries were added in the control panel since we append to the end.\n\t# The 'qname' sort order sorts by our standard domain name sort (by zone then by qname),\n\t# then by rtype, and last by the original order in the YAML file (since sorting by value\n\t# may not make sense, unless we parse IP addresses, for example).\n\tfor i, r in enumerate(records):\n\t\tr[\"sort-order\"][\"created\"] = i\n\tdomain_sort_order = utils.sort_domains([r[\"qname\"] for r in records], env)\n\tfor i, r in enumerate(sorted(records, key = lambda r : (\n\t\t\tzones.index(r[\"zone\"]) if r.get(\"zone\") else 0, # record is not within a zone managed by the box\n\t\t\tdomain_sort_order.index(r[\"qname\"]),\n\t\t\tr[\"rtype\"]))):\n\t\tr[\"sort-order\"][\"qname\"] = i\n\n\t# Return.\n\treturn json_response(records)\n\n@app.route('/dns/custom/<qname>', methods=['GET', 'POST', 'PUT', 'DELETE'])\n@app.route('/dns/custom/<qname>/<rtype>', methods=['GET', 'POST', 'PUT', 'DELETE'])\n@authorized_personnel_only\ndef dns_set_record(qname, rtype=\"A\"):\n\tfrom dns_update import do_dns_update, set_custom_dns_record\n\ttry:\n\t\t# Normalize.\n\t\trtype = rtype.upper()\n\n\t\t# Read the record value from the request BODY, which must be\n\t\t# ASCII-only. Not used with GET.\n\t\tvalue = request.stream.read().decode(\"ascii\", \"ignore\").strip()\n\n\t\tif request.method == \"GET\":\n\t\t\t# Get the existing records matching the qname and rtype.\n\t\t\treturn dns_get_records(qname, rtype)\n\n\t\tif request.method in {\"POST\", \"PUT\"}:\n\t\t\t# There is a default value for A/AAAA records.\n\t\t\tif rtype in {\"A\", \"AAAA\"} and value == \"\":\n\t\t\t\tvalue = request.environ.get(\"HTTP_X_FORWARDED_FOR\") # normally REMOTE_ADDR but we're behind nginx as a reverse proxy\n\n\t\t\t# Cannot add empty records.\n\t\t\tif value == '':\n\t\t\t\treturn (\"No value for the record provided.\", 400)\n\n\t\t\tif request.method == \"POST\":\n\t\t\t\t# Add a new record (in addition to any existing records\n\t\t\t\t# for this qname-rtype pair).\n\t\t\t\taction = \"add\"\n\t\t\telif request.method == \"PUT\":\n\t\t\t\t# In REST, PUT is supposed to be idempotent, so we'll\n\t\t\t\t# make this action set (replace all records for this\n\t\t\t\t# qname-rtype pair) rather than add (add a new record).\n\t\t\t\taction = \"set\"\n\n\t\telif request.method == \"DELETE\":\n\t\t\tif value == '':\n\t\t\t\t# Delete all records for this qname-type pair.\n\t\t\t\tvalue = None\n\t\t\telse:\n\t\t\t\t# Delete just the qname-rtype-value record exactly.\n\t\t\t\tpass\n\t\t\taction = \"remove\"\n\n\t\tif set_custom_dns_record(qname, rtype, value, action, env):\n\t\t\treturn do_dns_update(env) or \"Something isn't right.\"\n\t\treturn \"OK\"\n\n\texcept ValueError as e:\n\t\treturn (str(e), 400)\n\n@app.route('/dns/dump')\n@authorized_personnel_only\ndef dns_get_dump():\n\tfrom dns_update import build_recommended_dns\n\treturn json_response(build_recommended_dns(env))\n\n@app.route('/dns/zonefile/<zone>')\n@authorized_personnel_only\ndef dns_get_zonefile(zone):\n\tfrom dns_update import get_dns_zonefile\n\treturn Response(get_dns_zonefile(zone, env), status=200, mimetype='text/plain')\n\n# SSL\n\n@app.route('/ssl/status')\n@authorized_personnel_only\ndef ssl_get_status():\n\tfrom ssl_certificates import get_certificates_to_provision\n\tfrom web_update import get_web_domains_info, get_web_domains\n\n\t# What domains can we provision certificates for? What unexpected problems do we have?\n\tprovision, cant_provision = get_certificates_to_provision(env, show_valid_certs=False)\n\n\t# What's the current status of TLS certificates on all of the domain?\n\tdomains_status = get_web_domains_info(env)\n\tdomains_status = [\n\t\t{\n\t\t\t\"domain\": d[\"domain\"],\n\t\t\t\"status\": d[\"ssl_certificate\"][0],\n\t\t\t\"text\": d[\"ssl_certificate\"][1] + (\" \" + cant_provision[d[\"domain\"]] if d[\"domain\"] in cant_provision else \"\")\n\t\t} for d in domains_status ]\n\n\t# Warn the user about domain names not hosted here because of other settings.\n\tfor domain in set(get_web_domains(env, exclude_dns_elsewhere=False)) - set(get_web_domains(env)):\n\t\tdomains_status.append({\n\t\t\t\"domain\": domain,\n\t\t\t\"status\": \"not-applicable\",\n\t\t\t\"text\": \"The domain's website is hosted elsewhere.\",\n\t\t})\n\n\treturn json_response({\n\t\t\"can_provision\": utils.sort_domains(provision, env),\n\t\t\"status\": domains_status,\n\t})\n\n@app.route('/ssl/csr/<domain>', methods=['POST'])\n@authorized_personnel_only\ndef ssl_get_csr(domain):\n\tfrom ssl_certificates import create_csr\n\tssl_private_key = os.path.join(os.path.join(env[\"STORAGE_ROOT\"], 'ssl', 'ssl_private_key.pem'))\n\treturn create_csr(domain, ssl_private_key, request.form.get('countrycode', ''), env)\n\n@app.route('/ssl/install', methods=['POST'])\n@authorized_personnel_only\ndef ssl_install_cert():\n\tfrom web_update import get_web_domains\n\tfrom ssl_certificates import install_cert\n\tdomain = request.form.get('domain')\n\tssl_cert = request.form.get('cert')\n\tssl_chain = request.form.get('chain')\n\tif domain not in get_web_domains(env):\n\t\treturn \"Invalid domain name.\"\n\treturn install_cert(domain, ssl_cert, ssl_chain, env)\n\n@app.route('/ssl/provision', methods=['POST'])\n@authorized_personnel_only\ndef ssl_provision_certs():\n\tfrom ssl_certificates import provision_certificates\n\trequests = provision_certificates(env, limit_domains=None)\n\treturn json_response({ \"requests\": requests })\n\n# multi-factor auth\n\n@app.route('/mfa/status', methods=['POST'])\n@authorized_personnel_only\ndef mfa_get_status():\n\t# Anyone accessing this route is an admin, and we permit them to\n\t# see the MFA status for any user if they submit a 'user' form\n\t# field. But we don't include provisioning info since a user can\n\t# only provision for themselves.\n\temail = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request\n\ttry:\n\t\tresp = {\n\t\t\t\"enabled_mfa\": get_public_mfa_state(email, env)\n\t\t}\n\t\tif email == request.user_email:\n\t\t\tresp.update({\n\t\t\t\t\"new_mfa\": {\n\t\t\t\t\t\"totp\": provision_totp(email, env)\n\t\t\t\t}\n\t\t\t})\n\texcept ValueError as e:\n\t\treturn (str(e), 400)\n\treturn json_response(resp)\n\n@app.route('/mfa/totp/enable', methods=['POST'])\n@authorized_personnel_only\ndef totp_post_enable():\n\tsecret = request.form.get('secret')\n\ttoken = request.form.get('token')\n\tlabel = request.form.get('label')\n\tif not isinstance(token, str):\n\t\treturn (\"Bad Input\", 400)\n\ttry:\n\t\tvalidate_totp_secret(secret)\n\t\tenable_mfa(request.user_email, \"totp\", secret, token, label, env)\n\texcept ValueError as e:\n\t\treturn (str(e), 400)\n\treturn \"OK\"\n\n@app.route('/mfa/disable', methods=['POST'])\n@authorized_personnel_only\ndef totp_post_disable():\n\t# Anyone accessing this route is an admin, and we permit them to\n\t# disable the MFA status for any user if they submit a 'user' form\n\t# field.\n\temail = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request\n\ttry:\n\t\tresult = disable_mfa(email, request.form.get('mfa-id') or None, env) # convert empty string to None\n\texcept ValueError as e:\n\t\treturn (str(e), 400)\n\tif result: # success\n\t\treturn \"OK\"\n\t# error\n\treturn (\"Invalid user or MFA id.\", 400)\n\n# WEB\n\n@app.route('/web/domains')\n@authorized_personnel_only\ndef web_get_domains():\n\tfrom web_update import get_web_domains_info\n\treturn json_response(get_web_domains_info(env))\n\n@app.route('/web/update', methods=['POST'])\n@authorized_personnel_only\ndef web_update():\n\tfrom web_update import do_web_update\n\treturn do_web_update(env)\n\n# System\n\n@app.route('/system/version', methods=[\"GET\"])\n@authorized_personnel_only\ndef system_version():\n\tfrom status_checks import what_version_is_this\n\ttry:\n\t\treturn what_version_is_this(env)\n\texcept Exception as e:\n\t\treturn (str(e), 500)\n\n@app.route('/system/latest-upstream-version', methods=[\"POST\"])\n@authorized_personnel_only\ndef system_latest_upstream_version():\n\tfrom status_checks import get_latest_miab_version\n\ttry:\n\t\treturn get_latest_miab_version()\n\texcept Exception as e:\n\t\treturn (str(e), 500)\n\n@app.route('/system/status', methods=[\"POST\"])\n@authorized_personnel_only\ndef system_status():\n\tfrom status_checks import run_checks\n\tclass WebOutput:\n\t\tdef __init__(self):\n\t\t\tself.items = []\n\t\tdef add_heading(self, heading):\n\t\t\tself.items.append({ \"type\": \"heading\", \"text\": heading, \"extra\": [] })\n\t\tdef print_ok(self, message):\n\t\t\tself.items.append({ \"type\": \"ok\", \"text\": message, \"extra\": [] })\n\t\tdef print_error(self, message):\n\t\t\tself.items.append({ \"type\": \"error\", \"text\": message, \"extra\": [] })\n\t\tdef print_warning(self, message):\n\t\t\tself.items.append({ \"type\": \"warning\", \"text\": message, \"extra\": [] })\n\t\tdef print_line(self, message, monospace=False):\n\t\t\tself.items[-1][\"extra\"].append({ \"text\": message, \"monospace\": monospace })\n\toutput = WebOutput()\n\t# Create a temporary pool of processes for the status checks\n\twith multiprocessing.pool.Pool(processes=5) as pool:\n\t\trun_checks(False, env, output, pool)\n\t\tpool.close()\n\t\tpool.join()\n\treturn json_response(output.items)\n\n@app.route('/system/updates')\n@authorized_personnel_only\ndef show_updates():\n\tfrom status_checks import list_apt_updates\n\treturn \"\".join(\n\t\t\"{} ({})\\n\".format(p[\"package\"], p[\"version\"])\n\t\tfor p in list_apt_updates())\n\n@app.route('/system/update-packages', methods=[\"POST\"])\n@authorized_personnel_only\ndef do_updates():\n\tutils.shell(\"check_call\", [\"/usr/bin/apt-get\", \"-qq\", \"update\"])\n\treturn utils.shell(\"check_output\", [\"/usr/bin/apt-get\", \"-y\", \"upgrade\"], env={\n\t\t\"DEBIAN_FRONTEND\": \"noninteractive\"\n\t})\n\n\n@app.route('/system/reboot', methods=[\"GET\"])\n@authorized_personnel_only\ndef needs_reboot():\n\tfrom status_checks import is_reboot_needed_due_to_package_installation\n\tif is_reboot_needed_due_to_package_installation():\n\t\treturn json_response(True)\n\treturn json_response(False)\n\n@app.route('/system/reboot', methods=[\"POST\"])\n@authorized_personnel_only\ndef do_reboot():\n\t# To keep the attack surface low, we don't allow a remote reboot if one isn't necessary.\n\tfrom status_checks import is_reboot_needed_due_to_package_installation\n\tif is_reboot_needed_due_to_package_installation():\n\t\treturn utils.shell(\"check_output\", [\"/sbin/shutdown\", \"-r\", \"now\"], capture_stderr=True)\n\treturn \"No reboot is required, so it is not allowed.\"\n\n\n@app.route('/system/backup/status')\n@authorized_personnel_only\ndef backup_status():\n\tfrom backup import backup_status\n\ttry:\n\t\treturn json_response(backup_status(env))\n\texcept Exception as e:\n\t\treturn json_response({ \"error\": str(e) })\n\n@app.route('/system/backup/config', methods=[\"GET\"])\n@authorized_personnel_only\ndef backup_get_custom():\n\tfrom backup import get_backup_config\n\treturn json_response(get_backup_config(env, for_ui=True))\n\n@app.route('/system/backup/config', methods=[\"POST\"])\n@authorized_personnel_only\ndef backup_set_custom():\n\tfrom backup import backup_set_custom\n\treturn json_response(backup_set_custom(env,\n\t\trequest.form.get('target', ''),\n\t\trequest.form.get('target_user', ''),\n\t\trequest.form.get('target_pass', ''),\n\t\trequest.form.get('min_age', '')\n\t))\n\n@app.route('/system/privacy', methods=[\"GET\"])\n@authorized_personnel_only\ndef privacy_status_get():\n\tconfig = utils.load_settings(env)\n\treturn json_response(config.get(\"privacy\", True))\n\n@app.route('/system/privacy', methods=[\"POST\"])\n@authorized_personnel_only\ndef privacy_status_set():\n\tconfig = utils.load_settings(env)\n\tconfig[\"privacy\"] = (request.form.get('value') == \"private\")\n\tutils.write_settings(config, env)\n\treturn \"OK\"\n\n# MUNIN\n\n@app.route('/munin/')\n@authorized_personnel_only\ndef munin_start():\n\t# Munin pages, static images, and dynamically generated images are served\n\t# outside of the AJAX API. We'll start with a 'start' API that sets a cookie\n\t# that subsequent requests will read for authorization. (We don't use cookies\n\t# for the API to avoid CSRF vulnerabilities.)\n\tresponse = make_response(\"OK\")\n\tresponse.set_cookie(\"session\", auth_service.create_session_key(request.user_email, env, type='cookie'),\n\t    max_age=60*30, secure=True, httponly=True, samesite=\"Strict\") # 30 minute duration\n\treturn response\n\ndef check_request_cookie_for_admin_access():\n\tsession = auth_service.get_session(None, request.cookies.get(\"session\", \"\"), \"cookie\", env)\n\tif not session: return False\n\tprivs = get_mail_user_privileges(session[\"email\"], env)\n\tif not isinstance(privs, list): return False\n\treturn \"admin\" in privs\n\ndef authorized_personnel_only_via_cookie(f):\n\t@wraps(f)\n\tdef g(*args, **kwargs):\n\t\tif not check_request_cookie_for_admin_access():\n\t\t\treturn Response(\"Unauthorized\", status=403, mimetype='text/plain', headers={})\n\t\treturn f(*args, **kwargs)\n\treturn g\n\n@app.route('/munin/<path:filename>')\n@authorized_personnel_only_via_cookie\ndef munin_static_file(filename=\"\"):\n\t# Proxy the request to static files.\n\tif filename == \"\": filename = \"index.html\"\n\treturn send_from_directory(\"/var/cache/munin/www\", filename)\n\n@app.route('/munin/cgi-graph/<path:filename>')\n@authorized_personnel_only_via_cookie\ndef munin_cgi(filename):\n\t\"\"\" Relay munin cgi dynazoom requests\n\t/usr/lib/munin/cgi/munin-cgi-graph is a perl cgi script in the munin package\n\tthat is responsible for generating binary png images _and_ associated HTTP\n\theaders based on parameters in the requesting URL. All output is written\n\tto stdout which munin_cgi splits into response headers and binary response\n\tdata.\n\tmunin-cgi-graph reads environment variables to determine\n\twhat it should do. It expects a path to be in the env-var PATH_INFO, and a\n\tquerystring to be in the env-var QUERY_STRING.\n\tmunin-cgi-graph has several failure modes. Some write HTTP Status headers and\n\tothers return nonzero exit codes.\n\tSituating munin_cgi between the user-agent and munin-cgi-graph enables keeping\n\tthe cgi script behind mailinabox's auth mechanisms and avoids additional\n\tsupport infrastructure like spawn-fcgi.\n\t\"\"\"\n\n\tCOMMAND = 'su munin --preserve-environment --shell=/bin/bash -c /usr/lib/munin/cgi/munin-cgi-graph'\n\t# su changes user, we use the munin user here\n\t# --preserve-environment retains the environment, which is where Popen's `env` data is\n\t# --shell=/bin/bash ensures the shell used is bash\n\t# -c \"/usr/lib/munin/cgi/munin-cgi-graph\" passes the command to run as munin\n\t# \"%s\" is a placeholder for where the request's querystring will be added\n\n\tif filename == \"\":\n\t\treturn (\"a path must be specified\", 404)\n\n\tquery_str = request.query_string.decode(\"utf-8\", 'ignore')\n\n\tenv = {'PATH_INFO': f'/{filename}/', 'REQUEST_METHOD': 'GET', 'QUERY_STRING': query_str}\n\tcode, binout = utils.shell('check_output',\n\t\t\t\t\t\t\t   COMMAND.split(\" \", 5),\n\t\t\t\t\t\t\t   # Using a maxsplit of 5 keeps the last arguments together\n\t\t\t\t\t\t\t   env=env,\n\t\t\t\t\t\t\t   return_bytes=True,\n\t\t\t\t\t\t\t   trap=True)\n\n\tif code != 0:\n\t\t# nonzero returncode indicates error\n\t\tapp.logger.error(\"munin_cgi: munin-cgi-graph returned nonzero exit code, %s\", code)\n\t\treturn (\"error processing graph image\", 500)\n\n\t# /usr/lib/munin/cgi/munin-cgi-graph returns both headers and binary png when successful.\n\t# A double-Windows-style-newline always indicates the end of HTTP headers.\n\theaders, image_bytes = binout.split(b'\\r\\n\\r\\n', 1)\n\tresponse = make_response(image_bytes)\n\tfor line in headers.splitlines():\n\t\tname, value = line.decode(\"utf8\").split(':', 1)\n\t\tresponse.headers[name] = value\n\tif 'Status' in response.headers and '404' in response.headers['Status']:\n\t\tapp.logger.warning(\"munin_cgi: munin-cgi-graph returned 404 status code. PATH_INFO=%s\", env['PATH_INFO'])\n\treturn response\n\ndef log_failed_login(request):\n\t# We need to figure out the ip to list in the message, all our calls are routed\n\t# through nginx who will put the original ip in X-Forwarded-For.\n\t# During setup we call the management interface directly to determine the user\n\t# status. So we can't always use X-Forwarded-For because during setup that header\n\t# will not be present.\n\tip = request.headers.getlist(\"X-Forwarded-For\")[0] if request.headers.getlist(\"X-Forwarded-For\") else request.remote_addr\n\n\t# We need to add a timestamp to the log message, otherwise /dev/log will eat the \"duplicate\"\n\t# message.\n\tapp.logger.warning(\"Mail-in-a-Box Management Daemon: Failed login attempt from ip %s - timestamp %s\", ip, time.time())\n\n\n# APP\n\nif __name__ == '__main__':\n\tif \"DEBUG\" in os.environ:\n\t\t# Turn on Flask debugging.\n\t\tapp.debug = True\n\n\tif not app.debug:\n\t\tapp.logger.addHandler(utils.create_syslog_handler())\n\n\t#app.logger.info('API key: ' + auth_service.key)\n\n\t# Start the application server. Listens on 127.0.0.1 (IPv4 only).\n\tapp.run(port=10222)\n"
  },
  {
    "path": "management/daily_tasks.sh",
    "content": "#!/bin/bash\n# This script is run daily (at 3am each night).\n\n# Set character encoding flags to ensure that any non-ASCII\n# characters don't cause problems. See setup/start.sh and\n# the management daemon startup script.\nexport LANGUAGE=en_US.UTF-8\nexport LC_ALL=en_US.UTF-8\nexport LANG=en_US.UTF-8\nexport LC_TYPE=en_US.UTF-8\n\n# On Mondays, i.e. once a week, send the administrator a report of total emails\n# sent and received so the admin might notice server abuse.\nif [ \"$(date \"+%u\")\" -eq 1 ]; then\n    management/mail_log.py -t week | management/email_administrator.py \"Mail-in-a-Box Usage Report\"\nfi\n\n# Take a backup.\nmanagement/backup.py 2>&1 | management/email_administrator.py \"Backup Status\"\n\n# Provision any new certificates for new domains or domains with expiring certificates.\nmanagement/ssl_certificates.py -q  2>&1 | management/email_administrator.py \"TLS Certificate Provisioning Result\"\n\n# Run status checks and email the administrator if anything changed.\nmanagement/status_checks.py --show-changes  2>&1 | management/email_administrator.py \"Status Checks Change Notice\"\n"
  },
  {
    "path": "management/dns_update.py",
    "content": "#!/usr/local/lib/mailinabox/env/bin/python\n\n# Creates DNS zone files for all of the domains of all of the mail users\n# and mail aliases and restarts nsd.\n########################################################################\n\nimport sys, os, os.path, datetime, re, hashlib, base64\nimport ipaddress\nimport rtyaml\nimport dns.resolver\n\nfrom utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains, get_ssh_port\nfrom ssl_certificates import get_ssl_certificates, check_certificate\n\n# From https://stackoverflow.com/questions/3026957/how-to-validate-a-domain-name-using-regex-php/16491074#16491074\n# This regular expression matches domain names according to RFCs, it also accepts fqdn with an leading dot,\n# underscores, as well as asterisks which are allowed in domain names but not hostnames (i.e. allowed in\n# DNS but not in URLs), which are common in certain record types like for DKIM.\nDOMAIN_RE = r\"^(?!\\-)(?:[*][.])?(?:[a-zA-Z\\d\\-_]{0,62}[a-zA-Z\\d_]\\.){1,126}(?!\\d+)[a-zA-Z\\d_]{1,63}(\\.?)$\"\n\ndef get_dns_domains(env):\n\t# Add all domain names in use by email users and mail aliases, any\n\t# domains we serve web for (except www redirects because that would\n\t# lead to infinite recursion here) and ensure PRIMARY_HOSTNAME is in the list.\n\tfrom mailconfig import get_mail_domains\n\tfrom web_update import get_web_domains\n\tdomains = set()\n\tdomains |= set(get_mail_domains(env))\n\tdomains |= set(get_web_domains(env, include_www_redirects=False))\n\tdomains.add(env['PRIMARY_HOSTNAME'])\n\treturn domains\n\ndef get_dns_zones(env):\n\t# What domains should we create DNS zones for? Never create a zone for\n\t# a domain & a subdomain of that domain.\n\tdomains = get_dns_domains(env)\n\n\t# Exclude domains that are subdomains of other domains we know. Proceed\n\t# by looking at shorter domains first.\n\tzone_domains = set()\n\tfor domain in sorted(domains, key=len):\n\t\tfor d in zone_domains:\n\t\t\tif domain.endswith(\".\" + d):\n\t\t\t\t# We found a parent domain already in the list.\n\t\t\t\tbreak\n\t\telse:\n\t\t\t# 'break' did not occur: there is no parent domain.\n\t\t\tzone_domains.add(domain)\n\n\t# Make a nice and safe filename for each domain.\n\tzonefiles = [[domain, safe_domain_name(domain) + \".txt\"] for domain in zone_domains]\n\n\t# Sort the list so that the order is nice and so that nsd.conf has a\n\t# stable order so we don't rewrite the file & restart the service\n\t# meaninglessly.\n\tzone_order = sort_domains([ zone[0] for zone in zonefiles ], env)\n\tzonefiles.sort(key = lambda zone : zone_order.index(zone[0]) )\n\n\treturn zonefiles\n\ndef do_dns_update(env, force=False):\n\t# Write zone files.\n\tos.makedirs('/etc/nsd/zones', exist_ok=True)\n\tzonefiles = []\n\tupdated_domains = []\n\tfor (domain, zonefile, records) in build_zones(env):\n\t\t# The final set of files will be signed.\n\t\tzonefiles.append((domain, zonefile + \".signed\"))\n\n\t\t# See if the zone has changed, and if so update the serial number\n\t\t# and write the zone file.\n\t\tif not write_nsd_zone(domain, \"/etc/nsd/zones/\" + zonefile, records, env, force):\n\t\t\t# Zone was not updated. There were no changes.\n\t\t\tcontinue\n\n\t\t# Mark that we just updated this domain.\n\t\tupdated_domains.append(domain)\n\n\t\t# Sign the zone.\n\t\t#\n\t\t# Every time we sign the zone we get a new result, which means\n\t\t# we can't sign a zone without bumping the zone's serial number.\n\t\t# Thus we only sign a zone if write_nsd_zone returned True\n\t\t# indicating the zone changed, and thus it got a new serial number.\n\t\t# write_nsd_zone is smart enough to check if a zone's signature\n\t\t# is nearing expiration and if so it'll bump the serial number\n\t\t# and return True so we get a chance to re-sign it.\n\t\tsign_zone(domain, zonefile, env)\n\n\t# Write the main nsd.conf file.\n\tif write_nsd_conf(zonefiles, list(get_custom_dns_config(env)), env):\n\t\t# Make sure updated_domains contains *something* if we wrote an updated\n\t\t# nsd.conf so that we know to restart nsd.\n\t\tif len(updated_domains) == 0:\n\t\t\tupdated_domains.append(\"DNS configuration\")\n\n\t# Tell nsd to reload changed zone files.\n\tif len(updated_domains) > 0:\n\t\t# 'reconfig' is needed if there are added or removed zones, but\n\t\t# it may not reload existing zones, so we call 'reload' too. If\n\t\t# nsd isn't running, nsd-control fails, so in that case revert\n\t\t# to restarting nsd to make sure it is running. Restarting nsd\n\t\t# should also refresh everything.\n\t\ttry:\n\t\t\tshell('check_call', [\"/usr/sbin/nsd-control\", \"reconfig\"])\n\t\t\tshell('check_call', [\"/usr/sbin/nsd-control\", \"reload\"])\n\t\texcept:\n\t\t\tshell('check_call', [\"/usr/sbin/service\", \"nsd\", \"restart\"])\n\n\t# Write the OpenDKIM configuration tables for all of the mail domains.\n\tfrom mailconfig import get_mail_domains\n\tif write_opendkim_tables(get_mail_domains(env), env):\n\t\t# Settings changed. Kick opendkim.\n\t\tshell('check_call', [\"/usr/sbin/service\", \"opendkim\", \"restart\"])\n\t\tif len(updated_domains) == 0:\n\t\t\t# If this is the only thing that changed?\n\t\t\tupdated_domains.append(\"OpenDKIM configuration\")\n\n\t# Clear bind9's DNS cache so our own DNS resolver is up to date.\n\t# (ignore errors with trap=True)\n\tshell('check_call', [\"/usr/sbin/rndc\", \"flush\"], trap=True)\n\n\tif len(updated_domains) == 0:\n\t\t# if nothing was updated (except maybe OpenDKIM's files), don't show any output\n\t\treturn \"\"\n\treturn \"updated DNS: \" + \",\".join(updated_domains) + \"\\n\"\n\n########################################################################\n\ndef build_zones(env):\n\t# What domains (and their zone filenames) should we build?\n\tdomains = get_dns_domains(env)\n\tzonefiles = get_dns_zones(env)\n\n\t# Create a dictionary of domains to a set of attributes for each\n\t# domain, such as whether there are mail users at the domain.\n\tfrom mailconfig import get_mail_domains\n\tfrom web_update import get_web_domains\n\tmail_domains = set(get_mail_domains(env))\n\tmail_user_domains = set(get_mail_domains(env, users_only=True)) # i.e. will log in for mail, Nextcloud\n\tweb_domains = set(get_web_domains(env))\n\tauto_domains = web_domains - set(get_web_domains(env, include_auto=False))\n\tdomains |= auto_domains # www redirects not included in the initial list, see above\n\n\t# Add ns1/ns2+PRIMARY_HOSTNAME which must also have A/AAAA records\n\t# when the box is acting as authoritative DNS server for its domains.\n\tfor ns in (\"ns1\", \"ns2\"):\n\t\td = ns + \".\" + env[\"PRIMARY_HOSTNAME\"]\n\t\tdomains.add(d)\n\t\tauto_domains.add(d)\n\n\tdomains = {\n\t\tdomain: {\n\t\t\t\"user\": domain in mail_user_domains,\n\t\t\t\"mail\": domain in mail_domains,\n\t\t\t\"web\": domain in web_domains,\n\t\t\t\"auto\": domain in auto_domains,\n\t\t}\n\t\tfor domain in domains\n\t}\n\n\t# For MTA-STS, we'll need to check if the PRIMARY_HOSTNAME certificate is\n\t# singned and valid. Check that now rather than repeatedly for each domain.\n\tdomains[env[\"PRIMARY_HOSTNAME\"]][\"certificate-is-valid\"] = is_domain_cert_signed_and_valid(env[\"PRIMARY_HOSTNAME\"], env)\n\n\t# Load custom records to add to zones.\n\tadditional_records = list(get_custom_dns_config(env))\n\n\t# Build DNS records for each zone.\n\tfor domain, zonefile in zonefiles:\n\t\t# Build the records to put in the zone.\n\t\trecords = build_zone(domain, domains, additional_records, env)\n\t\tyield (domain, zonefile, records)\n\ndef build_zone(domain, domain_properties, additional_records, env, is_zone=True):\n\trecords = []\n\n\t# For top-level zones, define the authoritative name servers.\n\t#\n\t# Normally we are our own nameservers. Some TLDs require two distinct IP addresses,\n\t# so we allow the user to override the second nameserver definition so that\n\t# secondary DNS can be set up elsewhere.\n\t#\n\t# 'False' in the tuple indicates these records would not be used if the zone\n\t# is managed outside of the box.\n\tif is_zone:\n\t\t# Obligatory NS record to ns1.PRIMARY_HOSTNAME.\n\t\trecords.append((None,  \"NS\",  \"ns1.{}.\".format(env[\"PRIMARY_HOSTNAME\"]), False))\n\n\t\t# NS record to ns2.PRIMARY_HOSTNAME or whatever the user overrides.\n\t\t# User may provide one or more additional nameservers\n\t\tsecondary_ns_list = get_secondary_dns(additional_records, mode=\"NS\") \\\n\t\t\tor [\"ns2.\" + env[\"PRIMARY_HOSTNAME\"]]\n\t\trecords.extend((None,  \"NS\", secondary_ns+'.', False) for secondary_ns in secondary_ns_list)\n\n\n\t# In PRIMARY_HOSTNAME...\n\tif domain == env[\"PRIMARY_HOSTNAME\"]:\n\t\t# Set the A/AAAA records. Do this early for the PRIMARY_HOSTNAME so that the user cannot override them\n\t\t# and we can provide different explanatory text.\n\t\trecords.append((None, \"A\", env[\"PUBLIC_IP\"], \"Required. Sets the IP address of the box.\"))\n\t\tif env.get(\"PUBLIC_IPV6\"): records.append((None, \"AAAA\", env[\"PUBLIC_IPV6\"], \"Required. Sets the IPv6 address of the box.\"))\n\n\t\t# Add a DANE TLSA record for SMTP.\n\t\trecords.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.\"))\n\n\t\t# Add a DANE TLSA record for HTTPS, which some browser extensions might make use of.\n\t\trecords.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.\"))\n\n\t\t# Add a SSHFP records to help SSH key validation. One per available SSH key on this system.\n\t\trecords.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())\n\n\t# Add DNS records for any subdomains of this domain. We should not have a zone for\n\t# both a domain and one of its subdomains.\n\tif is_zone: # don't recurse when we're just loading data for a subdomain\n\t\tsubdomains = [d for d in domain_properties if d.endswith(\".\" + domain)]\n\t\tfor subdomain in subdomains:\n\t\t\tsubdomain_qname = subdomain[0:-len(\".\" + domain)]\n\t\t\tsubzone = build_zone(subdomain, domain_properties, additional_records, env, is_zone=False)\n\t\t\tfor child_qname, child_rtype, child_value, child_explanation in subzone:\n\t\t\t\tif child_qname is None:\n\t\t\t\t\tchild_qname = subdomain_qname\n\t\t\t\telse:\n\t\t\t\t\tchild_qname += \".\" + subdomain_qname\n\t\t\t\trecords.append((child_qname, child_rtype, child_value, child_explanation))\n\n\thas_rec_base = list(records) # clone current state\n\tdef has_rec(qname, rtype, prefix=None):\n\t\treturn any(rec[0] == qname and rec[1] == rtype and (prefix is None or rec[2].startswith(prefix)) for rec in has_rec_base)\n\n\t# The user may set other records that don't conflict with our settings.\n\t# Don't put any TXT records above this line, or it'll prevent any custom TXT records.\n\tfor qname, rtype, value in filter_custom_records(domain, additional_records):\n\t\t# Don't allow custom records for record types that override anything above.\n\t\t# But allow multiple custom records for the same rtype --- see how has_rec_base is used.\n\t\tif has_rec(qname, rtype): continue\n\n\t\t# The \"local\" keyword on A/AAAA records are short-hand for our own IP.\n\t\t# This also flags for web configuration that the user wants a website here.\n\t\tif rtype == \"A\" and value == \"local\":\n\t\t\tvalue = env[\"PUBLIC_IP\"]\n\t\tif rtype == \"AAAA\" and value == \"local\":\n\t\t\tif \"PUBLIC_IPV6\" in env:\n\t\t\t\tvalue = env[\"PUBLIC_IPV6\"]\n\t\t\telse:\n\t\t\t\tcontinue\n\t\trecords.append((qname, rtype, value, \"(Set by user.)\"))\n\n\t# Add A/AAAA defaults if not overridden by the user's custom settings (and not otherwise configured).\n\t# Any CNAME or A record on the qname overrides A and AAAA. But when we set the default A record,\n\t# we should not cause the default AAAA record to be skipped because it thinks a custom A record\n\t# was set. So set has_rec_base to a clone of the current set of DNS settings, and don't update\n\t# during this process.\n\thas_rec_base = list(records)\n\ta_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.\"\n\tif domain_properties[domain][\"auto\"]:\n\t\tif domain.startswith((\"ns1.\", \"ns2.\")): a_expl = False # omit from 'External DNS' page since this only applies if box is its own DNS server\n\t\tif 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.\"\n\t\tif domain.startswith(\"mta-sts.\"): a_expl = \"Optional. MTA-STS Policy Host serving /.well-known/mta-sts.txt.\"\n\t\tif domain.startswith(\"autoconfig.\"): a_expl = \"Provides email configuration autodiscovery support for Thunderbird Autoconfig.\"\n\t\tif domain.startswith(\"autodiscover.\"): a_expl = \"Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover.\"\n\tdefaults = [\n\t\t(None,  \"A\",    env[\"PUBLIC_IP\"], a_expl),\n\t\t(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.)\"),\n\t]\n\tfor qname, rtype, value, explanation in defaults:\n\t\tif value is None or value.strip() == \"\": continue # skip IPV6 if not set\n\t\tif not is_zone and qname == \"www\": continue # don't create any default 'www' subdomains on what are themselves subdomains\n\t\t# Set the default record, but not if:\n\t\t# (1) there is not a user-set record of the same type already\n\t\t# (2) there is not a CNAME record already, since you can't set both and who knows what takes precedence\n\t\t# (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)\n\t\tif not has_rec(qname, rtype) and not has_rec(qname, \"CNAME\") and not has_rec(qname, \"A\"):\n\t\t\trecords.append((qname, rtype, value, explanation))\n\n\t# Don't pin the list of records that has_rec checks against anymore.\n\thas_rec_base = records\n\n\tif domain_properties[domain][\"mail\"]:\n\t\t# The MX record says where email for the domain should be delivered: Here!\n\t\tif not has_rec(None, \"MX\", prefix=\"10 \"):\n\t\t\trecords.append((None,  \"MX\",  \"10 {}.\".format(env[\"PRIMARY_HOSTNAME\"]), f\"Required. Specifies the hostname (and priority) of the machine that handles @{domain} mail.\"))\n\n\t\t# SPF record: Permit the box ('mx', see above) to send mail on behalf of\n\t\t# the domain, and no one else.\n\t\t# Skip if the user has set a custom SPF record.\n\t\tif not has_rec(None, \"TXT\", prefix=\"v=spf1 \"):\n\t\t\trecords.append((None,  \"TXT\", 'v=spf1 mx -all', f\"Recommended. Specifies that only the box is permitted to send @{domain} mail.\"))\n\n\t\t# Append the DKIM TXT record to the zone as generated by OpenDKIM.\n\t\t# Skip if the user has set a DKIM record already.\n\t\topendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')\n\t\twith open(opendkim_record_file, encoding=\"utf-8\") as orf:\n\t\t\tm = re.match(r'(\\S+)\\s+IN\\s+TXT\\s+\\( ((?:\"[^\"]+\"\\s+)+)\\)', orf.read(), re.S)\n\t\t\tval = \"\".join(re.findall(r'\"([^\"]+)\"', m.group(2)))\n\t\t\tif not has_rec(m.group(1), \"TXT\", prefix=\"v=DKIM1; \"):\n\t\t\t\trecords.append((m.group(1), \"TXT\", val, f\"Recommended. Provides a way for recipients to verify that this machine sent @{domain} mail.\"))\n\n\t\t# Append a DMARC record.\n\t\t# Skip if the user has set a DMARC record already.\n\t\tif not has_rec(\"_dmarc\", \"TXT\", prefix=\"v=DMARC1; \"):\n\t\t\trecords.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.\"))\n\n\tif domain_properties[domain][\"user\"]:\n\t\t# Add CardDAV/CalDAV SRV records on the non-primary hostname that points to the primary hostname\n\t\t# for autoconfiguration of mail clients (so only domains hosting user accounts need it).\n\t\t# The SRV record format is priority (0, whatever), weight (0, whatever), port, service provider hostname (w/ trailing dot).\n\t\tif domain != env[\"PRIMARY_HOSTNAME\"]:\n\t\t\tfor dav in (\"card\", \"cal\"):\n\t\t\t\tqname = \"_\" + dav + \"davs._tcp\"\n\t\t\t\tif not has_rec(qname, \"SRV\"):\n\t\t\t\t\trecords.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.\"))\n\n\t# If this is a domain name that there are email addresses configured for, i.e. \"something@\"\n\t# this domain name, then the domain name is a MTA-STS (https://tools.ietf.org/html/rfc8461)\n\t# Policy Domain.\n\t#\n\t# A \"_mta-sts\" TXT record signals the presence of a MTA-STS policy. The id field helps clients\n\t# cache the policy. It should be stable so we don't update DNS unnecessarily but change when\n\t# the policy changes. It must be at most 32 letters and numbers, so we compute a hash of the\n\t# policy file.\n\t#\n\t# The policy itself is served at the \"mta-sts\" (no underscore) subdomain over HTTPS. Therefore\n\t# the TLS certificate used by Postfix for STARTTLS must be a valid certificate for the MX\n\t# domain name (PRIMARY_HOSTNAME) *and* the TLS certificate used by nginx for HTTPS on the mta-sts\n\t# subdomain must be valid certificate for that domain. Do not set an MTA-STS policy if either\n\t# certificate in use is not valid (e.g. because it is self-signed and a valid certificate has not\n\t# yet been provisioned). Since we cannot provision a certificate without A/AAAA records, we\n\t# always set them (by including them in the www domains) --- only the TXT records depend on there\n\t# being valid certificates.\n\tmta_sts_records = [ ]\n\tif domain_properties[domain][\"mail\"] \\\n\t  and domain_properties[env[\"PRIMARY_HOSTNAME\"]][\"certificate-is-valid\"] \\\n\t  and is_domain_cert_signed_and_valid(\"mta-sts.\" + domain, env):\n\t\t# Compute an up-to-32-character hash of the policy file. We'll take a SHA-1 hash of the policy\n\t\t# file (20 bytes) and encode it as base-64 (28 bytes, using alphanumeric alternate characters\n\t\t# instead of '+' and '/' which are not allowed in an MTA-STS policy id) but then just take its\n\t\t# first 20 characters, which is more than sufficient to change whenever the policy file changes\n\t\t# (and ensures any '=' padding at the end of the base64 encoding is dropped).\n\t\twith open(\"/var/lib/mailinabox/mta-sts.txt\", \"rb\") as f:\n\t\t\tmta_sts_policy_id = base64.b64encode(hashlib.sha1(f.read()).digest(), altchars=b\"AA\").decode(\"ascii\")[0:20]\n\t\tmta_sts_records.extend([\n\t\t\t(\"_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.\")\n\t\t])\n\n\t\t# Enable SMTP TLS reporting (https://tools.ietf.org/html/rfc8460) if the user has set a config option.\n\t\t# Skip if the rules below if the user has set a custom _smtp._tls record.\n\t\tif env.get(\"MTA_STS_TLSRPT_RUA\") and not has_rec(\"_smtp._tls\", \"TXT\", prefix=\"v=TLSRPTv1;\"):\n\t\t\tmta_sts_records.append((\"_smtp._tls\", \"TXT\", \"v=TLSRPTv1; rua=\" + env[\"MTA_STS_TLSRPT_RUA\"], \"Optional. Enables MTA-STS reporting.\"))\n\tfor qname, rtype, value, explanation in mta_sts_records:\n\t\tif not has_rec(qname, rtype):\n\t\t\trecords.append((qname, rtype, value, explanation))\n\n\t# Add no-mail-here records for any qname that has an A or AAAA record\n\t# but no MX record. This would include domain itself if domain is a\n\t# non-mail domain and also may include qnames from custom DNS records.\n\t# Do this once at the end of generating a zone.\n\tif is_zone:\n\t\tqnames_with_a = {qname for (qname, rtype, value, explanation) in records if rtype in {\"A\", \"AAAA\"}}\n\t\tqnames_with_mx = {qname for (qname, rtype, value, explanation) in records if rtype == \"MX\"}\n\t\tfor qname in qnames_with_a - qnames_with_mx:\n\t\t\t# Mark this domain as not sending mail with hard-fail SPF and DMARC records.\n\t\t\td = (qname+\".\" if qname else \"\") + domain\n\t\t\tif not has_rec(qname, \"TXT\", prefix=\"v=spf1 \"):\n\t\t\t\trecords.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).\"))\n\t\t\tif not has_rec(\"_dmarc\" + (\".\"+qname if qname else \"\"), \"TXT\", prefix=\"v=DMARC1; \"):\n\t\t\t\trecords.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}.\"))\n\n\t\t\t# And with a null MX record (https://explained-from-first-principles.com/email/#null-mx-record)\n\t\t\tif not has_rec(qname, \"MX\"):\n\t\t\t\trecords.append((qname, \"MX\", '0 .', \"Recommended. Prevents use of this domain name for incoming mail.\"))\n\n\t# Sort the records. The None records *must* go first in the nsd zone file. Otherwise it doesn't matter.\n\trecords.sort(key = lambda rec : list(reversed(rec[0].split(\".\")) if rec[0] is not None else \"\"))\n\n\treturn records\n\ndef is_domain_cert_signed_and_valid(domain, env):\n\tcert = get_ssl_certificates(env).get(domain)\n\tif not cert: return False # no certificate provisioned\n\tcert_status = check_certificate(domain, cert['certificate'], cert['private-key'])\n\treturn cert_status[0] == 'OK'\n\n########################################################################\n\ndef build_tlsa_record(env):\n\t# A DANE TLSA record in DNS specifies that connections on a port\n\t# must use TLS and the certificate must match a particular criteria.\n\t#\n\t# Thanks to http://blog.huque.com/2012/10/dnssec-and-certificates.html\n\t# and https://community.letsencrypt.org/t/please-avoid-3-0-1-and-3-0-2-dane-tlsa-records-with-le-certificates/7022\n\t# for explaining all of this! Also see https://tools.ietf.org/html/rfc6698#section-2.1\n\t# and https://github.com/mail-in-a-box/mailinabox/issues/268#issuecomment-167160243.\n\t#\n\t# There are several criteria. We used to use \"3 0 1\" criteria, which\n\t# meant to pin a leaf (3) certificate (0) with SHA256 hash (1). But\n\t# certificates change, and especially as we move to short-lived certs\n\t# they change often. The TLSA record handily supports the criteria of\n\t# a leaf certificate (3)'s subject public key (1) with SHA256 hash (1).\n\t# The subject public key is the public key portion of the private key\n\t# that generated the CSR that generated the certificate. Since we\n\t# generate a private key once the first time Mail-in-a-Box is set up\n\t# and reuse it for all subsequent certificates, the TLSA record will\n\t# remain valid indefinitely.\n\n\tfrom ssl_certificates import load_cert_chain, load_pem\n\tfrom cryptography.hazmat.primitives.serialization import Encoding, PublicFormat\n\n\tfn = os.path.join(env[\"STORAGE_ROOT\"], \"ssl\", \"ssl_certificate.pem\")\n\tcert = load_pem(load_cert_chain(fn)[0])\n\n\tsubject_public_key = cert.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)\n\t# We could have also loaded ssl_private_key.pem and called priv_key.public_key().public_bytes(...)\n\n\tpk_hash = hashlib.sha256(subject_public_key).hexdigest()\n\n\t# Specify the TLSA parameters:\n\t# 3: Match the (leaf) certificate. (No CA, no trust path needed.)\n\t# 1: Match its subject public key.\n\t# 1: Use SHA256.\n\treturn \"3 1 1 \" + pk_hash\n\ndef build_sshfp_records():\n\t# The SSHFP record is a way for us to embed this server's SSH public\n\t# key fingerprint into the DNS so that remote hosts have an out-of-band\n\t# method to confirm the fingerprint. See RFC 4255 and RFC 6594. This\n\t# depends on DNSSEC.\n\t#\n\t# On the client side, set SSH's VerifyHostKeyDNS option to 'ask' to\n\t# include this info in the key verification prompt or 'yes' to trust\n\t# the SSHFP record.\n\t#\n\t# See https://github.com/xelerance/sshfp for inspiriation.\n\n\talgorithm_number = {\n\t\t\"ssh-rsa\": 1,\n\t\t\"ssh-dss\": 2,\n\t\t\"ecdsa-sha2-nistp256\": 3,\n\t\t\"ssh-ed25519\": 4,\n\t}\n\n\t# Get our local fingerprints by running ssh-keyscan. The output looks\n\t# like the known_hosts file: hostname, keytype, fingerprint. The order\n\t# of the output is arbitrary, so sort it to prevent spurious updates\n\t# to the zone file (that trigger bumping the serial number). However,\n\t# if SSH has been configured to listen on a nonstandard port, we must\n\t# specify that port to sshkeyscan.\n\n\tport = get_ssh_port()\n\n\t# If nothing returned, SSH is probably not installed.\n\tif not port:\n\t\treturn\n\n\tkeys = shell(\"check_output\", [\"ssh-keyscan\", \"-4\", \"-t\", \"rsa,dsa,ecdsa,ed25519\", \"-p\", str(port), \"localhost\"])\n\tkeys = sorted(keys.split(\"\\n\"))\n\n\tfor key in keys:\n\t\tif key.strip() == \"\" or key[0] == \"#\": continue\n\t\ttry:\n\t\t\t_host, keytype, pubkey = key.split(\" \")\n\t\t\tyield \"%d %d ( %s )\" % (\n\t\t\t\talgorithm_number[keytype],\n\t\t\t\t2, # specifies we are using SHA-256 on next line\n\t\t\t\thashlib.sha256(base64.b64decode(pubkey)).hexdigest().upper(),\n\t\t\t\t)\n\t\texcept:\n\t\t\t# Lots of things can go wrong. Don't let it disturb the DNS\n\t\t\t# zone.\n\t\t\tpass\n\n########################################################################\n\ndef write_nsd_zone(domain, zonefile, records, env, force):\n\t# On the $ORIGIN line, there's typically a ';' comment at the end explaining\n\t# what the $ORIGIN line does. Any further data after the domain confuses\n\t# ldns-signzone, however. It used to say '; default zone domain'.\n\t#\n\t# The SOA contact address for all of the domains on this system is hostmaster\n\t# @ the PRIMARY_HOSTNAME. Hopefully that's legit.\n\t#\n\t# For the refresh through TTL fields, a good reference is:\n\t# https://www.ripe.net/publications/docs/ripe-203\n\t#\n\t# A hash of the available DNSSEC keys are added in a comment so that when\n\t# the keys change we force a re-generation of the zone which triggers\n\t# re-signing it.\n\n\tzone = \"\"\"\n$ORIGIN {domain}.\n$TTL 86400          ; default time to live\n\n@ IN SOA ns1.{primary_domain}. hostmaster.{primary_domain}. (\n           __SERIAL__     ; serial number\n           7200     ; Refresh (secondary nameserver update interval)\n           3600     ; Retry (when refresh fails, how often to try again, should be lower than the refresh)\n           1209600  ; Expire (when refresh fails, how long secondary nameserver will keep records around anyway)\n           86400    ; Negative TTL (how long negative responses are cached)\n           )\n\"\"\"\n\n\t# Replace replacement strings.\n\tzone = zone.format(domain=domain, primary_domain=env[\"PRIMARY_HOSTNAME\"])\n\n\t# Add records.\n\tfor subdomain, querytype, value, _explanation in records:\n\t\tif subdomain:\n\t\t\tzone += subdomain\n\t\tzone += \"\\tIN\\t\" + querytype + \"\\t\"\n\t\tif querytype == \"TXT\":\n\t\t\t# Divide into 255-byte max substrings.\n\t\t\tv2 = \"\"\n\t\t\twhile len(value) > 0:\n\t\t\t\ts = value[0:255]\n\t\t\t\tvalue = value[255:]\n\t\t\t\ts = s.replace('\\\\', '\\\\\\\\') # escape backslashes\n\t\t\t\ts = s.replace('\"', '\\\\\"') # escape quotes\n\t\t\t\ts = '\"' + s + '\"' # wrap in quotes\n\t\t\t\tv2 += s + \" \"\n\t\t\tvalue = v2\n\t\tzone += value + \"\\n\"\n\n\t# Append a stable hash of DNSSEC signing keys in a comment.\n\tzone += f\"\\n; DNSSEC signing keys hash: {hash_dnssec_keys(domain, env)}\\n\"\n\n\t# DNSSEC requires re-signing a zone periodically. That requires\n\t# bumping the serial number even if no other records have changed.\n\t# We don't see the DNSSEC records yet, so we have to figure out\n\t# if a re-signing is necessary so we can prematurely bump the\n\t# serial number.\n\tforce_bump = False\n\tif not os.path.exists(zonefile + \".signed\"):\n\t\t# No signed file yet. Shouldn't normally happen unless a box\n\t\t# is going from not using DNSSEC to using DNSSEC.\n\t\tforce_bump = True\n\telse:\n\t\t# We've signed the domain. Check if we are close to the expiration\n\t\t# time of the signature. If so, we'll force a bump of the serial\n\t\t# number so we can re-sign it.\n\t\twith open(zonefile + \".signed\", encoding=\"utf-8\") as f:\n\t\t\tsigned_zone = f.read()\n\t\texpiration_times = re.findall(r\"\\sRRSIG\\s+SOA\\s+\\d+\\s+\\d+\\s\\d+\\s+(\\d{14})\", signed_zone)\n\t\tif len(expiration_times) == 0:\n\t\t\t# weird\n\t\t\tforce_bump = True\n\t\telse:\n\t\t\t# All of the times should be the same, but if not choose the soonest.\n\t\t\texpiration_time = min(expiration_times)\n\t\t\texpiration_time = datetime.datetime.strptime(expiration_time, \"%Y%m%d%H%M%S\")\n\t\t\tif expiration_time - datetime.datetime.now() < datetime.timedelta(days=3):\n\t\t\t\t# We're within three days of the expiration, so bump serial & resign.\n\t\t\t\tforce_bump = True\n\n\t# Set the serial number.\n\tserial = datetime.datetime.now().strftime(\"%Y%m%d00\")\n\tif os.path.exists(zonefile):\n\t\t# If the zone already exists, is different, and has a later serial number,\n\t\t# increment the number.\n\t\twith open(zonefile, encoding=\"utf-8\") as f:\n\t\t\texisting_zone = f.read()\n\t\t\tm = re.search(r\"(\\d+)\\s*;\\s*serial number\", existing_zone)\n\t\t\tif m:\n\t\t\t\t# Clear out the serial number in the existing zone file for the\n\t\t\t\t# purposes of seeing if anything *else* in the zone has changed.\n\t\t\t\texisting_serial = m.group(1)\n\t\t\t\texisting_zone = existing_zone.replace(m.group(0), \"__SERIAL__     ; serial number\")\n\n\t\t\t\t# If the existing zone is the same as the new zone (modulo the serial number),\n\t\t\t\t# there is no need to update the file. Unless we're forcing a bump.\n\t\t\t\tif zone == existing_zone and not force_bump and not force:\n\t\t\t\t\treturn False\n\n\t\t\t\t# If the existing serial is not less than a serial number\n\t\t\t\t# based on the current date plus 00, increment it. Otherwise,\n\t\t\t\t# the serial number is less than our desired new serial number\n\t\t\t\t# so we'll use the desired new number.\n\t\t\t\tif existing_serial >= serial:\n\t\t\t\t\tserial = str(int(existing_serial) + 1)\n\n\tzone = zone.replace(\"__SERIAL__\", serial)\n\n\t# Write the zone file.\n\twith open(zonefile, \"w\", encoding=\"utf-8\") as f:\n\t\tf.write(zone)\n\n\treturn True # file is updated\n\ndef get_dns_zonefile(zone, env):\n\tfor domain, fn in get_dns_zones(env):\n\t\tif zone == domain:\n\t\t\tbreak\n\telse:\n\t\tmsg = f\"{zone} is not a domain name that corresponds to a zone.\"\n\t\traise ValueError(msg)\n\n\tnsd_zonefile = \"/etc/nsd/zones/\" + fn\n\twith open(nsd_zonefile, encoding=\"utf-8\") as f:\n\t\treturn f.read()\n\n########################################################################\n\ndef write_nsd_conf(zonefiles, additional_records, env):\n\t# Write the list of zones to a configuration file.\n\tnsd_conf_file = \"/etc/nsd/nsd.conf.d/zones.conf\"\n\tnsdconf = \"\"\n\n\t# Append the zones.\n\tfor domain, zonefile in zonefiles:\n\t\tnsdconf += f\"\"\"\nzone:\n\tname: {domain}\n\tzonefile: {zonefile}\n\"\"\"\n\n\t\t# If custom secondary nameservers have been set, allow zone transfers\n\t\t# and, if not a subnet, notifies to them.\n\t\tfor ipaddr in get_secondary_dns(additional_records, mode=\"xfr\"):\n\t\t\tif \"/\" not in ipaddr:\n\t\t\t\tnsdconf += f\"\\n\\tnotify: {ipaddr} NOKEY\"\n\t\t\tnsdconf += f\"\\n\\tprovide-xfr: {ipaddr} NOKEY\\n\"\n\n\t# Check if the file is changing. If it isn't changing,\n\t# return False to flag that no change was made.\n\tif os.path.exists(nsd_conf_file):\n\t\twith open(nsd_conf_file, encoding=\"utf-8\") as f:\n\t\t\tif f.read() == nsdconf:\n\t\t\t\treturn False\n\n\t# Write out new contents and return True to signal that\n\t# configuration changed.\n\twith open(nsd_conf_file, \"w\", encoding=\"utf-8\") as f:\n\t\tf.write(nsdconf)\n\treturn True\n\n########################################################################\n\ndef find_dnssec_signing_keys(domain, env):\n\t# For key that we generated (one per algorithm)...\n\td = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec')\n\tkeyconfs = [f for f in os.listdir(d) if f.endswith(\".conf\")]\n\tfor keyconf in keyconfs:\n\t\t# Load the file holding the KSK and ZSK key filenames.\n\t\tkeyconf_fn = os.path.join(d, keyconf)\n\t\tkeyinfo = load_env_vars_from_file(keyconf_fn)\n\n\t\t# Skip this key if the conf file has a setting named DOMAINS,\n\t\t# holding a comma-separated list of domain names, and if this\n\t\t# domain is not in the list. This allows easily disabling a\n\t\t# key by setting \"DOMAINS=\" or \"DOMAINS=none\", other than\n\t\t# deleting the key's .conf file, which might result in the key\n\t\t# being regenerated next upgrade. Keys should be disabled if\n\t\t# they are not needed to reduce the DNSSEC query response size.\n\t\tif \"DOMAINS\" in keyinfo and domain not in [dd.strip() for dd in keyinfo[\"DOMAINS\"].split(\",\")]:\n\t\t\tcontinue\n\n\t\tfor keytype in (\"KSK\", \"ZSK\"):\n\t\t\tyield keytype, keyinfo[keytype]\n\ndef hash_dnssec_keys(domain, env):\n\t# Create a stable (by sorting the items) hash of all of the private keys\n\t# that will be used to sign this domain.\n\tkeydata = []\n\tfor keytype, keyfn in sorted(find_dnssec_signing_keys(domain, env)):\n\t\toldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + \".private\")\n\t\tkeydata.extend((keytype, keyfn))\n\t\twith open(oldkeyfn, encoding=\"utf-8\") as fr:\n\t\t\tkeydata.append( fr.read() )\n\tkeydata = \"\".join(keydata).encode(\"utf8\")\n\treturn hashlib.sha1(keydata).hexdigest()\n\ndef sign_zone(domain, zonefile, env):\n\t# Sign the zone with all of the keys that were generated during\n\t# setup so that the user can choose which to use in their DS record at\n\t# their registrar, and also to support migration to newer algorithms.\n\n\t# In order to use the key files generated at setup which are for\n\t# the domain _domain_, we have to re-write the files and place\n\t# the actual domain name in it, so that ldns-signzone works.\n\t#\n\t# Patch each key, storing the patched version in /tmp for now.\n\t# Each key has a .key and .private file. Collect a list of filenames\n\t# for all of the keys (and separately just the key-signing keys).\n\tall_keys = []\n\tksk_keys = []\n\tfor keytype, keyfn in find_dnssec_signing_keys(domain, env):\n\t\tnewkeyfn = '/tmp/' + keyfn.replace(\"_domain_\", domain)\n\n\t\tfor ext in (\".private\", \".key\"):\n\t\t\t# Copy the .key and .private files to /tmp to patch them up.\n\t\t\t#\n\t\t\t# Use os.umask and open().write() to securely create a copy that only\n\t\t\t# we (root) can read.\n\t\t\toldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ext)\n\t\t\twith open(oldkeyfn, encoding=\"utf-8\") as fr:\n\t\t\t\tkeydata = fr.read()\n\t\t\tkeydata = keydata.replace(\"_domain_\", domain)\n\t\t\tprev_umask = os.umask(0o77) # ensure written file is not world-readable\n\t\t\ttry:\n\t\t\t\twith open(newkeyfn + ext, \"w\", encoding=\"utf-8\") as fw:\n\t\t\t\t\tfw.write(keydata)\n\t\t\tfinally:\n\t\t\t\tos.umask(prev_umask) # other files we write should be world-readable\n\n\t\t# Put the patched key filename base (without extension) into the list of keys we'll sign with.\n\t\tall_keys.append(newkeyfn)\n\t\tif keytype == \"KSK\": ksk_keys.append(newkeyfn)\n\n\t# Do the signing.\n\texpiry_date = (datetime.datetime.now() + datetime.timedelta(days=30)).strftime(\"%Y%m%d\")\n\tshell('check_call', [\"/usr/bin/ldns-signzone\",\n\t\t# expire the zone after 30 days\n\t\t\"-e\", expiry_date,\n\n\t\t# use NSEC3\n\t\t\"-n\",\n\n\t\t# zonefile to sign\n\t\t\"/etc/nsd/zones/\" + zonefile,\n\t\t# keys to sign with (order doesn't matter -- it'll figure it out)\n\t\t*all_keys\n\t]\n\t)\n\n\t# Create a DS record based on the patched-up key files. The DS record is specific to the\n\t# zone being signed, so we can't use the .ds files generated when we created the keys.\n\t# The DS record points to the KSK only. Write this next to the zone file so we can\n\t# get it later to give to the user with instructions on what to do with it.\n\t#\n\t# Generate a DS record for each key. There are also several possible hash algorithms that may\n\t# be used, so we'll pre-generate all for each key. One DS record per line. Only one\n\t# needs to actually be deployed at the registrar. We'll select the preferred one\n\t# in the status checks.\n\twith open(\"/etc/nsd/zones/\" + zonefile + \".ds\", \"w\", encoding=\"utf-8\") as f:\n\t\tfor key in ksk_keys:\n\t\t\tfor digest_type in ('1', '2', '4'):\n\t\t\t\trr_ds = shell('check_output', [\"/usr/bin/ldns-key2ds\",\n\t\t\t\t\t\"-n\", # output to stdout\n\t\t\t\t\t\"-\" + digest_type, # 1=SHA1, 2=SHA256, 4=SHA384\n\t\t\t\t\tkey + \".key\"\n\t\t\t\t])\n\t\t\t\tf.write(rr_ds)\n\n\t# Remove the temporary patched key files.\n\tfor fn in all_keys:\n\t\tos.unlink(fn + \".private\")\n\t\tos.unlink(fn + \".key\")\n\n########################################################################\n\ndef write_opendkim_tables(domains, env):\n\t# Append a record to OpenDKIM's KeyTable and SigningTable for each domain\n\t# that we send mail from (zones and all subdomains).\n\n\topendkim_key_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private')\n\n\tif not os.path.exists(opendkim_key_file):\n\t\t# Looks like OpenDKIM is not installed.\n\t\treturn False\n\n\tconfig = {\n\t\t# The SigningTable maps email addresses to a key in the KeyTable that\n\t\t# specifies signing information for matching email addresses. Here we\n\t\t# map each domain to a same-named key.\n\t\t#\n\t\t# Elsewhere we set the DMARC policy for each domain such that mail claiming\n\t\t# to be From: the domain must be signed with a DKIM key on the same domain.\n\t\t# So we must have a separate KeyTable entry for each domain.\n\t\t\"SigningTable\":\n\t\t\t\"\".join(\n\t\t\t\tf\"*@{domain} {domain}\\n\"\n\t\t\t\tfor domain in domains\n\t\t\t),\n\n\t\t# The KeyTable specifies the signing domain, the DKIM selector, and the\n\t\t# path to the private key to use for signing some mail. Per DMARC, the\n\t\t# signing domain must match the sender's From: domain.\n\t\t\"KeyTable\":\n\t\t\t\"\".join(\n\t\t\t\tf\"{domain} {domain}:mail:{opendkim_key_file}\\n\"\n\t\t\t\tfor domain in domains\n\t\t\t),\n\t}\n\n\tdid_update = False\n\tfor filename, content in config.items():\n\t\t# Don't write the file if it doesn't need an update.\n\t\tif os.path.exists(\"/etc/opendkim/\" + filename):\n\t\t\twith open(\"/etc/opendkim/\" + filename, encoding=\"utf-8\") as f:\n\t\t\t\tif f.read() == content:\n\t\t\t\t\tcontinue\n\n\t\t# The contents needs to change.\n\t\twith open(\"/etc/opendkim/\" + filename, \"w\", encoding=\"utf-8\") as f:\n\t\t\tf.write(content)\n\t\tdid_update = True\n\n\t# Return whether the files changed. If they didn't change, there's\n\t# no need to kick the opendkim process.\n\treturn did_update\n\n########################################################################\n\ndef get_custom_dns_config(env, only_real_records=False):\n\ttry:\n\t\twith open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), encoding=\"utf-8\") as f:\n\t\t\tcustom_dns = rtyaml.load(f)\n\t\tif not isinstance(custom_dns, dict): raise ValueError # caught below\n\texcept:\n\t\treturn [ ]\n\n\tfor qname, value in custom_dns.items():\n\t\tif qname == \"_secondary_nameserver\" and only_real_records: continue # skip fake record\n\n\t\t# Short form. Mapping a domain name to a string is short-hand\n\t\t# for creating A records.\n\t\tif isinstance(value, str):\n\t\t\tvalues = [(\"A\", value)]\n\n\t\t# A mapping creates multiple records.\n\t\telif isinstance(value, dict):\n\t\t\tvalues = value.items()\n\n\t\t# No other type of data is allowed.\n\t\telse:\n\t\t\traise ValueError\n\n\t\tfor rtype, value2 in values:\n\t\t\tif isinstance(value2, str):\n\t\t\t\tyield (qname, rtype, value2)\n\t\t\telif isinstance(value2, list):\n\t\t\t\tfor value3 in value2:\n\t\t\t\t\tyield (qname, rtype, value3)\n\t\t\t# No other type of data is allowed.\n\t\t\telse:\n\t\t\t\traise ValueError\n\ndef filter_custom_records(domain, custom_dns_iter):\n\tfor qname, rtype, value in custom_dns_iter:\n\t\t# We don't count the secondary nameserver config (if present) as a record - that would just be\n\t\t# confusing to users. Instead it is accessed/manipulated directly via (get/set)_custom_dns_config.\n\t\tif qname == \"_secondary_nameserver\": continue\n\n\t\t# Is this record for the domain or one of its subdomains?\n\t\t# If `domain` is None, return records for all domains.\n\t\tif domain is not None and qname != domain and not qname.endswith(\".\" + domain): continue\n\n\t\t# Turn the fully qualified domain name in the YAML file into\n\t\t# our short form (None => domain, or a relative QNAME) if\n\t\t# domain is not None.\n\t\tif domain is not None:\n\t\t\tqname = None if qname == domain else qname[0:len(qname) - len(\".\" + domain)]\n\n\t\tyield (qname, rtype, value)\n\ndef write_custom_dns_config(config, env):\n\t# We get a list of (qname, rtype, value) triples. Convert this into a\n\t# nice dictionary format for storage on disk.\n\tfrom collections import OrderedDict\n\tconfig = list(config)\n\tdns = OrderedDict()\n\tseen_qnames = set()\n\n\t# Process the qnames in the order we see them.\n\tfor qname in [rec[0] for rec in config]:\n\t\tif qname in seen_qnames: continue\n\t\tseen_qnames.add(qname)\n\n\t\trecords = [(rec[1], rec[2]) for rec in config if rec[0] == qname]\n\t\tif len(records) == 1 and records[0][0] == \"A\":\n\t\t\tdns[qname] = records[0][1]\n\t\telse:\n\t\t\tdns[qname] = OrderedDict()\n\t\t\tseen_rtypes = set()\n\n\t\t\t# Process the rtypes in the order we see them.\n\t\t\tfor rtype in [rec[0] for rec in records]:\n\t\t\t\tif rtype in seen_rtypes: continue\n\t\t\t\tseen_rtypes.add(rtype)\n\n\t\t\t\tvalues = [rec[1] for rec in records if rec[0] == rtype]\n\t\t\t\tif len(values) == 1:\n\t\t\t\t\tvalues = values[0]\n\t\t\t\tdns[qname][rtype] = values\n\n\t# Write.\n\tconfig_yaml = rtyaml.dump(dns)\n\twith open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), \"w\", encoding=\"utf-8\") as f:\n\t\tf.write(config_yaml)\n\ndef set_custom_dns_record(qname, rtype, value, action, env):\n\t# validate qname\n\tfor zone, _fn in get_dns_zones(env):\n\t\t# It must match a zone apex or be a subdomain of a zone\n\t\t# that we are otherwise hosting.\n\t\tif qname == zone or qname.endswith(\".\"+zone):\n\t\t\tbreak\n\telse:\n\t\t# No match.\n\t\tif qname != \"_secondary_nameserver\":\n\t\t\tmsg = f\"{qname} is not a domain name or a subdomain of a domain name managed by this box.\"\n\t\t\traise ValueError(msg)\n\n\t# validate rtype\n\trtype = rtype.upper()\n\tif value is not None and qname != \"_secondary_nameserver\":\n\t\tif not re.search(DOMAIN_RE, qname):\n\t\t\tmsg = \"Invalid name.\"\n\t\t\traise ValueError(msg)\n\n\t\tif rtype in {\"A\", \"AAAA\"}:\n\t\t\tif value != \"local\": # \"local\" is a special flag for us\n\t\t\t\tv = ipaddress.ip_address(value) # raises a ValueError if there's a problem\n\t\t\t\tif rtype == \"A\" and not isinstance(v, ipaddress.IPv4Address): raise ValueError(\"That's an IPv6 address.\")\n\t\t\t\tif rtype == \"AAAA\" and not isinstance(v, ipaddress.IPv6Address): raise ValueError(\"That's an IPv4 address.\")\n\t\telif rtype in {\"CNAME\", \"NS\"}:\n\t\t\tif rtype == \"NS\" and qname == zone:\n\t\t\t\tmsg = \"NS records can only be set for subdomains.\"\n\t\t\t\traise ValueError(msg)\n\n\t\t\t# ensure value has a trailing dot\n\t\t\tif not value.endswith(\".\"):\n\t\t\t\tvalue += \".\"\n\n\t\t\tif not re.search(DOMAIN_RE, value):\n\t\t\t\tmsg = \"Invalid value.\"\n\t\t\t\traise ValueError(msg)\n\t\telif rtype in {\"CNAME\", \"TXT\", \"SRV\", \"MX\", \"SSHFP\", \"CAA\"}:\n\t\t\t# anything goes\n\t\t\tpass\n\t\telse:\n\t\t\tmsg = f\"Unknown record type '{rtype}'.\"\n\t\t\traise ValueError(msg)\n\n\t# load existing config\n\tconfig = list(get_custom_dns_config(env))\n\n\t# update\n\tnewconfig = []\n\tmade_change = False\n\tneeds_add = True\n\tfor _qname, _rtype, _value in config:\n\t\tif action == \"add\":\n\t\t\tif (_qname, _rtype, _value) == (qname, rtype, value):\n\t\t\t\t# Record already exists. Bail.\n\t\t\t\treturn False\n\t\telif action == \"set\":\n\t\t\tif (_qname, _rtype) == (qname, rtype):\n\t\t\t\tif _value == value:\n\t\t\t\t\t# Flag that the record already exists, don't\n\t\t\t\t\t# need to add it.\n\t\t\t\t\tneeds_add = False\n\t\t\t\telse:\n\t\t\t\t\t# Drop any other values for this (qname, rtype).\n\t\t\t\t\tmade_change = True\n\t\t\t\t\tcontinue\n\t\telif action == \"remove\":\n\t\t\tif (_qname, _rtype, _value) == (qname, rtype, value):\n\t\t\t\t# Drop this record.\n\t\t\t\tmade_change = True\n\t\t\t\tcontinue\n\t\t\tif value is None and (_qname, _rtype) == (qname, rtype):\n\t\t\t\t# Drop all qname-rtype records.\n\t\t\t\tmade_change = True\n\t\t\t\tcontinue\n\t\telse:\n\t\t\traise ValueError(\"Invalid action: \" + action)\n\n\t\t# Preserve this record.\n\t\tnewconfig.append((_qname, _rtype, _value))\n\n\tif action in {\"add\", \"set\"} and needs_add and value is not None:\n\t\tnewconfig.append((qname, rtype, value))\n\t\tmade_change = True\n\n\tif made_change:\n\t\t# serialize & save\n\t\twrite_custom_dns_config(newconfig, env)\n\treturn made_change\n\n########################################################################\n\ndef get_secondary_dns(custom_dns, mode=None):\n\tresolver = dns.resolver.get_default_resolver()\n\tresolver.timeout = 10\n\tresolver.lifetime = 10\n\n\tvalues = []\n\tfor qname, _rtype, value in custom_dns:\n\t\tif qname != '_secondary_nameserver': continue\n\t\tfor hostname in value.split(\" \"):\n\t\t\thostname = hostname.strip()\n\t\t\tif mode is None:\n\t\t\t\t# Just return the setting.\n\t\t\t\tvalues.append(hostname)\n\t\t\t\tcontinue\n\n\t\t\t# If the entry starts with \"xfr:\" only include it in the zone transfer settings.\n\t\t\tif hostname.startswith(\"xfr:\"):\n\t\t\t\tif mode != \"xfr\": continue\n\t\t\t\thostname = hostname[4:]\n\n\t\t\t# If is a hostname, before including in zone xfr lines,\n\t\t\t# resolve to an IP address.\n\t\t\t# It may not resolve to IPv6, so don't throw an exception if it\n\t\t\t# doesn't. Skip the entry if there is a DNS error.\n\t\t\tif mode == \"xfr\":\n\t\t\t\ttry:\n\t\t\t\t\tipaddress.ip_interface(hostname) # test if it's an IP address or CIDR notation\n\t\t\t\t\tvalues.append(hostname)\n\t\t\t\texcept ValueError:\n\t\t\t\t\ttry:\n\t\t\t\t\t\tresponse = dns.resolver.resolve(hostname+'.', \"A\", raise_on_no_answer=False)\n\t\t\t\t\t\tvalues.extend(map(str, response))\n\t\t\t\t\texcept dns.exception.DNSException:\n\t\t\t\t\t\tpass\n\t\t\t\t\ttry:\n\t\t\t\t\t\tresponse = dns.resolver.resolve(hostname+'.', \"AAAA\", raise_on_no_answer=False)\n\t\t\t\t\t\tvalues.extend(map(str, response))\n\t\t\t\t\texcept dns.exception.DNSException:\n\t\t\t\t\t\tpass\n\n\t\t\telse:\n\t\t\t\tvalues.append(hostname)\n\n\treturn values\n\ndef set_secondary_dns(hostnames, env):\n\tif len(hostnames) > 0:\n\t\t# Validate that all hostnames are valid and that all zone-xfer IP addresses are valid.\n\t\tresolver = dns.resolver.get_default_resolver()\n\t\tresolver.timeout = 5\n\t\tresolver.lifetime = 5\n\n\t\tfor item in hostnames:\n\t\t\tif not item.startswith(\"xfr:\"):\n\t\t\t\t# Resolve hostname.\n\t\t\t\ttry:\n\t\t\t\t\tresolver.resolve(item, \"A\")\n\t\t\t\texcept (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout):\n\t\t\t\t\ttry:\n\t\t\t\t\t\tresolver.resolve(item, \"AAAA\")\n\t\t\t\t\texcept (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout):\n\t\t\t\t\t\tmsg = f\"Could not resolve the IP address of {item}.\"\n\t\t\t\t\t\traise ValueError(msg)\n\t\t\telse:\n\t\t\t\t# Validate IP address.\n\t\t\t\ttry:\n\t\t\t\t\tif \"/\" in item[4:]:\n\t\t\t\t\t\tipaddress.ip_network(item[4:]) # raises a ValueError if there's a problem\n\t\t\t\t\telse:\n\t\t\t\t\t\tipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem\n\t\t\t\texcept ValueError:\n\t\t\t\t\tmsg = f\"'{item[4:]}' is not an IPv4 or IPv6 address or subnet.\"\n\t\t\t\t\traise ValueError(msg)\n\n\t\t# Set.\n\t\tset_custom_dns_record(\"_secondary_nameserver\", \"A\", \" \".join(hostnames), \"set\", env)\n\telse:\n\t\t# Clear.\n\t\tset_custom_dns_record(\"_secondary_nameserver\", \"A\", None, \"set\", env)\n\n\t# Apply.\n\treturn do_dns_update(env)\n\n\ndef get_custom_dns_records(custom_dns, qname, rtype):\n\tfor qname1, rtype1, value in custom_dns:\n\t\tif qname1 == qname and rtype1 == rtype:\n\t\t\tyield value\n\n########################################################################\n\ndef build_recommended_dns(env):\n\tret = []\n\tfor (domain, _zonefile, records) in build_zones(env):\n\t\t# remove records that we don't display\n\t\trecords = [r for r in records if r[3] is not False]\n\n\t\t# put Required at the top, then Recommended, then everythiing else\n\t\trecords.sort(key = lambda r : 0 if r[3].startswith(\"Required.\") else (1 if r[3].startswith(\"Recommended.\") else 2))\n\n\t\t# expand qnames\n\t\tfor i in range(len(records)):\n\t\t\tqname = domain if records[i][0] is None else records[i][0] + \".\" + domain\n\n\t\t\trecords[i] = {\n\t\t\t\t\"qname\": qname,\n\t\t\t\t\"rtype\": records[i][1],\n\t\t\t\t\"value\": records[i][2],\n\t\t\t\t\"explanation\": records[i][3],\n\t\t\t}\n\n\t\t# return\n\t\tret.append((domain, records))\n\treturn ret\n\nif __name__ == \"__main__\":\n\tfrom utils import load_environment\n\tenv = load_environment()\n\tif sys.argv[-1] == \"--lint\":\n\t\twrite_custom_dns_config(get_custom_dns_config(env), env)\n\telif sys.argv[-1] == \"--update\":\n\t\tdo_dns_update(env, force=True)\n\telse:\n\t\tfor _zone, records in build_recommended_dns(env):\n\t\t\tfor record in records:\n\t\t\t\tprint(\"; \" + record['explanation'])\n\t\t\t\tprint(record['qname'], record['rtype'], record['value'], sep=\"\\t\")\n\t\t\t\tprint()\n"
  },
  {
    "path": "management/email_administrator.py",
    "content": "#!/usr/local/lib/mailinabox/env/bin/python\n\n# Reads in STDIN. If the stream is not empty, mail it to the system administrator.\n\nimport sys\n\nimport html\nimport smtplib\n\nfrom email.mime.multipart import MIMEMultipart\nfrom email.mime.text import MIMEText\n\n# In Python 3.6:\n#from email.message import Message\n\nfrom utils import load_environment\n\n# Load system environment info.\nenv = load_environment()\n\n# Process command line args.\nsubject = sys.argv[1]\n\n# Administrator's email address.\nadmin_addr = \"administrator@\" + env['PRIMARY_HOSTNAME']\n\n# Read in STDIN.\ncontent = sys.stdin.read().strip()\n\n# If there's nothing coming in, just exit.\nif content == \"\":\n    sys.exit(0)\n\n# create MIME message\nmsg = MIMEMultipart('alternative')\n\n# In Python 3.6:\n#msg = Message()\n\nmsg['From'] = '\"{}\" <{}>'.format(env['PRIMARY_HOSTNAME'], admin_addr)\nmsg['To'] = admin_addr\nmsg['Subject'] = \"[{}] {}\".format(env['PRIMARY_HOSTNAME'], subject)\n\ncontent_html = f'<html><body><pre style=\"overflow-x: scroll; white-space: pre;\">{html.escape(content)}</pre></body></html>'\n\nmsg.attach(MIMEText(content, 'plain'))\nmsg.attach(MIMEText(content_html, 'html'))\n\n# In Python 3.6:\n#msg.set_content(content)\n#msg.add_alternative(content_html, \"html\")\n\n# send\nsmtpclient = smtplib.SMTP('127.0.0.1', 25)\nsmtpclient.ehlo()\nsmtpclient.sendmail(\n        admin_addr, # MAIL FROM\n        admin_addr, # RCPT TO\n        msg.as_string())\nsmtpclient.quit()\n"
  },
  {
    "path": "management/mail_log.py",
    "content": "#!/usr/local/lib/mailinabox/env/bin/python\nimport argparse\nimport datetime\nimport gzip\nimport os.path\nimport re\nimport shutil\nimport tempfile\nimport textwrap\nfrom collections import defaultdict, OrderedDict\n\nimport dateutil.parser\nimport time\n\nfrom dateutil.relativedelta import relativedelta\n\nimport utils\n\n\nLOG_FILES = (\n    '/var/log/mail.log.6.gz',\n    '/var/log/mail.log.5.gz',\n    '/var/log/mail.log.4.gz',\n    '/var/log/mail.log.3.gz',\n    '/var/log/mail.log.2.gz',\n    '/var/log/mail.log.1',\n    '/var/log/mail.log',\n)\n\nTIME_DELTAS = OrderedDict([\n    ('all', datetime.timedelta(weeks=52)),\n    ('month', datetime.timedelta(weeks=4)),\n    ('2weeks', datetime.timedelta(days=14)),\n    ('week', datetime.timedelta(days=7)),\n    ('2days', datetime.timedelta(days=2)),\n    ('day', datetime.timedelta(days=1)),\n    ('12hours', datetime.timedelta(hours=12)),\n    ('6hours', datetime.timedelta(hours=6)),\n    ('hour', datetime.timedelta(hours=1)),\n    ('30min', datetime.timedelta(minutes=30)),\n    ('10min', datetime.timedelta(minutes=10)),\n    ('5min', datetime.timedelta(minutes=5)),\n    ('min', datetime.timedelta(minutes=1)),\n    ('today', datetime.datetime.now() - datetime.datetime.now().replace(hour=0, minute=0, second=0))\n])\n\nEND_DATE = NOW = datetime.datetime.now()\nSTART_DATE = None\n\nVERBOSE = False\n\n# List of strings to filter users with\nFILTERS = None\n\n# What to show (with defaults)\nSCAN_OUT = True  # Outgoing email\nSCAN_IN = True  # Incoming email\nSCAN_DOVECOT_LOGIN = True  # Dovecot Logins\nSCAN_GREY = False  # Greylisted email\nSCAN_BLOCKED = False  # Rejected email\n\n\ndef scan_files(collector):\n    \"\"\" Scan files until they run out or the earliest date is reached \"\"\"\n\n    stop_scan = False\n\n    for fn in LOG_FILES:\n\n        tmp_file = None\n\n        if not os.path.exists(fn):\n            continue\n        if fn[-3:] == '.gz':\n            tmp_file = tempfile.NamedTemporaryFile()\n            with gzip.open(fn, 'rb') as f:\n                shutil.copyfileobj(f, tmp_file)\n\n        if VERBOSE:\n            print(\"Processing file\", fn, \"...\")\n        fn = tmp_file.name if tmp_file else fn\n\n        for line in readline(fn):\n            if scan_mail_log_line(line.strip(), collector) is False:\n                if stop_scan:\n                    return\n                stop_scan = True\n            else:\n                stop_scan = False\n\n\n\ndef scan_mail_log(env):\n    \"\"\" Scan the system's mail log files and collect interesting data\n\n    This function scans the 2 most recent mail log files in /var/log/.\n\n    Args:\n        env (dict): Dictionary containing MiaB settings\n\n    \"\"\"\n\n    collector = {\n        \"scan_count\": 0,  # Number of lines scanned\n        \"parse_count\": 0,  # Number of lines parsed (i.e. that had their contents examined)\n        \"scan_time\": time.time(),  # The time in seconds the scan took\n        \"sent_mail\": OrderedDict(),  # Data about email sent by users\n        \"received_mail\": OrderedDict(),  # Data about email received by users\n        \"logins\": OrderedDict(),  # Data about login activity\n        \"postgrey\": {},  # Data about greylisting of email addresses\n        \"rejected\": OrderedDict(),  # Emails that were blocked\n        \"known_addresses\": None,  # Addresses handled by the Miab installation\n        \"other-services\": set(),\n    }\n\n    try:\n        import mailconfig\n        collector[\"known_addresses\"] = (set(mailconfig.get_mail_users(env)) |\n                                        {alias[0] for alias in mailconfig.get_mail_aliases(env)})\n    except ImportError:\n        pass\n\n    print(f\"Scanning logs from {START_DATE:%Y-%m-%d %H:%M:%S} to {END_DATE:%Y-%m-%d %H:%M:%S}\"\n    )\n\n    # Scan the lines in the log files until the date goes out of range\n    scan_files(collector)\n\n    if not collector[\"scan_count\"]:\n        print(\"No log lines scanned...\")\n        return\n\n    collector[\"scan_time\"] = time.time() - collector[\"scan_time\"]\n\n    print(\"{scan_count} Log lines scanned, {parse_count} lines parsed in {scan_time:.2f} \"\n          \"seconds\\n\".format(**collector))\n\n    # Print Sent Mail report\n\n    if collector[\"sent_mail\"]:\n        msg = \"Sent email\"\n        print_header(msg)\n\n        data = OrderedDict(sorted(collector[\"sent_mail\"].items(), key=email_sort))\n\n        print_user_table(\n            data.keys(),\n            data=[\n                (\"sent\", [u[\"sent_count\"] for u in data.values()]),\n                (\"hosts\", [len(u[\"hosts\"]) for u in data.values()]),\n            ],\n            sub_data=[\n                (\"sending hosts\", [u[\"hosts\"] for u in data.values()]),\n            ],\n            activity=[\n                (\"sent\", [u[\"activity-by-hour\"] for u in data.values()]),\n            ],\n            earliest=[u[\"earliest\"] for u in data.values()],\n            latest=[u[\"latest\"] for u in data.values()],\n        )\n\n        accum = defaultdict(int)\n        data = collector[\"sent_mail\"].values()\n\n        for h in range(24):\n            accum[h] = sum(d[\"activity-by-hour\"][h] for d in data)\n\n        print_time_table(\n            [\"sent\"],\n            [accum]\n        )\n\n    # Print Received Mail report\n\n    if collector[\"received_mail\"]:\n        msg = \"Received email\"\n        print_header(msg)\n\n        data = OrderedDict(sorted(collector[\"received_mail\"].items(), key=email_sort))\n\n        print_user_table(\n            data.keys(),\n            data=[\n                (\"received\", [u[\"received_count\"] for u in data.values()]),\n            ],\n            activity=[\n                (\"sent\", [u[\"activity-by-hour\"] for u in data.values()]),\n            ],\n            earliest=[u[\"earliest\"] for u in data.values()],\n            latest=[u[\"latest\"] for u in data.values()],\n        )\n\n        accum = defaultdict(int)\n        for h in range(24):\n            accum[h] = sum(d[\"activity-by-hour\"][h] for d in data.values())\n\n        print_time_table(\n            [\"received\"],\n            [accum]\n        )\n\n    # Print login report\n\n    if collector[\"logins\"]:\n        msg = \"User logins per hour\"\n        print_header(msg)\n\n        data = OrderedDict(sorted(collector[\"logins\"].items(), key=email_sort))\n\n        # Get a list of all of the protocols seen in the logs in reverse count order.\n        all_protocols = defaultdict(int)\n        for u in data.values():\n            for protocol_name, count in u[\"totals_by_protocol\"].items():\n                all_protocols[protocol_name] += count\n        all_protocols = [k for k, v in sorted(all_protocols.items(), key=lambda kv : -kv[1])]\n\n        print_user_table(\n            data.keys(),\n            data=[\n                (protocol_name, [\n                    round(u[\"totals_by_protocol\"][protocol_name] / (u[\"latest\"]-u[\"earliest\"]).total_seconds() * 60*60, 1)\n                    if (u[\"latest\"]-u[\"earliest\"]).total_seconds() > 0\n                    else 0 # prevent division by zero\n                  for u in data.values()])\n                for protocol_name in all_protocols\n            ],\n            sub_data=[\n                (\"Protocol and Source\", [[\n                    f\"{protocol_name} {host}: {count} times\"\n                    for (protocol_name, host), count\n                    in sorted(u[\"totals_by_protocol_and_host\"].items(), key=lambda kv:-kv[1])\n                  ] for u in data.values()])\n            ],\n            activity=[\n                (protocol_name, [u[\"activity-by-hour\"][protocol_name] for u in data.values()])\n                for protocol_name in all_protocols\n            ],\n            earliest=[u[\"earliest\"] for u in data.values()],\n            latest=[u[\"latest\"] for u in data.values()],\n            numstr=lambda n : str(round(n, 1)),\n        )\n\n        accum = { protocol_name: defaultdict(int) for protocol_name in all_protocols }\n        for h in range(24):\n            for protocol_name in all_protocols:\n              accum[protocol_name][h] = sum(d[\"activity-by-hour\"][protocol_name][h] for d in data.values())\n\n        print_time_table(\n            all_protocols,\n            [accum[protocol_name] for protocol_name in all_protocols]\n        )\n\n    if collector[\"postgrey\"]:\n        msg = \"Greylisted Email {:%Y-%m-%d %H:%M:%S} and {:%Y-%m-%d %H:%M:%S}\"\n        print_header(msg.format(START_DATE, END_DATE))\n\n        print(textwrap.fill(\n            \"The following mail was greylisted, meaning the emails were temporarily rejected. \"\n            \"Legitimate senders must try again after three minutes.\",\n            width=80, initial_indent=\" \", subsequent_indent=\" \"\n        ), end='\\n\\n')\n\n        data = OrderedDict(sorted(collector[\"postgrey\"].items(), key=email_sort))\n        users = []\n        received = []\n        senders = []\n        sender_clients = []\n        delivered_dates = []\n\n        for recipient in data:\n            sorted_recipients = sorted(data[recipient].items(), key=lambda kv: kv[1][0] or kv[1][1])\n            for (client_address, sender), (first_date, delivered_date) in sorted_recipients:\n                if first_date:\n                    users.append(recipient)\n                    received.append(first_date)\n                    senders.append(sender)\n                    delivered_dates.append(delivered_date)\n                    sender_clients.append(client_address)\n\n        print_user_table(\n            users,\n            data=[\n                (\"received\", received),\n                (\"sender\", senders),\n                (\"delivered\", [str(d) or \"no retry yet\" for d in delivered_dates]),\n                (\"sending host\", sender_clients)\n            ],\n            delimit=True,\n        )\n\n    if collector[\"rejected\"]:\n        msg = \"Blocked Email {:%Y-%m-%d %H:%M:%S} and {:%Y-%m-%d %H:%M:%S}\"\n        print_header(msg.format(START_DATE, END_DATE))\n\n        data = OrderedDict(sorted(collector[\"rejected\"].items(), key=email_sort))\n\n        rejects = []\n\n        if VERBOSE:\n            for user_data in data.values():\n                user_rejects = []\n                for date, sender, message in user_data[\"blocked\"]:\n                    if len(sender) > 64:\n                        sender = sender[:32] + \"…\" + sender[-32:]\n                    user_rejects.extend((f'{date} - {sender} ', f'  {message}'))\n                rejects.append(user_rejects)\n\n        print_user_table(\n            data.keys(),\n            data=[\n                (\"blocked\", [len(u[\"blocked\"]) for u in data.values()]),\n            ],\n            sub_data=[\n                (\"blocked emails\", rejects),\n            ],\n            earliest=[u[\"earliest\"] for u in data.values()],\n            latest=[u[\"latest\"] for u in data.values()],\n        )\n\n    if collector[\"other-services\"] and VERBOSE and False:\n        print_header(\"Other services\")\n        print(\"The following unknown services were found in the log file.\")\n        print(\" \", *sorted(collector[\"other-services\"]), sep='\\n│ ')\n\n\ndef scan_mail_log_line(line, collector):\n    \"\"\" Scan a log line and extract interesting data \"\"\"\n\n    m = re.match(r\"(\\w+[\\s]+\\d+ \\d+:\\d+:\\d+) ([\\w]+ )?([\\w\\-/]+)[^:]*: (.*)\", line)\n\n    if not m:\n        return True\n\n    date, _system, service, log = m.groups()\n    collector[\"scan_count\"] += 1\n\n    # print()\n    # print(\"date:\", date)\n    # print(\"host:\", system)\n    # print(\"service:\", service)\n    # print(\"log:\", log)\n\n    # Replaced the dateutil parser for a less clever way of parser that is roughly 4 times faster.\n    # date = dateutil.parser.parse(date)\n\n    # strptime fails on Feb 29 with ValueError: day is out of range for month if correct year is not provided.\n    # See https://bugs.python.org/issue26460\n    date = datetime.datetime.strptime(str(NOW.year) + ' ' + date, '%Y %b %d %H:%M:%S')\n    # if log date in future, step back a year\n    if date > NOW:\n      date = date.replace(year = NOW.year - 1)\n    #print(\"date:\", date)\n\n    # Check if the found date is within the time span we are scanning\n    if date > END_DATE:\n        # Don't process, and halt\n        return False\n    if date < START_DATE:\n        # Don't process, but continue\n        return True\n\n    if service == \"postfix/submission/smtpd\":\n        if SCAN_OUT:\n            scan_postfix_submission_line(date, log, collector)\n    elif service == \"postfix/lmtp\":\n        if SCAN_IN:\n            scan_postfix_lmtp_line(date, log, collector)\n    elif service.endswith(\"-login\"):\n        if SCAN_DOVECOT_LOGIN:\n            scan_dovecot_login_line(date, log, collector, service[:4])\n    elif service == \"postgrey\":\n        if SCAN_GREY:\n            scan_postgrey_line(date, log, collector)\n    elif service == \"postfix/smtpd\":\n        if SCAN_BLOCKED:\n            scan_postfix_smtpd_line(date, log, collector)\n    elif service in {\"postfix/qmgr\", \"postfix/pickup\", \"postfix/cleanup\", \"postfix/scache\",\n                     \"spampd\", \"postfix/anvil\", \"postfix/master\", \"opendkim\", \"postfix/lmtp\",\n                     \"postfix/tlsmgr\", \"anvil\"}:\n        # nothing to look at\n        return True\n    else:\n        collector[\"other-services\"].add(service)\n        return True\n\n    collector[\"parse_count\"] += 1\n    return True\n\n\ndef scan_postgrey_line(date, log, collector):\n    \"\"\" Scan a postgrey log line and extract interesting data \"\"\"\n\n    m = re.match(r\"action=(greylist|pass), reason=(.*?), (?:delay=\\d+, )?client_name=(.*), \"\n                 r\"client_address=(.*), sender=(.*), recipient=(.*)\",\n                 log)\n\n    if m:\n\n        action, reason, client_name, client_address, sender, user = m.groups()\n\n        if user_match(user):\n\n            # Might be useful to group services that use a lot of mail different servers on sub\n            # domains like <sub>1.domein.com\n\n            # if '.' in client_name:\n            #     addr = client_name.split('.')\n            #     if len(addr) > 2:\n            #         client_name = '.'.join(addr[1:])\n\n            key = (client_address if client_name == 'unknown' else client_name, sender)\n\n            rep = collector[\"postgrey\"].setdefault(user, {})\n\n            if action == \"greylist\" and reason == \"new\":\n                rep[key] = (date, rep[key][1] if key in rep else None)\n            elif action == \"pass\":\n                rep[key] = (rep[key][0] if key in rep else None, date)\n\n\ndef scan_postfix_smtpd_line(date, log, collector):\n    \"\"\" Scan a postfix smtpd log line and extract interesting data \"\"\"\n\n    # Check if the incoming mail was rejected\n\n    m = re.match(r\"NOQUEUE: reject: RCPT from .*?: (.*?); from=<(.*?)> to=<(.*?)>\", log)\n\n    if m:\n        message, sender, user = m.groups()\n\n        # skip this, if reported in the greylisting report\n        if \"Recipient address rejected: Greylisted\" in message:\n            return\n\n        # only log mail to known recipients\n        if user_match(user) and (collector[\"known_addresses\"] is None or user in collector[\"known_addresses\"]):\n            data = collector[\"rejected\"].get(\n                user,\n                {\n                    \"blocked\": [],\n                    \"earliest\": None,\n                    \"latest\": None,\n                }\n            )\n            # simplify this one\n            m = re.search(\n                r\"Client host \\[(.*?)\\] blocked using zen.spamhaus.org; (.*)\", message\n            )\n            if m:\n                message = \"ip blocked: \" + m.group(2)\n            else:\n                # simplify this one too\n                m = re.search(\n                    r\"Sender address \\[.*@(.*)\\] blocked using dbl.spamhaus.org; (.*)\", message\n                )\n                if m:\n                    message = \"domain blocked: \" + m.group(2)\n\n            if data[\"earliest\"] is None:\n                data[\"earliest\"] = date\n            data[\"latest\"] = date\n            data[\"blocked\"].append((date, sender, message))\n\n            collector[\"rejected\"][user] = data\n\n\ndef scan_dovecot_login_line(date, log, collector, protocol_name):\n    \"\"\" Scan a dovecot login log line and extract interesting data \"\"\"\n\n    m = re.match(r\"Info: Login: user=<(.*?)>, method=PLAIN, rip=(.*?),\", log)\n\n    if m:\n        # TODO: CHECK DIT\n        user, host = m.groups()\n\n        if user_match(user):\n            add_login(user, date, protocol_name, host, collector)\n\n\ndef add_login(user, date, protocol_name, host, collector):\n            # Get the user data, or create it if the user is new\n            data = collector[\"logins\"].get(\n                user,\n                {\n                    \"earliest\": None,\n                    \"latest\": None,\n                    \"totals_by_protocol\": defaultdict(int),\n                    \"totals_by_protocol_and_host\": defaultdict(int),\n                    \"activity-by-hour\": defaultdict(lambda : defaultdict(int)),\n                }\n            )\n\n            if data[\"earliest\"] is None:\n                data[\"earliest\"] = date\n            data[\"latest\"] = date\n\n            data[\"totals_by_protocol\"][protocol_name] += 1\n            data[\"totals_by_protocol_and_host\"][protocol_name, host] += 1\n\n            if host not in {\"127.0.0.1\", \"::1\"} or True:\n                data[\"activity-by-hour\"][protocol_name][date.hour] += 1\n\n            collector[\"logins\"][user] = data\n\n\ndef scan_postfix_lmtp_line(date, log, collector):\n    \"\"\" Scan a postfix lmtp log line and extract interesting data\n\n    It is assumed that every log of postfix/lmtp indicates an email that was successfully\n    received by Postfix.\n\n    \"\"\"\n\n    m = re.match(r\"([A-Z0-9]+): to=<(\\S+)>, .* Saved\", log)\n\n    if m:\n        _, user = m.groups()\n\n        if user_match(user):\n            # Get the user data, or create it if the user is new\n            data = collector[\"received_mail\"].get(\n                user,\n                {\n                    \"received_count\": 0,\n                    \"earliest\": None,\n                    \"latest\": None,\n                    \"activity-by-hour\": defaultdict(int),\n                }\n            )\n\n            data[\"received_count\"] += 1\n            data[\"activity-by-hour\"][date.hour] += 1\n\n            if data[\"earliest\"] is None:\n                data[\"earliest\"] = date\n            data[\"latest\"] = date\n\n            collector[\"received_mail\"][user] = data\n\n\ndef scan_postfix_submission_line(date, log, collector):\n    \"\"\" Scan a postfix submission log line and extract interesting data\n\n    Lines containing a sasl_method with the values PLAIN or LOGIN are assumed to indicate a sent\n    email.\n\n    \"\"\"\n\n    # Match both the 'plain' and 'login' sasl methods, since both authentication methods are\n    # allowed by Dovecot. Exclude trailing comma after the username when additional fields\n\t# follow after.\n    m = re.match(r\"([A-Z0-9]+): client=(\\S+), sasl_method=(PLAIN|LOGIN), sasl_username=(\\S+)(?<!,)\", log)\n\n    if m:\n        _, client, _method, user = m.groups()\n\n        if user_match(user):\n            # Get the user data, or create it if the user is new\n            data = collector[\"sent_mail\"].get(\n                user,\n                {\n                    \"sent_count\": 0,\n                    \"hosts\": set(),\n                    \"earliest\": None,\n                    \"latest\": None,\n                    \"activity-by-hour\": defaultdict(int),\n                }\n            )\n\n            data[\"sent_count\"] += 1\n            data[\"hosts\"].add(client)\n            data[\"activity-by-hour\"][date.hour] += 1\n\n            if data[\"earliest\"] is None:\n                data[\"earliest\"] = date\n            data[\"latest\"] = date\n\n            collector[\"sent_mail\"][user] = data\n\n            # Also log this as a login.\n            add_login(user, date, \"smtp\", client, collector)\n\n# Utility functions\n\ndef readline(filename):\n    \"\"\" A generator that returns the lines of a file\n    \"\"\"\n    with open(filename, errors='replace', encoding='utf-8') as file:\n        while True:\n          line = file.readline()\n          if not line:\n              break\n          yield line\n\n\ndef user_match(user):\n    \"\"\" Check if the given user matches any of the filters \"\"\"\n    return FILTERS is None or any(u in user for u in FILTERS)\n\n\ndef email_sort(email):\n    \"\"\" Split the given email address into a reverse order tuple, for sorting i.e (domain, name) \"\"\"\n    return tuple(reversed(email[0].split('@')))\n\n\ndef valid_date(string):\n    \"\"\" Validate the given date string fetched from the --enddate argument \"\"\"\n    try:\n        date = dateutil.parser.parse(string)\n    except ValueError:\n        msg = f\"Unrecognized date and/or time '{string}'\"\n        raise argparse.ArgumentTypeError(msg)\n    return date\n\n\n# Print functions\n\ndef print_time_table(labels, data, do_print=True):\n    labels.insert(0, \"hour\")\n    data.insert(0, [str(h) for h in range(24)])\n\n    temp = \"│ {:<%d} \" % max(len(l) for l in labels)\n    lines = [temp.format(label) for label in labels]\n\n    for h in range(24):\n        max_len = max(len(str(d[h])) for d in data)\n        base = \"{:>%d} \" % max(2, max_len)\n\n        for i, d in enumerate(data):\n            lines[i] += base.format(d[h])\n\n    lines.insert(0, \"┬ totals by time of day:\")\n    lines.append(\"└\" + (len(lines[-1]) - 2) * \"─\")\n\n    if do_print:\n        print(\"\\n\".join(lines))\n        return None\n    return lines\n\n\ndef print_user_table(users, data=None, sub_data=None, activity=None, latest=None, earliest=None,\n                     delimit=False, numstr=str):\n    str_temp = \"{:<32} \"\n    lines = []\n    data = data or []\n\n    col_widths = len(data) * [0]\n    col_left = len(data) * [False]\n    vert_pos = 0\n\n    do_accum = all(isinstance(n, (int, float)) for _, d in data for n in d)\n    data_accum = len(data) * ([0] if do_accum else [\" \"])\n\n    last_user = None\n\n    for row, user in enumerate(users):\n\n        if delimit:\n            if last_user and last_user != user:\n                lines.append(len(lines[-1]) * \"…\")\n            last_user = user\n\n        line = \"{:<32} \".format(user[:31] + \"…\" if len(user) > 32 else user)\n\n        for col, (l, d) in enumerate(data):\n            if isinstance(d[row], str):\n                col_str = str_temp.format(d[row][:31] + \"…\" if len(d[row]) > 32 else d[row])\n                col_left[col] = True\n            elif isinstance(d[row], datetime.datetime):\n                col_str = f\"{d[row]!s:<20}\"\n                col_left[col] = True\n            else:\n                temp = f\"{{:>{max(5, len(l) + 1, len(str(d[row])) + 1)}}}\"\n                col_str = temp.format(str(d[row]))\n            col_widths[col] = max(col_widths[col], len(col_str))\n            line += col_str\n\n            if do_accum:\n                data_accum[col] += d[row]\n\n        try:\n            if None not in [latest, earliest]: # noqa: PLR6201\n                vert_pos = len(line)\n                e = earliest[row]\n                l = latest[row]\n                timespan = relativedelta(l, e)\n                if timespan.months:\n                    temp = \" │ {:0.1f} months\"\n                    line += temp.format(timespan.months + timespan.days / 30.0)\n                elif timespan.days:\n                    temp = \" │ {:0.1f} days\"\n                    line += temp.format(timespan.days + timespan.hours / 24.0)\n                elif (e.hour, e.minute) == (l.hour, l.minute):\n                    temp = \" │ {:%H:%M}\"\n                    line += temp.format(e)\n                else:\n                    temp = \" │ {:%H:%M} - {:%H:%M}\"\n                    line += temp.format(e, l)\n\n        except KeyError:\n            pass\n\n        lines.append(line.rstrip())\n\n        try:\n            if VERBOSE:\n                if sub_data is not None:\n                    for l, d in sub_data:\n                        if d[row]:\n                            lines.extend(('┬', f'│ {l}', '├─%s─' % (len(l) * '─'), '│'))\n                            max_len = 0\n                            for v in list(d[row]):\n                                lines.append(f\"│ {v}\")\n                                max_len = max(max_len, len(v))\n                            lines.append(\"└\" + (max_len + 1) * \"─\")\n\n                if activity is not None:\n                    lines.extend(print_time_table(\n                        [label for label, _ in activity],\n                        [data[row] for _, data in activity],\n                        do_print=False\n                    ))\n\n        except KeyError:\n            pass\n\n    header = str_temp.format(\"\")\n\n    for col, (l, _) in enumerate(data):\n        if col_left[col]:\n            header += l.ljust(max(5, len(l) + 1, col_widths[col]))\n        else:\n            header += l.rjust(max(5, len(l) + 1, col_widths[col]))\n\n    if None not in [latest, earliest]: # noqa: PLR6201\n        header += \" │ timespan   \"\n\n    lines.insert(0, header.rstrip())\n\n    table_width = max(len(l) for l in lines)\n    t_line = table_width * \"─\"\n    b_line = table_width * \"─\"\n\n    if vert_pos:\n        t_line = t_line[:vert_pos + 1] + \"┼\" + t_line[vert_pos + 2:]\n        b_line = b_line[:vert_pos + 1] + (\"┬\" if VERBOSE else \"┼\") + b_line[vert_pos + 2:]\n\n    lines.insert(1, t_line)\n    lines.append(b_line)\n\n    # Print totals\n\n    data_accum = [numstr(a) for a in data_accum]\n    footer = str_temp.format(\"Totals:\" if do_accum else \" \")\n    for row, (l, _) in enumerate(data):\n        temp = \"{:>%d}\" % max(5, len(l) + 1)\n        footer += temp.format(data_accum[row])\n\n    try:\n        if None not in [latest, earliest]: # noqa: PLR6201\n            max_l = max(latest)\n            min_e = min(earliest)\n            timespan = relativedelta(max_l, min_e)\n            if timespan.days:\n                temp = \" │ {:0.2f} days\"\n                footer += temp.format(timespan.days + timespan.hours / 24.0)\n            elif (min_e.hour, min_e.minute) == (max_l.hour, max_l.minute):\n                temp = \" │ {:%H:%M}\"\n                footer += temp.format(min_e)\n            else:\n                temp = \" │ {:%H:%M} - {:%H:%M}\"\n                footer += temp.format(min_e, max_l)\n\n    except KeyError:\n        pass\n\n    lines.append(footer)\n\n    print(\"\\n\".join(lines))\n\n\ndef print_header(msg):\n    print('\\n' + msg)\n    print(\"═\" * len(msg), '\\n')\n\n\nif __name__ == \"__main__\":\n    try:\n        env_vars = utils.load_environment()\n    except FileNotFoundError:\n        env_vars = {}\n\n    parser = argparse.ArgumentParser(\n        description=\"Scan the mail log files for interesting data. By default, this script \"\n                    \"shows today's incoming and outgoing mail statistics. This script was (\"\n                    \"re)written for the Mail-in-a-box email server.\"\n                    \"https://github.com/mail-in-a-box/mailinabox\",\n        add_help=False\n    )\n\n    # Switches to determine what to parse and what to ignore\n\n    parser.add_argument(\"-r\", \"--received\", help=\"Scan for received emails.\",\n                        action=\"store_true\")\n    parser.add_argument(\"-s\", \"--sent\", help=\"Scan for sent emails.\",\n                        action=\"store_true\")\n    parser.add_argument(\"-l\", \"--logins\", help=\"Scan for user logins to IMAP/POP3.\",\n                        action=\"store_true\")\n    parser.add_argument(\"-g\", \"--grey\", help=\"Scan for greylisted emails.\",\n                        action=\"store_true\")\n    parser.add_argument(\"-b\", \"--blocked\", help=\"Scan for blocked emails.\",\n                        action=\"store_true\")\n\n    parser.add_argument(\"-t\", \"--timespan\", choices=TIME_DELTAS.keys(), default='today',\n                        metavar='<time span>',\n                        help=\"Time span to scan, going back from the end date. Possible values: \"\n                             \"{}. Defaults to 'today'.\".format(\", \".join(list(TIME_DELTAS.keys()))))\n    # keep the --startdate arg for backward compatibility\n    parser.add_argument(\"-d\", \"--enddate\", \"--startdate\",  action=\"store\", dest=\"enddate\",\n                        type=valid_date, metavar='<end date>',\n                        help=\"Date and time to end scanning the log file. If no date is \"\n                             \"provided, scanning will end at the current date and time. \"\n                             \"Alias --startdate is for compatibility.\")\n    parser.add_argument(\"-u\", \"--users\", action=\"store\", dest=\"users\",\n                        metavar='<email1,email2,email...>',\n                        help=\"Comma separated list of (partial) email addresses to filter the \"\n                             \"output with.\")\n\n    parser.add_argument('-h', '--help', action='help', help=\"Print this message and exit.\")\n    parser.add_argument(\"-v\", \"--verbose\", help=\"Output extra data where available.\",\n                        action=\"store_true\")\n\n    args = parser.parse_args()\n\n    if args.enddate is not None:\n        END_DATE = args.enddate\n        if args.timespan == 'today':\n            args.timespan = 'day'\n        print(f\"Setting end date to {END_DATE}\")\n\n    START_DATE = END_DATE - TIME_DELTAS[args.timespan]\n\n    VERBOSE = args.verbose\n\n    if args.received or args.sent or args.logins or args.grey or args.blocked:\n        SCAN_IN = args.received\n        if not SCAN_IN:\n            print(\"Ignoring received emails\")\n\n        SCAN_OUT = args.sent\n        if not SCAN_OUT:\n            print(\"Ignoring sent emails\")\n\n        SCAN_DOVECOT_LOGIN = args.logins\n        if not SCAN_DOVECOT_LOGIN:\n            print(\"Ignoring logins\")\n\n        SCAN_GREY = args.grey\n        if SCAN_GREY:\n            print(\"Showing greylisted emails\")\n\n        SCAN_BLOCKED = args.blocked\n        if SCAN_BLOCKED:\n            print(\"Showing blocked emails\")\n\n    if args.users is not None:\n        FILTERS = args.users.strip().split(',')\n\n    scan_mail_log(env_vars)\n"
  },
  {
    "path": "management/mailconfig.py",
    "content": "#!/usr/local/lib/mailinabox/env/bin/python\n\n# NOTE:\n# This script is run both using the system-wide Python 3\n# interpreter (/usr/bin/python3) as well as through the\n# virtualenv (/usr/local/lib/mailinabox/env). So only\n# import packages at the top level of this script that\n# are installed in *both* contexts. We use the system-wide\n# Python 3 in setup/questions.sh to validate the email\n# address entered by the user.\n\nimport os, sqlite3, re\n\nimport utils\nfrom email_validator import validate_email as validate_email_, EmailNotValidError\nimport idna\nimport operator\n\ndef validate_email(email, mode=None):\n\t# Checks that an email address is syntactically valid. Returns True/False.\n\t# An email address may contain ASCII characters only because Dovecot's\n\t# authentication mechanism gets confused with other character encodings.\n\t#\n\t# When mode==\"user\", we're checking that this can be a user account name.\n\t# Dovecot has tighter restrictions - letters, numbers, underscore, and\n\t# dash only!\n\t#\n\t# When mode==\"alias\", we're allowing anything that can be in a Postfix\n\t# alias table, i.e. omitting the local part (\"@domain.tld\") is OK.\n\n\t# Check the syntax of the address.\n\ttry:\n\t\tvalidate_email_(email,\n\t\t\tallow_smtputf8=False,\n\t\t\tcheck_deliverability=False,\n\t\t\tallow_empty_local=(mode==\"alias\")\n\t\t\t)\n\texcept EmailNotValidError:\n\t\treturn False\n\n\tif mode == 'user':\n\t\t# There are a lot of characters permitted in email addresses, but\n\t\t# Dovecot's sqlite auth driver seems to get confused if there are any\n\t\t# unusual characters in the address. Bah. Also note that since\n\t\t# the mailbox path name is based on the email address, the address\n\t\t# shouldn't be absurdly long and must not have a forward slash.\n\t\t# Our database is case sensitive (oops), which affects mail delivery\n\t\t# (Postfix always queries in lowercase?), so also only permit lowercase\n\t\t# letters.\n\t\tif len(email) > 255: return False\n\t\tif re.search(r'[^\\@\\.a-z0-9_\\-]+', email):\n\t\t\treturn False\n\n\t# Everything looks good.\n\treturn True\n\ndef sanitize_idn_email_address(email):\n\t# The user may enter Unicode in an email address. Convert the domain part\n\t# to IDNA before going into our database. Leave the local part alone ---\n\t# although validate_email will reject non-ASCII characters.\n\t#\n\t# The domain name system only exists in ASCII, so it doesn't make sense\n\t# to store domain names in Unicode. We want to store what is meaningful\n\t# to the underlying protocols.\n\ttry:\n\t\tlocalpart, domainpart = email.split(\"@\")\n\t\tdomainpart = idna.encode(domainpart).decode('ascii')\n\t\treturn localpart + \"@\" + domainpart\n\texcept (ValueError, idna.IDNAError):\n\t\t# ValueError: String does not have a single @-sign, so it is not\n\t\t# a valid email address. IDNAError: Domain part is not IDNA-valid.\n\t\t# Validation is not this function's job, so return value unchanged.\n\t\t# If there are non-ASCII characters it will be filtered out by\n\t\t# validate_email.\n\t\treturn email\n\ndef prettify_idn_email_address(email):\n\t# This is the opposite of sanitize_idn_email_address. We store domain\n\t# names in IDNA in the database, but we want to show Unicode to the user.\n\ttry:\n\t\tlocalpart, domainpart = email.split(\"@\")\n\t\tdomainpart = idna.decode(domainpart.encode(\"ascii\"))\n\t\treturn localpart + \"@\" + domainpart\n\texcept (ValueError, UnicodeError, idna.IDNAError):\n\t\t# Failed to decode IDNA, or the email address does not have a\n\t\t# single @-sign. Should never happen.\n\t\treturn email\n\ndef is_dcv_address(email):\n\temail = email.lower()\n\treturn any(email.startswith((localpart + \"@\", localpart + \"+\")) for localpart in (\"admin\", \"administrator\", \"postmaster\", \"hostmaster\", \"webmaster\", \"abuse\"))\n\ndef open_database(env, with_connection=False):\n\tconn = sqlite3.connect(env[\"STORAGE_ROOT\"] + \"/mail/users.sqlite\")\n\tif not with_connection:\n\t\treturn conn.cursor()\n\treturn conn, conn.cursor()\n\ndef get_mail_users(env):\n\t# Returns a flat, sorted list of all user accounts.\n\tc = open_database(env)\n\tc.execute('SELECT email FROM users')\n\tusers = [ row[0] for row in c.fetchall() ]\n\treturn utils.sort_email_addresses(users, env)\n\ndef sizeof_fmt(num):\n\tfor unit in ['','K','M','G','T']:\n\t\tif abs(num) < 1024.0:\n\t\t\tif abs(num) > 99:\n\t\t\t\treturn f\"{num:3.0f}{unit}\"\n\t\t\treturn f\"{num:2.1f}{unit}\"\n\n\t\tnum /= 1024.0\n\n\treturn str(num)\n\ndef get_mail_users_ex(env, with_archived=False):\n\t# Returns a complex data structure of all user accounts, optionally\n\t# including archived (status=\"inactive\") accounts.\n\t#\n\t# [\n\t#   {\n\t#     domain: \"domain.tld\",\n\t#     users: [\n\t#       {\n\t#         email: \"name@domain.tld\",\n\t#         privileges: [ \"priv1\", \"priv2\", ... ],\n\t#         status: \"active\" | \"inactive\",\n\t#       },\n\t#       ...\n\t#     ]\n\t#   },\n\t#   ...\n\t# ]\n\n\t# Get users and their privileges.\n\tusers = []\n\tactive_accounts = set()\n\tc = open_database(env)\n\tc.execute('SELECT email, privileges, quota FROM users')\n\tfor email, privileges, quota in c.fetchall():\n\t\tactive_accounts.add(email)\n\n\t\t(user, domain) = email.split('@')\n\t\tbox_size = 0\n\t\tbox_quota = 0\n\t\tpercent = ''\n\t\ttry:\n\t\t\tdirsize_file = os.path.join(env['STORAGE_ROOT'], f'mail/mailboxes/{domain}/{user}/maildirsize')\n\t\t\twith open(dirsize_file, encoding=\"utf-8\") as f:\n\t\t\t\tbox_quota = int(f.readline().split('S')[0])\n\t\t\t\tfor line in f:\n\t\t\t\t\t(size, _count) = line.split(' ')\n\t\t\t\t\tbox_size += int(size)\n\n\t\t\ttry:\n\t\t\t\tpercent = (box_size / box_quota) * 100\n\t\t\texcept:\n\t\t\t\tpercent = 'Error'\n\n\t\texcept:\n\t\t\tbox_size = '?'\n\t\t\tbox_quota = '?'\n\t\t\tpercent = '?'\n\n\t\tif quota == '0':\n\t\t\tpercent = ''\n\n\t\tuser = {\n\t\t\t\"email\": email,\n\t\t\t\"privileges\": parse_privs(privileges),\n            \"quota\": quota,\n\t\t\t\"box_quota\": box_quota,\n\t\t\t\"box_size\": sizeof_fmt(box_size) if box_size != '?' else box_size,\n\t\t\t\"percent\": f'{percent:3.0f}%' if type(percent) != str else percent,\n\t\t\t\"status\": \"active\",\n\t\t}\n\t\tusers.append(user)\n\n\t# Add in archived accounts.\n\tif with_archived:\n\t\troot = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes')\n\t\tfor domain in os.listdir(root):\n\t\t\tif os.path.isdir(os.path.join(root, domain)):\n\t\t\t\tfor user in os.listdir(os.path.join(root, domain)):\n\t\t\t\t\temail = user + \"@\" + domain\n\t\t\t\t\tmbox = os.path.join(root, domain, user)\n\t\t\t\t\tif email in active_accounts: continue\n\t\t\t\t\tuser = {\n\t\t\t\t\t\t\"email\": email,\n\t\t\t\t\t\t\"privileges\": [],\n\t\t\t\t\t\t\"status\": \"inactive\",\n\t\t\t\t\t\t\"mailbox\": mbox,\n                        \"box_size\": '?',\n                        \"box_quota\": '?',\n                        \"percent\": '?',\n\t\t\t\t\t}\n\t\t\t\t\tusers.append(user)\n\n\t# Group by domain.\n\tdomains = { }\n\tfor user in users:\n\t\tdomain = get_domain(user[\"email\"])\n\t\tif domain not in domains:\n\t\t\tdomains[domain] = {\n\t\t\t\t\"domain\": domain,\n\t\t\t\t\"users\": []\n\t\t\t\t}\n\t\tdomains[domain][\"users\"].append(user)\n\n\t# Sort domains.\n\tdomains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]\n\n\t# Sort users within each domain first by status then lexicographically by email address.\n\tfor domain in domains:\n\t\tdomain[\"users\"].sort(key = lambda user : (user[\"status\"] != \"active\", user[\"email\"]))\n\n\treturn domains\n\ndef get_admins(env):\n\t# Returns a set of users with admin privileges.\n\tusers = set()\n\tfor domain in get_mail_users_ex(env):\n\t\tfor user in domain[\"users\"]:\n\t\t\tif \"admin\" in user[\"privileges\"]:\n\t\t\t\tusers.add(user[\"email\"])\n\treturn users\n\ndef get_mail_aliases(env):\n\t# Returns a sorted list of tuples of (address, forward-tos, permitted-senders, auto).\n\tc = open_database(env)\n\tc.execute('SELECT source, destination, permitted_senders, 0 as auto FROM aliases UNION SELECT source, destination, permitted_senders, 1 as auto FROM auto_aliases')\n\taliases = { row[0]: row for row in c.fetchall() } # make dict\n\n\t# put in a canonical order: sort by domain, then by email address lexicographically\n\treturn [ aliases[address] for address in utils.sort_email_addresses(aliases.keys(), env) ]\n\ndef get_mail_aliases_ex(env):\n\t# Returns a complex data structure of all mail aliases, similar\n\t# to get_mail_users_ex.\n\t#\n\t# [\n\t#   {\n\t#     domain: \"domain.tld\",\n\t#     alias: [\n\t#       {\n\t#         address: \"name@domain.tld\", # IDNA-encoded\n\t#         address_display: \"name@domain.tld\", # full Unicode\n\t#         forwards_to: [\"user1@domain.com\", \"receiver-only1@domain.com\", ...],\n\t#         permitted_senders: [\"user1@domain.com\", \"sender-only1@domain.com\", ...] OR null,\n\t#         auto: True|False\n\t#       },\n\t#       ...\n\t#     ]\n\t#   },\n\t#   ...\n\t# ]\n\n\tdomains = {}\n\tfor address, forwards_to, permitted_senders, auto in get_mail_aliases(env):\n\t\t# skip auto domain maps since these are not informative in the control panel's aliases list\n\t\tif auto and address.startswith(\"@\"): continue\n\n\t\t# get alias info\n\t\tdomain = get_domain(address)\n\n\t\t# add to list\n\t\tif domain not in domains:\n\t\t\tdomains[domain] = {\n\t\t\t\t\"domain\": domain,\n\t\t\t\t\"aliases\": [],\n\t\t\t}\n\t\tdomains[domain][\"aliases\"].append({\n\t\t\t\"address\": address,\n\t\t\t\"address_display\": prettify_idn_email_address(address),\n\t\t\t\"forwards_to\": [prettify_idn_email_address(r.strip()) for r in forwards_to.split(\",\")],\n\t\t\t\"permitted_senders\": [prettify_idn_email_address(s.strip()) for s in permitted_senders.split(\",\")] if permitted_senders is not None else None,\n\t\t\t\"auto\": bool(auto),\n\t\t})\n\n\t# Sort domains.\n\tdomains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]\n\n\t# Sort aliases within each domain first by required-ness then lexicographically by address.\n\tfor domain in domains:\n\t\tdomain[\"aliases\"].sort(key = operator.itemgetter(\"auto\", \"address\"))\n\treturn domains\n\ndef get_domain(emailaddr, as_unicode=True):\n\t# Gets the domain part of an email address. Turns IDNA\n\t# back to Unicode for display.\n\tret = emailaddr.split('@', 1)[1]\n\tif as_unicode:\n\t\ttry:\n\t\t\tret = idna.decode(ret.encode('ascii'))\n\t\texcept (ValueError, UnicodeError, idna.IDNAError):\n\t\t\t# Looks like we have an invalid email address in\n\t\t\t# the database. Now is not the time to complain.\n\t\t\tpass\n\treturn ret\n\ndef get_mail_domains(env, filter_aliases=lambda alias : True, users_only=False):\n\t# Returns the domain names (IDNA-encoded) of all of the email addresses\n\t# configured on the system. If users_only is True, only return domains\n\t# with email addresses that correspond to user accounts. Exclude Unicode\n\t# forms of domain names listed in the automatic aliases table.\n\tdomains = []\n\tdomains.extend([get_domain(login, as_unicode=False) for login in get_mail_users(env)])\n\tif not users_only:\n\t\tdomains.extend([get_domain(address, as_unicode=False) for address, _, _, auto in get_mail_aliases(env) if filter_aliases(address) and not auto ])\n\treturn set(domains)\n\ndef add_mail_user(email, pw, privs, quota, env):\n\t# validate email\n\tif email.strip() == \"\":\n\t\treturn (\"No email address provided.\", 400)\n\tif not validate_email(email):\n\t\treturn (\"Invalid email address.\", 400)\n\tif not validate_email(email, mode='user'):\n\t\treturn (\"User account email addresses may only use the lowercase ASCII letters a-z, the digits 0-9, underscore (_), hyphen (-), and period (.).\", 400)\n\tif is_dcv_address(email) and len(get_mail_users(env)) > 0:\n\t\t# Make domain control validation hijacking a little harder to mess up by preventing the usual\n\t\t# addresses used for DCV from being user accounts. Except let it be the first account because\n\t\t# during box setup the user won't know the rules.\n\t\treturn (\"You may not make a user account for that address because it is frequently used for domain control validation. Use an alias instead if necessary.\", 400)\n\n\t# validate password\n\tvalidate_password(pw)\n\n\t# validate privileges\n\tif privs is None or privs.strip() == \"\":\n\t\tprivs = []\n\telse:\n\t\tprivs = privs.split(\"\\n\")\n\t\tfor p in privs:\n\t\t\tvalidation = validate_privilege(p)\n\t\t\tif validation: return validation\n\n\tif quota is None:\n\t\tquota = '0'\n\n\ttry:\n\t\tquota = validate_quota(quota)\n\texcept ValueError as e:\n\t\treturn (str(e), 400)\n\n\t# get the database\n\tconn, c = open_database(env, with_connection=True)\n\n\t# hash the password\n\tpw = hash_password(pw)\n\n\t# add the user to the database\n\ttry:\n\t\tc.execute(\"INSERT INTO users (email, password, privileges, quota) VALUES (?, ?, ?, ?)\",\n\t\t\t(email, pw, \"\\n\".join(privs), quota))\n\texcept sqlite3.IntegrityError:\n\t\treturn (\"User already exists.\", 400)\n\n\t# write databasebefore next step\n\tconn.commit()\n\n\tdovecot_quota_recalc(email)\n\n\t# Update things in case any new domains are added.\n\treturn kick(env, \"mail user added\")\n\ndef set_mail_password(email, pw, env):\n\t# validate that password is acceptable\n\tvalidate_password(pw)\n\n\t# hash the password\n\tpw = hash_password(pw)\n\n\t# update the database\n\tconn, c = open_database(env, with_connection=True)\n\tc.execute(\"UPDATE users SET password=? WHERE email=?\", (pw, email))\n\tif c.rowcount != 1:\n\t\treturn (f\"That's not a user ({email}).\", 400)\n\tconn.commit()\n\treturn \"OK\"\n\ndef hash_password(pw):\n\t# Turn the plain password into a Dovecot-format hashed password, meaning\n\t# something like \"{SCHEME}hashedpassworddata\".\n\t# http://wiki2.dovecot.org/Authentication/PasswordSchemes\n\treturn utils.shell('check_output', [\"/usr/bin/doveadm\", \"pw\", \"-s\", \"SHA512-CRYPT\", \"-p\", pw]).strip()\n\n\ndef get_mail_quota(email, env):\n\t_conn, c = open_database(env, with_connection=True)\n\tc.execute(\"SELECT quota FROM users WHERE email=?\", (email,))\n\trows = c.fetchall()\n\tif len(rows) != 1:\n\t\treturn (f\"That's not a user ({email}).\", 400)\n\n\treturn rows[0][0]\n\n\ndef set_mail_quota(email, quota, env):\n\t# validate that password is acceptable\n\tquota = validate_quota(quota)\n\n\t# update the database\n\tconn, c = open_database(env, with_connection=True)\n\tc.execute(\"UPDATE users SET quota=? WHERE email=?\", (quota, email))\n\tif c.rowcount != 1:\n\t\treturn (f\"That's not a user ({email}).\", 400)\n\tconn.commit()\n\n\tdovecot_quota_recalc(email)\n\n\treturn \"OK\"\n\ndef dovecot_quota_recalc(email):\n\t# dovecot processes running for the user will not recognize the new quota setting\n\t# a reload is necessary to reread the quota setting, but it will also shut down\n\t# running dovecot processes.  Email clients generally log back in when they lose\n\t# a connection.\n\t# subprocess.call(['doveadm', 'reload'])\n\n\t# force dovecot to recalculate the quota info for the user.\n\tutils.shell(\"check_call\", [\"doveadm\", \"quota\", \"recalc\", \"-u\", email])\n\ndef validate_quota(quota):\n\t# validate quota\n\tquota = quota.strip().upper()\n\n\tif quota == \"\":\n\t\tmsg = \"No quota provided.\"\n\t\traise ValueError(msg)\n\tif re.search(r\"[\\s,.]\", quota):\n\t\tmsg = \"Quotas cannot contain spaces, commas, or decimal points.\"\n\t\traise ValueError(msg)\n\tif not re.match(r'^[\\d]+[GM]?$', quota):\n\t\tmsg = \"Invalid quota.\"\n\t\traise ValueError(msg)\n\n\treturn quota\n\ndef get_mail_password(email, env):\n\t# Gets the hashed password for a user. Passwords are stored in Dovecot's\n\t# password format, with a prefixed scheme.\n\t# http://wiki2.dovecot.org/Authentication/PasswordSchemes\n\t# update the database\n\tc = open_database(env)\n\tc.execute('SELECT password FROM users WHERE email=?', (email,))\n\trows = c.fetchall()\n\tif len(rows) != 1:\n\t\tmsg = f\"That's not a user ({email}).\"\n\t\traise ValueError(msg)\n\treturn rows[0][0]\n\ndef remove_mail_user(email, env):\n\t# remove\n\tconn, c = open_database(env, with_connection=True)\n\tc.execute(\"DELETE FROM users WHERE email=?\", (email,))\n\tif c.rowcount != 1:\n\t\treturn (f\"That's not a user ({email}).\", 400)\n\tconn.commit()\n\n\t# Update things in case any domains are removed.\n\treturn kick(env, \"mail user removed\")\n\ndef parse_privs(value):\n\treturn [p for p in value.split(\"\\n\") if p.strip() != \"\"]\n\ndef get_mail_user_privileges(email, env, empty_on_error=False):\n\t# get privs\n\tc = open_database(env)\n\tc.execute('SELECT privileges FROM users WHERE email=?', (email,))\n\trows = c.fetchall()\n\tif len(rows) != 1:\n\t\tif empty_on_error: return []\n\t\treturn (f\"That's not a user ({email}).\", 400)\n\treturn parse_privs(rows[0][0])\n\ndef validate_privilege(priv):\n\tif \"\\n\" in priv or priv.strip() == \"\":\n\t\treturn (f\"That's not a valid privilege ({priv}).\", 400)\n\treturn None\n\ndef add_remove_mail_user_privilege(email, priv, action, env):\n\t# validate\n\tvalidation = validate_privilege(priv)\n\tif validation: return validation\n\n\t# get existing privs, but may fail\n\tprivs = get_mail_user_privileges(email, env)\n\tif isinstance(privs, tuple): return privs # error\n\n\t# update privs set\n\tif action == \"add\":\n\t\tif priv not in privs:\n\t\t\tprivs.append(priv)\n\telif action == \"remove\":\n\t\tprivs = [p for p in privs if p != priv]\n\telse:\n\t\treturn (\"Invalid action.\", 400)\n\n\t# commit to database\n\tconn, c = open_database(env, with_connection=True)\n\tc.execute(\"UPDATE users SET privileges=? WHERE email=?\", (\"\\n\".join(privs), email))\n\tif c.rowcount != 1:\n\t\treturn (\"Something went wrong.\", 400)\n\tconn.commit()\n\n\treturn \"OK\"\n\ndef add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exists=False, do_kick=True):\n\t# convert Unicode domain to IDNA\n\taddress = sanitize_idn_email_address(address)\n\n\t# Our database is case sensitive (oops), which affects mail delivery\n\t# (Postfix always queries in lowercase?), so force lowercase.\n\taddress = address.lower()\n\n\t# validate address\n\taddress = address.strip()\n\tif address == \"\":\n\t\treturn (\"No email address provided.\", 400)\n\tif not validate_email(address, mode='alias'):\n\t\treturn (f\"Invalid email address ({address}).\", 400)\n\n\t# validate forwards_to\n\tvalidated_forwards_to = []\n\tforwards_to = forwards_to.strip()\n\n\t# extra checks for email addresses used in domain control validation\n\tis_dcv_source = is_dcv_address(address)\n\n\t# Postfix allows a single @domain.tld as the destination, which means\n\t# the local part on the address is preserved in the rewrite. We must\n\t# try to convert Unicode to IDNA first before validating that it's a\n\t# legitimate alias address. Don't allow this sort of rewriting for\n\t# DCV source addresses.\n\tr1 = sanitize_idn_email_address(forwards_to)\n\tif validate_email(r1, mode='alias') and not is_dcv_source:\n\t\tvalidated_forwards_to.append(r1)\n\n\telse:\n\t\t# Parse comma and \\n-separated destination emails & validate. In this\n\t\t# case, the forwards_to must be complete email addresses.\n\t\tfor line in forwards_to.split(\"\\n\"):\n\t\t\tfor email in line.split(\",\"):\n\t\t\t\temail = email.strip()\n\t\t\t\tif email == \"\": continue\n\t\t\t\temail = sanitize_idn_email_address(email) # Unicode => IDNA\n\t\t\t\t# Strip any +tag from email alias and check privileges\n\t\t\t\tprivileged_email = re.sub(r\"(?=\\+)[^@]*(?=@)\",'',email)\n\t\t\t\tif not validate_email(email):\n\t\t\t\t\treturn (f\"Invalid receiver email address ({email}).\", 400)\n\t\t\t\tif is_dcv_source and not is_dcv_address(email) and \"admin\" not in get_mail_user_privileges(privileged_email, env, empty_on_error=True):\n\t\t\t\t\t# Make domain control validation hijacking a little harder to mess up by\n\t\t\t\t\t# requiring aliases for email addresses typically used in DCV to forward\n\t\t\t\t\t# only to accounts that are administrators on this system.\n\t\t\t\t\treturn (\"This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.\", 400)\n\t\t\t\tvalidated_forwards_to.append(email)\n\n\t# validate permitted_senders\n\tvalid_logins = get_mail_users(env)\n\tvalidated_permitted_senders = []\n\tpermitted_senders = permitted_senders.strip()\n\n\t# Parse comma and \\n-separated sender logins & validate. The permitted_senders must be\n\t# valid usernames.\n\tfor line in permitted_senders.split(\"\\n\"):\n\t\tfor login in line.split(\",\"):\n\t\t\tlogin = login.strip()\n\t\t\tif login == \"\": continue\n\t\t\tif login not in valid_logins:\n\t\t\t\treturn (f\"Invalid permitted sender: {login} is not a user on this system.\", 400)\n\t\t\tvalidated_permitted_senders.append(login)\n\n\t# Make sure the alias has either a forwards_to or a permitted_sender.\n\tif len(validated_forwards_to) + len(validated_permitted_senders) == 0:\n\t\treturn (\"The alias must either forward to an address or have a permitted sender.\", 400)\n\n\t# save to db\n\n\tforwards_to = \",\".join(validated_forwards_to)\n\n\tpermitted_senders = None if len(validated_permitted_senders) == 0 else \",\".join(validated_permitted_senders)\n\n\tconn, c = open_database(env, with_connection=True)\n\ttry:\n\t\tc.execute(\"INSERT INTO aliases (source, destination, permitted_senders) VALUES (?, ?, ?)\", (address, forwards_to, permitted_senders))\n\t\treturn_status = \"alias added\"\n\texcept sqlite3.IntegrityError:\n\t\tif not update_if_exists:\n\t\t\treturn (f\"Alias already exists ({address}).\", 400)\n\t\tc.execute(\"UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?\", (forwards_to, permitted_senders, address))\n\t\treturn_status = \"alias updated\"\n\n\tconn.commit()\n\n\tif do_kick:\n\t\t# Update things in case any new domains are added.\n\t\treturn kick(env, return_status)\n\treturn None\n\ndef remove_mail_alias(address, env, do_kick=True):\n\t# convert Unicode domain to IDNA\n\taddress = sanitize_idn_email_address(address)\n\n\t# remove\n\tconn, c = open_database(env, with_connection=True)\n\tc.execute(\"DELETE FROM aliases WHERE source=?\", (address,))\n\tif c.rowcount != 1:\n\t\treturn (f\"That's not an alias ({address}).\", 400)\n\tconn.commit()\n\n\tif do_kick:\n\t\t# Update things in case any domains are removed.\n\t\treturn kick(env, \"alias removed\")\n\treturn None\n\ndef add_auto_aliases(aliases, env):\n\tconn, c = open_database(env, with_connection=True)\n\tc.execute(\"DELETE FROM auto_aliases\")\n\tfor source, destination in aliases.items():\n\t\tc.execute(\"INSERT INTO auto_aliases (source, destination) VALUES (?, ?)\", (source, destination))\n\tconn.commit()\n\ndef get_system_administrator(env):\n\treturn \"administrator@\" + env['PRIMARY_HOSTNAME']\n\ndef get_required_aliases(env):\n\t# These are the aliases that must exist.\n\taliases = set()\n\n\t# The system administrator alias is required.\n\taliases.add(get_system_administrator(env))\n\n\t# The hostmaster alias is exposed in the DNS SOA for each zone.\n\taliases.add(\"hostmaster@\" + env['PRIMARY_HOSTNAME'])\n\n\t# Get a list of domains we serve mail for, except ones for which the only\n\t# email on that domain are the required aliases or a catch-all/domain-forwarder.\n\treal_mail_domains = get_mail_domains(env,\n\t\tfilter_aliases = lambda alias :\n\t\t\tnot alias.startswith(\"postmaster@\")\n\t\t\tand not alias.startswith(\"admin@\")\n\t\t\tand not alias.startswith(\"abuse@\")\n\t\t\tand not alias.startswith(\"@\")\n\t\t\t)\n\n\t# Create postmaster@, admin@ and abuse@ for all domains we serve\n\t# mail on. postmaster@ is assumed to exist by our Postfix configuration.\n\t# admin@isn't anything, but it might save the user some trouble e.g. when\n\t# buying an SSL certificate.\n\t# abuse@ is part of RFC2142: https://www.ietf.org/rfc/rfc2142.txt\n\tfor domain in real_mail_domains:\n\t\taliases.add(\"postmaster@\" + domain)\n\t\taliases.add(\"admin@\" + domain)\n\t\taliases.add(\"abuse@\" + domain)\n\n\treturn aliases\n\ndef kick(env, mail_result=None):\n\tresults = []\n\n\t# Include the current operation's result in output.\n\n\tif mail_result is not None:\n\t\tresults.append(mail_result + \"\\n\")\n\n\tauto_aliases = { }\n\n\t# Map required aliases to the administrator alias (which should be created manually).\n\tadministrator = get_system_administrator(env)\n\trequired_aliases = get_required_aliases(env)\n\tfor alias in required_aliases:\n\t\tif alias == administrator: continue # don't make an alias from the administrator to itself --- this alias must be created manually\n\t\tauto_aliases[alias] = administrator\n\n\t# Add domain maps from Unicode forms of IDNA domains to the ASCII forms stored in the alias table.\n\tfor domain in get_mail_domains(env):\n\t\ttry:\n\t\t\tdomain_unicode = idna.decode(domain.encode(\"ascii\"))\n\t\t\tif domain == domain_unicode: continue # not an IDNA/Unicode domain\n\t\t\tauto_aliases[\"@\" + domain_unicode] = \"@\" + domain\n\t\texcept (ValueError, UnicodeError, idna.IDNAError):\n\t\t\tcontinue\n\n\tadd_auto_aliases(auto_aliases, env)\n\n\t# Remove auto-generated postmaster/admin/abuse alises from the main aliases table.\n\t# They are now stored in the auto_aliases table.\n\tfor address, forwards_to, _permitted_senders, auto in get_mail_aliases(env):\n\t\tuser, domain = address.split(\"@\")\n\t\tif user in {\"postmaster\", \"admin\", \"abuse\"} \\\n\t\t\tand address not in required_aliases \\\n\t\t\tand forwards_to == get_system_administrator(env) \\\n\t\t\tand not auto:\n\t\t\tremove_mail_alias(address, env, do_kick=False)\n\t\t\tresults.append(f\"removed alias {address} (was to {forwards_to}; domain no longer used for email)\\n\")\n\n\t# Update DNS and nginx in case any domains are added/removed.\n\n\tfrom dns_update import do_dns_update\n\tresults.append( do_dns_update(env) )\n\n\tfrom web_update import do_web_update\n\tresults.append( do_web_update(env) )\n\n\treturn \"\".join(s for s in results if s != \"\")\n\ndef validate_password(pw):\n\t# validate password\n\tif pw.strip() == \"\":\n\t\tmsg = \"No password provided.\"\n\t\traise ValueError(msg)\n\tif len(pw) < 8:\n\t\tmsg = \"Passwords must be at least eight characters.\"\n\t\traise ValueError(msg)\n\nif __name__ == \"__main__\":\n\timport sys\n\tif len(sys.argv) > 2 and sys.argv[1] == \"validate-email\":\n\t\t# Validate that we can create a Dovecot account for a given string.\n\t\tif validate_email(sys.argv[2], mode='user'):\n\t\t\tsys.exit(0)\n\t\telse:\n\t\t\tsys.exit(1)\n\n\tif len(sys.argv) > 1 and sys.argv[1] == \"update\":\n\t\tfrom utils import load_environment\n\t\tprint(kick(load_environment()))\n"
  },
  {
    "path": "management/mfa.py",
    "content": "import base64\nimport hmac\nimport io\nimport os\nimport pyotp\nimport qrcode\n\nfrom mailconfig import open_database\n\ndef get_user_id(email, c):\n\tc.execute('SELECT id FROM users WHERE email=?', (email,))\n\tr = c.fetchone()\n\tif not r: raise ValueError(\"User does not exist.\")\n\treturn r[0]\n\ndef get_mfa_state(email, env):\n\tc = open_database(env)\n\tc.execute('SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?', (get_user_id(email, c),))\n\treturn [\n\t\t{ \"id\": r[0], \"type\": r[1], \"secret\": r[2], \"mru_token\": r[3], \"label\": r[4] }\n\t\tfor r in c.fetchall()\n\t]\n\ndef get_public_mfa_state(email, env):\n\tmfa_state = get_mfa_state(email, env)\n\treturn [\n\t\t{ \"id\": s[\"id\"], \"type\": s[\"type\"], \"label\": s[\"label\"] }\n\t\tfor s in mfa_state\n\t]\n\ndef get_hash_mfa_state(email, env):\n\tmfa_state = get_mfa_state(email, env)\n\treturn [\n\t\t{ \"id\": s[\"id\"], \"type\": s[\"type\"], \"secret\": s[\"secret\"] }\n\t\tfor s in mfa_state\n\t]\n\ndef enable_mfa(email, type, secret, token, label, env):\n\tif type == \"totp\":\n\t\tvalidate_totp_secret(secret)\n\t\t# Sanity check with the provide current token.\n\t\ttotp = pyotp.TOTP(secret)\n\t\tif not totp.verify(token, valid_window=1):\n\t\t\tmsg = \"Invalid token.\"\n\t\t\traise ValueError(msg)\n\telse:\n\t\tmsg = \"Invalid MFA type.\"\n\t\traise ValueError(msg)\n\n\tconn, c = open_database(env, with_connection=True)\n\tc.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label))\n\tconn.commit()\n\ndef set_mru_token(email, mfa_id, token, env):\n\tconn, c = open_database(env, with_connection=True)\n\tc.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?', (token, get_user_id(email, c), mfa_id))\n\tconn.commit()\n\ndef disable_mfa(email, mfa_id, env):\n\tconn, c = open_database(env, with_connection=True)\n\tif mfa_id is None:\n\t\t# Disable all MFA for a user.\n\t\tc.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c),))\n\telse:\n\t\t# Disable a particular MFA mode for a user.\n\t\tc.execute('DELETE FROM mfa WHERE user_id=? AND id=?', (get_user_id(email, c), mfa_id))\n\tconn.commit()\n\treturn c.rowcount > 0\n\ndef validate_totp_secret(secret):\n\tif not isinstance(secret, str) or secret.strip() == \"\":\n\t\tmsg = \"No secret provided.\"\n\t\traise ValueError(msg)\n\tif len(secret) != 32:\n\t\tmsg = \"Secret should be a 32 characters base32 string\"\n\t\traise ValueError(msg)\n\ndef provision_totp(email, env):\n\t# Make a new secret.\n\tsecret = base64.b32encode(os.urandom(20)).decode('utf-8')\n\tvalidate_totp_secret(secret) # sanity check\n\n\t# Make a URI that we encode within a QR code.\n\turi = pyotp.TOTP(secret).provisioning_uri(\n\t\tname=email,\n\t\tissuer_name=env[\"PRIMARY_HOSTNAME\"] + \" Mail-in-a-Box Control Panel\"\n\t)\n\n\t# Generate a QR code as a base64-encode PNG image.\n\tqr = qrcode.make(uri)\n\tbyte_arr = io.BytesIO()\n\tqr.save(byte_arr, format='PNG')\n\tpng_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8')\n\n\treturn {\n\t\t\"type\": \"totp\",\n\t\t\"secret\": secret,\n\t\t\"qr_code_base64\": png_b64\n\t}\n\ndef validate_auth_mfa(email, request, env):\n\t# Validates that a login request satisfies any MFA modes\n\t# that have been enabled for the user's account. Returns\n\t# a tuple (status, [hints]). status is True for a successful\n\t# MFA login, False for a missing token. If status is False,\n\t# hints is an array of codes that indicate what the user\n\t# can try. Possible codes are:\n\t# \"missing-totp-token\"\n\t# \"invalid-totp-token\"\n\n\tmfa_state = get_mfa_state(email, env)\n\n\t# If no MFA modes are added, return True.\n\tif len(mfa_state) == 0:\n\t\treturn (True, [])\n\n\t# Try the enabled MFA modes.\n\thints = set()\n\tfor mfa_mode in mfa_state:\n\t\tif mfa_mode[\"type\"] == \"totp\":\n\t\t\t# Check that a token is present in the X-Auth-Token header.\n\t\t\t# If not, give a hint that one can be supplied.\n\t\t\ttoken = request.headers.get('x-auth-token')\n\t\t\tif not token:\n\t\t\t\thints.add(\"missing-totp-token\")\n\t\t\t\tcontinue\n\n\t\t\t# Check for a replay attack.\n\t\t\tif hmac.compare_digest(token, mfa_mode['mru_token'] or \"\"):\n\t\t\t\t# If the token fails, skip this MFA mode.\n\t\t\t\thints.add(\"invalid-totp-token\")\n\t\t\t\tcontinue\n\n\t\t\t# Check the token.\n\t\t\ttotp = pyotp.TOTP(mfa_mode[\"secret\"])\n\t\t\tif not totp.verify(token, valid_window=1):\n\t\t\t\thints.add(\"invalid-totp-token\")\n\t\t\t\tcontinue\n\n\t\t\t# On success, record the token to prevent a replay attack.\n\t\t\tset_mru_token(email, mfa_mode['id'], token, env)\n\t\t\treturn (True, [])\n\n\t# On a failed login, indicate failure and any hints for what the user can do instead.\n\treturn (False, list(hints))\n"
  },
  {
    "path": "management/munin_start.sh",
    "content": "#!/bin/bash\nmkdir -p /var/run/munin && chown munin /var/run/munin\n"
  },
  {
    "path": "management/ssl_certificates.py",
    "content": "#!/usr/local/lib/mailinabox/env/bin/python\n# Utilities for installing and selecting SSL certificates.\n\nimport os, os.path, re, shutil, subprocess, tempfile\n\nfrom utils import shell, safe_domain_name, sort_domains\nimport functools\nimport operator\n\n# SELECTING SSL CERTIFICATES FOR USE IN WEB\n\ndef get_ssl_certificates(env):\n\t# Scan all of the installed SSL certificates and map every domain\n\t# that the certificates are good for to the best certificate for\n\t# the domain.\n\n\tfrom cryptography.hazmat.primitives.asymmetric import dsa, rsa, ec\n\tfrom cryptography.x509 import Certificate\n\n\t# The certificates are all stored here:\n\tssl_root = os.path.join(env[\"STORAGE_ROOT\"], 'ssl')\n\n\t# List all of the files in the SSL directory and one level deep.\n\tdef get_file_list():\n\t\tif not os.path.exists(ssl_root):\n\t\t\treturn\n\t\tfor fn in os.listdir(ssl_root):\n\t\t\tif fn == 'ssl_certificate.pem':\n\t\t\t\t# This is always a symbolic link\n\t\t\t\t# to the certificate to use for\n\t\t\t\t# PRIMARY_HOSTNAME. Don't let it\n\t\t\t\t# be eligible for use because we\n\t\t\t\t# could end up creating a symlink\n\t\t\t\t# to itself --- we want to find\n\t\t\t\t# the cert that it should be a\n\t\t\t\t# symlink to.\n\t\t\t\tcontinue\n\t\t\tfn = os.path.join(ssl_root, fn)\n\t\t\tif os.path.isfile(fn):\n\t\t\t\tyield fn\n\t\t\telif os.path.isdir(fn):\n\t\t\t\tfor fn1 in os.listdir(fn):\n\t\t\t\t\tfn1 = os.path.join(fn, fn1)\n\t\t\t\t\tif os.path.isfile(fn1):\n\t\t\t\t\t\tyield fn1\n\n\t# Remember stuff.\n\tprivate_keys = { }\n\tcertificates = [ ]\n\n\t# Scan each of the files to find private keys and certificates.\n\t# We must load all of the private keys first before processing\n\t# certificates so that we can check that we have a private key\n\t# available before using a certificate.\n\tfor fn in get_file_list():\n\t\ttry:\n\t\t\tpem = load_pem(load_cert_chain(fn)[0])\n\t\texcept ValueError:\n\t\t\t# Not a valid PEM format for a PEM type we care about.\n\t\t\tcontinue\n\n\t\t# Is it a certificate?\n\t\tif isinstance(pem, Certificate):\n\t\t\tcertificates.append({ \"filename\": fn, \"cert\": pem })\n\t\t# It is a private key\n\t\telif (isinstance(pem, (rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey))):\n\t\t\tprivate_keys[pem.public_key().public_numbers()] = { \"filename\": fn, \"key\": pem }\n\n\n\t# Process the certificates.\n\tdomains = { }\n\tfor cert in certificates:\n\t\t# What domains is this certificate good for?\n\t\tcert_domains, primary_domain = get_certificate_domains(cert[\"cert\"])\n\t\tcert[\"primary_domain\"] = primary_domain\n\n\t\t# Is there a private key file for this certificate?\n\t\tprivate_key = private_keys.get(cert[\"cert\"].public_key().public_numbers())\n\t\tif not private_key:\n\t\t\tcontinue\n\t\tcert[\"private_key\"] = private_key\n\n\t\t# Add this cert to the list of certs usable for the domains.\n\t\tfor domain in cert_domains:\n\t\t\t# The primary hostname can only use a certificate mapped\n\t\t\t# to the system private key.\n\t\t\tif domain == env['PRIMARY_HOSTNAME'] and cert[\"private_key\"][\"filename\"] != os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'):\n\t\t\t\tcontinue\n\n\t\t\tdomains.setdefault(domain, []).append(cert)\n\n\t# Sort the certificates to prefer good ones.\n\timport datetime\n\tnow = datetime.datetime.utcnow()\n\tret = { }\n\tfor domain, cert_list in domains.items():\n\t\t#for c in cert_list: print(domain, c.not_valid_before, c.not_valid_after, \"(\"+str(now)+\")\", c.issuer, c.subject, c._filename)\n\t\tcert_list.sort(key = lambda cert : (\n\t\t\t# must be valid NOW\n\t\t\tcert[\"cert\"].not_valid_before <= now <= cert[\"cert\"].not_valid_after,\n\n\t\t\t# prefer one that is not self-signed\n\t\t\tcert[\"cert\"].issuer != cert[\"cert\"].subject,\n\n\t\t\t###########################################################\n\t\t\t# The above lines ensure that valid certificates are chosen\n\t\t\t# over invalid certificates. The lines below choose between\n\t\t\t# multiple valid certificates available for this domain.\n\t\t\t###########################################################\n\n\t\t\t# prefer one with the expiration furthest into the future so\n\t\t\t# that we can easily rotate to new certs as we get them\n\t\t\tcert[\"cert\"].not_valid_after,\n\n\t\t\t###########################################################\n\t\t\t# We always choose the certificate that is good for the\n\t\t\t# longest period of time. This is important for how we\n\t\t\t# provision certificates for Let's Encrypt. To ensure that\n\t\t\t# we don't re-provision every night, we have to ensure that\n\t\t\t# if we choose to provison a certificate that it will\n\t\t\t# *actually* be used so the provisioning logic knows it\n\t\t\t# doesn't still need to provision a certificate for the\n\t\t\t# domain.\n\t\t\t###########################################################\n\n\t\t\t# in case a certificate is installed in multiple paths,\n\t\t\t# prefer the... lexicographically last one?\n\t\t\tcert[\"filename\"],\n\n\t\t), reverse=True)\n\t\tcert = cert_list.pop(0)\n\t\tret[domain] = {\n\t\t\t\"private-key\": cert[\"private_key\"][\"filename\"],\n\t\t\t\"certificate\": cert[\"filename\"],\n\t\t\t\"primary-domain\": cert[\"primary_domain\"],\n\t\t\t\"certificate_object\": cert[\"cert\"],\n\t\t\t}\n\n\treturn ret\n\ndef get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=False, use_main_cert=True):\n\tif use_main_cert or not allow_missing_cert:\n\t\t# Get the system certificate info.\n\t\tssl_private_key = os.path.join(os.path.join(env[\"STORAGE_ROOT\"], 'ssl', 'ssl_private_key.pem'))\n\t\tssl_certificate = os.path.join(os.path.join(env[\"STORAGE_ROOT\"], 'ssl', 'ssl_certificate.pem'))\n\t\tsystem_certificate = {\n\t\t\t\"private-key\": ssl_private_key,\n\t\t\t\"certificate\": ssl_certificate,\n\t\t\t\"primary-domain\": env['PRIMARY_HOSTNAME'],\n\t\t\t\"certificate_object\": load_pem(load_cert_chain(ssl_certificate)[0]),\n\t\t}\n\n\tif use_main_cert and domain == env['PRIMARY_HOSTNAME']:\n\t\t# The primary domain must use the server certificate because\n\t\t# it is hard-coded in some service configuration files.\n\t\treturn system_certificate\n\n\twildcard_domain = re.sub(r\"^[^\\.]+\", \"*\", domain)\n\tif domain in ssl_certificates:\n\t\treturn ssl_certificates[domain]\n\tif wildcard_domain in ssl_certificates:\n\t\treturn ssl_certificates[wildcard_domain]\n\tif not allow_missing_cert:\n\t\t# No valid certificate is available for this domain! Return default files.\n\t\treturn system_certificate\n\t# No valid certificate is available for this domain.\n\treturn None\n\n\n# PROVISIONING CERTIFICATES FROM LETSENCRYPT\n\ndef get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True):\n\t# Get a set of domain names that we can provision certificates for\n\t# using certbot. We start with domains that the box is serving web\n\t# for and subtract:\n\t# * domains not in limit_domains if limit_domains is not empty\n\t# * domains with custom \"A\" records, i.e. they are hosted elsewhere\n\t# * domains with actual \"A\" records that point elsewhere (misconfiguration)\n\t# * domains that already have certificates that will be valid for a while\n\n\tfrom web_update import get_web_domains\n\tfrom status_checks import query_dns, normalize_ip\n\n\texisting_certs = get_ssl_certificates(env)\n\n\tplausible_web_domains = get_web_domains(env, exclude_dns_elsewhere=False)\n\tactual_web_domains = get_web_domains(env)\n\n\tdomains_to_provision = set()\n\tdomains_cant_provision = { }\n\n\tfor domain in plausible_web_domains:\n\t\t# Skip domains that the user doesn't want to provision now.\n\t\tif limit_domains and domain not in limit_domains:\n\t\t\tcontinue\n\n\t\t# Check that there isn't an explicit A/AAAA record.\n\t\tif domain not in actual_web_domains:\n\t\t\tdomains_cant_provision[domain] = \"The domain has a custom DNS A/AAAA record that points the domain elsewhere, so there is no point to installing a TLS certificate here and we could not automatically provision one anyway because provisioning requires access to the website (which isn't here).\"\n\n\t\t# Check that the DNS resolves to here.\n\t\telse:\n\n\t\t\t# Does the domain resolve to this machine in public DNS? If not,\n\t\t\t# we can't do domain control validation. For IPv6 is configured,\n\t\t\t# make sure both IPv4 and IPv6 are correct because we don't know\n\t\t\t# how Let's Encrypt will connect.\n\t\t\tbad_dns = []\n\t\t\tfor rtype, value in [(\"A\", env[\"PUBLIC_IP\"]), (\"AAAA\", env.get(\"PUBLIC_IPV6\"))]:\n\t\t\t\tif not value: continue # IPv6 is not configured\n\t\t\t\tresponse = query_dns(domain, rtype)\n\t\t\t\tif response != normalize_ip(value):\n\t\t\t\t\tbad_dns.append(f\"{response} ({rtype})\")\n\n\t\t\tif bad_dns:\n\t\t\t\tdomains_cant_provision[domain] = \"The domain name does not resolve to this machine: \" \\\n\t\t\t\t\t+ (\", \".join(bad_dns)) \\\n\t\t\t\t\t+ \".\"\n\n\t\t\telse:\n\t\t\t\t# DNS is all good.\n\n\t\t\t\t# Check for a good existing cert.\n\t\t\t\texisting_cert = get_domain_ssl_files(domain, existing_certs, env, use_main_cert=False, allow_missing_cert=True)\n\t\t\t\tif existing_cert:\n\t\t\t\t\texisting_cert_check = check_certificate(domain, existing_cert['certificate'], existing_cert['private-key'],\n\t\t\t\t\t\twarn_if_expiring_soon=14)\n\t\t\t\t\tif existing_cert_check[0] == \"OK\":\n\t\t\t\t\t\tif show_valid_certs:\n\t\t\t\t\t\t\tdomains_cant_provision[domain] = \"The domain has a valid certificate already. ({} Certificate: {}, private key {})\".format(\n\t\t\t\t\t\t\t\texisting_cert_check[1],\n\t\t\t\t\t\t\t\texisting_cert['certificate'],\n\t\t\t\t\t\t\t\texisting_cert['private-key'])\n\t\t\t\t\t\tcontinue\n\n\t\t\t\tdomains_to_provision.add(domain)\n\n\treturn (domains_to_provision, domains_cant_provision)\n\ndef provision_certificates(env, limit_domains):\n\t# What domains should we provision certificates for? And what\n\t# errors prevent provisioning for other domains.\n\tdomains, domains_cant_provision = get_certificates_to_provision(env, limit_domains=limit_domains)\n\n\t# Build a list of what happened on each domain or domain-set.\n\tret = []\n\tfor domain, error in domains_cant_provision.items():\n\t\tret.append({\n\t\t\t\"domains\": [domain],\n\t\t\t\"log\": [error],\n\t\t\t\"result\": \"skipped\",\n\t\t})\n\n\t# Break into groups by DNS zone: Group every domain with its parent domain, if\n\t# its parent domain is in the list of domains to request a certificate for.\n\t# Start with the zones so that if the zone doesn't need a certificate itself,\n\t# its children will still be grouped together. Sort the provision domains to\n\t# put parents ahead of children.\n\t# Since Let's Encrypt requests are limited to 100 domains at a time,\n\t# we'll create a list of lists of domains where the inner lists have\n\t# at most 100 items. By sorting we also get the DNS zone domain as the first\n\t# entry in each list (unless we overflow beyond 100) which ends up as the\n\t# primary domain listed in each certificate.\n\tfrom dns_update import get_dns_zones\n\tcerts = { }\n\tfor zone, _zonefile in get_dns_zones(env):\n\t\tcerts[zone] = [[]]\n\tfor domain in sort_domains(domains, env):\n\t\t# Does the domain end with any domain we've seen so far.\n\t\tfor parent in certs:\n\t\t\tif domain.endswith(\".\" + parent):\n\t\t\t\t# Add this to the parent's list of domains.\n\t\t\t\t# Start a new group if the list already has\n\t\t\t\t# 100 items.\n\t\t\t\tif len(certs[parent][-1]) == 100:\n\t\t\t\t\tcerts[parent].append([])\n\t\t\t\tcerts[parent][-1].append(domain)\n\t\t\t\tbreak\n\t\telse:\n\t\t\t# This domain is not a child of any domain we've seen yet, so\n\t\t\t# start a new group. This shouldn't happen since every zone\n\t\t\t# was already added.\n\t\t\tcerts[domain] = [[domain]]\n\n\t# Flatten to a list of lists of domains (from a mapping). Remove empty\n\t# lists (zones with no domains that need certs).\n\tcerts = functools.reduce(operator.iadd, certs.values(), [])\n\tcerts = [_ for _ in certs if len(_) > 0]\n\n\t# Prepare to provision.\n\n\t# Where should we put our Let's Encrypt account info and state cache.\n\taccount_path = os.path.join(env['STORAGE_ROOT'], 'ssl/lets_encrypt')\n\tif not os.path.exists(account_path):\n\t\tos.mkdir(account_path)\n\n\t# Provision certificates.\n\tfor domain_list in certs:\n\t\tret.append({\n\t\t\t\"domains\": domain_list,\n\t\t\t\"log\": [],\n\t\t})\n\t\ttry:\n\t\t\t# Create a CSR file for our master private key so that certbot\n\t\t\t# uses our private key.\n\t\t\tkey_file = os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem')\n\t\t\twith tempfile.NamedTemporaryFile() as csr_file:\n\t\t\t\t# We could use openssl, but certbot requires\n\t\t\t\t# that the CN domain and SAN domains match\n\t\t\t\t# the domain list passed to certbot, and adding\n\t\t\t\t# SAN domains openssl req is ridiculously complicated.\n\t\t\t\t# subprocess.check_output([\n\t\t\t\t# \t\"openssl\", \"req\", \"-new\",\n\t\t\t\t# \t\"-key\", key_file,\n\t\t\t\t# \t\"-out\", csr_file.name,\n\t\t\t\t# \t\"-subj\", \"/CN=\" + domain_list[0],\n\t\t\t\t# \t\"-sha256\" ])\n\t\t\t\tfrom cryptography import x509\n\t\t\t\tfrom cryptography.hazmat.backends import default_backend\n\t\t\t\tfrom cryptography.hazmat.primitives.serialization import Encoding\n\t\t\t\tfrom cryptography.hazmat.primitives import hashes\n\t\t\t\tfrom cryptography.x509.oid import NameOID\n\t\t\t\tbuilder = x509.CertificateSigningRequestBuilder()\n\t\t\t\tbuilder = builder.subject_name(x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, domain_list[0]) ]))\n\t\t\t\tbuilder = builder.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)\n\t\t\t\tbuilder = builder.add_extension(x509.SubjectAlternativeName(\n\t\t\t\t\t[x509.DNSName(d) for d in domain_list]\n\t\t\t\t), critical=False)\n\t\t\t\trequest = builder.sign(load_pem(load_cert_chain(key_file)[0]), hashes.SHA256(), default_backend())\n\t\t\t\twith open(csr_file.name, \"wb\") as f:\n\t\t\t\t\tf.write(request.public_bytes(Encoding.PEM))\n\n\t\t\t\t# Provision, writing to a temporary file.\n\t\t\t\twebroot = os.path.join(account_path, 'webroot')\n\t\t\t\tos.makedirs(webroot, exist_ok=True)\n\t\t\t\twith tempfile.TemporaryDirectory() as d:\n\t\t\t\t\tcert_file = os.path.join(d, 'cert_and_chain.pem')\n\t\t\t\t\tprint(\"Provisioning TLS certificates for \" + \", \".join(domain_list) + \".\")\n\t\t\t\t\tcertbotret = subprocess.check_output([\n\t\t\t\t\t\t\"certbot\",\n\t\t\t\t\t\t\"certonly\",\n\t\t\t\t\t\t#\"-v\", # just enough to see ACME errors\n\t\t\t\t\t\t\"--non-interactive\", # will fail if user hasn't registered during Mail-in-a-Box setup\n\n\t\t\t\t\t\t\"-d\", \",\".join(domain_list), # first will be main domain\n\n\t\t\t\t\t\t\"--csr\", csr_file.name, # use our private key; unfortunately this doesn't work with auto-renew so we need to save cert manually\n\t\t\t\t\t\t\"--cert-path\", os.path.join(d, 'cert'), # we only use the full chain\n\t\t\t\t\t\t\"--chain-path\", os.path.join(d, 'chain'), # we only use the full chain\n\t\t\t\t\t\t\"--fullchain-path\", cert_file,\n\n\t\t\t\t\t\t\"--webroot\", \"--webroot-path\", webroot,\n\n\t\t\t\t\t\t\"--config-dir\", account_path,\n\t\t\t\t\t\t#\"--staging\",\n\t\t\t\t\t], stderr=subprocess.STDOUT).decode(\"utf8\")\n\t\t\t\t\tinstall_cert_copy_file(cert_file, env)\n\n\t\t\tret[-1][\"log\"].append(certbotret)\n\t\t\tret[-1][\"result\"] = \"installed\"\n\t\texcept subprocess.CalledProcessError as e:\n\t\t\tret[-1][\"log\"].append(e.output.decode(\"utf8\"))\n\t\t\tret[-1][\"result\"] = \"error\"\n\t\texcept Exception as e:\n\t\t\tret[-1][\"log\"].append(str(e))\n\t\t\tret[-1][\"result\"] = \"error\"\n\n\t# Run post-install steps.\n\tret.extend(post_install_func(env))\n\n\t# Return what happened with each certificate request.\n\treturn ret\n\ndef provision_certificates_cmdline():\n\timport sys\n\tfrom exclusiveprocess import Lock\n\n\tfrom utils import load_environment\n\n\tLock(die=True).forever()\n\tenv = load_environment()\n\n\tquiet = False\n\tdomains = []\n\n\tfor arg in sys.argv[1:]:\n\t\tif arg == \"-q\":\n\t\t\tquiet = True\n\t\telse:\n\t\t\tdomains.append(arg)\n\n\t# Go.\n\tstatus = provision_certificates(env, limit_domains=domains)\n\n\t# Show what happened.\n\tfor request in status:\n\t\tif isinstance(request, str):\n\t\t\tprint(request)\n\t\telse:\n\t\t\tif quiet and request['result'] == 'skipped':\n\t\t\t\tcontinue\n\t\t\tprint(request['result'] + \":\", \", \".join(request['domains']) + \":\")\n\t\t\tfor line in request[\"log\"]:\n\t\t\t\tprint(line)\n\t\t\tprint()\n\n\n# INSTALLING A NEW CERTIFICATE FROM THE CONTROL PANEL\n\ndef create_csr(domain, ssl_key, country_code, env):\n\treturn shell(\"check_output\", [\n\t\t\t\t\"openssl\", \"req\", \"-new\",\n\t\t\t\t\"-key\", ssl_key,\n\t\t\t\t\"-sha256\",\n\t\t\t\t\"-subj\", f\"/C={country_code}/CN={domain}\"])\n\ndef install_cert(domain, ssl_cert, ssl_chain, env, raw=False):\n\t# Write the combined cert+chain to a temporary path and validate that it is OK.\n\t# The certificate always goes above the chain.\n\timport tempfile\n\tfd, fn = tempfile.mkstemp('.pem')\n\tos.write(fd, (ssl_cert + '\\n' + ssl_chain).encode(\"ascii\"))\n\tos.close(fd)\n\n\t# Do validation on the certificate before installing it.\n\tssl_private_key = os.path.join(os.path.join(env[\"STORAGE_ROOT\"], 'ssl', 'ssl_private_key.pem'))\n\tcert_status, cert_status_details = check_certificate(domain, fn, ssl_private_key)\n\tif cert_status != \"OK\":\n\t\tif cert_status == \"SELF-SIGNED\":\n\t\t\tcert_status = \"This is a self-signed certificate. I can't install that.\"\n\t\tos.unlink(fn)\n\t\tif cert_status_details is not None:\n\t\t\tcert_status += \" \" + cert_status_details\n\t\treturn cert_status\n\n\t# Copy certificate into ssl directory.\n\tinstall_cert_copy_file(fn, env)\n\n\t# Run post-install steps.\n\tret = post_install_func(env)\n\tif raw: return ret\n\treturn \"\\n\".join(ret)\n\n\ndef install_cert_copy_file(fn, env):\n\t# Where to put it?\n\t# Make a unique path for the certificate.\n\tfrom cryptography.hazmat.primitives import hashes\n\tfrom binascii import hexlify\n\tcert = load_pem(load_cert_chain(fn)[0])\n\t_all_domains, cn = get_certificate_domains(cert)\n\tpath = \"{}-{}-{}.pem\".format(\n\t\tsafe_domain_name(cn), # common name, which should be filename safe because it is IDNA-encoded, but in case of a malformed cert make sure it's ok to use as a filename\n\t\tcert.not_valid_after.date().isoformat().replace(\"-\", \"\"), # expiration date\n\t\thexlify(cert.fingerprint(hashes.SHA256())).decode(\"ascii\")[0:8], # fingerprint prefix\n\t\t)\n\tssl_certificate = os.path.join(os.path.join(env[\"STORAGE_ROOT\"], 'ssl', path))\n\n\t# Install the certificate.\n\tos.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)\n\tshutil.move(fn, ssl_certificate)\n\n\ndef post_install_func(env):\n\tret = []\n\n\t# Get the certificate to use for PRIMARY_HOSTNAME.\n\tssl_certificates = get_ssl_certificates(env)\n\tcert = get_domain_ssl_files(env['PRIMARY_HOSTNAME'], ssl_certificates, env, use_main_cert=False)\n\tif not cert:\n\t\t# Ruh-row, we don't have any certificate usable\n\t\t# for the primary hostname.\n\t\tret.append(\"there is no valid certificate for \" + env['PRIMARY_HOSTNAME'])\n\n\t# Symlink the best cert for PRIMARY_HOSTNAME to the system\n\t# certificate path, which is hard-coded for various purposes, and then\n\t# restart postfix and dovecot.\n\tsystem_ssl_certificate = os.path.join(os.path.join(env[\"STORAGE_ROOT\"], 'ssl', 'ssl_certificate.pem'))\n\tif cert and os.readlink(system_ssl_certificate) != cert['certificate']:\n\t\t# Update symlink.\n\t\tret.append(\"updating primary certificate\")\n\t\tssl_certificate = cert['certificate']\n\t\tos.unlink(system_ssl_certificate)\n\t\tos.symlink(ssl_certificate, system_ssl_certificate)\n\n\t\t# Restart postfix and dovecot so they pick up the new file.\n\t\tshell('check_call', [\"/usr/sbin/service\", \"postfix\", \"restart\"])\n\t\tshell('check_call', [\"/usr/sbin/service\", \"dovecot\", \"restart\"])\n\t\tret.append(\"mail services restarted\")\n\n\t\t# The DANE TLSA record will remain valid so long as the private key\n\t\t# hasn't changed. We don't ever change the private key automatically.\n\t\t# If the user does it, they must manually update DNS.\n\n\t# Update the web configuration so nginx picks up the new certificate file.\n\tfrom web_update import do_web_update\n\tret.append( do_web_update(env) )\n\n\treturn ret\n\n# VALIDATION OF CERTIFICATES\n\ndef check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring_soon=10, rounded_time=False, just_check_domain=False):\n\t# Check that the ssl_certificate & ssl_private_key files are good\n\t# for the provided domain.\n\n\tfrom cryptography.hazmat.primitives.asymmetric import rsa, dsa, ec\n\tfrom cryptography.x509 import Certificate\n\n\t# The ssl_certificate file may contain a chain of certificates. We'll\n\t# need to split that up before we can pass anything to openssl or\n\t# parse them in Python. Parse it with the cryptography library.\n\ttry:\n\t\tssl_cert_chain = load_cert_chain(ssl_certificate)\n\t\tcert = load_pem(ssl_cert_chain[0])\n\t\tif not isinstance(cert, Certificate): raise ValueError(\"This is not a certificate file.\")\n\texcept ValueError as e:\n\t\treturn (f\"There is a problem with the certificate file: {e!s}\", None)\n\n\t# First check that the domain name is one of the names allowed by\n\t# the certificate.\n\tif domain is not None:\n\t\tcertificate_names, _cert_primary_name = get_certificate_domains(cert)\n\n\t\t# Check that the domain appears among the acceptable names, or a wildcard\n\t\t# form of the domain name (which is a stricter check than the specs but\n\t\t# should work in normal cases).\n\t\twildcard_domain = re.sub(r\"^[^\\.]+\", \"*\", domain)\n\t\tif domain not in certificate_names and wildcard_domain not in certificate_names:\n\t\t\treturn (\"The certificate is for the wrong domain name. It is for {}.\".format(\", \".join(sorted(certificate_names))), None)\n\n\t# Second, check that the certificate matches the private key.\n\tif ssl_private_key is not None:\n\t\ttry:\n\t\t\twith open(ssl_private_key, 'rb') as f:\n\t\t\t\tpriv_key = load_pem(f.read())\n\t\texcept ValueError as e:\n\t\t\treturn (f\"The private key file {ssl_private_key} is not a private key file: {e!s}\", None)\n\n\t\tif (not isinstance(priv_key, rsa.RSAPrivateKey)\n\t\t\tand not isinstance(priv_key, dsa.DSAPrivateKey)\n\t\t\tand not isinstance(priv_key, ec.EllipticCurvePrivateKey)):\n\t\t\treturn (f\"The private key file {ssl_private_key} is not a private key file.\", None)\n\n\t\tif priv_key.public_key().public_numbers() != cert.public_key().public_numbers():\n\t\t\treturn (f\"The certificate does not correspond to the private key at {ssl_private_key}.\", None)\n\n\t\t# We could also use the openssl command line tool to get the modulus\n\t\t# listed in each file. The output of each command below looks like \"Modulus=XXXXX\".\n\t\t# $ openssl rsa -inform PEM -noout -modulus -in ssl_private_key\n\t\t# $ openssl x509 -in ssl_certificate -noout -modulus\n\n\t# Third, check if the certificate is self-signed. Return a special flag string.\n\tif cert.issuer == cert.subject:\n\t\treturn (\"SELF-SIGNED\", None)\n\n\t# When selecting which certificate to use for non-primary domains, we check if the primary\n\t# certificate or a www-parent-domain certificate is good for the domain. There's no need\n\t# to run extra checks beyond this point.\n\tif just_check_domain:\n\t\treturn (\"OK\", None)\n\n\t# Check that the certificate hasn't expired. The datetimes returned by the\n\t# certificate are 'naive' and in UTC. We need to get the current time in UTC.\n\timport datetime\n\tnow = datetime.datetime.utcnow()\n\tif not(cert.not_valid_before <= now <= cert.not_valid_after):\n\t\treturn (f\"The certificate has expired or is not yet valid. It is valid from {cert.not_valid_before} to {cert.not_valid_after}.\", None)\n\n\t# Next validate that the certificate is valid. This checks whether the certificate\n\t# is self-signed, that the chain of trust makes sense, that it is signed by a CA\n\t# that Ubuntu has installed on this machine's list of CAs, and I think that it hasn't\n\t# expired.\n\n\t# The certificate chain has to be passed separately and is given via STDIN.\n\t# This command returns a non-zero exit status in most cases, so trap errors.\n\tretcode, verifyoutput = shell('check_output', [\n\t\t\"openssl\",\n\t\t\"verify\", \"-verbose\",\n\t\t\"-purpose\", \"sslserver\", \"-policy_check\",]\n\t\t+ ([] if len(ssl_cert_chain) == 1 else [\"-untrusted\", \"/proc/self/fd/0\"])\n\t\t+ [ssl_certificate],\n\t\tinput=b\"\\n\\n\".join(ssl_cert_chain[1:]),\n\t\ttrap=True)\n\n\tif \"self signed\" in verifyoutput:\n\t\t# Certificate is self-signed. Probably we detected this above.\n\t\treturn (\"SELF-SIGNED\", None)\n\n\tif retcode != 0:\n\t\tif \"unable to get local issuer certificate\" in verifyoutput:\n\t\t\treturn (f\"The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. ({verifyoutput})\", None)\n\n\t\t# There is some unknown problem. Return the `openssl verify` raw output.\n\t\treturn (\"There is a problem with the certificate.\", verifyoutput.strip())\n\n\t# `openssl verify` returned a zero exit status so the cert is currently\n\t# good.\n\n\t# But is it expiring soon?\n\tcert_expiration_date = cert.not_valid_after\n\tndays = (cert_expiration_date-now).days\n\tif not rounded_time or ndays <= 10:\n\t\t# Yikes better renew soon!\n\t\texpiry_info = \"The certificate expires in %d days on %s.\" % (ndays, cert_expiration_date.date().isoformat())\n\telse:\n\t\t# We'll renew it with Lets Encrypt.\n\t\texpiry_info = f\"The certificate expires on {cert_expiration_date.date().isoformat()}.\"\n\n\tif warn_if_expiring_soon and ndays <= warn_if_expiring_soon:\n\t\t# Warn on day 10 to give 4 days for us to automatically renew the\n\t\t# certificate, which occurs on day 14.\n\t\treturn (\"The certificate is expiring soon: \" + expiry_info, None)\n\n\t# Return the special OK code.\n\treturn (\"OK\", expiry_info)\n\ndef load_cert_chain(pemfile):\n\t# A certificate .pem file may contain a chain of certificates.\n\t# Load the file and split them apart.\n\tre_pem = rb\"(-+BEGIN (?:.+)-+[\\r\\n]+(?:[A-Za-z0-9+/=]{1,64}[\\r\\n]+)+-+END (?:.+)-+[\\r\\n]+)\"\n\twith open(pemfile, \"rb\") as f:\n\t\tpem = f.read() + b\"\\n\" # ensure trailing newline\n\t\tpemblocks = re.findall(re_pem, pem)\n\t\tif len(pemblocks) == 0:\n\t\t\tmsg = \"File does not contain valid PEM data.\"\n\t\t\traise ValueError(msg)\n\t\treturn pemblocks\n\ndef load_pem(pem):\n\t# Parse a \"---BEGIN .... END---\" PEM string and return a Python object for it\n\t# using classes from the cryptography package.\n\tfrom cryptography.x509 import load_pem_x509_certificate\n\tfrom cryptography.hazmat.primitives import serialization\n\tfrom cryptography.hazmat.backends import default_backend\n\tpem_type = re.match(b\"-+BEGIN (.*?)-+[\\r\\n]\", pem)\n\tif pem_type is None:\n\t\tmsg = \"File is not a valid PEM-formatted file.\"\n\t\traise ValueError(msg)\n\tpem_type = pem_type.group(1)\n\tif pem_type.endswith(b\"PRIVATE KEY\"):\n\t\treturn serialization.load_pem_private_key(pem, password=None, backend=default_backend())\n\tif pem_type == b\"CERTIFICATE\":\n\t\treturn load_pem_x509_certificate(pem, default_backend())\n\traise ValueError(\"Unsupported PEM object type: \" + pem_type.decode(\"ascii\", \"replace\"))\n\ndef get_certificate_domains(cert):\n\tfrom cryptography.x509 import DNSName, ExtensionNotFound, OID_COMMON_NAME, OID_SUBJECT_ALTERNATIVE_NAME\n\timport idna\n\n\tnames = set()\n\tcn = None\n\n\t# The domain may be found in the Subject Common Name (CN). This comes back as an IDNA (ASCII)\n\t# string, which is the format we store domains in - so good.\n\ttry:\n\t\tcn = cert.subject.get_attributes_for_oid(OID_COMMON_NAME)[0].value\n\t\tnames.add(cn)\n\texcept IndexError:\n\t\t# No common name? Certificate is probably generated incorrectly.\n\t\t# But we'll let it error-out when it doesn't find the domain.\n\t\tpass\n\n\t# ... or be one of the Subject Alternative Names. The cryptography library handily IDNA-decodes\n\t# the names for us. We must encode back to ASCII, but wildcard certificates can't pass through\n\t# IDNA encoding/decoding so we must special-case. See https://github.com/pyca/cryptography/pull/2071.\n\tdef idna_decode_dns_name(dns_name):\n\t\tif dns_name.startswith(\"*.\"):\n\t\t\treturn \"*.\" + idna.encode(dns_name[2:]).decode('ascii')\n\t\treturn idna.encode(dns_name).decode('ascii')\n\n\ttry:\n\t\tsans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)\n\t\tnames.update(idna_decode_dns_name(san) for san in sans)\n\texcept ExtensionNotFound:\n\t\tpass\n\n\treturn names, cn\n\nif __name__  == \"__main__\":\n\t# Provision certificates.\n\tprovision_certificates_cmdline()\n"
  },
  {
    "path": "management/status_checks.py",
    "content": "#!/usr/local/lib/mailinabox/env/bin/python\n#\n# Checks that the upstream DNS has been set correctly and that\n# TLS certificates have been signed, etc., and if not tells the user\n# what to do next.\n\nimport sys, os, os.path, re, datetime, multiprocessing.pool\nimport asyncio\nimport dateutil.parser, dateutil.relativedelta, dateutil.tz\n\nimport dns.reversename, dns.resolver\nimport idna\nimport psutil\nimport postfix_mta_sts_resolver.resolver\n\nfrom dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, get_secondary_dns, get_custom_dns_records\nfrom web_update import get_web_domains, get_domains_with_a_records\nfrom ssl_certificates import get_ssl_certificates, get_domain_ssl_files, check_certificate\nfrom mailconfig import get_mail_domains, get_mail_aliases\n\nfrom utils import shell, sort_domains, load_env_vars_from_file, load_settings, get_ssh_port, get_ssh_config_value\nfrom backup import get_backup_config, backup_status\n\ndef get_services():\n\treturn [\n\t\t{ \"name\": \"Local DNS (bind9)\", \"port\": 53, \"public\": False, },\n\t\t#{ \"name\": \"NSD Control\", \"port\": 8952, \"public\": False, },\n\t\t{ \"name\": \"Local DNS Control (bind9/rndc)\", \"port\": 953, \"public\": False, },\n\t\t{ \"name\": \"Dovecot LMTP LDA\", \"port\": 10026, \"public\": False, },\n\t\t{ \"name\": \"Postgrey\", \"port\": 10023, \"public\": False, },\n\t\t{ \"name\": \"Spamassassin\", \"port\": 10025, \"public\": False, },\n\t\t{ \"name\": \"OpenDKIM\", \"port\": 8891, \"public\": False, },\n\t\t{ \"name\": \"OpenDMARC\", \"port\": 8893, \"public\": False, },\n\t\t{ \"name\": \"Mail-in-a-Box Management Daemon\", \"port\": 10222, \"public\": False, },\n\t\t{ \"name\": \"SSH Login (ssh)\", \"port\": get_ssh_port(), \"public\": True, },\n\t\t{ \"name\": \"Public DNS (nsd4)\", \"port\": 53, \"public\": True, },\n\t\t{ \"name\": \"Incoming Mail (SMTP/postfix)\", \"port\": 25, \"public\": True, },\n\t\t{ \"name\": \"Outgoing Mail (SMTP 465/postfix)\", \"port\": 465, \"public\": True, },\n\t\t{ \"name\": \"Outgoing Mail (SMTP 587/postfix)\", \"port\": 587, \"public\": True, },\n\t\t#{ \"name\": \"Postfix/master\", \"port\": 10587, \"public\": True, },\n\t\t{ \"name\": \"IMAPS (dovecot)\", \"port\": 993, \"public\": True, },\n\t\t{ \"name\": \"Mail Filters (Sieve/dovecot)\", \"port\": 4190, \"public\": True, },\n\t\t{ \"name\": \"HTTP Web (nginx)\", \"port\": 80, \"public\": True, },\n\t\t{ \"name\": \"HTTPS Web (nginx)\", \"port\": 443, \"public\": True, },\n\t]\n\ndef run_checks(rounded_values, env, output, pool, domains_to_check=None):\n\t# run systems checks\n\toutput.add_heading(\"System\")\n\n\t# check that services are running\n\tif not run_services_checks(env, output, pool):\n\t\t# If critical services are not running, stop. If bind9 isn't running,\n\t\t# all later DNS checks will timeout and that will take forever to\n\t\t# go through, and if running over the web will cause a fastcgi timeout.\n\t\treturn\n\n\t# clear bind9's DNS cache so our DNS checks are up to date\n\t# (ignore errors; if bind9/rndc isn't running we'd already report\n\t# that in run_services checks.)\n\tshell('check_call', [\"/usr/sbin/rndc\", \"flush\"], trap=True)\n\n\trun_system_checks(rounded_values, env, output)\n\n\t# perform other checks asynchronously\n\n\trun_network_checks(env, output)\n\trun_domain_checks(rounded_values, env, output, pool, domains_to_check=domains_to_check)\n\ndef run_services_checks(env, output, pool):\n\t# Check that system services are running.\n\tall_running = True\n\tfatal = False\n\tret = pool.starmap(check_service, ((i, service, env) for i, service in enumerate(get_services())), chunksize=1)\n\tfor _i, running, fatal2, output2 in sorted(ret):\n\t\tif output2 is None: continue # skip check (e.g. no port was set, e.g. no sshd)\n\t\tall_running = all_running and running\n\t\tfatal = fatal or fatal2\n\t\toutput2.playback(output)\n\n\t# Check fail2ban.\n\tcode, ret = shell('check_output', [\"fail2ban-client\", \"status\"], capture_stderr=True, trap=True)\n\tif code != 0:\n\t\toutput.print_error(\"fail2ban is not running.\")\n\t\tall_running = False\n\n\tif all_running:\n\t\toutput.print_ok(\"All system services are running.\")\n\n\treturn not fatal\n\ndef check_service(i, service, env):\n\tif not service[\"port\"]:\n\t\t# Skip check (no port, e.g. no sshd).\n\t\treturn (i, None, None, None)\n\n\toutput = BufferedOutput()\n\trunning = False\n\tfatal = False\n\n\t# Helper function to make a connection to the service, since we try\n\t# up to three ways (localhost, IPv4 address, IPv6 address).\n\tdef try_connect(ip):\n\t\t# Connect to the given IP address on the service's port with a one-second timeout.\n\t\timport socket\n\t\ts = socket.socket(socket.AF_INET if \":\" not in ip else socket.AF_INET6, socket.SOCK_STREAM)\n\t\ts.settimeout(1)\n\t\ttry:\n\t\t\ts.connect((ip, service[\"port\"]))\n\t\t\treturn True\n\t\texcept OSError:\n\t\t\t# timed out or some other odd error\n\t\t\treturn False\n\t\tfinally:\n\t\t\ts.close()\n\n\tif service[\"public\"]:\n\t\t# Service should be publicly accessible.\n\t\tif try_connect(env[\"PUBLIC_IP\"]):\n\t\t\t# IPv4 ok.\n\t\t\tif not env.get(\"PUBLIC_IPV6\") or service.get(\"ipv6\") is False or try_connect(env[\"PUBLIC_IPV6\"]):\n\t\t\t\t# No IPv6, or service isn't meant to run on IPv6, or IPv6 is good.\n\t\t\t\trunning = True\n\n\t\t\t# IPv4 ok but IPv6 failed. Try the PRIVATE_IPV6 address to see if the service is bound to the interface.\n\t\t\telif service[\"port\"] != 53 and try_connect(env[\"PRIVATE_IPV6\"]):\n\t\t\t\toutput.print_error(\"%s is running (and available over IPv4 and the local IPv6 address), but it is not publicly accessible at %s:%d.\" % (service['name'], env['PUBLIC_IPV6'], service['port']))\n\t\t\telse:\n\t\t\t\toutput.print_error(\"%s is running and available over IPv4 but is not accessible over IPv6 at %s port %d.\" % (service['name'], env['PUBLIC_IPV6'], service['port']))\n\n\t\t# IPv4 failed. Try the private IP to see if the service is running but not accessible (except DNS because a different service runs on the private IP).\n\t\telif service[\"port\"] != 53 and try_connect(\"127.0.0.1\"):\n\t\t\toutput.print_error(\"%s is running but is not publicly accessible at %s:%d.\" % (service['name'], env['PUBLIC_IP'], service['port']))\n\t\telse:\n\t\t\toutput.print_error(\"%s is not running (port %d).\" % (service['name'], service['port']))\n\n\t\t# Why is nginx not running?\n\t\tif not running and service[\"port\"] in {80, 443}:\n\t\t\toutput.print_line(shell('check_output', ['nginx', '-t'], capture_stderr=True, trap=True)[1].strip())\n\n\t# Service should be running locally.\n\telif try_connect(\"127.0.0.1\"):\n\t\trunning = True\n\telse:\n\t\toutput.print_error(\"%s is not running (port %d).\" % (service['name'], service['port']))\n\n\t# Flag if local DNS is not running.\n\tif not running and service[\"port\"] == 53 and service[\"public\"] is False:\n\t\tfatal = True\n\n\treturn (i, running, fatal, output)\n\ndef run_system_checks(rounded_values, env, output):\n\tcheck_ssh_password(env, output)\n\tcheck_software_updates(env, output)\n\tcheck_miab_version(env, output)\n\tcheck_system_aliases(env, output)\n\tcheck_free_disk_space(rounded_values, env, output)\n\tcheck_free_memory(rounded_values, env, output)\n\tcheck_backup(rounded_values, env, output)\n\ndef check_ufw(env, output):\n\tif not os.path.isfile('/usr/sbin/ufw'):\n\t\toutput.print_warning(\"\"\"The ufw program was not installed. If your system is able to run iptables, rerun the setup.\"\"\")\n\t\treturn\n\n\tcode, ufw = shell('check_output', ['ufw', 'status'], trap=True)\n\n\tif code != 0:\n\t\t# The command failed, it's safe to say the firewall is disabled\n\t\toutput.print_warning(\"\"\"The firewall is not working on this machine. An error was received\n\t\t\t\t\twhile trying to check the firewall. To investigate run 'sudo ufw status'.\"\"\")\n\t\treturn\n\n\tufw = ufw.splitlines()\n\tif ufw[0] == \"Status: active\":\n\t\tnot_allowed_ports = 0\n\t\tfor service in get_services():\n\t\t\tif service[\"public\"] and not is_port_allowed(ufw, service[\"port\"]):\n\t\t\t\tnot_allowed_ports += 1\n\t\t\t\toutput.print_error(\"Port {} ({}) should be allowed in the firewall, please re-run the setup.\".format(service[\"port\"], service[\"name\"]))\n\n\t\tif not_allowed_ports == 0:\n\t\t\toutput.print_ok(\"Firewall is active.\")\n\telse:\n\t\toutput.print_warning(\"\"\"The firewall is disabled on this machine. This might be because the system\n\t\t\tis protected by an external firewall. We can't protect the system against bruteforce attacks\n\t\t\twithout the local firewall active. Connect to the system via ssh and try to run: ufw enable.\"\"\")\n\ndef is_port_allowed(ufw, port):\n\treturn any(re.match(str(port) +\"[/ \\t].*\", item) for item in ufw)\n\ndef check_ssh_password(env, output):\n\tconfig_value = get_ssh_config_value(\"passwordauthentication\")\n\tif config_value:\n\t\tif config_value == \"no\":\n\t\t\toutput.print_ok(\"SSH disallows password-based login.\")\n\t\telse:\n\t\t\toutput.print_error(\"\"\"The SSH server on this machine permits password-based login. A more secure\n\t\t\t\tway to log in is using a public key. Add your SSH public key to $HOME/.ssh/authorized_keys, check\n\t\t\t\tthat you can log in without a password, set the option 'PasswordAuthentication no' in\n\t\t\t\t/etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.\"\"\")\n\ndef is_reboot_needed_due_to_package_installation():\n\treturn os.path.exists(\"/var/run/reboot-required\")\n\ndef check_software_updates(env, output):\n\t# Check for any software package updates.\n\tpkgs = list_apt_updates(apt_update=False)\n\tif is_reboot_needed_due_to_package_installation():\n\t\toutput.print_error(\"System updates have been installed and a reboot of the machine is required.\")\n\telif len(pkgs) == 0:\n\t\toutput.print_ok(\"System software is up to date.\")\n\telse:\n\t\toutput.print_error(\"There are %d software packages that can be updated.\" % len(pkgs))\n\t\tfor p in pkgs:\n\t\t\toutput.print_line(\"{} ({})\".format(p[\"package\"], p[\"version\"]))\n\ndef check_system_aliases(env, output):\n\t# Check that the administrator alias exists since that's where all\n\t# admin email is automatically directed.\n\tcheck_alias_exists(\"System administrator address\", \"administrator@\" + env['PRIMARY_HOSTNAME'], env, output)\n\ndef check_free_disk_space(rounded_values, env, output):\n\t# Check free disk space.\n\tst = os.statvfs(env['STORAGE_ROOT'])\n\tbytes_total = st.f_blocks * st.f_frsize\n\tbytes_free = st.f_bavail * st.f_frsize\n\tdisk_msg = \"The disk has %.2f GB space remaining.\" % (bytes_free/1024.0/1024.0/1024.0)\n\tif bytes_free > .3 * bytes_total:\n\t\tif rounded_values: disk_msg = \"The disk has more than 30% free space.\"\n\t\toutput.print_ok(disk_msg)\n\telif bytes_free > .15 * bytes_total:\n\t\tif rounded_values: disk_msg = \"The disk has less than 30% free space.\"\n\t\toutput.print_warning(disk_msg)\n\telse:\n\t\tif rounded_values: disk_msg = \"The disk has less than 15% free space.\"\n\t\toutput.print_error(disk_msg)\n\n\t# Check that there's only one duplicity cache. If there's more than one,\n\t# it's probably no longer in use, and we can recommend clearing the cache\n\t# to save space. The cache directory may not exist yet, which is OK.\n\tbackup_cache_path = os.path.join(env['STORAGE_ROOT'], 'backup/cache')\n\ttry:\n\t\tbackup_cache_count = len(os.listdir(backup_cache_path))\n\texcept:\n\t\tbackup_cache_count = 0\n\tif backup_cache_count > 1:\n\t\toutput.print_warning(f\"The backup cache directory {backup_cache_path} has more than one backup target cache. Consider clearing this directory to save disk space.\")\n\ndef check_free_memory(rounded_values, env, output):\n\t# Check free memory.\n\tpercent_free = 100 - psutil.virtual_memory().percent\n\tmemory_msg = f\"System memory is {round(percent_free)!s}% free.\"\n\tif percent_free >= 20:\n\t\tif rounded_values: memory_msg = \"System free memory is at least 20%.\"\n\t\toutput.print_ok(memory_msg)\n\telif percent_free >= 10:\n\t\tif rounded_values: memory_msg = \"System free memory is below 20%.\"\n\t\toutput.print_warning(memory_msg)\n\telse:\n\t\tif rounded_values: memory_msg = \"System free memory is below 10%.\"\n\t\toutput.print_error(memory_msg)\n\n\ndef check_backup(rounded_values, env, output):\n\t# Check backups\n\tbackup_config = get_backup_config(env, for_ui=True)\n\t\n\t# Is the backup enabled?\n\tif backup_config.get(\"target\", \"off\") == \"off\":\n\t\toutput.print_warning(\"Backups are disabled. It is recommended to enable a backup for your box.\")\n\t\treturn\n\telse:\n\t\toutput.print_ok(\"Backups are enabled\")\n\t\n\t# Get the age of the most recent backup\n\tbackup_stat = backup_status(env)\n\t\n\tbackups = backup_stat.get(\"backups\", {})\n\tif backups and len(backups) > 0:\n\t\tmost_recent = backups[0][\"date\"]\n\t\t\n\t\t# Calculate time between most recent backup and current time\n\t\tnow = datetime.datetime.now(dateutil.tz.tzlocal())\n\t\tbk_date = dateutil.parser.parse(most_recent).astimezone(dateutil.tz.tzlocal())\n\t\tbk_age = dateutil.relativedelta.relativedelta(now, bk_date)\n\t\t\n\t\tif bk_age.days > 7:\n\t\t\toutput.print_error(\"Backup is more than a week old\")\n\telse:\n\t\toutput.print_error(\"Could not obtain backup status or no backup has been made (yet). \"\n\t\t\t\"This could happen if you have just enabled backups. In that case, check back tomorrow.\")\n\n\ndef run_network_checks(env, output):\n\t# Also see setup/network-checks.sh.\n\n\toutput.add_heading(\"Network\")\n\n\tcheck_ufw(env, output)\n\n\t# Stop if we cannot make an outbound connection on port 25. Many residential\n\t# networks block outbound port 25 to prevent their network from sending spam.\n\t# See if we can reach one of Google's MTAs with a 5-second timeout.\n\t_code, ret = shell(\"check_call\", [\"/bin/nc\", \"-z\", \"-w5\", \"aspmx.l.google.com\", \"25\"], trap=True)\n\tif ret == 0:\n\t\toutput.print_ok(\"Outbound mail (SMTP port 25) is not blocked.\")\n\telse:\n\t\toutput.print_error(\"\"\"Outbound mail (SMTP port 25) seems to be blocked by your network. You\n\t\t\twill not be able to send any mail. Many residential networks block port 25 to prevent hijacked\n\t\t\tmachines from being able to send spam. A quick connection test to Google's mail server on port 25\n\t\t\tfailed.\"\"\")\n\n\t# Stop if the IPv4 address is listed in the ZEN Spamhaus Block List.\n\t# The user might have ended up on an IP address that was previously in use\n\t# by a spammer, or the user may be deploying on a residential network. We\n\t# will not be able to reliably send mail in these cases.\n\trev_ip4 = \".\".join(reversed(env['PUBLIC_IP'].split('.')))\n\tzen = query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None)\n\tevaluate_spamhaus_lookup(env['PUBLIC_IP'], 'IPv4', rev_ip4, output, zen)\n\n\tif not env['PUBLIC_IPV6']:\n\t\treturn\n\n\tfrom ipaddress import IPv6Address\n\n\trev_ip6 = \".\".join(reversed(IPv6Address(env['PUBLIC_IPV6']).exploded.split(':')))\n\tzen = query_dns(rev_ip6+'.zen.spamhaus.org', 'A', nxdomain=None)\n\tevaluate_spamhaus_lookup(env['PUBLIC_IPV6'], 'IPv6', rev_ip6, output, zen)\n\n\ndef evaluate_spamhaus_lookup(lookupaddress, lookuptype, lookupdomain, output, zen):\n\t# See https://www.spamhaus.org/news/article/807/using-our-public-mirrors-check-your-return-codes-now. for\n\t# information on spamhaus return codes\n\tif zen is None:\n\t\toutput.print_ok(f\"{lookuptype} address is not blacklisted by zen.spamhaus.org.\")\n\telif zen == \"[timeout]\":\n\t\toutput.print_warning(f\"\"\"Connection to zen.spamhaus.org timed out. Could not determine whether this box's\n\t\t \t{lookuptype} address is blacklisted. Please try again later.\"\"\")\n\telif zen == \"[Not Set]\":\n\t\toutput.print_warning(f\"\"\"Could not connect to zen.spamhaus.org. Could not determine whether this box's\n\t\t\t{lookuptype} address is blacklisted. Please try again later.\"\"\")\n\telif zen == \"127.255.255.252\":\n\t\toutput.print_warning(f\"\"\"Incorrect spamhaus query: {lookupdomain + '.zen.spamhaus.org'}. Could not determine whether\n\t\t \tthis box's {lookuptype} address is blacklisted.\"\"\")\n\telif zen == \"127.255.255.254\":\n\t\toutput.print_warning(f\"\"\"Mail-in-a-Box is configured to use a public DNS server. This is not supported by\n\t\t\tspamhaus. Could not determine whether this box's {lookuptype} address is blacklisted.\"\"\")\n\telif zen == \"127.255.255.255\":\n\t\toutput.print_warning(f\"\"\"Too many queries have been performed on the spamhaus server. Could not determine\n\t\t \twhether this box's {lookuptype} address is blacklisted.\"\"\")\n\telse:\n\t\toutput.print_error(f\"\"\"The {lookuptype} address of this machine {lookupaddress} is listed in the Spamhaus Block\n\t\t \tList (code {zen}), which may prevent recipients from receiving your email. See\n\t\t \thttp://www.spamhaus.org/query/ip/{lookupaddress}.\"\"\")\n\n\ndef run_domain_checks(rounded_time, env, output, pool, domains_to_check=None):\n\t# Get the list of domains we handle mail for.\n\tmail_domains = get_mail_domains(env)\n\n\t# Get the list of domains we serve DNS zones for (i.e. does not include subdomains).\n\tdns_zonefiles = dict(get_dns_zones(env))\n\tdns_domains = set(dns_zonefiles)\n\n\t# Get the list of domains we serve HTTPS for.\n\tweb_domains = set(get_web_domains(env))\n\n\tif domains_to_check is None:\n\t\tdomains_to_check = mail_domains | dns_domains | web_domains\n\n\t# Remove \"www\", \"autoconfig\", \"autodiscover\", and \"mta-sts\" subdomains, which we group with their parent,\n\t# if their parent is in the domains to check list.\n\tdomains_to_check = [\n\t\td for d in domains_to_check\n\t\tif not (\n\t\t   d.split(\".\", 1)[0] in {\"www\", \"autoconfig\", \"autodiscover\", \"mta-sts\"}\n\t\t   and len(d.split(\".\", 1)) == 2\n\t\t   and d.split(\".\", 1)[1] in domains_to_check\n\t\t)\n\t]\n\n\t# Get the list of domains that we don't serve web for because of a custom CNAME/A record.\n\tdomains_with_a_records = get_domains_with_a_records(env)\n\n\t# Serial version:\n\t#for domain in sort_domains(domains_to_check, env):\n\t#\trun_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)\n\n\t# Parallelize the checks across a worker pool.\n\targs = ((domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains, domains_with_a_records)\n\t\tfor domain in domains_to_check)\n\tret = pool.starmap(run_domain_checks_on_domain, args, chunksize=1)\n\tret = dict(ret) # (domain, output) => { domain: output }\n\tfor domain in sort_domains(ret, env):\n\t\tret[domain].playback(output)\n\ndef run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains, domains_with_a_records):\n\toutput = BufferedOutput()\n\n\t# When running inside Flask, the worker threads don't get a thread pool automatically.\n\t# Also this method is called in a forked worker pool, so creating a new loop is probably\n\t# a good idea.\n\tasyncio.set_event_loop(asyncio.new_event_loop())\n\n\t# we'd move this up, but this returns non-pickleable values\n\tssl_certificates = get_ssl_certificates(env)\n\n\t# The domain is IDNA-encoded in the database, but for display use Unicode.\n\ttry:\n\t\tdomain_display = idna.decode(domain.encode('ascii'))\n\t\toutput.add_heading(domain_display)\n\texcept (ValueError, UnicodeError, idna.IDNAError) as e:\n\t\t# Looks like we have some invalid data in our database.\n\t\toutput.add_heading(domain)\n\t\toutput.print_error(\"Domain name is invalid: \" + str(e))\n\n\tif domain == env[\"PRIMARY_HOSTNAME\"]:\n\t\tcheck_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles)\n\n\tif domain in dns_domains:\n\t\tcheck_dns_zone(domain, env, output, dns_zonefiles)\n\n\tif domain in mail_domains:\n\t\tcheck_mail_domain(domain, env, output)\n\n\tif domain in web_domains:\n\t\tcheck_web_domain(domain, rounded_time, ssl_certificates, env, output)\n\n\tif domain in dns_domains:\n\t\tcheck_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_a_records)\n\n\t# Check auto-configured subdomains. See run_domain_checks.\n\t# Skip mta-sts because we check the policy directly.\n\tfor label in (\"www\", \"autoconfig\", \"autodiscover\"):\n\t\tsubdomain = label + \".\" + domain\n\t\tif subdomain in web_domains or subdomain in mail_domains:\n\t\t\t# Run checks.\n\t\t\tsubdomain_output = run_domain_checks_on_domain(subdomain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains, domains_with_a_records)\n\n\t\t\t# Prepend the domain name to the start of each check line, and then add to the\n\t\t\t# checks for this domain.\n\t\t\tfor attr, args, kwargs in subdomain_output[1].buf:\n\t\t\t\tif attr == \"add_heading\":\n\t\t\t\t\t# Drop the heading, but use its text as the subdomain name in\n\t\t\t\t\t# each line since it is in Unicode form.\n\t\t\t\t\tsubdomain = args[0]\n\t\t\t\t\tcontinue\n\t\t\t\tif len(args) == 1 and isinstance(args[0], str):\n\t\t\t\t\targs = [ subdomain + \": \" + args[0] ]\n\t\t\t\tgetattr(output, attr)(*args, **kwargs)\n\n\treturn (domain, output)\n\ndef check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):\n\t# If a DS record is set on the zone containing this domain, check DNSSEC now.\n\thas_dnssec = False\n\tfor zone in dns_domains:\n\t\tif (zone == domain or domain.endswith(\".\" + zone)) and query_dns(zone, \"DS\", nxdomain=None) is not None:\n\t\t\thas_dnssec = True\n\t\t\tcheck_dnssec(zone, env, output, dns_zonefiles, is_checking_primary=True)\n\n\tip = query_dns(domain, \"A\")\n\tns_ips = query_dns(\"ns1.\" + domain, \"A\") + '/' + query_dns(\"ns2.\" + domain, \"A\")\n\tmy_ips = env['PUBLIC_IP'] + ((\" / \"+env['PUBLIC_IPV6']) if env.get(\"PUBLIC_IPV6\") else \"\")\n\n\t# Check that the ns1/ns2 hostnames resolve to A records. This information probably\n\t# comes from the TLD since the information is set at the registrar as glue records.\n\t# We're probably not actually checking that here but instead checking that we, as\n\t# the nameserver, are reporting the right info --- but if the glue is incorrect this\n\t# will probably fail.\n\tif ns_ips == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']:\n\t\toutput.print_ok(\"Nameserver glue records are correct at registrar. [ns1/ns2.{} ↦ {}]\".format(env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))\n\n\telif ip == env['PUBLIC_IP']:\n\t\t# The NS records are not what we expect, but the domain resolves correctly, so\n\t\t# the user may have set up external DNS. List this discrepancy as a warning.\n\t\toutput.print_warning(\"\"\"Nameserver glue records (ns1.{} and ns2.{}) should be configured at your domain name\n\t\t\tregistrar as having the IP address of this box ({}). They currently report addresses of {}. If you have set up External DNS, this may be OK.\"\"\".format(env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))\n\n\telse:\n\t\toutput.print_error(\"\"\"Nameserver glue records are incorrect. The ns1.{} and ns2.{} nameservers must be configured at your domain name\n\t\t\tregistrar as having the IP address {}. They currently report addresses of {}. It may take several hours for\n\t\t\tpublic DNS to update after a change.\"\"\".format(env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))\n\n\t# Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP[V6] in public DNS.\n\tipv6 = query_dns(domain, \"AAAA\") if env.get(\"PUBLIC_IPV6\") else None\n\tif ip == env['PUBLIC_IP'] and not (ipv6 and env['PUBLIC_IPV6'] and ipv6 != normalize_ip(env['PUBLIC_IPV6'])):\n\t\toutput.print_ok(\"Domain resolves to box's IP address. [{} ↦ {}]\".format(env['PRIMARY_HOSTNAME'], my_ips))\n\telse:\n\t\toutput.print_error(\"\"\"This domain must resolve to this box's IP address ({}) in public DNS but it currently resolves\n\t\t\tto {}. It may take several hours for public DNS to update after a change. This problem may result from other\n\t\t\tissues listed above.\"\"\".format(my_ips, ip + ((\" / \" + ipv6) if ipv6 is not None else \"\")))\n\n\n\t# Check reverse DNS matches the PRIMARY_HOSTNAME. Note that it might not be\n\t# a DNS zone if it is a subdomain of another domain we have a zone for.\n\texisting_rdns_v4 = query_dns(dns.reversename.from_address(env['PUBLIC_IP']), \"PTR\")\n\texisting_rdns_v6 = query_dns(dns.reversename.from_address(env['PUBLIC_IPV6']), \"PTR\") if env.get(\"PUBLIC_IPV6\") else None\n\tif existing_rdns_v4 == domain and existing_rdns_v6 in {None, domain}:\n\t\toutput.print_ok(\"Reverse DNS is set correctly at ISP. [{} ↦ {}]\".format(my_ips, env['PRIMARY_HOSTNAME']))\n\telif existing_rdns_v4 == existing_rdns_v6 or existing_rdns_v6 is None:\n\t\toutput.print_error(f\"\"\"This box's reverse DNS is currently {existing_rdns_v4}, but it should be {domain}. Your ISP or cloud provider will have instructions\n\t\t\ton setting up reverse DNS for this box.\"\"\" )\n\telse:\n\t\toutput.print_error(f\"\"\"This box's reverse DNS is currently {existing_rdns_v4} (IPv4) and {existing_rdns_v6} (IPv6), but it should be {domain}. Your ISP or cloud provider will have instructions\n\t\t\ton setting up reverse DNS for this box.\"\"\" )\n\n\t# Check the TLSA record.\n\ttlsa_qname = \"_25._tcp.\" + domain\n\ttlsa25 = query_dns(tlsa_qname, \"TLSA\", nxdomain=None)\n\ttlsa25_expected = build_tlsa_record(env)\n\tif tlsa25 == tlsa25_expected:\n\t\toutput.print_ok(f\"\"\"The DANE TLSA record for incoming mail is correct ({tlsa_qname}).\"\"\",)\n\telif tlsa25 is None:\n\t\tif has_dnssec:\n\t\t\t# Omit a warning about it not being set if DNSSEC isn't enabled,\n\t\t\t# since TLSA shouldn't be used without DNSSEC.\n\t\t\toutput.print_warning(\"\"\"The DANE TLSA record for incoming mail is not set. This is optional.\"\"\")\n\telse:\n\t\toutput.print_error(f\"\"\"The DANE TLSA record for incoming mail ({tlsa_qname}) is not correct. It is '{tlsa25}' but it should be '{tlsa25_expected}'.\n\t\t\tIt may take several hours for public DNS to update after a change.\"\"\")\n\n\t# Check that the hostmaster@ email address exists.\n\tcheck_alias_exists(\"Hostmaster contact address\", \"hostmaster@\" + domain, env, output)\n\ndef check_alias_exists(alias_name, alias, env, output):\n\tmail_aliases = {address: receivers for address, receivers, *_ in get_mail_aliases(env)}\n\tif alias in mail_aliases:\n\t\tif mail_aliases[alias]:\n\t\t\toutput.print_ok(f\"{alias_name} exists as a mail alias. [{alias} ↦ {mail_aliases[alias]}]\")\n\t\telse:\n\t\t\toutput.print_error(f\"\"\"You must set the destination of the mail alias for {alias} to direct email to you or another administrator.\"\"\")\n\telse:\n\t\toutput.print_error(f\"\"\"You must add a mail alias for {alias} which directs email to you or another administrator.\"\"\")\n\ndef check_dns_zone(domain, env, output, dns_zonefiles):\n\t# If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query.\n\t# If it is not set, we suggest it last.\n\tif query_dns(domain, \"DS\", nxdomain=None) is not None:\n\t\tcheck_dnssec(domain, env, output, dns_zonefiles)\n\n\t# We provide a DNS zone for the domain. It should have NS records set up\n\t# at the domain name's registrar pointing to this box. The secondary DNS\n\t# server may be customized.\n\t# (I'm not sure whether this necessarily tests the TLD's configuration,\n\t# as it should, or if one successful NS line at the TLD will result in\n\t# this query being answered by the box, which would mean the test is only\n\t# half working.)\n\n\tcustom_dns_records = list(get_custom_dns_config(env)) # generator => list so we can reuse it\n\tcorrect_ip = \"; \".join(sorted(get_custom_dns_records(custom_dns_records, domain, \"A\"))) or env['PUBLIC_IP']\n\tcustom_secondary_ns = get_secondary_dns(custom_dns_records, mode=\"NS\")\n\tsecondary_ns = custom_secondary_ns or [\"ns2.\" + env['PRIMARY_HOSTNAME']]\n\n\texisting_ns = query_dns(domain, \"NS\")\n\tcorrect_ns = \"; \".join(sorted([\"ns1.\" + env[\"PRIMARY_HOSTNAME\"], *secondary_ns]))\n\tip = query_dns(domain, \"A\")\n\n\tprobably_external_dns = False\n\n\tif existing_ns.lower() == correct_ns.lower():\n\t\toutput.print_ok(f\"Nameservers are set correctly at registrar. [{correct_ns}]\")\n\telif ip == correct_ip:\n\t\t# The domain resolves correctly, so maybe the user is using External DNS.\n\t\toutput.print_warning(f\"\"\"The nameservers set on this domain at your domain name registrar should be {correct_ns}. They are currently {existing_ns}.\n\t\t\tIf you are using External DNS, this may be OK.\"\"\" )\n\t\tprobably_external_dns = True\n\telse:\n\t\toutput.print_error(f\"\"\"The nameservers set on this domain are incorrect. They are currently {existing_ns}. Use your domain name registrar's\n\t\t\tcontrol panel to set the nameservers to {correct_ns}.\"\"\" )\n\n\t# Check that each custom secondary nameserver resolves the IP address.\n\n\tif custom_secondary_ns and not probably_external_dns:\n\t\tSOARecord = query_dns(domain, \"SOA\", at=env['PUBLIC_IP'])# Explicitly ask the local dns server.\n\n\t\tfor ns in custom_secondary_ns:\n\t\t\t# We must first resolve the nameserver to an IP address so we can query it.\n\t\t\tns_ips = query_dns(ns, \"A\")\n\t\t\tif not ns_ips or ns_ips in {'[Not Set]', '[timeout]'}:\n\t\t\t\toutput.print_error(f\"Secondary nameserver {ns} is not valid (it doesn't resolve to an IP address).\")\n\t\t\t\tcontinue\n\t\t\t# Choose the first IP if nameserver returns multiple\n\t\t\tns_ip = ns_ips.split('; ')[0]\n\t\t\t\n\t\t\t# No need to check if we could not obtain the SOA record\n\t\t\tif SOARecord == '[timeout]':\n\t\t\t\tcheckSOA = False\n\t\t\telse:\t\t\t\n\t\t\t\tcheckSOA = True\n\n\t\t\t# Now query it to see what it says about this domain.\n\t\t\tip = query_dns(domain, \"A\", at=ns_ip, nxdomain=None)\n\t\t\tif ip == correct_ip:\n\t\t\t\toutput.print_ok(f\"Secondary nameserver {ns} resolved the domain correctly.\")\n\t\t\telif ip is None:\n\t\t\t\toutput.print_error(f\"Secondary nameserver {ns} is not configured to resolve this domain.\")\n\t\t\t\t# No need to check SOA record if not configured as nameserver\n\t\t\t\tcheckSOA = False\n\t\t\telif ip == '[timeout]':\n\t\t\t\toutput.print_error(f\"Secondary nameserver {ns} did not resolve this domain, result: {ip}\")\n\t\t\t\tcheckSOA = False\n\t\t\telse:\n\t\t\t\toutput.print_error(f\"Secondary nameserver {ns} is not configured correctly. (It resolved this domain as {ip}. It should be {correct_ip}.)\")\n\n\t\t\tif checkSOA:\n\t\t\t\t# Check that secondary DNS server is synchronized with our primary DNS server. Simplified by checking the SOA record which has a version number\n\t\t\t\tSOASecondary = query_dns(domain, \"SOA\", at=ns_ip)\n\n\t\t\t\tif SOARecord == SOASecondary:\n\t\t\t\t\toutput.print_ok(f\"Secondary nameserver {ns} has consistent SOA record.\")\n\t\t\t\telif SOASecondary == '[Not Set]':\n\t\t\t\t\toutput.print_error(f\"Secondary nameserver {ns} has no SOA record configured.\")\n\t\t\t\telif SOASecondary == '[timeout]':\n\t\t\t\t\toutput.print_warning(f\"Secondary nameserver {ns} timed out on checking SOA record.\")\n\t\t\t\telse:\n\t\t\t\t\toutput.print_error(f\"\"\"Secondary nameserver {ns} has inconsistent SOA record (primary: {SOARecord} versus secondary: {SOASecondary}).\n\t\t\t\t\tCheck that synchronization between secondary and primary DNS servers is properly set-up.\"\"\")\n\ndef check_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_a_records):\n\t# Warn if a custom DNS record is preventing this or the automatic www redirect from\n\t# being served.\n\tif domain in domains_with_a_records:\n\t\toutput.print_warning(\"\"\"Web has been disabled for this domain because you have set a custom DNS record.\"\"\")\n\tif \"www.\" + domain in domains_with_a_records:\n\t\toutput.print_warning(f\"\"\"A redirect from 'www.{domain}' has been disabled for this domain because you have set a custom DNS record on the www subdomain.\"\"\")\n\n\t# Since DNSSEC is optional, if a DS record is NOT set at the registrar suggest it.\n\t# (If it was set, we did the check earlier.)\n\tif query_dns(domain, \"DS\", nxdomain=None) is None:\n\t\tcheck_dnssec(domain, env, output, dns_zonefiles)\n\n\ndef check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):\n\t# See if the domain has a DS record set at the registrar. The DS record must\n\t# match one of the keys that we've used to sign the zone. It may use one of\n\t# several hashing algorithms. We've pre-generated all possible valid DS\n\t# records, although some will be preferred.\n\n\talg_name_map = { '7': 'RSASHA1-NSEC3-SHA1', '8': 'RSASHA256', '13': 'ECDSAP256SHA256' }\n\tdigalg_name_map = { '1': 'SHA-1', '2': 'SHA-256', '4': 'SHA-384' }\n\n\t# Read in the pre-generated DS records\n\texpected_ds_records = { }\n\tds_file = '/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds'\n\tif not os.path.exists(ds_file): return # Domain is in our database but DNS has not yet been updated.\n\twith open(ds_file, encoding=\"utf-8\") as f:\n\t\tfor rr_ds in f:\n\t\t\trr_ds = rr_ds.rstrip()\n\t\t\tds_keytag, ds_alg, ds_digalg, ds_digest = rr_ds.split(\"\\t\")[4].split(\" \")\n\n\t\t\t# Some registrars may want the public key so they can compute the digest. The DS\n\t\t\t# record that we suggest using is for the KSK (and that's how the DS records were generated).\n\t\t\t# We'll also give the nice name for the key algorithm.\n\t\t\tdnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], f'dns/dnssec/{alg_name_map[ds_alg]}.conf'))\n\t\t\twith open(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys['KSK'] + '.key'), encoding=\"utf-8\") as f:\n\t\t\t\tdnsssec_pubkey = f.read().split(\"\\t\")[3].split(\" \")[3]\n\n\t\t\texpected_ds_records[ ds_keytag, ds_alg, ds_digalg, ds_digest ] = {\n\t\t\t\t\"record\": rr_ds,\n\t\t\t\t\"keytag\": ds_keytag,\n\t\t\t\t\"alg\": ds_alg,\n\t\t\t\t\"alg_name\": alg_name_map[ds_alg],\n\t\t\t\t\"digalg\": ds_digalg,\n\t\t\t\t\"digalg_name\": digalg_name_map[ds_digalg],\n\t\t\t\t\"digest\": ds_digest,\n\t\t\t\t\"pubkey\": dnsssec_pubkey,\n\t\t\t}\n\n\t# Query public DNS for the DS record at the registrar.\n\tds = query_dns(domain, \"DS\", nxdomain=None, as_list=True)\n\tif ds is None or isinstance(ds, str): ds = []\n\n\t# There may be more that one record, so we get the result as a list.\n\t# Filter out records that don't look valid, just in case, and split\n\t# each record on spaces.\n\tds = [tuple(str(rr).split(\" \")) for rr in ds if len(str(rr).split(\" \")) == 4]\n\n\tif len(ds) == 0:\n\t\toutput.print_warning(\"\"\"This domain's DNSSEC DS record is not set. The DS record is optional. The DS record activates DNSSEC. See below for instructions.\"\"\")\n\telse:\n\t\tmatched_ds = set(ds) & set(expected_ds_records)\n\t\tif matched_ds:\n\t\t\t# At least one DS record matches one that corresponds with one of the ways we signed\n\t\t\t# the zone, so it is valid.\n\t\t\t#\n\t\t\t# But it may not be preferred. Only algorithm 13 is preferred. Warn if any of the\n\t\t\t# matched zones uses a different algorithm.\n\t\t\tif {r[1] for r in matched_ds} == { '13' } and {r[2] for r in matched_ds} <= { '2', '4' }: # all are alg 13 and digest type 2 or 4\n\t\t\t\toutput.print_ok(\"DNSSEC 'DS' record is set correctly at registrar.\")\n\t\t\t\treturn\n\t\t\tif len([r for r in matched_ds if r[1] == '13' and r[2] in { '2', '4' }]) > 0: # some but not all are alg 13\n\t\t\t\toutput.print_ok(\"DNSSEC 'DS' record is set correctly at registrar. (Records using algorithm other than ECDSAP256SHA256 and digest types other than SHA-256/384 should be removed.)\")\n\t\t\t\treturn\n\t\t\t# no record uses alg 13\n\t\t\toutput.print_warning(\"\"\"DNSSEC 'DS' record set at registrar is valid but should be updated to ECDSAP256SHA256 and SHA-256 (see below).\n\t\t\t\tIMPORTANT: Do not delete existing DNSSEC 'DS' records for this domain until confirmation that the new DNSSEC 'DS' record\n\t\t\t\tfor this domain is valid.\"\"\")\n\t\telse:\n\t\t\tif is_checking_primary:\n\t\t\t\toutput.print_error(f\"\"\"The DNSSEC 'DS' record for {domain} is incorrect. See further details below.\"\"\")\n\t\t\t\treturn\n\t\t\toutput.print_error(\"\"\"This domain's DNSSEC DS record is incorrect. The chain of trust is broken between the public DNS system\n\t\t\t\tand this machine's DNS server. It may take several hours for public DNS to update after a change. If you did not recently\n\t\t\t\tmake a change, you must resolve this immediately (see below).\"\"\")\n\n\toutput.print_line(\"\"\"Follow the instructions provided by your domain name registrar to set a DS record.\n\t\tRegistrars support different sorts of DS records. Use the first option that works:\"\"\")\n\tpreferred_ds_order = [(7, 2), (8, 4), (13, 4), (8, 2), (13, 2)] # low to high, see https://github.com/mail-in-a-box/mailinabox/issues/1998\n\n\tdef preferred_ds_order_func(ds_suggestion):\n\t\tk = (int(ds_suggestion['alg']), int(ds_suggestion['digalg']))\n\t\tif k in preferred_ds_order:\n\t\t\treturn preferred_ds_order.index(k)\n\t\treturn -1 # index before first item\n\toutput.print_line(\"\")\n\tfor i, ds_suggestion in enumerate(sorted(expected_ds_records.values(), key=preferred_ds_order_func, reverse=True)):\n\t\tif preferred_ds_order_func(ds_suggestion) == -1: continue # don't offer record types that the RFC says we must not offer\n\t\toutput.print_line(\"\")\n\t\toutput.print_line(\"Option \" + str(i+1) + \":\")\n\t\toutput.print_line(\"----------\")\n\t\toutput.print_line(\"Key Tag: \" + ds_suggestion['keytag'])\n\t\toutput.print_line(\"Key Flags: KSK / 257\")\n\t\toutput.print_line(\"Algorithm: {} / {}\".format(ds_suggestion['alg'], ds_suggestion['alg_name']))\n\t\toutput.print_line(\"Digest Type: {} / {}\".format(ds_suggestion['digalg'], ds_suggestion['digalg_name']))\n\t\toutput.print_line(\"Digest: \" + ds_suggestion['digest'])\n\t\toutput.print_line(\"Public Key: \")\n\t\toutput.print_line(ds_suggestion['pubkey'], monospace=True)\n\t\toutput.print_line(\"\")\n\t\toutput.print_line(\"Bulk/Record Format:\")\n\t\toutput.print_line(ds_suggestion['record'], monospace=True)\n\tif len(ds) > 0:\n\t\toutput.print_line(\"\")\n\t\toutput.print_line(\"The DS record is currently set to:\")\n\t\tfor rr in sorted(ds):\n\t\t\toutput.print_line(\"Key Tag: {}, Algorithm: {}, Digest Type: {}, Digest: {}\".format(*rr))\n\ndef check_mail_domain(domain, env, output):\n\t# Check the MX record.\n\n\trecommended_mx = \"10 \" + env['PRIMARY_HOSTNAME']\n\tmx = query_dns(domain, \"MX\", nxdomain=None)\n\n\tif mx is None or mx == \"[timeout]\":\n\t\tmxhost = None\n\telse:\n\t\t# query_dns returns a semicolon-delimited list\n\t\t# of priority-host pairs.\n\t\tmxhost = mx.split('; ')[0].split(' ')[1]\n\n\tif mxhost is None:\n\t\t# A missing MX record is okay on the primary hostname because\n\t\t# the primary hostname's A record (the MX fallback) is... itself,\n\t\t# which is what we want the MX to be.\n\t\tif domain == env['PRIMARY_HOSTNAME']:\n\t\t\toutput.print_ok(f\"Domain's email is directed to this domain. [{domain} has no MX record, which is ok]\")\n\n\t\t# And a missing MX record is okay on other domains if the A record\n\t\t# matches the A record of the PRIMARY_HOSTNAME. Actually this will\n\t\t# probably confuse DANE TLSA, but we'll let that slide for now.\n\t\telse:\n\t\t\tdomain_a = query_dns(domain, \"A\", nxdomain=None)\n\t\t\tprimary_a = query_dns(env['PRIMARY_HOSTNAME'], \"A\", nxdomain=None)\n\t\t\tif domain_a is not None and domain_a == primary_a:\n\t\t\t\toutput.print_ok(f\"Domain's email is directed to this domain. [{domain} has no MX record but its A record is OK]\")\n\t\t\telse:\n\t\t\t\toutput.print_error(f\"\"\"This domain's DNS MX record is not set. It should be '{recommended_mx}'. Mail will not\n\t\t\t\t\tbe delivered to this box. It may take several hours for public DNS to update after a\n\t\t\t\t\tchange. This problem may result from other issues listed here.\"\"\")\n\n\telif mxhost == env['PRIMARY_HOSTNAME']:\n\t\tgood_news = f\"Domain's email is directed to this domain. [{domain} ↦ {mx}]\"\n\t\tif mx != recommended_mx:\n\t\t\tgood_news += f\"  This configuration is non-standard.  The recommended configuration is '{recommended_mx}'.\"\n\t\toutput.print_ok(good_news)\n\n\t\t# Check MTA-STS policy.\n\t\tloop = asyncio.new_event_loop()\n\t\tsts_resolver = postfix_mta_sts_resolver.resolver.STSResolver(loop=loop)\n\t\tvalid, policy = loop.run_until_complete(sts_resolver.resolve(domain))\n\t\tif valid == postfix_mta_sts_resolver.resolver.STSFetchResult.VALID:\n\t\t\tif policy[1].get(\"mx\") == [env['PRIMARY_HOSTNAME']] and policy[1].get(\"mode\") == \"enforce\": # policy[0] is the policyid\n\t\t\t\toutput.print_ok(\"MTA-STS policy is present.\")\n\t\t\telse:\n\t\t\t\toutput.print_error(f\"MTA-STS policy is present but has unexpected settings. [{policy[1]}]\")\n\t\telse:\n\t\t\toutput.print_error(f\"MTA-STS policy is missing: {valid}\")\n\n\telse:\n\t\toutput.print_error(f\"\"\"This domain's DNS MX record is incorrect. It is currently set to '{mx}' but should be '{recommended_mx}'. Mail will not\n\t\t\tbe delivered to this box. It may take several hours for public DNS to update after a change. This problem may result from\n\t\t\tother issues listed here.\"\"\")\n\n\t# Check that the postmaster@ email address exists. Not required if the domain has a\n\t# catch-all address or domain alias.\n\tif \"@\" + domain not in [address for address, *_ in get_mail_aliases(env)]:\n\t\tcheck_alias_exists(\"Postmaster contact address\", \"postmaster@\" + domain, env, output)\n\n\t# Stop if the domain is listed in the Spamhaus Domain Block List.\n\t# The user might have chosen a domain that was previously in use by a spammer\n\t# and will not be able to reliably send mail.\n\t\n\t# See https://www.spamhaus.org/news/article/807/using-our-public-mirrors-check-your-return-codes-now. for\n\t# information on spamhaus return codes\n\tdbl = query_dns(domain+'.dbl.spamhaus.org', \"A\", nxdomain=None)\n\tif dbl is None:\n\t\toutput.print_ok(\"Domain is not blacklisted by dbl.spamhaus.org.\")\n\telif dbl == \"[timeout]\":\n\t\toutput.print_warning(f\"Connection to dbl.spamhaus.org timed out. Could not determine whether the domain {domain} is blacklisted. Please try again later.\")\n\telif dbl == \"[Not Set]\":\n\t\toutput.print_warning(f\"Could not connect to dbl.spamhaus.org. Could not determine whether the domain {domain} is blacklisted. Please try again later.\")\n\telif dbl == \"127.255.255.252\":\n\t\toutput.print_warning(\"Incorrect spamhaus query: {}. Could not determine whether the domain {} is blacklisted.\".format(domain+'.dbl.spamhaus.org', domain))\n\telif dbl == \"127.255.255.254\":\n\t\toutput.print_warning(f\"Mail-in-a-Box is configured to use a public DNS server. This is not supported by spamhaus. Could not determine whether the domain {domain} is blacklisted.\")\n\telif dbl == \"127.255.255.255\":\n\t\toutput.print_warning(f\"Too many queries have been performed on the spamhaus server. Could not determine whether the domain {domain} is blacklisted.\")\n\telse:\n\t\toutput.print_error(f\"\"\"This domain is listed in the Spamhaus Domain Block List (code {dbl}),\n\t\t\twhich may prevent recipients from receiving your mail.\n\t\t\tSee http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/{domain}.\"\"\")\n\ndef check_web_domain(domain, rounded_time, ssl_certificates, env, output):\n\t# See if the domain's A record resolves to our PUBLIC_IP. This is already checked\n\t# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and\n\t# other domains, it is required to access its website.\n\tif domain != env['PRIMARY_HOSTNAME']:\n\t\tok_values = []\n\t\tfor (rtype, expected) in ((\"A\", env['PUBLIC_IP']), (\"AAAA\", env.get('PUBLIC_IPV6'))):\n\t\t\tif not expected: continue # IPv6 is not configured\n\t\t\tvalue = query_dns(domain, rtype)\n\t\t\tif value == normalize_ip(expected):\n\t\t\t\tok_values.append(value)\n\t\t\telse:\n\t\t\t\toutput.print_error(f\"\"\"This domain should resolve to this box's IP address ({rtype} {expected}) if you would like the box to serve\n\t\t\t\t\twebmail or a website on this domain. The domain currently resolves to {value} in public DNS. It may take several hours for\n\t\t\t\t\tpublic DNS to update after a change. This problem may result from other issues listed here.\"\"\")\n\t\t\t\treturn\n\n\t\t# If both A and AAAA are correct...\n\t\toutput.print_ok(\"Domain resolves to this box's IP address. [{} ↦ {}]\".format(domain, '; '.join(ok_values)))\n\n\n\t# We need a TLS certificate for PRIMARY_HOSTNAME because that's where the\n\t# user will log in with IMAP or webmail. Any other domain we serve a\n\t# website for also needs a signed certificate.\n\tcheck_ssl_cert(domain, rounded_time, ssl_certificates, env, output)\n\ndef query_dns(qname, rtype, nxdomain='[Not Set]', at=None, as_list=False):\n\t# Make the qname absolute by appending a period. Without this, dns.resolver.query\n\t# will fall back a failed lookup to a second query with this machine's hostname\n\t# appended. This has been causing some false-positive Spamhaus reports. The\n\t# reverse DNS lookup will pass a dns.name.Name instance which is already\n\t# absolute so we should not modify that.\n\tif isinstance(qname, str):\n\t\tqname += \".\"\n\n\t# Use the default nameservers (as defined by the system, which is our locally\n\t# running bind server), or if the 'at' argument is specified, use that host\n\t# as the nameserver.\n\tresolver = dns.resolver.get_default_resolver()\n\n\t# Make sure at is not a string that cannot be used as a nameserver\n\tif at and at not in {'[Not set]', '[timeout]'}:\n\t\tresolver = dns.resolver.Resolver()\n\t\tresolver.nameservers = [at]\n\n\t# Set a timeout so that a non-responsive server doesn't hold us back.\n\tresolver.timeout = 5\n\t# The number of seconds to spend trying to get an answer to the question. If the\n\t# lifetime expires a dns.exception.Timeout exception will be raised.\n\tresolver.lifetime = 5\n\n\t# Do the query.\n\ttry:\n\t\tresponse = resolver.resolve(qname, rtype)\n\texcept (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):\n\t\t# Host did not have an answer for this query; not sure what the\n\t\t# difference is between the two exceptions.\n\t\treturn nxdomain\n\texcept dns.exception.Timeout:\n\t\treturn \"[timeout]\"\n\n\t# Normalize IP addresses. IP address --- especially IPv6 addresses --- can\n\t# be expressed in equivalent string forms. Canonicalize the form before\n\t# returning them. The caller should normalize any IP addresses the result\n\t# of this method is compared with.\n\tif rtype in {\"A\", \"AAAA\"}:\n\t\tresponse = [normalize_ip(str(r)) for r in response]\n\n\tif as_list:\n\t\treturn response\n\n\t# There may be multiple answers; concatenate the response. Remove trailing\n\t# periods from responses since that's how qnames are encoded in DNS but is\n\t# confusing for us. The order of the answers doesn't matter, so sort so we\n\t# can compare to a well known order.\n\treturn \"; \".join(sorted(str(r).rstrip('.') for r in response))\n\ndef check_ssl_cert(domain, rounded_time, ssl_certificates, env, output):\n\t# Check that TLS certificate is signed.\n\n\t# Skip the check if the A record is not pointed here.\n\tif query_dns(domain, \"A\", None) not in {env['PUBLIC_IP'], None}: return\n\n\t# Where is the certificate file stored?\n\ttls_cert = get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=True)\n\tif tls_cert is None:\n\t\toutput.print_warning(\"\"\"No TLS (SSL) certificate is installed for this domain. Visitors to a website on\n\t\t\tthis domain will get a security warning. If you are not serving a website on this domain, you do\n\t\t\tnot need to take any action. Use the TLS Certificates page in the control panel to install a\n\t\t\tTLS certificate.\"\"\")\n\t\treturn\n\n\t# Check that the certificate is good.\n\n\tcert_status, cert_status_details = check_certificate(domain, tls_cert[\"certificate\"], tls_cert[\"private-key\"], rounded_time=rounded_time)\n\n\tif cert_status == \"OK\":\n\t\t# The certificate is ok. The details has expiry info.\n\t\toutput.print_ok(\"TLS (SSL) certificate is signed & valid. \" + cert_status_details)\n\n\telif cert_status == \"SELF-SIGNED\":\n\t\t# Offer instructions for purchasing a signed certificate.\n\t\tif domain == env['PRIMARY_HOSTNAME']:\n\t\t\toutput.print_error(\"\"\"The TLS (SSL) certificate for this domain is currently self-signed. You will get a security\n\t\t\twarning when you check or send email and when visiting this domain in a web browser (for webmail or\n\t\t\tstatic site hosting).\"\"\")\n\t\telse:\n\t\t\toutput.print_error(\"\"\"The TLS (SSL) certificate for this domain is self-signed.\"\"\")\n\n\telse:\n\t\toutput.print_error(\"The TLS (SSL) certificate has a problem: \" + cert_status)\n\t\tif cert_status_details:\n\t\t\toutput.print_line(\"\")\n\t\t\toutput.print_line(cert_status_details)\n\t\t\toutput.print_line(\"\")\n\n_apt_updates = None\ndef list_apt_updates(apt_update=True):\n\t# See if we have this information cached recently.\n\t# Keep the information for 8 hours.\n\tglobal _apt_updates\n\tif _apt_updates is not None and _apt_updates[0] > datetime.datetime.now() - datetime.timedelta(hours=8):\n\t\treturn _apt_updates[1]\n\n\t# Run apt-get update to refresh package list. This should be running daily\n\t# anyway, so on the status checks page don't do this because it is slow.\n\tif apt_update:\n\t\tshell(\"check_call\", [\"/usr/bin/apt-get\", \"-qq\", \"update\"])\n\n\t# Run apt-get upgrade in simulate mode to get a list of what\n\t# it would do.\n\tsimulated_install = shell(\"check_output\", [\"/usr/bin/apt-get\", \"-qq\", \"-s\", \"upgrade\"])\n\tpkgs = []\n\tfor line in simulated_install.split('\\n'):\n\t\tif line.strip() == \"\":\n\t\t\tcontinue\n\t\tif re.match(r'^Conf .*', line):\n\t\t\t # remove these lines, not informative\n\t\t\tcontinue\n\t\tm = re.match(r'^Inst (.*) \\[(.*)\\] \\((\\S*)', line)\n\t\tif m:\n\t\t\tpkgs.append({ \"package\": m.group(1), \"version\": m.group(3), \"current_version\": m.group(2) })\n\t\telse:\n\t\t\tpkgs.append({ \"package\": \"[\" + line + \"]\", \"version\": \"\", \"current_version\": \"\" })\n\n\t# Cache for future requests.\n\t_apt_updates = (datetime.datetime.now(), pkgs)\n\n\treturn pkgs\n\ndef what_version_is_this(env):\n\t# This function runs `git describe --always --abbrev=0` on the Mail-in-a-Box installation directory.\n\t# Git may not be installed and Mail-in-a-Box may not have been cloned from github,\n\t# so this function may raise all sorts of exceptions.\n\tmiab_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n\treturn shell(\"check_output\", [\"/usr/bin/git\", \"describe\", \"--always\", \"--abbrev=0\"], env={\"GIT_DIR\": os.path.join(miab_dir, '.git')}).strip()\n\ndef get_latest_miab_version():\n\t# This pings https://mailinabox.email/setup.sh and extracts the tag named in\n\t# the script to determine the current product version.\n    from urllib.request import urlopen, HTTPError, URLError\n\n    try:\n        return re.search(b'TAG=(.*)', urlopen(\"https://mailinabox.email/setup.sh?ping=1\", timeout=5).read()).group(1).decode(\"utf8\")\n    except (TimeoutError, HTTPError, URLError):\n        return None\n\ndef check_miab_version(env, output):\n\tconfig = load_settings(env)\n\n\ttry:\n\t\tthis_ver = what_version_is_this(env)\n\texcept:\n\t\tthis_ver = \"Unknown\"\n\n\tif config.get(\"privacy\", True):\n\t\toutput.print_warning(f\"You are running version Mail-in-a-Box {this_ver}. Mail-in-a-Box version check disabled by privacy setting.\")\n\telse:\n\t\tlatest_ver = get_latest_miab_version()\n\n\t\tif this_ver == latest_ver:\n\t\t\toutput.print_ok(f\"Mail-in-a-Box is up to date. You are running version {this_ver}.\")\n\t\telif latest_ver is None:\n\t\t\toutput.print_error(f\"Latest Mail-in-a-Box version could not be determined. You are running version {this_ver}.\")\n\t\telse:\n\t\t\toutput.print_error(f\"A new version of Mail-in-a-Box is available. You are running version {this_ver}. The latest version is {latest_ver}. For upgrade instructions, see https://mailinabox.email. \")\n\ndef run_and_output_changes(env, pool):\n\timport json\n\tfrom difflib import SequenceMatcher\n\n\tout = ConsoleOutput()\n\n\t# Run status checks.\n\tcur = BufferedOutput()\n\trun_checks(True, env, cur, pool)\n\n\t# Load previously saved status checks.\n\tcache_fn = \"/var/cache/mailinabox/status_checks.json\"\n\tif os.path.exists(cache_fn):\n\t\twith open(cache_fn, encoding=\"utf-8\") as f:\n\t\t\ttry:\n\t\t\t\tprev = json.load(f)\n\t\t\texcept json.JSONDecodeError:\n\t\t\t\tprev = []\n\n\t\t# Group the serial output into categories by the headings.\n\t\tdef group_by_heading(lines):\n\t\t\tfrom collections import OrderedDict\n\t\t\tret = OrderedDict()\n\t\t\tk = []\n\t\t\tret[\"No Category\"] = k\n\t\t\tfor line_type, line_args, line_kwargs in lines:\n\t\t\t\tif line_type == \"add_heading\":\n\t\t\t\t\tk = []\n\t\t\t\t\tret[line_args[0]] = k\n\t\t\t\telse:\n\t\t\t\t\tk.append((line_type, line_args, line_kwargs))\n\t\t\treturn ret\n\t\tprev_status = group_by_heading(prev)\n\t\tcur_status = group_by_heading(cur.buf)\n\n\t\t# Compare the previous to the current status checks\n\t\t# category by category.\n\t\tfor category, cur_lines in cur_status.items():\n\t\t\tif category not in prev_status:\n\t\t\t\tout.add_heading(category + \" -- Added\")\n\t\t\t\tBufferedOutput(with_lines=cur_lines).playback(out)\n\t\t\telse:\n\t\t\t\t# Actual comparison starts here...\n\t\t\t\tprev_lines = prev_status[category]\n\t\t\t\tdef stringify(lines):\n\t\t\t\t\treturn [json.dumps(line) for line in lines]\n\t\t\t\tdiff = SequenceMatcher(None, stringify(prev_lines), stringify(cur_lines)).get_opcodes()\n\t\t\t\tfor op, i1, i2, j1, j2 in diff:\n\t\t\t\t\tif op == \"replace\":\n\t\t\t\t\t\tout.add_heading(category + \" -- Previously:\")\n\t\t\t\t\telif op == \"delete\":\n\t\t\t\t\t\tout.add_heading(category + \" -- Removed\")\n\t\t\t\t\tif op in {\"replace\", \"delete\"}:\n\t\t\t\t\t\tBufferedOutput(with_lines=prev_lines[i1:i2]).playback(out)\n\n\t\t\t\t\tif op == \"replace\":\n\t\t\t\t\t\tout.add_heading(category + \" -- Currently:\")\n\t\t\t\t\telif op == \"insert\":\n\t\t\t\t\t\tout.add_heading(category + \" -- Added\")\n\t\t\t\t\tif op in {\"replace\", \"insert\"}:\n\t\t\t\t\t\tBufferedOutput(with_lines=cur_lines[j1:j2]).playback(out)\n\n\t\tfor category, prev_lines in prev_status.items():\n\t\t\tif category not in cur_status:\n\t\t\t\tout.add_heading(category)\n\t\t\t\tout.print_warning(\"This section was removed.\")\n\n\t# Store the current status checks output for next time.\n\tos.makedirs(os.path.dirname(cache_fn), exist_ok=True)\n\twith open(cache_fn, \"w\", encoding=\"utf-8\") as f:\n\t\tjson.dump(cur.buf, f, indent=True)\n\ndef normalize_ip(ip):\n\t# Use ipaddress module to normalize the IPv6 notation and\n\t# ensure we are matching IPv6 addresses written in different\n\t# representations according to rfc5952.\n\timport ipaddress\n\ttry:\n\t\treturn str(ipaddress.ip_address(ip))\n\texcept:\n\t\treturn ip\n\nclass FileOutput:\n\tdef __init__(self, buf, width):\n\t\tself.buf = buf\n\t\tself.width = width\n\n\tdef add_heading(self, heading):\n\t\tprint(file=self.buf)\n\t\tprint(heading, file=self.buf)\n\t\tprint(\"=\" * len(heading), file=self.buf)\n\n\tdef print_ok(self, message):\n\t\tself.print_block(message, first_line=\"✓  \")\n\n\tdef print_error(self, message):\n\t\tself.print_block(message, first_line=\"✖  \")\n\n\tdef print_warning(self, message):\n\t\tself.print_block(message, first_line=\"?  \")\n\n\tdef print_block(self, message, first_line=\"   \"):\n\t\tprint(first_line, end='', file=self.buf)\n\t\tmessage = re.sub(\"\\n\\\\s*\", \" \", message)\n\t\twords = re.split(r\"(\\s+)\", message)\n\t\tlinelen = 0\n\t\tfor w in words:\n\t\t\tif self.width and (linelen + len(w) > self.width-1-len(first_line)):\n\t\t\t\tprint(file=self.buf)\n\t\t\t\tprint(\"   \", end=\"\", file=self.buf)\n\t\t\t\tlinelen = 0\n\t\t\tif linelen == 0 and w.strip() == \"\": continue\n\t\t\tprint(w, end=\"\", file=self.buf)\n\t\t\tlinelen += len(w)\n\t\tprint(file=self.buf)\n\n\tdef print_line(self, message, monospace=False):\n\t\tfor line in message.split(\"\\n\"):\n\t\t\tself.print_block(line)\n\nclass ConsoleOutput(FileOutput):\n\tdef __init__(self):\n\t\tself.buf = sys.stdout\n\n\t\t# Do nice line-wrapping according to the size of the terminal.\n\t\t# The 'stty' program queries standard input for terminal information.\n\t\tif sys.stdin.isatty():\n\t\t\ttry:\n\t\t\t\tself.width = int(shell('check_output', ['stty', 'size']).split()[1])\n\t\t\texcept:\n\t\t\t\tself.width = 76\n\n\t\telse:\n\t\t\t# However if standard input is not a terminal, we would get\n\t\t\t# \"stty: standard input: Inappropriate ioctl for device\". So\n\t\t\t# we test with sys.stdin.isatty first, and if it is not a\n\t\t\t# terminal don't do any line wrapping. When this script is\n\t\t\t# run from cron, or if stdin has been redirected, this happens.\n\t\t\tself.width = None\n\nclass BufferedOutput:\n\t# Record all of the instance method calls so we can play them back later.\n\tdef __init__(self, with_lines=None):\n\t\tself.buf = with_lines or []\n\tdef __getattr__(self, attr):\n\t\tif attr not in {\"add_heading\", \"print_ok\", \"print_error\", \"print_warning\", \"print_block\", \"print_line\"}:\n\t\t\traise AttributeError\n\t\t# Return a function that just records the call & arguments to our buffer.\n\t\tdef w(*args, **kwargs):\n\t\t\tself.buf.append((attr, args, kwargs))\n\t\treturn w\n\tdef playback(self, output):\n\t\tfor attr, args, kwargs in self.buf:\n\t\t\tgetattr(output, attr)(*args, **kwargs)\n\n\nif __name__ == \"__main__\":\n\tfrom utils import load_environment\n\n\tenv = load_environment()\n\n\tif len(sys.argv) == 1:\n\t\twith multiprocessing.pool.Pool(processes=10) as pool:\n\t\t\trun_checks(False, env, ConsoleOutput(), pool)\n\n\telif sys.argv[1] == \"--show-changes\":\n\t\twith multiprocessing.pool.Pool(processes=10) as pool:\n\t\t\trun_and_output_changes(env, pool)\n\n\telif sys.argv[1] == \"--check-primary-hostname\":\n\t\t# See if the primary hostname appears resolvable and has a signed certificate.\n\t\tdomain = env['PRIMARY_HOSTNAME']\n\t\tif query_dns(domain, \"A\") != env['PUBLIC_IP']:\n\t\t\tsys.exit(1)\n\t\tssl_certificates = get_ssl_certificates(env)\n\t\ttls_cert = get_domain_ssl_files(domain, ssl_certificates, env)\n\t\tif not os.path.exists(tls_cert[\"certificate\"]):\n\t\t\tsys.exit(1)\n\t\tcert_status, cert_status_details = check_certificate(domain, tls_cert[\"certificate\"], tls_cert[\"private-key\"], warn_if_expiring_soon=False)\n\t\tif cert_status != \"OK\":\n\t\t\tsys.exit(1)\n\t\tsys.exit(0)\n\n\telif sys.argv[1] == \"--version\":\n\t\tprint(what_version_is_this(env))\n\n\telif sys.argv[1] == \"--only\":\n\t\twith multiprocessing.pool.Pool(processes=10) as pool:\n\t\t\trun_checks(False, env, ConsoleOutput(), pool, domains_to_check=sys.argv[2:])\n"
  },
  {
    "path": "management/templates/aliases.html",
    "content": "<style>\n#alias_table .actions > * { padding-right: 3px; }\n#alias_table .alias-auto .actions > * { display: none }\n</style>\n\n<h2>Aliases</h2>\n\n<h3>Add a mail alias</h3>\n\n<p>Aliases are email forwarders. An alias can forward email to a <a href=\"#users\">mail user</a> or to any email address.</p>\n\n<p>To use an alias or any address besides your own login username in outbound mail, the sending user must be included as a permitted sender for the alias.</p>\n\n<form id=\"addalias-form\" class=\"form-horizontal\" role=\"form\" onsubmit=\"do_add_alias(); return false;\">\n  <div class=\"form-group\">\n    <div class=\"col-sm-offset-1 col-sm-11\">\n      <div id=\"alias_type_buttons\" class=\"btn-group btn-group-xs\">\n        <button type=\"button\" class=\"btn btn-default\" data-mode=\"regular\">Regular</button>\n        <button type=\"button\" class=\"btn btn-default\" data-mode=\"catchall\">Catch-All</button>\n        <button type=\"button\" class=\"btn btn-default\" data-mode=\"domainalias\">Domain Alias</button>\n      </div>\n      <div id=\"alias_mode_info\" class=\"text-info small\" style=\"display: none; margin: .5em 0 0 0;\">\n        <span class=\"catchall hidden\">A catch-all alias captures all otherwise unmatched email to a domain.</span>\n        <span class=\"domainalias hidden\">A domain alias forwards all otherwise unmatched email from one domain to another domain, preserving the part before the @-sign.</span>\n      </div>\n    </div>\n  </div>\n  <div class=\"form-group\">\n    <label for=\"addaliasAddress\" class=\"col-sm-1 control-label\">Alias</label>\n    <div class=\"col-sm-10\">\n      <input type=\"email\" class=\"form-control\" id=\"addaliasAddress\">\n      <div style=\"margin-top: 3px; padding-left: 3px; font-size: 90%\" class=\"text-muted\">\n        <span class=\"catchall domainalias\">Enter just the part of an email address starting with the @-sign.</span>\n        You may use international (non-ASCII) characters for the domain part of the email address only.\n      </div>\n    </div>\n  </div>\n  <div class=\"form-group\">\n    <label for=\"addaliasForwardsTo\" class=\"col-sm-1 control-label\">Forwards To</label>\n    <div class=\"col-sm-10\">\n      <textarea class=\"form-control\" rows=\"3\" id=\"addaliasForwardsTo\"></textarea>\n      <div style=\"margin-top: 3px; padding-left: 3px; font-size: 90%\">\n        <span class=\"domainalias text-muted\">Enter just the part of an email address starting with the @-sign.</span>\n        <span class=\"text-danger\">Only forward mail to addresses handled by this Mail-in-a-Box, since mail forwarded by aliases to other domains may be rejected or filtered by the receiver. To forward mail to other domains, create a mail user and then log into webmail for the user and create a filter rule to forward mail.</span>\n      </div>\n    </div>\n  </div>\n  <div class=\"form-group\">\n    <label for=\"addaliasSenders\" class=\"col-sm-1 control-label\">Permitted Senders</label>\n    <div class=\"col-sm-10\">\n      <div class=\"radio\">\n        <label>\n          <input id=\"addaliasForwardsToNotAdvanced\" name=\"addaliasForwardsToDivToggle\" type=\"radio\" checked onclick=\"$('#addaliasForwardsToDiv').toggle(false)\">\n          Any mail user listed in the Forwards To box can send mail claiming to be from <span class=\"regularalias\">the alias address</span><span class=\"catchall domainalias\">any address on the alias domain</span>.\n        </label>\n      </div>\n      <div class=\"radio\">\n        <label>\n          <input id=\"addaliasForwardsToAdvanced\" name=\"addaliasForwardsToDivToggle\" type=\"radio\" id=\"addaliasForwardsToDivShower\" onclick=\"$('#addaliasForwardsToDiv').toggle(true)\">\n          I&rsquo;ll enter the mail users that can send mail claiming to be from <span class=\"regularalias\">the alias address</span><span class=\"catchall domainalias\">any address on the alias domain</span>.\n        </label>\n      </div>\n      <div id=\"addaliasForwardsToDiv\" style=\"margin-top: .5em; margin-left: 1.4em; display: none;\">\n        <textarea class=\"form-control\" rows=\"3\" id=\"addaliasSenders\" placeholder=\"one user per line or separated by commas\"></textarea>\n      </div>\n    </div>\n  </div>\n  <div class=\"form-group\">\n    <div class=\"col-sm-offset-1 col-sm-11\">\n      <button id=\"add-alias-button\" type=\"submit\" class=\"btn btn-primary\">Add Alias</button>\n      <button id=\"alias-cancel\" class=\"btn btn-default hidden\" onclick=\"aliases_reset_form(); return false;\">Cancel</button>\n    </div>\n  </div>\n</form>\n\n<h3>Existing mail aliases</h3>\n<table id=\"alias_table\" class=\"table\" style=\"width: auto\">\n  <thead>\n    <tr>\n      <th></th>\n      <th>Alias<br></th>\n      <th>Forwards To</th>\n      <th>Permitted Senders</th>\n    </tr>\n  </thead>\n  <tbody>\n  </tbody>\n</table>\n\n<p style=\"margin-top: 1.5em\"><small>hostmaster@, postmaster@, admin@ and abuse@ email addresses are required on some domains.</small></p>\n\n<div style=\"display: none\">\n  <table>\n  <tr id=\"alias-template\">\n    <td class='actions'>\n        <a href=\"#\" onclick=\"aliases_edit(this); scroll_top(); return false;\" class='edit' title=\"Edit Alias\">\n          <span class=\"glyphicon glyphicon-pencil\"></span>\n        </a>\n        <a href=\"#\" onclick=\"aliases_remove(this); return false;\" class='remove' title=\"Remove Alias\">\n          <span class=\"glyphicon glyphicon-trash\"></span>\n        </a>\n    </td>\n    <td class='address'> </td>\n    <td class='forwardsTo'> </td>\n    <td class='senders'> </td>\n  </tr>\n  </table>\n</div>\n\n<h3>Mail aliases API (advanced)</h3>\n\n<p>Use your box&rsquo;s mail aliases API to add and remove mail aliases from the command-line or custom services you build.</p>\n\n<p>Usage:</p>\n\n<pre>curl -X <b>VERB</b> [-d \"<b>parameters</b>\"] --user {email}:{password} https://{{hostname}}/admin/mail/aliases[<b>action</b>]</pre>\n\n<p>Brackets denote an optional argument. Please note that the POST body <code>parameters</code> must be URL-encoded.</p>\n\n<p>The email and password given to the <code>--user</code> option must be an administrative user on this system.</p>\n\n<h4 style=\"margin-bottom: 0\">Verbs</h4>\n\n<table class=\"table\" style=\"margin-top: .5em\">\n<thead><th>Verb</th> <th>Action</th><th></th></thead>\n<tr><td>GET</td><td><i>(none)</i></td> <td>Returns a list of existing mail aliases. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>\n<tr><td>POST</td><td>/add</td> <td>Adds a new mail alias. Required POST-body parameters are <code>address</code> and <code>forwards_to</code>.</td></tr>\n<tr><td>POST</td><td>/remove</td> <td>Removes a mail alias. Required POST-body parameter is <code>address</code>.</td></tr>\n</table>\n\n<h4>Examples:</h4>\n\n<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your email address and password.</p>\n\n<pre># Gives a JSON-encoded list of all mail aliases\ncurl -X GET https://{{hostname}}/admin/mail/aliases?format=json\n\n# Adds a new alias\ncurl -X POST -d \"address=new_alias@mydomail.com\" -d \"forwards_to=my_email@mydomain.com\" https://{{hostname}}/admin/mail/aliases/add\n\n# Removes an alias\ncurl -X POST -d \"address=new_alias@mydomail.com\" https://{{hostname}}/admin/mail/aliases/remove\n</pre>\n\n\n<script>\nfunction show_aliases() {\n  $('#alias_table tbody').html(\"<tr><td colspan='2' class='text-muted'>Loading...</td></tr>\")\n  api(\n    \"/mail/aliases\",\n    \"GET\",\n    { format: 'json' },\n    function(r) {\n      $('#alias_table tbody').html(\"\");\n      for (var i = 0; i < r.length; i++) {\n        var hdr = $(\"<tr><th colspan='4' style='background-color: #EEE'></th></tr>\");\n        hdr.find('th').text(r[i].domain);\n        $('#alias_table tbody').append(hdr);\n\n        for (var k = 0; k < r[i].aliases.length; k++) {\n          var alias = r[i].aliases[k];\n\n          var n = $(\"#alias-template\").clone();\n          n.attr('id', '');\n\n          if (alias.auto) n.addClass('alias-auto');\n          n.attr('data-address', alias.address_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend\n          n.find('td.address').text(alias.address_display)\n          for (var j = 0; j < alias.forwards_to.length; j++)\n            n.find('td.forwardsTo').append($(\"<div></div>\").text(alias.forwards_to[j]))\n          for (var j = 0; j < (alias.permitted_senders ? alias.permitted_senders.length : 0); j++)\n            n.find('td.senders').append($(\"<div></div>\").text(alias.permitted_senders[j]))\n          $('#alias_table tbody').append(n);\n        }\n      }\n    })\n\n  $(function() {\n    $('#alias_type_buttons button').off('click').click(function() {\n      $('#alias_type_buttons button').removeClass('active');\n      $(this).addClass('active');\n      $('#addalias-form .regularalias, #addalias-form .catchall, #addalias-form .domainalias').addClass('hidden');\n      if ($(this).attr('data-mode') == \"regular\") {\n        $('#addaliasAddress').attr('type', 'email');\n        $('#addaliasAddress').attr('placeholder', 'you@yourdomain.com (incoming email address)');\n        $('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');\n        $('#alias_mode_info').slideUp();\n        $('#addalias-form .regularalias').removeClass('hidden');\n      } else if ($(this).attr('data-mode') == \"catchall\") {\n        $('#addaliasAddress').attr('type', 'text');\n        $('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)');\n        $('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');\n        $('#alias_mode_info').slideDown();\n        $('#addalias-form .catchall').removeClass('hidden');\n      } else if ($(this).attr('data-mode') == \"domainalias\") {\n        $('#addaliasAddress').attr('type', 'text');\n        $('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)');\n        $('#addaliasForwardsTo').attr('placeholder', '@otherdomain.com (forward to other domain)');\n        $('#alias_mode_info').slideDown();\n        $('#addalias-form .domainalias').removeClass('hidden');\n      }\n    })\n    $('#alias_type_buttons button[data-mode=\"regular\"]').click(); // init\n  })\n}\n\nvar is_alias_add_update = false;\nfunction do_add_alias() {\n  var title = (!is_alias_add_update) ? \"Add Alias\" : \"Update Alias\";\n  var form_address = $(\"#addaliasAddress\").val();\n  var form_forwardsto = $(\"#addaliasForwardsTo\").val();\n  var form_senders = ($('#addaliasForwardsToAdvanced').prop('checked') ? $(\"#addaliasSenders\").val() : '');\n  if ($('#addaliasForwardsToAdvanced').prop('checked') && !/\\S/.exec($(\"#addaliasSenders\").val())) {\n    show_modal_error(title, \"You did not enter any permitted senders.\");\n    return false;\n  }\n  api(\n    \"/mail/aliases/add\",\n    \"POST\",\n    {\n      update_if_exists: is_alias_add_update ? '1' : '0',\n      address: form_address,\n      forwards_to: form_forwardsto,\n      permitted_senders: form_senders\n    },\n    function(r) {\n      // Responses are multiple lines of pre-formatted text.\n      show_modal_error(title, $(\"<pre/>\").text(r));\n      show_aliases()\n      aliases_reset_form();\n    },\n    function(r) {\n      show_modal_error(title, r);\n    });\n  return false;\n}\n\nfunction aliases_reset_form() {\n  $(\"#addaliasAddress\").prop('disabled', false);\n  $(\"#addaliasAddress\").val('')\n  $(\"#addaliasForwardsTo\").val('')\n  $(\"#addaliasSenders\").val('')\n  $('#alias-cancel').addClass('hidden');\n  $('#add-alias-button').text('Add Alias');\n  is_alias_add_update = false;\n}\n\nfunction aliases_edit(elem) {\n  var address = $(elem).parents('tr').attr('data-address');\n  var receiverdivs = $(elem).parents('tr').find('.forwardsTo div');\n  var senderdivs = $(elem).parents('tr').find('.senders div');\n  var forwardsTo = \"\";\n  for (var i = 0; i < receiverdivs.length; i++)\n    forwardsTo += $(receiverdivs[i]).text() + \"\\n\";\n  var senders = \"\";\n  for (var i = 0; i < senderdivs.length; i++)\n    senders += $(senderdivs[i]).text() + \"\\n\";\n  if (address.charAt(0) == '@' && forwardsTo.charAt(0) == '@')\n    $('#alias_type_buttons button[data-mode=\"domainalias\"]').click();\n  else if (address.charAt(0) == '@')\n    $('#alias_type_buttons button[data-mode=\"catchall\"]').click();\n  else\n    $('#alias_type_buttons button[data-mode=\"regular\"]').click();\n  $('#alias-cancel').removeClass('hidden');\n  $(\"#addaliasAddress\").prop('disabled', true);\n  $(\"#addaliasAddress\").val(address);\n  $(\"#addaliasForwardsTo\").val(forwardsTo);\n  $('#addaliasForwardsToAdvanced').prop('checked', senders != \"\");\n  $('#addaliasForwardsToNotAdvanced').prop('checked', senders == \"\");\n  $(\"#addaliasSenders\").val(senders);\n  $('#add-alias-button').text('Update');\n  $('body').animate({ scrollTop: 0 })\n  is_alias_add_update = true;\n}\n\nfunction aliases_remove(elem) {\n  var row_address = $(elem).parents('tr').attr('data-address');\n  show_modal_confirm(\n    \"Remove Alias\",\n    \"Remove \" + row_address + \"?\",\n    \"Remove\",\n    function() {\n      api(\n        \"/mail/aliases/remove\",\n        \"POST\",\n        {\n          address: row_address\n        },\n        function(r) {\n          // Responses are multiple lines of pre-formatted text.\n          show_modal_error(\"Remove Alias\", $(\"<pre/>\").text(r));\n          show_aliases();\n        });\n    });\n}\n\nfunction scroll_top() {\n        $('html, body').animate({\n            scrollTop: $(\"#panel_aliases\").offset().top\n        }, 1000);\n}\n</script>\n"
  },
  {
    "path": "management/templates/custom-dns.html",
    "content": "<style>\n#custom-dns-current td.long {\n  word-break: break-all;\n}\n</style>\n\n<h2>Custom DNS</h2>\n\n<p class=\"text-warning\">This is an advanced configuration page.</p>\n\n<p>It is possible to set custom DNS records on domains hosted here.</p>\n\n<h3>Set custom DNS records</h3>\n\n<p>You can set additional DNS records, such as if you have a website running on another server, to add DKIM records for external mail providers, or for various confirmation-of-ownership tests.</p>\n\n<form class=\"form-horizontal\" role=\"form\" onsubmit=\"do_set_custom_dns(); return false;\">\n  <div class=\"form-group\">\n    <label for=\"customdnsQname\" class=\"col-sm-1 control-label\">Name</label>\n    <div class=\"col-sm-10\">\n      <table style=\"max-width: 400px\">\n      <tr><td>\n        <input type=\"text\" class=\"form-control\" id=\"customdnsQname\" placeholder=\"subdomain\">\n      </td><td style=\"padding: 0 1em; font-weight: bold;\">.</td><td>\n        <select id=\"customdnsZone\" class=\"form-control\"> </select>\n      </td></tr></table>\n      <div class=\"text-info\" style=\"margin-top: .5em\">Leave the left field blank to set a record on the chosen domain name, or enter a subdomain.</div>\n    </div>\n  </div>\n  <div class=\"form-group\">\n    <label for=\"customdnsType\" class=\"col-sm-1 control-label\">Type</label>\n    <div class=\"col-sm-10\">\n      <select id=\"customdnsType\" class=\"form-control\" style=\"max-width: 400px\" onchange=\"show_customdns_rtype_hint()\">\n        <option value=\"A\" data-hint=\"Enter an IPv4 address (i.e. a dotted quad, such as 123.456.789.012).  The 'local' alias sets the record to this box's public IPv4 address.\">A (IPv4 address)</option>\n        <option value=\"AAAA\" data-hint=\"Enter an IPv6 address.  The 'local' alias sets the record to this box's public IPv6 address.\">AAAA (IPv6 address)</option>\n        <option value=\"CAA\" data-hint=\"Enter a CA that can issue certificates for this domain in the form of FLAG TAG VALUE. (0 issuewild &quot;letsencrypt.org&quot;)\">CAA (Certificate Authority Authorization)</option>\n        <option value=\"CNAME\" data-hint=\"Enter another domain name followed by a period at the end (e.g. mypage.github.io.).\">CNAME (DNS forwarding)</option>\n        <option value=\"TXT\" data-hint=\"Enter arbitrary text.\">TXT (text record)</option>\n        <option value=\"MX\" data-hint=\"Enter record in the form of PRIORITY DOMAIN., including trailing period (e.g. 20 mx.example.com.).\">MX (mail exchanger)</option>\n        <option value=\"SRV\" data-hint=\"Enter record in the form of PRIORITY WEIGHT PORT TARGET., including trailing period (e.g. 10 10 5060 sip.example.com.).\">SRV (service record)</option>\n        <option value=\"SSHFP\" data-hint=\"Enter record in the form of ALGORITHM TYPE FINGERPRINT.\">SSHFP (SSH fingerprint record)</option>\n        <option value=\"NS\" data-hint=\"Enter a hostname to which this subdomain should be delegated to\">NS (DNS subdomain delegation)</option>\n      </select>\n    </div>\n  </div>\n  <div class=\"form-group\">\n    <label for=\"customdnsValue\" class=\"col-sm-1 control-label\">Value</label>\n    <div class=\"col-sm-10\">\n      <input type=\"text\" class=\"form-control\" id=\"customdnsValue\" placeholder=\"\">\n      <div id=\"customdnsTypeHint\" class=\"text-info\" style=\"margin-top: .5em\"></div>\n    </div>\n  </div>\n  <div class=\"form-group\">\n    <div class=\"col-sm-offset-1 col-sm-11\">\n      <button type=\"submit\" class=\"btn btn-primary\">Set Record</button>\n    </div>\n  </div>\n</form>\n\n<div style=\"text-align: right; font-size; 90%; margin-top: 1em;\">\n  sort by:\n  <a href=\"#\" onclick=\"window.miab_custom_dns_data_sort_order='qname'; show_current_custom_dns_update_after_sort(); return false;\">domain name</a>\n  |\n  <a href=\"#\" onclick=\"window.miab_custom_dns_data_sort_order='created'; show_current_custom_dns_update_after_sort(); return false;\">created</a>\n</div>\n<table id=\"custom-dns-current\" class=\"table\" style=\"width: auto; display: none; margin-top: 0;\">\n  <thead>\n    <th>Domain Name</th>\n    <th>Record Type</th>\n    <th>Value</th>\n    <th></th>\n  </thead>\n  <tbody>\n    <tr><td colspan=\"4\">Loading...</td></tr>\n  </tbody>\n</table>\n\n<h3>Using a secondary nameserver</h3>\n\n<p>If your TLD requires you to have two separate nameservers, you can either set up <a href=\"#external_dns\">external DNS</a> and ignore the DNS server on this box entirely, or use the DNS server on this box but add a secondary (aka &ldquo;slave&rdquo;) nameserver.</p>\n<p>If you choose to use a secondary nameserver, you must find a secondary nameserver service provider. Your domain name registrar or virtual cloud provider may provide this service for you. Once you set up the secondary nameserver service, enter the hostname (not the IP address) of <em>their</em> secondary nameserver in the box below.</p>\n\n<form class=\"form-horizontal\" role=\"form\" onsubmit=\"do_set_secondary_dns(); return false;\">\n  <div class=\"form-group\">\n    <label for=\"secondarydnsHostname\" class=\"col-sm-1 control-label\">Hostname</label>\n    <div class=\"col-sm-10\">\n      <input type=\"text\" class=\"form-control\" id=\"secondarydnsHostname\" placeholder=\"ns1.cloudprovider.com\">\n    </div>\n  </div>\n  <div class=\"form-group\">\n    <div class=\"col-sm-offset-1 col-sm-11\">\n      <button type=\"submit\" class=\"btn btn-primary\">Update</button>\n    </div>\n  </div>\n  <div class=\"form-group\">\n    <div class=\"col-sm-offset-1 col-sm-11\">\n      <p class=\"small\">\n        Multiple secondary servers can be separated with commas or spaces (i.e., <code>ns2.hostingcompany.com ns3.hostingcompany.com</code>).\n        To enable zone transfers to additional servers without listing them as secondary nameservers, prefix a hostname, IP address, or subnet with <code>xfr:</code>, e.g. <code>xfr:10.20.30.40</code> or <code>xfr:10.0.0.0/8</code>.\n      </p>\n      <p id=\"secondarydns-clear-instructions\" style=\"display: none\" class=\"small\">\n        Clear the input field above and click Update to use this machine itself as secondary DNS, which is the default/normal setup.\n      </p>\n    </div>\n  </div>\n</form>\n\n\n<h3>Custom DNS API</h3>\n\n<p>Use your box&rsquo;s DNS API to set custom DNS records on domains hosted here. For instance, you can create your own dynamic DNS service.</p>\n\n<p>Usage:</p>\n\n<pre>curl -X <b>VERB</b> [-d \"<b>value</b>\"] --user {email}:{password} https://{{hostname}}/admin/dns/custom[/<b>qname</b>[/<b>rtype</b>]]</pre>\n\n<p>(Brackets denote an optional argument.)</p>\n\n<h4>Verbs</h4>\n\n<table class=\"table\">\n<thead><th>Verb</th> <th>Usage</th></thead>\n<tr><td>GET</td> <td>Returns matching custom DNS records as a JSON array of objects. Each object has the keys <code>qname</code>, <code>rtype</code>, and <code>value</code>. The optional <code>qname</code> and <code>rtype</code> parameters in the request URL filter the records returned in the response. The request body (<code>-d \"...\"</code>) must be omitted.</td></tr>\n<tr><td>PUT</td> <td>Sets a custom DNS record replacing any existing records with the same <code>qname</code> and <code>rtype</code>. Use PUT (instead of POST) when you only have one value for a <code>qname</code> and <code>rtype</code>, such as typical <code>A</code> records (without round-robin).</td></tr>\n<tr><td>POST</td> <td>Adds a new custom DNS record. Use POST when you have multiple <code>TXT</code> records or round-robin <code>A</code> records. (PUT would delete previously added records.)</td></tr>\n<tr><td>DELETE</td> <td>Deletes custom DNS records. If the request body (<code>-d \"...\"</code>) is empty or omitted, deletes all records matching the <code>qname</code> and <code>rtype</code>. If the request body is present, deletes only the record matching the <code>qname</code>, <code>rtype</code> and value.</td></tr>\n</table>\n\n<h4>Parameters</h4>\n\n<table class=\"table\">\n<thead><th>Parameter</th> <th>Value</th></thead>\n<tr><td>email</td> <td>The email address of any administrative user here.</td></tr>\n<tr><td>password</td> <td>That user&rsquo;s password.</td></tr>\n<tr><td>qname</td> <td>The fully qualified domain name for the record you are trying to set. It must be one of the domain names or a subdomain of one of the domain names hosted on this box. (Add mail users or aliases to add new domains.)</td></tr>\n<tr><td>rtype</td> <td>The resource type. Defaults to <code>A</code> if omitted. Possible values: <code>A</code> (an IPv4 address), <code>AAAA</code> (an IPv6 address), <code>TXT</code> (a text string), <code>CNAME</code> (an alias, which is a fully qualified domain name &mdash; don&rsquo;t forget the final period), <code>MX</code>, <code>SRV</code>, <code>SSHFP</code>, <code>CAA</code> or <code>NS</code>.</td></tr>\n<tr><td>value</td> <td>For PUT, POST, and DELETE, the record&rsquo;s value. If the <code>rtype</code> is <code>A</code> or <code>AAAA</code> and <code>value</code> is empty or omitted, the IPv4 or IPv6 address of the remote host is used (be sure to use the <code>-4</code> or <code>-6</code> options to curl). This is handy for dynamic DNS!</td></tr>\n</table>\n\n<p>Strict <a href=\"http://tools.ietf.org/html/rfc4408\">SPF</a> and <a href=\"https://datatracker.ietf.org/doc/draft-kucherawy-dmarc-base/?include_text=1\">DMARC</a> records will be added to all custom domains unless you override them.</p>\n\n<h4>Examples:</h4>\n\n<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your email address and password.</p>\n\n<pre># sets laptop.mydomain.com to point to the IP address of the machine you are executing curl on\ncurl -X PUT https://{{hostname}}/admin/dns/custom/laptop.mydomain.com\n\n# deletes that record and all A records for that domain name\ncurl -X DELETE https://{{hostname}}/admin/dns/custom/laptop.mydomain.com\n\n# sets a CNAME alias\ncurl -X PUT -d \"bar.mydomain.com.\" https://{{hostname}}/admin/dns/custom/foo.mydomain.com/cname\n\n# deletes that CNAME and all CNAME records for that domain name\ncurl -X DELETE https://{{hostname}}/admin/dns/custom/foo.mydomain.com/cname\n\n# adds a TXT record using POST to preserve any previous TXT records\ncurl -X POST -d \"some text here\" https://{{hostname}}/admin/dns/custom/foo.mydomain.com/txt\n\n# deletes that one TXT record while preserving other TXT records\ncurl -X DELETE -d \"some text here\" https://{{hostname}}/admin/dns/custom/foo.mydomain.com/txt\n</pre>\n\n<script>\nfunction show_custom_dns() {\n  api(\n    \"/dns/secondary-nameserver\",\n    \"GET\",\n    { },\n    function(data) {\n      $('#secondarydnsHostname').val(data.hostnames.join(' '));\n      $('#secondarydns-clear-instructions').toggle(data.hostnames.length > 0);\n    });\n\n  api(\n    \"/dns/zones\",\n    \"GET\",\n    { },\n    function(data) {\n      $('#customdnsZone').text('');\n      for (var i = 0; i < data.length; i++) {\n        $('#customdnsZone').append($('<option/>').text(data[i]));\n      }\n    });\n\n  show_current_custom_dns();\n  show_customdns_rtype_hint();\n}\n\nfunction show_current_custom_dns() {\n  api(\n    \"/dns/custom\",\n    \"GET\",\n    { },\n    function(data) {\n      if (data.length > 0)\n        $('#custom-dns-current').fadeIn();\n      else\n        $('#custom-dns-current').fadeOut();\n      window.miab_custom_dns_data = data;\n      show_current_custom_dns_update_after_sort();\n    });\n}\n\nfunction show_current_custom_dns_update_after_sort() {\n      var data = window.miab_custom_dns_data;\n      var sort_key = window.miab_custom_dns_data_sort_order || \"qname\";\n\n      data.sort(function(a, b) { return a[\"sort-order\"][sort_key] - b[\"sort-order\"][sort_key] });\n\n      var tbody = $('#custom-dns-current').find(\"tbody\");\n      tbody.text('');\n      var last_zone = null;\n      for (var i = 0; i < data.length; i++) {\n        if (sort_key == \"qname\" && data[i].zone != last_zone) {\n          var r = $(\"<tr><th colspan=4 style='background-color: #EEE'></th></tr>\");\n          r.find(\"th\").text(data[i].zone);\n          tbody.append(r);\n          last_zone = data[i].zone;\n        }\n\n        var tr = $(\"<tr/>\");\n        tbody.append(tr);\n        tr.attr('data-qname', data[i].qname);\n        tr.attr('data-rtype', data[i].rtype);\n        tr.attr('data-value', data[i].value);\n        tr.append($('<td class=\"long\"/>').text(data[i].qname));\n        tr.append($('<td/>').text(data[i].rtype));\n        tr.append($('<td class=\"long\" style=\"max-width: 40em\"/>').text(data[i].value));\n        tr.append($('<td>[<a href=\"#\" onclick=\"return delete_custom_dns_record(this)\">delete</a>]</td>'));\n      }\n}\n\nfunction delete_custom_dns_record(elem) {\n  var qname = $(elem).parents('tr').attr('data-qname');\n  var rtype = $(elem).parents('tr').attr('data-rtype');\n  var value = $(elem).parents('tr').attr('data-value');\n  do_set_custom_dns(qname, rtype, value, \"DELETE\");\n  return false;\n}\n\nfunction do_set_secondary_dns() {\n api(\n    \"/dns/secondary-nameserver\",\n    \"POST\",\n    {\n      hostnames: $('#secondarydnsHostname').val()\n    },\n    function(data) {\n      if (data == \"\") return; // nothing updated\n      show_modal_error(\"Secondary DNS\", $(\"<pre/>\").text(data));\n      $('#secondarydns-clear-instructions').slideDown();\n    },\n    function(err) {\n      show_modal_error(\"Secondary DNS\", $(\"<pre/>\").text(err));\n    });\n}\n\nfunction do_set_custom_dns(qname, rtype, value, method) {\n  if (!qname) {\n    if ($('#customdnsQname').val() != '')\n      qname = $('#customdnsQname').val() + '.' + $('#customdnsZone').val();\n    else\n      qname = $('#customdnsZone').val();\n    rtype = $('#customdnsType').val();\n    value = $('#customdnsValue').val();\n    method = 'POST';\n  }\n\n  api(\n    \"/dns/custom/\" + qname + \"/\" + rtype,\n    method,\n    value,\n    function(data) {\n      if (data == \"\") return; // nothing updated\n      show_modal_error(\"Custom DNS\", $(\"<pre/>\").text(data));\n      show_current_custom_dns();\n    },\n    function(err) {\n      show_modal_error(\"Custom DNS (Error)\", $(\"<pre/>\").text(err));\n    });\n}\n\nfunction show_customdns_rtype_hint() {\n  $('#customdnsTypeHint').text($(\"#customdnsType\").find('option:selected').attr('data-hint'));\n}\n</script>\n"
  },
  {
    "path": "management/templates/external-dns.html",
    "content": "<style>\n#external_dns_settings .heading td {\n  font-weight: bold;\n  font-size: 120%;\n  padding-top: 1.5em;\n}\n#external_dns_settings .heading.first td {\n  border-top: none;\n  padding-top: 0;\n}\n#external_dns_settings .values td {\n  border: 0;\n\tpadding-top: .75em;\n\tpadding-bottom: 0;\n}\n#external_dns_settings .value {\n\tword-break: break-all;\n}\n#external_dns_settings .explanation td {\n\tborder: 0;\n\tpadding-top: .5em;\n\tpadding-bottom: .75em;\n\tfont-style: italic;\n  font-size: 95%;\n\tcolor: #777;\n}\n</style>\n\n<h2>External DNS</h2>\n\n<p class=\"text-warning\">This is an advanced configuration page.</p>\n\n<p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere &mdash; such as in the DNS control panel provided by your domain name registrar or virtual cloud provider &mdash; by copying the DNS zone information shown in the table below into your external DNS server&rsquo;s control panel.</p>\n\n<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your domain name by setting a DS record at your registrar, you will likely have to turn it off before changing nameservers.</p>\n\n\n<p class=\"alert\" role=\"alert\">\n\t<span class=\"glyphicon glyphicon-info-sign\"></span>\n\tYou may encounter zone file errors when attempting to create a TXT record with a long string.\n\t<a href=\"https://tools.ietf.org/html/rfc4408#section-3.1.3\">RFC 4408</a> states a TXT record is allowed to contain multiple strings, and this technique can be used to construct records that would exceed the 255-byte maximum length.\n\tYou may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to validate your zone file.\n</p>\n\n<h3>Download zonefile</h3>\n<p>You can download your zonefiles here or use the table of records below.</p>\n<form class=\"form-inline\" role=\"form\" onsubmit=\"do_download_zonefile(); return false;\">\n    <div class=\"form-group\">\n        <div class=\"form-group\">\n            <label for=\"downloadZonefile\" class=\"control-label sr-only\">Zone</label>\n            <select id=\"downloadZonefile\" class=\"form-control\" style=\"width: auto\"> </select>\n        </div>\n        <button type=\"submit\" class=\"btn btn-primary\">Download</button>\n    </div>\n</form>\n\n<h3>Records</h3>\n\n<table id=\"external_dns_settings\" class=\"table\">\n\t<thead>\n\t\t<tr>\n\t\t\t<th>QName</th>\n\t\t\t<th>Type</th>\n\t\t\t<th>Value</th>\n\t\t</tr>\n\t</thead>\n\t<tbody>\n\t</tbody>\n</table>\n\n<script>\nfunction show_external_dns() {\n  api(\n    \"/dns/zones\",\n    \"GET\",\n    { },\n    function(data) {\n        var zones = $('#downloadZonefile');\n        zones.text('');\n        for (var j = 0; j < data.length; j++) {\n            zones.append($('<option/>').text(data[j]));\n        }\n    });\n\n  $('#external_dns_settings tbody').html(\"<tr><td colspan='2' class='text-muted'>Loading...</td></tr>\")\n  api(\n    \"/dns/dump\",\n    \"GET\",\n    { },\n    function(zones) {\n      $('#external_dns_settings tbody').html(\"\");\n      for (var j = 0; j < zones.length; j++) {\n        var h = $(\"<tr class='heading'><td colspan='3'></td></tr>\");\n        h.find(\"td\").text(zones[j][0]);\n        $('#external_dns_settings tbody').append(h);\n\n        var r = zones[j][1];\n        for (var i = 0; i < r.length; i++) {\n          var n = $(\"<tr class='values'><td class='qname'/><td class='rtype'/><td class='value'/></tr>\");\n          n.find('.qname').text(r[i].qname);\n          n.find('.rtype').text(r[i].rtype);\n          n.find('.value').text(r[i].value);\n          $('#external_dns_settings tbody').append(n);\n\n          var n = $(\"<tr class='explanation'><td colspan='3'/></tr>\");\n          n.find('td').text(r[i].explanation);\n          $('#external_dns_settings tbody').append(n);\n        }\n      }\n    })\n}\n\nfunction do_download_zonefile() {\n    var zone = $('#downloadZonefile').val();\n\n    api(\n        \"/dns/zonefile/\"+ zone,\n        \"GET\",\n        {},\n        function(data) {\n            show_modal_error(\"Download Zonefile\", $(\"<pre/>\").text(data));\n        },\n        function(err) {\n            show_modal_error(\"Download Zonefile (Error)\", $(\"<pre/>\").text(err));\n        });\n}\n</script>\n"
  },
  {
    "path": "management/templates/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge,chrome=1\">\n        <meta charset=\"utf-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\n        <title>{{hostname}} - Mail-in-a-Box Control Panel</title>\n\n        <meta name=\"robots\" content=\"noindex, nofollow\">\n\n        <link rel=\"stylesheet\" href=\"/admin/assets/bootstrap/css/bootstrap.min.css\">\n        <style>\n            body {\n              overflow-y: scroll;\n              padding-bottom: 20px;\n            }\n\n            p {\n              margin-bottom: 1.25em;\n            }\n\n            h1, h2, h3, h4 {\n              font-family: sans-serif;\n              font-weight: bold;\n            }\n\n            h2 {\n              margin: 1em 0;\n            }\n\n            h3 {\n              font-size: 130%;\n              border-bottom: 1px solid black;\n              padding-bottom: 3px;\n              margin-bottom: 13px;\n              margin-top: 30px;\n            }\n            .panel-heading h3 {\n              border: none;\n              padding: 0;\n              margin: 0;\n            }\n\n            h4 {\n              font-size: 110%;\n              margin-bottom: 13px;\n              margin-top: 18px;\n            }\n            h4:first-child {\n              margin-top: 6px;\n            }\n\n            .admin_panel {\n              display: none;\n            }\n\n            table.table {\n              margin: 1.5em 0;\n            }\n\n            ol li {\n              margin-bottom: 1em;\n            }\n\n            .if-logged-in { display: none; }\n            .if-logged-in-admin { display: none; }\n\n      /* The below only gets used if it is supported */\n      @media (prefers-color-scheme: dark) {\n        /* Invert invert lightness but not hue */\n        html {\n          filter: invert(100%) hue-rotate(180deg);\n        }\n\n        /* Override Bootstrap theme here to give more contrast. The black turns to white by the filter. */\n        .form-control {\n          color: black !important;\n        }\n\n        /* Revert the invert for the navbar */\n        button, div.navbar {\n          filter: invert(100%) hue-rotate(180deg);\n        }\n\n        /* Revert the revert for the dropdowns */\n        ul.dropdown-menu {\n          filter: invert(100%) hue-rotate(180deg);\n        }\n      }\n        </style>\n        <link rel=\"stylesheet\" href=\"/admin/assets/bootstrap/css/bootstrap-theme.min.css\">\n    </head>\n    <body>\n\n    <!--[if lt IE 8]><p>Internet Explorer version 8 or any modern web browser is required to use this website, sorry.<![endif]-->\n    <!--[if gt IE 7]><!-->\n\n    <div class=\"navbar navbar-inverse navbar-static-top\" role=\"navigation\">\n      <div class=\"container\">\n        <div class=\"navbar-header\">\n          <button type=\"button\" class=\"navbar-toggle collapsed\" data-toggle=\"collapse\" data-target=\".navbar-collapse\">\n            <span class=\"sr-only\">Toggle navigation</span>\n            <span class=\"icon-bar\"></span>\n            <span class=\"icon-bar\"></span>\n            <span class=\"icon-bar\"></span>\n          </button>\n          <a class=\"navbar-brand\" href=\"#\">{{hostname}}</a>\n        </div>\n        <div class=\"navbar-collapse collapse\">\n          <ul class=\"nav navbar-nav\">\n            <li class=\"dropdown if-logged-in-admin\">\n              <a href=\"#\" class=\"dropdown-toggle\" data-toggle=\"dropdown\">System <b class=\"caret\"></b></a>\n              <ul class=\"dropdown-menu\">\n                <li><a href=\"#system_status\">Status Checks</a></li>\n                <li><a href=\"#tls\">TLS (SSL) Certificates</a></li>\n                <li><a href=\"#system_backup\">Backup Status</a></li>\n                <li class=\"divider\"></li>\n                <li class=\"dropdown-header\">Advanced Pages</li>\n                <li><a href=\"#custom_dns\">Custom DNS</a></li>\n                <li><a href=\"#external_dns\">External DNS</a></li>\n                <li><a href=\"#munin\">Munin Monitoring</a></li>\n              </ul>\n            </li>\n            <li><a href=\"#mail-guide\" class=\"if-logged-in-not-admin\">Mail</a></li>\n            <li class=\"dropdown if-logged-in-admin\">\n              <a href=\"#\" class=\"dropdown-toggle\" data-toggle=\"dropdown\">Mail &amp; Users <b class=\"caret\"></b></a>\n              <ul class=\"dropdown-menu\">\n                <li><a href=\"#mail-guide\">Instructions</a></li>\n                <li><a href=\"#users\">Users</a></li>\n                <li><a href=\"#aliases\">Aliases</a></li>\n                <li class=\"divider\"></li>\n                <li class=\"dropdown-header\">Your Account</li>\n                <li><a href=\"#mfa\">Two-Factor Authentication</a></li>\n              </ul>\n            </li>\n            <li><a href=\"#sync_guide\" class=\"if-logged-in\">Contacts/Calendar</a></li>\n            <li><a href=\"#web\" class=\"if-logged-in-admin\">Web</a></li>\n          </ul>\n          <ul class=\"nav navbar-nav navbar-right\">\n            <li class=\"if-logged-in\"><a href=\"#\" onclick=\"do_logout(); return false;\" style=\"color: white\">Log out</a></li>\n          </ul>\n        </div><!--/.navbar-collapse -->\n      </div>\n    </div>\n\n    <div class=\"container\">\n      <div id=\"panel_welcome\" class=\"admin_panel\">\n      {% include \"welcome.html\" %}\n      </div>\n\n      <div id=\"panel_system_status\" class=\"admin_panel\">\n      {% include \"system-status.html\" %}\n      </div>\n\n      <div id=\"panel_system_backup\" class=\"admin_panel\">\n      {% include \"system-backup.html\" %}\n      </div>\n\n      <div id=\"panel_external_dns\" class=\"admin_panel\">\n      {% include \"external-dns.html\" %}\n      </div>\n\n      <div id=\"panel_custom_dns\" class=\"admin_panel\">\n      {% include \"custom-dns.html\" %}\n      </div>\n\n      <div id=\"panel_mfa\" class=\"admin_panel\">\n      {% include \"mfa.html\" %}\n      </div>\n\n      <div id=\"panel_login\" class=\"admin_panel\">\n      {% include \"login.html\" %}\n      </div>\n\n      <div id=\"panel_mail-guide\" class=\"admin_panel\">\n      {% include \"mail-guide.html\" %}\n      </div>\n\n      <div id=\"panel_users\" class=\"admin_panel\">\n      {% include \"users.html\" %}\n      </div>\n\n      <div id=\"panel_aliases\" class=\"admin_panel\">\n      {% include \"aliases.html\" %}\n      </div>\n\n      <div id=\"panel_sync_guide\" class=\"admin_panel\">\n      {% include \"sync-guide.html\" %}\n      </div>\n\n      <div id=\"panel_web\" class=\"admin_panel\">\n      {% include \"web.html\" %}\n      </div>\n\n      <div id=\"panel_tls\" class=\"admin_panel\">\n      {% include \"ssl.html\" %}\n      </div>\n\n      <div id=\"panel_munin\" class=\"admin_panel\">\n      {% include \"munin.html\" %}\n      </div>\n\n      <hr>\n\n      <footer>\n        <p>This is a <a href=\"https://mailinabox.email\">Mail-in-a-Box</a>.</p>\n      </footer>\n    </div> <!-- /container -->\n\n        <div id=\"ajax_loading_indicator\" style=\"display: none; position: fixed; left: 0; top: 0; width: 100%; height: 100%; z-index: 100000; text-align: center; background-color: rgba(255,255,255,.75)\">\n          <div style=\"margin: 20% auto\">\n            <div><span class=\"fa fa-spinner fa-pulse\"></span></div>\n            <div>Loading...</div>\n          </div>\n        </div>\n\n        <div id=\"global_modal\" class=\"modal fade\" tabindex=\"-1\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"errorModalTitle\">\n          <div class=\"modal-dialog modal-sm\">\n            <div class=\"modal-content\">\n              <div class=\"modal-header\">\n                <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-label=\"Close Dialog\">&times;</button>\n                <h4 class=\"modal-title\" id=\"errorModalTitle\"> </h4>\n              </div>\n              <div class=\"modal-body\">\n                <p> </p>\n              </div>\n              <div class=\"modal-footer\">\n                <button type=\"button\" class=\"btn btn-default\" data-dismiss=\"modal\">OK</button>\n                <button type=\"button\" class=\"btn btn-danger\" data-dismiss=\"modal\">Yes</button>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <script src=\"/admin/assets/jquery.min.js\"></script>\n        <script src=\"/admin/assets/bootstrap/js/bootstrap.min.js\"></script>\n\n        <script>\nvar global_modal_state = null;\nvar global_modal_funcs = null;\n\n$(function() {\n  $('#global_modal').on('shown.bs.modal', function (e) {\n    // set focus to first input in the global modal's body\n    var input = $('#global_modal .modal-body input');\n    if (input.length > 0) $(input[0]).focus();\n  })\n  $('#global_modal .btn-danger').click(function() {\n    // Don't take action now. Wait for the modal to be totally hidden\n    // so that we don't attempt to show another modal while this one\n    // is closing.\n    global_modal_state = 0; // OK\n  })\n  $('#global_modal .btn-default').click(function() {\n    global_modal_state = 1; // Cancel\n  })\n  $('#global_modal').on('hidden.bs.modal', function (e) {\n    // do the cancel function\n    if (global_modal_state == null) global_modal_state = 1; // cancel if the user hit ESC or clicked outside of the modal\n    if (global_modal_funcs && global_modal_funcs[global_modal_state])\n      global_modal_funcs[global_modal_state]();\n  })\n})\n\nfunction show_modal_error(title, message, callback) {\n  $('#global_modal h4').text(title);\n  $('#global_modal .modal-body').html(\"<p/>\");\n  if (typeof question == 'string') {\n    $('#global_modal p').text(message);\n    $('#global_modal .modal-dialog').addClass(\"modal-sm\");\n  } else {\n    $('#global_modal p').html(\"\").append(message);\n    $('#global_modal .modal-dialog').removeClass(\"modal-sm\");\n  }\n  $('#global_modal .btn-default').show().text(\"OK\");\n  $('#global_modal .btn-danger').hide();\n  global_modal_funcs = [callback, callback];\n  global_modal_state = null;\n  $('#global_modal').modal({});\n  return false; // handy when called from onclick\n}\n\nfunction show_modal_confirm(title, question, verb, yes_callback, cancel_callback) {\n  $('#global_modal h4').text(title);\n  if (typeof question == 'string') {\n    $('#global_modal .modal-dialog').addClass(\"modal-sm\");\n    $('#global_modal .modal-body').html(\"<p/>\");\n    $('#global_modal p').text(question);\n  } else {\n    $('#global_modal .modal-dialog').removeClass(\"modal-sm\");\n    $('#global_modal .modal-body').html(\"\").append(question);\n  }\n  if (typeof verb == 'string') {\n    $('#global_modal .btn-default').show().text(\"Cancel\");\n    $('#global_modal .btn-danger').show().text(verb);\n  } else {\n    $('#global_modal .btn-default').show().text(verb[1]);\n    $('#global_modal .btn-danger').show().text(verb[0]);\n  }\n  global_modal_funcs = [yes_callback, cancel_callback];\n  global_modal_state = null;\n  $('#global_modal').modal({});\n  return false; // handy when called from onclick\n}\n\nvar ajax_num_executing_requests = 0;\nfunction ajax_with_indicator(options) {\n  setTimeout(\"if (ajax_num_executing_requests > 0) $('#ajax_loading_indicator').fadeIn()\", 100);\n  function hide_loading_indicator() {\n    ajax_num_executing_requests--;\n    if (ajax_num_executing_requests == 0)\n      $('#ajax_loading_indicator').stop(true).hide(); // stop() prevents an ongoing fade from causing the thing to be shown again after this call\n  }\n  var old_success = options.success;\n  var old_error = options.error;\n  options.success = function(data) {\n    hide_loading_indicator();\n    if (data.status == \"error\")\n      show_modal_error(\"Error\", data.message);\n    else if (old_success)\n      old_success(data);\n  };\n  options.error = function(jqxhr) {\n    hide_loading_indicator();\n    if (!old_error)\n      show_modal_error(\"Error\", \"Something went wrong, sorry.\")\n    else\n      old_error(jqxhr.responseText, jqxhr);\n  };\n  ajax_num_executing_requests++;\n  $.ajax(options);\n  return false; // handy when called from onclick\n}\n\nvar api_credentials = null;\nfunction api(url, method, data, callback, callback_error, headers) {\n  // from http://www.webtoolkit.info/javascript-base64.html\n  function base64encode(input) {\n    _keyStr = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\";\n    var output = \"\";\n    var chr1, chr2, chr3, enc1, enc2, enc3, enc4;\n    var i = 0;\n    while (i < input.length) {\n        chr1 = input.charCodeAt(i++);\n        chr2 = input.charCodeAt(i++);\n        chr3 = input.charCodeAt(i++);\n        enc1 = chr1 >> 2;\n        enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);\n        enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);\n        enc4 = chr3 & 63;\n        if (isNaN(chr2)) {\n            enc3 = enc4 = 64;\n        } else if (isNaN(chr3)) {\n            enc4 = 64;\n        }\n        output = output +\n        _keyStr.charAt(enc1) + _keyStr.charAt(enc2) +\n        _keyStr.charAt(enc3) + _keyStr.charAt(enc4);\n    }\n\n    return output;\n  }\n\n  function default_error(text, xhr) {\n    if (xhr.status != 403) // else handled below\n      show_modal_error(\"Error\", \"Something went wrong, sorry.\")\n  }\n\n  ajax_with_indicator({\n    url: \"/admin\" + url,\n    method: method,\n    cache: false,\n    data: data,\n    headers: headers,\n    // the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding\n    processData: typeof data != \"string\",\n    mimeType: typeof data == \"string\" ? \"text/plain; charset=ascii\" : null,\n\n    beforeSend: function(xhr) {\n      // We don't store user credentials in a cookie to avoid the hassle of CSRF\n      // attacks. The Authorization header only gets set in our AJAX calls triggered\n      // by user actions.\n      if (api_credentials)\n        xhr.setRequestHeader(\n          'Authorization',\n          'Basic ' + base64encode(api_credentials.username + ':' + api_credentials.session_key));\n    },\n    success: callback,\n    error: callback_error || default_error,\n    statusCode: {\n      403: function(xhr) {\n        // Credentials are no longer valid. Try to login again.\n        var p = current_panel;\n        clear_credentials();\n        show_panel('login');\n        show_hide_menus();\n        switch_back_to_panel = p;\n      }\n    }\n  })\n}\n\nvar current_panel = null;\nvar switch_back_to_panel = null;\n\nfunction clear_credentials() {\n  // Forget the token.\n  api_credentials = null;\n  if (typeof localStorage != 'undefined')\n    localStorage.removeItem(\"miab-cp-credentials\");\n  if (typeof sessionStorage != 'undefined')\n    sessionStorage.removeItem(\"miab-cp-credentials\");\n}\n\nfunction do_logout() {\n  // Clear the session from the backend.\n  api(\"/logout\", \"POST\");\n\n  // Remove locally stored credentials\n  clear_credentials();\n\n  // Return to the start.\n  show_panel('login');\n\n  // Reset menus.\n  show_hide_menus();\n}\n\nfunction show_panel(panelid) {\n  if (panelid.getAttribute) {\n    // we might be passed an HTMLElement <a>.\n    panelid = panelid.getAttribute('href').substring(1);\n  }\n\n  $('.admin_panel').hide();\n  $('#panel_' + panelid).show();\n  if (window[\"show_\" + panelid])\n    window[\"show_\" + panelid]();\n\n  current_panel = panelid;\n  switch_back_to_panel = null;\n}\n\nwindow.onhashchange = function() {\n  var panelid = window.location.hash.substring(1);\n  show_panel(panelid);\n};\n\n$(function() {\n  // Recall saved user credentials.\n  try {\n    if (typeof sessionStorage != 'undefined' && sessionStorage.getItem(\"miab-cp-credentials\"))\n      api_credentials = JSON.parse(sessionStorage.getItem(\"miab-cp-credentials\"));\n    else if (typeof localStorage != 'undefined' && localStorage.getItem(\"miab-cp-credentials\"))\n      api_credentials = JSON.parse(localStorage.getItem(\"miab-cp-credentials\"));\n  } catch (_) {\n  }\n\n  // Toggle menu state.\n  show_hide_menus();\n\n  // Recall what the user was last looking at.\n  if (api_credentials != null && window.location.hash) {\n    var panelid = window.location.hash.substring(1);\n    show_panel(panelid);\n  } else if (api_credentials != null) {\n    show_panel('welcome');\n  } else {\n    show_panel('login');\n  }\n})\n\n        </script>\n    </body>\n</html>\n"
  },
  {
    "path": "management/templates/login.html",
    "content": "<style>\n  .title {\n    margin: 1em;\n    text-align: center;\n  }\n\n  .subtitle {\n    margin: 2em;\n    text-align: center;\n  }\n\n  .login {\n    margin: 0 auto;\n    max-width: 32em;\n  }\n\n  .login #loginOtp {\n    display: none;\n  }\n\n  #loginForm.is-twofactor #loginOtp {\n    display: block\n  }\n</style>\n\n<h1 class=\"title\">{{hostname}}</h1>\n\n{% if no_users_exist or no_admins_exist %}\n<div class=\"row\">\n<div class=\"col-md-offset-2 col-md-8\">\n  {% if no_users_exist %}\n  <p class=\"text-danger\">There are no users on this system! To make an administrative user,\n  log into this machine using SSH (like when you first set it up) and run:</p>\n  <pre>cd mailinabox\nsudo management/cli.py user add me@{{hostname}}\nsudo management/cli.py user make-admin me@{{hostname}}</pre>\n  {% else %}\n  <p class=\"text-danger\">There are no administrative users on this system! To make an administrative user,\n  log into this machine using SSH (like when you first set it up) and run:</p>\n  <pre>cd mailinabox\nsudo management/cli.py user make-admin me@{{hostname}}</pre>\n  {% endif %}\n  <hr>\n</div>\n</div>\n{% endif %}\n\n<p class=\"subtitle\">Log in here for your Mail-in-a-Box control panel.</p>\n\n<div class=\"login\">\n  <form id=\"loginForm\" class=\"form-horizontal\" role=\"form\" onsubmit=\"do_login(); return false;\" method=\"get\">\n    <div class=\"form-group\">\n      <label for=\"inputEmail3\" class=\"col-sm-3 control-label\">Email</label>\n      <div class=\"col-sm-9\">\n        <input name=\"email\" type=\"email\" class=\"form-control\" id=\"loginEmail\" placeholder=\"Email\">\n      </div>\n    </div>\n    <div class=\"form-group\">\n      <label for=\"inputPassword3\" class=\"col-sm-3 control-label\">Password</label>\n      <div class=\"col-sm-9\">\n        <input name=\"password\" type=\"password\" class=\"form-control\" id=\"loginPassword\" placeholder=\"Password\">\n      </div>\n    </div>\n    <div class=\"form-group\" id=\"loginOtp\">\n      <label for=\"loginOtpInput\" class=\"col-sm-3 control-label\">Code</label>\n      <div class=\"col-sm-9\">\n          <input type=\"text\" class=\"form-control\" id=\"loginOtpInput\" placeholder=\"6-digit code\" autocomplete=\"off\">\n          <div class=\"help-block\" style=\"margin-top: 5px; font-size: 90%\">Enter the six-digit code generated by your two factor authentication app.</div>\n      </div>\n    </div>\n    <div class=\"form-group\">\n      <div class=\"col-sm-offset-3 col-sm-9\">\n        <div class=\"checkbox\">\n          <label>\n            <input name='remember' type=\"checkbox\" id=\"loginRemember\"> Remember me\n          </label>\n        </div>\n      </div>\n    </div>\n    <div class=\"form-group\">\n      <div class=\"col-sm-offset-3 col-sm-9\">\n        <button type=\"submit\" class=\"btn btn-default\">Sign in</button>\n      </div>\n    </div>\n  </form>\n</div>\n\n<script>\nfunction do_login() {\n  if ($('#loginEmail').val() == \"\") {\n    show_modal_error(\"Login Failed\", \"Enter your email address.\", function() {\n      $('#loginEmail').focus();\n    });\n    return false;\n  }\n\n  if ($('#loginPassword').val() == \"\") {\n    show_modal_error(\"Login Failed\", \"Enter your email password.\", function() {\n        $('#loginPassword').focus();\n    });\n    return false;\n  }\n\n  // Exchange the email address & password for an API key.\n  api_credentials = { username: $('#loginEmail').val(), session_key: $('#loginPassword').val() }\n\n  api(\n  \"/login\",\n  \"POST\",\n  {},\n  function(response) {\n    // This API call always succeeds. It returns a JSON object indicating\n    // whether the request was authenticated or not.\n    if (response.status != 'ok') {\n      if (response.status === 'missing-totp-token' || (response.status === 'invalid' && response.reason == 'invalid-totp-token')) {\n        $('#loginForm').addClass('is-twofactor');\n        if (response.reason === \"invalid-totp-token\") {\n          show_modal_error(\"Login Failed\", \"Incorrect two factor authentication token.\");\n        } else {\n          setTimeout(() => {\n              $('#loginOtpInput').focus();\n          });\n        }\n      } else {\n        $('#loginForm').removeClass('is-twofactor');\n\n        // Show why the login failed.\n        show_modal_error(\"Login Failed\", response.reason)\n\n        // Reset any saved credentials.\n        do_logout();\n      }\n    } else if (!(\"api_key\" in response)) {\n      // Login succeeded but user might not be authorized!\n      show_modal_error(\"Login Failed\", \"You are not an administrator on this system.\")\n\n      // Reset any saved credentials.\n      do_logout();\n\n    } else {\n      // Login succeeded.\n\n      // Save the new credentials.\n      api_credentials = { username: response.email,\n                          session_key: response.api_key,\n                          privileges: response.privileges };\n\n      // Try to wipe the username/password information.\n      $('#loginEmail').val('');\n      $('#loginPassword').val('');\n      $('#loginOtpInput').val('');\n      $('#loginForm').removeClass('is-twofactor');\n\n      // Remember the credentials.\n      if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {\n        if ($('#loginRemember').val()) {\n          localStorage.setItem(\"miab-cp-credentials\", JSON.stringify(api_credentials));\n          sessionStorage.removeItem(\"miab-cp-credentials\");\n        } else {\n          localStorage.removeItem(\"miab-cp-credentials\");\n          sessionStorage.setItem(\"miab-cp-credentials\", JSON.stringify(api_credentials));\n        }\n      }\n\n      // Toggle menus.\n      show_hide_menus();\n\n      // Open the next panel the user wants to go to. Do this after the XHR response\n      // is over so that we don't start a new XHR request while this one is finishing,\n      // which confuses the loading indicator.\n      setTimeout(function() {\n        if (window.location.hash) {\n          var panelid = window.location.hash.substring(1);\n          show_panel(panelid);\n        } else {\n          show_panel(\n            !switch_back_to_panel || switch_back_to_panel == \"login\"\n            ? 'welcome'\n            : switch_back_to_panel)\n        }\n      }, 300);\n\n    }\n  },\n  undefined,\n  {\n    'x-auth-token': $('#loginOtpInput').val()\n  });\n}\n\nfunction show_login() {\n  $('#loginForm').removeClass('is-twofactor');\n  $('#loginOtpInput').val('');\n  $('#loginEmail,#loginPassword').each(function() {\n    var input = $(this);\n    if (!$.trim(input.val())) {\n      input.focus();\n      return false;\n    }\n  });\n}\n\nfunction show_hide_menus() {\n  var is_logged_in = (api_credentials != null);\n  var privs = api_credentials ? api_credentials.privileges : [];\n  $('.if-logged-in').toggle(is_logged_in);\n  $('.if-logged-in-admin, .if-logged-in-not-admin').toggle(false);\n  if (is_logged_in) {\n    $('.if-logged-in-not-admin').toggle(true);\n    privs.forEach(function(priv) {\n      $('.if-logged-in-' + priv).toggle(true);\n      $('.if-logged-in-not-' + priv).toggle(false);\n    });\n  }\n  $('.if-not-logged-in').toggle(!is_logged_in);\n}\n</script>\n"
  },
  {
    "path": "management/templates/mail-guide.html",
    "content": "<style>#panel_mail-guide table.table { width: auto; margin-left: .5em; }</style>\n\n<div>\n\t<h2 style=\"margin-bottom: 0\">Checking and Sending Mail</h2>\n\n\t<div class=\"row\">\n\t\t<div class=\"col-sm-7\">\n\t\t\t<h3>Webmail</h3>\n\n\t\t\t<p>Webmail lets you check your email from any web browser. Your webmail site is:</p>\n\t\t\t<p style=\"margin-left: 2em\"><strong><a href=\"https://{{hostname}}/mail\">https://{{hostname}}/mail</a></strong></p>\n\t\t\t<p>Your username is your whole email address.</p>\n\n\n\t\t\t<h3>Mobile/desktop apps</h3>\n\n\t\t\t\t<h4>Automatic configuration</h4>\n\n\t\t\t\t\t<p>iOS and macOS only: Open <a style=\"font-weight: bold\" href=\"https://{{hostname}}/mailinabox.mobileconfig\">this configuration link</a> on your iOS device or on your Mac desktop to easily set up mail (IMAP/SMTP), Contacts, and Calendar. Your username is your whole email address.</p>\n\t\n\t\t\t\t<h4>Manual configuration</h4>\n\n\t\t\t\t\t<p>Use the following settings when you set up your email on your phone, desktop, or other device:</p>\n\n\t\t\t\t\t<table class=\"table\">\n\t\t\t\t\t<thead>\n\t\t\t\t\t\t<tr><th>Option</th> <th>Value</th></tr>\n\t\t\t\t\t</thead>\n\t\t\t\t\t<tr><th>Protocol/Method</th> <td>IMAP</td></tr>\n\t\t\t\t\t<tr><th>Mail server</th> <td>{{hostname}}</td>\n\t\t\t\t\t<tr><th>IMAP Port</th> <td>993</td></tr>\n\t\t\t\t\t<tr><th>IMAP Security</th> <td>SSL or TLS</td></tr>\n\t\t\t\t\t<tr><th>SMTP Port</th> <td>465</td></tr>\n\t\t\t\t\t<tr><th>SMTP Security</td> <td>SSL or TLS</td></tr>\n\t\t\t\t\t<tr><th>Username:</th> <td>Your whole email address.</td></tr>\n\t\t\t\t\t<tr><th>Password:</th> <td>Your mail password.</td></tr>\n\t\t\t\t\t</table>\n\n\t\t\t\t\t<p>In addition to setting up your email, you&rsquo;ll also need to set up <a href=\"#sync_guide\">contacts and calendar synchronization</a> separately.</p>\n\n\t\t\t\t\t<p>As an alternative to IMAP you can also use the POP protocol: choose POP as the protocol, port 995, and SSL or TLS security in your mail client. The SMTP settings and usernames and passwords remain the same. However, we recommend you use IMAP instead.</p>\n\n\t\t\t\t<h4>Exchange/ActiveSync settings</h4>\n\n\t\t\t\t\t<p>On iOS devices, devices on this <a href=\"https://github.com/Z-Hub/Z-Push/wiki/Compatibility\">compatibility list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an Exchange or ActiveSync server. However, we&rsquo;ve found this to be more buggy than using IMAP as described above. If you encounter any problems, please use the manual settings above.</p>\n\n\t\t\t\t\t<table class=\"table\">\n\t\t\t\t\t<tr><th>Server</th> <td>{{hostname}}</td></tr>\n\t\t\t\t\t<tr><th>Options</th> <td>Secure Connection</td></tr>\n\t\t\t\t\t</table>\n\n\t\t\t\t\t<p>Your device should also provide a contacts list and calendar that syncs to this box when you use this method.</p>\n\t\t</div>\n\n\t\t<div class=\"col-sm-5\">\n\t\t<div class=\"panel panel-default\">\n\t\t<div class=\"panel-heading\">\n\t\t\t<h3>Other information about mail on your box</h3>\n\t\t</div>\n\t\t<div class=\"panel-body\">\n\t\t\t<h4>Greylisting</h4>\n\t\t\t<p>Your box uses a technique called greylisting to cut down on spam. Greylisting works by initially rejecting mail from people you haven&rsquo;t received mail from before. Legitimate mail servers will attempt redelivery shortly afterwards, but the vast majority of spam gets tricked by this. If you are waiting for an email from someone new, such as if you are registering on a new website and are waiting for an email confirmation, please be aware there will be a minimum of 3 minutes delay, depending how soon the remote server attempts redelivery.</p>\n\n\t\t\t<h4>+tag addresses</h4>\n\t\t\t<p>Every incoming email address also receives mail for <code>+tag</code> addresses. If your email address is <code>you@yourdomain.com</code>, you&rsquo;ll also automatically get mail sent to <code>you+anythinghere@yourdomain.com</code>. Use this as a fast way to segment incoming mail for your own filtering rules without having to create aliases in this control panel.</p>\n\n\t\t\t<h4>Use only this box to send as you</h4>\n\t\t\t<p>Your box sets strict email sending policies for your domain names to make it harder for spam and other fraudulent mail to claim to be you. Only this machine is authorized to send email on behalf of your domain names. If you use any other service to send email as you, it will likely get spam filtered by recipients.</p>\n\t\t</div>\n\t\t</div>\n\t\t</div>\n\t</div>\n</div>\n"
  },
  {
    "path": "management/templates/mfa.html",
    "content": "<style>\n    .twofactor #totp-setup,\n    .twofactor #disable-2fa,\n    .twofactor #output-2fa {\n        display: none;\n    }\n\n    .twofactor.loaded .loading-indicator {\n        display: none;\n    }\n\n    .twofactor.disabled #disable-2fa,\n    .twofactor.enabled #totp-setup {\n        display: none;\n    }\n\n    .twofactor.disabled #totp-setup,\n    .twofactor.enabled #disable-2fa {\n        display: block;\n    }\n\n    .twofactor #totp-setup-qr img {\n        display: block;\n        width: 256px;\n        max-width: 100%;\n        height: auto;\n    }\n\n    .twofactor #output-2fa.visible {\n        display: block;\n    }\n</style>\n\n<h2>Two-Factor Authentication</h2>\n\n<p>When two-factor authentication is enabled, you will be prompted to enter a six digit code from an\nauthenticator app (usually on your phone) when you log into this control panel.</p>\n\n<div class=\"panel panel-danger\">\n<div class=\"panel-heading\">\nEnabling two-factor authentication does not protect access to your email\n</div>\n<div class=\"panel-body\">\nEnabling two-factor authentication on this page only limits access to this control panel. Remember that most websites allow you to\nreset your password by checking your email, so anyone with access to your email can typically take over\nyour other accounts. Additionally, if your email address or any alias that forwards to your email\naddress is a typical domain control validation address (e.g admin@, administrator@, postmaster@, hostmaster@,\nwebmaster@, abuse@), extra care should be taken to protect the account. <strong>Always use a strong password,\nand ensure every administrator account for this control panel does the same.</strong>\n</div>\n</div>\n\n<div class=\"twofactor\">\n    <div class=\"loading-indicator\">Loading...</div>\n\n    <form id=\"totp-setup\">\n        <h3>Setup Instructions</h3>\n\n        <div class=\"form-group\">\n            <p>1. Install <a href=\"https://freeotp.github.io/\">FreeOTP</a> or <a href=\"https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html\">any\n            other two-factor authentication app</a> that supports TOTP.</p>\n        </div>\n\n        <div class=\"form-group\">\n            <p style=\"margin-bottom: 0\">2. Scan the QR code in the app or directly enter the secret into the app:</p>\n            <div id=\"totp-setup-qr\"></div>\n        </div>\n\n        <div class=\"form-group\">\n            <label for=\"otp-label\" style=\"font-weight: normal\">3. Optionally, give your device a label so that you can remember what device you set it up on:</label>\n            <input type=\"text\" id=\"totp-setup-label\" class=\"form-control\" placeholder=\"my phone\" />\n        </div>\n\n        <div class=\"form-group\">\n            <label for=\"otp\" style=\"font-weight: normal\">4. Use the app to generate your first six-digit code and enter it here:</label>\n            <input type=\"text\" id=\"totp-setup-token\" class=\"form-control\" placeholder=\"6-digit code\" />\n        </div>\n\n        <input type=\"hidden\" id=\"totp-setup-secret\" />\n\n        <div class=\"form-group\">\n            <p>When you click Enable Two-Factor Authentication, you will be logged out of the control panel and will have to log in\n            again, now using your two-factor authentication app.</p>\n            <button id=\"totp-setup-submit\" disabled type=\"submit\" class=\"btn\">Enable Two-Factor Authentication</button>\n        </div>\n    </form>\n\n    <form id=\"disable-2fa\">\n        <div class=\"form-group\">\n            <p>Two-factor authentication is active for your account<span id=\"mfa-device-label\"></span>.</p>\n            <p>You will have to log into the admin panel again after disabling two-factor authentication.</p>\n        </div>\n        <div class=\"form-group\">\n            <button type=\"submit\" class=\"btn btn-danger\">Disable Two-Factor Authentication</button>\n        </div>\n    </form>\n\n    <div id=\"output-2fa\" class=\"panel panel-danger\">\n        <div class=\"panel-body\"></div>\n    </div>\n</div>\n\n<script>\n    var el = {\n        disableForm: document.getElementById('disable-2fa'),\n        output: document.getElementById('output-2fa'),\n        totpSetupForm: document.getElementById('totp-setup'),\n        totpSetupToken: document.getElementById('totp-setup-token'),\n        totpSetupSecret: document.getElementById('totp-setup-secret'),\n        totpSetupLabel: document.getElementById('totp-setup-label'),\n        totpQr: document.getElementById('totp-setup-qr'),\n        totpSetupSubmit: document.querySelector('#totp-setup-submit'),\n        wrapper: document.querySelector('.twofactor')\n    }\n\n    function update_setup_disabled(evt) {\n        var val = evt.target.value.trim();\n\n        if (\n            typeof val !== 'string' ||\n            typeof el.totpSetupSecret.value !== 'string' ||\n            val.length !== 6 ||\n            el.totpSetupSecret.value.length !== 32 ||\n            !(/^\\+?\\d+$/.test(val))\n        ) {\n            el.totpSetupSubmit.setAttribute('disabled', '');\n        } else {\n            el.totpSetupSubmit.removeAttribute('disabled');\n        }\n    }\n\n    function render_totp_setup(provisioned_totp) {\n        var img = document.createElement('img');\n        img.src = \"data:image/png;base64,\" + provisioned_totp.qr_code_base64;\n\n        var code = document.createElement('div');\n        code.innerHTML = `Secret: ${provisioned_totp.secret}`;\n\n        el.totpQr.appendChild(img);\n        el.totpQr.appendChild(code);\n\n        el.totpSetupToken.addEventListener('input', update_setup_disabled);\n        el.totpSetupForm.addEventListener('submit', do_enable_totp);\n\n        el.totpSetupSecret.setAttribute('value', provisioned_totp.secret);\n\n        el.wrapper.classList.add('disabled');\n    }\n\n    function render_disable(mfa) {\n        el.disableForm.addEventListener('submit', do_disable);\n        el.wrapper.classList.add('enabled');\n        if (mfa.label)\n          $(\"#mfa-device-label\").text(\" on device '\" + mfa.label + \"'\");\n    }\n\n    function hide_error() {\n        el.output.querySelector('.panel-body').innerHTML = '';\n        el.output.classList.remove('visible');\n    }\n\n    function render_error(msg) {\n        el.output.querySelector('.panel-body').innerHTML = msg;\n        el.output.classList.add('visible');\n    }\n\n    function reset_view() {\n        el.wrapper.classList.remove('loaded', 'disabled', 'enabled');\n\n        el.disableForm.removeEventListener('submit', do_disable);\n\n        hide_error();\n\n        el.totpSetupForm.reset();\n        el.totpSetupForm.removeEventListener('submit', do_enable_totp);\n\n        el.totpSetupSecret.setAttribute('value', '');\n        el.totpSetupToken.removeEventListener('input', update_setup_disabled);\n\n        el.totpSetupSubmit.setAttribute('disabled', '');\n        el.totpQr.innerHTML = '';\n    }\n\n    function show_mfa() {\n        reset_view();\n\n        api(\n            '/mfa/status',\n            'POST',\n            {},\n            function(res) {\n                el.wrapper.classList.add('loaded');\n\n                var has_mfa = false;\n                res.enabled_mfa.forEach(function(mfa) {\n                    if (mfa.type == \"totp\") {\n                        render_disable(mfa);\n                        has_mfa = true;\n                    }\n                });\n\n                if (!has_mfa)\n                  render_totp_setup(res.new_mfa.totp);\n            }\n        );\n    }\n\n    function do_disable(evt) {\n        evt.preventDefault();\n        hide_error();\n\n        api(\n            '/mfa/disable',\n            'POST',\n            { type: 'totp' },\n            function() {\n                do_logout();\n            }\n        );\n\n        return false;\n    }\n\n    function do_enable_totp(evt) {\n        evt.preventDefault();\n        hide_error();\n\n        api(\n            '/mfa/totp/enable',\n            'POST',\n            {\n                token: $(el.totpSetupToken).val(),\n                secret: $(el.totpSetupSecret).val(),\n                label: $(el.totpSetupLabel).val()\n            },\n            function(res) { do_logout(); },\n            function(res) { render_error(res); }\n        );\n\n        return false;\n    }\n</script>\n"
  },
  {
    "path": "management/templates/munin.html",
    "content": "<h2>Munin Monitoring</h2>\n\n<style>\n</style>\n\n<p>Opening munin in a new tab... You may need to allow pop-ups for this site.</p>\n\n<script>\nfunction show_munin() {\n  // Set the cookie.\n  api(\n    \"/munin\",\n    \"GET\",\n    { },\n    function(r) {\n      // Redirect.\n      window.open(\"/admin/munin/index.html\", \"_blank\");\n    });\n}\n</script>\n"
  },
  {
    "path": "management/templates/ssl.html",
    "content": "<style>\n</style>\n\n<h2>TLS (SSL) Certificates</h2>\n\n<p>A TLS (formerly called SSL) certificate is a cryptographic file that proves to anyone connecting to a web address that the connection is secure between you and the owner of that address.</p>\n\n<p>You need a TLS certificate for this box&rsquo;s hostname ({{hostname}}) and every other domain name and subdomain that this box is hosting a website for (see the list below).</p>\n\n<div id=\"ssl_provision\">\n<h3>Provision certificates</h3>\n\n<div id=\"ssl_provision_p\" style=\"display: none; margin-top: 1.5em\">\n  <button onclick='return provision_tls_cert();' class='btn btn-primary' style=\"float: left; margin: 0 1.5em 1em 0;\">Provision</button>\n  <p>A TLS certificate can be automatically provisioned from <a href=\"https://letsencrypt.org/\" target=\"_blank\">Let&rsquo;s Encrypt</a>, a free TLS certificate provider, for:<br>\n  <span class=\"text-primary\"></span></p>\n</div>\n\n<div class=\"clearfix\"> </div>\n\n<div id=\"ssl_provision_result\"></div>\n</div>\n\n<h3>Certificate status</h3>\n\n<p style=\"margin-top: 1.5em\">Certificates expire after a period of time. All certificates will be automatically renewed through <a href=\"https://letsencrypt.org/\" target=\"_blank\">Let&rsquo;s Encrypt</a> 14 days prior to expiration.</p>\n\n<table id=\"ssl_domains\" class=\"table\" style=\"margin-bottom: 2em; width: auto; display: none\">\n  <thead>\n    <tr>\n      <th>Domain</th>\n      <th>Certificate Status</th>\n      <th>Actions</th>\n    </tr>\n  </thead>\n  <tbody>\n  </tbody>\n</table>\n\n\n<h3 id=\"ssl_install_header\">Install certificate</h3>\n\n<p>If you don't want to use our automatic Let's Encrypt integration, you can give any other certificate provider a try. You can generate the needed CSR below.</p>\n\n<p>Which domain are you getting a certificate for?</p>\n\n<p><select id=\"ssldomain\" onchange=\"show_csr()\" class=\"form-control\" style=\"width: auto\"></select></p>\n\n<p>(A multi-domain or wildcard certificate will be automatically applied to any domains it is valid for besides the one you choose above.)</p>\n\n<p>What country are you in? This is required by some TLS certificate providers. You may leave this blank if you know your TLS certificate provider doesn't require it.</p>\n\n<p><select id=\"sslcc\" onchange=\"show_csr()\" class=\"form-control\" style=\"width: auto\">\n\t<option value=\"\">(Select)</option>\n\t{% for code, name in csr_country_codes %}\n\t\t<option value=\"{{code}}\">{{name}}</option>\n\t{% endfor %}\n</select></p>\n\n<div id=\"csr_info\" style=\"display: none\">\n  <p>You will need to provide the certificate provider this Certificate Signing Request (CSR):</p>\n\n  <pre id=\"ssl_csr\"></pre>\n\n  <p><small>The CSR is safe to share. It can only be used in combination with a secret key stored on this machine.</small></p>\n\n  <p>The certificate provider will then provide you with a TLS/SSL certificate. They may also provide you with an intermediate chain. Paste each separately into the boxes below:</p>\n\n  <p style=\"margin-bottom: .5em\">TLS/SSL certificate:</p>\n  <p><textarea id=\"ssl_paste_cert\" class=\"form-control\" style=\"max-width: 40em; height: 8em\" placeholder=\"-----BEGIN CERTIFICATE-----&#xA;stuff here&#xA;-----END CERTIFICATE-----\"></textarea></p>\n\n  <p style=\"margin-bottom: .5em\">TLS/SSL intermediate chain (if provided):</p>\n  <p><textarea id=\"ssl_paste_chain\" class=\"form-control\" style=\"max-width: 40em; height: 8em\" placeholder=\"-----BEGIN CERTIFICATE-----&#xA;stuff here&#xA;-----END CERTIFICATE-----&#xA;-----BEGIN CERTIFICATE-----&#xA;more stuff here&#xA;-----END CERTIFICATE-----\"></textarea></p>\n\n  <p>After you paste in the information, click the install button.</p>\n\n  <button class=\"btn-primary\" onclick=\"install_cert()\">Install</button>\n</div>\n\n<script>\nfunction show_tls(keep_provisioning_shown) {\n api(\n    \"/ssl/status\",\n    \"GET\",\n    {\n    },\n    function(res) {\n      // provisioning status\n\n      if (!keep_provisioning_shown)\n        $('#ssl_provision').toggle(res.can_provision.length > 0)\n\n      $('#ssl_provision_p').toggle(res.can_provision.length > 0);\n      if (res.can_provision.length > 0)\n          $('#ssl_provision_p span').text(res.can_provision.join(\", \"));\n\n      // certificate status\n      var domains = res.status;\n      var tb = $('#ssl_domains tbody');\n      tb.text('');\n      $('#ssldomain').html('<option value=\"\">(select)</option>');\n      $('#ssl_domains').show();\n      for (var i = 0; i < domains.length; i++) {\n        var row = $(\"<tr><th class='domain'><a href=''></a></th><td class='status'></td> <td class='actions'><a href='#' onclick='return ssl_install(this);' class='btn btn-xs'>Install Certificate</a></td></tr>\");\n        tb.append(row);\n        row.attr('data-domain', domains[i].domain);\n        row.find('.domain a').text(domains[i].domain);\n        row.find('.domain a').attr('href', 'https://' + domains[i].domain);\n        if (domains[i].status == \"not-applicable\") {\n          domains[i].status = \"muted\"; // text-muted css class\n          row.find('.actions a').remove(); // no actions applicable\n        }\n        row.addClass(\"text-\" + domains[i].status);\n        row.find('.status').text(domains[i].text);\n        if (domains[i].status == \"success\") {\n          row.find('.actions a').addClass('btn-default').text('Replace Certificate');\n        } else {\n          row.find('.actions a').addClass('btn-primary').text('Install Certificate');\n        }\n\n        $('#ssldomain').append($('<option>').text(domains[i].domain));\n      }\n    });\n}\n\nfunction ssl_install(elem) {\n  var domain = $(elem).parents('tr').attr('data-domain');\n  $('#ssldomain').val(domain);\n  show_csr();\n  $('html, body').animate({ scrollTop: $('#ssl_install_header').offset().top - $('.navbar-fixed-top').height() - 20 })\n  return false;\n}\n\nfunction show_csr() {\n // Can't show a CSR until both inputs are entered.\n if ($('#ssldomain').val() == \"\") return;\n if ($('#sslcc').val() == \"\") return;\n\n // Scroll to it and fetch.\n $('#csr_info').slideDown();\n $('#ssl_csr').text('Loading...');\n api(\n    \"/ssl/csr/\" + $('#ssldomain').val(),\n    \"POST\",\n    {\n        countrycode: $('#sslcc').val()\n    },\n    function(data) {\n      $('#ssl_csr').text(data);\n    });\n}\n\nfunction install_cert() {\n api(\n    \"/ssl/install\",\n    \"POST\",\n    {\n      domain: $('#ssldomain').val(),\n      cert: $('#ssl_paste_cert').val(),\n      chain: $('#ssl_paste_chain').val()\n    },\n    function(status) {\n      if (/^OK($|\\n)/.test(status)) {\n        console.log(status)\n        show_modal_error(\"TLS Certificate Installation\", \"Certificate has been installed. Check that you have no connection problems to the domain.\", function() { show_ssl(); $('#csr_info').slideUp(); });\n      } else {\n        show_modal_error(\"TLS Certificate Installation\", status);\n      }\n    });\n}\n\nfunction provision_tls_cert() {\n  // Automatically provision any certs.\n  $('#ssl_provision_p .btn').attr('disabled', '1'); // prevent double-clicks\n  api(\n    \"/ssl/provision\",\n    \"POST\",\n    { },\n    function(status) {\n      // Clear last attempt.\n      $('#ssl_provision_result').text(\"\");\n      may_reenable_provision_button = true;\n\n      // Nothing was done. There might also be problem domains, but we've already displayed those.\n      if (status.requests.length == 0) {\n        show_modal_error(\"TLS Certificate Provisioning\", \"There were no domain names to provision certificates for.\");\n        // don't return - haven't re-enabled the provision button\n      }\n\n      // Each provisioning API call returns zero or more \"requests\" which represent\n      // a request to Let's Encrypt for a single certificate. Normally there is just\n      // one request (for a single multi-domain certificate).\n      for (var i = 0; i < status.requests.length; i++) {\n        var r = status.requests[i];\n\n        if (r.result == \"skipped\") {\n          // not interested --- this domain wasn't in the table\n          // to begin with\n          continue;\n        }\n\n        // create an HTML block to display the results of this request\n        var n = $(\"<div><h4/><p/></div>\");\n        $('#ssl_provision_result').append(n);\n\n        // plain log line\n        if (typeof r === \"string\") {\n          n.find(\"p\").text(r);\n          continue;\n        }\n\n        // show a header only to disambiguate request blocks\n        if (status.requests.length > 0)\n          n.find(\"h4\").text(r.domains.join(\", \"));\n\n        if (r.result == \"error\") {\n          n.find(\"p\").addClass(\"text-danger\").text(r.message);\n\n        } else if (r.result == \"installed\") {\n          n.find(\"p\").addClass(\"text-success\").text(\"The TLS certificate was provisioned and installed.\");\n          setTimeout(\"show_tls(true)\", 1); // update main table of certificate statuses, call with arg keep_provisioning_shown true so that we don't clear what we just outputted\n\n        }\n\n        // display the detailed log info in case of problems\n        var trace = $(\"<div class='small text-muted' style='margin-top: 1.5em'>Log:</div>\");\n        n.append(trace);\n        for (var j = 0; j < r.log.length; j++)\n          trace.append($(\"<div/>\").text(r.log[j]));\n      }\n\n      if (may_reenable_provision_button)\n        $('#ssl_provision_p .btn').removeAttr(\"disabled\");\n    });\n}\n</script>\n"
  },
  {
    "path": "management/templates/sync-guide.html",
    "content": "<div>\n\t<h2>Contacts &amp; Calendar Synchronization</h2>\n\n\t<p>This box can hold your contacts and calendar, just like it holds your email.</p>\n\n\t<hr>\n\n\t<div class=\"row\">\n\t\t<div class=\"col-sm-6\">\n\t\t\t<h4>In your browser</h4>\n\n\t\t\t<p>You can edit your contacts and calendar from your web browser.</p>\n\n\t\t\t<table class=\"table\">\n\t\t\t<thead><tr><th>For...</th> <th>Visit this URL</th></tr></thead>\n\t\t\t<tr><th>Contacts</td> <td><a href=\"https://{{hostname}}/cloud/contacts\">https://{{hostname}}/cloud/contacts</a></td></tr>\n\t\t\t<tr><th>Calendar</td> <td><a href=\"https://{{hostname}}/cloud/calendar\">https://{{hostname}}/cloud/calendar</a></td></tr>\n\t\t\t</table>\n\n\t\t\t<p>Log in settings are the same as with <a href=\"#mail-guide\">mail</a>: your\n\t\t\tcomplete email address and your mail password.</p>\n\t\t</div>\n\n\t\t<div class=\"col-sm-6\">\n\t\t\t<h4>On your mobile device</h4>\n\n\t\t\t<p>If you set up your <a href=\"#mail-guide\">mail</a> using Exchange/ActiveSync,\n\t\t\tyour contacts and calendar may already appear on your device.</p>\n\t\t\t<p>Otherwise, the app below can synchronize your contacts and calendar to your Android phone.</p>\n\n\t\t\t<table class=\"table\">\n\t\t\t<thead><tr><th>Google Play</th> <th>F-Droid</th></tr></thead>\n\t\t\t<tr><td><a href=\"https://play.google.com/store/apps/details?id=at.bitfire.davdroid\">DAVx⁵</a> (paid)</td> <td><a href=\"https://f-droid.org/packages/at.bitfire.davdroid/\">DAVx⁵</a> (free)</td></tr>\n\t\t\t</table>\n\n\t\t\t<p>Use the following settings:</p>\n\n\t\t\t<table class=\"table\">\n\t\t\t<tr><td>Account Type</td> <td>CardDAV or CalDAV</td></tr>\n\t\t\t<tr><td>Server Name</td> <td>{{hostname}}</td></tr>\n\t\t\t<tr><td>Use SSL</td> <td>Yes</td></tr>\n\t\t\t<tr><td>Username</td> <td>Your complete email address.</td></tr>\n\t\t\t<tr><td>Password</td> <td>Your mail password.</td></tr>\n\t\t\t</table>\n\t\t</div>\n\t</div>\n</div>\n"
  },
  {
    "path": "management/templates/system-backup.html",
    "content": "<style>\n#backup-status th { text-align: center; }\n#backup-status tr.full-backup td { font-weight: bold; }\n</style>\n\n<h2>Backup Status</h2>\n\n<p>The box makes an incremental backup each night. You can store the backup on any Amazon Web Services S3-compatible service, or other options.</p>\n\n<h3>Configuration</h3>\n\n<form class=\"form-horizontal\" role=\"form\" onsubmit=\"set_custom_backup(); return false;\">\n  <div class=\"form-group\">\n    <label for=\"backup-target-type\" class=\"col-sm-2 control-label\">Backup to:</label>\n    <div class=\"col-sm-2\">\n      <select class=\"form-control\" rows=\"1\" id=\"backup-target-type\" onchange=\"toggle_form()\">\n        <option value=\"off\">Nowhere (Disable Backups)</option>\n        <option value=\"local\">{{hostname}}</option>\n        <option value=\"rsync\">rsync</option>\n        <option value=\"s3\">S3 (Amazon or compatible) </option>\n        <option value=\"b2\">Backblaze B2</option>\n      </select>\n    </div>\n  </div>\n  <!-- LOCAL BACKUP -->\n  <div class=\"form-group backup-target-local\">\n    <div class=\"col-sm-10 col-sm-offset-2\">\n      <p>Backups are stored on this machine&rsquo;s own hard disk. You are responsible for periodically using SFTP (FTP over SSH) to copy the backup files from <tt class=\"backup-location\"></tt> to a safe location. These files are encrypted, so they are safe to store anywhere.</p>\n      <p>Separately copy the encryption password from <tt class=\"backup-encpassword-file\"></tt> to a safe and secure location. You will need this file to decrypt backup files.</p>\n    </div>\n  </div>\n  <!-- RSYNC BACKUP -->\n  <div class=\"form-group backup-target-rsync\">\n    <div class=\"col-sm-10 col-sm-offset-2\">\n\n      <p>Backups synced to a remote machine using rsync over SSH, with local\n      copies in <tt class=\"backup-location\"></tt>. These files are encrypted, so\n      they are safe to store anywhere.</p> <p>Separately copy the encryption\n      password from <tt class=\"backup-encpassword-file\"></tt> to a safe and\n      secure location. You will need this file to decrypt backup files.</p>\n\n    </div>\n  </div>\n  <div class=\"form-group backup-target-rsync\">\n    <label for=\"backup-target-rsync-host\" class=\"col-sm-2 control-label\">Hostname</label>\n    <div class=\"col-sm-8\">\n      <input type=\"text\" placeholder=\"hostname.local\" class=\"form-control\" rows=\"1\" id=\"backup-target-rsync-host\">\n      <div class=\"small\" style=\"margin-top: 2px\">\n\tThe hostname at your rsync provider, e.g. <tt>da2327.rsync.net</tt>.  Optionally includes a colon\n\tand the provider's non-standard ssh port number, e.g. <tt>u215843.your-storagebox.de:23</tt>. \n      </div>\n    </div>\n  </div>\n  <div class=\"form-group backup-target-rsync\">\n    <label for=\"backup-target-rsync-path\" class=\"col-sm-2 control-label\">Path</label>\n    <div class=\"col-sm-8\">\n        <input type=\"text\" placeholder=\"/backups/{{hostname}}\" class=\"form-control\" rows=\"1\" id=\"backup-target-rsync-path\">\n    </div>\n  </div>\n  <div class=\"form-group backup-target-rsync\">\n    <label for=\"backup-target-rsync-user\" class=\"col-sm-2 control-label\">Username</label>\n    <div class=\"col-sm-8\">\n      <input type=\"text\" class=\"form-control\" rows=\"1\" id=\"backup-target-rsync-user\">\n    </div>\n  </div>\n  <div class=\"form-group backup-target-rsync\">\n    <label for=\"ssh-pub-key\" class=\"col-sm-2 control-label\">Public SSH Key</label>\n    <div class=\"col-sm-8\">\n      <input type=\"text\" class=\"form-control\" rows=\"1\" id=\"ssh-pub-key\" readonly>\n      <div class=\"small\" style=\"margin-top: 2px\">\n        Copy the Public SSH Key above, and paste it within the <tt>~/.ssh/authorized_keys</tt>\n        of target user on the backup server specified above. That way you'll enable secure and\n        passwordless authentication from your Mail-in-a-Box server and your backup server.\n      </div>\n    </div>\n    <div id=\"copy_pub_key_div\" class=\"col-sm\">\n      <button type=\"button\" class=\"btn btn-small\" onclick=\"copy_pub_key_to_clipboard()\">Copy</button>\n    </div> \n  </div>\n  <!-- S3 BACKUP -->\n  <div class=\"form-group backup-target-s3\">\n    <div class=\"col-sm-10 col-sm-offset-2\">\n      <p>Backups are stored in an S3-compatible bucket. You must have an AWS or other S3 service account already.</p>\n      <p>You MUST manually copy the encryption password from <tt class=\"backup-encpassword-file\"></tt> to a safe and secure location. You will need this file to decrypt backup files. It is <b>NOT</b> stored in your S3 bucket.</p>\n    </div>\n  </div>\n  <div class=\"form-group backup-target-s3\">\n    <label for=\"backup-target-s3-host-select\" class=\"col-sm-2 control-label\">S3 Region</label>\n    <div class=\"col-sm-8\">\n      <select class=\"form-control\" rows=\"1\" id=\"backup-target-s3-host-select\">\n        {% for name, host in backup_s3_hosts %}\n          <option value=\"{{host}}\">{{name}}</option>\n        {% endfor %}\n        <option value=\"other\">Other (non AWS)</option>\n      </select>\n    </div>\n  </div>\n  <div class=\"form-group backup-target-s3\">\n    <label for=\"backup-target-s3-host\" class=\"col-sm-2 control-label\">S3 Host / Endpoint</label>\n    <div class=\"col-sm-8\">\n      <input type=\"text\" placeholder=\"https://s3.backuphost.com\" class=\"form-control\" rows=\"1\" id=\"backup-target-s3-host\">\n    </div>\n  </div>\n  <div class=\"form-group backup-target-s3\">\n    <label for=\"backup-target-s3-region-name\" class=\"col-sm-2 control-label\">S3 Region Name <span style=\"font-weight: normal\">(if required)</span></label>\n    <div class=\"col-sm-8\">\n      <input type=\"text\" placeholder=\"region.name\" class=\"form-control\" rows=\"1\" id=\"backup-target-s3-region-name\">\n    </div>\n  </div>\n  <div class=\"form-group backup-target-s3\">\n    <label for=\"backup-target-s3-path\" class=\"col-sm-2 control-label\">S3 Bucket &amp; Path</label>\n    <div class=\"col-sm-8\">\n      <input type=\"text\" placeholder=\"bucket-name/backup-directory\" class=\"form-control\" rows=\"1\" id=\"backup-target-s3-path\">\n    </div>\n  </div>\n  <div class=\"form-group backup-target-s3\">\n    <label for=\"backup-target-user\" class=\"col-sm-2 control-label\">S3 Access Key</label>\n    <div class=\"col-sm-8\">\n      <input type=\"text\" class=\"form-control\" rows=\"1\" id=\"backup-target-user\">\n    </div>\n  </div>\n  <div class=\"form-group backup-target-s3\">\n    <label for=\"backup-target-pass\" class=\"col-sm-2 control-label\">S3 Secret Access Key</label>\n    <div class=\"col-sm-8\">\n      <input type=\"text\" class=\"form-control\" rows=\"1\" id=\"backup-target-pass\">\n    </div>\n  </div>\n  <!-- Backblaze -->\n  <div class=\"form-group backup-target-b2\">\n      <div class=\"col-sm-10 col-sm-offset-2\">\n        <p>Backups are stored in a <a href=\"https://www.backblaze.com/\" target=\"_blank\" rel=\"noreferrer\">Backblaze</a> B2 bucket. You must have a Backblaze account already.</p>\n        <p>You MUST manually copy the encryption password from <tt class=\"backup-encpassword-file\"></tt> to a safe and secure location. You will need this file to decrypt backup files. It is NOT stored in your Backblaze B2 bucket.</p>\n      </div>\n  </div>\n  <div class=\"form-group backup-target-b2\">\n      <label for=\"backup-target-b2-user\" class=\"col-sm-2 control-label\">B2 Application KeyID</label>\n      <div class=\"col-sm-8\">\n        <input type=\"text\" class=\"form-control\" rows=\"1\" id=\"backup-target-b2-user\">\n      </div>\n  </div>\n  <div class=\"form-group backup-target-b2\">\n      <label for=\"backup-target-b2-pass\" class=\"col-sm-2 control-label\">B2 Application Key</label>\n      <div class=\"col-sm-8\">\n        <input type=\"text\" class=\"form-control\" rows=\"1\" id=\"backup-target-b2-pass\">\n      </div>\n  </div>\n  <div class=\"form-group backup-target-b2\">\n      <label for=\"backup-target-b2-bucket\" class=\"col-sm-2 control-label\">B2 Bucket</label>\n      <div class=\"col-sm-8\">\n        <input type=\"text\" class=\"form-control\" rows=\"1\" id=\"backup-target-b2-bucket\">\n      </div>\n  </div>\n  <!-- Common -->\n  <div class=\"form-group backup-target-local backup-target-rsync backup-target-s3 backup-target-b2\">\n    <label for=\"min-age\" class=\"col-sm-2 control-label\">Retention Days:</label>\n    <div class=\"col-sm-8\">\n      <input type=\"number\" class=\"form-control\" rows=\"1\" id=\"min-age\">\n      <div class=\"small\" style=\"margin-top: 2px\">This is the minimum time backup data is kept for. The box makes an incremental backup most nights, which requires that previous backups back to the most recent full backup be preserved, so backup data is often kept much longer than this setting. Full backups are made periodically when the incremental backup data size exceeds a limit.</div>\n    </div>\n  </div>\n  <div class=\"form-group\">\n    <div class=\"col-sm-offset-2 col-sm-10\">\n      <button id=\"set-s3-backup-button\" type=\"submit\" class=\"btn btn-primary\">Save</button>\n    </div>\n  </div>\n</form>\n\n<h3>Available backups</h3>\n\n<p>The backup location currently contains the backups listed below. The total size of the backups is currently <span id=\"backup-total-size\"></span>.</p>\n\n<table id=\"backup-status\" class=\"table\" style=\"width: auto\">\n  <thead>\n    <th colspan=\"2\">When</th>\n    <th>Type</th>\n    <th>Size</th>\n    <th>Deleted in...</th>\n  </thead>\n  <tbody>\n  </tbody>\n</table>\n<script>\n\nfunction toggle_form() {\n  var target_type = $(\"#backup-target-type\").val();\n  $(\".backup-target-local, .backup-target-rsync, .backup-target-s3, .backup-target-b2\").hide();\n  $(\".backup-target-\" + target_type).show();\n\n  init_inputs(target_type);\n}\n\nfunction nice_size(bytes) {\n  var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];\n  while (true) {\n    if (powers.length == 1) break;\n    if (bytes < 1000) break;\n    bytes /= 1024;\n    powers.shift();\n  }\n  // round to have three significant figures but at most one decimal place\n  if (bytes >= 100)\n    bytes = Math.round(bytes)\n  else\n    bytes = Math.round(bytes*10)/10;\n  return bytes + \" \" + powers[0];\n}\n\nfunction show_system_backup() {\n  show_custom_backup()\n\n  $('#backup-status tbody').html(\"<tr><td colspan='2' class='text-muted'>Loading...</td></tr>\")\n  api(\n    \"/system/backup/status\",\n    \"GET\",\n    { },\n    function(r) {\n      if (r.error) {\n          show_modal_error(\"Backup Error\", $(\"<pre/>\").text(r.error));\n          return;\n      }\n\n      $('#backup-status tbody').html(\"\");\n      var total_disk_size = 0;\n\n      if (typeof r.backups == \"undefined\") {\n        var tr = $('<tr><td colspan=\"3\">Backups are turned off.</td></tr>');\n        $('#backup-status tbody').append(tr);\n        return;\n      } else if (r.backups.length == 0) {\n        var tr = $('<tr><td colspan=\"3\">No backups have been made yet.</td></tr>');\n        $('#backup-status tbody').append(tr);\n      }\n\n      for (var i = 0; i < r.backups.length; i++) {\n        var b = r.backups[i];\n        var tr = $('<tr/>');\n        if (b.full) tr.addClass(\"full-backup\");\n        tr.append( $('<td/>').text(b.date_str) );\n        tr.append( $('<td/>').text(b.date_delta + \" ago\") );\n        tr.append( $('<td/>').text(b.full ? \"full\" : \"increment\") );\n        tr.append( $('<td style=\"text-align: right\"/>').text( nice_size(b.size)) );\n        if (b.deleted_in)\n          tr.append( $('<td/>').text(b.deleted_in) );\n        else\n          tr.append( $('<td class=\"text-muted\">unknown</td>') );\n        $('#backup-status tbody').append(tr);\n\n        total_disk_size += b.size;\n      }\n\n      total_disk_size += r.unmatched_file_size;\n      $('#backup-total-size').text(nice_size(total_disk_size));\n    })\n}\n\nfunction show_custom_backup() {\n    $(\".backup-target-local, .backup-target-rsync, .backup-target-s3, .backup-target-b2\").hide();\n    api(\n      \"/system/backup/config\",\n      \"GET\",\n      { },\n      function(r) {\n        $(\"#backup-target-user\").val(r.target_user);\n        $(\"#backup-target-pass\").val(r.target_pass);\n        $(\"#min-age\").val(r.min_age_in_days);\n        $(\".backup-location\").text(r.file_target_directory);\n        $(\".backup-encpassword-file\").text(r.enc_pw_file);\n        $(\"#ssh-pub-key\").val(r.ssh_pub_key);\n\n        if (r.target == \"file://\" + r.file_target_directory) {\n          $(\"#backup-target-type\").val(\"local\");\n        } else if (r.target == \"off\") {\n          $(\"#backup-target-type\").val(\"off\");\n        } else if (r.target.substring(0, 8) == \"rsync://\") {\n          const spec = url_split(r.target);\n          $(\"#backup-target-type\").val(spec.scheme);\n\t  $(\"#backup-target-rsync-user\").val(spec.user);\n\t  $(\"#backup-target-rsync-host\").val(spec.host);\n\t  $(\"#backup-target-rsync-path\").val(spec.path);\n        } else if (r.target.substring(0, 5) == \"s3://\") {\n          const spec = url_split(r.target);\n          $(\"#backup-target-type\").val(\"s3\");\n          $(\"#backup-target-s3-host-select\").val(spec.host);\n          $(\"#backup-target-s3-host\").val(spec.host);\n          $(\"#backup-target-s3-region-name\").val(spec.user); // stuffing the region name in the username\n          $(\"#backup-target-s3-path\").val(spec.path);\n        } else if (r.target.substring(0, 5) == \"b2://\") {\n          $(\"#backup-target-type\").val(\"b2\");\n          var targetPath = r.target.substring(5);\n          var b2_application_keyid = targetPath.split(':')[0];\n          var b2_applicationkey = targetPath.split(':')[1].split('@')[0];\n          var b2_bucket = targetPath.split('@')[1];\n          $(\"#backup-target-b2-user\").val(b2_application_keyid);\n          $(\"#backup-target-b2-pass\").val(decodeURIComponent(b2_applicationkey));\n          $(\"#backup-target-b2-bucket\").val(b2_bucket);\n        }\n        toggle_form()\n      })\n}\n\nfunction set_custom_backup() {\n  var target_type = $(\"#backup-target-type\").val();\n  var target_user = $(\"#backup-target-user\").val();\n  var target_pass = $(\"#backup-target-pass\").val();\n\n  var target;\n  if (target_type == \"local\" || target_type == \"off\")\n    target = target_type;\n  else if (target_type == \"s3\")\n    target = \"s3://\"\n             + ($(\"#backup-target-s3-region-name\").val() ? ($(\"#backup-target-s3-region-name\").val() + \"@\") : \"\")\n             + $(\"#backup-target-s3-host\").val()\n             + \"/\" + $(\"#backup-target-s3-path\").val();\n  else if (target_type == \"rsync\") {\n    target = \"rsync://\" + $(\"#backup-target-rsync-user\").val() + \"@\" + $(\"#backup-target-rsync-host\").val()\n                                                                + \"/\" + $(\"#backup-target-rsync-path\").val();\n    target_user = '';\n  } else if (target_type == \"b2\") {\n    target = 'b2://' + $('#backup-target-b2-user').val() + ':' + encodeURIComponent($('#backup-target-b2-pass').val())\n        + '@' + $('#backup-target-b2-bucket').val()\n    target_user = '';\n    target_pass = '';\n  }\n\n\n  var min_age = $(\"#min-age\").val();\n  api(\n    \"/system/backup/config\",\n    \"POST\",\n    {\n      target: target,\n      target_user: target_user,\n      target_pass: target_pass,\n      min_age: min_age\n    },\n    function(r) {\n      // use .text() --- it's a text response, not html\n      show_modal_error(\"Backup configuration\", $(\"<p/>\").text(r), function() { if (r == \"OK\") show_system_backup(); }); // refresh after modal on success\n    },\n    function(r) {\n      // use .text() --- it's a text response, not html\n      show_modal_error(\"Backup configuration\", $(\"<p/>\").text(r));\n    });\n  return false;\n}\n\nfunction init_inputs(target_type) {\n  function set_host(host) {\n    if(host !== 'other') {\n      $(\"#backup-target-s3-host\").val(host);\n    } else {\n      $(\"#backup-target-s3-host\").val('');\n    }\n  }\n  if (target_type == \"s3\") {\n    $('#backup-target-s3-host-select').off('change').on('change', function() {\n      set_host($('#backup-target-s3-host-select').val());\n    });\n    set_host($('#backup-target-s3-host-select').val());\n  }\n}\n\n// Return a two-element array of the substring preceding and the substring following\n// the first occurrence of separator in string. Return [undefined, string] if the\n// separator does not appear in string.\nconst split1_rest = (string, separator) => {\n    const index = string.indexOf(separator);\n    return (index >= 0) ? [string.substring(0, index), string.substring(index + separator.length)] : [undefined, string];\n};\n\n// Note: The manifest JS URL class does not work in some security-conscious\n// settings, e.g. Brave browser, so we roll our own that handles only what we need.\n//\n// Use greedy separator parsing to get parts of a MIAB backup target url.\n// Note: path will not include a leading forward slash '/'\nconst url_split = url => {\n    const [ scheme, scheme_rest ] = split1_rest(url, '://');\n    const [ user, user_rest ] = split1_rest(scheme_rest, '@');\n    const [ host, path ] = split1_rest(user_rest, '/');\n\n    return {\n\tscheme,\n\tuser,\n\thost,\n\tpath,\n    }\n};\n  \n// Hide Copy button if not in a modern clipboard-supporting environment.\n// Using document API because jQuery is not necessarily available in this script scope.\nif (!(navigator && navigator.clipboard && navigator.clipboard.writeText)) {\n    document.getElementById('copy_pub_key_div').hidden = true;\n}\n\nfunction copy_pub_key_to_clipboard() {\n    const ssh_pub_key = $(\"#ssh-pub-key\").val();\n    navigator.clipboard.writeText(ssh_pub_key);\n}  \n\n</script>\n"
  },
  {
    "path": "management/templates/system-status.html",
    "content": "<h2>System Status Checks</h2>\n\n<style>\n#system-checks .heading td {\n  font-weight: bold;\n  font-size: 120%;\n  padding-top: 1.5em;\n}\n#system-checks .heading.first td {\n  border-top: none;\n  padding-top: 0;\n}\n#system-checks .status-error td, .summary-error {\n  color: #733;\n}\n#system-checks .status-warning td, .summary-warning {\n  color: #770;\n}\n#system-checks .status-ok td, .summary-ok {\n  color: #040;\n}\n#system-checks div.extra {\n  display: none;\n  margin-top: 1em;\n  max-width: 50em;\n  word-wrap: break-word;\n}\n#system-checks a.showhide {\n  display: none;\n  font-size: 85%;\n}\n#system-checks .pre {\n  margin: 1em;\n  font-family: monospace;\n  white-space: pre-wrap;\n}\n</style>\n\n<div class=\"row\">\n\t<div class=\"col-md-push-9 col-md-3\">\n\n<div id=\"system-reboot-required\" style=\"display: none; margin-bottom: 1em;\">\n\t<button type=\"button\" class=\"btn btn-danger\" onclick=\"confirm_reboot(); return false;\">Reboot Box</button>\n\t<div>No reboot is necessary.</div>\n</div>\n\n<div id=\"system-privacy-setting\" style=\"display: none\">\n\t<div><a onclick=\"return enable_privacy(!current_privacy_setting)\" href=\"#\"><span>Enable/Disable</span> New-Version Check</a></div>\n\t<p style=\"line-height: 125%\"><small>(When enabled, status checks phone-home to check for a new release of Mail-in-a-Box.)</small></p>\n</div>\n\n\t</div> <!-- /col -->\n\t<div class=\"col-md-pull-3 col-md-8\">\n\n          <div id=\"system-checks-summary\">\n          </div>\n\n<table id=\"system-checks\" class=\"table\" style=\"max-width: 60em\">\n  <thead>\n  </thead>\n  <tbody>\n  </tbody>\n</table>\n\n\t</div> <!-- /col -->\n</div> <!-- /row -->\n\n<script>\nfunction show_system_status() {\n  const summary = $('#system-checks-summary');\n  summary.html(\"\");\n\n  $('#system-checks tbody').html(\"<tr><td colspan='2' class='text-muted'>Loading...</td></tr>\")\n\n  api(\n    \"/system/privacy\",\n    \"GET\",\n    { },\n    function(r) {\n      current_privacy_setting = r;\n      $('#system-privacy-setting').show();\n      $('#system-privacy-setting a span').text(r ? \"Enable\" : \"Disable\");\n      $('#system-privacy-setting p').toggle(r);\n    });\n\n  api(\n    \"/system/reboot\",\n    \"GET\",\n    { },\n    function(r) {\n      $('#system-reboot-required').show(); // show when r becomes available\n      $('#system-reboot-required').find('button').toggle(r);\n      $('#system-reboot-required').find('div').toggle(!r);\n    });\n\n  api(\n    \"/system/status\",\n    \"POST\",\n    { },\n    function(r) {\n      $('#system-checks tbody').html(\"\");\n      const ok_symbol = \"✓\";\n      const error_symbol = \"✖\";\n      const warning_symbol = \"?\";\n\n      let count_by_status = { ok: 0, error: 0, warning: 0 };\n\n      for (var i = 0; i < r.length; i++) {\n        var n = $(\"<tr><td class='status'/><td class='message'><p style='margin: 0'/><div class='extra'/><a class='showhide' href='#'/></tr>\");\n        if (i == 0) n.addClass('first')\n        if (r[i].type == \"heading\")\n                n.addClass(r[i].type)\n        else\n                n.addClass(\"status-\" + r[i].type)\n\n        if (r[i].type == \"ok\") n.find('td.status').text(ok_symbol);\n        if (r[i].type == \"error\") n.find('td.status').text(error_symbol);\n        if (r[i].type == \"warning\") n.find('td.status').text(warning_symbol);\n        count_by_status[r[i].type]++;\n\n        n.find('td.message p').text(r[i].text)\n        $('#system-checks tbody').append(n);\n\n        if (r[i].extra.length > 0) {\n          n.find('a.showhide').show().text(\"show more\").click(function() {\n            $(this).hide();\n            $(this).parent().find('.extra').fadeIn();\n            return false;\n          });\n        }\n\n        for (var j = 0; j < r[i].extra.length; j++) {\n\n          var m = $(\"<div/>\").text(r[i].extra[j].text)\n          if (r[i].extra[j].monospace)\n            m.addClass(\"pre\");\n          n.find('> td.message > div').append(m);\n        }\n      }\n\n      // Summary counts\n      summary.html(\"Summary: \");\n      if (count_by_status['error'] + count_by_status['warning'] == 0) {\n        summary.append($('<span class=\"summary-ok\"/>').text(`All ${count_by_status['ok']} ${ok_symbol} OK`));\n      } else {\n        summary.append($('<span class=\"summary-ok\"/>').text(`${count_by_status['ok']} ${ok_symbol} OK, `));\n        summary.append($('<span class=\"summary-error\"/>').text(`${count_by_status['error']} ${error_symbol} Error, `));\n        summary.append($('<span class=\"summary-warning\"/>').text(`${count_by_status['warning']} ${warning_symbol} Warning`));\n      }\n    })\n}\n\nvar current_privacy_setting = null;\nfunction enable_privacy(status) {\n  api(\n    \"/system/privacy\",\n    \"POST\",\n    {\n      value: (status ? \"private\" : \"off\")\n    },\n    function(res) {\n      show_system_status();\n    });\n  return false; // disable link\n}\n\nfunction confirm_reboot() {\n  show_modal_confirm(\n    \"Reboot\",\n    $(\"<p>This will reboot your Mail-in-a-Box <code>{{hostname}}</code>.</p> <p>Until the machine is fully restarted, your users will not be able to send and receive email, and you will not be able to connect to this control panel or with SSH. The reboot cannot be cancelled.</p>\"),\n    \"Reboot Now\",\n    function() {\n      api(\n        \"/system/reboot\",\n        \"POST\",\n        { },\n        function(r) {\n          var msg = \"<p>Please reload this page after a minute or so.</p>\";\n          if (r) msg = \"<p>The reboot command said:</p> <pre>\" + $(\"<pre/>\").text(r).html() + \"</pre>\"; // successful reboots don't produce any output; the output must be HTML-escaped\n          show_modal_error(\"Reboot\", msg);\n        });\n    });\n}\n</script>\n"
  },
  {
    "path": "management/templates/users.html",
    "content": "<h2>Users</h2>\n\n<style>\n#user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; }\n#user_table .actions { margin-top: .33em; font-size: 95%; }\n#user_table .account_inactive .if_active { display: none; }\n#user_table .account_active .if_inactive { display: none; }\n#user_table .account_active.if_inactive { display: none; }\n.row-center { text-align: center; }\n</style>\n\n<h3>Add a mail user</h3>\n\n<p>Add an email address to this system. This will create a new login username/password.</p>\n\n<form class=\"form-inline\" role=\"form\" onsubmit=\"return do_add_user(); return false;\">\n  <div class=\"form-group\">\n    <label class=\"sr-only\" for=\"adduserEmail\">Email address</label>\n    <input type=\"email\" class=\"form-control\" id=\"adduserEmail\" placeholder=\"Email Address\">\n  </div>\n  <div class=\"form-group\">\n    <label class=\"sr-only\" for=\"adduserPassword\">Password</label>\n    <input type=\"password\" class=\"form-control\" id=\"adduserPassword\" placeholder=\"Password\">\n  </div>\n  <div class=\"form-group\">\n    <select class=\"form-control\" id=\"adduserPrivs\">\n      <option value=\"\">Normal User</option>\n      <option value=\"admin\">Administrator</option>\n    </select>\n  </div>\n  <div class=\"form-group\">\n      <label class=\"sr-only\" for=\"adduserQuota\">Quota</label>\n      <input type=\"text\" class=\"form-control\" id=\"adduserQuota\" placeholder=\"Quota\" style=\"width:5em;\" value=\"0\">\n  </div>\n  <button type=\"submit\" class=\"btn btn-primary\">Add User</button>\n</form>\n<ul style=\"margin-top: 1em; padding-left: 1.5em; font-size: 90%;\">\n  <li>Passwords must be at least eight characters consisting of English letters and numbers only. For best results, <a href=\"#\" onclick=\"return generate_random_password()\">generate a random password</a>.</li>\n  <li>Use <a href=\"#aliases\">aliases</a> to create email addresses that forward to existing accounts.</li>\n  <li>Administrators get access to this control panel.</li>\n  <li>User accounts cannot contain any international (non-ASCII) characters, but <a href=\"#aliases\">aliases</a> can.</li>\n  <li>Quotas may not contain any spaces, commas or decimal points. Suffixes of G (gigabytes) and M (megabytes) are allowed.  For unlimited storage enter 0 (zero)</li>\n</ul>\n\n<h3>Existing mail users</h3>\n<table id=\"user_table\" class=\"table\" style=\"width: auto\">\n  <thead>\n    <tr>\n      <th width=\"35%\">Email Address</th>\n      <th class=\"row-center\">Size</th>\n      <th class=\"row-center\">Used</th>\n      <th class=\"row-center\">Quota</th>\n      <th>Actions</th>\n    </tr>\n  </thead>\n  <tbody>\n  </tbody>\n</table>\n\n<div style=\"display: none\">\n  <table>\n  <tr id=\"user-template\">\n    <td class='address'>\n    </td>\n    <td class=\"box-size row-center\"></td>\n    <td class=\"percent row-center\"></td>\n\t<td class=\"quota row-center\">\n\t</td>\n    <td class='actions'>\n        <span class='privs'>\n        </span>\n\n        <span class=\"if_active\">\n          <a href=\"#\" onclick=\"users_set_quota(this); return false;\" class='setquota' title=\"Set Quota\">\n              set quota\n          </a>\n          |\n        </span>\n\n        <span class=\"if_active\">\n          <a href=\"#\" onclick=\"users_set_password(this); return false;\" class='setpw' title=\"Set Password\">\n            set password\n          </a>\n          |\n        </span>\n\n        <span class='add-privs'>\n        </span>\n\n        <a href=\"#\" onclick=\"users_remove(this); return false;\" class='if_active' title=\"Archive Account\">\n          archive account\n        </a>\n    </td>\n  </tr>\n  <tr id=\"user-extra-template\" class=\"if_inactive\">\n    <td colspan=\"3\" style=\"border: 0; padding-top: 0\">\n      <div class='restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>\n    </td>\n  </tr>\n  </table>\n</div>\n\n<h3>Mail user API (advanced)</h3>\n\n<p>Use your box&rsquo;s mail user API to add/change/remove users from the command-line or custom services you build.</p>\n\n<p>Usage:</p>\n\n<pre>curl -X <b>VERB</b> [-d \"<b>parameters</b>\"] --user {email}:{password} https://{{hostname}}/admin/mail/users[<b>action</b>]</pre>\n\n<p>Brackets denote an optional argument. Please note that the POST body <code>parameters</code> must be URL-encoded.</p>\n\n<p>The email and password given to the <code>--user</code> option must be an administrative user on this system.</p>\n\n<h4 style=\"margin-bottom: 0\">Verbs</h4>\n\n<table class=\"table\" style=\"margin-top: .5em\">\n<thead><th>Verb</th> <th>Action</th><th></th></thead>\n<tr><td>GET</td><td><i>(none)</i></td> <td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>\n<tr>\n    <td>POST</td>\n    <td>/add</td>\n    <td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>. Optional parameters: <code>privilege=admin</code> and <code>quota</code></td>\n</tr>\n<tr>\n    <td>POST</td>\n    <td>/remove</td>\n    <td>Removes a mail user. Required POST-by parameter is <code>email</code>.</td>\n</tr>\n<tr><td>POST</td><td>/privileges/add</td> <td>Used to make a mail user an admin. Required POST-body parameters are <code>email</code> and <code>privilege=admin</code>.</td></tr>\n<tr><td>POST</td><td>/privileges/remove</td> <td>Used to remove the admin privilege from a mail user. Required POST-body parameter is <code>email</code>.</td></tr>\n<tr>\n    <td>GET</td>\n    <td>/quota</td>\n    <td>Get the quota for a mail user. Required POST-body parameters are <code>email</code> and will return JSON result</td>\n</tr>\n<tr>\n    <td>POST</td>\n    <td>/quota</td>\n    <td>Set the quota for a mail user. Required POST-body parameters are <code>email</code> and <code>quota</code>.</td>\n</tr>\n</table>\n\n<h4>Examples:</h4>\n\n<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your administrative email address and password.</p>\n\n<pre># Gives a JSON-encoded list of all mail users\ncurl -X GET https://{{hostname}}/admin/mail/users?format=json\n\n# Adds a new email user\ncurl -X POST -d \"email=new_user@mydomail.com\" -d \"password=s3curE_pa5Sw0rD\" https://{{hostname}}/admin/mail/users/add\n\n# Removes a email user\ncurl -X POST -d \"email=new_user@mydomail.com\" https://{{hostname}}/admin/mail/users/remove\n\n# Adds admin privilege to an email user\ncurl -X POST -d \"email=new_user@mydomail.com\" -d \"privilege=admin\" https://{{hostname}}/admin/mail/users/privileges/add\n\n# Removes admin privilege from an email user\ncurl -X POST -d \"email=new_user@mydomail.com\" https://{{hostname}}/admin/mail/users/privileges/remove\n</pre>\n\n<script>\nfunction show_users() {\n  $('#user_table tbody').html(\"<tr><td colspan='2' class='text-muted'>Loading...</td></tr>\")\n  api(\n    \"/mail/users\",\n    \"GET\",\n    { format: 'json' },\n    function(r) {\n      $('#user_table tbody').html(\"\");\n      for (var i = 0; i < r.length; i++) {\n        var hdr = $(\"<tr><th colspan='6' style='background-color: #EEE'></th></tr>\");\n        hdr.find('th').text(r[i].domain);\n        $('#user_table tbody').append(hdr);\n\n        for (var k = 0; k < r[i].users.length; k++) {\n          var user = r[i].users[k];\n\n          var n = $(\"#user-template\").clone();\n          var n2 = $(\"#user-extra-template\").clone();\n          n.attr('id', '');\n          n2.attr('id', '');\n          $('#user_table tbody').append(n);\n          $('#user_table tbody').append(n2);\n\n          n.addClass(\"account_\" + user.status);\n          n2.addClass(\"account_\" + user.status);\n\n          n.attr('data-email', user.email);\n          n.attr('data-quota', user.quota);\n          n.find('.address').text(user.email);\n          n.find('.box-size').text(user.box_size);\n          if (user.box_size == '?') {\n            n.find('.box-size').attr('title', 'Mailbox size is unkown')\n          }\n          n.find('.percent').text(user.percent);\n          n.find('.quota').text((user.quota == '0') ? 'unlimited' : user.quota);\n          n2.find('.restore_info tt').text(user.mailbox);\n\n          if (user.status == 'inactive') continue;\n\n          var add_privs = [\"admin\"];\n\n          for (var j = 0; j < user.privileges.length; j++) {\n            var p = $(\"<span><b><span class='name'></span></b> (<a href='#' onclick='mod_priv(this, \\\"remove\\\"); return false;' title='Remove Privilege'>remove privilege</a>) |</span>\");\n            p.find('span.name').text(user.privileges[j]);\n            n.find('.privs').append(p);\n            if (add_privs.indexOf(user.privileges[j]) >= 0)\n              add_privs.splice(add_privs.indexOf(user.privileges[j]), 1);\n          }\n\n          for (var j = 0; j < add_privs.length; j++) {\n            var p = $(\"<span><a href='#' onclick='mod_priv(this, \\\"add\\\"); return false;' title='Add Privilege'>make <span class='name'></span></a> | </span>\");\n            p.find('span.name').text(add_privs[j]);\n            n.find('.add-privs').append(p);\n          }\n        }\n      }\n    })\n}\n\nfunction do_add_user() {\n  var email = $(\"#adduserEmail\").val();\n  var pw = $(\"#adduserPassword\").val();\n  var privs = $(\"#adduserPrivs\").val();\n  var quota = $(\"#adduserQuota\").val();\n  api(\n    \"/mail/users/add\",\n    \"POST\",\n    {\n      email: email,\n      password: pw,\n      privileges: privs,\n      quota: quota\n    },\n    function(r) {\n      // Responses are multiple lines of pre-formatted text.\n      show_modal_error(\"Add User\", $(\"<pre/>\").text(r));\n      show_users()\n    },\n    function(r) {\n      show_modal_error(\"Add User\", r);\n    });\n  return false;\n}\n\nfunction users_set_password(elem) {\n  var email = $(elem).parents('tr').attr('data-email');\n\n  var yourpw = \"\";\n  if (api_credentials != null && email == api_credentials.username)\n    yourpw = \"<p class='text-danger'>If you change your own password, you will be logged out of this control panel and will need to log in again.</p>\";\n\n  show_modal_confirm(\n    \"Set Password\",\n    $(\"<p>Set a new password for <b>\" + email + \"</b>?</p> <p><label for='users_set_password_pw' style='display: block; font-weight: normal'>New Password:</label><input type='password' id='users_set_password_pw'></p><p><small>Passwords must be at least eight characters and may not contain spaces.</small>\" + yourpw + \"</p>\"),\n    \"Set Password\",\n    function() {\n      api(\n        \"/mail/users/password\",\n        \"POST\",\n        {\n          email: email,\n          password: $('#users_set_password_pw').val()\n        },\n        function(r) {\n          // Responses are multiple lines of pre-formatted text.\n          show_modal_error(\"Set Password\", $(\"<pre/>\").text(r));\n        },\n        function(r) {\n          show_modal_error(\"Set Password\", r);\n        });\n    });\n}\n\nfunction users_set_quota(elem) {\n    var email = $(elem).parents('tr').attr('data-email');\n    var quota = $(elem).parents('tr').attr('data-quota');\n\n    show_modal_confirm(\n        \"Set Quota\",\n        $(\"<p>Set quota for <b>\" + email + \"</b>?</p>\" +\n            \"<p>\" +\n            \"<label for='users_set_quota' style='display: block; font-weight: normal'>Quota:</label>\" +\n            \"<input type='text' id='users_set_quota' value='\" + quota + \"'></p>\" +\n            \"<p><small>Quotas may not contain any spaces or commas.  Suffixes of G (gigabytes) and M (megabytes) are allowed.</small></p>\" +\n            \"<p><small>For unlimited storage enter 0 (zero)</small></p>\"),\n        \"Set Quota\",\n        function() {\n            api(\n                \"/mail/users/quota\",\n                \"POST\",\n                {\n                    email: email,\n                    quota: $('#users_set_quota').val()\n                },\n                function(r) {\n                    show_users();\n                },\n                function(r) {\n                    show_modal_error(\"Set Quota\", r);\n                });\n        });\n}\n\nfunction users_remove(elem) {\n  var email = $(elem).parents('tr').attr('data-email');\n\n  // can't remove yourself\n  if (api_credentials != null && email == api_credentials.username) {\n    show_modal_error(\"Archive User\", \"You cannot archive your own account.\");\n    return;\n  }\n\n  show_modal_confirm(\n    \"Archive User\",\n    $(\"<p>Are you sure you want to archive <b>\" + email + \"</b>?</p> <p>The user's mailboxes will not be deleted (you can do that later), but the user will no longer be able to log into any services on this machine.</p>\"),\n    \"Archive\",\n    function() {\n      api(\n        \"/mail/users/remove\",\n        \"POST\",\n        {\n          email: email\n        },\n        function(r) {\n          // Responses are multiple lines of pre-formatted text.\n          show_modal_error(\"Remove User\", $(\"<pre/>\").text(r));\n          show_users();\n        },\n        function(r) {\n          show_modal_error(\"Remove User\", r);\n        });\n    });\n}\n\nfunction mod_priv(elem, add_remove) {\n  var email = $(elem).parents('tr').attr('data-email');\n  var priv = $(elem).parents('td').find('.name').text();\n\n  // can't remove your own admin access\n  if (priv == \"admin\" && add_remove == \"remove\" && api_credentials != null && email == api_credentials.username) {\n    show_modal_error(\"Modify Privileges\", \"You cannot remove the admin privilege from yourself.\");\n    return;\n  }\n\n  var add_remove1 = add_remove.charAt(0).toUpperCase() + add_remove.substring(1);\n  show_modal_confirm(\n    \"Modify Privileges\",\n    $(\"<p>Are you sure you want to \" + add_remove + \" the \" + priv + \" privilege for <b>\" + email + \"</b>?</p>\"),\n    add_remove1,\n    function() {\n      api(\n        \"/mail/users/privileges/\" + add_remove,\n        \"POST\",\n        {\n          email: email,\n          privilege: priv\n        },\n        function(r) {\n          show_users();\n        });\n    });\n}\n\nfunction generate_random_password() {\n  var pw = \"\";\n  var charset = \"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789\"; // confusable characters skipped\n  for (var i = 0; i < 12; i++)\n    pw += charset.charAt(Math.floor(Math.random() * charset.length));\n  show_modal_error(\"Random Password\", \"<p>Here, try this:</p> <p><code style='font-size: 110%'>\" + pw + \"</code></p>\");\n  return false; // cancel click\n}\n</script>\n"
  },
  {
    "path": "management/templates/web.html",
    "content": "<style>\n</style>\n\n<h2>Static Web Hosting</h2>\n\n<p>This machine is serving a simple, static website at <a href=\"https://{{hostname}}\">https://{{hostname}}</a> and at all domain names that you set up an email user or alias for.</p>\n\n<h3>Uploading web files</h3>\n\n<p>You can replace the default website with your own HTML pages and other static files. This control panel won&rsquo;t help you design a website, but once you have <tt>.html</tt> files you can upload them following these instructions:</p>\n\n<ol>\n<li>Ensure that any domains you are publishing a website for have no problems on the <a href=\"#system_status\">Status Checks</a> page.</li>\n\n<li>On your personal computer, install an SSH file transfer program such as <a href=\"https://filezilla-project.org/\">FileZilla</a> or <a href=\"https://man.openbsd.org/scp.1\">scp</a>.</li>\n\n<li>Log in to this machine with the file transfer program. The server is <strong>{{hostname}}</strong>, the protocol is SSH or SFTP, and use the <strong>SSH login credentials</strong> that you used when you originally created this machine at your cloud host provider. This is <strong>not</strong> what you use to log in either for email or this control panel. Your SSH credentials probably involves a private key file.</li>\n\n<li>Upload your <tt>.html</tt> or other files to the directory <tt>{{storage_root}}/www/default</tt> on this machine. They will appear directly and immediately on the web.</li>\n\n<li>The websites set up on this machine are listed in the table below with where to put the files for each website.</li>\n\n<table id=\"web_domains_existing\" class=\"table\" style=\"margin-bottom: 1em; width: auto;\">\n        <thead>\n                <tr>\n                        <th>Site</th>\n                        <th>Directory for Files</th>\n\t\t\t<th/>\n                </tr>\n        </thead>\n        <tbody>\n        </tbody>\n</table>\n\n<p>To add a domain to this table, create a dummy <a href=\"#users\">mail user</a> or <a href=\"#aliases\">alias</a> on the domain first and see the <a href=\"https://mailinabox.email/guide.html#domain-name-configuration\">setup guide</a> for adding nameserver records to the new domain at your registrar (but <i>not</i> glue records).</p>\n\n</ol>\n\n<script>\nfunction show_web() {\n api(\n    \"/web/domains\",\n    \"GET\",\n    {\n    },\n    function(domains) {\n\tvar tb = $('#web_domains_existing tbody');\n\ttb.text('');\n\tfor (var i = 0; i < domains.length; i++) {\n\t\tif (!domains[i].static_enabled) continue;\n\t\tvar row = $(\"<tr><th class='domain'><a href=''></a></th><td class='directory'><tt/></td> <td class='change-root hidden'><button class='btn btn-default btn-xs' onclick='show_change_web_root(this)'>Change</button></td></tr>\");\n\t\ttb.append(row);\n\t\trow.attr('data-domain', domains[i].domain);\n\t\trow.attr('data-custom-web-root', domains[i].custom_root);\n\t\trow.find('.domain a').text('https://' + domains[i].domain);\n\t\trow.find('.domain a').attr('href', 'https://' + domains[i].domain);\n\t\trow.find('.directory tt').text(domains[i].root);\n\t\tif (domains[i].root != domains[i].custom_root)\n\t\t\trow.find('.change-root').removeClass('hidden');\n\t}\n\n    });\n}\n\nfunction do_web_update() {\n api(\n    \"/web/update\",\n    \"POST\",\n    {\n    },\n    function(data) {\n      if (data == \"\")\n        data = \"Nothing changed.\";\n      else\n        data = $(\"<pre/>\").text(data);\n      show_modal_error(\"Web Update\", data, function() { show_web() });\n    });\n}\n\nfunction show_change_web_root(elem) {\n  var domain = $(elem).parents('tr').attr('data-domain');\n  var root = $(elem).parents('tr').attr('data-custom-web-root');\n  show_modal_confirm(\n    'Change Root Directory for ' + domain,\n    $('<p>You can change the static directory for <tt>' + domain + '</tt> to:</p> <p><tt>' + root + '</tt></p> <p>First create this directory on the server. Then click Update to scan for the directory and update web settings.</p>'),\n    'Update',\n    function() { do_web_update(); });\n}\n</script>\n"
  },
  {
    "path": "management/templates/welcome.html",
    "content": "<style>\n  .title {\n    margin: 1em;\n    text-align: center;\n  }\n\n  .subtitle {\n    margin: 2em;\n    text-align: center;\n  }\n</style>\n\n<h1 class=\"title\">{{hostname}}</h1>\n\n<p class=\"subtitle\">Welcome to your Mail-in-a-Box control panel.</p>\n\n"
  },
  {
    "path": "management/utils.py",
    "content": "import os.path\n\n# DO NOT import non-standard modules. This module is imported by\n# migrate.py which runs on fresh machines before anything is installed\n# besides Python.\n\n# THE ENVIRONMENT FILE AT /etc/mailinabox.conf\n\ndef load_environment():\n    # Load settings from /etc/mailinabox.conf.\n    return load_env_vars_from_file(\"/etc/mailinabox.conf\")\n\ndef load_env_vars_from_file(fn):\n    # Load settings from a KEY=VALUE file.\n    import collections\n    env = collections.OrderedDict()\n    with open(fn, encoding=\"utf-8\")  as f:\n        for line in f:\n            env.setdefault(*line.strip().split(\"=\", 1))\n    return env\n\ndef save_environment(env):\n    with open(\"/etc/mailinabox.conf\", \"w\", encoding=\"utf-8\") as f:\n        f.writelines(f\"{k}={v}\\n\" for k, v in env.items())\n\n# THE SETTINGS FILE AT STORAGE_ROOT/settings.yaml.\n\ndef write_settings(config, env):\n    import rtyaml\n    fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')\n    with open(fn, \"w\", encoding=\"utf-8\") as f:\n        f.write(rtyaml.dump(config))\n\ndef load_settings(env):\n    import rtyaml\n    fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')\n    try:\n        with open(fn, encoding=\"utf-8\") as f:\n            config = rtyaml.load(f)\n        if not isinstance(config, dict): raise ValueError # caught below\n        return config\n    except:\n        return { }\n\n# UTILITIES\n\ndef safe_domain_name(name):\n    # Sanitize a domain name so it is safe to use as a file name on disk.\n    import urllib.parse\n    return urllib.parse.quote(name, safe='')\n\ndef sort_domains(domain_names, env):\n    # Put domain names in a nice sorted order.\n\n    # The nice order will group domain names by DNS zone, i.e. the top-most\n    # domain name that we serve that ecompasses a set of subdomains. Map\n    # each of the domain names to the zone that contains them. Walk the domains\n    # from shortest to longest since zones are always shorter than their\n    # subdomains.\n    zones = { }\n    for domain in sorted(domain_names, key=len):\n        for z in zones.values():\n            if domain.endswith(\".\" + z):\n                # We found a parent domain already in the list.\n                zones[domain] = z\n                break\n        else:\n            # 'break' did not occur: there is no parent domain, so it is its\n            # own zone.\n            zones[domain] = domain\n\n    # Sort the zones.\n    zone_domains = sorted(zones.values(),\n      key = lambda d : (\n        # PRIMARY_HOSTNAME or the zone that contains it is always first.\n        not (d == env['PRIMARY_HOSTNAME'] or env['PRIMARY_HOSTNAME'].endswith(\".\" + d)),\n\n        # Then just dumb lexicographically.\n        d,\n      ))\n\n    # Now sort the domain names that fall within each zone.\n    return sorted(domain_names,\n      key = lambda d : (\n        # First by zone.\n        zone_domains.index(zones[d]),\n\n        # PRIMARY_HOSTNAME is always first within the zone that contains it.\n        d != env['PRIMARY_HOSTNAME'],\n\n        # Followed by any of its subdomains.\n        not d.endswith(\".\" + env['PRIMARY_HOSTNAME']),\n\n        # Then in right-to-left lexicographic order of the .-separated parts of the name.\n        list(reversed(d.split(\".\"))),\n      ))\n\n\ndef sort_email_addresses(email_addresses, env):\n    email_addresses = set(email_addresses)\n    domains = {email.split(\"@\", 1)[1] for email in email_addresses if \"@\" in email}\n    ret = []\n    for domain in sort_domains(domains, env):\n        domain_emails = {email for email in email_addresses if email.endswith(\"@\" + domain)}\n        ret.extend(sorted(domain_emails))\n        email_addresses -= domain_emails\n    ret.extend(sorted(email_addresses)) # whatever is left\n    return ret\n\ndef shell(method, cmd_args, env=None, capture_stderr=False, return_bytes=False, trap=False, input=None):\n    # A safe way to execute processes.\n    # Some processes like apt-get require being given a sane PATH.\n    import subprocess\n\n    if env is None:\n        env = {}\n    env.update({ \"PATH\": \"/sbin:/bin:/usr/sbin:/usr/bin\" })\n    kwargs = {\n        'env': env,\n        'stderr': None if not capture_stderr else subprocess.STDOUT,\n    }\n    if method == \"check_output\" and input is not None:\n        kwargs['input'] = input\n\n    try:\n        ret = getattr(subprocess, method)(cmd_args, **kwargs)\n        code = 0\n    except subprocess.CalledProcessError as e:\n        if not trap:\n            if False:\n                import sys, shlex\n                print(shlex.join(cmd_args), file=sys.stderr)\n                raise\n            raise\n        ret = e.output\n        code = e.returncode\n    if not return_bytes and isinstance(ret, bytes): ret = ret.decode(\"utf8\")\n    if not trap:\n        return ret\n    return code, ret\n\ndef create_syslog_handler():\n    import logging.handlers\n    handler = logging.handlers.SysLogHandler(address='/dev/log')\n    handler.setLevel(logging.WARNING)\n    return handler\n\ndef du(path):\n    # Computes the size of all files in the path, like the `du` command.\n    # Based on http://stackoverflow.com/a/17936789. Takes into account\n    # soft and hard links.\n    total_size = 0\n    seen = set()\n    for dirpath, _dirnames, filenames in os.walk(path):\n        for f in filenames:\n            fp = os.path.join(dirpath, f)\n            try:\n                stat = os.lstat(fp)\n            except OSError:\n                continue\n            if stat.st_ino in seen:\n                continue\n            seen.add(stat.st_ino)\n            total_size += stat.st_size\n    return total_size\n\ndef wait_for_service(port, public, env, timeout):\n\t# Block until a service on a given port (bound privately or publicly)\n\t# is taking connections, with a maximum timeout.\n\timport socket, time\n\tstart = time.perf_counter()\n\twhile True:\n\t\ts = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n\t\ts.settimeout(timeout/3)\n\t\ttry:\n\t\t\ts.connect((\"127.0.0.1\" if not public else env['PUBLIC_IP'], port))\n\t\t\treturn True\n\t\texcept OSError:\n\t\t\tif time.perf_counter() > start+timeout:\n\t\t\t\treturn False\n\t\ttime.sleep(min(timeout/4, 1))\n\ndef get_ssh_port():\n\tport_value = get_ssh_config_value(\"port\")\n\n\tif port_value:\n\t\treturn int(port_value)\n\n\treturn None\n\ndef get_ssh_config_value(parameter_name):\n\t# Returns ssh configuration value for the provided parameter\n\timport subprocess\n\ttry:\n\t\toutput = shell('check_output', ['sshd', '-T'])\n\texcept FileNotFoundError:\n\t\t# sshd is not installed. That's ok.\n\t\treturn None\n\texcept subprocess.CalledProcessError:\n\t\t# error while calling shell command\n\t\treturn None\n\n\tfor line in output.split(\"\\n\"):\n\t\tif \" \" not in line: continue # there's a blank line at the end\n\t\tkey, values = line.split(\" \", 1)\n\t\tif key == parameter_name:\n\t\t\treturn values # space-delimited if there are multiple values\n\n\t# Did not find the parameter!\n\treturn None\n\nif __name__ == \"__main__\":\n\tfrom web_update import get_web_domains\n\tenv = load_environment()\n\tdomains = get_web_domains(env)\n\tfor domain in domains:\n\t\tprint(domain)\n"
  },
  {
    "path": "management/web_update.py",
    "content": "# Creates an nginx configuration file so we serve HTTP/HTTPS on all\n# domains for which a mail account has been set up.\n########################################################################\n\nimport os.path, re, rtyaml\n\nfrom mailconfig import get_mail_domains\nfrom dns_update import get_custom_dns_config, get_dns_zones\nfrom ssl_certificates import get_ssl_certificates, get_domain_ssl_files, check_certificate\nfrom utils import shell, safe_domain_name, sort_domains\n\ndef get_web_domains(env, include_www_redirects=True, include_auto=True, exclude_dns_elsewhere=True):\n\t# What domains should we serve HTTP(S) for?\n\tdomains = set()\n\n\t# Serve web for all mail domains so that we might at least\n\t# provide auto-discover of email settings, and also a static website\n\t# if the user wants to make one.\n\tdomains |= get_mail_domains(env)\n\n\tif include_www_redirects and include_auto:\n\t\t# Add 'www.' subdomains that we want to provide default redirects\n\t\t# to the main domain for. We'll add 'www.' to any DNS zones, i.e.\n\t\t# the topmost of each domain we serve.\n\t\tdomains |= {'www.' + zone for zone, zonefile in get_dns_zones(env)}\n\n\tif include_auto:\n\t\t# Add Autoconfiguration domains for domains that there are user accounts at:\n\t\t# 'autoconfig.' for Mozilla Thunderbird auto setup.\n\t\t# 'autodiscover.' for ActiveSync autodiscovery (Z-Push).\n\t\tdomains |= {'autoconfig.' + maildomain for maildomain in get_mail_domains(env, users_only=True)}\n\t\tdomains |= {'autodiscover.' + maildomain for maildomain in get_mail_domains(env, users_only=True)}\n\n\t\t# 'mta-sts.' for MTA-STS support for all domains that have email addresses.\n\t\tdomains |= {'mta-sts.' + maildomain for maildomain in get_mail_domains(env)}\n\n\tif exclude_dns_elsewhere:\n\t\t# ...Unless the domain has an A/AAAA record that maps it to a different\n\t\t# IP address than this box. Remove those domains from our list.\n\t\tdomains -= get_domains_with_a_records(env)\n\n\t# Ensure the PRIMARY_HOSTNAME is in the list so we can serve webmail\n\t# as well as Z-Push for Exchange ActiveSync. This can't be removed\n\t# by a custom A/AAAA record and is never a 'www.' redirect.\n\tdomains.add(env['PRIMARY_HOSTNAME'])\n\n\t# Sort the list so the nginx conf gets written in a stable order.\n\treturn sort_domains(domains, env)\n\n\ndef get_domains_with_a_records(env):\n\tdomains = set()\n\tdns = get_custom_dns_config(env)\n\tfor domain, rtype, value in dns:\n\t\tif rtype == \"CNAME\" or (rtype in {\"A\", \"AAAA\"} and value not in {\"local\", env['PUBLIC_IP'], env['PUBLIC_IPV6']}):\n\t\t\tdomains.add(domain)\n\treturn domains\n\ndef get_web_domains_with_root_overrides(env):\n\t# Load custom settings so we can tell what domains have a redirect or proxy set up on '/',\n\t# which means static hosting is not happening.\n\troot_overrides = { }\n\tnginx_conf_custom_fn = os.path.join(env[\"STORAGE_ROOT\"], \"www/custom.yaml\")\n\tif os.path.exists(nginx_conf_custom_fn):\n\t\twith open(nginx_conf_custom_fn, encoding='utf-8') as f:\n\t\t\tcustom_settings = rtyaml.load(f)\n\t\tfor domain, settings in custom_settings.items():\n\t\t\tfor type, value in [('redirect', settings.get('redirects', {}).get('/')),\n\t\t\t\t('proxy', settings.get('proxies', {}).get('/'))]:\n\t\t\t\tif value:\n\t\t\t\t\troot_overrides[domain] = (type, value)\n\treturn root_overrides\n\ndef do_web_update(env):\n\t# Pre-load what SSL certificates we will use for each domain.\n\tssl_certificates = get_ssl_certificates(env)\n\n\t# Helper for reading config files and templates\n\tdef read_conf(conf_fn):\n\t\twith open(os.path.join(os.path.dirname(__file__), \"../conf\", conf_fn), encoding='utf-8') as f:\n\t\t\treturn f.read()\n\n\t# Build an nginx configuration file.\n\tnginx_conf = read_conf(\"nginx-top.conf\")\n\n\t# Load the templates.\n\ttemplate0 = read_conf(\"nginx.conf\")\n\ttemplate1 = read_conf(\"nginx-alldomains.conf\")\n\ttemplate2 = read_conf(\"nginx-primaryonly.conf\")\n\ttemplate3 = \"\\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\\n\"\n\n\t# Add the PRIMARY_HOST configuration first so it becomes nginx's default server.\n\tnginx_conf += make_domain_config(env['PRIMARY_HOSTNAME'], [template0, template1, template2], ssl_certificates, env)\n\n\t# Add configuration all other web domains.\n\thas_root_proxy_or_redirect = get_web_domains_with_root_overrides(env)\n\tweb_domains_not_redirect = get_web_domains(env, include_www_redirects=False)\n\tfor domain in get_web_domains(env):\n\t\tif domain == env['PRIMARY_HOSTNAME']:\n\t\t\t# PRIMARY_HOSTNAME is handled above.\n\t\t\tcontinue\n\t\tif domain in web_domains_not_redirect:\n\t\t\t# This is a regular domain.\n\t\t\tif domain not in has_root_proxy_or_redirect:\n\t\t\t\tnginx_conf += make_domain_config(domain, [template0, template1], ssl_certificates, env)\n\t\t\telse:\n\t\t\t\tnginx_conf += make_domain_config(domain, [template0], ssl_certificates, env)\n\t\telse:\n\t\t\t# Add default 'www.' redirect.\n\t\t\tnginx_conf += make_domain_config(domain, [template0, template3], ssl_certificates, env)\n\n\t# Did the file change? If not, don't bother writing & restarting nginx.\n\tnginx_conf_fn = \"/etc/nginx/conf.d/local.conf\"\n\tif os.path.exists(nginx_conf_fn):\n\t\twith open(nginx_conf_fn, encoding='utf-8') as f:\n\t\t\tif f.read() == nginx_conf:\n\t\t\t\treturn \"\"\n\n\t# Save the file.\n\twith open(nginx_conf_fn, \"w\", encoding='utf-8') as f:\n\t\tf.write(nginx_conf)\n\n\t# Kick nginx. Since this might be called from the web admin\n\t# don't do a 'restart'. That would kill the connection before\n\t# the API returns its response. A 'reload' should be good\n\t# enough and doesn't break any open connections.\n\tshell('check_call', [\"/usr/sbin/service\", \"nginx\", \"reload\"])\n\n\treturn \"web updated\\n\"\n\ndef make_domain_config(domain, templates, ssl_certificates, env):\n\t# GET SOME VARIABLES\n\n\t# Where will its root directory be for static files?\n\troot = get_web_root(domain, env)\n\n\t# What private key and SSL certificate will we use for this domain?\n\ttls_cert = get_domain_ssl_files(domain, ssl_certificates, env)\n\n\t# ADDITIONAL DIRECTIVES.\n\n\tnginx_conf_extra = \"\"\n\n\t# Because the certificate may change, we should recognize this so we\n\t# can trigger an nginx update.\n\tdef hashfile(filepath):\n\t\timport hashlib\n\t\tsha1 = hashlib.sha1()\n\t\twith open(filepath, 'rb') as f:\n\t\t\tsha1.update(f.read())\n\t\treturn sha1.hexdigest()\n\tnginx_conf_extra += \"\\t# ssl files sha1: {} / {}\\n\".format(hashfile(tls_cert[\"private-key\"]), hashfile(tls_cert[\"certificate\"]))\n\n\t# Add in any user customizations in YAML format.\n\thsts = \"yes\"\n\tnginx_conf_custom_fn = os.path.join(env[\"STORAGE_ROOT\"], \"www/custom.yaml\")\n\tif os.path.exists(nginx_conf_custom_fn):\n\t\twith open(nginx_conf_custom_fn, encoding='utf-8') as f:\n\t\t\tyaml = rtyaml.load(f)\n\t\tif domain in yaml:\n\t\t\tyaml = yaml[domain]\n\n\t\t\t# any proxy or redirect here?\n\t\t\tfor path, url in yaml.get(\"proxies\", {}).items():\n\t\t\t\t# Parse some flags in the fragment of the URL.\n\t\t\t\tpass_http_host_header = False\n\t\t\t\tproxy_redirect_off = False\n\t\t\t\tframe_options_header_sameorigin = False\n\t\t\t\tweb_sockets = False\n\t\t\t\tm = re.search(r\"#(.*)$\", url)\n\t\t\t\tif m:\n\t\t\t\t\tfor flag in m.group(1).split(\",\"):\n\t\t\t\t\t\tif flag == \"pass-http-host\":\n\t\t\t\t\t\t\tpass_http_host_header = True\n\t\t\t\t\t\telif flag == \"no-proxy-redirect\":\n\t\t\t\t\t\t\tproxy_redirect_off = True\n\t\t\t\t\t\telif flag == \"frame-options-sameorigin\":\n\t\t\t\t\t\t\tframe_options_header_sameorigin = True\n\t\t\t\t\t\telif flag == \"web-sockets\":\n\t\t\t\t\t\t\tweb_sockets = True\n\t\t\t\t\turl = re.sub(r\"#(.*)$\", \"\", url)\n\n\t\t\t\tnginx_conf_extra += f\"\\tlocation {path} {{\"\n\t\t\t\tnginx_conf_extra += f\"\\n\\t\\tproxy_pass {url};\"\n\t\t\t\tif proxy_redirect_off:\n\t\t\t\t\tnginx_conf_extra += \"\\n\\t\\tproxy_redirect off;\"\n\t\t\t\tif pass_http_host_header:\n\t\t\t\t\tnginx_conf_extra += \"\\n\\t\\tproxy_set_header Host $http_host;\"\n\t\t\t\tif frame_options_header_sameorigin:\n\t\t\t\t\tnginx_conf_extra += \"\\n\\t\\tproxy_set_header X-Frame-Options SAMEORIGIN;\"\n\t\t\t\tif web_sockets:\n\t\t\t\t\tnginx_conf_extra += \"\\n\\t\\tproxy_http_version 1.1;\"\n\t\t\t\t\tnginx_conf_extra += \"\\n\\t\\tproxy_set_header Upgrade $http_upgrade;\"\n\t\t\t\t\tnginx_conf_extra += \"\\n\\t\\tproxy_set_header Connection 'Upgrade';\"\n\t\t\t\tnginx_conf_extra += \"\\n\\t\\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\"\n\t\t\t\tnginx_conf_extra += \"\\n\\t\\tproxy_set_header X-Forwarded-Host $http_host;\"\n\t\t\t\tnginx_conf_extra += \"\\n\\t\\tproxy_set_header X-Forwarded-Proto $scheme;\"\n\t\t\t\tnginx_conf_extra += \"\\n\\t\\tproxy_set_header X-Real-IP $remote_addr;\"\n\t\t\t\tnginx_conf_extra += \"\\n\\t}\\n\"\n\t\t\tfor path, alias in yaml.get(\"aliases\", {}).items():\n\t\t\t\tnginx_conf_extra += f\"\\tlocation {path} {{\"\n\t\t\t\tnginx_conf_extra += f\"\\n\\t\\talias {alias};\"\n\t\t\t\tnginx_conf_extra += \"\\n\\t}\\n\"\n\t\t\tfor path, url in yaml.get(\"redirects\", {}).items():\n\t\t\t\tnginx_conf_extra += f\"\\trewrite {path} {url} permanent;\\n\"\n\n\t\t\t# override the HSTS directive type\n\t\t\thsts = yaml.get(\"hsts\", hsts)\n\n\t# Add the HSTS header.\n\tif hsts == \"yes\":\n\t\tnginx_conf_extra += '\\tadd_header Strict-Transport-Security \"max-age=15768000\" always;\\n'\n\telif hsts == \"preload\":\n\t\tnginx_conf_extra += '\\tadd_header Strict-Transport-Security \"max-age=15768000; includeSubDomains; preload\" always;\\n'\n\n\t# Add in any user customizations in the includes/ folder.\n\tnginx_conf_custom_include = os.path.join(env[\"STORAGE_ROOT\"], \"www\", safe_domain_name(domain) + \".conf\")\n\tif os.path.exists(nginx_conf_custom_include):\n\t\tnginx_conf_extra += f\"\\tinclude {nginx_conf_custom_include};\\n\"\n\t# PUT IT ALL TOGETHER\n\n\t# Combine the pieces. Iteratively place each template into the \"# ADDITIONAL DIRECTIVES HERE\" placeholder\n\t# of the previous template.\n\tnginx_conf = \"# ADDITIONAL DIRECTIVES HERE\\n\"\n\tfor t in [*templates, nginx_conf_extra]:\n\t\tnginx_conf = re.sub(\"[ \\t]*# ADDITIONAL DIRECTIVES HERE *\\n\", t, nginx_conf)\n\n\t# Replace substitution strings in the template & return.\n\tnginx_conf = nginx_conf.replace(\"$STORAGE_ROOT\", env['STORAGE_ROOT'])\n\tnginx_conf = nginx_conf.replace(\"$HOSTNAME\", domain)\n\tnginx_conf = nginx_conf.replace(\"$ROOT\", root)\n\tnginx_conf = nginx_conf.replace(\"$SSL_KEY\", tls_cert[\"private-key\"])\n\tnginx_conf = nginx_conf.replace(\"$SSL_CERTIFICATE\", tls_cert[\"certificate\"])\n\treturn nginx_conf.replace(\"$REDIRECT_DOMAIN\", re.sub(r\"^www\\.\", \"\", domain)) # for default www redirects to parent domain\n\n\ndef get_web_root(domain, env, test_exists=True):\n\t# Try STORAGE_ROOT/web/domain_name if it exists, but fall back to STORAGE_ROOT/web/default.\n\tfor test_domain in (domain, 'default'):\n\t\troot = os.path.join(env[\"STORAGE_ROOT\"], \"www\", safe_domain_name(test_domain))\n\t\tif os.path.exists(root) or not test_exists: break\n\treturn root\n\ndef get_web_domains_info(env):\n\twww_redirects = set(get_web_domains(env)) - set(get_web_domains(env, include_www_redirects=False))\n\thas_root_proxy_or_redirect = set(get_web_domains_with_root_overrides(env))\n\tssl_certificates = get_ssl_certificates(env)\n\n\t# for the SSL config panel, get cert status\n\tdef check_cert(domain):\n\t\ttry:\n\t\t\ttls_cert = get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=True)\n\t\texcept OSError: # PRIMARY_HOSTNAME cert is missing\n\t\t\ttls_cert = None\n\t\tif tls_cert is None: return (\"danger\", \"No certificate installed.\")\n\t\tcert_status, cert_status_details = check_certificate(domain, tls_cert[\"certificate\"], tls_cert[\"private-key\"])\n\t\tif cert_status == \"OK\":\n\t\t\treturn (\"success\", \"Signed & valid. \" + cert_status_details)\n\t\tif cert_status == \"SELF-SIGNED\":\n\t\t\treturn (\"warning\", \"Self-signed. Get a signed certificate to stop warnings.\")\n\t\treturn (\"danger\", \"Certificate has a problem: \" + cert_status)\n\n\treturn [\n\t\t{\n\t\t\t\"domain\": domain,\n\t\t\t\"root\": get_web_root(domain, env),\n\t\t\t\"custom_root\": get_web_root(domain, env, test_exists=False),\n\t\t\t\"ssl_certificate\": check_cert(domain),\n\t\t\t\"static_enabled\": domain not in (www_redirects | has_root_proxy_or_redirect),\n\t\t}\n\t\tfor domain in get_web_domains(env)\n\t]\n\n"
  },
  {
    "path": "management/wsgi.py",
    "content": "from daemon import app\nimport utils\n\napp.logger.addHandler(utils.create_syslog_handler())\n\nif __name__ == \"__main__\":\n    app.run(port=10222)\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.ruff]\nline-length = 320 # https://github.com/astral-sh/ruff/issues/8106\nindent-width = 4\n\ntarget-version = \"py310\"\n\npreview = true\n\noutput-format = \"concise\"\n\nextend-exclude = [\"tools/mail.py\"]\n\n[tool.ruff.lint]\nselect = [\n\t\"F\",\n\t\"E4\",\n\t\"E7\",\n\t\"E9\",\n\t\"W\",\n\t\"UP\",\n\t\"YTT\",\n\t\"S\",\n\t\"BLE\",\n\t\"B\",\n\t\"A\",\n\t\"C4\",\n\t\"T10\",\n\t\"DJ\",\n\t\"EM\",\n\t\"EXE\",\n\t\"ISC\",\n\t\"ICN\",\n\t\"G\",\n\t\"PIE\",\n\t\"PYI\",\n\t\"Q003\",\n\t\"Q004\",\n\t\"RSE\",\n\t\"RET\",\n\t\"SLF\",\n\t\"SLOT\",\n\t\"SIM\",\n\t\"TID\",\n\t\"TC\",\n\t\"ARG\",\n\t\"PGH\",\n\t\"PL\",\n\t\"TRY\",\n\t\"FLY\",\n\t\"PERF\",\n\t\"FURB\",\n\t\"LOG\",\n\t\"RUF\"\n]\nignore = [\n\t\"W191\",\n\t\"PLR09\",\n\t\"PLR1702\",\n\t\"PLR2004\",\n\t\"RUF001\",\n\t\"RUF002\",\n\t\"RUF003\",\n\t\"RUF023\"\n]\n\n[tool.ruff.format]\nquote-style = \"preserve\"\n\nindent-style = \"tab\"\n"
  },
  {
    "path": "security.md",
    "content": "Mail-in-a-Box Security Guide\n============================\n\nMail-in-a-Box turns a fresh Ubuntu 22.04 LTS 64-bit machine into a mail server appliance by installing and configuring various components.\n\nThis page documents the security posture of Mail-in-a-Box. The term “box” is used below to mean a configured Mail-in-a-Box.\n\nReporting Security Vulnerabilities\n----------------------------------\n\nSecurity vulnerabilities should be reported to the [project's maintainer](https://joshdata.me) via email.\n\nThreat Model\n------------\n\nNothing is perfectly secure, and an adversary with sufficient resources can always penetrate a system.\n\nThe primary goal of Mail-in-a-Box is to make deploying a good mail server easy, so we balance ― as everyone does ― privacy and security concerns with the practicality of actually deploying the system. That means we make certain assumptions about adversaries. We assume that adversaries . . .\n\n* Do not have physical access to the box (i.e., we do not aim to protect the box from physical access).\n* Have not been given Unix accounts on the box (i.e., we assume all users with shell access are trusted).\n\nOn the other hand, we do assume that adversaries are performing passive surveillance and, possibly, active man-in-the-middle attacks. And so:\n\n* User credentials are always sent through SSH/TLS, never in the clear, with modern TLS settings.\n* Outbound mail is sent with the highest level of TLS possible.\n* The box advertises its support for [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), when DNSSEC is enabled at the domain name registrar, so that inbound mail is more likely to be transmitted securely.\n\nAdditional details follow.\n\nUser Credentials\n----------------\n\nThe box's administrator and its (non-administrative) mail users must sometimes communicate their credentials to the box.\n\n### Services behind TLS\n\nThese services are protected by [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security):\n\n* SMTP Submission (ports 465/587). Mail users submit outbound mail through SMTP with TLS (port 465) or STARTTLS (port 587).\n* IMAP/POP (ports 993, 995). Mail users check for incoming mail through IMAP or POP over TLS.\n* HTTPS (port 443). Webmail, the Exchange/ActiveSync protocol, the administrative control panel, and any static hosted websites are accessed over HTTPS.\n\nThe services all follow these rules:\n\n* TLS certificates are generated with 2048-bit RSA keys and SHA-256 fingerprints. The box provides a self-signed certificate by default. The [setup guide](https://mailinabox.email/guide.html) explains how to verify the certificate fingerprint on first login. Users are encouraged to replace the certificate with a proper CA-signed one. ([source](setup/ssl.sh))\n* Only TLSv1.2+ are offered (the older SSL protocols are not offered).\n* We track the [Mozilla Intermediate Ciphers Recommendation](https://wiki.mozilla.org/Security/Server_Side_TLS), balancing security with supporting a wide range of mail clients. Diffie-Hellman ciphers use a 2048-bit key for forward secrecy. For more details, see the [output of SSLyze for these ports](tests/tls_results.txt).\n\nAdditionally:\n\n* SMTP Submission on port 587 will not accept user credentials without STARTTLS (true also of SMTP on port 25 in case of client misconfiguration), and the submission port won't accept mail without encryption. The minimum cipher key length is 128 bits. (The box is of course configured not to be an open relay. User credentials are required to send outbound mail.) ([source](setup/mail-postfix.sh))\n* HTTPS (port 443): The HTTPS Strict Transport Security header is set. A redirect from HTTP to HTTPS is offered. The [Qualys SSL Labs test](https://www.ssllabs.com/ssltest) should report an A+ grade. ([source 1](conf/nginx-ssl.conf), [source 2](conf/nginx.conf))\n\n### Password Storage\n\nThe passwords for mail users are stored on disk using the [SHA512-CRYPT](http://man7.org/linux/man-pages/man3/crypt.3.html) hashing scheme. ([source](management/mailconfig.py)) Password changes (as well as changes to control panel two-factor authentication settings) expire any control panel login sessions.\n\n### Console access\n\nConsole access (e.g. via SSH) is configured by the system image used to create the box, typically from by a cloud virtual machine provider (e.g. Digital Ocean). Mail-in-a-Box does not set any console access settings, although it will warn the administrator in the System Status Checks if password-based login is turned on.\n\nThe [setup guide video](https://mailinabox.email/) explains how to verify the host key fingerprint on first login.\n\nIf DNSSEC is enabled at the box's domain name's registrar, the SSHFP record that the box automatically puts into DNS can also be used to verify the host key fingerprint by setting `VerifyHostKeyDNS yes` in your `ssh/.config` file or by logging in with `ssh -o VerifyHostKeyDNS=yes`. ([source](management/dns_update.py))\n\n### Brute-force attack mitigation\n\n`fail2ban` provides some protection from brute-force login attacks (repeated logins that guess account passwords) by blocking offending IP addresses at the network level.\n\nThe following services are protected: SSH, IMAP (dovecot), SMTP submission (postfix), webmail (roundcube), Nextcloud/CalDAV/CardDAV (over HTTP), and the Mail-in-a-Box control panel (over HTTP).\n\nSome other services running on the box may be missing fail2ban filters.\n\nOutbound Mail\n-------------\n\nThe basic protocols of email delivery did not plan for the presence of adversaries on the network. For a number of reasons it is not possible in most cases to guarantee that a connection to a recipient server is secure.\n\n### DNSSEC\n\nThe first step in resolving the destination server for an email address is performing a DNS look-up for the MX record of the domain name. The box uses a locally-running [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC)-aware nameserver to perform the lookup. If the domain name has DNSSEC enabled, DNSSEC guards against DNS records being tampered with.\n\n### Encryption\n\nThe box (along with the vast majority of mail servers) uses [opportunistic encryption](https://en.wikipedia.org/wiki/Opportunistic_encryption), meaning the mail is encrypted in transit and protected from passive eavesdropping, but it is not protected from an active man-in-the-middle attack. Modern encryption settings (TLSv1 and later, no RC4) will be used to the extent the recipient server supports them. ([source](setup/mail-postfix.sh))\n\n### DANE\n\nIf the recipient's domain name supports DNSSEC and has published a [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities) record, then on-the-wire encryption is forced between the box and the recipient MTA and this encryption is not subject to a man-in-the-middle attack. The TLSA record contains a certificate fingerprint which the receiving MTA (server) must present to the box. ([source](setup/mail-postfix.sh))\n\n### Domain Policy Records\n\nDomain policy records allow recipient MTAs to detect when the _domain_ part of of the sender address in incoming mail has been spoofed. All outbound mail is signed with [DKIM](https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail) and \"quarantine\" [DMARC](https://en.wikipedia.org/wiki/DMARC) records are automatically set in DNS. Receiving MTAs that implement DMARC will automatically quarantine mail that is \"From:\" a domain hosted by the box but which was not sent by the box. (Strong [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework) records are also automatically set in DNS.) ([source](management/dns_update.py))\n\n### User Policy\n\nWhile domain policy records prevent other servers from sending mail with a \"From:\" header that matches a domain hosted on the box (see above), those policy records do not guarantee that the user portion of the sender email address matches the actual sender. In enterprise environments where the box may host the mail of untrusted users, it is important to guard against users impersonating other users.\n\nThe box restricts the envelope sender address (also called the return path or MAIL FROM address --- this is different from the \"From:\" header) that users may put into outbound mail. The envelope sender address must be either their own email address (their SMTP login username) or any alias that they are listed as a permitted sender of. (There is currently no restriction on the contents of the \"From:\" header.)\n\nIncoming Mail\n-------------\n\n### Encryption Settings\n\nAs with outbound email, there is no way to require on-the-wire encryption of incoming mail from all senders. When the box receives an incoming email (SMTP on port 25), it offers encryption (STARTTLS) but cannot require that senders use it because some senders may not support STARTTLS at all and other senders may support STARTTLS but not with the latest protocols/ciphers. To give senders the best chance at making use of encryption, the box offers protocols back to TLSv1 and ciphers with key lengths as low as 112 bits. Modern clients (senders) will make use of the 256-bit ciphers and Diffie-Hellman ciphers with a 2048-bit key for perfect forward secrecy, however. ([source](setup/mail-postfix.sh))\n\n### MTA-STS\n\nThe box publishes a SMTP MTA Strict Transport Security ([SMTP MTA-STS](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol#SMTP_MTA_Strict_Transport_Security)) policy (via DNS and HTTPS) in \"enforce\" mode. Senders that support MTA-STS will use a secure SMTP connection. (MTA-STS tells senders to connect and expect a signed TLS certificate for the \"MX\" domain without permitting a fallback to an unencrypted connection.)\n\n### DANE\n\nWhen DNSSEC is enabled at the box's domain name's registrar, [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities) records are automatically published in DNS. Senders supporting DANE will enforce encryption on-the-wire between them and the box --- see the section on DANE for outgoing mail above. ([source](management/dns_update.py))\n\n### Filters\n\nIncoming mail is run through several filters. Email is bounced if the sender's IP address is listed in the [Spamhaus Zen blacklist](http://www.spamhaus.org/zen/) or if the sender's domain is listed in the [Spamhaus Domain Block List](http://www.spamhaus.org/dbl/). Greylisting (with [postgrey](http://postgrey.schweikert.ch/)) is also used to cut down on spam. ([source](setup/mail-postfix.sh))\n"
  },
  {
    "path": "setup/bootstrap.sh",
    "content": "#!/bin/bash\n#########################################################\n# This script is intended to be run like this:\n#\n#   curl https://mailinabox.email/setup.sh | sudo bash\n#\n#########################################################\n\nif [ -z \"$TAG\" ]; then\n\t# If a version to install isn't explicitly given as an environment\n\t# variable, then install the latest version. But the latest version\n\t# depends on the machine's version of Ubuntu. Existing users need to\n\t# be able to upgrade to the latest version available for that version\n\t# of Ubuntu to satisfy the migration requirements.\n\t#\n\t# Also, the system status checks read this script for TAG = (without the\n\t# space, but if we put it in a comment it would confuse the status checks!)\n\t# to get the latest version, so the first such line must be the one that we\n\t# want to display in status checks.\n\t#\n\t# Allow point-release versions of the major releases, e.g. 22.04.1 is OK.\n\tUBUNTU_VERSION=$( lsb_release -d | sed 's/.*:\\s*//' | sed 's/\\([0-9]*\\.[0-9]*\\)\\.[0-9]/\\1/' )\n\tif [ \"$UBUNTU_VERSION\" == \"Ubuntu 22.04 LTS\" ]; then\n\t\t# This machine is running Ubuntu 22.04, which is supported by\n\t\t# Mail-in-a-Box versions 60 and later.\n\t\tTAG=v74\n\telif [ \"$UBUNTU_VERSION\" == \"Ubuntu 18.04 LTS\" ]; then\n\t\t# This machine is running Ubuntu 18.04, which is supported by\n\t\t# Mail-in-a-Box versions 0.40 through 5x.\n\t\techo \"Support is ending for Ubuntu 18.04.\"\n\t\techo \"Please immediately begin to migrate your data to\"\n\t\techo \"a new machine running Ubuntu 22.04. See:\"\n\t\techo \"https://mailinabox.email/maintenance.html#upgrade\"\n\t\tTAG=v57a\n\telif [ \"$UBUNTU_VERSION\" == \"Ubuntu 14.04 LTS\" ]; then\n\t\t# This machine is running Ubuntu 14.04, which is supported by\n\t\t# Mail-in-a-Box versions 1 through v0.30.\n\t\techo \"Ubuntu 14.04 is no longer supported.\"\n\t\techo \"The last version of Mail-in-a-Box supporting Ubuntu 14.04 will be installed.\"\n\t\tTAG=v0.30\n\telse\n\t\techo \"This script may be used only on a machine running Ubuntu 14.04, 18.04, or 22.04.\"\n\t\texit 1\n\tfi\nfi\n\n# Are we running as root?\nif [[ $EUID -ne 0 ]]; then\n\techo \"This script must be run as root. Did you leave out sudo?\"\n\texit 1\nfi\n\n# Clone the Mail-in-a-Box repository if it doesn't exist.\nif [ ! -d \"$HOME/mailinabox\" ]; then\n\tif [ ! -f /usr/bin/git ]; then\n\t\techo \"Installing git . . .\"\n\t\tapt-get -q -q update\n\t\tDEBIAN_FRONTEND=noninteractive apt-get -q -q install -y git < /dev/null\n\t\techo\n\tfi\n\n\tif [ \"$SOURCE\" == \"\" ]; then\n\t\tSOURCE=https://github.com/mail-in-a-box/mailinabox\n\tfi\n\n\techo \"Downloading Mail-in-a-Box $TAG. . .\"\n\tgit clone \\\n\t\t-b \"$TAG\" --depth 1 \\\n\t\t\"$SOURCE\" \\\n\t\t\"$HOME/mailinabox\" \\\n\t\t< /dev/null 2> /dev/null\n\n\techo\nfi\n\n# Change directory to it.\ncd \"$HOME/mailinabox\" || exit\n\n# Update it.\nif [ \"$TAG\" != \"$(git describe --always)\" ]; then\n\techo \"Updating Mail-in-a-Box to $TAG . . .\"\n\tgit fetch --depth 1 --force --prune origin tag \"$TAG\"\n\tif ! git checkout -q \"$TAG\"; then\n\t\techo \"Update failed. Did you modify something in $PWD?\"\n\t\texit 1\n\tfi\n\techo\nfi\n\n# Start setup script.\nsetup/start.sh\n"
  },
  {
    "path": "setup/dkim.sh",
    "content": "#!/bin/bash\n# OpenDKIM\n# --------\n#\n# OpenDKIM provides a service that puts a DKIM signature on outbound mail.\n#\n# The DNS configuration for DKIM is done in the management daemon.\n\nsource setup/functions.sh # load our functions\nsource /etc/mailinabox.conf # load global vars\n\n# Install DKIM...\necho \"Installing OpenDKIM/OpenDMARC...\"\napt_install opendkim opendkim-tools opendmarc\n\n# Make sure configuration directories exist.\nmkdir -p /etc/opendkim;\nmkdir -p \"$STORAGE_ROOT/mail/dkim\"\n\n# Used in InternalHosts and ExternalIgnoreList configuration directives.\n# Not quite sure why.\necho \"127.0.0.1\" > /etc/opendkim/TrustedHosts\n\n# We need to at least create these files, since we reference them later.\n# Otherwise, opendkim startup will fail\ntouch /etc/opendkim/KeyTable\ntouch /etc/opendkim/SigningTable\n\nif grep -q \"ExternalIgnoreList\" /etc/opendkim.conf; then\n\ttrue # already done #NODOC\nelse\n\t# Add various configuration options to the end of `opendkim.conf`.\n\tcat >> /etc/opendkim.conf << EOF;\nCanonicalization\t\trelaxed/simple\nMinimumKeyBits          1024\nExternalIgnoreList      refile:/etc/opendkim/TrustedHosts\nInternalHosts           refile:/etc/opendkim/TrustedHosts\nKeyTable                refile:/etc/opendkim/KeyTable\nSigningTable            refile:/etc/opendkim/SigningTable\nSocket                  inet:8891@127.0.0.1\nRequireSafeKeys         false\nEOF\nfi\n\n# Create a new DKIM key. This creates mail.private and mail.txt\n# in $STORAGE_ROOT/mail/dkim. The former is the private key and\n# the latter is the suggested DNS TXT entry which we'll include\n# in our DNS setup. Note that the files are named after the\n# 'selector' of the key, which we can change later on to support\n# key rotation.\n#\n# A 1024-bit key is seen as a minimum standard by several providers\n# such as Google. But they and others use a 2048 bit key, so we'll\n# do the same. Keys beyond 2048 bits may exceed DNS record limits.\nif [ ! -f \"$STORAGE_ROOT/mail/dkim/mail.private\" ]; then\n\topendkim-genkey -b 2048 -r -s mail -D \"$STORAGE_ROOT/mail/dkim\"\nfi\n\n# Ensure files are owned by the opendkim user and are private otherwise.\nchown -R opendkim:opendkim \"$STORAGE_ROOT/mail/dkim\"\nchmod go-rwx \"$STORAGE_ROOT/mail/dkim\"\n\ntools/editconf.py /etc/opendmarc.conf -s \\\n\t\"Syslog=true\" \\\n\t\"Socket=inet:8893@[127.0.0.1]\" \\\n\t\"FailureReports=false\"\n\n# SPFIgnoreResults causes the filter to ignore any SPF results in the header\n# of the message. This is useful if you want the filter to perform SPF checks\n# itself, or because you don't trust the arriving header. This added header is\n# used by spamassassin to evaluate the mail for spamminess.\n\ntools/editconf.py /etc/opendmarc.conf -s \\\n        \"SPFIgnoreResults=true\"\n\n# SPFSelfValidate causes the filter to perform a fallback SPF check itself\n# when it can find no SPF results in the message header. If SPFIgnoreResults\n# is also set, it never looks for SPF results in headers and always performs\n# the SPF check itself when this is set. This added header is used by\n# spamassassin to evaluate the mail for spamminess.\n\ntools/editconf.py /etc/opendmarc.conf -s \\\n        \"SPFSelfValidate=true\"\n\n# Disables generation of failure reports for sending domains that publish a\n# \"none\" policy.\n\ntools/editconf.py /etc/opendmarc.conf -s \\\n        \"FailureReportsOnNone=false\"\n\n# AlwaysAddARHeader Adds an \"Authentication-Results:\" header field even to\n# unsigned messages from domains with no \"signs all\" policy. The reported DKIM\n# result will be  \"none\" in such cases. Normally unsigned mail from non-strict\n# domains does not cause the results header field to be added. This added header\n# is used by spamassassin to evaluate the mail for spamminess.\n\ntools/editconf.py /etc/opendkim.conf -s \\\n        \"AlwaysAddARHeader=true\"\n\n# Add OpenDKIM and OpenDMARC as milters to postfix, which is how OpenDKIM\n# intercepts outgoing mail to perform the signing (by adding a mail header)\n# and how they both intercept incoming mail to add Authentication-Results\n# headers. The order possibly/probably matters: OpenDMARC relies on the\n# OpenDKIM Authentication-Results header already being present.\n#\n# Be careful. If we add other milters later, this needs to be concatenated\n# on the smtpd_milters line.\n#\n# The OpenDMARC milter is skipped in the SMTP submission listener by\n# configuring smtpd_milters there to only list the OpenDKIM milter\n# (see mail-postfix.sh).\ntools/editconf.py /etc/postfix/main.cf \\\n\t\"smtpd_milters=inet:127.0.0.1:8891 inet:127.0.0.1:8893\"\\\n\tnon_smtpd_milters=\\$smtpd_milters \\\n\tmilter_default_action=accept\n\n# We need to explicitly enable the opendmarc service, or it will not start\nhide_output systemctl enable opendmarc\n\n# Restart services.\nrestart_service opendkim\nrestart_service opendmarc\nrestart_service postfix\n\n"
  },
  {
    "path": "setup/dns.sh",
    "content": "#!/bin/bash\n# DNS\n# -----------------------------------------------\n\n# This script installs packages, but the DNS zone files are only\n# created by the /dns/update API in the management server because\n# the set of zones (domains) hosted by the server depends on the\n# mail users & aliases created by the user later.\n\nsource setup/functions.sh # load our functions\nsource /etc/mailinabox.conf # load global vars\n\n# Prepare nsd's configuration.\n# We configure nsd before installation as we only want it to bind to some addresses\n# and it otherwise will have port / bind conflicts with bind9 used as the local resolver\nmkdir -p /var/run/nsd\nmkdir -p /etc/nsd\nmkdir -p /etc/nsd/zones\ntouch /etc/nsd/zones.conf\n\ncat > /etc/nsd/nsd.conf << EOF;\n# Do not edit. Overwritten by Mail-in-a-Box setup.\nserver:\n  hide-version: yes\n  logfile: \"/var/log/nsd.log\"\n\n  # identify the server (CH TXT ID.SERVER entry).\n  identity: \"\"\n\n  # The directory for zonefile: files.\n  zonesdir: \"/etc/nsd/zones\"\n\n  # Allows NSD to bind to IP addresses that are not (yet) added to the\n  # network interface. This allows nsd to start even if the network stack\n  # isn't fully ready, which apparently happens in some cases.\n  # See https://www.nlnetlabs.nl/projects/nsd/nsd.conf.5.html.\n  ip-transparent: yes\n\nEOF\n\n# Since we have bind9 listening on localhost for locally-generated\n# DNS queries that require a recursive nameserver, and the system\n# might have other network interfaces for e.g. tunnelling, we have\n# to be specific about the network interfaces that nsd binds to.\nfor ip in $PRIVATE_IP $PRIVATE_IPV6; do\n\techo \"  ip-address: $ip\" >> /etc/nsd/nsd.conf;\ndone\n\n# Create a directory for additional configuration directives, including\n# the zones.conf file written out by our management daemon.\necho \"include: /etc/nsd/nsd.conf.d/*.conf\" >> /etc/nsd/nsd.conf;\n\n# Remove the old location of zones.conf that we generate. It will\n# now be stored in /etc/nsd/nsd.conf.d.\nrm -f /etc/nsd/zones.conf\n\n# Add log rotation\ncat > /etc/logrotate.d/nsd <<EOF;\n/var/log/nsd.log {\n  weekly\n  missingok\n  rotate 12\n  compress\n  delaycompress\n  notifempty\n}\nEOF\n\n# Install the packages.\n#\n# * nsd: The non-recursive nameserver that publishes our DNS records.\n# * ldnsutils: Helper utilities for signing DNSSEC zones.\n# * openssh-client: Provides ssh-keyscan which we use to create SSHFP records.\necho \"Installing nsd (DNS server)...\"\napt_install nsd ldnsutils openssh-client\n\n# Create DNSSEC signing keys.\n\nmkdir -p \"$STORAGE_ROOT/dns/dnssec\";\n\n# TLDs, registrars, and validating nameservers don't all support the same algorithms,\n# so we'll generate keys using a few different algorithms so that dns_update.py can\n# choose which algorithm to use when generating the zonefiles. See #1953 for recent\n# discussion. File for previously used algorithms (i.e. RSASHA1-NSEC3-SHA1) may still\n# be in the output directory, and we'll continue to support signing zones with them\n# so that trust isn't broken with deployed DS records, but we won't generate those\n# keys on new systems.\nFIRST=1 #NODOC\nfor algo in RSASHA256 ECDSAP256SHA256; do\nif [ ! -f \"$STORAGE_ROOT/dns/dnssec/$algo.conf\" ]; then\n\tif [ $FIRST == 1 ]; then\n\t\techo \"Generating DNSSEC signing keys...\"\n\t\tFIRST=0 #NODOC\n\tfi\n\n\t# Create the Key-Signing Key (KSK) (with `-k`) which is the so-called\n\t# Secure Entry Point. The domain name we provide (\"_domain_\") doesn't\n\t# matter -- we'll use the same keys for all our domains.\n\t#\n\t# `ldns-keygen` outputs the new key's filename to stdout, which\n\t# we're capturing into the `KSK` variable.\n\t#\n\t# ldns-keygen uses /dev/random for generating random numbers by default.\n\t# This is slow and unnecessary if we ensure /dev/urandom is seeded properly,\n\t# so we use /dev/urandom. See system.sh for an explanation. See #596, #115.\n\t# (This previously used -b 2048 but it's unclear if this setting makes sense\n\t# for non-RSA keys, so it's removed. The RSA-based keys are not recommended\n\t# anymore anyway.)\n\tKSK=$(umask 077; cd \"$STORAGE_ROOT/dns/dnssec\"; ldns-keygen -r /dev/urandom -a $algo -k _domain_);\n\n\t# Now create a Zone-Signing Key (ZSK) which is expected to be\n\t# rotated more often than a KSK, although we have no plans to\n\t# rotate it (and doing so would be difficult to do without\n\t# disturbing DNS availability.) Omit `-k`.\n\t# (This previously used -b 1024 but it's unclear if this setting makes sense\n\t# for non-RSA keys, so it's removed.)\n\tZSK=$(umask 077; cd \"$STORAGE_ROOT/dns/dnssec\"; ldns-keygen -r /dev/urandom -a $algo _domain_);\n\n\t# These generate two sets of files like:\n\t#\n\t# * `K_domain_.+007+08882.ds`: DS record normally provided to domain name registrar (but it's actually invalid with `_domain_` so we don't use this file)\n\t# * `K_domain_.+007+08882.key`: public key\n\t# * `K_domain_.+007+08882.private`: private key (secret!)\n\n\t# The filenames are unpredictable and encode the key generation\n\t# options. So we'll store the names of the files we just generated.\n\t# We might have multiple keys down the road. This will identify\n\t# what keys are the current keys.\n\tcat > \"$STORAGE_ROOT/dns/dnssec/$algo.conf\" << EOF;\nKSK=$KSK\nZSK=$ZSK\nEOF\nfi\n\n\t# And loop to do the next algorithm...\ndone\n\n# Force the dns_update script to be run every day to re-sign zones for DNSSEC\n# before they expire. When we sign zones (in `dns_update.py`) we specify a\n# 30-day validation window, so we had better re-sign before then.\ncat > /etc/cron.daily/mailinabox-dnssec << EOF;\n#!/bin/bash\n# Mail-in-a-Box\n# Re-sign any DNS zones with DNSSEC because the signatures expire periodically.\n$PWD/tools/dns_update\nEOF\nchmod +x /etc/cron.daily/mailinabox-dnssec\n\n# Permit DNS queries on TCP/UDP in the firewall.\n\nufw_allow domain\n\n"
  },
  {
    "path": "setup/firstuser.sh",
    "content": "#!/bin/bash\n# If there aren't any mail users yet, create one.\nif [ -z \"$(management/cli.py user)\" ]; then\n\t# The output of \"management/cli.py user\" is a list of mail users. If there\n\t# aren't any yet, it'll be empty.\n\n\t# If we didn't ask for an email address at the start, do so now.\n\tif [ -z \"${EMAIL_ADDR:-}\" ]; then\n\t\t# In an interactive shell, ask the user for an email address.\n\t\tif [ -z \"${NONINTERACTIVE:-}\" ]; then\n\t\t\tinput_box \"Mail Account\" \\\n\t\t\t\t\"Let's create your first mail account.\n\t\t\t\t\\n\\nWhat email address do you want?\" \\\n\t\t\t\t\"me@$(get_default_hostname)\" \\\n\t\t\t\tEMAIL_ADDR\n\n\t\t\tif [ -z \"$EMAIL_ADDR\" ]; then\n\t\t\t\t# user hit ESC/cancel\n\t\t\t\texit\n\t\t\tfi\n\t\t\twhile ! management/mailconfig.py validate-email \"$EMAIL_ADDR\"\n\t\t\tdo\n\t\t\t\tinput_box \"Mail Account\" \\\n\t\t\t\t\t\"That's not a valid email address.\n\t\t\t\t\t\\n\\nWhat email address do you want?\" \\\n\t\t\t\t\t\"$EMAIL_ADDR\" \\\n\t\t\t\t\tEMAIL_ADDR\n\t\t\t\tif [ -z \"$EMAIL_ADDR\" ]; then\n\t\t\t\t\t# user hit ESC/cancel\n\t\t\t\t\texit\n\t\t\t\tfi\n\t\t\tdone\n\n\t\t# But in a non-interactive shell, just make something up.\n\t\t# This is normally for testing.\n\t\telse\n\t\t\t# Use me@PRIMARY_HOSTNAME\n\t\t\tEMAIL_ADDR=me@$PRIMARY_HOSTNAME\n\t\t\tEMAIL_PW=12345678\n\t\t\techo\n\t\t\techo \"Creating a new administrative mail account for $EMAIL_ADDR with password $EMAIL_PW.\"\n\t\t\techo\n\t\tfi\n\telse\n\t\techo\n\t\techo \"Okay. I'm about to set up $EMAIL_ADDR for you. This account will also\"\n\t\techo \"have access to the box's control panel.\"\n\tfi\n\n\t# Create the user's mail account. This will ask for a password if none was given above.\n\tmanagement/cli.py user add \"$EMAIL_ADDR\" ${EMAIL_PW:+\"$EMAIL_PW\"}\n\n\t# Make it an admin.\n\thide_output management/cli.py user make-admin \"$EMAIL_ADDR\"\n\n\t# Create an alias to which we'll direct all automatically-created administrative aliases.\n\tmanagement/cli.py alias add \"administrator@$PRIMARY_HOSTNAME\" \"$EMAIL_ADDR\" > /dev/null\nfi\n"
  },
  {
    "path": "setup/functions.sh",
    "content": "#!/bin/bash\n# Turn on \"strict mode.\" See http://redsymbol.net/articles/unofficial-bash-strict-mode/.\n# -e: exit if any command unexpectedly fails.\n# -u: exit if we have a variable typo.\n# -o pipefail: don't ignore errors in the non-last command in a pipeline\nset -euo pipefail\n\nPHP_VER=8.0\n\nfunction hide_output {\n\t# This function hides the output of a command unless the command fails\n\t# and returns a non-zero exit code.\n\n\t# Get a temporary file.\n\tOUTPUT=$(mktemp)\n\n\t# Execute command, redirecting stderr/stdout to the temporary file. Since we\n\t# check the return code ourselves, disable 'set -e' temporarily.\n\tset +e\n\t\"$@\" &> \"$OUTPUT\"\n\tE=$?\n\tset -e\n\n\t# If the command failed, show the output that was captured in the temporary file.\n\tif [ $E != 0 ]; then\n\t\t# Something failed.\n\t\techo\n\t\techo \"FAILED: $*\"\n\t\techo -----------------------------------------\n\t\tcat \"$OUTPUT\"\n\t\techo -----------------------------------------\n\t\texit $E\n\tfi\n\n\t# Remove temporary file.\n\trm -f \"$OUTPUT\"\n}\n\nfunction apt_get_quiet {\n\t# Run apt-get in a totally non-interactive mode.\n\t#\n\t# Somehow all of these options are needed to get it to not ask the user\n\t# questions about a) whether to proceed (-y), b) package options (noninteractive),\n\t# and c) what to do about files changed locally (we don't cause that to happen but\n\t# some VM providers muck with their images; -o).\n\t#\n\t# Although we could pass -qq to apt-get to make output quieter, many packages write to stdout\n\t# and stderr things that aren't really important. Use our hide_output function to capture\n\t# all of that and only show it if there is a problem (i.e. if apt_get returns a failure exit status).\n\tDEBIAN_FRONTEND=noninteractive hide_output apt-get -y -o Dpkg::Options::=\"--force-confdef\" -o Dpkg::Options::=\"--force-confnew\" \"$@\"\n}\n\nfunction apt_install {\n\t# Install a bunch of packages. We used to report which packages were already\n\t# installed and which needed installing, before just running an 'apt-get\n\t# install' for all of the packages.  Calling `dpkg` on each package is slow,\n\t# and doesn't affect what we actually do, except in the messages, so let's\n\t# not do that anymore.\n\tapt_get_quiet install \"$@\"\n}\n\nfunction get_default_hostname {\n\t# Guess the machine's hostname. It should be a fully qualified\n\t# domain name suitable for DNS. None of these calls may provide\n\t# the right value, but it's the best guess we can make.\n\tset -- \"$(hostname --fqdn      2>/dev/null ||\n                 hostname --all-fqdns 2>/dev/null ||\n                 hostname             2>/dev/null)\"\n\tprintf '%s\\n' \"$1\" # return this value\n}\n\nfunction get_publicip_from_web_service {\n\t# This seems to be the most reliable way to determine the\n\t# machine's public IP address: asking a very nice web API\n\t# for how they see us. Thanks go out to icanhazip.com.\n\t# See: https://major.io/icanhazip-com-faq/\n\t#\n\t# Pass '4' or '6' as an argument to this function to specify\n\t# what type of address to get (IPv4, IPv6).\n\tcurl -\"$1\" --fail --silent --max-time 15 icanhazip.com 2>/dev/null || /bin/true\n}\n\nfunction get_default_privateip {\n\t# Return the IP address of the network interface connected\n\t# to the Internet.\n\t#\n\t# Pass '4' or '6' as an argument to this function to specify\n\t# what type of address to get (IPv4, IPv6).\n\t#\n\t# We used to use `hostname -I` and then filter for either\n\t# IPv4 or IPv6 addresses. However if there are multiple\n\t# network interfaces on the machine, not all may be for\n\t# reaching the Internet.\n\t#\n\t# Instead use `ip route get` which asks the kernel to use\n\t# the system's routes to select which interface would be\n\t# used to reach a public address. We'll use 8.8.8.8 as\n\t# the destination. It happens to be Google Public DNS, but\n\t# no connection is made. We're just seeing how the box\n\t# would connect to it. There many be multiple IP addresses\n\t# assigned to an interface. `ip route get` reports the\n\t# preferred. That's good enough for us. See issue #121.\n\t#\n\t# With IPv6, the best route may be via an interface that\n\t# only has a link-local address (fe80::*). These addresses\n\t# are only unique to an interface and so need an explicit\n\t# interface specification in order to use them with bind().\n\t# In these cases, we append \"%interface\" to the address.\n\t# See the Notes section in the man page for getaddrinfo and\n\t# https://discourse.mailinabox.email/t/update-broke-mailinabox/34/9.\n\t#\n\t# Also see ae67409603c49b7fa73c227449264ddd10aae6a9 and\n\t# issue #3 for why/how we originally added IPv6.\n\n\ttarget=8.8.8.8\n\n\t# For the IPv6 route, use the corresponding IPv6 address\n\t# of Google Public DNS. Again, it doesn't matter so long\n\t# as it's an address on the public Internet.\n\tif [ \"$1\" == \"6\" ]; then target=2001:4860:4860::8888; fi\n\n\t# Get the route information.\n\troute=$(ip -\"$1\" -o route get $target 2>/dev/null | grep -v unreachable)\n\n\t# Parse the address out of the route information.\n\taddress=$(echo \"$route\" | sed \"s/.* src \\([^ ]*\\).*/\\1/\")\n\n\tif [[ \"$1\" == \"6\" && $address == fe80:* ]]; then\n\t\t# For IPv6 link-local addresses, parse the interface out\n\t\t# of the route information and append it with a '%'.\n\t\tinterface=$(echo \"$route\" | sed \"s/.* dev \\([^ ]*\\).*/\\1/\")\n\t\taddress=$address%$interface\n\tfi\n\n\techo \"$address\"\n}\n\nfunction ufw_allow {\n\tif [ -z \"${DISABLE_FIREWALL:-}\" ]; then\n\t\t# ufw has completely unhelpful output\n\t\tufw allow \"$1\" > /dev/null;\n\tfi\n}\n\nfunction ufw_limit {\n\tif [ -z \"${DISABLE_FIREWALL:-}\" ]; then\n\t\t# ufw has completely unhelpful output\n\t\tufw limit \"$1\" > /dev/null;\n\tfi\n}\n\nfunction restart_service {\n\thide_output service \"$1\" restart\n}\n\n## Dialog Functions ##\nfunction message_box {\n\tdialog --title \"$1\" --msgbox \"$2\" 0 0\n}\n\nfunction input_box {\n\t# input_box \"title\" \"prompt\" \"defaultvalue\" VARIABLE\n\t# The user's input will be stored in the variable VARIABLE.\n\t# The exit code from dialog will be stored in VARIABLE_EXITCODE.\n\t# Temporarily turn off 'set -e' because we need the dialog return code.\n\tdeclare -n result=$4\n\tdeclare -n result_code=$4_EXITCODE\n\tset +e\n\tresult=$(dialog --stdout --title \"$1\" --inputbox \"$2\" 0 0 \"$3\")\n\tresult_code=$?\n\tset -e\n}\n\nfunction input_menu {\n\t# input_menu \"title\" \"prompt\" \"tag item tag item\" VARIABLE\n\t# The user's input will be stored in the variable VARIABLE.\n\t# The exit code from dialog will be stored in VARIABLE_EXITCODE.\n\tdeclare -n result=$4\n\tdeclare -n result_code=$4_EXITCODE\n\tlocal IFS=^$'\\n'\n\tset +e\n\tresult=$(dialog --stdout --title \"$1\" --menu \"$2\" 0 0 0 \"$3\")\n\tresult_code=$?\n\tset -e\n}\n\nfunction wget_verify {\n\t# Downloads a file from the web and checks that it matches\n\t# a provided hash. If the comparison fails, exit immediately.\n\tURL=$1\n\tHASH=$2\n\tDEST=$3\n\tCHECKSUM=\"$HASH  $DEST\"\n\trm -f \"$DEST\"\n\thide_output wget -O \"$DEST\" \"$URL\"\n\tif ! echo \"$CHECKSUM\" | sha1sum --check --strict > /dev/null; then\n\t\techo \"------------------------------------------------------------\"\n\t\techo \"Download of $URL did not match expected checksum.\"\n\t\techo \"Found:\"\n\t\tsha1sum \"$DEST\"\n\t\techo\n\t\techo \"Expected:\"\n\t\techo \"$CHECKSUM\"\n\t\trm -f \"$DEST\"\n\t\texit 1\n\tfi\n}\n\nfunction git_clone {\n\t# Clones a git repository, checks out a particular commit or tag,\n\t# and moves the repository (or a subdirectory in it) to some path.\n\t# We use separate clone and checkout because -b only supports tags\n\t# and branches, but we sometimes want to reference a commit hash\n\t# directly when the repo doesn't provide a tag.\n\tREPO=$1\n\tTREEISH=$2\n\tSUBDIR=$3\n\tTARGETPATH=$4\n\tTMPPATH=/tmp/git-clone-$$\n\trm -rf $TMPPATH \"$TARGETPATH\"\n\tgit clone -q \"$REPO\" $TMPPATH || exit 1\n\t(cd $TMPPATH; git checkout -q \"$TREEISH\";) || exit 1\n\tmv $TMPPATH/\"$SUBDIR\" \"$TARGETPATH\"\n\trm -rf $TMPPATH\n}\n"
  },
  {
    "path": "setup/mail-dovecot.sh",
    "content": "#!/bin/bash\n#\n# Dovecot (IMAP/POP and LDA)\n# ----------------------\n#\n# Dovecot is *both* the IMAP/POP server (the protocol that email applications\n# use to query a mailbox) as well as the local delivery agent (LDA),\n# meaning it is responsible for writing emails to mailbox storage on disk.\n# You could imagine why these things would be bundled together.\n#\n# As part of local mail delivery, Dovecot executes actions on incoming\n# mail as defined in a \"sieve\" script.\n#\n# Dovecot's LDA role comes after spam filtering. Postfix hands mail off\n# to Spamassassin which in turn hands it off to Dovecot. This all happens\n# using the LMTP protocol.\n\nsource setup/functions.sh # load our functions\nsource /etc/mailinabox.conf # load global vars\n\n\n# Install packages for dovecot. These are all core dovecot plugins,\n# but dovecot-lucene is packaged by *us* in the Mail-in-a-Box PPA,\n# not by Ubuntu.\n\necho \"Installing Dovecot (IMAP server)...\"\napt_install \\\n\tdovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite sqlite3 \\\n\tdovecot-sieve dovecot-managesieved\n\n# The `dovecot-imapd`, `dovecot-pop3d`, and `dovecot-lmtpd` packages automatically\n# enable IMAP, POP and LMTP protocols.\n\n# Set basic daemon options.\n\n# The `default_process_limit` is 100, which constrains the total number\n# of active IMAP connections (at, say, 5 open connections per user that\n# would be 20 users). Set it to 250 times the number of cores this\n# machine has, so on a two-core machine that's 500 processes/100 users).\n# The `default_vsz_limit` is the maximum amount of virtual memory that\n# can be allocated. It should be set *reasonably high* to avoid allocation\n# issues with larger mailboxes. We're setting it to 1/3 of the total\n# available memory (physical mem + swap) to be sure.\n# See here for discussion:\n# - https://www.dovecot.org/list/dovecot/2012-August/137569.html\n# - https://www.dovecot.org/list/dovecot/2011-December/132455.html\ntools/editconf.py /etc/dovecot/conf.d/10-master.conf \\\n\tdefault_process_limit=\"$(($(nproc) * 250))\" \\\n\tdefault_vsz_limit=\"$(($(free -tm  | tail -1 | awk '{print $2}') / 3))M\" \\\n\tlog_path=/var/log/mail.log\n\n# The inotify `max_user_instances` default is 128, which constrains\n# the total number of watched (IMAP IDLE push) folders by open connections.\n# See http://www.dovecot.org/pipermail/dovecot/2013-March/088834.html.\n# A reboot is required for this to take effect (which we don't do as\n# as a part of setup). Test with `cat /proc/sys/fs/inotify/max_user_instances`.\ntools/editconf.py /etc/sysctl.conf \\\n\tfs.inotify.max_user_instances=1024\n\n# Set the location where we'll store user mailboxes. '%d' is the domain name and '%n' is the\n# username part of the user's email address. We'll ensure that no bad domains or email addresses\n# are created within the management daemon.\ntools/editconf.py /etc/dovecot/conf.d/10-mail.conf \\\n\tmail_location=\"maildir:$STORAGE_ROOT/mail/mailboxes/%d/%n\" \\\n\tmail_privileged_group=mail \\\n\tfirst_valid_uid=0\n\n# Create, subscribe, and mark as special folders: INBOX, Drafts, Sent, Trash, Spam and Archive.\ncp conf/dovecot-mailboxes.conf /etc/dovecot/conf.d/15-mailboxes.conf\nsed -i \"s/#mail_plugins =\\(.*\\)/mail_plugins =\\1 \\$mail_plugins quota/\" /etc/dovecot/conf.d/10-mail.conf\nif ! grep -q \"mail_plugins.* imap_quota\" /etc/dovecot/conf.d/20-imap.conf; then\n  sed -i \"s/\\(mail_plugins =.*\\)/\\1\\n  mail_plugins = \\$mail_plugins imap_quota/\" /etc/dovecot/conf.d/20-imap.conf\nfi\n\n# configure stuff for quota support\nif ! grep -q \"quota_status_success = DUNNO\" /etc/dovecot/conf.d/90-quota.conf; then\n    cat > /etc/dovecot/conf.d/90-quota.conf << EOF;\nplugin {\n  quota = maildir\n\n  quota_grace = 10%%\n\n  quota_status_success = DUNNO\n  quota_status_nouser = DUNNO\n  quota_status_overquota = \"522 5.2.2 Mailbox is full\"\n}\n\nservice quota-status {\n    executable = quota-status -p postfix\n    inet_listener {\n        port = 12340\n    }\n}\nEOF\nfi\n\n# ### IMAP/POP\n\n# Require that passwords are sent over SSL only, and allow the usual IMAP authentication mechanisms.\n# The LOGIN mechanism is supposedly for Microsoft products like Outlook to do SMTP login (I guess\n# since we're using Dovecot to handle SMTP authentication?).\ntools/editconf.py /etc/dovecot/conf.d/10-auth.conf \\\n\tdisable_plaintext_auth=yes \\\n\t\"auth_mechanisms=plain login\"\n\n# Enable SSL, specify the location of the SSL certificate and private key files.\n# Use Mozilla's \"Intermediate\" recommendations at https://ssl-config.mozilla.org/#server=dovecot&server-version=2.2.33&config=intermediate&openssl-version=1.1.1,\n# except that the current version of Dovecot does not have a TLSv1.3 setting, so we only use TLSv1.2.\ntools/editconf.py /etc/dovecot/conf.d/10-ssl.conf \\\n\tssl=required \\\n\t\"ssl_cert=<$STORAGE_ROOT/ssl/ssl_certificate.pem\" \\\n\t\"ssl_key=<$STORAGE_ROOT/ssl/ssl_private_key.pem\" \\\n\t\"ssl_min_protocol=TLSv1.2\" \\\n\t\"ssl_cipher_list=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\" \\\n\t\"ssl_prefer_server_ciphers=no\" \\\n\t\"ssl_dh_parameters_length=2048\" \\\n\t\"ssl_dh=<$STORAGE_ROOT/ssl/dh2048.pem\"\n\n# Disable in-the-clear IMAP/POP because there is no reason for a user to transmit\n# login credentials outside of an encrypted connection. Only the over-TLS versions\n# are made available (IMAPS on port 993; POP3S on port 995).\nsed -i \"s/#port = 143/port = 0/\" /etc/dovecot/conf.d/10-master.conf\nsed -i \"s/#port = 110/port = 0/\" /etc/dovecot/conf.d/10-master.conf\n\n# Make IMAP IDLE slightly more efficient. By default, Dovecot says \"still here\"\n# every two minutes. With K-9 mail, the bandwidth and battery usage due to\n# this are minimal. But for good measure, let's go to 4 minutes to halve the\n# bandwidth and number of times the device's networking might be woken up.\n# The risk is that if the connection is silent for too long it might be reset\n# by a peer. See [#129](https://github.com/mail-in-a-box/mailinabox/issues/129)\n# and [How bad is IMAP IDLE](http://razor.occams.info/blog/2014/08/09/how-bad-is-imap-idle/).\ntools/editconf.py /etc/dovecot/conf.d/20-imap.conf \\\n\timap_idle_notify_interval=\"4 mins\"\n\n# Set POP3 UIDL.\n# UIDLs are used by POP3 clients to keep track of what messages they've downloaded.\n# For new POP3 servers, the easiest way to set up UIDLs is to use IMAP's UIDVALIDITY\n# and UID values, the default in Dovecot.\ntools/editconf.py /etc/dovecot/conf.d/20-pop3.conf \\\n\tpop3_uidl_format=\"%08Xu%08Xv\"\n\n# ### LDA (LMTP)\n\n# Enable Dovecot's LDA service with the LMTP protocol. It will listen\n# on port 10026, and Spamassassin will be configured to pass mail there.\n#\n# The disabled unix socket listener is normally how Postfix and Dovecot\n# would communicate (see the Postfix setup script for the corresponding\n# setting also commented out).\n#\n# Also increase the number of allowed IMAP connections per mailbox because\n# we all have so many devices lately.\ncat > /etc/dovecot/conf.d/99-local.conf << EOF;\nservice lmtp {\n  #unix_listener /var/spool/postfix/private/dovecot-lmtp {\n  #  user = postfix\n  #  group = postfix\n  #}\n  inet_listener lmtp {\n    address = 127.0.0.1\n    port = 10026\n  }\n}\n\n# Enable imap-login on localhost to allow the user_external plugin\n# for Nextcloud to do imap authentication. (See #1577)\nservice imap-login {\n  inet_listener imap {\n    address = 127.0.0.1\n    port = 143\n  }\n}\nprotocol imap {\n  mail_max_userip_connections = 40\n}\nEOF\n\n# Setting a `postmaster_address` is required or LMTP won't start. An alias\n# will be created automatically by our management daemon.\ntools/editconf.py /etc/dovecot/conf.d/15-lda.conf \\\n\t\"postmaster_address=postmaster@$PRIMARY_HOSTNAME\"\n\n# ### Sieve\n\n# Enable the Dovecot sieve plugin which let's users run scripts that process\n# mail as it comes in.\nsed -i \"s/#mail_plugins = .*/mail_plugins = \\$mail_plugins sieve/\" /etc/dovecot/conf.d/20-lmtp.conf\n\n# Configure sieve. We'll create a global script that moves mail marked\n# as spam by Spamassassin into the user's Spam folder.\n#\n# * `sieve_before`: The path to our global sieve which handles moving spam to the Spam folder.\n#\n# * `sieve_before2`: The path to our global sieve directory for sieve which can contain .sieve files\n# to run globally for every user before their own sieve files run.\n#\n# * `sieve_after`: The path to our global sieve directory which can contain .sieve files\n# to run globally for every user after their own sieve files run.\n#\n# * `sieve`: The path to the user's main active script. ManageSieve will create a symbolic\n# link here to the actual sieve script. It should not be in the mailbox directory\n# (because then it might appear as a folder) and it should not be in the sieve_dir\n# (because then I suppose it might appear to the user as one of their scripts).\n# * `sieve_dir`: Directory for :personal include scripts for the include extension. This\n# is also where the ManageSieve service stores the user's scripts.\ncat > /etc/dovecot/conf.d/99-local-sieve.conf << EOF;\nplugin {\n  sieve_before = /etc/dovecot/sieve-spam.sieve\n  sieve_before2 = $STORAGE_ROOT/mail/sieve/global_before\n  sieve_after = $STORAGE_ROOT/mail/sieve/global_after\n  sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve\n  sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n\n  sieve_redirect_envelope_from = recipient\n}\nEOF\n\n# Copy the global sieve script into where we've told Dovecot to look for it. Then\n# compile it. Global scripts must be compiled now because Dovecot won't have\n# permission later.\ncp conf/sieve-spam.txt /etc/dovecot/sieve-spam.sieve\nsievec /etc/dovecot/sieve-spam.sieve\n\n# PERMISSIONS\n\n# Ensure configuration files are owned by dovecot and not world readable.\nchown -R mail:dovecot /etc/dovecot\nchmod -R o-rwx /etc/dovecot\n\n# Ensure mailbox files have a directory that exists and are owned by the mail user.\nmkdir -p \"$STORAGE_ROOT/mail/mailboxes\"\nchown -R mail:mail \"$STORAGE_ROOT/mail/mailboxes\"\n\n# Same for the sieve scripts.\nmkdir -p \"$STORAGE_ROOT/mail/sieve\"\nmkdir -p \"$STORAGE_ROOT/mail/sieve/global_before\"\nmkdir -p \"$STORAGE_ROOT/mail/sieve/global_after\"\nchown -R mail:mail \"$STORAGE_ROOT/mail/sieve\"\n\n# Allow the IMAP/POP ports in the firewall.\nufw_allow imaps\nufw_allow pop3s\n\n# Allow the Sieve port in the firewall.\nufw_allow sieve\n\n# Restart services.\nrestart_service dovecot\n"
  },
  {
    "path": "setup/mail-postfix.sh",
    "content": "#!/bin/bash\n#\n# Postfix (SMTP)\n# --------------\n#\n# Postfix handles the transmission of email between servers\n# using the SMTP protocol. It is a Mail Transfer Agent (MTA).\n#\n# Postfix listens on port 25 (SMTP) for incoming mail from\n# other servers on the Internet. It is responsible for very\n# basic email filtering such as by IP address and greylisting,\n# it checks that the destination address is valid, rewrites\n# destinations according to aliases, and passses email on to\n# another service for local mail delivery.\n#\n# The first hop in local mail delivery is to spampd via\n# LMTP. spampd then passes mail over to Dovecot for\n# storage in the user's mailbox.\n#\n# Postfix also listens on ports 465/587 (SMTPS, SMTP+STARTLS) for\n# connections from users who can authenticate and then sends\n# their email out to the outside world. Postfix queries Dovecot\n# to authenticate users.\n#\n# Address validation, alias rewriting, and user authentication\n# is configured in a separate setup script mail-users.sh\n# because of the overlap of this part with the Dovecot\n# configuration.\n\nsource setup/functions.sh # load our functions\nsource /etc/mailinabox.conf # load global vars\n\n# ### Install packages.\n\n# Install postfix's packages.\n#\n# * `postfix`: The SMTP server.\n# * `postfix-pcre`: Enables header filtering.\n# * `postgrey`: A mail policy service that soft-rejects mail the first time\n#   it is received. Spammers don't usually try again. Legitimate mail\n#   always will.\n# * `ca-certificates`: A trust store used to squelch postfix warnings about\n#   untrusted opportunistically-encrypted connections.\necho \"Installing Postfix (SMTP server)...\"\napt_install postfix postfix-sqlite postfix-pcre postgrey ca-certificates\n\n# ### Basic Settings\n\n# Set some basic settings...\n#\n# * Have postfix listen on all network interfaces.\n# * Make outgoing connections on a particular interface (if multihomed) so that SPF passes on the receiving side.\n# * Set our name (the Debian default seems to be \"localhost\" but make it our hostname).\n# * Set the name of the local machine to localhost, which means xxx@localhost is delivered locally, although we don't use it.\n# * Set the SMTP banner (which must have the hostname first, then anything).\ntools/editconf.py /etc/postfix/main.cf \\\n\tinet_interfaces=all \\\n\tsmtp_bind_address=\"$PRIVATE_IP\" \\\n\tsmtp_bind_address6=\"$PRIVATE_IPV6\" \\\n\tmyhostname=\"$PRIMARY_HOSTNAME\"\\\n\tsmtpd_banner=\"\\$myhostname ESMTP Hi, I'm a Mail-in-a-Box (Ubuntu/Postfix; see https://mailinabox.email/)\" \\\n\tmydestination=localhost\n\n# Tweak some queue settings:\n# * Inform users when their e-mail delivery is delayed more than 3 hours (default is not to warn).\n# * Stop trying to send an undeliverable e-mail after 2 days (instead of 5), and for bounce messages just try for 1 day.\ntools/editconf.py /etc/postfix/main.cf \\\n\tdelay_warning_time=3h \\\n\tmaximal_queue_lifetime=2d \\\n\tbounce_queue_lifetime=1d\n\n# Guard against SMTP smuggling\n# This \"long-term\" fix is recommended at https://www.postfix.org/smtp-smuggling.html.\n# This beecame supported in a backported fix in package version 3.6.4-1ubuntu1.3. It is\n# unnecessary in Postfix 3.9+ where this is the default. The \"short-term\" workarounds\n# that we previously had are reverted to postfix defaults (though smtpd_discard_ehlo_keywords\n# was never included in a released version of Mail-in-a-Box).\ntools/editconf.py /etc/postfix/main.cf -e \\\n       smtpd_data_restrictions= \\\n       smtpd_discard_ehlo_keywords=\ntools/editconf.py /etc/postfix/main.cf \\\n       smtpd_forbid_bare_newline=normalize\n\n# ### Outgoing Mail\n\n# Enable the 'submission' ports 465 and 587 and tweak their settings.\n#\n# * Enable authentication. It's disabled globally so that it is disabled on port 25,\n#   so we need to explicitly enable it here.\n# * Do not add the OpenDMAC Authentication-Results header. That should only be added\n#   on incoming mail. Omit the OpenDMARC milter by re-setting smtpd_milters to the\n#   OpenDKIM milter only. See dkim.sh.\n# * Even though we dont allow auth over non-TLS connections (smtpd_tls_auth_only below, and without auth the client cant\n#   send outbound mail), don't allow non-TLS mail submission on this port anyway to prevent accidental misconfiguration.\n#   Setting smtpd_tls_security_level=encrypt also triggers the use of the 'mandatory' settings below (but this is ignored with smtpd_tls_wrappermode=yes.)\n# * Give it a different name in syslog to distinguish it from the port 25 smtpd server.\n# * Add a new cleanup service specific to the submission service ('authclean')\n#   that filters out privacy-sensitive headers on mail being sent out by\n#   authenticated users.  By default Postfix also applies this to attached\n#   emails but we turn this off by setting nested_header_checks empty.\ntools/editconf.py /etc/postfix/master.cf -s -w \\\n\t\"smtps=inet n       -       -       -       -       smtpd\n\t  -o smtpd_tls_wrappermode=yes\n\t  -o smtpd_sasl_auth_enable=yes\n\t  -o syslog_name=postfix/submission\n\t  -o smtpd_milters=inet:127.0.0.1:8891\n\t  -o cleanup_service_name=authclean\" \\\n\t\"submission=inet n       -       -       -       -       smtpd\n\t  -o smtpd_sasl_auth_enable=yes\n\t  -o syslog_name=postfix/submission\n\t  -o smtpd_milters=inet:127.0.0.1:8891\n\t  -o smtpd_tls_security_level=encrypt\n\t  -o cleanup_service_name=authclean\" \\\n\t\"authclean=unix  n       -       -       -       0       cleanup\n\t  -o header_checks=pcre:/etc/postfix/outgoing_mail_header_filters\n\t  -o nested_header_checks=\"\n\n# Install the `outgoing_mail_header_filters` file required by the new 'authclean' service.\ncp conf/postfix_outgoing_mail_header_filters /etc/postfix/outgoing_mail_header_filters\n\n# Modify the `outgoing_mail_header_filters` file to use the local machine name and ip\n# on the first received header line.  This may help reduce the spam score of email by\n# removing the 127.0.0.1 reference.\nsed -i \"s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/\" /etc/postfix/outgoing_mail_header_filters\nsed -i \"s/PUBLIC_IP/$PUBLIC_IP/\" /etc/postfix/outgoing_mail_header_filters\n\n# Enable TLS on incoming connections. It is not required on port 25, allowing for opportunistic\n# encryption. On ports 465 and 587 it is mandatory (see above). Shared and non-shared settings are\n# given here. Shared settings include:\n# * Require TLS before a user is allowed to authenticate.\n# * Set the path to the server TLS certificate and 2048-bit DH parameters for old DH ciphers.\n# For port 25 only:\n# * Disable extremely old versions of TLS and extremely unsafe ciphers, but some mail servers out in\n#   the world are very far behind and if we disable too much, they may not be able to use TLS and\n#   won't fall back to cleartext. So we don't disable too much. smtpd_tls_exclude_ciphers applies to\n#   both port 25 and port 587, but because we override the cipher list for both, it probably isn't used.\n#   Use Mozilla's \"Old\" recommendations at https://ssl-config.mozilla.org/#server=postfix&server-version=3.3.0&config=old&openssl-version=1.1.1\ntools/editconf.py /etc/postfix/main.cf \\\n\tsmtpd_tls_security_level=may\\\n\tsmtpd_tls_auth_only=yes \\\n\tsmtpd_tls_cert_file=\"$STORAGE_ROOT/ssl/ssl_certificate.pem\" \\\n\tsmtpd_tls_key_file=\"$STORAGE_ROOT/ssl/ssl_private_key.pem\" \\\n\tsmtpd_tls_dh1024_param_file=\"$STORAGE_ROOT/ssl/dh2048.pem\" \\\n\tsmtpd_tls_protocols=\"!SSLv2,!SSLv3\" \\\n\tsmtpd_tls_ciphers=medium \\\n\ttls_medium_cipherlist=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:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA256:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA \\\n\tsmtpd_tls_exclude_ciphers=aNULL,RC4 \\\n\ttls_preempt_cipherlist=no \\\n\tsmtpd_tls_received_header=yes\n\n# For ports 465/587 (via the 'mandatory' settings):\n# * Use Mozilla's \"Intermediate\" TLS recommendations from https://ssl-config.mozilla.org/#server=postfix&server-version=3.3.0&config=intermediate&openssl-version=1.1.1\n#   using and overriding the \"high\" cipher list so we don't conflict with the more permissive settings for port 25.\ntools/editconf.py /etc/postfix/main.cf \\\n\tsmtpd_tls_mandatory_protocols=\"!SSLv2,!SSLv3,!TLSv1,!TLSv1.1\" \\\n\tsmtpd_tls_mandatory_ciphers=high \\\n\ttls_high_cipherlist=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 \\\n\tsmtpd_tls_mandatory_exclude_ciphers=aNULL,DES,3DES,MD5,DES+MD5,RC4\n\n# Prevent non-authenticated users from sending mail that requires being\n# relayed elsewhere. We don't want to be an \"open relay\". On outbound\n# mail, require one of:\n#\n# * `permit_sasl_authenticated`: Authenticated users (i.e. on port 465/587).\n# * `permit_mynetworks`: Mail that originates locally.\n# * `reject_unauth_destination`: No one else. (Permits mail whose destination is local and rejects other mail.)\ntools/editconf.py /etc/postfix/main.cf \\\n\tsmtpd_relay_restrictions=permit_sasl_authenticated,permit_mynetworks,reject_unauth_destination\n\n\n# ### DANE\n\n# When connecting to remote SMTP servers, prefer TLS and use DANE if available.\n#\n# Preferring (\"opportunistic\") TLS means Postfix will use TLS if the remote end\n# offers it, otherwise it will transmit the message in the clear. Postfix will\n# accept whatever SSL certificate the remote end provides. Opportunistic TLS\n# protects against passive easvesdropping (but not man-in-the-middle attacks).\n# Since we'd rather have poor encryption than none at all, we use Mozilla's\n# \"Old\" recommendations at https://ssl-config.mozilla.org/#server=postfix&server-version=3.3.0&config=old&openssl-version=1.1.1\n# for opportunistic encryption but \"Intermediate\" recommendations when DANE\n# is used (see next and above). The cipher lists are set above.\n\n# DANE takes this a step further:\n# Postfix queries DNS for the TLSA record on the destination MX host. If no TLSA records are found,\n# then opportunistic TLS is used. Otherwise the server certificate must match the TLSA records\n# or else the mail bounces. TLSA also requires DNSSEC on the MX host. Postfix doesn't do DNSSEC\n# itself but assumes the system's nameserver does and reports DNSSEC status. Thus this also\n# relies on our local DNS server (see system.sh) and `smtp_dns_support_level=dnssec`.\n#\n# The `smtp_tls_CAfile` is superfluous, but it eliminates warnings in the logs about untrusted certs,\n# which we don't care about seeing because Postfix is doing opportunistic TLS anyway. Better to encrypt,\n# even if we don't know if it's to the right party, than to not encrypt at all. Instead we'll\n# now see notices about trusted certs. The CA file is provided by the package `ca-certificates`.\ntools/editconf.py /etc/postfix/main.cf \\\n\tsmtp_tls_protocols=\\!SSLv2,\\!SSLv3 \\\n\tsmtp_tls_ciphers=medium \\\n\tsmtp_tls_exclude_ciphers=aNULL,RC4 \\\n\tsmtp_tls_security_level=dane \\\n\tsmtp_dns_support_level=dnssec \\\n\tsmtp_tls_mandatory_protocols=\"!SSLv2,!SSLv3,!TLSv1,!TLSv1.1\" \\\n\tsmtp_tls_mandatory_ciphers=high \\\n\tsmtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt \\\n\tsmtp_tls_loglevel=2\n\n# ### Incoming Mail\n\n# Pass mail to spampd, which acts as the local delivery agent (LDA),\n# which then passes the mail over to the Dovecot LMTP server after.\n# spampd runs on port 10025 by default.\n#\n# In a basic setup we would pass mail directly to Dovecot by setting\n# virtual_transport to `lmtp:unix:private/dovecot-lmtp`.\ntools/editconf.py /etc/postfix/main.cf \"virtual_transport=lmtp:[127.0.0.1]:10025\"\n# Clear the lmtp_destination_recipient_limit setting which in previous\n# versions of Mail-in-a-Box was set to 1 because of a spampd bug.\n# See https://github.com/mail-in-a-box/mailinabox/issues/1523.\ntools/editconf.py /etc/postfix/main.cf  -e lmtp_destination_recipient_limit=\n\n\n# Who can send mail to us? Some basic filters.\n#\n# * `reject_non_fqdn_sender`: Reject not-nice-looking return paths.\n# * `reject_unknown_sender_domain`: Reject return paths with invalid domains.\n# * `reject_authenticated_sender_login_mismatch`: Reject if mail FROM address does not match the client SASL login\n# * `reject_rhsbl_sender`: Reject return paths that use blacklisted domains.\n# * `permit_sasl_authenticated`: Authenticated users (i.e. on port 587) can skip further checks.\n# * `permit_mynetworks`: Mail that originates locally can skip further checks.\n# * `reject_rbl_client`: Reject connections from IP addresses blacklisted in zen.spamhaus.org\n# * `reject_unlisted_recipient`: Although Postfix will reject mail to unknown recipients, it's nicer to reject such mail ahead of greylisting rather than after.\n# * `check_policy_service`: Apply greylisting using postgrey.\n#\n# Note the spamhaus rbl return codes are taken into account as advised here: https://docs.spamhaus.com/datasets/docs/source/40-real-world-usage/PublicMirrors/MTAs/020-Postfix.html\n# Notes: #NODOC\n# permit_dnswl_client can pass through mail from whitelisted IP addresses, which would be good to put before greylisting #NODOC\n# so these IPs get mail delivered quickly. But when an IP is not listed in the permit_dnswl_client list (i.e. it is not #NODOC\n# whitelisted) then postfix does a DEFER_IF_REJECT, which results in all \"unknown user\" sorts of messages turning into #NODOC\n# \"450 4.7.1 Client host rejected: Service unavailable\". This is a retry code, so the mail doesn't properly bounce. #NODOC\ntools/editconf.py /etc/postfix/main.cf \\\n\tsmtpd_sender_restrictions=\"reject_non_fqdn_sender,reject_unknown_sender_domain,reject_authenticated_sender_login_mismatch,reject_rhsbl_sender dbl.spamhaus.org=127.0.1.[2..99]\" \\\n\tsmtpd_recipient_restrictions=\"permit_sasl_authenticated,permit_mynetworks,reject_rbl_client zen.spamhaus.org=127.0.0.[2..11],reject_unlisted_recipient,check_policy_service inet:127.0.0.1:10023,check_policy_service inet:127.0.0.1:12340\"\n\n# Postfix connects to Postgrey on the 127.0.0.1 interface specifically. Ensure that\n# Postgrey listens on the same interface (and not IPv6, for instance).\n# A lot of legit mail servers try to resend before 300 seconds.\n# As a matter of fact RFC is not strict about retry timer so postfix and\n# other MTA have their own intervals. To fix the problem of receiving\n# e-mails really latter, delay of greylisting has been set to\n# 180 seconds (default is 300 seconds). We will move the postgrey database\n# under $STORAGE_ROOT. This prevents a \"warming up\" that would have occurred\n# previously with a migrated or reinstalled OS.  We will specify this new path\n# with the --dbdir=... option. Arguments within POSTGREY_OPTS can not have spaces,\n# including dbdir. This is due to the way the init script sources the\n# /etc/default/postgrey file. --dbdir=... either needs to be a path without spaces\n# (luckily $STORAGE_ROOT does not currently work with spaces), or it needs to be a\n# symlink without spaces that can point to a folder with spaces).  We'll just assume\n# $STORAGE_ROOT won't have spaces to simplify things.\ntools/editconf.py /etc/default/postgrey \\\n\tPOSTGREY_OPTS=\\\"\"--inet=127.0.0.1:10023 --delay=180 --dbdir=$STORAGE_ROOT/mail/postgrey/db\"\\\"\n\n\n# If the $STORAGE_ROOT/mail/postgrey is empty, copy the postgrey database over from the old location\nif [ ! -d \"$STORAGE_ROOT/mail/postgrey/db\" ]; then\n\t# Stop the service\n\tservice postgrey stop\n\t# Ensure the new paths for postgrey db exists\n\tmkdir -p \"$STORAGE_ROOT/mail/postgrey/db\"\n\t# Move over database files\n\tmv /var/lib/postgrey/* \"$STORAGE_ROOT/mail/postgrey/db/\" || true\nfi\n# Ensure permissions are set\nchown -R postgrey:postgrey \"$STORAGE_ROOT/mail/postgrey/\"\nchmod 700 \"$STORAGE_ROOT/mail/postgrey/\"{,db}\n\n# We are going to setup a newer whitelist for postgrey, the version included in the distribution is old\ncat > /etc/cron.daily/mailinabox-postgrey-whitelist << EOF;\n#!/bin/bash\n\n# Mail-in-a-Box\n\n# check we have a postgrey_whitelist_clients file and that it is not older than 28 days\nif [ ! -f /etc/postgrey/whitelist_clients ] || find /etc/postgrey/whitelist_clients -mtime +28 | grep -q '.' ; then\n    # ok we need to update the file, so lets try to fetch it\n    if curl https://postgrey.schweikert.ch/pub/postgrey_whitelist_clients --output /tmp/postgrey_whitelist_clients -sS --fail > /dev/null 2>&1 ; then\n        # if fetching hasn't failed yet then check it is a plain text file\n        # curl manual states that --fail sometimes still produces output\n        # this final check will at least check the output is not html\n        # before moving it into place\n        if [ \"\\$(file -b --mime-type /tmp/postgrey_whitelist_clients)\" == \"text/plain\" ]; then\n            mv /tmp/postgrey_whitelist_clients /etc/postgrey/whitelist_clients\n            service postgrey restart\n\telse\n            rm /tmp/postgrey_whitelist_clients\n        fi\n    fi\nfi\nEOF\nchmod +x /etc/cron.daily/mailinabox-postgrey-whitelist\n/etc/cron.daily/mailinabox-postgrey-whitelist\n\n# Increase the message size limit from 10MB to 128MB.\n# The same limit is specified in nginx.conf for mail submitted via webmail and Z-Push.\ntools/editconf.py /etc/postfix/main.cf \\\n\tmessage_size_limit=134217728\n\n# Allow the two SMTP ports in the firewall.\n\nufw_allow smtp\nufw_allow smtps\nufw_allow submission\n\n# Restart services\n\nrestart_service postfix\nrestart_service postgrey\n"
  },
  {
    "path": "setup/mail-users.sh",
    "content": "#!/bin/bash\n#\n# User Authentication and Destination Validation\n# ----------------------------------------------\n#\n# This script configures user authentication for Dovecot\n# and Postfix (which relies on Dovecot) and destination\n# validation by querying an Sqlite3 database of mail users.\n\nsource setup/functions.sh # load our functions\nsource /etc/mailinabox.conf # load global vars\n\n# ### User and Alias Database\n\n# The database of mail users (i.e. authenticated users, who have mailboxes)\n# and aliases (forwarders).\n\ndb_path=$STORAGE_ROOT/mail/users.sqlite\n\n# Create an empty database if it doesn't yet exist.\nif [ ! -f \"$db_path\" ]; then\n\techo \"Creating new user database: $db_path\";\n\techo \"CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '', quota TEXT NOT NULL DEFAULT '0');\" | sqlite3 \"$db_path\";\n\techo \"CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);\" | sqlite3 \"$db_path\";\n\techo \"CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);\" | sqlite3 \"$db_path\";\n\techo \"CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);\" | sqlite3 \"$db_path\";\nfi\n\n# ### User Authentication\n\n# Have Dovecot query our database, and not system users, for authentication.\nsed -i \"s/#*\\(\\!include auth-system.conf.ext\\)/#\\1/\"  /etc/dovecot/conf.d/10-auth.conf\nsed -i \"s/#\\(\\!include auth-sql.conf.ext\\)/\\1/\"  /etc/dovecot/conf.d/10-auth.conf\n\n# Specify how the database is to be queried for user authentication (passdb)\n# and where user mailboxes are stored (userdb).\ncat > /etc/dovecot/conf.d/auth-sql.conf.ext << EOF;\npassdb {\n  driver = sql\n  args = /etc/dovecot/dovecot-sql.conf.ext\n}\nuserdb {\n  driver = sql\n  args = /etc/dovecot/dovecot-sql.conf.ext\n}\nEOF\n\n# Configure the SQL to query for a user's metadata and password.\ncat > /etc/dovecot/dovecot-sql.conf.ext << EOF;\ndriver = sqlite\nconnect = $db_path\ndefault_pass_scheme = SHA512-CRYPT\npassword_query = SELECT email as user, password FROM users WHERE email='%u';\nuser_query = SELECT email AS user, \"mail\" as uid, \"mail\" as gid, \"$STORAGE_ROOT/mail/mailboxes/%d/%n\" as home, '*:bytes=' || quota AS quota_rule FROM users WHERE email='%u';\niterate_query = SELECT email AS user FROM users;\nEOF\nchmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions\n\n# Have Dovecot provide an authorization service that Postfix can access & use.\ncat > /etc/dovecot/conf.d/99-local-auth.conf << EOF;\nservice auth {\n  unix_listener /var/spool/postfix/private/auth {\n    mode = 0666\n    user = postfix\n    group = postfix\n  }\n}\nEOF\n\n# And have Postfix use that service. We *disable* it here\n# so that authentication is not permitted on port 25 (which\n# does not run DKIM on relayed mail, so outbound mail isn't\n# correct, see #830), but we enable it specifically for the\n# submission port.\ntools/editconf.py /etc/postfix/main.cf \\\n\tsmtpd_sasl_type=dovecot \\\n\tsmtpd_sasl_path=private/auth \\\n\tsmtpd_sasl_auth_enable=no\n\n# ### Sender Validation\n\n# We use Postfix's reject_authenticated_sender_login_mismatch filter to\n# prevent intra-domain spoofing by logged in but untrusted users in outbound\n# email. In all outbound mail (the sender has authenticated), the MAIL FROM\n# address (aka envelope or return path address) must be \"owned\" by the user\n# who authenticated. An SQL query will find who are the owners of any given\n# address.\ntools/editconf.py /etc/postfix/main.cf \\\n\tsmtpd_sender_login_maps=sqlite:/etc/postfix/sender-login-maps.cf\n\n# Postfix will query the exact address first, where the priority will be alias\n# records first, then user records. If there are no matches for the exact\n# address, then Postfix will query just the domain part, which we call\n# catch-alls and domain aliases. A NULL permitted_senders column means to\n# take the value from the destination column.\ncat > /etc/postfix/sender-login-maps.cf << EOF;\ndbpath=$db_path\nquery = SELECT permitted_senders FROM (SELECT permitted_senders, 0 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NOT NULL UNION SELECT destination AS permitted_senders, 1 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NULL UNION SELECT email as permitted_senders, 2 AS priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1;\nEOF\n\n# ### Destination Validation\n\n# Use a Sqlite3 database to check whether a destination email address exists,\n# and to perform any email alias rewrites in Postfix. Additionally, we disable\n# SMTPUTF8 because Dovecot's LMTP server that delivers mail to inboxes does\n# not support it, and if a message is received with the SMTPUTF8 flag it will\n# bounce.\ntools/editconf.py /etc/postfix/main.cf \\\n\tsmtputf8_enable=no \\\n\tvirtual_mailbox_domains=sqlite:/etc/postfix/virtual-mailbox-domains.cf \\\n\tvirtual_mailbox_maps=sqlite:/etc/postfix/virtual-mailbox-maps.cf \\\n\tvirtual_alias_maps=sqlite:/etc/postfix/virtual-alias-maps.cf \\\n\tlocal_recipient_maps=\\$virtual_mailbox_maps\n\n# SQL statement to check if we handle incoming mail for a domain, either for users or aliases.\ncat > /etc/postfix/virtual-mailbox-domains.cf << EOF;\ndbpath=$db_path\nquery = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s' UNION SELECT 1 FROM auto_aliases WHERE source LIKE '%%@%s'\nEOF\n\n# SQL statement to check if we handle incoming mail for a user.\ncat > /etc/postfix/virtual-mailbox-maps.cf << EOF;\ndbpath=$db_path\nquery = SELECT 1 FROM users WHERE email='%s'\nEOF\n\n# SQL statement to rewrite an email address if an alias is present.\n#\n# Postfix makes multiple queries for each incoming mail. It first\n# queries the whole email address, then just the user part in certain\n# locally-directed cases (but we don't use this), then just `@`+the\n# domain part. The first query that returns something wins. See\n# http://www.postfix.org/virtual.5.html.\n#\n# virtual-alias-maps has precedence over virtual-mailbox-maps, but\n# we don't want catch-alls and domain aliases to catch mail for users\n# that have been defined on those domains. To fix this, we not only\n# query the aliases table but also the users table when resolving\n# aliases, i.e. we turn users into aliases from themselves to\n# themselves. That means users will match in postfix's first query\n# before postfix gets to the third query for catch-alls/domain alises.\n#\n# If there is both an alias and a user for the same address either\n# might be returned by the UNION, so the whole query is wrapped in\n# another select that prioritizes the alias definition to preserve\n# postfix's preference for aliases for whole email addresses.\n#\n# Since we might have alias records with an empty destination because\n# it might have just permitted_senders, skip any records with an\n# empty destination here so that other lower priority rules might match.\ncat > /etc/postfix/virtual-alias-maps.cf << EOF;\ndbpath=$db_path\nquery = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' AND destination<>'' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s' UNION SELECT destination, 2 as priority FROM auto_aliases WHERE source='%s' AND destination<>'') ORDER BY priority LIMIT 1;\nEOF\n\n# Restart Services\n##################\n\nrestart_service postfix\nrestart_service dovecot\n\n# force a recalculation of all user quotas\ndoveadm quota recalc -A\n"
  },
  {
    "path": "setup/management.sh",
    "content": "#!/bin/bash\n\nsource setup/functions.sh\nsource /etc/mailinabox.conf # load global vars\n\necho \"Installing Mail-in-a-Box system management daemon...\"\n\n# DEPENDENCIES\n\n# duplicity is used to make backups of user data.\n#\n# virtualenv is used to isolate the Python 3 packages we\n# install via pip from the system-installed packages.\n#\n# certbot installs EFF's certbot which we use to\n# provision free TLS certificates.\napt_install duplicity python3-pip virtualenv certbot rsync\n\n# b2sdk is used for backblaze backups.\n# boto3 is used for amazon aws backups.\n# Both are installed outside the pipenv, so they can be used by duplicity\nhide_output pip3 install --upgrade b2sdk boto3\n\n# Create a virtualenv for the installation of Python 3 packages\n# used by the management daemon.\ninst_dir=/usr/local/lib/mailinabox\nmkdir -p $inst_dir\nvenv=$inst_dir/env\nif [ ! -d $venv ]; then\n\t# A bug specific to Ubuntu 22.04 and Python 3.10 requires\n\t# forcing a virtualenv directory layout option (see #2335\n\t# and https://github.com/pypa/virtualenv/pull/2415). In\n\t# our issue, reportedly installing python3-distutils didn't\n\t# fix the problem.)\n\texport DEB_PYTHON_INSTALL_LAYOUT='deb'\n\thide_output virtualenv -ppython3 $venv\nfi\n\n# Upgrade pip because the Ubuntu-packaged version is out of date.\nhide_output $venv/bin/pip install --upgrade pip\n\n# Install other Python 3 packages used by the management daemon.\n# The first line is the packages that Josh maintains himself!\n# NOTE: email_validator is repeated in setup/questions.sh, so please keep the versions synced.\nhide_output $venv/bin/pip install --upgrade \\\n\trtyaml \"email_validator>=1.0.0\" \"exclusiveprocess\" \\\n\tflask dnspython python-dateutil expiringdict gunicorn \\\n\tqrcode[pil] pyotp \\\n\t\"idna>=2.0.0\" \"cryptography==37.0.2\" psutil postfix-mta-sts-resolver \\\n\tb2sdk boto3\n\n# CONFIGURATION\n\n# Create a backup directory and a random key for encrypting backups.\nmkdir -p \"$STORAGE_ROOT/backup\"\nif [ ! -f \"$STORAGE_ROOT/backup/secret_key.txt\" ]; then\n\t(umask 077; openssl rand -base64 2048 > \"$STORAGE_ROOT/backup/secret_key.txt\")\nfi\n\n\n# Download jQuery and Bootstrap local files\n\n# Make sure we have the directory to save to.\nassets_dir=$inst_dir/vendor/assets\nrm -rf $assets_dir\nmkdir -p $assets_dir\n\n# jQuery CDN URL\njquery_version=2.2.4\njquery_url=https://code.jquery.com\n\n# Get jQuery\nwget_verify $jquery_url/jquery-$jquery_version.min.js 69bb69e25ca7d5ef0935317584e6153f3fd9a88c $assets_dir/jquery.min.js\n\n# Bootstrap CDN URL\nbootstrap_version=3.4.1\nbootstrap_url=https://github.com/twbs/bootstrap/releases/download/v$bootstrap_version/bootstrap-$bootstrap_version-dist.zip\n\n# Get Bootstrap\nwget_verify $bootstrap_url 0bb64c67c2552014d48ab4db81c2e8c01781f580 /tmp/bootstrap.zip\nunzip -q /tmp/bootstrap.zip -d $assets_dir\nmv $assets_dir/bootstrap-$bootstrap_version-dist $assets_dir/bootstrap\nrm -f /tmp/bootstrap.zip\n\n# Create an init script to start the management daemon and keep it\n# running after a reboot.\n# Set a long timeout since some commands take a while to run, matching\n# the timeout we set for PHP (fastcgi_read_timeout in the nginx confs).\n# Note: Authentication currently breaks with more than 1 gunicorn worker.\ncat > $inst_dir/start <<EOF;\n#!/bin/bash\n# Set character encoding flags to ensure that any non-ASCII don't cause problems.\nexport LANGUAGE=en_US.UTF-8\nexport LC_ALL=en_US.UTF-8\nexport LANG=en_US.UTF-8\nexport LC_TYPE=en_US.UTF-8\n\nmkdir -p /var/lib/mailinabox\ntr -cd '[:xdigit:]' < /dev/urandom | head -c 32 > /var/lib/mailinabox/api.key\nchmod 640 /var/lib/mailinabox/api.key\n\nsource $venv/bin/activate\nexport PYTHONPATH=$PWD/management\nexec gunicorn -b localhost:10222 -w 1 --timeout 630 wsgi:app\nEOF\nchmod +x $inst_dir/start\ncp --remove-destination conf/mailinabox.service /lib/systemd/system/mailinabox.service # target was previously a symlink so remove it first\nhide_output systemctl link -f /lib/systemd/system/mailinabox.service\nhide_output systemctl daemon-reload\nhide_output systemctl enable mailinabox.service\n\n# Perform nightly tasks at 3am in system time: take a backup, run\n# status checks and email the administrator any changes.\n\nminute=$((RANDOM % 60))  # avoid overloading mailinabox.email\ncat > /etc/cron.d/mailinabox-nightly << EOF;\n# Mail-in-a-Box --- Do not edit / will be overwritten on update.\n# Run nightly tasks: backup, status checks.\n$minute 1 * * *\troot\t(cd $PWD && management/daily_tasks.sh)\nEOF\n\n# Start the management server.\nrestart_service mailinabox\n"
  },
  {
    "path": "setup/migrate.py",
    "content": "#!/usr/bin/python3\n\n# Migrates any file structures, database schemas, etc. between versions of Mail-in-a-Box.\n\n# We have to be careful here that any dependencies are already installed in the previous\n# version since this script runs before all other aspects of the setup script.\n\nimport sys, os, os.path, glob, re, shutil\n\nsys.path.insert(0, 'management')\nfrom utils import load_environment, save_environment, shell\nimport contextlib\n\ndef migration_1(env):\n\t# Re-arrange where we store SSL certificates. There was a typo also.\n\n\tdef move_file(fn, domain_name_escaped, filename):\n\t\t# Moves an SSL-related file into the right place.\n\t\tfn1 = os.path.join( env[\"STORAGE_ROOT\"], 'ssl', domain_name_escaped, file_type)\n\t\tos.makedirs(os.path.dirname(fn1), exist_ok=True)\n\t\tshutil.move(fn, fn1)\n\n\t# Migrate the 'domains' directory.\n\tfor sslfn in glob.glob(os.path.join( env[\"STORAGE_ROOT\"], 'ssl/domains/*' )):\n\t\tfn = os.path.basename(sslfn)\n\t\tm = re.match(r\"(.*)_(certifiate.pem|cert_sign_req.csr|private_key.pem)$\", fn)\n\t\tif m:\n\t\t\t# get the new name for the file\n\t\t\tdomain_name, file_type = m.groups()\n\t\t\tif file_type == \"certifiate.pem\": file_type = \"ssl_certificate.pem\" # typo\n\t\t\tif file_type == \"cert_sign_req.csr\": file_type = \"certificate_signing_request.csr\" # nicer\n\t\t\tmove_file(sslfn, domain_name, file_type)\n\n\t# Move the old domains directory if it is now empty.\n\twith contextlib.suppress(Exception):\n\t\tos.rmdir(os.path.join( env[\"STORAGE_ROOT\"], 'ssl/domains'))\n\ndef migration_2(env):\n\t# Delete the .dovecot_sieve script everywhere. This was formerly a copy of our spam -> Spam\n\t# script. We now install it as a global script, and we use managesieve, so the old file is\n\t# irrelevant. Also delete the compiled binary form.\n\tfor fn in glob.glob(os.path.join(env[\"STORAGE_ROOT\"], 'mail/mailboxes/*/*/.dovecot.sieve')):\n\t\tos.unlink(fn)\n\tfor fn in glob.glob(os.path.join(env[\"STORAGE_ROOT\"], 'mail/mailboxes/*/*/.dovecot.svbin')):\n\t\tos.unlink(fn)\n\ndef migration_3(env):\n\t# Move the migration ID from /etc/mailinabox.conf to $STORAGE_ROOT/mailinabox.version\n\t# so that the ID stays with the data files that it describes the format of. The writing\n\t# of the file will be handled by the main function.\n\tpass\n\ndef migration_4(env):\n\t# Add a new column to the mail users table where we can store administrative privileges.\n\tdb = os.path.join(env[\"STORAGE_ROOT\"], 'mail/users.sqlite')\n\tshell(\"check_call\", [\"sqlite3\", db, \"ALTER TABLE users ADD privileges TEXT NOT NULL DEFAULT ''\"])\n\ndef migration_5(env):\n\t# The secret key for encrypting backups was world readable. Fix here.\n\tos.chmod(os.path.join(env[\"STORAGE_ROOT\"], 'backup/secret_key.txt'), 0o600)\n\ndef migration_6(env):\n\t# We now will generate multiple DNSSEC keys for different algorithms, since TLDs may\n\t# not support them all. .email only supports RSA/SHA-256. Rename the keys.conf file\n\t# to be algorithm-specific.\n\tbasepath = os.path.join(env[\"STORAGE_ROOT\"], 'dns/dnssec')\n\tshutil.move(os.path.join(basepath, 'keys.conf'), os.path.join(basepath, 'RSASHA1-NSEC3-SHA1.conf'))\n\ndef migration_7(env):\n\t# I previously wanted domain names to be stored in Unicode in the database. Now I want them\n\t# to be in IDNA. Affects aliases only.\n\timport sqlite3\n\tconn = sqlite3.connect(os.path.join(env[\"STORAGE_ROOT\"], \"mail/users.sqlite\"))\n\n\t# Get existing alias source addresses.\n\tc = conn.cursor()\n\tc.execute('SELECT source FROM aliases')\n\taliases = [ row[0] for row in c.fetchall() ]\n\n\t# Update to IDNA-encoded domains.\n\tfor email in aliases:\n\t\ttry:\n\t\t\tlocalpart, domainpart = email.split(\"@\")\n\t\t\tdomainpart = domainpart.encode(\"idna\").decode(\"ascii\")\n\t\t\tnewemail = localpart + \"@\" + domainpart\n\t\t\tif newemail != email:\n\t\t\t\tc = conn.cursor()\n\t\t\t\tc.execute(\"UPDATE aliases SET source=? WHERE source=?\", (newemail, email))\n\t\t\t\tif c.rowcount != 1: raise ValueError(\"Alias not found.\")\n\t\t\t\tprint(\"Updated alias\", email, \"to\", newemail)\n\t\texcept Exception as e:\n\t\t\tprint(\"Error updating IDNA alias\", email, e)\n\n\t# Save.\n\tconn.commit()\n\ndef migration_8(env):\n\t# Delete DKIM keys. We had generated 1024-bit DKIM keys.\n\t# By deleting the key file we'll automatically generate\n\t# a new key, which will be 2048 bits.\n\tos.unlink(os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private'))\n\ndef migration_9(env):\n\t# Add a column to the aliases table to store permitted_senders,\n\t# which is a list of user account email addresses that are\n\t# permitted to send mail using this alias instead of their own\n\t# address. This was motivated by the addition of #427 (\"Reject\n\t# outgoing mail if FROM does not match Login\") - which introduced\n\t# the notion of outbound permitted-senders.\n\tdb = os.path.join(env[\"STORAGE_ROOT\"], 'mail/users.sqlite')\n\tshell(\"check_call\", [\"sqlite3\", db, \"ALTER TABLE aliases ADD permitted_senders TEXT\"])\n\ndef migration_10(env):\n\t# Clean up the SSL certificates directory.\n\n\t# Move the primary certificate to a new name and then\n\t# symlink it to the system certificate path.\n\timport datetime\n\tsystem_certificate = os.path.join(env[\"STORAGE_ROOT\"], 'ssl/ssl_certificate.pem')\n\tif not os.path.islink(system_certificate): # not already a symlink\n\t\tnew_path = os.path.join(env[\"STORAGE_ROOT\"], 'ssl', env['PRIMARY_HOSTNAME'] + \"-\" + datetime.datetime.now().date().isoformat().replace(\"-\", \"\") + \".pem\")\n\t\tprint(\"Renamed\", system_certificate, \"to\", new_path, \"and created a symlink for the original location.\")\n\t\tshutil.move(system_certificate, new_path)\n\t\tos.symlink(new_path, system_certificate)\n\n\t# Flatten the directory structure. For any directory\n\t# that contains a single file named ssl_certificate.pem,\n\t# move the file out and name it the same as the directory,\n\t# and remove the directory.\n\tfor sslcert in glob.glob(os.path.join( env[\"STORAGE_ROOT\"], 'ssl/*/ssl_certificate.pem' )):\n\t\td = os.path.dirname(sslcert)\n\t\tif len(os.listdir(d)) == 1:\n\t\t\t# This certificate is the only file in that directory.\n\t\t\tnewname = os.path.join(env[\"STORAGE_ROOT\"], 'ssl', os.path.basename(d) + '.pem')\n\t\t\tif not os.path.exists(newname):\n\t\t\t\tshutil.move(sslcert, newname)\n\t\t\t\tos.rmdir(d)\n\ndef migration_11(env):\n\t# Archive the old Let's Encrypt account directory managed by free_tls_certificates\n\t# because we'll use that path now for the directory managed by certbot.\n\ttry:\n\t\told_path = os.path.join(env[\"STORAGE_ROOT\"], 'ssl', 'lets_encrypt')\n\t\tnew_path = os.path.join(env[\"STORAGE_ROOT\"], 'ssl', 'lets_encrypt-old')\n\t\tshutil.move(old_path, new_path)\n\texcept:\n\t\t# meh\n\t\tpass\n\ndef migration_12(env):\n\t# Upgrading to Carddav Roundcube plugin to version 3+, it requires the carddav_*\n        # tables to be dropped.\n        # Checking that the roundcube database already exists.\n        if os.path.exists(os.path.join(env[\"STORAGE_ROOT\"], \"mail/roundcube/roundcube.sqlite\")):\n            import sqlite3\n            conn = sqlite3.connect(os.path.join(env[\"STORAGE_ROOT\"], \"mail/roundcube/roundcube.sqlite\"))\n            c = conn.cursor()\n            # Get a list of all the tables that begin with 'carddav_'\n            c.execute(\"SELECT name FROM sqlite_master WHERE type = ? AND name LIKE ?\", ('table', 'carddav_%'))\n            carddav_tables = c.fetchall()\n            # If there were tables that begin with 'carddav_', drop them\n            if carddav_tables:\n                for table in carddav_tables:\n                    try:\n                        table = table[0]\n                        c = conn.cursor()\n                        dropcmd = f\"DROP TABLE {table}\"\n                        c.execute(dropcmd)\n                    except:\n                        print(\"Failed to drop table\", table)\n            # Save.\n            conn.commit()\n            conn.close()\n\n            # Delete all sessions, requiring users to login again to recreate carddav_*\n            # databases\n            conn = sqlite3.connect(os.path.join(env[\"STORAGE_ROOT\"], \"mail/roundcube/roundcube.sqlite\"))\n            c = conn.cursor()\n            c.execute(\"delete from session;\")\n            conn.commit()\n            conn.close()\n\ndef migration_13(env):\n\t# Add the \"mfa\" table for configuring MFA for login to the control panel.\n\tdb = os.path.join(env[\"STORAGE_ROOT\"], 'mail/users.sqlite')\n\tshell(\"check_call\", [\"sqlite3\", db, \"CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);\"])\n\ndef migration_14(env):\n\t# Add the \"auto_aliases\" table.\n\tdb = os.path.join(env[\"STORAGE_ROOT\"], 'mail/users.sqlite')\n\tshell(\"check_call\", [\"sqlite3\", db, \"CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);\"])\n\ndef migration_15(env):\n\t# Add a column to the users table to store their quota limit.  Default to '0' for unlimited.\n\tdb = os.path.join(env[\"STORAGE_ROOT\"], 'mail/users.sqlite')\n\tshell(\"check_call\", [\"sqlite3\", db, \"ALTER TABLE users ADD COLUMN quota TEXT NOT NULL DEFAULT '0';\"])\n\n\n###########################################################\n\ndef get_current_migration():\n\tver = 0\n\twhile True:\n\t\tnext_ver = (ver + 1)\n\t\tmigration_func = globals().get(\"migration_%d\" % next_ver)\n\t\tif not migration_func:\n\t\t\treturn ver\n\t\tver = next_ver\n\ndef run_migrations():\n\tif not os.access(\"/etc/mailinabox.conf\", os.W_OK, effective_ids=True):\n\t\tprint(\"This script must be run as root.\", file=sys.stderr)\n\t\tsys.exit(1)\n\n\tenv = load_environment()\n\n\tmigration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version')\n\tmigration_id = None\n\tif os.path.exists(migration_id_file):\n\t\twith open(migration_id_file, encoding='utf-8') as f:\n\t\t\tmigration_id = f.read().strip()\n\n\tif migration_id is None:\n\t\t# Load the legacy location of the migration ID. We'll drop support\n\t\t# for this eventually.\n\t\tmigration_id = env.get(\"MIGRATIONID\")\n\n\tif migration_id is None:\n\t\tprint()\n\t\tprint(f\"{migration_id_file} file doesn't exists. Skipping migration...\")\n\t\treturn\n\n\tourver = int(migration_id)\n\n\twhile True:\n\t\tnext_ver = (ourver + 1)\n\t\tmigration_func = globals().get(\"migration_%d\" % next_ver)\n\n\t\tif not migration_func:\n\t\t\t# No more migrations to run.\n\t\t\tbreak\n\n\t\tprint()\n\t\tprint(\"Running migration to Mail-in-a-Box #%d...\" % next_ver)\n\n\t\ttry:\n\t\t\tmigration_func(env)\n\t\texcept Exception as e:\n\t\t\tprint()\n\t\t\tprint(\"Error running the migration script:\")\n\t\t\tprint()\n\t\t\tprint(e)\n\t\t\tprint()\n\t\t\tprint(\"Your system may be in an inconsistent state now. We're terribly sorry. A re-install from a backup might be the best way to continue.\")\n\t\t\tsys.exit(1)\n\n\t\tourver = next_ver\n\n\t\t# Write out our current version now. Do this sooner rather than later\n\t\t# in case of any problems.\n\t\twith open(migration_id_file, \"w\", encoding='utf-8') as f:\n\t\t\tf.write(str(ourver) + \"\\n\")\n\n\t\t# Delete the legacy location of this field.\n\t\tif \"MIGRATIONID\" in env:\n\t\t\tdel env[\"MIGRATIONID\"]\n\t\t\tsave_environment(env)\n\n\t\t# iterate and try next version...\n\nif __name__ == \"__main__\":\n\tif sys.argv[-1] == \"--current\":\n\t\t# Return the number of the highest migration.\n\t\tprint(str(get_current_migration()))\n\telif sys.argv[-1] == \"--migrate\":\n\t\t# Perform migrations.\n\t\trun_migrations()\n\n"
  },
  {
    "path": "setup/munin.sh",
    "content": "#!/bin/bash\n# Munin: resource monitoring tool\n#################################################\n\nsource setup/functions.sh # load our functions\nsource /etc/mailinabox.conf # load global vars\n\n# install Munin\necho \"Installing Munin (system monitoring)...\"\napt_install munin munin-node libcgi-fast-perl\n# libcgi-fast-perl is needed by /usr/lib/munin/cgi/munin-cgi-graph\n\n# edit config\ncat > /etc/munin/munin.conf <<EOF;\ndbdir /var/lib/munin\nhtmldir /var/cache/munin/www\nlogdir /var/log/munin\nrundir /var/run/munin\ntmpldir /etc/munin/templates\n\nincludedir /etc/munin/munin-conf.d\n\n# path dynazoom uses for requests\ncgiurl_graph /admin/munin/cgi-graph\n\n# a simple host tree\n[$PRIMARY_HOSTNAME]\naddress 127.0.0.1\n\n# send alerts to the following address\ncontacts admin\ncontact.admin.command mail -s \"Munin notification \\${var:host}\" administrator@$PRIMARY_HOSTNAME\ncontact.admin.always_send warning critical\nEOF\n\n# The Debian installer touches these files and chowns them to www-data:adm for use with spawn-fcgi\nchown munin /var/log/munin/munin-cgi-html.log\nchown munin /var/log/munin/munin-cgi-graph.log\n\n# ensure munin-node knows the name of this machine\n# and reduce logging level to warning\ntools/editconf.py /etc/munin/munin-node.conf -s \\\n\thost_name=\"$PRIMARY_HOSTNAME\" \\\n\tlog_level=1\n\n# Update the activated plugins through munin's autoconfiguration.\nmunin-node-configure --shell --remove-also 2>/dev/null | sh || /bin/true\n\n# Deactivate monitoring of NTP peers. Not sure why anyone would want to monitor a NTP peer. The addresses seem to change\n# (which is taken care of my munin-node-configure, but only when we re-run it.)\nfind /etc/munin/plugins/ -lname /usr/share/munin/plugins/ntp_ -print0 | xargs -0 /bin/rm -f\n\n# Deactivate monitoring of network interfaces that are not up. Otherwise we can get a lot of empty charts.\nfor f in $(find /etc/munin/plugins/ \\( -lname /usr/share/munin/plugins/if_ -o -lname /usr/share/munin/plugins/if_err_ -o -lname /usr/share/munin/plugins/bonding_err_ \\)); do\n\tIF=$(echo \"$f\" | sed s/.*_//);\n\tif ! grep -qFx up \"/sys/class/net/$IF/operstate\" 2>/dev/null; then\n\t\trm \"$f\";\n\tfi;\ndone\n\n# Create a 'state' directory. Not sure why we need to do this manually.\nmkdir -p /var/lib/munin-node/plugin-state/\n\n# Create a systemd service for munin.\nln -sf \"$PWD/management/munin_start.sh\" /usr/local/lib/mailinabox/munin_start.sh\nchmod 0744 /usr/local/lib/mailinabox/munin_start.sh\ncp --remove-destination conf/munin.service /lib/systemd/system/munin.service # target was previously a symlink so remove first\nhide_output systemctl link -f /lib/systemd/system/munin.service\nhide_output systemctl daemon-reload\nhide_output systemctl unmask munin.service\nhide_output systemctl enable munin.service\n\n# Restart services.\nrestart_service munin\nrestart_service munin-node\n\n# generate initial statistics so the directory isn't empty\n# (We get \"Pango-WARNING **: error opening config file '/root/.config/pango/pangorc': Permission denied\"\n# if we don't explicitly set the HOME directory when sudo'ing.)\n# We check to see if munin-cron is already running, if it is, there is no need to run it simultaneously\n# generating an error.\nif [ ! -f /var/run/munin/munin-update.lock ]; then\n\tsudo -H -u munin munin-cron\nfi\n"
  },
  {
    "path": "setup/network-checks.sh",
    "content": "#!/bin/bash\n# Install the 'host', 'sed', and and 'nc' tools. This script is run before\n# the rest of the system setup so we may not yet have things installed.\napt_get_quiet install bind9-host sed netcat-openbsd\n\n# Stop if the PRIMARY_HOSTNAME is listed in the Spamhaus Domain Block List.\n# The user might have chosen a name that was previously in use by a spammer\n# and will not be able to reliably send mail. Do this after any automatic\n# choices made above.\nif host \"$PRIMARY_HOSTNAME.dbl.spamhaus.org\" > /dev/null; then\n\techo\n\techo \"The hostname you chose '$PRIMARY_HOSTNAME' is listed in the\"\n\techo \"Spamhaus Domain Block List. See http://www.spamhaus.org/dbl/\"\n\techo \"and http://www.spamhaus.org/query/domain/$PRIMARY_HOSTNAME.\"\n\techo\n\techo \"You will not be able to send mail using this domain name, so\"\n\techo \"setup cannot continue.\"\n\techo\n\texit 1\nfi\n\n# Stop if the IPv4 address is listed in the ZEN Spamhouse Block List.\n# The user might have ended up on an IP address that was previously in use\n# by a spammer, or the user may be deploying on a residential network. We\n# will not be able to reliably send mail in these cases.\nREVERSED_IPV4=$(echo \"$PUBLIC_IP\" | sed \"s/\\([0-9]*\\).\\([0-9]*\\).\\([0-9]*\\).\\([0-9]*\\)/\\4.\\3.\\2.\\1/\")\nif host \"$REVERSED_IPV4.zen.spamhaus.org\" > /dev/null; then\n\techo\n\techo \"The IP address $PUBLIC_IP is listed in the Spamhaus Block List.\"\n\techo \"See http://www.spamhaus.org/query/ip/$PUBLIC_IP.\"\n\techo\n\techo \"You will not be able to send mail using this machine, so setup\"\n\techo \"cannot continue.\"\n\techo\n\techo \"Associate a different IP address with this machine if possible.\"\n\techo \"Many residential network IP addresses are listed, so Mail-in-a-Box\"\n\techo \"typically cannot be used on a residential Internet connection.\"\n\techo\n\texit 1\nfi\n\n# Stop if we cannot make an outbound connection on port 25. Many residential\n# networks block outbound port 25 to prevent their network from sending spam.\n# See if we can reach one of Google's MTAs with a 5-second timeout.\nif ! nc -z -w5 aspmx.l.google.com 25; then\n\techo\n\techo \"Outbound mail (port 25) seems to be blocked by your network.\"\n\techo\n\techo \"You will not be able to send mail using this machine, so setup\"\n\techo \"cannot continue.\"\n\techo\n\techo \"Many residential networks block port 25 to prevent hijacked\"\n\techo \"machines from being able to send spam. I just tried to connect\"\n\techo \"to Google's mail server on port 25 but the connection did not\"\n\techo \"succeed.\"\n\techo\n\texit 1\nfi\n"
  },
  {
    "path": "setup/nextcloud.sh",
    "content": "#!/bin/bash\n# Nextcloud\n##########################\n\nsource setup/functions.sh # load our functions\nsource /etc/mailinabox.conf # load global vars\n\n# ### Installing Nextcloud\n\necho \"Installing Nextcloud (contacts/calendar)...\"\n\n# Nextcloud core and app (plugin) versions to install.\n# With each version we store a hash to ensure we install what we expect.\n\n# Nextcloud core\n# --------------\n# * See https://nextcloud.com/changelog for the latest version.\n# * Check https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html\n#   for whether it supports the version of PHP available on this machine.\n# * Since Nextcloud only supports upgrades from consecutive major versions,\n#   we automatically install intermediate versions as needed.\n# * The hash is the SHA1 hash of the ZIP package, which you can find by just running this script and\n#   copying it from the error message when it doesn't match what is below.\nnextcloud_ver=26.0.13\nnextcloud_hash=d5c10b650e5396d5045131c6d22c02a90572527c\n\n# Nextcloud apps\n# --------------\n# * Find the most recent tag that is compatible with the Nextcloud version above by:\n#   https://github.com/nextcloud-releases/contacts/tags\n#   https://github.com/nextcloud-releases/calendar/tags\n#   https://github.com/nextcloud/user_external/tags\n#\n# * For these three packages, contact, calendar and user_external, the hash is the SHA1 hash of\n# the ZIP package, which you can find by just running this script and copying it from\n# the error message when it doesn't match what is below:\n\n# Always ensure the versions are supported, see https://apps.nextcloud.com/apps/contacts\ncontacts_ver=5.5.3\ncontacts_hash=799550f38e46764d90fa32ca1a6535dccd8316e5\n\n# Always ensure the versions are supported, see https://apps.nextcloud.com/apps/calendar\ncalendar_ver=4.7.6\ncalendar_hash=a995bca4effeecb2cab25f3bbeac9bfe05fee766\n\n# Always ensure the versions are supported, see https://apps.nextcloud.com/apps/user_external\nuser_external_ver=3.3.0\nuser_external_hash=280d24eb2a6cb56b4590af8847f925c28d8d853e\n\n# Developer advice (test plan)\n# ----------------------------\n# When upgrading above versions, how to test?\n#\n# 1. Enter your server instance (or on the Vagrant image)\n# 1. Git clone <your fork>\n# 2. Git checkout <your fork>\n# 3. Run `sudo ./setup/nextcloud.sh`\n# 4. Ensure the installation completes. If any hashes mismatch, correct them.\n# 5. Enter nextcloud web, run following tests:\n# 5.1 You still can create, edit and delete contacts\n# 5.2 You still can create, edit and delete calendar events\n# 5.3 You still can create, edit and delete users\n# 5.4 Go to Administration > Logs and ensure no new errors are shown\n\n# Clear prior packages and install dependencies from apt.\napt-get purge -qq -y owncloud* # we used to use the package manager\n\napt_install curl php\"${PHP_VER}\" php\"${PHP_VER}\"-fpm \\\n\tphp\"${PHP_VER}\"-cli php\"${PHP_VER}\"-sqlite3 php\"${PHP_VER}\"-gd php\"${PHP_VER}\"-imap php\"${PHP_VER}\"-curl \\\n\tphp\"${PHP_VER}\"-dev php\"${PHP_VER}\"-gd php\"${PHP_VER}\"-xml php\"${PHP_VER}\"-mbstring php\"${PHP_VER}\"-zip php\"${PHP_VER}\"-apcu \\\n\tphp\"${PHP_VER}\"-intl php\"${PHP_VER}\"-imagick php\"${PHP_VER}\"-gmp php\"${PHP_VER}\"-bcmath\n\n# Enable APC before Nextcloud tools are run.\ntools/editconf.py /etc/php/\"$PHP_VER\"/mods-available/apcu.ini -c ';' \\\n\tapc.enabled=1 \\\n\tapc.enable_cli=1\n\nInstallNextcloud() {\n\n\tversion=$1\n\thash=$2\n\tversion_contacts=$3\n\thash_contacts=$4\n\tversion_calendar=$5\n\thash_calendar=$6\n\tversion_user_external=${7:-}\n\thash_user_external=${8:-}\n\n\techo\n\techo \"Upgrading to Nextcloud version $version\"\n\techo\n\n\t# Download and verify\n\twget_verify \"https://download.nextcloud.com/server/releases/nextcloud-$version.zip\" \"$hash\" /tmp/nextcloud.zip\n\n\t# Remove the current owncloud/Nextcloud\n\trm -rf /usr/local/lib/owncloud\n\n\t# Extract ownCloud/Nextcloud\n\tunzip -q /tmp/nextcloud.zip -d /usr/local/lib\n\tmv /usr/local/lib/nextcloud /usr/local/lib/owncloud\n\trm -f /tmp/nextcloud.zip\n\n\t# The two apps we actually want are not in Nextcloud core. Download the releases from\n\t# their github repositories.\n\tmkdir -p /usr/local/lib/owncloud/apps\n\n\twget_verify \"https://github.com/nextcloud-releases/contacts/archive/refs/tags/v$version_contacts.tar.gz\" \"$hash_contacts\" /tmp/contacts.tgz\n\ttar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/\n\trm /tmp/contacts.tgz\n\n\twget_verify \"https://github.com/nextcloud-releases/calendar/archive/refs/tags/v$version_calendar.tar.gz\" \"$hash_calendar\" /tmp/calendar.tgz\n\ttar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/\n\trm /tmp/calendar.tgz\n\n\t# Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core,\n\t# we will install from their github repository.\n\tif [ -n \"$version_user_external\" ]; then\n\t\twget_verify \"https://github.com/nextcloud-releases/user_external/releases/download/v$version_user_external/user_external-v$version_user_external.tar.gz\" \"$hash_user_external\" /tmp/user_external.tgz\n\t\ttar -xf /tmp/user_external.tgz -C /usr/local/lib/owncloud/apps/\n\t\trm /tmp/user_external.tgz\n\tfi\n\n\t# Fix weird permissions.\n\tchmod 750 /usr/local/lib/owncloud/{apps,config}\n\n\t# Create a symlink to the config.php in STORAGE_ROOT (for upgrades we're restoring the symlink we previously\n\t# put in, and in new installs we're creating a symlink and will create the actual config later).\n\tln -sf \"$STORAGE_ROOT/owncloud/config.php\" /usr/local/lib/owncloud/config/config.php\n\n\t# Make sure permissions are correct or the upgrade step won't run.\n\t# $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress\n\t# that error.\n\tchown -f -R www-data:www-data \"$STORAGE_ROOT/owncloud\" /usr/local/lib/owncloud || /bin/true\n\n\t# If this isn't a new installation, immediately run the upgrade script.\n\t# Then check for success (0=ok and 3=no upgrade needed, both are success).\n\tif [ -e \"$STORAGE_ROOT/owncloud/owncloud.db\" ]; then\n\t\t# ownCloud 8.1.1 broke upgrades. It may fail on the first attempt, but\n\t\t# that can be OK.\n\t\tsudo -u www-data php\"$PHP_VER\" /usr/local/lib/owncloud/occ upgrade\n\t\tE=$?\n\t\tif [ $E -ne 0 ] && [ $E -ne 3 ]; then\n\t\t\techo \"Trying ownCloud upgrade again to work around ownCloud upgrade bug...\"\n\t\t\tsudo -u www-data php\"$PHP_VER\" /usr/local/lib/owncloud/occ upgrade\n\t\t\tE=$?\n\t\t\tif [ $E -ne 0 ] && [ $E -ne 3 ]; then exit 1; fi\n\t\t\tsudo -u www-data php\"$PHP_VER\" /usr/local/lib/owncloud/occ maintenance:mode --off\n\t\t\techo \"...which seemed to work.\"\n\t\tfi\n\n\t\t# Add missing indices. NextCloud didn't include this in the normal upgrade because it might take some time.\n\t\tsudo -u www-data php\"$PHP_VER\" /usr/local/lib/owncloud/occ db:add-missing-indices\n\t\tsudo -u www-data php\"$PHP_VER\" /usr/local/lib/owncloud/occ db:add-missing-primary-keys\n\n\t\t# Run conversion to BigInt identifiers, this process may take some time on large tables.\n\t\tsudo -u www-data php\"$PHP_VER\" /usr/local/lib/owncloud/occ db:convert-filecache-bigint --no-interaction\n\tfi\n}\n\n# Current Nextcloud Version, #1623\n# Checking /usr/local/lib/owncloud/version.php shows version of the Nextcloud application, not the DB\n# $STORAGE_ROOT/owncloud is kept together even during a backup. It is better to rely on config.php than\n# version.php since the restore procedure can leave the system in a state where you have a newer Nextcloud\n# application version than the database.\n\n# If config.php exists, get version number, otherwise CURRENT_NEXTCLOUD_VER is empty.\nif [ -f \"$STORAGE_ROOT/owncloud/config.php\" ]; then\n\tCURRENT_NEXTCLOUD_VER=$(php\"$PHP_VER\" -r \"include(\\\"$STORAGE_ROOT/owncloud/config.php\\\"); echo(\\$CONFIG['version']);\")\nelse\n\tCURRENT_NEXTCLOUD_VER=\"\"\nfi\n\n# If the Nextcloud directory is missing (never been installed before, or the nextcloud version to be installed is different\n# from the version currently installed, do the install/upgrade\nif [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextcloud_ver ]]; then\n\n\t# Stop php-fpm if running. If they are not running (which happens on a previously failed install), dont bail.\n\tservice php\"$PHP_VER\"-fpm stop &> /dev/null || /bin/true\n\n\t# Backup the existing ownCloud/Nextcloud.\n\t# Create a backup directory to store the current installation and database to\n\tBACKUP_DIRECTORY=$STORAGE_ROOT/owncloud-backup/$(date +\"%Y-%m-%d-%T\")\n\tmkdir -p \"$BACKUP_DIRECTORY\"\n\tif [ -d /usr/local/lib/owncloud/ ]; then\n\t\techo \"Upgrading Nextcloud --- backing up existing installation, configuration, and database to directory to $BACKUP_DIRECTORY...\"\n\t\tcp -r /usr/local/lib/owncloud \"$BACKUP_DIRECTORY/owncloud-install\"\n\tfi\n\tif [ -e \"$STORAGE_ROOT/owncloud/owncloud.db\" ]; then\n\t\tcp \"$STORAGE_ROOT/owncloud/owncloud.db\" \"$BACKUP_DIRECTORY\"\n\tfi\n\tif [ -e \"$STORAGE_ROOT/owncloud/config.php\" ]; then\n\t\tcp \"$STORAGE_ROOT/owncloud/config.php\" \"$BACKUP_DIRECTORY\"\n\tfi\n\n\t# If ownCloud or Nextcloud was previously installed....\n\tif [ -n \"${CURRENT_NEXTCLOUD_VER}\" ]; then\n\t\t# Database migrations from ownCloud are no longer possible because ownCloud cannot be run under\n\t\t# PHP 7.\n\n\t\tif [ -e \"$STORAGE_ROOT/owncloud/config.php\" ]; then\n\t\t\t# Remove the read-onlyness of the config, which is needed for migrations, especially for v24\n\t\t\tsed -i -e '/config_is_read_only/d' \"$STORAGE_ROOT/owncloud/config.php\"\n\t\tfi\n\n\t\tif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^[89] ]]; then\n\t\t\techo \"Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 8 or 9) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup will continue, but skip the Nextcloud migration.\"\n\t\t\treturn 0\n\t\telif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^1[012] ]]; then\n\t\t\techo \"Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 10, 11 or 12) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup will continue, but skip the Nextcloud migration.\"\n\t\t\treturn 0\n\t\telif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^1[3456789] ]]; then\n\t\t\techo \"Upgrades from Mail-in-a-Box prior to v60 with Nextcloud 19 or earlier are not supported. Upgrade to the latest Mail-in-a-Box version supported on your machine first. Setup will continue, but skip the Nextcloud migration.\"\n\t\t\treturn 0\n\t\tfi\n\n\t\t# Hint: whenever you bump, remember this:\n\t\t# - Run a server with the previous version\n\t\t# - On a new if-else block, copy the versions/hashes from the previous version\n\t\t# - Run sudo ./setup/start.sh on the new machine. Upon completion, test its basic functionalities.\n\n\t\tif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^20 ]]; then\n\t\t\tInstallNextcloud 21.0.7 f5c7079c5b56ce1e301c6a27c0d975d608bb01c9 4.0.7 45e7cf4bfe99cd8d03625cf9e5a1bb2e90549136 3.0.4 d0284b68135777ec9ca713c307216165b294d0fe\n\t\t\tCURRENT_NEXTCLOUD_VER=\"21.0.7\"\n\t\tfi\n\t\tif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^21 ]]; then\n\t\t\tInstallNextcloud 22.2.6 9d39741f051a8da42ff7df46ceef2653a1dc70d9 4.1.0 697f6b4a664e928d72414ea2731cb2c9d1dc3077 3.2.2 ce4030ab57f523f33d5396c6a81396d440756f5f 3.0.0 0df781b261f55bbde73d8c92da3f99397000972f\n\t\t\tCURRENT_NEXTCLOUD_VER=\"22.2.6\"\n\t\tfi\n\t\tif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^22 ]]; then\n\t\t\tInstallNextcloud 23.0.12 d138641b8e7aabebe69bb3ec7c79a714d122f729 4.1.0 697f6b4a664e928d72414ea2731cb2c9d1dc3077 3.2.2 ce4030ab57f523f33d5396c6a81396d440756f5f 3.0.0 0df781b261f55bbde73d8c92da3f99397000972f\n\t\t\tCURRENT_NEXTCLOUD_VER=\"23.0.12\"\n\t\tfi\n\t\tif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^23 ]]; then\n\t\t\tInstallNextcloud 24.0.12 7aa5d61632c1ccf4ca3ff00fb6b295d318c05599 4.1.0 697f6b4a664e928d72414ea2731cb2c9d1dc3077 3.2.2 ce4030ab57f523f33d5396c6a81396d440756f5f 3.0.0 0df781b261f55bbde73d8c92da3f99397000972f\n\t\t\tCURRENT_NEXTCLOUD_VER=\"24.0.12\"\n\t\tfi\n        if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^24 ]]; then\n\t\t\tInstallNextcloud 25.0.7 a5a565c916355005c7b408dd41a1e53505e1a080 5.3.0 4b0a6666374e3b55cfd2ae9b72e1d458b87d4c8c 4.4.2 21a42e15806adc9b2618760ef94f1797ef399e2f 3.2.0 a494073dcdecbbbc79a9c77f72524ac9994d2eec\n\t\t\tCURRENT_NEXTCLOUD_VER=\"25.0.7\"\n\t\tfi\n\tfi\n\n\tInstallNextcloud $nextcloud_ver $nextcloud_hash $contacts_ver $contacts_hash $calendar_ver $calendar_hash $user_external_ver $user_external_hash\nfi\n\n# ### Configuring Nextcloud\n\n# Setup Nextcloud if the Nextcloud database does not yet exist. Running setup when\n# the database does exist wipes the database and user data.\nif [ ! -f \"$STORAGE_ROOT/owncloud/owncloud.db\" ]; then\n\t# Create user data directory\n\tmkdir -p \"$STORAGE_ROOT/owncloud\"\n\n\t# Create an initial configuration file.\n\tinstanceid=oc$(echo \"$PRIMARY_HOSTNAME\" | sha1sum | fold -w 10 | head -n 1)\n\tcat > \"$STORAGE_ROOT/owncloud/config.php\" <<EOF;\n<?php\n\\$CONFIG = array (\n  'datadirectory' => '$STORAGE_ROOT/owncloud',\n\n  'instanceid' => '$instanceid',\n\n  'forcessl' => true, # if unset/false, Nextcloud sends a HSTS=0 header, which conflicts with nginx config\n\n  'overwritewebroot' => '/cloud',\n  'overwrite.cli.url' => '/cloud',\n  'user_backends' => array(\n    array(\n      'class' => '\\OCA\\UserExternal\\IMAP',\n      'arguments' => array(\n        '127.0.0.1', 143, null, null, false, false\n       ),\n    ),\n  ),\n  'memcache.local' => '\\OC\\Memcache\\APCu',\n);\n?>\nEOF\n\n\t# Create an auto-configuration file to fill in database settings\n\t# when the install script is run. Make an administrator account\n\t# here or else the install can't finish.\n\tadminpassword=$(dd if=/dev/urandom bs=1 count=40 2>/dev/null | sha1sum | fold -w 30 | head -n 1)\n\tcat > /usr/local/lib/owncloud/config/autoconfig.php <<EOF;\n<?php\n\\$AUTOCONFIG = array (\n  # storage/database\n  'directory' => '$STORAGE_ROOT/owncloud',\n  'dbtype' => 'sqlite3',\n\n  # create an administrator account with a random password so that\n  # the user does not have to enter anything on first load of Nextcloud\n  'adminlogin'    => 'root',\n  'adminpass'     => '$adminpassword',\n);\n?>\nEOF\n\n\t# Set permissions\n\tchown -R www-data:www-data \"$STORAGE_ROOT/owncloud\" /usr/local/lib/owncloud\n\n\t# Execute Nextcloud's setup step, which creates the Nextcloud sqlite database.\n\t# It also wipes it if it exists. And it updates config.php with database\n\t# settings and deletes the autoconfig.php file.\n\t(cd /usr/local/lib/owncloud || exit; sudo -u www-data php\"$PHP_VER\" /usr/local/lib/owncloud/index.php;)\nfi\n\n# Update config.php.\n# * trusted_domains is reset to localhost by autoconfig starting with ownCloud 8.1.1,\n#   so set it here. It also can change if the box's PRIMARY_HOSTNAME changes, so\n#   this will make sure it has the right value.\n# * Some settings weren't included in previous versions of Mail-in-a-Box.\n# * We need to set the timezone to the system timezone to allow fail2ban to ban\n#   users within the proper timeframe\n# * We need to set the logdateformat to something that will work correctly with fail2ban\n# * mail_domain' needs to be set every time we run the setup. Making sure we are setting\n#   the correct domain name if the domain is being change from the previous setup.\n# Use PHP to read the settings file, modify it, and write out the new settings array.\nTIMEZONE=$(cat /etc/timezone)\nCONFIG_TEMP=$(/bin/mktemp)\nphp\"$PHP_VER\" <<EOF > \"$CONFIG_TEMP\" && mv \"$CONFIG_TEMP\" \"$STORAGE_ROOT/owncloud/config.php\";\n<?php\ninclude(\"$STORAGE_ROOT/owncloud/config.php\");\n\n\\$CONFIG['config_is_read_only'] = false;\n\n\\$CONFIG['trusted_domains'] = array('$PRIMARY_HOSTNAME');\n\n\\$CONFIG['memcache.local'] = '\\OC\\Memcache\\APCu';\n\\$CONFIG['overwrite.cli.url'] = 'https://${PRIMARY_HOSTNAME}/cloud';\n\n\\$CONFIG['logtimezone'] = '$TIMEZONE';\n\\$CONFIG['logdateformat'] = 'Y-m-d H:i:s';\n\n\\$CONFIG['user_backends'] = array(\n  array(\n    'class' => '\\OCA\\UserExternal\\IMAP',\n    'arguments' => array(\n      '127.0.0.1', 143, null, null, false, false\n    ),\n  ),\n);\n\n\\$CONFIG['mail_domain'] = '$PRIMARY_HOSTNAME';\n\\$CONFIG['mail_from_address'] = 'administrator'; # just the local part, matches the required administrator alias on mail_domain/$PRIMARY_HOSTNAME\n\\$CONFIG['mail_smtpmode'] = 'sendmail';\n\\$CONFIG['mail_smtpauth'] = true; # if smtpmode is smtp\n\\$CONFIG['mail_smtphost'] = '127.0.0.1'; # if smtpmode is smtp\n\\$CONFIG['mail_smtpport'] = '587'; # if smtpmode is smtp\n\\$CONFIG['mail_smtpsecure'] = ''; # if smtpmode is smtp, must be empty string\n\\$CONFIG['mail_smtpname'] = ''; # if smtpmode is smtp, set this to a mail user\n\\$CONFIG['mail_smtppassword'] = ''; # if smtpmode is smtp, set this to the user's password\n\necho \"<?php\\n\\\\\\$CONFIG = \";\nvar_export(\\$CONFIG);\necho \";\";\n?>\nEOF\nchown www-data:www-data \"$STORAGE_ROOT/owncloud/config.php\"\n\n# Enable/disable apps. Note that this must be done after the Nextcloud setup.\n# The firstrunwizard gave Josh all sorts of problems, so disabling that.\n# user_external is what allows Nextcloud to use IMAP for login. The contacts\n# and calendar apps are the extensions we really care about here.\nhide_output sudo -u www-data php\"$PHP_VER\" /usr/local/lib/owncloud/console.php app:disable firstrunwizard\nhide_output sudo -u www-data php\"$PHP_VER\" /usr/local/lib/owncloud/console.php app:enable user_external\nhide_output sudo -u www-data php\"$PHP_VER\" /usr/local/lib/owncloud/console.php app:enable contacts\nhide_output sudo -u www-data php\"$PHP_VER\" /usr/local/lib/owncloud/console.php app:enable calendar\n\n# When upgrading, run the upgrade script again now that apps are enabled. It seems like\n# the first upgrade at the top won't work because apps may be disabled during upgrade?\n# Check for success (0=ok, 3=no upgrade needed).\nsudo -u www-data php\"$PHP_VER\" /usr/local/lib/owncloud/occ upgrade\nE=$?\nif [ $E -ne 0 ] && [ $E -ne 3 ]; then exit 1; fi\n\n# Disable default apps that we don't support\nsudo -u www-data \\\n\tphp\"$PHP_VER\" /usr/local/lib/owncloud/occ app:disable photos dashboard activity \\\n\t| (grep -v \"No such app enabled\" || /bin/true)\n\n# Set PHP FPM values to support large file uploads\n# (semicolon is the comment character in this file, hashes produce deprecation warnings)\ntools/editconf.py /etc/php/\"$PHP_VER\"/fpm/php.ini -c ';' \\\n\tupload_max_filesize=16G \\\n\tpost_max_size=16G \\\n\toutput_buffering=16384 \\\n\tmemory_limit=512M \\\n\tmax_execution_time=600 \\\n\tshort_open_tag=On\n\n# Set Nextcloud recommended opcache settings\ntools/editconf.py /etc/php/\"$PHP_VER\"/cli/conf.d/10-opcache.ini -c ';' \\\n\topcache.enable=1 \\\n\topcache.enable_cli=1 \\\n\topcache.interned_strings_buffer=8 \\\n\topcache.max_accelerated_files=10000 \\\n\topcache.memory_consumption=128 \\\n\topcache.save_comments=1 \\\n\topcache.revalidate_freq=1\n\n# Migrate users_external data from <0.6.0 to version 3.0.0\n# (see https://github.com/nextcloud/user_external).\n# This version was probably in use in Mail-in-a-Box v0.41 (February 26, 2019) and earlier.\n# We moved to v0.6.3 in 193763f8. Ignore errors - maybe there are duplicated users with the\n# correct backend already.\nsqlite3 \"$STORAGE_ROOT/owncloud/owncloud.db\" \"UPDATE oc_users_external SET backend='127.0.0.1';\" || /bin/true\n\n# Set up a general cron job for Nextcloud.\n# Also add another job for Calendar updates, per advice in the Nextcloud docs\n# https://docs.nextcloud.com/server/24/admin_manual/groupware/calendar.html#background-jobs\ncat > /etc/cron.d/mailinabox-nextcloud << EOF;\n#!/bin/bash\n# Mail-in-a-Box\n*/5 * * * *\twww-data\tphp$PHP_VER -f /usr/local/lib/owncloud/cron.php\n*/5 * * * *\twww-data\tphp$PHP_VER -f /usr/local/lib/owncloud/occ dav:send-event-reminders\nEOF\nchmod +x /etc/cron.d/mailinabox-nextcloud\n\n# We also need to change the sending mode from background-job to occ.\n# Or else the reminders will just be sent as soon as possible when the background jobs run.\nhide_output sudo -u www-data php\"$PHP_VER\" -f /usr/local/lib/owncloud/occ config:app:set dav sendEventRemindersMode --value occ\n\n# Now set the config to read-only.\n# Do this only at the very bottom when no further occ commands are needed.\nsed -i'' \"s/'config_is_read_only'\\s*=>\\s*false/'config_is_read_only' => true/\" \"$STORAGE_ROOT/owncloud/config.php\"\n\n# Rotate the nextcloud.log file\ncat > /etc/logrotate.d/nextcloud <<EOF\n# Nextcloud logs\n$STORAGE_ROOT/owncloud/nextcloud.log {\n\t\tsize 10M\n\t\tcreate 640 www-data www-data\n\t\trotate 30\n\t\tcopytruncate\n\t\tmissingok\n\t\tcompress\n}\nEOF\n\n# There's nothing much of interest that a user could do as an admin for Nextcloud,\n# and there's a lot they could mess up, so we don't make any users admins of Nextcloud.\n# But if we wanted to, we would do this:\n# ```\n# for user in $(management/cli.py user admins); do\n#\t sqlite3 $STORAGE_ROOT/owncloud/owncloud.db \"INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')\"\n# done\n# ```\n\n# Enable PHP modules and restart PHP.\nrestart_service php\"$PHP_VER\"-fpm\n"
  },
  {
    "path": "setup/preflight.sh",
    "content": "#!/bin/bash\n# Are we running as root?\nif [[ $EUID -ne 0 ]]; then\n\techo \"This script must be run as root. Please re-run like this:\"\n\techo\n\techo \"sudo $0\"\n\techo\n\texit 1\nfi\n\n# Check that we are running on Ubuntu 22.04 LTS (or 22.04.xx).\n# Pull in the variables defined in /etc/os-release but in a\n# namespace to avoid polluting our variables.\nsource <(cat /etc/os-release | sed s/^/OS_RELEASE_/)\nif [ \"${OS_RELEASE_ID:-}\" != \"ubuntu\" ] || [ \"${OS_RELEASE_VERSION_ID:-}\" != \"22.04\" ]; then\n\techo \"Mail-in-a-Box only supports being installed on Ubuntu 22.04, sorry. You are running:\"\n\techo\n\techo \"${OS_RELEASE_ID:-\"Unknown linux distribution\"} ${OS_RELEASE_VERSION_ID:-}\"\n\techo\n\techo \"We can't write scripts that run on every possible setup, sorry.\"\n\texit 1\nfi\n\n# Check that we have enough memory.\n#\n# /proc/meminfo reports free memory in kibibytes. Our baseline will be 512 MB,\n# which is 500000 kibibytes.\n#\n# We will display a warning if the memory is below 768 MB which is 750000 kibibytes\n#\n# Skip the check if we appear to be running inside of Vagrant, because that's really just for testing.\nTOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}')\nif [ \"$TOTAL_PHYSICAL_MEM\" -lt 490000 ]; then\nif [ ! -d /vagrant ]; then\n\tTOTAL_PHYSICAL_MEM=$(( TOTAL_PHYSICAL_MEM * 1024 / 1000 / 1000 ))\n\techo \"Your Mail-in-a-Box needs more memory (RAM) to function properly.\"\n\techo \"Please provision a machine with at least 512 MB, 1 GB recommended.\"\n\techo \"This machine has $TOTAL_PHYSICAL_MEM MB memory.\"\n\texit\nfi\nfi\nif [ \"$TOTAL_PHYSICAL_MEM\" -lt 750000 ]; then\n\techo \"WARNING: Your Mail-in-a-Box has less than 768 MB of memory.\"\n\techo \"         It might run unreliably when under heavy load.\"\nfi\n\n# Check that tempfs is mounted with exec\nMOUNTED_TMP_AS_NO_EXEC=$(grep \"/tmp.*noexec\" /proc/mounts || /bin/true)\nif [ -n \"$MOUNTED_TMP_AS_NO_EXEC\" ]; then\n\techo \"Mail-in-a-Box has to have exec rights on /tmp, please mount /tmp with exec\"\n\texit\nfi\n\n# Check that no .wgetrc exists\nif [ -e ~/.wgetrc ]; then\n\techo \"Mail-in-a-Box expects no overrides to wget defaults, ~/.wgetrc exists\"\n\texit\nfi\n\n# Check that we are running on x86_64 or i686 architecture, which are the only\n# ones we support / test.\nARCHITECTURE=$(uname -m)\nif [ \"$ARCHITECTURE\" != \"x86_64\" ] && [ \"$ARCHITECTURE\" != \"i686\" ]; then\n\techo\n\techo \"WARNING:\"\n\techo \"Mail-in-a-Box has only been tested on x86_64 and i686 platform\"\n\techo \"architectures. Your architecture, $ARCHITECTURE, may not work.\"\n\techo \"You are on your own.\"\n\techo\nfi\n"
  },
  {
    "path": "setup/questions.sh",
    "content": "#!/bin/bash\nif [ -z \"${NONINTERACTIVE:-}\" ]; then\n\t# Install 'dialog' so we can ask the user questions. The original motivation for\n\t# this was being able to ask the user for input even if stdin has been redirected,\n\t# e.g. if we piped a bootstrapping install script to bash to get started. In that\n\t# case, the nifty '[ -t 0 ]' test won't work. But with Vagrant we must suppress so we\n\t# use a shell flag instead. Really suppress any output from installing dialog.\n\t#\n\t# Also install dependencies needed to validate the email address.\n\tif [ ! -f /usr/bin/dialog ] || [ ! -f /usr/bin/python3 ] || [ ! -f /usr/bin/pip3 ]; then\n\t\techo \"Installing packages needed for setup...\"\n\t\tapt-get -q -q update\n\t\tapt_get_quiet install dialog python3 python3-pip  || exit 1\n\tfi\n\n\t# Installing email_validator is repeated in setup/management.sh, but in setup/management.sh\n\t# we install it inside a virtualenv. In this script, we don't have the virtualenv yet\n\t# so we install the python package globally.\n\thide_output pip3 install \"email_validator>=1.0.0\" || exit 1\n\n\tmessage_box \"Mail-in-a-Box Installation\" \\\n\t\t\"Hello and thanks for deploying a Mail-in-a-Box!\n\t\t\\n\\nI'm going to ask you a few questions.\n\t\t\\n\\nTo change your answers later, just run 'sudo mailinabox' from the command line.\n\t\t\\n\\nNOTE: You should only install this on a brand new Ubuntu installation 100% dedicated to Mail-in-a-Box. Mail-in-a-Box will, for example, remove apache2.\"\nfi\n\n# The box needs a name.\nif [ -z \"${PRIMARY_HOSTNAME:-}\" ]; then\n\tif [ -z \"${DEFAULT_PRIMARY_HOSTNAME:-}\" ]; then\n\t\t# We recommend to use box.example.com as this hosts name. The\n\t\t# domain the user possibly wants to use is example.com then.\n\t\t# We strip the string \"box.\" from the hostname to get the mail\n\t\t# domain. If the hostname differs, nothing happens here.\n\t\tDEFAULT_DOMAIN_GUESS=$(get_default_hostname | sed -e 's/^box\\.//')\n\n\t\t# This is the first run. Ask the user for his email address so we can\n\t\t# provide the best default for the box's hostname.\n\t\tinput_box \"Your Email Address\" \\\n\"What email address are you setting this box up to manage?\n\\n\\nThe part after the @-sign must be a domain name or subdomain\nthat you control. You can add other email addresses to this\nbox later (including email addresses on other domain names\nor subdomains you control).\n\\n\\nWe've guessed an email address. Backspace it and type in what\nyou really want.\n\\n\\nEmail Address:\" \\\n\t\t\t\"me@$DEFAULT_DOMAIN_GUESS\" \\\n\t\t\tEMAIL_ADDR\n\n\t\tif [ -z \"$EMAIL_ADDR\" ]; then\n\t\t\t# user hit ESC/cancel\n\t\t\texit\n\t\tfi\n\t\twhile ! python3 management/mailconfig.py validate-email \"$EMAIL_ADDR\"\n\t\tdo\n\t\t\tinput_box \"Your Email Address\" \\\n\t\t\t\t\"That's not a valid email address.\\n\\nWhat email address are you setting this box up to manage?\" \\\n\t\t\t\t\"$EMAIL_ADDR\" \\\n\t\t\t\tEMAIL_ADDR\n\t\t\tif [ -z \"$EMAIL_ADDR\" ]; then\n\t\t\t\t# user hit ESC/cancel\n\t\t\t\texit\n\t\t\tfi\n\t\tdone\n\n\t\t# Take the part after the @-sign as the user's domain name, and add\n\t\t# 'box.' to the beginning to create a default hostname for this machine.\n\t\tDEFAULT_PRIMARY_HOSTNAME=box.$(echo \"$EMAIL_ADDR\" | sed 's/.*@//')\n\tfi\n\n\tinput_box \"Hostname\" \\\n\"This box needs a name, called a 'hostname'. The name will form a part of the box's web address.\n\\n\\nWe recommend that the name be a subdomain of the domain in your email\naddress, so we're suggesting $DEFAULT_PRIMARY_HOSTNAME.\n\\n\\nYou can change it, but we recommend you don't.\n\\n\\nHostname:\" \\\n\t\t\"$DEFAULT_PRIMARY_HOSTNAME\" \\\n\t\tPRIMARY_HOSTNAME\n\n\tif [ -z \"$PRIMARY_HOSTNAME\" ]; then\n\t\t# user hit ESC/cancel\n\t\texit\n\tfi\nfi\n\n# If the machine is behind a NAT, inside a VM, etc., it may not know\n# its IP address on the public network / the Internet. Ask the Internet\n# and possibly confirm with user.\nif [ -z \"${PUBLIC_IP:-}\" ]; then\n\t# Ask the Internet.\n\tGUESSED_IP=$(get_publicip_from_web_service 4)\n\n\t# On the first run, if we got an answer from the Internet then don't\n\t# ask the user.\n\tif [[ -z \"${DEFAULT_PUBLIC_IP:-}\" && -n \"$GUESSED_IP\" ]]; then\n\t\tPUBLIC_IP=$GUESSED_IP\n\n\t# Otherwise on the first run at least provide a default.\n\telif [[ -z \"${DEFAULT_PUBLIC_IP:-}\" ]]; then\n\t\tDEFAULT_PUBLIC_IP=$(get_default_privateip 4)\n\n\t# On later runs, if the previous value matches the guessed value then\n\t# don't ask the user either.\n\telif [ \"${DEFAULT_PUBLIC_IP:-}\" == \"$GUESSED_IP\" ]; then\n\t\tPUBLIC_IP=$GUESSED_IP\n\tfi\n\n\tif [ -z \"${PUBLIC_IP:-}\" ]; then\n\t\tinput_box \"Public IP Address\" \\\n\t\t\t\"Enter the public IP address of this machine, as given to you by your ISP.\n\t\t\t\\n\\nPublic IP address:\" \\\n\t\t\t\"${DEFAULT_PUBLIC_IP:-}\" \\\n\t\t\tPUBLIC_IP\n\n\t\tif [ -z \"$PUBLIC_IP\" ]; then\n\t\t\t# user hit ESC/cancel\n\t\t\texit\n\t\tfi\n\tfi\nfi\n\n# Same for IPv6. But it's optional. Also, if it looks like the system\n# doesn't have an IPv6, don't ask for one.\nif [ -z \"${PUBLIC_IPV6:-}\" ]; then\n\t# Ask the Internet.\n\tGUESSED_IP=$(get_publicip_from_web_service 6)\n\tMATCHED=0\n\tif [[ -z \"${DEFAULT_PUBLIC_IPV6:-}\" && -n \"$GUESSED_IP\" ]]; then\n\t\tPUBLIC_IPV6=$GUESSED_IP\n\telif [[ \"${DEFAULT_PUBLIC_IPV6:-}\" == \"$GUESSED_IP\" ]]; then\n\t\t# No IPv6 entered and machine seems to have none, or what\n\t\t# the user entered matches what the Internet tells us.\n\t\tPUBLIC_IPV6=$GUESSED_IP\n\t\tMATCHED=1\n\telif [[ -z \"${DEFAULT_PUBLIC_IPV6:-}\" ]]; then\n\t\tDEFAULT_PUBLIC_IP=$(get_default_privateip 6)\n\tfi\n\n\tif [[ -z \"${PUBLIC_IPV6:-}\" && $MATCHED == 0 ]]; then\n\t\tinput_box \"IPv6 Address (Optional)\" \\\n\t\t\t\"Enter the public IPv6 address of this machine, as given to you by your ISP.\n\t\t\t\\n\\nLeave blank if the machine does not have an IPv6 address.\n\t\t\t\\n\\nPublic IPv6 address:\" \\\n\t\t\t\"${DEFAULT_PUBLIC_IPV6:-}\" \\\n\t\t\tPUBLIC_IPV6\n\n\t\tif [ ! -n \"$PUBLIC_IPV6_EXITCODE\" ]; then\n\t\t\t# user hit ESC/cancel\n\t\t\texit\n\t\tfi\n\tfi\nfi\n\n# Get the IP addresses of the local network interface(s) that are connected\n# to the Internet. We need these when we want to have services bind only to\n# the public network interfaces (not loopback, not tunnel interfaces).\nif [ -z \"${PRIVATE_IP:-}\" ]; then\n\tPRIVATE_IP=$(get_default_privateip 4)\nfi\nif [ -z \"${PRIVATE_IPV6:-}\" ]; then\n\tPRIVATE_IPV6=$(get_default_privateip 6)\nfi\nif [[ -z \"$PRIVATE_IP\" && -z \"$PRIVATE_IPV6\" ]]; then\n\techo\n\techo \"I could not determine the IP or IPv6 address of the network interface\"\n\techo \"for connecting to the Internet. Setup must stop.\"\n\techo\n\thostname -I\n\troute\n\techo\n\texit\nfi\n\n# Automatic configuration, e.g. as used in our Vagrant configuration.\nif [ \"$PUBLIC_IP\" = \"auto\" ]; then\n\t# Use a public API to get our public IP address, or fall back to local network configuration.\n\tPUBLIC_IP=$(get_publicip_from_web_service 4 || get_default_privateip 4)\nfi\nif [ \"$PUBLIC_IPV6\" = \"auto\" ]; then\n\t# Use a public API to get our public IPv6 address, or fall back to local network configuration.\n\tPUBLIC_IPV6=$(get_publicip_from_web_service 6 || get_default_privateip 6)\nfi\nif [ \"$PRIMARY_HOSTNAME\" = \"auto\" ]; then\n\tPRIMARY_HOSTNAME=$(get_default_hostname)\nfi\n\n# Set STORAGE_USER and STORAGE_ROOT to default values (user-data and /home/user-data), unless\n# we've already got those values from a previous run.\nif [ -z \"${STORAGE_USER:-}\" ]; then\n\tSTORAGE_USER=$([[ -z \"${DEFAULT_STORAGE_USER:-}\" ]] && echo \"user-data\" || echo \"$DEFAULT_STORAGE_USER\")\nfi\nif [ -z \"${STORAGE_ROOT:-}\" ]; then\n\tSTORAGE_ROOT=$([[ -z \"${DEFAULT_STORAGE_ROOT:-}\" ]] && echo \"/home/$STORAGE_USER\" || echo \"$DEFAULT_STORAGE_ROOT\")\nfi\n\n# Show the configuration, since the user may have not entered it manually.\necho\necho \"Primary Hostname: $PRIMARY_HOSTNAME\"\necho \"Public IP Address: $PUBLIC_IP\"\nif [ -n \"$PUBLIC_IPV6\" ]; then\n\techo \"Public IPv6 Address: $PUBLIC_IPV6\"\nfi\nif [ \"$PRIVATE_IP\" != \"$PUBLIC_IP\" ]; then\n\techo \"Private IP Address: $PRIVATE_IP\"\nfi\nif [ \"$PRIVATE_IPV6\" != \"$PUBLIC_IPV6\" ]; then\n\techo \"Private IPv6 Address: $PRIVATE_IPV6\"\nfi\nif [ -f /usr/bin/git ] && [ -d .git ]; then\n\techo \"Mail-in-a-Box Version: $(git describe --always)\"\nfi\necho\n"
  },
  {
    "path": "setup/spamassassin.sh",
    "content": "#!/bin/bash\n# Spam filtering with spamassassin via spampd\n# -------------------------------------------\n#\n# spampd sits between postfix and dovecot. It takes mail from postfix\n# over the LMTP protocol, runs spamassassin on it, and then passes the\n# message over LMTP to dovecot for local delivery.\n#\n# In order to move spam automatically into the Spam folder we use the dovecot sieve\n# plugin.\n\nsource /etc/mailinabox.conf # get global vars\nsource setup/functions.sh # load our functions\n\n# Install packages and basic configuration\n# ----------------------------------------\n\n# Install packages.\n# libmail-dkim-perl is needed to make the spamassassin DKIM module work.\n# For more information see Debian Bug #689414:\n# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=689414\necho \"Installing SpamAssassin...\"\napt_install spampd razor pyzor dovecot-antispam libmail-dkim-perl\n\n# Allow spamassassin to download new rules.\ntools/editconf.py /etc/default/spamassassin \\\n\tCRON=1\n\n# Configure pyzor, which is a client to a live database of hashes of\n# spam emails. Set the pyzor configuration directory to something sane.\n# The default is ~/.pyzor. We used to use that, so we'll kill that old\n# directory. Then write the public pyzor server to its servers file.\n# That will prevent an automatic download on first use, and also means\n# we can skip 'pyzor discover', both of which are currently broken by\n# something happening on Sourceforge (#496).\nrm -rf ~/.pyzor\ntools/editconf.py /etc/spamassassin/local.cf -s \\\n\tpyzor_options=\"--homedir /etc/spamassassin/pyzor\"\nmkdir -p /etc/spamassassin/pyzor\necho \"public.pyzor.org:24441\" > /etc/spamassassin/pyzor/servers\n# check with: pyzor --homedir /etc/mail/spamassassin/pyzor ping\n\n# Configure spampd:\n# * Pass messages on to docevot on port 10026. This is actually the default setting but we don't\n#   want to lose track of it. (We've configured Dovecot to listen on this port elsewhere.)\n# * Increase the maximum message size of scanned messages from the default of 64KB to 500KB, which\n#   is Spamassassin (spamc)'s own default. Specified in KBytes.\n# * Disable localmode so Pyzor, DKIM and DNS checks can be used.\ntools/editconf.py /etc/default/spampd \\\n\tDESTPORT=10026 \\\n\tADDOPTS=\"\\\"--maxsize=2000\\\"\" \\\n\tLOCALONLY=0\n\n# Spamassassin normally wraps spam as an attachment inside a fresh\n# email with a report about the message. This also protects the user\n# from accidentally opening a message with embedded malware.\n#\n# It's nice to see what rules caused the message to be marked as spam,\n# but it's also annoying to get to the original message when it is an\n# attachment, modern mail clients are safer now and don't load remote\n# content or execute scripts, and it is probably confusing to most users.\n#\n# Tell Spamassassin not to modify the original message except for adding\n# the X-Spam-Status & X-Spam-Score mail headers and related headers.\ntools/editconf.py /etc/spamassassin/local.cf -s \\\n\treport_safe=0 \\\n\t\"add_header all Report\"=_REPORT_ \\\n\t\"add_header all Score\"=_SCORE_\n\n\n# Authentication-Results SPF/Dmarc checks\n# ---------------------------------------\n# OpenDKIM and OpenDMARC are configured to validate and add \"Authentication-Results: ...\"\n# headers by checking the sender's SPF & DMARC policies. Instead of blocking mail that fails\n# these checks, we can use these headers to evaluate the mail as spam.\n#\n# Our custom rules are added to their own file so that an update to the deb package config\n# does not remove our changes.\n#\n# We need to escape period's in $PRIMARY_HOSTNAME since spamassassin config uses regex.\n\nescapedprimaryhostname=\"${PRIMARY_HOSTNAME//./\\\\.}\"\n\ncat > /etc/spamassassin/miab_spf_dmarc.cf << EOF\n# Evaluate DMARC Authentication-Results\nheader DMARC_PASS Authentication-Results =~ /$escapedprimaryhostname; dmarc=pass/\ndescribe DMARC_PASS DMARC check passed\nscore DMARC_PASS -0.1\n\nheader DMARC_NONE Authentication-Results =~ /$escapedprimaryhostname; dmarc=none/\ndescribe DMARC_NONE DMARC record not found\nscore DMARC_NONE 0.1\n\nheader DMARC_FAIL_NONE Authentication-Results =~ /$escapedprimaryhostname; dmarc=fail \\(p=none/\ndescribe DMARC_FAIL_NONE DMARC check failed (p=none)\nscore DMARC_FAIL_NONE 2.0\n\nheader DMARC_FAIL_QUARANTINE Authentication-Results =~ /$escapedprimaryhostname; dmarc=fail \\(p=quarantine/\ndescribe DMARC_FAIL_QUARANTINE DMARC check failed (p=quarantine)\nscore DMARC_FAIL_QUARANTINE 5.0\n\nheader DMARC_FAIL_REJECT Authentication-Results =~ /$escapedprimaryhostname; dmarc=fail \\(p=reject/\ndescribe DMARC_FAIL_REJECT DMARC check failed (p=reject)\nscore DMARC_FAIL_REJECT 10.0\n\n# Evaluate SPF Authentication-Results\nheader SPF_PASS Authentication-Results =~ /$escapedprimaryhostname; spf=pass/\ndescribe SPF_PASS SPF check passed\nscore SPF_PASS -0.1\n\nheader SPF_NONE Authentication-Results =~ /$escapedprimaryhostname; spf=none/\ndescribe SPF_NONE SPF record not found\nscore SPF_NONE 2.0\n\nheader SPF_FAIL Authentication-Results =~ /$escapedprimaryhostname; spf=fail/\ndescribe SPF_FAIL SPF check failed\nscore SPF_FAIL 5.0\nEOF\n\n# Bayesean learning\n# -----------------\n#\n# Spamassassin can learn from mail marked as spam or ham, but it needs to be\n# configured. We'll store the learning data in our storage area.\n#\n# These files must be:\n#\n# * Writable by sa-learn-pipe script below, which run as the 'mail' user, for manual tagging of mail as spam/ham.\n# * Readable by the spampd process ('spampd' user) during mail filtering.\n# * Writable by the debian-spamd user, which runs /etc/cron.daily/spamassassin.\n#\n# We'll have these files owned by spampd and grant access to the other two processes.\n#\n# Spamassassin will change the access rights back to the defaults, so we must also configure\n# the filemode in the config file.\n\ntools/editconf.py /etc/spamassassin/local.cf -s \\\n\tbayes_path=\"$STORAGE_ROOT/mail/spamassassin/bayes\" \\\n\tbayes_file_mode=0666\n\nmkdir -p \"$STORAGE_ROOT/mail/spamassassin\"\nchown -R spampd:spampd \"$STORAGE_ROOT/mail/spamassassin\"\n\n# To mark mail as spam or ham, just drag it in or out of the Spam folder. We'll\n# use the Dovecot antispam plugin to detect the message move operation and execute\n# a shell script that invokes learning.\n\n# Enable the Dovecot antispam plugin.\n# (Be careful if we use multiple plugins later.) #NODOC\nsed -i \"s/#mail_plugins = .*/mail_plugins = \\$mail_plugins antispam/\" /etc/dovecot/conf.d/20-imap.conf\nsed -i \"s/#mail_plugins = .*/mail_plugins = \\$mail_plugins antispam/\" /etc/dovecot/conf.d/20-pop3.conf\n\n# Configure the antispam plugin to call sa-learn-pipe.sh.\ncat > /etc/dovecot/conf.d/99-local-spampd.conf << EOF;\nplugin {\n    antispam_backend = pipe\n    antispam_spam_pattern_ignorecase = SPAM\n    antispam_trash_pattern_ignorecase = trash;Deleted *\n    antispam_allow_append_to_spam = yes\n    antispam_pipe_program_spam_args = /usr/local/bin/sa-learn-pipe.sh;--spam\n    antispam_pipe_program_notspam_args = /usr/local/bin/sa-learn-pipe.sh;--ham\n    antispam_pipe_program = /bin/bash\n}\nEOF\n\n# Have Dovecot run its mail process with a supplementary group (the spampd group)\n# so that it can access the learning files.\n\ntools/editconf.py /etc/dovecot/conf.d/10-mail.conf \\\n\tmail_access_groups=spampd\n\n# Here's the script that the antispam plugin executes. It spools the message into\n# a temporary file and then runs sa-learn on it.\n# from http://wiki2.dovecot.org/Plugins/Antispam\nrm -f /usr/bin/sa-learn-pipe.sh # legacy location #NODOC\ncat > /usr/local/bin/sa-learn-pipe.sh << EOF;\ncat<&0 >> /tmp/sendmail-msg-\\$\\$.txt\n/usr/bin/sa-learn \\$* /tmp/sendmail-msg-\\$\\$.txt > /dev/null\nrm -f /tmp/sendmail-msg-\\$\\$.txt\nexit 0\nEOF\nchmod a+x /usr/local/bin/sa-learn-pipe.sh\n\n# Create empty bayes training data (if it doesn't exist). Once the files exist,\n# ensure they are group-writable so that the Dovecot process has access.\nsudo -u spampd /usr/bin/sa-learn --sync 2>/dev/null\nchmod -R 660 \"$STORAGE_ROOT/mail/spamassassin\"\nchmod 770 \"$STORAGE_ROOT/mail/spamassassin\"\n\n# Initial training?\n# sa-learn --ham storage/mail/mailboxes/*/*/cur/\n# sa-learn --spam storage/mail/mailboxes/*/*/.Spam/cur/\n\n# Kick services.\nrestart_service spampd\nrestart_service dovecot\n\n"
  },
  {
    "path": "setup/ssl.sh",
    "content": "#!/bin/bash\n#\n# RSA private key, SSL certificate, Diffie-Hellman bits files\n# -------------------------------------------\n\n# Create an RSA private key, a self-signed SSL certificate, and some\n# Diffie-Hellman cipher bits, if they have not yet been created.\n#\n# The RSA private key and certificate are used for:\n#\n#  * DNSSEC DANE TLSA records\n#  * IMAP\n#  * SMTP (opportunistic TLS for port 25 and submission on ports 465/587)\n#  * HTTPS\n#\n# The certificate is created with its CN set to the PRIMARY_HOSTNAME. It is\n# also used for other domains served over HTTPS until the user installs a\n# better certificate for those domains.\n#\n# The Diffie-Hellman cipher bits are used for SMTP and HTTPS, when a\n# Diffie-Hellman cipher is selected during TLS negotiation. Diffie-Hellman\n# provides Perfect Forward Secrecy.\n\nsource setup/functions.sh # load our functions\nsource /etc/mailinabox.conf # load global vars\n\n# Show a status line if we are going to take any action in this file.\nif  [ ! -f /usr/bin/openssl ] \\\n || [ ! -f \"$STORAGE_ROOT/ssl/ssl_private_key.pem\" ] \\\n || [ ! -f \"$STORAGE_ROOT/ssl/ssl_certificate.pem\" ] \\\n || [ ! -f \"$STORAGE_ROOT/ssl/dh2048.pem\" ]; then\n\techo \"Creating initial SSL certificate and perfect forward secrecy Diffie-Hellman parameters...\"\nfi\n\n# Install openssl.\n\napt_install openssl\n\n# Create a directory to store TLS-related things like \"SSL\" certificates.\n\nmkdir -p \"$STORAGE_ROOT/ssl\"\n\n# Generate a new private key.\n#\n# The key is only as good as the entropy available to openssl so that it\n# can generate a random key. \"OpenSSL’s built-in RSA key generator ....\n# is seeded on first use with (on Linux) 32 bytes read from /dev/urandom,\n# the process ID, user ID, and the current time in seconds. [During key\n# generation OpenSSL] mixes into the entropy pool the current time in seconds,\n# the process ID, and the possibly uninitialized contents of a ... buffer\n# ... dozens to hundreds of times.\"\n#\n# A perfect storm of issues can cause the generated key to be not very random:\n#\n#   * improperly seeded /dev/urandom, but see system.sh for how we mitigate this\n#   * the user ID of this process is always the same (we're root), so that seed is useless\n#   * zero'd memory (plausible on embedded systems, cloud VMs?)\n#   * a predictable process ID (likely on an embedded/virtualized system)\n#   * a system clock reset to a fixed time on boot\n#\n# Since we properly seed /dev/urandom in system.sh we should be fine, but I leave\n# in the rest of the notes in case that ever changes.\nif [ ! -f \"$STORAGE_ROOT/ssl/ssl_private_key.pem\" ]; then\n\t# Set the umask so the key file is never world-readable.\n\t(umask 077; hide_output \\\n\t\topenssl genrsa -out \"$STORAGE_ROOT/ssl/ssl_private_key.pem\" 2048)\nfi\n\n# Generate a self-signed SSL certificate because things like nginx, dovecot,\n# etc. won't even start without some certificate in place, and we need nginx\n# so we can offer the user a control panel to install a better certificate.\nif [ ! -f \"$STORAGE_ROOT/ssl/ssl_certificate.pem\" ]; then\n\t# Generate a certificate signing request.\n\tCSR=/tmp/ssl_cert_sign_req-$$.csr\n\thide_output \\\n\topenssl req -new -key \"$STORAGE_ROOT/ssl/ssl_private_key.pem\" -out $CSR \\\n\t  -sha256 -subj \"/CN=$PRIMARY_HOSTNAME\"\n\n\t# Generate the self-signed certificate.\n\tCERT=$STORAGE_ROOT/ssl/$PRIMARY_HOSTNAME-selfsigned-$(date --rfc-3339=date | sed s/-//g).pem\n\thide_output \\\n\topenssl x509 -req -days 365 \\\n\t  -in $CSR -signkey \"$STORAGE_ROOT/ssl/ssl_private_key.pem\" -out \"$CERT\"\n\n\t# Delete the certificate signing request because it has no other purpose.\n\trm -f $CSR\n\n\t# Symlink the certificate into the system certificate path, so system services\n\t# can find it.\n\tln -s \"$CERT\" \"$STORAGE_ROOT/ssl/ssl_certificate.pem\"\nfi\n\n# Generate some Diffie-Hellman cipher bits.\n# openssl's default bit length for this is 1024 bits, but we'll create\n# 2048 bits of bits per the latest recommendations.\nif [ ! -f \"$STORAGE_ROOT/ssl/dh2048.pem\" ]; then\n\topenssl dhparam -out \"$STORAGE_ROOT/ssl/dh2048.pem\" 2048\nfi\n\n# Cleanup expired SSL certificates from $STORAGE_ROOT/ssl daily\ncat > /etc/cron.daily/mailinabox-ssl-cleanup << EOF;\n#!/bin/bash\n# Mail-in-a-Box\n# Cleanup expired SSL certificates\n$(pwd)/tools/ssl_cleanup\nEOF\nchmod +x /etc/cron.daily/mailinabox-ssl-cleanup\n"
  },
  {
    "path": "setup/start.sh",
    "content": "#!/bin/bash\n# This is the entry point for configuring the system.\n#####################################################\n\nsource setup/functions.sh # load our functions\n\n# Check system setup: Are we running as root on Ubuntu 18.04 on a\n# machine with enough memory? Is /tmp mounted with exec.\n# If not, this shows an error and exits.\nsource setup/preflight.sh\n\n# Ensure Python reads/writes files in UTF-8. If the machine\n# triggers some other locale in Python, like ASCII encoding,\n# Python may not be able to read/write files. This is also\n# in the management daemon startup script and the cron script.\n\nif ! locale -a | grep en_US.utf8 > /dev/null; then\n    # Generate locale if not exists\n    hide_output locale-gen en_US.UTF-8\nfi\n\nexport LANGUAGE=en_US.UTF-8\nexport LC_ALL=en_US.UTF-8\nexport LANG=en_US.UTF-8\nexport LC_TYPE=en_US.UTF-8\n\n# Fix so line drawing characters are shown correctly in Putty on Windows. See #744.\nexport NCURSES_NO_UTF8_ACS=1\n\n# Recall the last settings used if we're running this a second time.\nif [ -f /etc/mailinabox.conf ]; then\n\t# Run any system migrations before proceeding. Since this is a second run,\n\t# we assume we have Python already installed.\n\tsetup/migrate.py --migrate || exit 1\n\n\t# Load the old .conf file to get existing configuration options loaded\n\t# into variables with a DEFAULT_ prefix.\n\tcat /etc/mailinabox.conf | sed s/^/DEFAULT_/ > /tmp/mailinabox.prev.conf\n\tsource /tmp/mailinabox.prev.conf\n\trm -f /tmp/mailinabox.prev.conf\nelse\n\tFIRST_TIME_SETUP=1\nfi\n\n# Put a start script in a global location. We tell the user to run 'mailinabox'\n# in the first dialog prompt, so we should do this before that starts.\ncat > /usr/local/bin/mailinabox << EOF;\n#!/bin/bash\ncd $PWD\nsource setup/start.sh\nEOF\nchmod +x /usr/local/bin/mailinabox\n\n# Ask the user for the PRIMARY_HOSTNAME, PUBLIC_IP, and PUBLIC_IPV6,\n# if values have not already been set in environment variables. When running\n# non-interactively, be sure to set values for all! Also sets STORAGE_USER and\n# STORAGE_ROOT.\nsource setup/questions.sh\n\n# Run some network checks to make sure setup on this machine makes sense.\n# Skip on existing installs since we don't want this to block the ability to\n# upgrade, and these checks are also in the control panel status checks.\nif [ -z \"${DEFAULT_PRIMARY_HOSTNAME:-}\" ]; then\nif [ -z \"${SKIP_NETWORK_CHECKS:-}\" ]; then\n\tsource setup/network-checks.sh\nfi\nfi\n\n# Create the STORAGE_USER and STORAGE_ROOT directory if they don't already exist.\n#\n# Set the directory and all of its parent directories' permissions to world\n# readable since it holds files owned by different processes.\n#\n# If the STORAGE_ROOT is missing the mailinabox.version file that lists a\n# migration (schema) number for the files stored there, assume this is a fresh\n# installation to that directory and write the file to contain the current\n# migration number for this version of Mail-in-a-Box.\nif ! id -u \"$STORAGE_USER\" >/dev/null 2>&1; then\n\tuseradd -m \"$STORAGE_USER\"\nfi\nif [ ! -d \"$STORAGE_ROOT\" ]; then\n\tmkdir -p \"$STORAGE_ROOT\"\nfi\nf=$STORAGE_ROOT\nwhile [[ $f != / ]]; do chmod a+rx \"$f\"; f=$(dirname \"$f\"); done;\nif [ ! -f \"$STORAGE_ROOT/mailinabox.version\" ]; then\n\tsetup/migrate.py --current > \"$STORAGE_ROOT/mailinabox.version\"\n\tchown \"$STORAGE_USER:$STORAGE_USER\" \"$STORAGE_ROOT/mailinabox.version\"\nfi\n\n# Save the global options in /etc/mailinabox.conf so that standalone\n# tools know where to look for data. The default MTA_STS_MODE setting\n# is blank unless set by an environment variable, but see web.sh for\n# how that is interpreted.\ncat > /etc/mailinabox.conf << EOF;\nSTORAGE_USER=$STORAGE_USER\nSTORAGE_ROOT=$STORAGE_ROOT\nPRIMARY_HOSTNAME=$PRIMARY_HOSTNAME\nPUBLIC_IP=$PUBLIC_IP\nPUBLIC_IPV6=$PUBLIC_IPV6\nPRIVATE_IP=$PRIVATE_IP\nPRIVATE_IPV6=$PRIVATE_IPV6\nMTA_STS_MODE=${DEFAULT_MTA_STS_MODE:-enforce}\nEOF\n\n# Start service configuration.\nsource setup/system.sh\nsource setup/ssl.sh\nsource setup/dns.sh\nsource setup/mail-postfix.sh\nsource setup/mail-dovecot.sh\nsource setup/mail-users.sh\nsource setup/dkim.sh\nsource setup/spamassassin.sh\nsource setup/web.sh\nsource setup/webmail.sh\nsource setup/nextcloud.sh\nsource setup/zpush.sh\nsource setup/management.sh\nsource setup/munin.sh\n\n# Wait for the management daemon to start...\nuntil nc -z -w 4 127.0.0.1 10222\ndo\n\techo \"Waiting for the Mail-in-a-Box management daemon to start...\"\n\tsleep 2\ndone\n\n# ...and then have it write the DNS and nginx configuration files and start those\n# services.\ntools/dns_update\ntools/web_update\n\n# Give fail2ban another restart. The log files may not all have been present when\n# fail2ban was first configured, but they should exist now.\nrestart_service fail2ban\n\n# If there aren't any mail users yet, create one.\nsource setup/firstuser.sh\n\n# Register with Let's Encrypt, including agreeing to the Terms of Service.\n# We'd let certbot ask the user interactively, but when this script is\n# run in the recommended curl-pipe-to-bash method there is no TTY and\n# certbot will fail if it tries to ask.\nif [ ! -d \"$STORAGE_ROOT/ssl/lets_encrypt/accounts/acme-v02.api.letsencrypt.org/\" ]; then\necho\necho \"-----------------------------------------------\"\necho \"Mail-in-a-Box uses Let's Encrypt to provision free SSL/TLS certificates\"\necho \"to enable HTTPS connections to your box. We're automatically\"\necho \"agreeing you to their subscriber agreement. See https://letsencrypt.org.\"\necho\ncertbot register --register-unsafely-without-email --agree-tos --config-dir \"$STORAGE_ROOT/ssl/lets_encrypt\"\nfi\n\n# Done.\necho\necho \"-----------------------------------------------\"\necho\necho \"Your Mail-in-a-Box is running.\"\necho\necho \"Please log in to the control panel for further instructions at:\"\necho\nif management/status_checks.py --check-primary-hostname; then\n\t# Show the nice URL if it appears to be resolving and has a valid certificate.\n\techo \"https://$PRIMARY_HOSTNAME/admin\"\n\techo\n\techo \"If you have a DNS problem put the box's IP address in the URL\"\n\techo \"(https://$PUBLIC_IP/admin) but then check the TLS fingerprint:\"\n\topenssl x509 -in \"$STORAGE_ROOT/ssl/ssl_certificate.pem\" -noout -fingerprint -sha256\\\n        \t| sed \"s/SHA256 Fingerprint=//i\"\nelse\n\techo \"https://$PUBLIC_IP/admin\"\n\techo\n\techo \"You will be alerted that the website has an invalid certificate. Check that\"\n\techo \"the certificate fingerprint matches:\"\n\techo\n\topenssl x509 -in \"$STORAGE_ROOT/ssl/ssl_certificate.pem\" -noout -fingerprint -sha256\\\n        \t| sed \"s/SHA256 Fingerprint=//i\"\n\techo\n\techo \"Then you can confirm the security exception and continue.\"\n\techo\nfi\n"
  },
  {
    "path": "setup/system.sh",
    "content": "#!/bin/bash\nsource /etc/mailinabox.conf\nsource setup/functions.sh # load our functions\n\n# Basic System Configuration\n# -------------------------\n\n# ### Set hostname of the box\n\n# If the hostname is not correctly resolvable sudo can't be used. This will result in\n# errors during the install\n#\n# First set the hostname in the configuration file, then activate the setting\n\necho \"$PRIMARY_HOSTNAME\" > /etc/hostname\nhostname \"$PRIMARY_HOSTNAME\"\n\n# ### Fix permissions\n\n# The default Ubuntu Bionic image on Scaleway throws warnings during setup about incorrect\n# permissions (group writeable) set on the following directories.\n\nchmod g-w /etc /etc/default /usr\n\n# ### Add swap space to the system\n\n# If the physical memory of the system is below 2GB it is wise to create a\n# swap file. This will make the system more resiliant to memory spikes and\n# prevent for instance spam filtering from crashing\n\n# We will create a 1G file, this should be a good balance between disk usage\n# and buffers for the system. We will only allocate this file if there is more\n# than 5GB of disk space available\n\n# The following checks are performed:\n# - Check if swap is currently mountend by looking at /proc/swaps\n# - Check if the user intents to activate swap on next boot by checking fstab entries.\n# - Check if a swapfile already exists\n# - Check if the root file system is not btrfs, might be an incompatible version with\n#   swapfiles. User should handle it them selves.\n# - Check the memory requirements\n# - Check available diskspace\n\n# See https://www.digitalocean.com/community/tutorials/how-to-add-swap-on-ubuntu-14-04\n# for reference\n\nSWAP_MOUNTED=$(cat /proc/swaps | tail -n+2)\nSWAP_IN_FSTAB=$(grep \"swap\" /etc/fstab || /bin/true)\nROOT_IS_BTRFS=$(grep \"\\/ .*btrfs\" /proc/mounts || /bin/true)\nTOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}' || /bin/true)\nAVAILABLE_DISK_SPACE=$(df / --output=avail | tail -n 1)\nif\n\t[ -z \"$SWAP_MOUNTED\" ] &&\n\t[ -z \"$SWAP_IN_FSTAB\" ] &&\n\t[ ! -e /swapfile ] &&\n\t[ -z \"$ROOT_IS_BTRFS\" ] &&\n\t[ \"$TOTAL_PHYSICAL_MEM\" -lt 1900000 ] &&\n\t[ \"$AVAILABLE_DISK_SPACE\" -gt 5242880 ]\nthen\n\techo \"Adding a swap file to the system...\"\n\n\t# Allocate and activate the swap file. Allocate in 1KB chunks\n\t# doing it in one go, could fail on low memory systems\n\tdd if=/dev/zero of=/swapfile bs=1024 count=$((1024*1024)) status=none\n\tif [ -e /swapfile ]; then\n\t\tchmod 600 /swapfile\n\t\thide_output mkswap /swapfile\n\t\tswapon /swapfile\n\tfi\n\n\t# Check if swap is mounted then activate on boot\n\tif swapon -s | grep -q \"\\/swapfile\"; then\n\t\techo \"/swapfile   none    swap    sw    0   0\" >> /etc/fstab\n\telse\n\t\techo \"ERROR: Swap allocation failed\"\n\tfi\nfi\n\n# ### Set log retention policy.\n\n# Set the systemd journal log retention from infinite to 10 days,\n# since over time the logs take up a large amount of space.\n# (See https://discourse.mailinabox.email/t/journalctl-reclaim-space-on-small-mailinabox/6728/11.)\ntools/editconf.py /etc/systemd/journald.conf MaxRetentionSec=10day\n\n# ### Improve server privacy\n\n# Disable MOTD adverts to prevent revealing server information in MOTD request headers\n# See https://ma.ttias.be/what-exactly-being-sent-ubuntu-motd/\nif [ -f /etc/default/motd-news ]; then\ntools/editconf.py /etc/default/motd-news ENABLED=0\nrm -f /var/cache/motd-news\nfi\n\n# ### Add PPAs.\n\n# We install some non-standard Ubuntu packages maintained by other\n# third-party providers. First ensure add-apt-repository is installed.\n\nif [ ! -f /usr/bin/add-apt-repository ]; then\n\techo \"Installing add-apt-repository...\"\n\thide_output apt-get update\n\tapt_install software-properties-common\nfi\n\n# Ensure the universe repository is enabled since some of our packages\n# come from there and minimal Ubuntu installs may have it turned off.\nhide_output add-apt-repository -y universe\n\n# Install the duplicity PPA.\nhide_output add-apt-repository -y ppa:duplicity-team/duplicity-release-git\n\n# Stock PHP is now 8.1, but we're transitioning through 8.0 because\n# of Nextcloud.\nhide_output add-apt-repository --y ppa:ondrej/php\n\n# ### Update Packages\n\n# Update system packages to make sure we have the latest upstream versions\n# of things from Ubuntu, as well as the directory of packages provide by the\n# PPAs so we can install those packages later.\n# --allow-releaseinfo-change is added because ppa:ondrej/php changed its Label.\n\necho \"Updating system packages...\"\nhide_output apt-get update --allow-releaseinfo-change\napt_get_quiet upgrade\n\n# Old kernels pile up over time and take up a lot of disk space, and because of Mail-in-a-Box\n# changes there may be other packages that are no longer needed. Clear out anything apt knows\n# is safe to delete.\n\napt_get_quiet autoremove\n\n# ### Install System Packages\n\n# Install basic utilities.\n#\n# * unattended-upgrades: Apt tool to install security updates automatically.\n# * cron: Runs background processes periodically.\n# * ntp: keeps the system time correct\n# * fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall\n# * netcat-openbsd: `nc` command line networking tool\n# * git: we install some things directly from github\n# * sudo: allows privileged users to execute commands as root without being root\n# * coreutils: includes `nproc` tool to report number of processors, mktemp\n# * bc: allows us to do math to compute sane defaults\n# * openssh-client: provides ssh-keygen\n\necho \"Installing system packages...\"\napt_install python3 python3-dev python3-pip python3-setuptools \\\n\tnetcat-openbsd wget curl git sudo coreutils bc file \\\n\tpollinate openssh-client unzip \\\n\tunattended-upgrades cron ntp fail2ban rsyslog\n\n# ### Suppress Upgrade Prompts\n# When Ubuntu 20 comes out, we don't want users to be prompted to upgrade,\n# because we don't yet support it.\nif [ -f /etc/update-manager/release-upgrades ]; then\n\ttools/editconf.py /etc/update-manager/release-upgrades Prompt=never\n\trm -f /var/lib/ubuntu-release-upgrader/release-upgrade-available\nfi\n\n# ### Set the system timezone\n#\n# Some systems are missing /etc/timezone, which we cat into the configs for\n# Z-Push and ownCloud, so we need to set it to something. Daily cron tasks\n# like the system backup are run at a time tied to the system timezone, so\n# letting the user choose will help us identify the right time to do those\n# things (i.e. late at night in whatever timezone the user actually lives\n# in).\n#\n# However, changing the timezone once it is set seems to confuse fail2ban\n# and requires restarting fail2ban (done below in the fail2ban\n# section) and syslog (see #328). There might be other issues, and it's\n# not likely the user will want to change this, so we only ask on first\n# setup.\nif [ -z \"${NONINTERACTIVE:-}\" ]; then\n\tif [ ! -f /etc/timezone ] || [ -n \"${FIRST_TIME_SETUP:-}\" ]; then\n\t\t# If the file is missing or this is the user's first time running\n\t\t# Mail-in-a-Box setup, run the interactive timezone configuration\n\t\t# tool.\n\t\tdpkg-reconfigure tzdata\n\t\trestart_service rsyslog\n\tfi\nelse\n\t# This is a non-interactive setup so we can't ask the user.\n\t# If /etc/timezone is missing, set it to UTC.\n\tif [ ! -f /etc/timezone ]; then\n\t\techo \"Setting timezone to UTC.\"\n\t\techo \"Etc/UTC\" > /etc/timezone\n\t\trestart_service rsyslog\n\tfi\nfi\n\n# ### Seed /dev/urandom\n#\n# /dev/urandom is used by various components for generating random bytes for\n# encryption keys and passwords:\n#\n# * TLS private key (see `ssl.sh`, which calls `openssl genrsa`)\n# * DNSSEC signing keys (see `dns.sh`)\n# * our management server's API key (via Python's os.urandom method)\n# * Roundcube's SECRET_KEY (`webmail.sh`)\n#\n# Why /dev/urandom? It's the same as /dev/random, except that it doesn't wait\n# for a constant new stream of entropy. In practice, we only need a little\n# entropy at the start to get going. After that, we can safely pull a random\n# stream from /dev/urandom and not worry about how much entropy has been\n# added to the stream. (http://www.2uo.de/myths-about-urandom/) So we need\n# to worry about /dev/urandom being seeded properly (which is also an issue\n# for /dev/random), but after that /dev/urandom is superior to /dev/random\n# because it's faster and doesn't block indefinitely to wait for hardware\n# entropy. Note that `openssl genrsa` even uses `/dev/urandom`, and if it's\n# good enough for generating an RSA private key, it's good enough for anything\n# else we may need.\n#\n# Now about that seeding issue....\n#\n# /dev/urandom is seeded from \"the uninitialized contents of the pool buffers when\n# the kernel starts, the startup clock time in nanosecond resolution,...and\n# entropy saved across boots to a local file\" as well as the order of\n# execution of concurrent accesses to /dev/urandom. (Heninger et al 2012,\n# https://factorable.net/weakkeys12.conference.pdf) But when memory is zeroed,\n# the system clock is reset on boot, /etc/init.d/urandom has not yet run, or\n# the machine is single CPU or has no concurrent accesses to /dev/urandom prior\n# to this point, /dev/urandom may not be seeded well. After this, /dev/urandom\n# draws from the same entropy sources as /dev/random, but it doesn't block or\n# issue any warnings if no entropy is actually available. (http://www.2uo.de/myths-about-urandom/)\n# Entropy might not be readily available because this machine has no user input\n# devices (common on servers!) and either no hard disk or not enough IO has\n# occurred yet --- although haveged tries to mitigate this. So there's a good chance\n# that accessing /dev/urandom will not be drawing from any hardware entropy and under\n# a perfect-storm circumstance where the other seeds are meaningless, /dev/urandom\n# may not be seeded at all.\n#\n# The first thing we'll do is block until we can seed /dev/urandom with enough\n# hardware entropy to get going, by drawing from /dev/random. haveged makes this\n# less likely to stall for very long.\n\necho \"Initializing system random number generator...\"\ndd if=/dev/random of=/dev/urandom bs=1 count=32 2> /dev/null\n\n# This is supposedly sufficient. But because we're not sure if hardware entropy\n# is really any good on virtualized systems, we'll also seed from Ubuntu's\n# pollinate servers:\n\npollinate  -q -r\n\n# Between these two, we really ought to be all set.\n\n# We need an ssh key to store backups via rsync, if it doesn't exist create one\nif [ ! -f /root/.ssh/id_rsa_miab ]; then\n\techo 'Creating SSH key for backup…'\n\tssh-keygen -t rsa -b 2048 -a 100 -f /root/.ssh/id_rsa_miab -N '' -q\nfi\n\n# ### Package maintenance\n#\n# Allow apt to install system updates automatically every day.\n\ncat > /etc/apt/apt.conf.d/02periodic <<EOF;\nAPT::Periodic::MaxAge \"7\";\nAPT::Periodic::Update-Package-Lists \"1\";\nAPT::Periodic::Unattended-Upgrade \"1\";\nAPT::Periodic::Verbose \"0\";\nEOF\n\n# ### Firewall\n\n# Various virtualized environments like Docker and some VPSs don't provide #NODOC\n# a kernel that supports iptables. To avoid error-like output in these cases, #NODOC\n# we skip this if the user sets DISABLE_FIREWALL=1. #NODOC\nif [ -z \"${DISABLE_FIREWALL:-}\" ]; then\n\t# Install `ufw` which provides a simple firewall configuration.\n\tapt_install ufw\n\n\t# Allow incoming connections to SSH.\n\tufw_limit ssh;\n\n\t# ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC\n\t# settings, find the port it is supposedly running on, and open that port #NODOC\n\t# too. #NODOC\n\tSSH_PORT=$(sshd -T 2>/dev/null | grep \"^port \" | sed \"s/port //\" | tr '\\n' ' ') #NODOC\n\tif [ -n \"$SSH_PORT\" ]; then\n\t    for port in $SSH_PORT; do\n\t        if [ \"$port\" != \"22\" ]; then\n\t            echo \"Opening alternate SSH port $port.\" #NODOC\n                ufw_limit \"$port\" #NODOC\n            fi\n        done\n\tfi\n\n\tufw --force enable;\nfi #NODOC\n\n# ### Local DNS Service\n\n# Install a local recursive DNS server --- i.e. for DNS queries made by\n# local services running on this machine.\n#\n# (This is unrelated to the box's public, non-recursive DNS server that\n# answers remote queries about domain names hosted on this box. For that\n# see dns.sh.)\n#\n# The default systemd-resolved service provides local DNS name resolution. By default it\n# is a recursive stub nameserver, which means it simply relays requests to an\n# external nameserver, usually provided by your ISP or configured in /etc/systemd/resolved.conf.\n#\n# This won't work for us for three reasons.\n#\n# 1) We have higher security goals --- we want DNSSEC to be enforced on all\n#    DNS queries (some upstream DNS servers do, some don't).\n# 2) We will configure postfix to use DANE, which uses DNSSEC to find TLS\n#    certificates for remote servers. DNSSEC validation *must* be performed\n#    locally because we can't trust an unencrypted connection to an external\n#    DNS server.\n# 3) DNS-based mail server blacklists (RBLs) typically block large ISP\n#    DNS servers because they only provide free data to small users. Since\n#    we use RBLs to block incoming mail from blacklisted IP addresses,\n#    we have to run our own DNS server. See #1424.\n#\n# systemd-resolved has a setting to perform local DNSSEC validation on all\n# requests (in /etc/systemd/resolved.conf, set DNSSEC=yes), but because it's\n# a stub server the main part of a request still goes through an upstream\n# DNS server, which won't work for RBLs. So we really need a local recursive\n# nameserver.\n#\n# We'll install `bind9`, which as packaged for Ubuntu, has DNSSEC enabled by default via \"dnssec-validation auto\".\n# We'll have it be bound to 127.0.0.1 so that it does not interfere with\n# the public, recursive nameserver `nsd` bound to the public ethernet interfaces.\n#\n# About the settings:\n#\n# * Adding -4 to OPTIONS will have `bind9` not listen on IPv6 addresses\n#   so that we're sure there's no conflict with nsd, our public domain\n#   name server, on IPV6.\n# * The listen-on directive in named.conf.options restricts `bind9` to\n#   binding to the loopback interface instead of all interfaces.\n# * The max-recursion-queries directive increases the maximum number of iterative queries.\n#  \tIf more queries than specified are sent, bind9 returns SERVFAIL. After flushing the cache during system checks,\n#\twe ran into the limit thus we are increasing it from 75 (default value) to 100.\napt_install bind9\ntools/editconf.py /etc/default/named \\\n\t\"OPTIONS=\\\"-u bind -4\\\"\"\nif ! grep -q \"listen-on \" /etc/bind/named.conf.options; then\n\t# Add a listen-on directive if it doesn't exist inside the options block.\n\tsed -i \"s/^}/\\n\\tlisten-on { 127.0.0.1; };\\n}/\" /etc/bind/named.conf.options\nfi\nif ! grep -q \"max-recursion-queries \" /etc/bind/named.conf.options; then\n\t# Add a max-recursion-queries directive if it doesn't exist inside the options block.\n\tsed -i \"s/^}/\\n\\tmax-recursion-queries 100;\\n}/\" /etc/bind/named.conf.options\nfi\n\n# First we'll disable systemd-resolved's management of resolv.conf and its stub server.\n# Breaking the symlink to /run/systemd/resolve/stub-resolv.conf means\n# systemd-resolved will read it for DNS servers to use. Put in 127.0.0.1,\n# which is where bind9 will be running. Obviously don't do this before\n# installing bind9 or else apt won't be able to resolve a server to\n# download bind9 from.\nrm -f /etc/resolv.conf\ntools/editconf.py /etc/systemd/resolved.conf DNSStubListener=no\necho \"nameserver 127.0.0.1\" > /etc/resolv.conf\n\n# Restart the DNS services.\n\nrestart_service bind9\nsystemctl restart systemd-resolved\n\n# ### Fail2Ban Service\n\n# Configure the Fail2Ban installation to prevent dumb bruce-force attacks against dovecot, postfix, ssh, etc.\nrm -f /etc/fail2ban/jail.local # we used to use this file but don't anymore\nrm -f /etc/fail2ban/jail.d/defaults-debian.conf # removes default config so we can manage all of fail2ban rules in one config\ncat conf/fail2ban/jails.conf \\\n    | sed \"s/PUBLIC_IPV6/$PUBLIC_IPV6/g\" \\\n\t| sed \"s/PUBLIC_IP/$PUBLIC_IP/g\" \\\n\t| sed \"s#STORAGE_ROOT#$STORAGE_ROOT#\" \\\n\t> /etc/fail2ban/jail.d/mailinabox.conf\ncp -f conf/fail2ban/filter.d/* /etc/fail2ban/filter.d/\n\n# On first installation, the log files that the jails look at don't all exist.\n# e.g., The roundcube error log isn't normally created until someone logs into\n# Roundcube for the first time. This causes fail2ban to fail to start. Later\n# scripts will ensure the files exist and then fail2ban is given another\n# restart at the very end of setup.\nrestart_service fail2ban\n\nsystemctl enable fail2ban\n"
  },
  {
    "path": "setup/web.sh",
    "content": "#!/bin/bash\n# HTTP: Turn on a web server serving static files\n#################################################\n\nsource setup/functions.sh # load our functions\nsource /etc/mailinabox.conf # load global vars\n\n# Some Ubuntu images start off with Apache. Remove it since we\n# will use nginx. Use autoremove to remove any Apache dependencies.\nif [ -f /usr/sbin/apache2 ]; then\n\techo \"Removing apache...\"\n\thide_output apt-get -y purge apache2 apache2-*\n\thide_output apt-get -y --purge autoremove\nfi\n\n# Install nginx and a PHP FastCGI daemon.\n#\n# Turn off nginx's default website.\n\necho \"Installing Nginx (web server)...\"\n\napt_install nginx php\"${PHP_VER}\"-cli php\"${PHP_VER}\"-fpm idn2\n\nrm -f /etc/nginx/sites-enabled/default\n\n# Copy in a nginx configuration file for common and best-practices\n# SSL settings from @konklone. Replace STORAGE_ROOT so it can find\n# the DH params.\nrm -f /etc/nginx/nginx-ssl.conf # we used to put it here\nsed \"s#STORAGE_ROOT#$STORAGE_ROOT#\" \\\n\tconf/nginx-ssl.conf > /etc/nginx/conf.d/ssl.conf\n\n# Fix some nginx defaults.\n#\n# The server_names_hash_bucket_size seems to prevent long domain names!\n# The default, according to nginx's docs, depends on \"the size of the\n# processor’s cache line.\" It could be as low as 32. We fixed it at\n# 64 in 2014 to accommodate a long domain name (20 characters?). But\n# even at 64, a 58-character domain name won't work (#93), so now\n# we're going up to 128.\n#\n# Drop TLSv1.0, TLSv1.1, following the Mozilla \"Intermediate\" recommendations\n# at https://ssl-config.mozilla.org/#server=nginx&server-version=1.17.0&config=intermediate&openssl-version=1.1.1.\ntools/editconf.py /etc/nginx/nginx.conf -s \\\n\tserver_names_hash_bucket_size=\"128;\" \\\n\tssl_protocols=\"TLSv1.2 TLSv1.3;\"\n\n# Tell PHP not to expose its version number in the X-Powered-By header.\ntools/editconf.py /etc/php/\"$PHP_VER\"/fpm/php.ini -c ';' \\\n\texpose_php=Off\n\n# Set PHPs default charset to UTF-8, since we use it. See #367.\ntools/editconf.py /etc/php/\"$PHP_VER\"/fpm/php.ini -c ';' \\\n        default_charset=\"UTF-8\"\n\n# Configure the path environment for php-fpm\ntools/editconf.py /etc/php/\"$PHP_VER\"/fpm/pool.d/www.conf -c ';' \\\n\tenv[PATH]=/usr/local/bin:/usr/bin:/bin \\\n\n# Configure php-fpm based on the amount of memory the machine has\n# This is based on the nextcloud manual for performance tuning: https://docs.nextcloud.com/server/17/admin_manual/installation/server_tuning.html\n# Some synchronisation issues can occur when many people access the site at once.\n# The pm=ondemand setting is used for memory constrained machines < 2GB, this is copied over from PR: 1216\nTOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}' || /bin/true)\nif [ \"$TOTAL_PHYSICAL_MEM\" -lt 1000000 ]\nthen\n        tools/editconf.py /etc/php/\"$PHP_VER\"/fpm/pool.d/www.conf -c ';' \\\n                pm=ondemand \\\n                pm.max_children=8 \\\n                pm.start_servers=2 \\\n                pm.min_spare_servers=1 \\\n                pm.max_spare_servers=3\nelif [ \"$TOTAL_PHYSICAL_MEM\" -lt 2000000 ]\nthen\n        tools/editconf.py /etc/php/\"$PHP_VER\"/fpm/pool.d/www.conf -c ';' \\\n                pm=ondemand \\\n                pm.max_children=16 \\\n                pm.start_servers=4 \\\n                pm.min_spare_servers=1 \\\n                pm.max_spare_servers=6\nelif [ \"$TOTAL_PHYSICAL_MEM\" -lt 3000000 ]\nthen\n        tools/editconf.py /etc/php/\"$PHP_VER\"/fpm/pool.d/www.conf -c ';' \\\n                pm=dynamic \\\n                pm.max_children=60 \\\n                pm.start_servers=6 \\\n                pm.min_spare_servers=3 \\\n                pm.max_spare_servers=9\nelse\n        tools/editconf.py /etc/php/\"$PHP_VER\"/fpm/pool.d/www.conf -c ';' \\\n                pm=dynamic \\\n                pm.max_children=120 \\\n                pm.start_servers=12 \\\n                pm.min_spare_servers=6 \\\n                pm.max_spare_servers=18\nfi\n\n# Other nginx settings will be configured by the management service\n# since it depends on what domains we're serving, which we don't know\n# until mail accounts have been created.\n\n# Create the iOS/OS X Mobile Configuration file which is exposed via the\n# nginx configuration at /mailinabox-mobileconfig.\nmkdir -p /var/lib/mailinabox\nchmod a+rx /var/lib/mailinabox\ncat conf/ios-profile.xml \\\n\t| sed \"s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/\" \\\n\t| sed \"s/UUID1/$(cat /proc/sys/kernel/random/uuid)/\" \\\n\t| sed \"s/UUID2/$(cat /proc/sys/kernel/random/uuid)/\" \\\n\t| sed \"s/UUID3/$(cat /proc/sys/kernel/random/uuid)/\" \\\n\t| sed \"s/UUID4/$(cat /proc/sys/kernel/random/uuid)/\" \\\n\t > /var/lib/mailinabox/mobileconfig.xml\nchmod a+r /var/lib/mailinabox/mobileconfig.xml\n\n# Create the Mozilla Auto-configuration file which is exposed via the\n# nginx configuration at /.well-known/autoconfig/mail/config-v1.1.xml.\n# The format of the file is documented at:\n# https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat\n# and https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration/FileFormat/HowTo.\ncat conf/mozilla-autoconfig.xml \\\n\t| sed \"s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/\" \\\n\t > /var/lib/mailinabox/mozilla-autoconfig.xml\nchmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml\n\n# Create a generic mta-sts.txt file which is exposed via the\n# nginx configuration at /.well-known/mta-sts.txt\n# more documentation is available on:\n# https://www.uriports.com/blog/mta-sts-explained/\n# default mode is \"enforce\". In /etc/mailinabox.conf change\n# \"MTA_STS_MODE=testing\" which means \"Messages will be delivered\n# as though there was no failure but a report will be sent if\n# TLS-RPT is configured\" if you are not sure you want this yet. Or \"none\".\nPUNY_PRIMARY_HOSTNAME=$(echo \"$PRIMARY_HOSTNAME\" | idn2)\ncat conf/mta-sts.txt \\\n        | sed \"s/MODE/${MTA_STS_MODE}/\" \\\n        | sed \"s/PRIMARY_HOSTNAME/$PUNY_PRIMARY_HOSTNAME/\" \\\n         > /var/lib/mailinabox/mta-sts.txt\nchmod a+r /var/lib/mailinabox/mta-sts.txt\n\n# make a default homepage\nif [ -d \"$STORAGE_ROOT/www/static\" ]; then mv \"$STORAGE_ROOT/www/static\" \"$STORAGE_ROOT/www/default\"; fi # migration #NODOC\nmkdir -p \"$STORAGE_ROOT/www/default\"\nif [ ! -f \"$STORAGE_ROOT/www/default/index.html\" ]; then\n\tcp conf/www_default.html \"$STORAGE_ROOT/www/default/index.html\"\nfi\nchown -R \"$STORAGE_USER\" \"$STORAGE_ROOT/www\"\n\n# Start services.\nrestart_service nginx\nrestart_service php\"$PHP_VER\"-fpm\n\n# Open ports.\nufw_allow http\nufw_allow https\n"
  },
  {
    "path": "setup/webmail.sh",
    "content": "#!/bin/bash\n# Webmail with Roundcube\n# ----------------------\n\nsource setup/functions.sh # load our functions\nsource /etc/mailinabox.conf # load global vars\n\n# ### Installing Roundcube\n\n# We install Roundcube from sources, rather than from Ubuntu, because:\n#\n# 1. Ubuntu's `roundcube-core` package has dependencies on Apache & MySQL, which we don't want.\n#\n# 2. The Roundcube shipped with Ubuntu is consistently out of date.\n#\n# 3. It's packaged incorrectly --- it seems to be missing a directory of files.\n#\n# So we'll use apt-get to manually install the dependencies of roundcube that we know we need,\n# and then we'll manually install roundcube from source.\n\n# These dependencies are from `apt-cache showpkg roundcube-core`.\necho \"Installing Roundcube (webmail)...\"\napt_install \\\n\tdbconfig-common \\\n\tphp\"${PHP_VER}\"-cli php\"${PHP_VER}\"-sqlite3 php\"${PHP_VER}\"-intl php\"${PHP_VER}\"-common php\"${PHP_VER}\"-curl php\"${PHP_VER}\"-imap \\\n\tphp\"${PHP_VER}\"-gd php\"${PHP_VER}\"-pspell php\"${PHP_VER}\"-mbstring php\"${PHP_VER}\"-xml libjs-jquery libjs-jquery-mousewheel libmagic1 \\\n\tsqlite3\n\n# Install Roundcube from source if it is not already present or if it is out of date.\n# Combine the Roundcube version number with the commit hash of plugins to track\n# whether we have the latest version of everything.\n# For the latest versions, see:\n#   https://github.com/roundcube/roundcubemail/releases\n#   https://github.com/mfreiholz/persistent_login/commits/master\n#   https://github.com/stremlau/html5_notifier/commits/master\n#   https://github.com/mstilkerich/rcmcarddav/releases\n# The easiest way to get the package hashes is to run this script and get the hash from\n# the error message.\nVERSION=1.6.12\nHASH=544bc6b91a19bf1cc4a1255c5106a732325e1ce7\nPERSISTENT_LOGIN_VERSION=bde7b6840c7d91de627ea14e81cf4133cbb3c07a # version 5.3\nHTML5_NOTIFIER_VERSION=68d9ca194212e15b3c7225eb6085dbcf02fd13d7 # version 0.6.4+\nCARDDAV_VERSION=4.4.3\nCARDDAV_HASH=74f8ba7aee33e78beb9de07f7f44b81f6071b644\n\nUPDATE_KEY=$VERSION:$PERSISTENT_LOGIN_VERSION:$HTML5_NOTIFIER_VERSION:$CARDDAV_VERSION\n\n# paths that are often reused.\nRCM_DIR=/usr/local/lib/roundcubemail\nRCM_PLUGIN_DIR=${RCM_DIR}/plugins\nRCM_CONFIG=${RCM_DIR}/config/config.inc.php\n\nneeds_update=0 #NODOC\nif [ ! -f /usr/local/lib/roundcubemail/version ]; then\n\t# not installed yet #NODOC\n\tneeds_update=1 #NODOC\nelif [[ \"$UPDATE_KEY\" != $(cat /usr/local/lib/roundcubemail/version) ]]; then\n\t# checks if the version is what we want\n\tneeds_update=1 #NODOC\nfi\nif [ $needs_update == 1 ]; then\n  # if upgrading from 1.3.x, clear the temp_dir\n  if [ -f /usr/local/lib/roundcubemail/version ]; then\n    if [ \"$(cat /usr/local/lib/roundcubemail/version | cut -c1-3)\" == '1.3' ]; then\n      find /var/tmp/roundcubemail/ -type f ! -name 'RCMTEMP*' -delete\n    fi\n  fi\n\n\t# install roundcube\n\twget_verify \\\n\t\thttps://github.com/roundcube/roundcubemail/releases/download/$VERSION/roundcubemail-$VERSION-complete.tar.gz \\\n\t\t$HASH \\\n\t\t/tmp/roundcube.tgz\n\ttar -C /usr/local/lib --no-same-owner -zxf /tmp/roundcube.tgz\n\trm -rf /usr/local/lib/roundcubemail\n\tmv /usr/local/lib/roundcubemail-$VERSION/ $RCM_DIR\n\trm -f /tmp/roundcube.tgz\n\n\t# install roundcube persistent_login plugin\n\tgit_clone https://github.com/mfreiholz/Roundcube-Persistent-Login-Plugin.git $PERSISTENT_LOGIN_VERSION '' ${RCM_PLUGIN_DIR}/persistent_login\n\n\t# install roundcube html5_notifier plugin\n\tgit_clone https://github.com/kitist/html5_notifier.git $HTML5_NOTIFIER_VERSION '' ${RCM_PLUGIN_DIR}/html5_notifier\n\n\t# download and verify the full release of the carddav plugin\n\twget_verify \\\n\t\thttps://github.com/mstilkerich/rcmcarddav/releases/download/v${CARDDAV_VERSION}/carddav-v${CARDDAV_VERSION}.tar.gz \\\n\t\t$CARDDAV_HASH \\\n\t\t/tmp/carddav.tar.gz\n\n\t# unzip and cleanup\n\ttar -C ${RCM_PLUGIN_DIR} -zxf /tmp/carddav.tar.gz\n\trm -f /tmp/carddav.tar.gz\n\n\t# record the version we've installed\n\techo $UPDATE_KEY > ${RCM_DIR}/version\nfi\n\n# ### Configuring Roundcube\n\n# Generate a secret key of PHP-string-safe characters appropriate\n# for the cipher algorithm selected below.\nSECRET_KEY=$(dd if=/dev/urandom bs=1 count=32 2>/dev/null | base64 | sed s/=//g)\n\n# Create a configuration file.\n#\n# For security, temp and log files are not stored in the default locations\n# which are inside the roundcube sources directory. We put them instead\n# in normal places.\ncat > $RCM_CONFIG <<EOF;\n<?php\n/*\n * Do not edit. Written by Mail-in-a-Box. Regenerated on updates.\n */\n\\$config = array();\n\\$config['log_dir'] = '/var/log/roundcubemail/';\n\\$config['temp_dir'] = '/var/tmp/roundcubemail/';\n\\$config['db_dsnw'] = 'sqlite:///$STORAGE_ROOT/mail/roundcube/roundcube.sqlite?mode=0640';\n\\$config['imap_host'] = 'ssl://localhost:993';\n\\$config['imap_conn_options'] = array(\n  'ssl'         => array(\n     'verify_peer'  => false,\n     'verify_peer_name'  => false,\n   ),\n );\n\\$config['imap_timeout'] = 15;\n\\$config['smtp_host'] = 'tls://127.0.0.1';\n\\$config['smtp_conn_options'] = array(\n  'ssl'         => array(\n     'verify_peer'  => false,\n     'verify_peer_name'  => false,\n   ),\n );\n\\$config['support_url'] = 'https://mailinabox.email/';\n\\$config['product_name'] = '$PRIMARY_HOSTNAME Webmail';\n\\$config['cipher_method'] = 'AES-256-CBC'; # persistent login cookie and potentially other things\n\\$config['des_key'] = '$SECRET_KEY'; # 37 characters -> ~256 bits for AES-256, see above\n\\$config['plugins'] = array('html5_notifier', 'archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'persistent_login', 'carddav');\n\\$config['skin'] = 'elastic';\n\\$config['login_autocomplete'] = 2;\n\\$config['login_username_filter'] = 'email';\n\\$config['password_charset'] = 'UTF-8';\n\\$config['junk_mbox'] = 'Spam';\n/* ensure roudcube session id's aren't leaked to other parts of the server */\n\\$config['session_path'] = '/mail/';\n/* prevent CSRF, requires php 7.3+ */\n\\$config['session_samesite'] = 'Strict';\n\\$config['quota_zero_as_unlimited'] = true;\n?>\nEOF\n\n# Configure CardDav\ncat > ${RCM_PLUGIN_DIR}/carddav/config.inc.php <<EOF;\n<?php\n/* Do not edit. Written by Mail-in-a-Box. Regenerated on updates. */\n\\$prefs['_GLOBAL']['hide_preferences'] = true;\n\\$prefs['_GLOBAL']['suppress_version_warning'] = true;\n\\$prefs['ownCloud'] = array(\n\t 'name'         =>  'ownCloud',\n\t 'username'     =>  '%u', // login username\n\t 'password'     =>  '%p', // login password\n\t 'url'          =>  'https://${PRIMARY_HOSTNAME}/cloud/remote.php/dav/addressbooks/users/%u/contacts/',\n\t 'active'       =>  true,\n\t 'readonly'     =>  false,\n\t 'refresh_time' => '02:00:00',\n\t 'fixed'        =>  array('username','password'),\n\t 'preemptive_auth' => '1',\n\t 'hide'        =>  false,\n);\n?>\nEOF\n\n# Create writable directories.\nmkdir -p /var/log/roundcubemail /var/tmp/roundcubemail \"$STORAGE_ROOT/mail/roundcube\"\nchown -R www-data:www-data /var/log/roundcubemail /var/tmp/roundcubemail \"$STORAGE_ROOT/mail/roundcube\"\n\n# Ensure the log file monitored by fail2ban exists, or else fail2ban can't start.\nsudo -u www-data touch /var/log/roundcubemail/errors.log\n\n# Password changing plugin settings\n# The config comes empty by default, so we need the settings\n# we're not planning to change in config.inc.dist...\ncp ${RCM_PLUGIN_DIR}/password/config.inc.php.dist \\\n\t${RCM_PLUGIN_DIR}/password/config.inc.php\n\ntools/editconf.py ${RCM_PLUGIN_DIR}/password/config.inc.php \\\n\t\"\\$config['password_minimum_length']=8;\" \\\n\t\"\\$config['password_db_dsn']='sqlite:///$STORAGE_ROOT/mail/users.sqlite';\" \\\n\t\"\\$config['password_query']='UPDATE users SET password=%P WHERE email=%u';\" \\\n\t\"\\$config['password_algorithm']='sha512-crypt';\" \\\n\t\"\\$config['password_algorithm_prefix']='{SHA512-CRYPT}';\"\n\n# so PHP can use doveadm, for the password changing plugin\nusermod -a -G dovecot www-data\n\n# set permissions so that PHP can use users.sqlite\n# could use dovecot instead of www-data, but not sure it matters\nchown root:www-data \"$STORAGE_ROOT/mail\"\nchmod 775 \"$STORAGE_ROOT/mail\"\nchown root:www-data \"$STORAGE_ROOT/mail/users.sqlite\"\nchmod 664 \"$STORAGE_ROOT/mail/users.sqlite\"\n\n# Fix Carddav permissions:\nchown -f -R root:www-data ${RCM_PLUGIN_DIR}/carddav\n# root:www-data need all permissions, others only read\nchmod -R 774 ${RCM_PLUGIN_DIR}/carddav\n\n# Run Roundcube database migration script (database is created if it does not exist)\nphp\"$PHP_VER\" ${RCM_DIR}/bin/updatedb.sh --dir ${RCM_DIR}/SQL --package roundcube\nchown www-data:www-data \"$STORAGE_ROOT/mail/roundcube/roundcube.sqlite\"\nchmod 664 \"$STORAGE_ROOT/mail/roundcube/roundcube.sqlite\"\n\n# Patch the Roundcube code to eliminate an issue that causes postfix to reject our sqlite\n# user database (see https://github.com/mail-in-a-box/mailinabox/issues/2185)\nsed -i.miabold 's/^[^#]\\+.\\+PRAGMA journal_mode = WAL.\\+$/#&/' \\\n/usr/local/lib/roundcubemail/program/lib/Roundcube/db/sqlite.php\n\n# Because Roundcube wants to set the PRAGMA we just deleted from the source, we apply it here\n# to the roundcube database (see https://github.com/roundcube/roundcubemail/issues/8035)\n# Database should exist, created by migration script\nhide_output sqlite3 \"$STORAGE_ROOT/mail/roundcube/roundcube.sqlite\" 'PRAGMA journal_mode=WAL;'\n\n# Enable PHP modules.\nphpenmod -v \"$PHP_VER\" imap\nrestart_service php\"$PHP_VER\"-fpm\n"
  },
  {
    "path": "setup/zpush.sh",
    "content": "#!/bin/bash\n#\n# Z-Push: The Microsoft Exchange protocol server\n# ----------------------------------------------\n#\n# Mostly for use on iOS which doesn't support IMAP IDLE.\n#\n# Although Ubuntu ships Z-Push (as d-push) it has a dependency on Apache\n# so we won't install it that way.\n#\n# Thanks to http://frontender.ch/publikationen/push-mail-server-using-nginx-and-z-push.html.\n\nsource setup/functions.sh # load our functions\nsource /etc/mailinabox.conf # load global vars\n\n# Prereqs.\n\necho \"Installing Z-Push (Exchange/ActiveSync server)...\"\napt_install \\\n       php\"${PHP_VER}\"-soap php\"${PHP_VER}\"-imap libawl-php php\"$PHP_VER\"-xml php\"${PHP_VER}\"-intl\n\nphpenmod -v \"$PHP_VER\" imap\n\n# Copy Z-Push into place.\nVERSION=2.7.6\nTARGETHASH=a0591c8c58b2cdb3ba25cd7a9115a40a3f76a72c\nneeds_update=0 #NODOC\nif [ ! -f /usr/local/lib/z-push/version ]; then\n\tneeds_update=1 #NODOC\nelif [[ $VERSION != $(cat /usr/local/lib/z-push/version) ]]; then\n\t# checks if the version\n\tneeds_update=1 #NODOC\nfi\nif [ $needs_update == 1 ]; then\n\t# Download\n\twget_verify \"https://github.com/Z-Hub/Z-Push/archive/refs/tags/$VERSION.zip\" $TARGETHASH /tmp/z-push.zip\n\n\t# Extract into place.\n\trm -rf /usr/local/lib/z-push /tmp/z-push\n\tunzip -q /tmp/z-push.zip -d /tmp/z-push\n\tmv /tmp/z-push/*/src /usr/local/lib/z-push\n\trm -rf /tmp/z-push.zip /tmp/z-push\n\n\t# Create admin and top scripts with PHP_VER  \n\trm -f /usr/sbin/z-push-{admin,top}\n    echo '#!/bin/bash' > /usr/sbin/z-push-admin\n    echo php\"$PHP_VER\" /usr/local/lib/z-push/z-push-admin.php '\"$@\"' >> /usr/sbin/z-push-admin\n    chmod 755 /usr/sbin/z-push-admin\n    echo '#!/bin/bash' > /usr/sbin/z-push-top\n    echo php\"$PHP_VER\" /usr/local/lib/z-push/z-push-top.php '\"$@\"' >> /usr/sbin/z-push-top\n    chmod 755 /usr/sbin/z-push-top\n\t\n\techo $VERSION > /usr/local/lib/z-push/version\nfi\n\n# Configure default config.\nsed -i \"s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^\" /usr/local/lib/z-push/config.php\nsed -i \"s/define('BACKEND_PROVIDER', .*/define('BACKEND_PROVIDER', 'BackendCombined');/\" /usr/local/lib/z-push/config.php\nsed -i \"s/define('USE_FULLEMAIL_FOR_LOGIN', .*/define('USE_FULLEMAIL_FOR_LOGIN', true);/\" /usr/local/lib/z-push/config.php\nsed -i \"s/define('LOGLEVEL', .*/define('LOGLEVEL', LOGLEVEL_ERROR);/\" /usr/local/lib/z-push/config.php\n\n# Configure BACKEND\nrm -f /usr/local/lib/z-push/backend/combined/config.php\ncp conf/zpush/backend_combined.php /usr/local/lib/z-push/backend/combined/config.php\n\n# Configure IMAP\nrm -f /usr/local/lib/z-push/backend/imap/config.php\ncp conf/zpush/backend_imap.php /usr/local/lib/z-push/backend/imap/config.php\nsed -i \"s%STORAGE_ROOT%$STORAGE_ROOT%\" /usr/local/lib/z-push/backend/imap/config.php\n\n# Configure CardDav\nrm -f /usr/local/lib/z-push/backend/carddav/config.php\ncp conf/zpush/backend_carddav.php /usr/local/lib/z-push/backend/carddav/config.php\n\n# Configure CalDav\nrm -f /usr/local/lib/z-push/backend/caldav/config.php\ncp conf/zpush/backend_caldav.php /usr/local/lib/z-push/backend/caldav/config.php\n\n# Configure Autodiscover\nrm -f /usr/local/lib/z-push/autodiscover/config.php\ncp conf/zpush/autodiscover_config.php /usr/local/lib/z-push/autodiscover/config.php\nsed -i \"s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/\" /usr/local/lib/z-push/autodiscover/config.php\nsed -i \"s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^\" /usr/local/lib/z-push/autodiscover/config.php\n\n# Some directories it will use.\n\nmkdir -p /var/log/z-push\nmkdir -p /var/lib/z-push\nchmod 750 /var/log/z-push\nchmod 750 /var/lib/z-push\nchown www-data:www-data /var/log/z-push\nchown www-data:www-data /var/lib/z-push\n\n# Add log rotation\n\ncat > /etc/logrotate.d/z-push <<EOF;\n/var/log/z-push/*.log {\n\tweekly\n\tmissingok\n\trotate 52\n\tcompress\n\tdelaycompress\n\tnotifempty\n}\nEOF\n\n# Restart service.\n\nrestart_service php\"$PHP_VER\"-fpm\n\n# Fix states after upgrade\n\nif [ $needs_update == 1 ]; then\n\thide_output php\"$PHP_VER\" /usr/local/lib/z-push/z-push-admin.php -a fixstates\nfi\n"
  },
  {
    "path": "tests/fail2ban.py",
    "content": "# Test that a box's fail2ban setting are working\n# correctly by attempting a bunch of failed logins.\n#\n# Specify a SSH login command (which we use to reset\n# fail2ban after each test) and the hostname to\n# try to log in to.\n######################################################################\n\nimport sys, os, time\n\n# parse command line\n\nif len(sys.argv) != 4:\n\tprint('Usage: tests/fail2ban.py \"ssh user@hostname\" hostname owncloud_user')\n\tsys.exit(1)\n\nssh_command, hostname, owncloud_user = sys.argv[1:4]\n\n# define some test types\n\nimport socket\nsocket.setdefaulttimeout(10)\n\nclass IsBlocked(Exception):\n\t\"\"\"Tests raise this exception when it appears that a fail2ban\n\tjail is in effect, i.e. on a connection refused error.\"\"\"\n\ndef smtp_test():\n\timport smtplib\n\n\ttry:\n\t\tserver = smtplib.SMTP(hostname, 587)\n\texcept ConnectionRefusedError:\n\t\t# looks like fail2ban worked\n\t\traise IsBlocked\n\tserver.starttls()\n\tserver.ehlo_or_helo_if_needed()\n\n\ttry:\n\t\tserver.login(\"fakeuser\", \"fakepassword\")\n\t\tmsg = \"authentication didn't fail\"\n\t\traise Exception(msg)\n\texcept smtplib.SMTPAuthenticationError:\n\t\t# athentication should fail\n\t\tpass\n\n\ttry:\n\t\tserver.quit()\n\texcept:\n\t\t# ignore errors here\n\t\tpass\n\ndef imap_test():\n\timport imaplib\n\n\ttry:\n\t\tM = imaplib.IMAP4_SSL(hostname)\n\texcept ConnectionRefusedError:\n\t\t# looks like fail2ban worked\n\t\traise IsBlocked\n\n\ttry:\n\t\tM.login(\"fakeuser\", \"fakepassword\")\n\t\tmsg = \"authentication didn't fail\"\n\t\traise Exception(msg)\n\texcept imaplib.IMAP4.error:\n\t\t# authentication should fail\n\t\tpass\n\tfinally:\n\t\tM.logout() # shuts down connection, has nothing to do with login()\n\n\ndef pop_test():\n\timport poplib\n\ttry:\n\t\tM = poplib.POP3_SSL(hostname)\n\texcept ConnectionRefusedError:\n\t\t# looks like fail2ban worked\n\t\traise IsBlocked\n\ttry:\n\t\tM.user('fakeuser')\n\t\ttry:\n\t\t\tM.pass_('fakepassword')\n\t\texcept poplib.error_proto:\n\t\t\t# Authentication should fail.\n\t\t\tM = None # don't .quit()\n\t\t\treturn\n\t\tM.list()\n\t\tmsg = \"authentication didn't fail\"\n\t\traise Exception(msg)\n\tfinally:\n\t\tif M:\n\t\t\tM.quit()\n\ndef managesieve_test():\n\t# We don't have a Python sieve client, so we'll\n\t# just run the IMAP client and see what happens.\n\timport imaplib\n\n\ttry:\n\t\tM = imaplib.IMAP4(hostname, 4190)\n\texcept ConnectionRefusedError:\n\t\t# looks like fail2ban worked\n\t\traise IsBlocked\n\n\ttry:\n\t\tM.login(\"fakeuser\", \"fakepassword\")\n\t\tmsg = \"authentication didn't fail\"\n\t\traise Exception(msg)\n\texcept imaplib.IMAP4.error:\n\t\t# authentication should fail\n\t\tpass\n\tfinally:\n\t\tM.logout() # shuts down connection, has nothing to do with login()\n\ndef http_test(url, expected_status, postdata=None, qsargs=None, auth=None):\n\timport urllib.parse\n\timport requests\n\tfrom requests.auth import HTTPBasicAuth\n\n\t# form request\n\turl = urllib.parse.urljoin(\"https://\" + hostname, url)\n\tif qsargs: url += \"?\" + urllib.parse.urlencode(qsargs)\n\turlopen = requests.get if not postdata else requests.post\n\n\ttry:\n\t\t# issue request\n\t\tr = urlopen(\n\t\t\turl,\n\t\t\tauth=HTTPBasicAuth(*auth) if auth else None,\n\t\t\tdata=postdata,\n\t\t\theaders={'User-Agent': 'Mail-in-a-Box fail2ban tester'},\n\t\t\ttimeout=8,\n\t\t\tverify=False) # don't bother with HTTPS validation, it may not be configured yet\n\texcept requests.exceptions.ConnectTimeout:\n\t\traise IsBlocked\n\texcept requests.exceptions.ConnectionError as e:\n\t\tif \"Connection refused\" in str(e):\n\t\t\traise IsBlocked\n\t\traise # some other unexpected condition\n\n\t# return response status code\n\tif r.status_code != expected_status:\n\t\tr.raise_for_status() # anything but 200\n\t\tmsg = f\"Got unexpected status code {r.status_code}.\"\n\t\traise OSError(msg)\n\n# define how to run a test\n\ndef restart_fail2ban_service(final=False):\n\t# Log in over SSH to restart fail2ban.\n\tcommand = \"sudo fail2ban-client reload\"\n\tif not final:\n\t\t# Stop recidive jails during testing.\n\t\tcommand += \" && sudo fail2ban-client stop recidive\"\n\tos.system(f'{ssh_command} \"{command}\"')\n\ndef testfunc_runner(i, testfunc, *args):\n\tprint(i+1, end=\" \", flush=True)\n\ttestfunc(*args)\n\ndef run_test(testfunc, args, count, within_seconds, parallel):\n\t# Run testfunc count times in within_seconds seconds (and actually\n\t# within a little less time so we're sure we're under the limit).\n\t#\n\t# Because some services are slow, like IMAP, we can't necessarily\n\t# run testfunc sequentially and still get to count requests within\n\t# the required time. So we split the requests across threads.\n\n\tfrom multiprocessing import Pool\n\n\trestart_fail2ban_service()\n\n\t# Log.\n\tprint(testfunc.__name__, \" \".join(str(a) for a in args), \"...\")\n\n\t# Record the start time so we can know how to evenly space our\n\t# calls to testfunc.\n\tstart_time = time.time()\n\n\twith Pool(parallel) as p:\n\t\t# Distribute the requests across the pool.\n\t\tasyncresults = []\n\t\tfor i in range(count):\n\t\t\tar = p.apply_async(testfunc_runner, [i, testfunc, *list(args)])\n\t\t\tasyncresults.append(ar)\n\n\t\t# Wait for all runs to finish.\n\t\tp.close()\n\t\tp.join()\n\n\t\t# Check for errors.\n\t\tfor ar in asyncresults:\n\t\t\ttry:\n\t\t\t\tar.get()\n\t\t\texcept IsBlocked:\n\t\t\t\tprint(\"Test machine prematurely blocked!\")\n\t\t\t\treturn False\n\n\t# Did we make enough requests within the limit?\n\tif (time.time()-start_time) > within_seconds:\n\t\traise Exception(\"Test failed to make %s requests in %d seconds.\" % (count, within_seconds))\n\n\t# Wait a moment for the block to be put into place.\n\ttime.sleep(4)\n\n\t# The next call should fail.\n\tprint(\"*\", end=\" \", flush=True)\n\ttry:\n\t\ttestfunc(*args)\n\texcept IsBlocked:\n\t\t# Success -- this one is supposed to be refused.\n\t\tprint(\"blocked [OK]\")\n\t\treturn True # OK\n\n\tprint(\"not blocked!\")\n\treturn False\n\n######################################################################\n\nif __name__ == \"__main__\":\n\t# run tests\n\n\t# SMTP bans at 10 even though we say 20 in the config because we get\n\t# doubled-up warnings in the logs, we'll let that be for now\n\trun_test(smtp_test, [], 10, 30, 8)\n\n\t# IMAP\n\trun_test(imap_test, [], 20, 30, 4)\n\n\t# POP\n\trun_test(pop_test, [], 20, 30, 4)\n\n\t# Managesieve\n\trun_test(managesieve_test, [], 20, 30, 4)\n\n\t# Mail-in-a-Box control panel\n\trun_test(http_test, [\"/admin/login\", 200], 20, 30, 1)\n\n\t# Munin via the Mail-in-a-Box control panel\n\trun_test(http_test, [\"/admin/munin/\", 401], 20, 30, 1)\n\n\t# ownCloud\n\trun_test(http_test, [\"/cloud/remote.php/webdav\", 401, None, None, [owncloud_user, \"aa\"]], 20, 120, 1)\n\n\t# restart fail2ban so that this client machine is no longer blocked\n\trestart_fail2ban_service(final=True)\n"
  },
  {
    "path": "tests/pip-requirements.txt",
    "content": "dnspython3\n"
  },
  {
    "path": "tests/test_dns.py",
    "content": "#!/usr/bin/env python3\n#\n# Tests the DNS configuration of a Mail-in-a-Box.\n#\n# tests/dns.py ipaddr hostname\n#\n# where ipaddr is the IP address of your Mail-in-a-Box\n# and hostname is the domain name to check the DNS for.\n\nimport sys, re\nimport dns.reversename, dns.resolver\n\nif len(sys.argv) < 3:\n\tprint(\"Usage: tests/dns.py ipaddress hostname [primary hostname]\")\n\tsys.exit(1)\n\nipaddr, hostname = sys.argv[1:3]\nprimary_hostname = hostname\nif len(sys.argv) == 4:\n\tprimary_hostname = sys.argv[3]\n\ndef test(server, description):\n\ttests = [\n\t\t(hostname, \"A\", ipaddr),\n\t\t#(hostname, \"NS\", \"ns1.%s.;ns2.%s.\" % (primary_hostname, primary_hostname)),\n\t\t(\"ns1.\" + primary_hostname, \"A\", ipaddr),\n\t\t(\"ns2.\" + primary_hostname, \"A\", ipaddr),\n\t\t(\"www.\" + hostname, \"A\", ipaddr),\n\t\t(hostname, \"MX\", \"10 \" + primary_hostname + \".\"),\n\t\t(hostname, \"TXT\", '\"v=spf1 mx -all\"'),\n\t\t(\"mail._domainkey.\" + hostname, \"TXT\", '\"v=DKIM1; k=rsa; s=email; \" \"p=__KEY__\"'),\n\t\t#(\"_adsp._domainkey.\" + hostname, \"TXT\", \"\\\"dkim=all\\\"\"),\n\t\t(\"_dmarc.\" + hostname, \"TXT\", '\"v=DMARC1; p=quarantine;\"'),\n\t]\n\treturn test2(tests, server, description)\n\ndef test_ptr(server, description):\n\tipaddr_rev = dns.reversename.from_address(ipaddr)\n\ttests = [\n\t\t(ipaddr_rev, \"PTR\", hostname+'.'),\n\t]\n\treturn test2(tests, server, description)\n\ndef test2(tests, server, description):\n\tfirst = True\n\tresolver = dns.resolver.get_default_resolver()\n\tresolver.nameservers = [server]\n\tfor qname, rtype, expected_answer in tests:\n\t\t# do the query and format the result as a string\n\t\ttry:\n\t\t\tresponse = dns.resolver.resolve(qname, rtype)\n\t\texcept dns.resolver.NoNameservers:\n\t\t\t# host did not have an answer for this query\n\t\t\tprint(f\"Could not connect to {server} for DNS query.\")\n\t\t\tsys.exit(1)\n\t\texcept (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):\n\t\t\t# host did not have an answer for this query; not sure what the\n\t\t\t# difference is between the two exceptions\n\t\t\tresponse = [\"[no value]\"]\n\t\tresponse = \";\".join(str(r) for r in response)\n\t\tresponse = re.sub(r\"(\\\"p=).*(\\\")\", r\"\\1__KEY__\\2\", response) # normalize DKIM key\n\t\tresponse = response.replace('\"\" ', \"\") # normalize TXT records (DNSSEC signing inserts empty text string components)\n\n\t\t# is it right?\n\t\tif response == expected_answer:\n\t\t\t#print(server, \":\", qname, rtype, \"?\", response)\n\t\t\tcontinue\n\n\t\t# show problem\n\t\tif first:\n\t\t\tprint(\"Incorrect DNS Response from\", description)\n\t\t\tprint()\n\t\t\tprint(\"QUERY               \", \"RESPONSE    \", \"CORRECT VALUE\", sep='\\t')\n\t\t\tfirst = False\n\n\t\tprint((qname + \"/\" + rtype).ljust(20), response.ljust(12), expected_answer, sep='\\t')\n\treturn first # success\n\n# Test the response from the machine itself.\nif not test(ipaddr, \"Mail-in-a-Box\"):\n\tprint ()\n\tprint (f\"Please run the Mail-in-a-Box setup script on {hostname} again.\")\n\tsys.exit(1)\nelse:\n\tprint (\"The Mail-in-a-Box provided correct DNS answers.\")\n\tprint ()\n\n\t# If those settings are OK, also test Google's Public DNS\n\t# to see if the machine is hooked up to recursive DNS properly.\n\tif not test(\"8.8.8.8\", \"Google Public DNS\"):\n\t\tprint ()\n\t\tprint (f\"Check that the nameserver settings for {hostname} are correct at your domain registrar. It may take a few hours for Google Public DNS to update after changes on your Mail-in-a-Box.\")\n\t\tsys.exit(1)\n\telse:\n\t\tprint (\"Your domain registrar or DNS host appears to be configured correctly as well. Public DNS provides the same answers.\")\n\t\tprint ()\n\n\t\t# And if that's OK, also check reverse DNS (the PTR record).\n\t\tif not test_ptr(\"8.8.8.8\", \"Google Public DNS (Reverse DNS)\"):\n\t\t\tprint ()\n\t\t\tprint (f\"The reverse DNS for {hostname} is not correct. Consult your ISP for how to set the reverse DNS (also called the PTR record) for {hostname} to {ipaddr}.\")\n\t\t\tsys.exit(1)\n\t\telse:\n\t\t\tprint (\"And the reverse DNS for the domain is correct.\")\n\t\t\tprint ()\n\t\t\tprint (\"DNS is OK.\")\n"
  },
  {
    "path": "tests/test_mail.py",
    "content": "#!/usr/bin/env python3\n# Tests sending and receiving mail by sending a test message to yourself.\n\nimport sys, imaplib, smtplib, uuid, time\nimport socket, dns.reversename, dns.resolver\n\nif len(sys.argv) < 3:\n\tprint(\"Usage: tests/mail.py hostname emailaddress password\")\n\tsys.exit(1)\n\nhost, emailaddress, pw = sys.argv[1:4]\n\n# Attempt to login with IMAP. Our setup uses email addresses\n# as IMAP/SMTP usernames.\ntry:\n\tM = imaplib.IMAP4_SSL(host)\n\tM.login(emailaddress, pw)\nexcept OSError as e:\n\tprint(\"Connection error:\", e)\n\tsys.exit(1)\nexcept imaplib.IMAP4.error as e:\n\t# any sort of login error\n\te = \", \".join(a.decode(\"utf8\") for a in e.args)\n\tprint(\"IMAP error:\", e)\n\tsys.exit(1)\n\nM.select()\nprint(\"IMAP login is OK.\")\n\n# Attempt to send a mail to ourself.\nmailsubject = \"Mail-in-a-Box Automated Test Message \" + uuid.uuid4().hex\nemailto = emailaddress\nmsg = f\"\"\"From: {emailaddress}\nTo: {emailto}\nSubject: {mailsubject}\n\nThis is a test message. It should be automatically deleted by the test script.\"\"\"\n\n# Connect to the server on the SMTP submission TLS port.\nserver = smtplib.SMTP_SSL(host)\n#server.set_debuglevel(1)\n\n# Verify that the EHLO name matches the server's reverse DNS.\nipaddr = socket.gethostbyname(host) # IPv4 only!\nreverse_ip = dns.reversename.from_address(ipaddr) # e.g. \"1.0.0.127.in-addr.arpa.\"\ntry:\n\treverse_dns = dns.resolver.resolve(reverse_ip, 'PTR')[0].target.to_text(omit_final_dot=True) # => hostname\nexcept dns.resolver.NXDOMAIN:\n\tprint(f\"Reverse DNS lookup failed for {ipaddr}. SMTP EHLO name check skipped.\")\n\treverse_dns = None\nif reverse_dns is not None:\n\tserver.ehlo_or_helo_if_needed() # must send EHLO before getting the server's EHLO name\n\thelo_name = server.ehlo_resp.decode(\"utf8\").split(\"\\n\")[0] # first line is the EHLO name\n\tif helo_name != reverse_dns:\n\t\tprint(\"The server's EHLO name does not match its reverse hostname. Check DNS settings.\")\n\telse:\n\t\tprint(f\"SMTP EHLO name ({helo_name}) is OK.\")\n\n# Login and send a test email.\nserver.login(emailaddress, pw)\nserver.sendmail(emailaddress, [emailto], msg)\nserver.quit()\nprint(\"SMTP submission is OK.\")\n\nwhile True:\n\t# Wait so the message can propagate to the inbox.\n\ttime.sleep(10)\n\n\t# Read the subject lines of all of the emails in the inbox\n\t# to find our test message, and then delete it.\n\tfound = False\n\ttyp, data = M.search(None, 'ALL')\n\tfor num in data[0].split():\n\t\ttyp, data = M.fetch(num, '(BODY[HEADER.FIELDS (SUBJECT)])')\n\t\timapsubjectline = data[0][1].strip().decode(\"utf8\")\n\t\tif imapsubjectline == \"Subject: \" + mailsubject:\n\t\t\t# We found our test message.\n\t\t\tfound = True\n\n\t\t\t# To test DKIM, download the whole mssage body. Unfortunately,\n\t\t\t# pydkim doesn't actually work.\n\t\t\t# You must 'sudo apt-get install python3-dkim python3-dnspython' first.\n\t\t\t#typ, msgdata = M.fetch(num, '(RFC822)')\n\t\t\t#msg = msgdata[0][1]\n\t\t\t#if dkim.verify(msg):\n\t\t\t#\tprint(\"DKIM signature on the test message is OK (verified).\")\n\t\t\t#else:\n\t\t\t#\tprint(\"DKIM signature on the test message failed verification.\")\n\n\t\t\t# Delete the test message.\n\t\t\tM.store(num, '+FLAGS', '\\\\Deleted')\n\t\t\tM.expunge()\n\n\t\t\tbreak\n\n\tif found:\n\t\tbreak\n\n\tprint(\"Test message not present in the inbox yet...\")\n\nM.close()\nM.logout()\n\nprint(\"Test message sent & received successfully.\")\n"
  },
  {
    "path": "tests/test_smtp_server.py",
    "content": "#!/usr/bin/env python3\nimport smtplib, sys\n\nif len(sys.argv) < 3:\n        print(\"Usage: tests/smtp_server.py host email.to email.from\")\n        sys.exit(1)\n\nhost, toaddr, fromaddr = sys.argv[1:4]\nmsg = f\"\"\"From: {fromaddr}\nTo: {toaddr}\nSubject: SMTP server test\n\nThis is a test message.\"\"\"\n\nserver = smtplib.SMTP(host, 25)\nserver.set_debuglevel(1)\nserver.sendmail(fromaddr, [toaddr], msg)\nserver.quit()\n\n"
  },
  {
    "path": "tests/tls.py",
    "content": "#!/usr/bin/python3\n\n# Runs SSLyze on the TLS endpoints of a box and outputs\n# the results so we can inspect the settings and compare\n# against a known good version in tls_results.txt.\n#\n# Make sure you have SSLyze available:\n# wget https://github.com/nabla-c0d3/sslyze/releases/download/release-0.11/sslyze-0_11-linux64.zip\n# unzip sslyze-0_11-linux64.zip\n#\n# Then run:\n#\n# python3 tls.py yourservername\n#\n# If you are on a residential network that blocks outbound\n# port 25 connections, then you can proxy the connections\n# through some other host you can ssh into (maybe the box\n# itself?):\n#\n# python3 tls.py --proxy user@ssh_host yourservername\n#\n# (This will launch \"ssh -N -L10023:yourservername:testport user@ssh_host\"\n# to create a tunnel.)\n\nimport sys, subprocess, re, time, json, csv, io, urllib.request\n\n######################################################################\n\n# PARSE COMMAND LINE\n\nproxy = None\nargs = list(sys.argv[1:])\nwhile len(args) > 0:\n\tif args[0] == \"--proxy\":\n\t\targs.pop(0)\n\t\tproxy = args.pop(0)\n\tbreak\n\nif len(args) == 0:\n\tprint(\"Usage: python3 tls.py [--proxy ssh_host] hostname\")\n\tsys.exit(0)\n\nhost = args[0]\n\n######################################################################\n\nSSLYZE = \"sslyze-0_11-linux64/sslyze/sslyze.py\"\n\ncommon_opts = [\"--sslv2\", \"--sslv3\", \"--tlsv1\", \"--tlsv1_1\", \"--tlsv1_2\", \"--reneg\", \"--resum\",\n\t\"--hide_rejected_ciphers\", \"--compression\", \"--heartbleed\"]\n\n# Recommendations from Mozilla as of May 20, 2015 at\n# https://wiki.mozilla.org/Security/Server_Side_TLS.\n#\n# The 'modern' ciphers support Firefox 27, Chrome 22, IE 11,\n# Opera 14, Safari 7, Android 4.4, Java 8. Assumes TLSv1.1,\n# TLSv1.2 only, though we may also be allowing TLSv3.\n#\n# The 'intermediate' ciphers support Firefox 1, Chrome 1, IE 7,\n# Opera 5, Safari 1, Windows XP IE8, Android 2.3, Java 7.\n# Assumes TLSv1, TLSv1.1, TLSv1.2.\n#\n# The 'old' ciphers bring compatibility back to Win XP IE 6.\nMOZILLA_CIPHERS_MODERN = \"ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256\"\nMOZILLA_CIPHERS_INTERMEDIATE = \"ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS\"\nMOZILLA_CIPHERS_OLD = \"ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:ECDHE-RSA-DES-CBC3-SHA:ECDHE-ECDSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:DES-CBC3-SHA:HIGH:SEED:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!RSAPSK:!aDH:!aECDH:!EDH-DSS-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA:!SRP\"\n\n######################################################################\n\ndef sslyze(opts, port, ok_ciphers):\n\t# Print header.\n\theader = (\"PORT %d\" % port)\n\tprint(header)\n\tprint(\"-\" * (len(header)))\n\n\t# What ciphers should we expect?\n\tok_ciphers = subprocess.check_output([\"openssl\", \"ciphers\", ok_ciphers]).decode(\"utf8\").strip().split(\":\")\n\n\t# Form the SSLyze connection string.\n\tconnection_string = host + \":\" + str(port)\n\n\t# Proxy via SSH.\n\tproxy_proc = None\n\tif proxy:\n\t\tconnection_string = \"localhost:10023\"\n\t\tproxy_proc = subprocess.Popen([\"ssh\", \"-N\", \"-L10023:%s:%d\" % (host, port), proxy])\n\t\ttime.sleep(3)\n\n\ttry:\n\t\t# Execute SSLyze.\n\t\tout = subprocess.check_output([SSLYZE, *common_opts, *opts, connection_string])\n\t\tout = out.decode(\"utf8\")\n\n\t\t# Trim output to make better for storing in git.\n\t\tif \"SCAN RESULTS FOR\" not in out:\n\t\t\t# Failed. Just output the error.\n\t\t\tout = re.sub(\"[\\\\w\\\\W]*CHECKING HOST\\\\(S\\\\) AVAILABILITY\\n\\\\s*-+\\n\", \"\", out) # chop off header that shows the host we queried\n\t\tout = re.sub(\"[\\\\w\\\\W]*SCAN RESULTS FOR.*\\n\\\\s*-+\\n\", \"\", out) # chop off header that shows the host we queried\n\t\tout = re.sub(r\"SCAN COMPLETED IN .*\", \"\", out)\n\t\tout = out.rstrip(\" \\n-\") + \"\\n\"\n\n\t\t# Print.\n\t\tprint(out)\n\n\t\t# Pull out the accepted ciphers list for each SSL/TLS protocol\n\t\t# version outputted.\n\t\taccepted_ciphers = set()\n\t\tfor ciphers in re.findall(\" Accepted:([\\\\w\\\\W]*?)\\n *\\n\", out):\n\t\t\taccepted_ciphers |= set(re.findall(\"\\n\\\\s*(\\\\S*)\", ciphers))\n\n\t\t# Compare to what Mozilla recommends, for a given modernness-level.\n\t\tprint(\"  Should Not Offer: \" + (\", \".join(sorted(accepted_ciphers-set(ok_ciphers))) or \"(none -- good)\"))\n\t\tprint(\"  Could Also Offer: \" + (\", \".join(sorted(set(ok_ciphers)-accepted_ciphers)) or \"(none -- good)\"))\n\n\t\t# What clients does that mean we support on this protocol?\n\t\tsupported_clients = { }\n\t\tfor cipher in accepted_ciphers:\n\t\t\tif cipher in cipher_clients:\n\t\t\t\tfor client in cipher_clients[cipher]:\n\t\t\t\t\tsupported_clients[client] = supported_clients.get(client, 0) + 1\n\t\tprint(\"  Supported Clients: \" + (\", \".join(sorted(supported_clients.keys(), key = lambda client : -supported_clients[client]))))\n\n\t\t# Blank line.\n\t\tprint()\n\n\tfinally:\n\t\tif proxy_proc:\n\t\t\tproxy_proc.terminate()\n\t\t\ttry:\n\t\t\t\tproxy_proc.wait(5)\n\t\t\texcept subprocess.TimeoutExpired:\n\t\t\t\tproxy_proc.kill()\n\n# Get a list of OpenSSL cipher names.\ncipher_names = { }\nfor cipher in csv.DictReader(io.StringIO(urllib.request.urlopen(\"https://raw.githubusercontent.com/mail-in-a-box/user-agent-tls-capabilities/master/cipher_names.csv\").read().decode(\"utf8\"))):\n\t# not sure why there are some multi-line values, use first line:\n\tcipher[\"OpenSSL\"] = cipher[\"OpenSSL\"].split(\"\\n\")[0]\n\tcipher_names[cipher[\"IANA\"]] = cipher[\"OpenSSL\"]\n\n# Get a list of what clients support what ciphers, using OpenSSL cipher names.\nclient_compatibility = json.loads(urllib.request.urlopen(\"https://raw.githubusercontent.com/mail-in-a-box/user-agent-tls-capabilities/master/clients.json\").read().decode(\"utf8\"))\ncipher_clients = { }\nfor client in client_compatibility:\n\tif len(set(client['protocols']) & {\"TLS 1.0\", \"TLS 1.1\", \"TLS 1.2\"}) == 0: continue # does not support TLS\n\tfor cipher in client['ciphers']:\n\t\tcipher_clients.setdefault(cipher_names.get(cipher), set()).add(\"/\".join(x for x in [client['client']['name'], client['client']['version'], client['client']['platform']] if x))\n\n# Run SSLyze on various ports.\n\n# SMTP\nsslyze([\"--starttls=smtp\"], 25, MOZILLA_CIPHERS_OLD)\n\n# SMTP Submission\nsslyze([\"--starttls=smtp\"], 587, MOZILLA_CIPHERS_MODERN)\n\n# HTTPS\nsslyze([\"--http_get\", \"--chrome_sha1\", \"--hsts\"], 443, MOZILLA_CIPHERS_INTERMEDIATE)\n\n# IMAP\nsslyze([], 993, MOZILLA_CIPHERS_MODERN)\n\n# POP3\nsslyze([], 995, MOZILLA_CIPHERS_MODERN)\n"
  },
  {
    "path": "tests/tls_results.txt",
    "content": "PORT 25\n-------\n\n  * Deflate Compression:\n      OK - Compression disabled          \n\n  * Session Renegotiation:\n      Client-initiated Renegotiations:   VULNERABLE - Server honors client-initiated renegotiations\n      Secure Renegotiation:              OK - Supported\n\n  * OpenSSL Heartbleed:\n      OK - Not vulnerable to Heartbleed  \n\n  * Session Resumption:\n      With Session IDs:                  OK - Supported (5 successful, 0 failed, 0 errors, 5 total attempts).\n      With TLS Session Tickets:          OK - Supported\n\n  * SSLV2 Cipher Suites:\n      Server rejected all cipher suites.\n\n  * TLSV1_2 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES256-GCM-SHA384   ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA384       ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES256-SHA          ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES256-GCM-SHA384   ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-CAMELLIA256-SHA       DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES256-SHA256         DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES256-GCM-SHA384     DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 CAMELLIA256-SHA               -              256 bits      250 2.0.0 Ok                       \n                 AES256-SHA256                 -              256 bits      250 2.0.0 Ok                       \n                 AES256-SHA                    -              256 bits      250 2.0.0 Ok                       \n                 AES256-GCM-SHA384             -              256 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES128-SHA256       ECDH-521 bits  128 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES128-SHA          ECDH-521 bits  128 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES128-GCM-SHA256   ECDH-521 bits  128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-SEED-SHA              DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-CAMELLIA128-SHA       DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES128-SHA256         DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES128-GCM-SHA256     DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 SEED-SHA                      -              128 bits      250 2.0.0 Ok                       \n                 CAMELLIA128-SHA               -              128 bits      250 2.0.0 Ok                       \n                 AES128-SHA256                 -              128 bits      250 2.0.0 Ok                       \n                 AES128-SHA                    -              128 bits      250 2.0.0 Ok                       \n                 AES128-GCM-SHA256             -              128 bits      250 2.0.0 Ok                       \n\n  * TLSV1_1 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES256-SHA          ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA          ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-CAMELLIA256-SHA       DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 CAMELLIA256-SHA               -              256 bits      250 2.0.0 Ok                       \n                 AES256-SHA                    -              256 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES128-SHA          ECDH-521 bits  128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-SEED-SHA              DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-CAMELLIA128-SHA       DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 SEED-SHA                      -              128 bits      250 2.0.0 Ok                       \n                 CAMELLIA128-SHA               -              128 bits      250 2.0.0 Ok                       \n                 AES128-SHA                    -              128 bits      250 2.0.0 Ok                       \n\n  * SSLV3 Cipher Suites:\n      Server rejected all cipher suites.\n\n  * TLSV1 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES256-SHA          ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA          ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-CAMELLIA256-SHA       DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 CAMELLIA256-SHA               -              256 bits      250 2.0.0 Ok                       \n                 AES256-SHA                    -              256 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES128-SHA          ECDH-521 bits  128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-SEED-SHA              DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-CAMELLIA128-SHA       DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 SEED-SHA                      -              128 bits      250 2.0.0 Ok                       \n                 CAMELLIA128-SHA               -              128 bits      250 2.0.0 Ok                       \n                 AES128-SHA                    -              128 bits      250 2.0.0 Ok\n\n  Should Not Offer: (none -- good)\n  Could Also Offer: AES128-CCM, AES128-CCM8, AES256-CCM, AES256-CCM8, CAMELLIA128-SHA256, CAMELLIA256-SHA256, DHE-DSS-AES128-GCM-SHA256, DHE-DSS-AES128-SHA, DHE-DSS-AES128-SHA256, DHE-DSS-AES256-GCM-SHA384, DHE-DSS-AES256-SHA, DHE-DSS-AES256-SHA256, DHE-DSS-CAMELLIA128-SHA, DHE-DSS-CAMELLIA128-SHA256, DHE-DSS-CAMELLIA256-SHA, DHE-DSS-CAMELLIA256-SHA256, DHE-DSS-SEED-SHA, DHE-RSA-AES128-CCM, DHE-RSA-AES128-CCM8, DHE-RSA-AES256-CCM, DHE-RSA-AES256-CCM8, DHE-RSA-CAMELLIA128-SHA256, DHE-RSA-CAMELLIA256-SHA256, DHE-RSA-CHACHA20-POLY1305, ECDHE-ECDSA-AES128-CCM, ECDHE-ECDSA-AES128-CCM8, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-SHA, ECDHE-ECDSA-AES128-SHA256, ECDHE-ECDSA-AES256-CCM, ECDHE-ECDSA-AES256-CCM8, ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-SHA, ECDHE-ECDSA-AES256-SHA384, ECDHE-ECDSA-CAMELLIA128-SHA256, ECDHE-ECDSA-CAMELLIA256-SHA384, ECDHE-ECDSA-CHACHA20-POLY1305, ECDHE-RSA-CAMELLIA128-SHA256, ECDHE-RSA-CAMELLIA256-SHA384, ECDHE-RSA-CHACHA20-POLY1305\n  Supported Clients: Yahoo Slurp/Jan 2015, OpenSSL/1.0.2, BingPreview/Jan 2015, OpenSSL/1.0.1l, YandexBot/Jan 2015, Android/4.4.2, Safari/6/iOS 6.0.1, Safari/8/OS X 10.10, Safari/7/OS X 10.9, Safari/7/iOS 7.1, IE/11/Win 8.1, Safari/8/iOS 8.1.2, IE Mobile/11/Win Phone 8.1, IE/11/Win 7, Baidu/Jan 2015, Firefox/31.3.0 ESR/Win 7, Android/5.0.0, Chrome/42/OS X, Java/8u31, Googlebot/Feb 2015, Firefox/37/OS X, Android/4.3, Android/4.2.2, Safari/5.1.9/OS X 10.6.8, Android/4.0.4, Android/4.1.1, Safari/6.0.4/OS X 10.8.4, IE Mobile/10/Win Phone 8.0, IE/8-10/Win 7, IE/7/Vista, OpenSSL/0.9.8y, Java/7u25, Android/2.3.7, Java/6u45\n\nPORT 587\n--------\n\n  * Deflate Compression:\n      OK - Compression disabled          \n\n  * Session Renegotiation:\n      Client-initiated Renegotiations:   VULNERABLE - Server honors client-initiated renegotiations\n      Secure Renegotiation:              OK - Supported\n\n  * OpenSSL Heartbleed:\n      OK - Not vulnerable to Heartbleed  \n\n  * Session Resumption:\n      With Session IDs:                  OK - Supported (5 successful, 0 failed, 0 errors, 5 total attempts).\n      With TLS Session Tickets:          OK - Supported\n\n  * SSLV2 Cipher Suites:\n      Server rejected all cipher suites.\n\n  * TLSV1_2 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES256-GCM-SHA384   ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA384       ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES256-SHA          ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES256-GCM-SHA384   ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-CAMELLIA256-SHA       DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES256-SHA256         DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES256-GCM-SHA384     DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 CAMELLIA256-SHA               -              256 bits      250 2.0.0 Ok                       \n                 AES256-SHA256                 -              256 bits      250 2.0.0 Ok                       \n                 AES256-SHA                    -              256 bits      250 2.0.0 Ok                       \n                 AES256-GCM-SHA384             -              256 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES128-SHA256       ECDH-521 bits  128 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES128-SHA          ECDH-521 bits  128 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES128-GCM-SHA256   ECDH-521 bits  128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-SEED-SHA              DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-CAMELLIA128-SHA       DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES128-SHA256         DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES128-GCM-SHA256     DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 SEED-SHA                      -              128 bits      250 2.0.0 Ok                       \n                 CAMELLIA128-SHA               -              128 bits      250 2.0.0 Ok                       \n                 AES128-SHA256                 -              128 bits      250 2.0.0 Ok                       \n                 AES128-SHA                    -              128 bits      250 2.0.0 Ok                       \n                 AES128-GCM-SHA256             -              128 bits      250 2.0.0 Ok                       \n\n  * TLSV1_1 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES256-SHA          ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA          ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-CAMELLIA256-SHA       DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 CAMELLIA256-SHA               -              256 bits      250 2.0.0 Ok                       \n                 AES256-SHA                    -              256 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES128-SHA          ECDH-521 bits  128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-SEED-SHA              DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-CAMELLIA128-SHA       DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 SEED-SHA                      -              128 bits      250 2.0.0 Ok                       \n                 CAMELLIA128-SHA               -              128 bits      250 2.0.0 Ok                       \n                 AES128-SHA                    -              128 bits      250 2.0.0 Ok                       \n\n  * SSLV3 Cipher Suites:\n      Server rejected all cipher suites.\n\n  * TLSV1 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES256-SHA          ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA          ECDH-521 bits  256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-CAMELLIA256-SHA       DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits      250 2.0.0 Ok                       \n                 CAMELLIA256-SHA               -              256 bits      250 2.0.0 Ok                       \n                 AES256-SHA                    -              256 bits      250 2.0.0 Ok                       \n                 ECDHE-RSA-AES128-SHA          ECDH-521 bits  128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-SEED-SHA              DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-CAMELLIA128-SHA       DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits      250 2.0.0 Ok                       \n                 SEED-SHA                      -              128 bits      250 2.0.0 Ok                       \n                 CAMELLIA128-SHA               -              128 bits      250 2.0.0 Ok                       \n                 AES128-SHA                    -              128 bits      250 2.0.0 Ok\n\n  Should Not Offer: AES128-GCM-SHA256, AES128-SHA, AES128-SHA256, AES256-GCM-SHA384, AES256-SHA, AES256-SHA256, CAMELLIA128-SHA, CAMELLIA256-SHA, DHE-RSA-AES128-GCM-SHA256, DHE-RSA-AES128-SHA, DHE-RSA-AES128-SHA256, DHE-RSA-AES256-GCM-SHA384, DHE-RSA-AES256-SHA, DHE-RSA-AES256-SHA256, DHE-RSA-CAMELLIA128-SHA, DHE-RSA-CAMELLIA256-SHA, DHE-RSA-SEED-SHA, ECDHE-RSA-AES128-SHA, ECDHE-RSA-AES256-SHA, SEED-SHA\n  Could Also Offer: ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-SHA256, ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-SHA384, ECDHE-ECDSA-CHACHA20-POLY1305, ECDHE-RSA-CHACHA20-POLY1305\n  Supported Clients: Yahoo Slurp/Jan 2015, OpenSSL/1.0.2, BingPreview/Jan 2015, OpenSSL/1.0.1l, YandexBot/Jan 2015, Android/4.4.2, Safari/6/iOS 6.0.1, Safari/8/OS X 10.10, Safari/7/OS X 10.9, Safari/7/iOS 7.1, IE/11/Win 8.1, Safari/8/iOS 8.1.2, IE Mobile/11/Win Phone 8.1, IE/11/Win 7, Baidu/Jan 2015, Firefox/31.3.0 ESR/Win 7, Android/5.0.0, Chrome/42/OS X, Java/8u31, Googlebot/Feb 2015, Firefox/37/OS X, Android/4.3, Android/4.2.2, Safari/5.1.9/OS X 10.6.8, Android/4.0.4, Android/4.1.1, Safari/6.0.4/OS X 10.8.4, IE Mobile/10/Win Phone 8.0, IE/8-10/Win 7, IE/7/Vista, OpenSSL/0.9.8y, Java/7u25, Android/2.3.7, Java/6u45\n\nPORT 443\n--------\n\n  * Deflate Compression:\n      OK - Compression disabled          \n\n  * Session Renegotiation:\n      Client-initiated Renegotiations:   OK - Rejected\n      Secure Renegotiation:              OK - Supported\n\n  * HTTP Strict Transport Security:\n      OK - HSTS header received:         max-age=15768000\n\n  * Session Resumption:\n      With Session IDs:                  OK - Supported (5 successful, 0 failed, 0 errors, 5 total attempts).\n      With TLS Session Tickets:          OK - Supported\n\n  * OpenSSL Heartbleed:\n      OK - Not vulnerable to Heartbleed  \n\nUnhandled exception when processing --chrome_sha1: \nexceptions.TypeError - Incorrect padding\n\n  * SSLV2 Cipher Suites:\n      Server rejected all cipher suites.\n\n  * TLSV1_2 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES128-GCM-SHA256   ECDH-256 bits  128 bits      HTTP 200 OK                        \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA384       ECDH-256 bits  256 bits      HTTP 200 OK                        \n                 ECDHE-RSA-AES256-SHA          ECDH-256 bits  256 bits      HTTP 200 OK                        \n                 ECDHE-RSA-AES256-GCM-SHA384   ECDH-256 bits  256 bits      HTTP 200 OK                        \n                 DHE-RSA-AES256-SHA256         DH-2048 bits   256 bits      HTTP 200 OK                        \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits      HTTP 200 OK                        \n                 DHE-RSA-AES256-GCM-SHA384     DH-2048 bits   256 bits      HTTP 200 OK                        \n                 AES256-SHA256                 -              256 bits      HTTP 200 OK                        \n                 AES256-SHA                    -              256 bits      HTTP 200 OK                        \n                 AES256-GCM-SHA384             -              256 bits      HTTP 200 OK                        \n                 ECDHE-RSA-AES128-SHA256       ECDH-256 bits  128 bits      HTTP 200 OK                        \n                 ECDHE-RSA-AES128-SHA          ECDH-256 bits  128 bits      HTTP 200 OK                        \n                 ECDHE-RSA-AES128-GCM-SHA256   ECDH-256 bits  128 bits      HTTP 200 OK                        \n                 DHE-RSA-AES128-SHA256         DH-2048 bits   128 bits      HTTP 200 OK                        \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits      HTTP 200 OK                        \n                 DHE-RSA-AES128-GCM-SHA256     DH-2048 bits   128 bits      HTTP 200 OK                        \n                 AES128-SHA256                 -              128 bits      HTTP 200 OK                        \n                 AES128-SHA                    -              128 bits      HTTP 200 OK                        \n                 AES128-GCM-SHA256             -              128 bits      HTTP 200 OK                        \n\n  * TLSV1_1 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES128-SHA          ECDH-256 bits  128 bits      HTTP 200 OK                        \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA          ECDH-256 bits  256 bits      HTTP 200 OK                        \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits      HTTP 200 OK                        \n                 AES256-SHA                    -              256 bits      HTTP 200 OK                        \n                 ECDHE-RSA-AES128-SHA          ECDH-256 bits  128 bits      HTTP 200 OK                        \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits      HTTP 200 OK                        \n                 AES128-SHA                    -              128 bits      HTTP 200 OK                        \n\n  * SSLV3 Cipher Suites:\n      Server rejected all cipher suites.\n\n  * TLSV1 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES128-SHA          ECDH-256 bits  128 bits      HTTP 200 OK                        \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA          ECDH-256 bits  256 bits      HTTP 200 OK                        \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits      HTTP 200 OK                        \n                 AES256-SHA                    -              256 bits      HTTP 200 OK                        \n                 ECDHE-RSA-AES128-SHA          ECDH-256 bits  128 bits      HTTP 200 OK                        \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits      HTTP 200 OK                        \n                 AES128-SHA                    -              128 bits      HTTP 200 OK\n\n  Should Not Offer: (none -- good)\n  Could Also Offer: ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-SHA, ECDHE-ECDSA-AES128-SHA256, ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-SHA, ECDHE-ECDSA-AES256-SHA384, ECDHE-ECDSA-CHACHA20-POLY1305, ECDHE-RSA-CHACHA20-POLY1305\n  Supported Clients: Yahoo Slurp/Jan 2015, OpenSSL/1.0.2, YandexBot/Jan 2015, BingPreview/Jan 2015, OpenSSL/1.0.1l, Android/4.4.2, Safari/6/iOS 6.0.1, Safari/8/OS X 10.10, Safari/7/OS X 10.9, Safari/7/iOS 7.1, IE/11/Win 8.1, Safari/8/iOS 8.1.2, IE Mobile/11/Win Phone 8.1, IE/11/Win 7, Android/5.0.0, Chrome/42/OS X, Java/8u31, Googlebot/Feb 2015, Firefox/31.3.0 ESR/Win 7, Firefox/37/OS X, Android/4.3, Android/4.2.2, Baidu/Jan 2015, Safari/5.1.9/OS X 10.6.8, Android/4.0.4, Android/4.1.1, Safari/6.0.4/OS X 10.8.4, IE Mobile/10/Win Phone 8.0, IE/8-10/Win 7, IE/7/Vista, OpenSSL/0.9.8y, Java/7u25, Android/2.3.7, Java/6u45\n\nPORT 993\n--------\n\n  * Deflate Compression:\n      OK - Compression disabled          \n\nUnhandled exception when processing --reneg: \n_nassl.OpenSSLError - error:140940F5:SSL routines:ssl3_read_bytes:unexpected record\n\n  * OpenSSL Heartbleed:\n      OK - Not vulnerable to Heartbleed  \n\n  * Session Resumption:\n      With Session IDs:                  NOT SUPPORTED (0 successful, 5 failed, 0 errors, 5 total attempts).\n      With TLS Session Tickets:          NOT SUPPORTED - TLS ticket assigned but not accepted.\n\n  * SSLV2 Cipher Suites:\n      Server rejected all cipher suites.\n\n  * TLSV1_2 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES128-GCM-SHA256   ECDH-384 bits  128 bits                                         \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA384       ECDH-384 bits  256 bits                                         \n                 ECDHE-RSA-AES256-SHA          ECDH-384 bits  256 bits                                         \n                 ECDHE-RSA-AES256-GCM-SHA384   ECDH-384 bits  256 bits                                         \n                 DHE-RSA-AES256-SHA256         DH-2048 bits   256 bits                                         \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits                                         \n                 DHE-RSA-AES256-GCM-SHA384     DH-2048 bits   256 bits                                         \n                 AES256-SHA256                 -              256 bits                                         \n                 AES256-SHA                    -              256 bits                                         \n                 AES256-GCM-SHA384             -              256 bits                                         \n                 ECDHE-RSA-AES128-SHA256       ECDH-384 bits  128 bits                                         \n                 ECDHE-RSA-AES128-SHA          ECDH-384 bits  128 bits                                         \n                 ECDHE-RSA-AES128-GCM-SHA256   ECDH-384 bits  128 bits                                         \n                 DHE-RSA-AES128-SHA256         DH-2048 bits   128 bits                                         \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits                                         \n                 DHE-RSA-AES128-GCM-SHA256     DH-2048 bits   128 bits                                         \n                 AES128-SHA256                 -              128 bits                                         \n                 AES128-SHA                    -              128 bits                                         \n                 AES128-GCM-SHA256             -              128 bits                                         \n\n  * TLSV1_1 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES128-SHA          ECDH-384 bits  128 bits                                         \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA          ECDH-384 bits  256 bits                                         \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits                                         \n                 AES256-SHA                    -              256 bits                                         \n                 ECDHE-RSA-AES128-SHA          ECDH-384 bits  128 bits                                         \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits                                         \n                 AES128-SHA                    -              128 bits                                         \n\n  * SSLV3 Cipher Suites:\n      Server rejected all cipher suites.\n\n  * TLSV1 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES128-SHA          ECDH-384 bits  128 bits                                         \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA          ECDH-384 bits  256 bits                                         \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits                                         \n                 AES256-SHA                    -              256 bits                                         \n                 ECDHE-RSA-AES128-SHA          ECDH-384 bits  128 bits                                         \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits                                         \n                 AES128-SHA                    -              128 bits\n\n  Should Not Offer: AES128-GCM-SHA256, AES128-SHA, AES128-SHA256, AES256-GCM-SHA384, AES256-SHA, AES256-SHA256, DHE-RSA-AES128-GCM-SHA256, DHE-RSA-AES128-SHA, DHE-RSA-AES128-SHA256, DHE-RSA-AES256-GCM-SHA384, DHE-RSA-AES256-SHA, DHE-RSA-AES256-SHA256, ECDHE-RSA-AES128-SHA, ECDHE-RSA-AES256-SHA\n  Could Also Offer: ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-SHA256, ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-SHA384, ECDHE-ECDSA-CHACHA20-POLY1305, ECDHE-RSA-CHACHA20-POLY1305\n  Supported Clients: Yahoo Slurp/Jan 2015, OpenSSL/1.0.2, YandexBot/Jan 2015, BingPreview/Jan 2015, OpenSSL/1.0.1l, Android/4.4.2, Safari/6/iOS 6.0.1, Safari/8/OS X 10.10, Safari/7/OS X 10.9, Safari/7/iOS 7.1, IE/11/Win 8.1, Safari/8/iOS 8.1.2, IE Mobile/11/Win Phone 8.1, IE/11/Win 7, Android/5.0.0, Chrome/42/OS X, Java/8u31, Googlebot/Feb 2015, Firefox/31.3.0 ESR/Win 7, Firefox/37/OS X, Android/4.3, Android/4.2.2, Baidu/Jan 2015, Safari/5.1.9/OS X 10.6.8, Android/4.0.4, Android/4.1.1, Safari/6.0.4/OS X 10.8.4, IE Mobile/10/Win Phone 8.0, IE/8-10/Win 7, IE/7/Vista, OpenSSL/0.9.8y, Java/7u25, Android/2.3.7, Java/6u45\n\nPORT 995\n--------\n\n  * Deflate Compression:\n      OK - Compression disabled          \n\nUnhandled exception when processing --reneg: \n_nassl.OpenSSLError - error:140940F5:SSL routines:ssl3_read_bytes:unexpected record\n\n  * OpenSSL Heartbleed:\n      OK - Not vulnerable to Heartbleed  \n\n  * Session Resumption:\n      With Session IDs:                  NOT SUPPORTED (0 successful, 5 failed, 0 errors, 5 total attempts).\n      With TLS Session Tickets:          NOT SUPPORTED - TLS ticket assigned but not accepted.\n\n  * SSLV2 Cipher Suites:\n      Server rejected all cipher suites.\n\n  * TLSV1_2 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES128-GCM-SHA256   ECDH-384 bits  128 bits                                         \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA384       ECDH-384 bits  256 bits                                         \n                 ECDHE-RSA-AES256-SHA          ECDH-384 bits  256 bits                                         \n                 ECDHE-RSA-AES256-GCM-SHA384   ECDH-384 bits  256 bits                                         \n                 DHE-RSA-AES256-SHA256         DH-2048 bits   256 bits                                         \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits                                         \n                 DHE-RSA-AES256-GCM-SHA384     DH-2048 bits   256 bits                                         \n                 AES256-SHA256                 -              256 bits                                         \n                 AES256-SHA                    -              256 bits                                         \n                 AES256-GCM-SHA384             -              256 bits                                         \n                 ECDHE-RSA-AES128-SHA256       ECDH-384 bits  128 bits                                         \n                 ECDHE-RSA-AES128-SHA          ECDH-384 bits  128 bits                                         \n                 ECDHE-RSA-AES128-GCM-SHA256   ECDH-384 bits  128 bits                                         \n                 DHE-RSA-AES128-SHA256         DH-2048 bits   128 bits                                         \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits                                         \n                 DHE-RSA-AES128-GCM-SHA256     DH-2048 bits   128 bits                                         \n                 AES128-SHA256                 -              128 bits                                         \n                 AES128-SHA                    -              128 bits                                         \n                 AES128-GCM-SHA256             -              128 bits                                         \n\n  * TLSV1_1 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES128-SHA          ECDH-384 bits  128 bits                                         \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA          ECDH-384 bits  256 bits                                         \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits                                         \n                 AES256-SHA                    -              256 bits                                         \n                 ECDHE-RSA-AES128-SHA          ECDH-384 bits  128 bits                                         \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits                                         \n                 AES128-SHA                    -              128 bits                                         \n\n  * SSLV3 Cipher Suites:\n      Server rejected all cipher suites.\n\n  * TLSV1 Cipher Suites:\n      Preferred:                       \n                 ECDHE-RSA-AES128-SHA          ECDH-384 bits  128 bits                                         \n      Accepted:                        \n                 ECDHE-RSA-AES256-SHA          ECDH-384 bits  256 bits                                         \n                 DHE-RSA-AES256-SHA            DH-2048 bits   256 bits                                         \n                 AES256-SHA                    -              256 bits                                         \n                 ECDHE-RSA-AES128-SHA          ECDH-384 bits  128 bits                                         \n                 DHE-RSA-AES128-SHA            DH-2048 bits   128 bits                                         \n                 AES128-SHA                    -              128 bits\n\n  Should Not Offer: AES128-GCM-SHA256, AES128-SHA, AES128-SHA256, AES256-GCM-SHA384, AES256-SHA, AES256-SHA256, DHE-RSA-AES128-GCM-SHA256, DHE-RSA-AES128-SHA, DHE-RSA-AES128-SHA256, DHE-RSA-AES256-GCM-SHA384, DHE-RSA-AES256-SHA, DHE-RSA-AES256-SHA256, ECDHE-RSA-AES128-SHA, ECDHE-RSA-AES256-SHA\n  Could Also Offer: ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-SHA256, ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-SHA384, ECDHE-ECDSA-CHACHA20-POLY1305, ECDHE-RSA-CHACHA20-POLY1305\n  Supported Clients: Yahoo Slurp/Jan 2015, OpenSSL/1.0.2, YandexBot/Jan 2015, BingPreview/Jan 2015, OpenSSL/1.0.1l, Android/4.4.2, Safari/6/iOS 6.0.1, Safari/8/OS X 10.10, Safari/7/OS X 10.9, Safari/7/iOS 7.1, IE/11/Win 8.1, Safari/8/iOS 8.1.2, IE Mobile/11/Win Phone 8.1, IE/11/Win 7, Android/5.0.0, Chrome/42/OS X, Java/8u31, Googlebot/Feb 2015, Firefox/31.3.0 ESR/Win 7, Firefox/37/OS X, Android/4.3, Android/4.2.2, Baidu/Jan 2015, Safari/5.1.9/OS X 10.6.8, Android/4.0.4, Android/4.1.1, Safari/6.0.4/OS X 10.8.4, IE Mobile/10/Win Phone 8.0, IE/8-10/Win 7, IE/7/Vista, OpenSSL/0.9.8y, Java/7u25, Android/2.3.7, Java/6u45\n\n"
  },
  {
    "path": "tools/archive_conf_files.sh",
    "content": "#!/bin/bash\n# Use this script to make an archive of the contents of all\n# of the configuration files we edit with editconf.py.\nfor fn in $(grep -hr editconf.py setup | sed \"s/tools\\/editconf.py //\" | sed \"s/ .*//\" | sort | uniq); do\n\techo ======================================================================\n\techo \"$fn\"\n\techo ======================================================================\n\tcat \"$fn\"\ndone\n\n"
  },
  {
    "path": "tools/dns_update",
    "content": "#!/bin/bash\nPOSTDATA=dummy\nif [ \"$1\" == \"--force\" ]; then\n\tPOSTDATA=force=1\nfi\ncurl -s -d $POSTDATA --user \"$(</var/lib/mailinabox/api.key):\" http://127.0.0.1:10222/dns/update\n"
  },
  {
    "path": "tools/editconf.py",
    "content": "#!/usr/bin/python3\n#\n# This is a helper tool for editing configuration files during the setup\n# process. The tool is given new values for settings as command-line\n# arguments. It comments-out existing setting values in the configuration\n# file and adds new values either after their former location or at the\n# end.\n#\n# The configuration file has settings that look like:\n#\n# NAME=VALUE\n#\n# If the -s option is given, then space becomes the delimiter, i.e.:\n#\n# NAME VALUE\n#\n# If the -e option is given and VALUE is empty, the setting is removed\n# from the configuration file if it is set (i.e. existing occurrences\n# are commented out and no new setting is added).\n#\n# If the -c option is given, then the supplied character becomes the comment character\n#\n# If the -w option is given, then setting lines continue onto following\n# lines while the lines start with whitespace, e.g.:\n#\n# NAME VAL\n#   UE\n\nimport sys, re\n\n# sanity check\nif len(sys.argv) < 3:\n\tprint(\"usage: python3 editconf.py /etc/file.conf [-e] [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]\")\n\tsys.exit(1)\n\n# parse command line arguments\nfilename = sys.argv[1]\nsettings = sys.argv[2:]\n\ndelimiter = \"=\"\ndelimiter_re = r\"\\s*=\\s*\"\nerase_setting = False\ncomment_char = \"#\"\nfolded_lines = False\ntesting = False\nwhile settings[0][0] == \"-\" and settings[0] != \"--\":\n\topt = settings.pop(0)\n\tif opt == \"-s\":\n\t\t# Space is the delimiter\n\t\tdelimiter = \" \"\n\t\tdelimiter_re = r\"\\s+\"\n\telif opt == \"-e\":\n\t\t# Erase settings that have empty values.\n\t\terase_setting = True\n\telif opt == \"-w\":\n\t\t# Line folding is possible in this file.\n\t\tfolded_lines = True\n\telif opt == \"-c\":\n\t\t# Specifies a different comment character.\n\t\tcomment_char = settings.pop(0)\n\telif opt == \"-t\":\n\t\ttesting = True\n\telse:\n\t\tprint(\"Invalid option.\")\n\t\tsys.exit(1)\n\n# sanity check command line\nfor setting in settings:\n\ttry:\n\t\tname, value = setting.split(\"=\", 1)\n\texcept:\n\t\timport subprocess\n\t\tprint(\"Invalid command line: \", subprocess.list2cmdline(sys.argv))\n\n# create the new config file in memory\n\nfound = set()\nbuf = \"\"\nwith open(filename, encoding=\"utf-8\") as f:\n        input_lines = list(f)\n\nwhile len(input_lines) > 0:\n\tline = input_lines.pop(0)\n\n\t# If this configuration file uses folded lines, append any folded lines\n\t# into our input buffer.\n\tif folded_lines and line[0] not in {comment_char, \" \", \"\"}:\n\t\twhile len(input_lines) > 0 and input_lines[0][0] in \" \\t\":\n\t\t\tline += input_lines.pop(0)\n\n\t# See if this line is for any settings passed on the command line.\n\tfor i in range(len(settings)):\n\t\t# Check if this line contain this setting from the command-line arguments.\n\t\tname, val = settings[i].split(\"=\", 1)\n\t\tm = re.match(\n\t\t\t   r\"(\\s*)\"\n\t\t\t \"(\" + re.escape(comment_char) + r\"\\s*)?\"\n\t\t\t + re.escape(name) + delimiter_re + r\"(.*?)\\s*$\",\n\t\t\t line, re.S)\n\t\tif not m: continue\n\t\tindent, is_comment, existing_val = m.groups()\n\n\t\t# If this is already the setting, keep it in the file, except:\n\t\t# * If we've already seen it before, then remove this duplicate line.\n\t\t# * If val is empty and erase_setting is on, then comment it out.\n\t\tif is_comment is None and existing_val == val and not (not val and erase_setting):\n\t\t\t# It may be that we've already inserted this setting higher\n\t\t\t# in the file so check for that first.\n\t\t\tif i in found: break\n\t\t\tbuf += line\n\t\t\tfound.add(i)\n\t\t\tbreak\n\n\t\t# comment-out the existing line (also comment any folded lines)\n\t\tif is_comment is None:\n\t\t\tbuf += comment_char + line.rstrip().replace(\"\\n\", \"\\n\" + comment_char) + \"\\n\"\n\t\telse:\n\t\t\t# the line is already commented, pass it through\n\t\t\tbuf += line\n\n\t\t# if this option already is set don't add the setting again,\n\t\t# or if we're clearing the setting with -e, don't add it\n\t\tif (i in found) or (not val and erase_setting):\n\t\t\tbreak\n\n\t\t# add the new setting\n\t\tbuf += indent + name + delimiter + val + \"\\n\"\n\n\t\t# note that we've applied this option\n\t\tfound.add(i)\n\n\t\tbreak\n\telse:\n\t\t# If did not match any setting names, pass this line through.\n\t\tbuf += line\n\n# Put any settings we didn't see at the end of the file,\n# except settings being cleared.\nfor i in range(len(settings)):\n\tif i not in found:\n\t\tname, val = settings[i].split(\"=\", 1)\n\t\tif not (not val and erase_setting):\n\t\t\tbuf += name + delimiter + val + \"\\n\"\n\nif not testing:\n\t# Write out the new file.\n\twith open(filename, \"w\", encoding=\"utf-8\") as f:\n\t\tf.write(buf)\nelse:\n\t# Just print the new file to stdout.\n\tprint(buf)\n"
  },
  {
    "path": "tools/mail.py",
    "content": "#!/bin/bash\n# This script has moved.\nmanagement/cli.py \"$@\"\n"
  },
  {
    "path": "tools/owncloud-restore.sh",
    "content": "#!/bin/bash\n#\n# This script will restore the backup made during an installation\nsource /etc/mailinabox.conf # load global vars\n\nif [ -z \"$1\" ]; then\n\techo \"Usage: owncloud-restore.sh <backup directory>\"\n\techo\n\techo \"WARNING: This will restore the database to the point of the installation!\"\n\techo \"         This means that you will lose all changes made by users after that point\"\n\techo\n\techo\n\techo \"Backups are stored here: $STORAGE_ROOT/owncloud-backup/\"\n\techo\n\techo \"Available backups:\"\n\techo\n\tfind \"$STORAGE_ROOT/owncloud-backup/\"* -maxdepth 0 -type d\n\techo\n\techo \"Supply the directory that was created during the last installation as the only commandline argument\"\n\texit\nfi\n\nif [ ! -f \"$1/config.php\" ]; then\n\techo \"This isn't a valid backup location\"\n\texit 1\nfi\n\necho \"Restoring backup from $1\"\nservice php8.0-fpm stop\n\n# remove the current ownCloud/Nextcloud installation\nrm -rf /usr/local/lib/owncloud/\n# restore the current ownCloud/Nextcloud application\ncp -r  \"$1/owncloud-install\" /usr/local/lib/owncloud\n\n# restore access rights\nchmod 750 /usr/local/lib/owncloud/{apps,config}\n\ncp \"$1/owncloud.db\" \"$STORAGE_ROOT/owncloud/\"\ncp \"$1/config.php\" \"$STORAGE_ROOT/owncloud/\"\n\nln -sf \"$STORAGE_ROOT/owncloud/config.php\" /usr/local/lib/owncloud/config/config.php\nchown -f -R www-data:www-data \"$STORAGE_ROOT/owncloud\" /usr/local/lib/owncloud\nchown www-data:www-data \"$STORAGE_ROOT/owncloud/config.php\"\n\nsudo -u www-data \"php$PHP_VER\" /usr/local/lib/owncloud/occ maintenance:mode --off\n\nservice php8.0-fpm start\necho \"Done\"\n"
  },
  {
    "path": "tools/owncloud-unlockadmin.sh",
    "content": "#!/bin/bash\n#\n# This script will give you administrative access to the Nextcloud\n# instance running here.\n#\n# Run this at your own risk. This is for testing & experimentation\n# purpopses only. After this point you are on your own.\n\nsource /etc/mailinabox.conf # load global vars\n\nADMIN=$(./mail.py user admins | head -n 1)\ntest -z \"$1\" || ADMIN=$1\n\necho \"I am going to unlock admin features for $ADMIN.\"\necho \"You can provide another user to unlock as the first argument of this script.\"\necho\necho \"WARNING: you could break mail-in-a-box when fiddling around with Nextcloud's admin interface\"\necho \"If in doubt, press CTRL-C to cancel.\"\necho\necho \"Press enter to continue.\"\nread\n\nsudo -u www-data \"php$PHP_VER\" /usr/local/lib/owncloud/occ group:adduser admin \"$ADMIN\" && echo \"Done.\"\n"
  },
  {
    "path": "tools/parse-nginx-log-bootstrap-accesses.py",
    "content": "#!/usr/bin/python3\n#\n# This is a tool Josh uses on his box serving mailinabox.email to parse the nginx\n# access log to see how many people are installing Mail-in-a-Box each day, by\n# looking at accesses to the bootstrap.sh script (which is currently at the URL\n# .../setup.sh).\n\nimport re, glob, gzip, os.path, json\nimport dateutil.parser\n\noutfn = \"/home/user-data/www/mailinabox.email/install-stats.json\"\n\n# Make a unique list of (date, ip address) pairs so we don't double-count\n# accesses that are for the same install.\naccesses = set()\n\n# Scan the current and rotated access logs.\nfor fn in glob.glob(\"/var/log/nginx/access.log*\"):\n\t# Gunzip if necessary.\n\t# Loop through the lines in the access log.\n\twith (gzip.open if fn.endswith(\".gz\") else open)(fn, \"rb\") as f:\n\t\tfor line in f:\n\t\t\t# Find lines that are GETs on the bootstrap script by either curl or wget.\n\t\t\t# (Note that we purposely skip ...?ping=1 requests which is the admin panel querying us for updates.)\n\t\t\t# (Also, the URL changed in January 2016, but we'll accept both.)\n\t\t\tm = re.match(rb\"(?P<ip>\\S+) - - \\[(?P<date>.*?)\\] \\\"GET /(bootstrap.sh|setup.sh) HTTP/.*\\\" 200 \\d+ .* \\\"(?:curl|wget)\", line, re.I)\n\t\t\tif m:\n\t\t\t\tdate, time = m.group(\"date\").decode(\"ascii\").split(\":\", 1)\n\t\t\t\tdate = dateutil.parser.parse(date).date().isoformat()\n\t\t\t\tip = m.group(\"ip\").decode(\"ascii\")\n\t\t\t\taccesses.add( (date, ip) )\n\n# Aggregate by date.\nby_date = { }\nfor date, ip in accesses:\n\tby_date[date] = by_date.get(date, 0) + 1\n\n# Since logs are rotated, store the statistics permanently in a JSON file.\n# Load in the stats from an existing file.\nif os.path.exists(outfn):\n\twith open(outfn, encoding=\"utf-8\") as f:\n\t\texisting_data = json.load(f)\n\tfor date, count in existing_data:\n\t\tif date not in by_date:\n\t\t\tby_date[date] = count\n\n# Turn into a list rather than a dict structure to make it ordered.\nby_date = sorted(by_date.items())\n\n# Pop the last one because today's stats are incomplete.\nby_date.pop(-1)\n\n# Write out.\nwith open(outfn, \"w\", encoding=\"utf-8\") as f:\n\tjson.dump(by_date, f, sort_keys=True, indent=True)\n"
  },
  {
    "path": "tools/readable_bash.py",
    "content": "#!/usr/bin/python3\n#\n# Generate documentation for how this machine works by\n# parsing our bash scripts!\n\nimport cgi, re\nimport markdown\nfrom modgrammar import *\n\ndef generate_documentation():\n\tprint(\"\"\"<!DOCTYPE html>\n<html>\n    <head>\n        <meta charset=\"utf-8\">\n        <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge,chrome=1\">\n        <meta name=\"viewport\" content=\"width=device-width\">\n\n        <title>Build Your Own Mail Server From Scratch</title>\n\n        <link rel=\"stylesheet\" href=\"https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css\">\n        <link rel=\"stylesheet\" href=\"https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css\">\n\n        <style>\n\t\t    @import url(https://fonts.googleapis.com/css?family=Iceland);\n\t\t    @import url(https://fonts.googleapis.com/css?family=Raleway:400,700);\n\t\t\t@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,500);\n\t\t   \tbody {\n\t\t    \t\tfont-family: Raleway, sans-serif;\n\t\t    \t\tfont-size: 16px;\n\t\t\t\t\tcolor: #555;\n\t    \t}\n\t    \th2, h3 {\n\t    \t\tmargin-top: .25em;\n\t    \t\tmargin-bottom: .75em;\n\t    \t}\n\t    \tp {\n\t    \t\tmargin-bottom: 1em;\n\t    \t}\n\t\t    \t.intro p {\n\t\t    \t\tmargin: 1.5em 0;\n\t\t    \t}\n\t\t    li {\n\t\t    \tmargin-bottom: .33em;\n\t\t    }\n\n\t\t\t.sourcefile {\n\t    \t\tpadding-top: 1.5em;\n\t    \t\tpadding-bottom: 1em;\n\t    \t\tfont-size: 90%;\n\t    \t\ttext-align: right;\n\t\t\t}\n\t\t\t\t.sourcefile a {\n\t\t\t\t\tcolor: red;\n\t\t\t\t}\n\n\t    \t.instructions .row.contd {\n\t    \t\tborder-top: 1px solid #E0E0E0;\n\t    \t}\n\n\t    \t.prose {\n\t    \t\tpadding-top: 1em;\n\t    \t\tpadding-bottom: 1em;\n\t    \t}\n\t    \t.terminal {\n\t    \t\tbackground-color: #EEE;\n\t    \t\tpadding-top: 1em;\n\t    \t\tpadding-bottom: 1em;\n\t    \t}\n\n\t    \tul {\n\t    \t\tpadding-left: 1.25em;\n\t    \t}\n\n\t    \tpre {\n\t    \t\tcolor: black;\n\t    \t\tborder: 0;\n\t    \t\tbackground: none;\n\t    \t\tfont-size: 100%;\n\t    \t}\n\n\t    \tdiv.write-to {\n\t    \t\tmargin: 0 0 1em .5em;\n\t    \t}\n\t    \tdiv.write-to p {\n\t    \t\tpadding: .5em;\n\t    \t\tmargin: 0;\n\t    \t}\n\t    \tdiv.write-to .filename {\n\t    \t\tpadding: .25em .5em;\n\t    \t\tbackground-color: #666;\n\t    \t\tcolor: white;\n\t    \t\tfont-family: monospace;\n\t    \t\tfont-weight: bold;\n\t    \t}\n\t    \tdiv.write-to .filename span {\n\t    \t\tfont-family: sans-serif;\n\t    \t\tfont-weight: normal;\n\t    \t}\n\t    \tdiv.write-to pre {\n\t    \t\tmargin: 0;\n\t    \t\tpadding: .5em;\n\t    \t\tborder: 1px solid #999;\n\t    \t\tborder-radius: 0;\n\t    \t\tfont-size: 90%;\n\t    \t}\n\n\t    \tpre.shell > div:before {\n\t    \t\tcontent: \"$ \";\n\t    \t\tcolor: #666;\n\t    \t}\n        </style>\n    </head>\n    <body>\n    <div class=\"container\">\n      <div class=\"row intro\">\n        <div class=\"col-xs-12\">\n        <h1>Build Your Own Mail Server From Scratch</h1>\n        <p>Here&rsquo;s how you can build your own mail server from scratch.</p>\n        <p>This document is generated automatically from <a href=\"https://mailinabox.email\">Mail-in-a-Box</a>&rsquo;s setup script <a href=\"https://github.com/mail-in-a-box/mailinabox\">source code</a>.</p>\n        <hr>\n      </div>\n    </div>\n    <div class=\"container instructions\">\n \"\"\")\n\n\tparser = Source.parser()\n\twith open(\"setup/start.sh\", encoding=\"utf-8\") as start_file:\n\t\tfor line in start_file:\n\t\t\ttry:\n\t\t\t\tfn = parser.parse_string(line).filename()\n\t\t\texcept:\n\t\t\t\tcontinue\n\t\t\tif fn in {\"setup/start.sh\", \"setup/preflight.sh\", \"setup/questions.sh\", \"setup/firstuser.sh\", \"setup/management.sh\"}:\n\t\t\t\tcontinue\n\n\t\timport sys\n\t\tprint(fn, file=sys.stderr)\n\n\t\tprint(BashScript.parse(fn))\n\n\tprint(\"\"\"\n        <script src=\"https://ajax.googleapis.com/ajax/libs/jquery/1.10.1/jquery.min.js\"></script>\n        <script src=\"https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js\"></script>\n        <script>\n        $(function() {\n\t\t\t$('.terminal').each(function() {\n\t\t\t  $(this).outerHeight( $(this).parent().innerHeight() );\n\t\t\t});\n        })\n\t\t</script>\n    </body>\n</html>\n\"\"\")\n\nclass HashBang(Grammar):\n\tgrammar = (L('#!'), REST_OF_LINE, EOL)\n\tdef value(self):\n\t\treturn \"\"\n\ndef strip_indent(s):\n\ts = s.replace(\"\\t\", \"    \")\n\tlines = s.split(\"\\n\")\n\ttry:\n\t\tmin_indent = min(len(re.match(r\"\\s*\", line).group(0)) for line in lines if len(line) > 0)\n\texcept ValueError:\n\t\t# No non-empty lines.\n\t\tmin_indent = 0\n\tlines = [line[min_indent:] for line in lines]\n\treturn \"\\n\".join(lines)\n\nclass Comment(Grammar):\n\tgrammar = ONE_OR_MORE(ZERO_OR_MORE(SPACE), L('#'), REST_OF_LINE, EOL)\n\tdef value(self):\n\t\tif self.string.replace(\"#\", \"\").strip() == \"\":\n\t\t\treturn \"\\n\"\n\t\tlines = [x[2].string for x in self[0]]\n\t\tcontent = \"\\n\".join(lines)\n\t\tcontent = strip_indent(content)\n\t\treturn markdown.markdown(content, output_format=\"html4\") + \"\\n\\n\"\n\nFILENAME = WORD('a-z0-9-/.')\n\nclass Source(Grammar):\n\tgrammar = ((L('.') | L('source')), L(' '), FILENAME, Comment | EOL)\n\tdef filename(self):\n\t\treturn self[2].string.strip()\n\tdef value(self):\n\t\treturn BashScript.parse(self.filename())\n\nclass CatEOF(Grammar):\n\tgrammar = (ZERO_OR_MORE(SPACE), L('cat '), L('>') | L('>>'), L(' '), ANY_EXCEPT(WHITESPACE), L(\" <<\"), OPTIONAL(SPACE), L(\"EOF\"), EOL, REPEAT(ANY, greedy=False), EOL, L(\"EOF\"), EOL)\n\tdef value(self):\n\t\tcontent = self[9].string\n\t\tcontent = re.sub(r\"\\\\([$])\", r\"\\1\", content) # un-escape bash-escaped characters\n\t\treturn \"<div class='write-to'><div class='filename'>{} <span>({})</span></div><pre>{}</pre></div>\\n\".format(self[4].string,\n\t\t\t   \"overwrite\" if \">>\" not in self[2].string else \"append to\",\n\t\t\t   cgi.escape(content))\n\nclass HideOutput(Grammar):\n\tgrammar = (L(\"hide_output \"), REF(\"BashElement\"))\n\tdef value(self):\n\t\treturn self[1].value()\n\nclass EchoLine(Grammar):\n\tgrammar = (OPTIONAL(SPACE), L(\"echo \"), REST_OF_LINE, EOL)\n\tdef value(self):\n\t\tif \"|\" in self.string  or \">\" in self.string:\n\t\t\treturn \"<pre class='shell'><div>\" + recode_bash(self.string.strip()) + \"</div></pre>\\n\"\n\t\treturn \"\"\n\nclass EditConf(Grammar):\n\tgrammar = (\n\t\tL('tools/editconf.py '),\n\t\tFILENAME,\n\t\tSPACE,\n\t\tOPTIONAL((LIST_OF(\n\t\t\tL(\"-w\") | L(\"-s\") | L(\"-c ;\"),\n\t\t\tsep=SPACE,\n\t\t), SPACE)),\n\t\tREST_OF_LINE,\n\t\tOPTIONAL(SPACE),\n\t\tEOL\n\t\t)\n\tdef value(self):\n\t\t# conffile = self[1]\n\t\toptions = []\n\t\teq = \"=\"\n\t\tif self[3] and \"-s\" in self[3].string: eq = \" \"\n\t\tfor opt in re.split(r\"\\s+\", self[4].string):\n\t\t\tk, v = opt.split(\"=\", 1)\n\t\t\tv = re.sub(r\"\\n+\", \"\", fixup_tokens(v)) # not sure why newlines are getting doubled\n\t\t\toptions.append(f\"{k}{eq}{v}\")\n\t\treturn \"<div class='write-to'><div class='filename'>\" + self[1].string + \" <span>(change settings)</span></div><pre>\" + \"\\n\".join(cgi.escape(s) for s in options) + \"</pre></div>\\n\"\n\nclass CaptureOutput(Grammar):\n\tgrammar = OPTIONAL(SPACE), WORD(\"A-Za-z_\"), L('=$('), REST_OF_LINE, L(\")\"), OPTIONAL(L(';')), EOL\n\tdef value(self):\n\t\tcmd = self[3].string\n\t\tcmd = cmd.replace(\"; \", \"\\n\")\n\t\treturn \"<div class='write-to'><div class='filename'>$\" + self[1].string + \"=</div><pre>\" + cgi.escape(cmd) + \"</pre></div>\\n\"\n\nclass SedReplace(Grammar):\n\tgrammar = OPTIONAL(SPACE), L('sed -i \"s/'), OPTIONAL(L('^')), ONE_OR_MORE(WORD(\"-A-Za-z0-9 #=\\\\{};.*$_!()\")), L('/'), ONE_OR_MORE(WORD(\"-A-Za-z0-9 #=\\\\{};.*$_!()\")), L('/\"'), SPACE, FILENAME, EOL\n\tdef value(self):\n\t\treturn \"<div class='write-to'><div class='filename'>edit<br>\" + self[8].string + \"</div><p>replace</p><pre>\" + cgi.escape(self[3].string.replace(\".*\", \". . .\")) + \"</pre><p>with</p><pre>\" + cgi.escape(self[5].string.replace(\"\\\\n\", \"\\n\").replace(\"\\\\t\", \"\\t\")) + \"</pre></div>\\n\"\n\nclass EchoPipe(Grammar):\n\tgrammar = OPTIONAL(SPACE), L(\"echo \"), REST_OF_LINE, L(' | '), REST_OF_LINE, EOL\n\tdef value(self):\n\t\ttext = \" \".join(f'\"{s}\"' for s in self[2].string.split(\" \"))\n\t\treturn \"<pre class='shell'><div>echo \" + recode_bash(text) + r\" \\<br> | \" + recode_bash(self[4].string) + \"</div></pre>\\n\"\n\ndef shell_line(bash):\n\treturn \"<pre class='shell'><div>\" + recode_bash(bash.strip()) + \"</div></pre>\\n\"\n\nclass AptGet(Grammar):\n\tgrammar = (ZERO_OR_MORE(SPACE), L(\"apt_install \"), REST_OF_LINE, EOL)\n\tdef value(self):\n\t\treturn shell_line(\"apt-get install -y \" + re.sub(r\"\\s+\", \" \", self[2].string))\nclass UfwAllow(Grammar):\n\tgrammar = (ZERO_OR_MORE(SPACE), L(\"ufw_allow \"), REST_OF_LINE, EOL)\n\tdef value(self):\n\t\treturn shell_line(\"ufw allow \" + self[2].string)\nclass UfwLimit(Grammar):\n\tgrammar = (ZERO_OR_MORE(SPACE), L(\"ufw_limit \"), REST_OF_LINE, EOL)\n\tdef value(self):\n\t\treturn shell_line(\"ufw limit \" + self[2].string)\nclass RestartService(Grammar):\n\tgrammar = (ZERO_OR_MORE(SPACE), L(\"restart_service \"), REST_OF_LINE, EOL)\n\tdef value(self):\n\t\treturn shell_line(\"service \" + self[2].string + \" restart\")\n\nclass OtherLine(Grammar):\n\tgrammar = (REST_OF_LINE, EOL)\n\tdef value(self):\n\t\tif self.string.strip() == \"\": return \"\"\n\t\tif \"source setup/functions.sh\" in self.string: return \"\"\n\t\tif \"source /etc/mailinabox.conf\" in self.string: return \"\"\n\t\treturn \"<pre class='shell'><div>\" + recode_bash(self.string.strip()) + \"</div></pre>\\n\"\n\nclass BashElement(Grammar):\n\tgrammar = Comment | CatEOF | EchoPipe | EchoLine | HideOutput | EditConf | SedReplace | AptGet | UfwAllow | UfwLimit | RestartService | OtherLine\n\tdef value(self):\n\t\treturn self[0].value()\n\n# Make some special characters to private use Unicode code points.\nbash_special_characters1 = {\n\t\"\\n\": \"\\uE000\",\n\t\" \": \"\\uE001\",\n}\nbash_special_characters2 = {\n\t\"$\": \"\\uE010\",\n}\nbash_escapes = {\n\t\"n\": \"\\uE020\",\n\t\"t\": \"\\uE021\",\n}\n\ndef quasitokenize(bashscript):\n\t# Make a parse of bash easier by making the tokenization easy.\n\tnewscript = \"\"\n\tquote_mode = None\n\tescape_next = False\n\tline_comment = False\n\tsubshell = 0\n\tfor c in bashscript:\n\t\tif line_comment:\n\t\t\t# We're in a comment until the end of the line.\n\t\t\tnewscript += c\n\t\t\tif c == '\\n':\n\t\t\t\tline_comment = False\n\t\telif escape_next:\n\t\t\t# Previous character was a \\. Normally the next character\n\t\t\t# comes through literally, but escaped newlines are line\n\t\t\t# continuations and some escapes are for special characters\n\t\t\t# which we'll recode and then turn back into escapes later.\n\t\t\tif c == \"\\n\":\n\t\t\t\tc = \" \"\n\t\t\telif c in bash_escapes:\n\t\t\t\tc = bash_escapes[c]\n\t\t\tnewscript += c\n\t\t\tescape_next = False\n\t\telif c == \"\\\\\":\n\t\t\t# Escaping next character.\n\t\t\tescape_next = True\n\t\telif quote_mode is None and c in {'\"', \"'\"}:\n\t\t\t# Starting a quoted word.\n\t\t\tquote_mode = c\n\t\telif c == quote_mode:\n\t\t\t# Ending a quoted word.\n\t\t\tquote_mode = None\n\t\telif quote_mode is not None and quote_mode != \"EOF\" and c in bash_special_characters1:\n\t\t\t# Replace special tokens within quoted words so that they\n\t\t\t# don't interfere with tokenization later.\n\t\t\tnewscript += bash_special_characters1[c]\n\t\telif quote_mode is None and c == '#':\n\t\t\t# Start of a line comment.\n\t\t\tnewscript += c\n\t\t\tline_comment = True\n\t\telif quote_mode is None and c == ';' and subshell == 0:\n\t\t\t# End of a statement.\n\t\t\tnewscript += \"\\n\"\n\t\telif quote_mode is None and c == '(':\n\t\t\t# Start of a subshell.\n\t\t\tnewscript += c\n\t\t\tsubshell += 1\n\t\telif quote_mode is None and c == ')':\n\t\t\t# End of a subshell.\n\t\t\tnewscript += c\n\t\t\tsubshell -= 1\n\t\telif quote_mode is None and c == '\\t':\n\t\t\t# Make these just spaces.\n\t\t\tif newscript[-1] != \" \":\n\t\t\t\tnewscript += \" \"\n\t\telif quote_mode is None and c == ' ':\n\t\t\t# Collapse consecutive spaces.\n\t\t\tif newscript[-1] != \" \":\n\t\t\t\tnewscript += \" \"\n\t\telif c in bash_special_characters2:\n\t\t\tnewscript += bash_special_characters2[c]\n\t\telse:\n\t\t\t# All other characters.\n\t\t\tnewscript += c\n\n\t\t# \"<< EOF\" escaping.\n\t\tif quote_mode is None and re.search(\"<<\\\\s*EOF\\n$\", newscript):\n\t\t\tquote_mode = \"EOF\"\n\t\telif quote_mode == \"EOF\" and re.search(\"\\nEOF\\n$\", newscript):\n\t\t\tquote_mode = None\n\n\treturn newscript\n\ndef recode_bash(s):\n\tdef requote(tok):\n\t\ttok = tok.replace(\"\\\\\", \"\\\\\\\\\")\n\t\tfor c in bash_special_characters2:\n\t\t\ttok = tok.replace(c, \"\\\\\" + c)\n\t\ttok = fixup_tokens(tok)\n\t\tif \" \" in tok or '\"' in tok:\n\t\t\ttok = tok.replace('\"', '\\\\\"')\n\t\t\ttok = '\"' + tok +'\"'\n\t\telse:\n\t\t\ttok = tok.replace(\"'\", \"\\\\'\")\n\t\treturn tok\n\treturn cgi.escape(\" \".join(requote(tok) for tok in s.split(\" \")))\n\ndef fixup_tokens(s):\n\tfor c, enc in bash_special_characters1.items():\n\t\ts = s.replace(enc, c)\n\tfor c, enc in bash_special_characters2.items():\n\t\ts = s.replace(enc, c)\n\tfor esc, c in bash_escapes.items():\n\t\ts = s.replace(c, \"\\\\\" + esc)\n\treturn s\n\nclass BashScript(Grammar):\n\tgrammar = (OPTIONAL(HashBang), REPEAT(BashElement))\n\tdef value(self):\n\t\treturn [line.value() for line in self[1]]\n\n\t@staticmethod\n\tdef parse(fn):\n\t\tif fn in {\"setup/functions.sh\", \"/etc/mailinabox.conf\"}: return \"\"\n\t\twith open(fn, encoding=\"utf-8\") as f:\n\t\t\tstring = f.read()\n\n\t\t# tokenize\n\t\tstring = re.sub(\".* #NODOC\\n\", \"\", string)\n\t\tstring = re.sub(\"\\n\\\\s*if .*then.*|\\n\\\\s*fi|\\n\\\\s*else|\\n\\\\s*elif .*\", \"\", string)\n\t\tstring = quasitokenize(string)\n\t\tstring = string.replace(r\"hide_output \", \"\")\n\n\t\tparser = BashScript.parser()\n\t\tresult = parser.parse_string(string)\n\n\t\tv = \"<div class='row'><div class='col-xs-12 sourcefile'>view the bash source for the following section at <a href=\\\"{}\\\">{}</a></div></div>\\n\".format(\"https://github.com/mail-in-a-box/mailinabox/tree/master/\" + fn, fn)\n\n\t\tmode = 0\n\t\tfor item in result.value():\n\t\t\tif item.strip() == \"\":\n\t\t\t\tpass\n\t\t\telif item.startswith(\"<p\") and not item.startswith(\"<pre\"):\n\t\t\t\tclz = \"\"\n\t\t\t\tif mode == 2:\n\t\t\t\t\tv += \"</div>\\n\" # col\n\t\t\t\t\tv += \"</div>\\n\" # row\n\t\t\t\t\tmode = 0\n\t\t\t\t\tclz = \"contd\"\n\t\t\t\tif mode == 0:\n\t\t\t\t\tv += f\"<div class='row {clz}'>\\n\"\n\t\t\t\t\tv += \"<div class='col-md-6 prose'>\\n\"\n\t\t\t\tv += item\n\t\t\t\tmode = 1\n\t\t\telif item.startswith(\"<h\"):\n\t\t\t\tif mode != 0:\n\t\t\t\t\tv += \"</div>\\n\" # col\n\t\t\t\t\tv += \"</div>\\n\" # row\n\t\t\t\tv += \"<div class='row'>\\n\"\n\t\t\t\tv += \"<div class='col-md-6 header'>\\n\"\n\t\t\t\tv += item\n\t\t\t\tv += \"</div>\\n\" # col\n\t\t\t\tv += \"<div class='col-md-6 terminal'> </div>\\n\"\n\t\t\t\tv += \"</div>\\n\" # row\n\t\t\t\tmode = 0\n\t\t\telse:\n\t\t\t\tif mode == 0:\n\t\t\t\t\tv += \"<div class='row'>\\n\"\n\t\t\t\t\tv += \"<div class='col-md-offset-6 col-md-6 terminal'>\\n\"\n\t\t\t\telif mode == 1:\n\t\t\t\t\tv += \"</div>\\n\"\n\t\t\t\t\tv += \"<div class='col-md-6 terminal'>\\n\"\n\t\t\t\tmode = 2\n\t\t\t\tv += item\n\n\t\tv += \"</div>\\n\" # col\n\t\tv += \"</div>\\n\" # row\n\n\t\tv = fixup_tokens(v)\n\n\t\tv = v.replace(\"</pre>\\n<pre class='shell'>\", \"\")\n\t\tv = re.sub(r\"<pre>([\\w\\W]*?)</pre>\", lambda m : \"<pre>\" + strip_indent(m.group(1)) + \"</pre>\", v)\n\n\t\tv = re.sub(r\"(\\$?)PRIMARY_HOSTNAME\", r\"<b>box.yourdomain.com</b>\", v)\n\t\tv = re.sub(r\"\\$STORAGE_ROOT\", r\"<b>$STORE</b>\", v)\n\t\treturn v.replace(\"`pwd`\",  \"<code><b>/path/to/mailinabox</b></code>\")\n\n\ndef wrap_lines(text, cols=60):\n\tret = \"\"\n\twords = re.split(r\"(\\s+)\", text)\n\tlinelen = 0\n\tfor w in words:\n\t\tif linelen + len(w) > cols-1:\n\t\t\tret += \" \\\\\\n\"\n\t\t\tret += \"   \"\n\t\t\tlinelen = 0\n\t\tif linelen == 0 and w.strip() == \"\": continue\n\t\tret += w\n\t\tlinelen += len(w)\n\treturn ret\n\nif __name__ == '__main__':\n\tgenerate_documentation()\n"
  },
  {
    "path": "tools/ssl_cleanup",
    "content": "#!/bin/bash\n# Cleanup SSL certificates which expired more than 7 days ago from $STORAGE_ROOT/ssl and move them to $STORAGE_ROOT/ssl.expired\n\nsource /etc/mailinabox.conf\nshopt -s extglob nullglob\n\nretain_after=\"$(date --date=\"7 days ago\" +%Y%m%d)\"\n\nmkdir -p $STORAGE_ROOT/ssl.expired\nfor file in $STORAGE_ROOT/ssl/*-+([0-9])-+([0-9a-f]).pem; do\n  pem=\"$(basename \"$file\")\"\n  not_valid_after=\"$(cut -d- -f1 <<< \"${pem: -21}\")\"\n\n  if [ \"$not_valid_after\" -lt \"$retain_after\" ]; then\n    mv \"$file\" \"$STORAGE_ROOT/ssl.expired/${pem}\"\n  fi\ndone\n"
  },
  {
    "path": "tools/web_update",
    "content": "#!/bin/bash\ncurl -s -d POSTDATA --user \"$(</var/lib/mailinabox/api.key):\" http://127.0.0.1:10222/web/update\n"
  }
]