├── .editorconfig
├── .gitignore
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── Vagrantfile
├── api
├── docs
│ ├── generate-docs.sh
│ └── template.hbs
└── mailinabox.yml
├── conf
├── dovecot-mailboxes.conf
├── fail2ban
│ ├── filter.d
│ │ ├── dovecotimap.conf
│ │ ├── miab-management-daemon.conf
│ │ ├── miab-munin.conf
│ │ ├── miab-owncloud.conf
│ │ ├── miab-postfix-submission.conf
│ │ └── miab-roundcube.conf
│ └── jails.conf
├── ios-profile.xml
├── mailinabox.service
├── mozilla-autoconfig.xml
├── mta-sts.txt
├── munin.service
├── nginx-alldomains.conf
├── nginx-primaryonly.conf
├── nginx-ssl.conf
├── nginx-top.conf
├── nginx.conf
├── postfix_outgoing_mail_header_filters
├── sieve-spam.txt
├── www_default.html
└── zpush
│ ├── autodiscover_config.php
│ ├── backend_caldav.php
│ ├── backend_carddav.php
│ ├── backend_combined.php
│ └── backend_imap.php
├── management
├── auth.py
├── backup.py
├── cli.py
├── csr_country_codes.tsv
├── daemon.py
├── daily_tasks.sh
├── dns_update.py
├── email_administrator.py
├── mail_log.py
├── mailconfig.py
├── mfa.py
├── munin_start.sh
├── ssl_certificates.py
├── status_checks.py
├── templates
│ ├── aliases.html
│ ├── custom-dns.html
│ ├── external-dns.html
│ ├── index.html
│ ├── login.html
│ ├── mail-guide.html
│ ├── mfa.html
│ ├── munin.html
│ ├── ssl.html
│ ├── sync-guide.html
│ ├── system-backup.html
│ ├── system-status.html
│ ├── users.html
│ ├── web.html
│ └── welcome.html
├── utils.py
├── web_update.py
└── wsgi.py
├── security.md
├── setup
├── bootstrap.sh
├── dkim.sh
├── dns.sh
├── firstuser.sh
├── functions.sh
├── mail-dovecot.sh
├── mail-postfix.sh
├── mail-users.sh
├── management.sh
├── migrate.py
├── munin.sh
├── network-checks.sh
├── nextcloud.sh
├── preflight.sh
├── questions.sh
├── spamassassin.sh
├── ssl.sh
├── start.sh
├── system.sh
├── web.sh
├── webmail.sh
└── zpush.sh
├── tests
├── fail2ban.py
├── pip-requirements.txt
├── test_dns.py
├── test_mail.py
├── test_smtp_server.py
├── tls.py
└── tls_results.txt
└── tools
├── archive_conf_files.sh
├── dns_update
├── editconf.py
├── mail.py
├── owncloud-restore.sh
├── owncloud-unlockadmin.sh
├── parse-nginx-log-bootstrap-accesses.py
├── readable_bash.py
├── ssl_cleanup
└── web_update
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig helps developers define and maintain consistent
2 | # coding styles between different editors and IDEs
3 | # editorconfig.org
4 |
5 | root = true
6 |
7 | [*]
8 | indent_style = space
9 | indent_size = 4
10 | end_of_line = lf
11 | charset = utf-8
12 | trim_trailing_whitespace = true
13 | insert_final_newline = true
14 |
15 | [Makefile]
16 | indent_style = tab
17 | indent_size = 4
18 |
19 | [Vagrantfile]
20 | indent_size = 2
21 |
22 | [*.rb]
23 | indent_size = 2
24 |
25 | [*.py]
26 | indent_style = tab
27 |
28 | [*.js]
29 | indent_size = 2
30 |
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *~
2 | tests/__pycache__/
3 | management/__pycache__/
4 | tools/__pycache__/
5 | externals/
6 | .env
7 | .vagrant
8 | api/docs/api-docs.html
9 | *.code-workspace
10 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Mail-in-a-Box Code of Conduct
2 |
3 | Mail-in-a-Box is an open source community project about working, as a group, to empower ourselves and others to have control over our own digital communications. Just as we hope to increase technological diversity on the Internet through decentralization, we also believe that diverse viewpoints and voices among our community members foster innovation and creative solutions to the challenges we face.
4 |
5 | We are committed to providing a safe, welcoming, and harassment-free space for collaboration, for everyone, without regard to age, disability, economic situation, ethnicity, gender identity and expression, language fluency, level of knowledge or experience, nationality, personal appearance, race, religion, sexual identity and orientation, or any other attribute. Community comes first. This policy supersedes all other project goals.
6 |
7 | The maintainers of Mail-in-a-Box share the dual responsibility of leading by example and enforcing these policies as necessary to maintain an open and welcoming environment. All community members should be excellent to each other.
8 |
9 | ## Scope
10 |
11 | This Code of Conduct applies to all places where Mail-in-a-Box community activity is occurring, including on GitHub, in discussion forums, on Slack, on social media, and in real life. The Code of Conduct applies not only on websites/at events run by the Mail-in-a-Box community (e.g. our GitHub organization, our Slack team) but also at any other location where the Mail-in-a-Box community is present (e.g. in issues of other GitHub organizations where Mail-in-a-Box community members are discussing problems related to Mail-in-a-Box, or real-life professional conferences), or whenever a Mail-in-a-Box community member is representing Mail-in-a-Box to the public at large or acting on behalf of Mail-in-a-Box.
12 |
13 | This code does not apply to activity on a server running Mail-in-a-Box software, unless your server is hosting a service for the Mail-in-a-Box community at large.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to creating a positive environment include:
18 |
19 | * Using welcoming and inclusive language
20 | * Being respectful of differing viewpoints and experiences
21 | * Gracefully accepting constructive criticism
22 | * Showing empathy towards other community members
23 | * Making room for new and quieter voices
24 |
25 | Examples of unacceptable behavior by participants include:
26 |
27 | * The use of sexualized language or imagery and unwelcome sexual attention or advances
28 | * Trolling, insulting/derogatory/unwelcome comments, and personal or political attacks
29 | * Public or private harassment
30 | * Publishing others' private information, such as a physical or electronic address, without explicit permission
31 | * 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).
32 | * Other conduct which could reasonably be considered inappropriate in a professional setting
33 | * Retaliating against anyone who reports a violation of this code.
34 |
35 | We will not tolerate harassment. Harassment is any unwelcome or hostile behavior towards another person for any reason. This includes, but is not limited to, offensive verbal comments related to personal characteristics or choices, sexual images or comments, deliberate intimidation, bullying, stalking, following, harassing photography or recording, sustained disruption of discussion or events, nonconsensual publication of private comments, inappropriate physical contact, or unwelcome sexual attention. Conduct need not be intentional to be harassment.
36 |
37 | ## Enforcement
38 |
39 | We will remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not consistent with this Code of Conduct. We may ban, temporarily or permanently, any contributor for violating this code, when appropriate.
40 |
41 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project lead, [Joshua Tauberer](https://razor.occams.info/). All reports will be treated confidentially, impartially, consistently, and swiftly.
42 |
43 | Because the need for confidentiality for all parties involved in an enforcement action outweighs the goals of openness, limited information will be shared with the Mail-in-a-Box community regarding enforcement actions that have taken place.
44 |
45 | ## Attribution
46 |
47 | This Code of Conduct is adapted from the [Contributor Covenant, version 1.4](http://contributor-covenant.org/version/1/4) and the code of conduct of [Code for DC](http://codefordc.org/resources/codeofconduct.html).
48 |
49 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Mail-in-a-Box is an open source project. Your contributions and pull requests are welcome.
4 |
5 | ## Development
6 |
7 | To start developing Mail-in-a-Box, [clone the repository](https://github.com/mail-in-a-box/mailinabox) and familiarize yourself with the code.
8 |
9 | $ git clone https://github.com/mail-in-a-box/mailinabox
10 |
11 | ### Vagrant and VirtualBox
12 |
13 | We recommend you use [Vagrant](https://www.vagrantup.com/intro/getting-started/install.html) and [VirtualBox](https://www.virtualbox.org/wiki/Downloads) for development. Please install them first.
14 |
15 | With Vagrant set up, the following should boot up Mail-in-a-Box inside a virtual machine:
16 |
17 | $ vagrant up --provision
18 |
19 | _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._
20 |
21 | ### Modifying your `hosts` file
22 |
23 | After a while, Mail-in-a-Box will be available at `192.168.56.4` (unless you changed that in your `Vagrantfile`). To be able to use the web-based bits, we recommend to add a hostname to your `hosts` file:
24 |
25 | $ echo "192.168.56.4 mailinabox.lan" | sudo tee -a /etc/hosts
26 |
27 | You should now be able to navigate to https://mailinabox.lan/admin using your browser. There should be an initial admin user with the name `me@mailinabox.lan` and the password `12345678`.
28 |
29 | ### Making changes
30 |
31 | Your working copy of Mail-in-a-Box will be mounted inside your VM at `/vagrant`. Any change you make locally will appear inside your VM automatically.
32 |
33 | Running `vagrant up --provision` again will repeat the installation with your modifications.
34 |
35 | Alternatively, you can also ssh into the VM using:
36 |
37 | $ vagrant ssh
38 |
39 | Once inside the VM, you can re-run individual parts of the setup like in this example:
40 |
41 | vm$ cd /vagrant
42 | vm$ sudo setup/owncloud.sh # replace with script you'd like to re-run
43 |
44 | ### Tests
45 |
46 | Mail-in-a-Box needs more tests. If you're still looking for a way to help out, writing and contributing tests would be a great start!
47 |
48 | ## Public domain
49 |
50 | This project is in the public domain. Copyright and related rights in the work worldwide are waived through the [CC0 1.0 Universal public domain dedication][CC0]. See the LICENSE file in this directory.
51 |
52 | All contributions to this project must be released under the same CC0 wavier. By submitting a pull request or patch, you are agreeing to comply with this waiver of copyright interest.
53 |
54 | [CC0]: http://creativecommons.org/publicdomain/zero/1.0/
55 |
56 | ## Code of Conduct
57 |
58 | This project has a [Code of Conduct](CODE_OF_CONDUCT.md). Please review it when joining our community.
59 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Creative Commons Legal Code
2 |
3 | CC0 1.0 Universal
4 |
5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
12 | HEREUNDER.
13 |
14 | Statement of Purpose
15 |
16 | The laws of most jurisdictions throughout the world automatically confer
17 | exclusive Copyright and Related Rights (defined below) upon the creator
18 | and subsequent owner(s) (each and all, an "owner") of an original work of
19 | authorship and/or a database (each, a "Work").
20 |
21 | Certain owners wish to permanently relinquish those rights to a Work for
22 | the purpose of contributing to a commons of creative, cultural and
23 | scientific works ("Commons") that the public can reliably and without fear
24 | of later claims of infringement build upon, modify, incorporate in other
25 | works, reuse and redistribute as freely as possible in any form whatsoever
26 | and for any purposes, including without limitation commercial purposes.
27 | These owners may contribute to the Commons to promote the ideal of a free
28 | culture and the further production of creative, cultural and scientific
29 | works, or to gain reputation or greater distribution for their Work in
30 | part through the use and efforts of others.
31 |
32 | For these and/or other purposes and motivations, and without any
33 | expectation of additional consideration or compensation, the person
34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she
35 | is an owner of Copyright and Related Rights in the Work, voluntarily
36 | elects to apply CC0 to the Work and publicly distribute the Work under its
37 | terms, with knowledge of his or her Copyright and Related Rights in the
38 | Work and the meaning and intended legal effect of CC0 on those rights.
39 |
40 | 1. Copyright and Related Rights. A Work made available under CC0 may be
41 | protected by copyright and related or neighboring rights ("Copyright and
42 | Related Rights"). Copyright and Related Rights include, but are not
43 | limited to, the following:
44 |
45 | i. the right to reproduce, adapt, distribute, perform, display,
46 | communicate, and translate a Work;
47 | ii. moral rights retained by the original author(s) and/or performer(s);
48 | iii. publicity and privacy rights pertaining to a person's image or
49 | likeness depicted in a Work;
50 | iv. rights protecting against unfair competition in regards to a Work,
51 | subject to the limitations in paragraph 4(a), below;
52 | v. rights protecting the extraction, dissemination, use and reuse of data
53 | in a Work;
54 | vi. database rights (such as those arising under Directive 96/9/EC of the
55 | European Parliament and of the Council of 11 March 1996 on the legal
56 | protection of databases, and under any national implementation
57 | thereof, including any amended or successor version of such
58 | directive); and
59 | vii. other similar, equivalent or corresponding rights throughout the
60 | world based on applicable law or treaty, and any national
61 | implementations thereof.
62 |
63 | 2. Waiver. To the greatest extent permitted by, but not in contravention
64 | of, applicable law, Affirmer hereby overtly, fully, permanently,
65 | irrevocably and unconditionally waives, abandons, and surrenders all of
66 | Affirmer's Copyright and Related Rights and associated claims and causes
67 | of action, whether now known or unknown (including existing as well as
68 | future claims and causes of action), in the Work (i) in all territories
69 | worldwide, (ii) for the maximum duration provided by applicable law or
70 | treaty (including future time extensions), (iii) in any current or future
71 | medium and for any number of copies, and (iv) for any purpose whatsoever,
72 | including without limitation commercial, advertising or promotional
73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
74 | member of the public at large and to the detriment of Affirmer's heirs and
75 | successors, fully intending that such Waiver shall not be subject to
76 | revocation, rescission, cancellation, termination, or any other legal or
77 | equitable action to disrupt the quiet enjoyment of the Work by the public
78 | as contemplated by Affirmer's express Statement of Purpose.
79 |
80 | 3. Public License Fallback. Should any part of the Waiver for any reason
81 | be judged legally invalid or ineffective under applicable law, then the
82 | Waiver shall be preserved to the maximum extent permitted taking into
83 | account Affirmer's express Statement of Purpose. In addition, to the
84 | extent the Waiver is so judged Affirmer hereby grants to each affected
85 | person a royalty-free, non transferable, non sublicensable, non exclusive,
86 | irrevocable and unconditional license to exercise Affirmer's Copyright and
87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the
88 | maximum duration provided by applicable law or treaty (including future
89 | time extensions), (iii) in any current or future medium and for any number
90 | of copies, and (iv) for any purpose whatsoever, including without
91 | limitation commercial, advertising or promotional purposes (the
92 | "License"). The License shall be deemed effective as of the date CC0 was
93 | applied by Affirmer to the Work. Should any part of the License for any
94 | reason be judged legally invalid or ineffective under applicable law, such
95 | partial invalidity or ineffectiveness shall not invalidate the remainder
96 | of the License, and in such case Affirmer hereby affirms that he or she
97 | will not (i) exercise any of his or her remaining Copyright and Related
98 | Rights in the Work or (ii) assert any associated claims and causes of
99 | action with respect to the Work, in either case contrary to Affirmer's
100 | express Statement of Purpose.
101 |
102 | 4. Limitations and Disclaimers.
103 |
104 | a. No trademark or patent rights held by Affirmer are waived, abandoned,
105 | surrendered, licensed or otherwise affected by this document.
106 | b. Affirmer offers the Work as-is and makes no representations or
107 | warranties of any kind concerning the Work, express, implied,
108 | statutory or otherwise, including without limitation warranties of
109 | title, merchantability, fitness for a particular purpose, non
110 | infringement, or the absence of latent or other defects, accuracy, or
111 | the present or absence of errors, whether or not discoverable, all to
112 | the greatest extent permissible under applicable law.
113 | c. Affirmer disclaims responsibility for clearing rights of other persons
114 | that may apply to the Work or any use thereof, including without
115 | limitation any person's Copyright and Related Rights in the Work.
116 | Further, Affirmer disclaims responsibility for obtaining any necessary
117 | consents, permissions or other rights required for any use of the
118 | Work.
119 | d. Affirmer understands and acknowledges that Creative Commons is not a
120 | party to this document and has no duty or obligation with respect to
121 | this CC0 or use of the Work.
122 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Mail-in-a-Box
2 | =============
3 |
4 | By [@JoshData](https://github.com/JoshData) and [contributors](https://github.com/mail-in-a-box/mailinabox/graphs/contributors).
5 |
6 | Mail-in-a-Box helps individuals take back control of their email by defining a one-click, easy-to-deploy SMTP+everything else server: a mail server in a box.
7 |
8 | **Please see [https://mailinabox.email](https://mailinabox.email) for the project's website and setup guide!**
9 |
10 | * * *
11 |
12 | Our goals are to:
13 |
14 | * Make deploying a good mail server easy.
15 | * Promote [decentralization](http://redecentralize.org/), innovation, and privacy on the web.
16 | * Have automated, auditable, and [idempotent](https://web.archive.org/web/20190518072631/https://sharknet.us/2014/02/01/automated-configuration-management-challenges-with-idempotency/) configuration.
17 | * **Not** make a totally unhackable, NSA-proof server.
18 | * **Not** make something customizable by power users.
19 |
20 | Additionally, this project has a [Code of Conduct](CODE_OF_CONDUCT.md), which supersedes the goals above. Please review it when joining our community.
21 |
22 |
23 | In The Box
24 | ----------
25 |
26 | Mail-in-a-Box turns a fresh Ubuntu 22.04 LTS 64-bit machine into a working mail server by installing and configuring various components.
27 |
28 | It is a one-click email appliance. There are no user-configurable setup options. It "just works."
29 |
30 | The components installed are:
31 |
32 | * 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
33 | * Webmail ([Roundcube](http://roundcube.net/)), mail filter rules (thanks to Roundcube and Dovecot), and email client autoconfig settings (served by [nginx](http://nginx.org/))
34 | * Spam filtering ([spamassassin](https://spamassassin.apache.org/)) and greylisting ([postgrey](http://postgrey.schweikert.ch/))
35 | * 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
36 | * TLS certificates are automatically provisioned using [Let's Encrypt](https://letsencrypt.org/) for protecting https and all of the other services on the box
37 | * 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/))
38 |
39 | It also includes system management tools:
40 |
41 | * Comprehensive health monitoring that checks each day that services are running, ports are open, TLS certificates are valid, and DNS records are correct
42 | * A control panel for adding/removing mail users, aliases, custom DNS records, configuring backups, etc.
43 | * An API for all of the actions on the control panel
44 |
45 | Internationalized domain names are supported and configured easily (but SMTPUTF8 is not supported, unfortunately).
46 |
47 | It also supports static website hosting since the box is serving HTTPS anyway. (To serve a website for your domains elsewhere, just add a custom DNS "A" record in you Mail-in-a-Box's control panel to point domains to another server.)
48 |
49 | For more information on how Mail-in-a-Box handles your privacy, see the [security details page](security.md).
50 |
51 |
52 | Installation
53 | ------------
54 |
55 | See the [setup guide](https://mailinabox.email/guide.html) for detailed, user-friendly instructions.
56 |
57 | For experts, start with a completely fresh (really, I mean it) Ubuntu 22.04 LTS 64-bit machine. On the machine...
58 |
59 | Clone this repository and checkout the tag corresponding to the most recent release (which you can find in the tags or releases lists on GitHub):
60 |
61 | $ git clone https://github.com/mail-in-a-box/mailinabox
62 | $ cd mailinabox
63 | $ git checkout TAGNAME
64 |
65 | Begin the installation.
66 |
67 | $ sudo setup/start.sh
68 |
69 | The installation will install, uninstall, and configure packages to turn the machine into a working, good mail server.
70 |
71 | For help, DO NOT contact Josh directly --- I don't do tech support by email or tweet (no exceptions).
72 |
73 | Post your question on the [discussion forum](https://discourse.mailinabox.email/) instead, where maintainers and Mail-in-a-Box users may be able to help you.
74 |
75 | Note that while we want everything to "just work," we can't control the rest of the Internet. Other mail services might block or spam-filter email sent from your Mail-in-a-Box.
76 | This is a challenge faced by everyone who runs their own mail server, with or without Mail-in-a-Box. See our discussion forum for tips about that.
77 |
78 |
79 | Contributing and Development
80 | ----------------------------
81 |
82 | Mail-in-a-Box is an open source project. Your contributions and pull requests are welcome. See [CONTRIBUTING](CONTRIBUTING.md) to get started.
83 |
84 |
85 | The Acknowledgements
86 | --------------------
87 |
88 | This project was inspired in part by the ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) blog post by Drew Crawford, [Sovereign](https://github.com/sovereign/sovereign) by Alex Payne, and conversations with @shevski, @konklone, and @GregElin.
89 |
90 | Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/) and [Modoboa](https://github.com/tonioo/modoboa).
91 |
92 |
93 | The History
94 | -----------
95 |
96 | * 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).
97 | * 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.
98 | * 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.
99 | * 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.
100 | * FastCompany mentioned Mail-in-a-Box a [roundup of privacy projects](http://www.fastcompany.com/3047645/your-own-private-cloud) on June 26, 2015.
101 |
--------------------------------------------------------------------------------
/Vagrantfile:
--------------------------------------------------------------------------------
1 | # -*- mode: ruby -*-
2 | # vi: set ft=ruby :
3 |
4 | Vagrant.configure("2") do |config|
5 | config.vm.box = "ubuntu/jammy64"
6 |
7 | # Network config: Since it's a mail server, the machine must be connected
8 | # to the public web. However, we currently don't want to expose SSH since
9 | # the machine's box will let anyone log into it. So instead we'll put the
10 | # machine on a private network.
11 | config.vm.hostname = "mailinabox.lan"
12 | config.vm.network "private_network", ip: "192.168.56.4"
13 |
14 | config.vm.provision :shell, :inline => <<-SH
15 | # Set environment variables so that the setup script does
16 | # not ask any questions during provisioning. We'll let the
17 | # machine figure out its own public IP.
18 | export NONINTERACTIVE=1
19 | export PUBLIC_IP=auto
20 | export PUBLIC_IPV6=auto
21 | export PRIMARY_HOSTNAME=auto
22 | #export SKIP_NETWORK_CHECKS=1
23 |
24 | # Start the setup script.
25 | cd /vagrant
26 | setup/start.sh
27 | SH
28 | end
29 |
--------------------------------------------------------------------------------
/api/docs/generate-docs.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | # Requirements:
4 | # - Node.js
5 | # - redoc-cli (`npm install redoc-cli -g`)
6 |
7 | redoc-cli bundle ../mailinabox.yml \
8 | -t template.hbs \
9 | -o api-docs.html \
10 | --templateOptions.metaDescription="Mail-in-a-Box HTTP API" \
11 | --title="Mail-in-a-Box HTTP API" \
12 | --options.expandSingleSchemaField \
13 | --options.hideSingleRequestSampleTab \
14 | --options.jsonSampleExpandLevel=10 \
15 | --options.hideDownloadButton \
16 | --options.theme.logo.maxHeight=180px \
17 | --options.theme.logo.maxWidth=180px \
18 | --options.theme.colors.primary.main="#C52" \
19 | --options.theme.typography.fontSize=16px \
20 | --options.theme.typography.fontFamily="Raleway, sans-serif" \
21 | --options.theme.typography.headings.fontFamily="Ubuntu, Arial, sans-serif" \
22 | --options.theme.typography.code.fontSize=15px \
23 | --options.theme.typography.code.fontFamily='"Source Code Pro", monospace'
--------------------------------------------------------------------------------
/api/docs/template.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{title}}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
24 | {{{redocHead}}}
25 |
26 |
27 |
28 | {{{redocHTML}}}
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/conf/dovecot-mailboxes.conf:
--------------------------------------------------------------------------------
1 | ## NOTE: This file is automatically generated by Mail-in-a-Box.
2 | ## Do not edit this file. It is continually updated by
3 | ## Mail-in-a-Box and your changes will be lost.
4 | ##
5 | ## Mail-in-a-Box machines are not meant to be modified.
6 | ## If you modify any system configuration you are on
7 | ## your own --- please do not ask for help from us.
8 |
9 | namespace inbox {
10 | # Automatically create & subscribe some folders.
11 | # * Create and subscribe the INBOX folder.
12 | # * Our sieve rule for spam expects that the Spam folder exists.
13 | # * Z-Push must be configured with the same settings in conf/zpush/backend_imap.php (#580).
14 |
15 | # MUA notes:
16 | # * Roundcube will show an error if the user tries to delete a message before the Trash folder exists (#359).
17 | # * K-9 mail will poll every 90 seconds if a Drafts folder does not exist.
18 | # * 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).
19 | # * Thunderbird's default in its UI is 'Archives' (plural) but it will configure new accounts to use whatever we say here (#581).
20 |
21 | # auto:
22 | # 'create' will automatically create this mailbox.
23 | # 'subscribe' will both create and subscribe to the mailbox.
24 |
25 | # special_use is a space separated list of IMAP SPECIAL-USE
26 | # attributes as specified by RFC 6154:
27 | # \All \Archive \Drafts \Flagged \Junk \Sent \Trash
28 |
29 | mailbox INBOX {
30 | auto = subscribe
31 | }
32 | mailbox Spam {
33 | special_use = \Junk
34 | auto = subscribe
35 | }
36 | mailbox Drafts {
37 | special_use = \Drafts
38 | auto = subscribe
39 | }
40 | mailbox Sent {
41 | special_use = \Sent
42 | auto = subscribe
43 | }
44 | mailbox Trash {
45 | special_use = \Trash
46 | auto = subscribe
47 | }
48 | mailbox Archive {
49 | special_use = \Archive
50 | auto = subscribe
51 | }
52 |
53 | # dovevot's standard mailboxes configuration file marks two sent folders
54 | # with the \Sent attribute, just in case clients don't agree about which
55 | # they're using. We'll keep that, plus add Junk as an alternative for Spam.
56 | # These are not auto-created.
57 | mailbox "Sent Messages" {
58 | special_use = \Sent
59 | }
60 | mailbox Junk {
61 | special_use = \Junk
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/conf/fail2ban/filter.d/dovecotimap.conf:
--------------------------------------------------------------------------------
1 | # Fail2Ban filter Dovecot authentication and pop3/imap/managesieve server
2 | # For Mail-in-a-Box
3 |
4 | [INCLUDES]
5 |
6 | before = common.conf
7 |
8 | [Definition]
9 |
10 | _daemon = (auth|dovecot(-auth)?|auth-worker)
11 |
12 | failregex = ^%(__prefix_line)s(pop3|imap|managesieve)-login: (Info: )?(Aborted login|Disconnected)(: Inactivity)? \(((no auth attempts|auth failed, \d+ attempts)( in \d+ secs)?|tried to use (disabled|disallowed) \S+ auth)\):( user=<\S*>,)?( method=\S+,)? rip=, lip=(\d{1,3}\.){3}\d{1,3}(, TLS( handshaking)?(: Disconnected)?)?(, session=<\S+>)?\s*$
13 |
14 | ignoreregex =
15 |
16 | # DEV Notes:
17 | # * the first regex is essentially a copy of pam-generic.conf
18 | # * Probably doesn't do dovecot sql/ldap backends properly
19 | #
20 | # Author: Martin Waschbuesch
21 | # Daniel Black (rewrote with begin and end anchors)
22 | # Mail-in-a-Box (swapped session=...)
23 |
--------------------------------------------------------------------------------
/conf/fail2ban/filter.d/miab-management-daemon.conf:
--------------------------------------------------------------------------------
1 | # Fail2Ban filter Mail-in-a-Box management daemon
2 |
3 | [INCLUDES]
4 |
5 | before = common.conf
6 |
7 | [Definition]
8 |
9 | _daemon = mailinabox
10 |
11 | failregex = Mail-in-a-Box Management Daemon: Failed login attempt from ip - timestamp .*
12 | ignoreregex =
13 |
--------------------------------------------------------------------------------
/conf/fail2ban/filter.d/miab-munin.conf:
--------------------------------------------------------------------------------
1 | [INCLUDES]
2 |
3 | before = common.conf
4 |
5 | [Definition]
6 | failregex= - .*GET /admin/munin/.* HTTP/\d+\.\d+\" 401.*
7 | ignoreregex =
8 |
--------------------------------------------------------------------------------
/conf/fail2ban/filter.d/miab-owncloud.conf:
--------------------------------------------------------------------------------
1 | [INCLUDES]
2 |
3 | before = common.conf
4 |
5 | [Definition]
6 | datepattern = %%Y-%%m-%%d %%H:%%M:%%S
7 | failregex=Login failed: .*Remote IP: '[\)']
8 | ignoreregex =
9 |
--------------------------------------------------------------------------------
/conf/fail2ban/filter.d/miab-postfix-submission.conf:
--------------------------------------------------------------------------------
1 | [INCLUDES]
2 |
3 | before = common.conf
4 |
5 | [Definition]
6 | failregex=postfix/submission/smtpd.*warning.*\[\]: .* authentication (failed|aborted)
7 | ignoreregex =
8 |
--------------------------------------------------------------------------------
/conf/fail2ban/filter.d/miab-roundcube.conf:
--------------------------------------------------------------------------------
1 | [INCLUDES]
2 |
3 | before = common.conf
4 |
5 | [Definition]
6 |
7 | failregex = IMAP Error: Login failed for .*? from \. AUTHENTICATE.*
8 |
9 | ignoreregex =
10 |
--------------------------------------------------------------------------------
/conf/fail2ban/jails.conf:
--------------------------------------------------------------------------------
1 | # Fail2Ban configuration file for Mail-in-a-Box. Do not edit.
2 | # This file is re-generated on updates.
3 |
4 | [DEFAULT]
5 | # Whitelist our own IP addresses. 127.0.0.1/8 is the default. But our status checks
6 | # ping services over the public interface so we should whitelist that address of
7 | # ours too. The string is substituted during installation.
8 | ignoreip = 127.0.0.1/8 PUBLIC_IP ::1 PUBLIC_IPV6
9 |
10 | [dovecot]
11 | enabled = true
12 | filter = dovecotimap
13 | logpath = /var/log/mail.log
14 | findtime = 30
15 | maxretry = 20
16 |
17 | [miab-management]
18 | enabled = true
19 | filter = miab-management-daemon
20 | port = http,https
21 | logpath = /var/log/syslog
22 | maxretry = 20
23 | findtime = 30
24 |
25 | [miab-munin]
26 | enabled = true
27 | port = http,https
28 | filter = miab-munin
29 | logpath = /var/log/nginx/access.log
30 | maxretry = 20
31 | findtime = 30
32 |
33 | [miab-owncloud]
34 | enabled = true
35 | port = http,https
36 | filter = miab-owncloud
37 | logpath = STORAGE_ROOT/owncloud/nextcloud.log
38 | maxretry = 20
39 | findtime = 120
40 |
41 | [miab-postfix465]
42 | enabled = true
43 | port = 465
44 | filter = miab-postfix-submission
45 | logpath = /var/log/mail.log
46 | maxretry = 20
47 | findtime = 30
48 |
49 | [miab-postfix587]
50 | enabled = true
51 | port = 587
52 | filter = miab-postfix-submission
53 | logpath = /var/log/mail.log
54 | maxretry = 20
55 | findtime = 30
56 |
57 | [miab-roundcube]
58 | enabled = true
59 | port = http,https
60 | filter = miab-roundcube
61 | logpath = /var/log/roundcubemail/errors.log
62 | maxretry = 20
63 | findtime = 30
64 |
65 | [recidive]
66 | enabled = true
67 | maxretry = 10
68 | action = iptables-allports[name=recidive]
69 | # In the recidive section of jail.conf the action contains:
70 | #
71 | # action = iptables-allports[name=recidive]
72 | # sendmail-whois-lines[name=recidive, logpath=/var/log/fail2ban.log]
73 | #
74 | # The last line on the action will sent an email to the configured address. This mail will
75 | # notify the administrator that someone has been repeatedly triggering one of the other jails.
76 | # By default we don't configure this address and no action is required from the admin anyway.
77 | # So the notification is omitted. This will prevent message appearing in the mail.log that mail
78 | # can't be delivered to fail2ban@$HOSTNAME.
79 |
80 | [postfix-sasl]
81 | enabled = true
82 |
83 | [sshd]
84 | enabled = true
85 | maxretry = 7
86 | bantime = 3600
87 |
--------------------------------------------------------------------------------
/conf/ios-profile.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 | PayloadContent
13 |
14 |
15 | CalDAVAccountDescription
16 | PRIMARY_HOSTNAME calendar
17 | CalDAVHostName
18 | PRIMARY_HOSTNAME
19 | CalDAVPort
20 | 443
21 | CalDAVUseSSL
22 |
23 | PayloadDescription
24 | PRIMARY_HOSTNAME (Mail-in-a-Box)
25 | PayloadDisplayName
26 | PRIMARY_HOSTNAME calendar
27 | PayloadIdentifier
28 | email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.CalDAV
29 | PayloadOrganization
30 |
31 | PayloadType
32 | com.apple.caldav.account
33 | PayloadUUID
34 | UUID1
35 | PayloadVersion
36 | 1
37 |
38 |
39 | EmailAccountDescription
40 | PRIMARY_HOSTNAME mail
41 | EmailAccountType
42 | EmailTypeIMAP
43 | IncomingMailServerAuthentication
44 | EmailAuthPassword
45 | IncomingMailServerHostName
46 | PRIMARY_HOSTNAME
47 | IncomingMailServerPortNumber
48 | 993
49 | IncomingMailServerUseSSL
50 |
51 | OutgoingMailServerAuthentication
52 | EmailAuthPassword
53 | OutgoingMailServerHostName
54 | PRIMARY_HOSTNAME
55 | OutgoingMailServerPortNumber
56 | 465
57 | OutgoingMailServerUseSSL
58 |
59 | OutgoingPasswordSameAsIncomingPassword
60 |
61 | PayloadDescription
62 | PRIMARY_HOSTNAME (Mail-in-a-Box)
63 | PayloadDisplayName
64 | PRIMARY_HOSTNAME mail
65 | PayloadIdentifier
66 | email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.E-Mail
67 | PayloadOrganization
68 |
69 | PayloadType
70 | com.apple.mail.managed
71 | PayloadUUID
72 | UUID2
73 | PayloadVersion
74 | 1
75 | PreventAppSheet
76 |
77 | PreventMove
78 |
79 | SMIMEEnabled
80 |
81 |
82 |
83 | CardDAVAccountDescription
84 | PRIMARY_HOSTNAME contacts
85 | CardDAVHostName
86 | PRIMARY_HOSTNAME
87 | CardDAVPort
88 | 443
89 | CardDAVPrincipalURL
90 | /cloud/remote.php/carddav/addressbooks/
91 | CardDAVUseSSL
92 |
93 | PayloadDescription
94 | PRIMARY_HOSTNAME (Mail-in-a-Box)
95 | PayloadDisplayName
96 | PRIMARY_HOSTNAME contacts
97 | PayloadIdentifier
98 | email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.carddav
99 | PayloadOrganization
100 |
101 | PayloadType
102 | com.apple.carddav.account
103 | PayloadUUID
104 | UUID3
105 | PayloadVersion
106 | 1
107 |
108 |
109 | PayloadDescription
110 | PRIMARY_HOSTNAME (Mail-in-a-Box)
111 | PayloadDisplayName
112 | PRIMARY_HOSTNAME
113 | PayloadIdentifier
114 | email.mailinabox.mobileconfig.PRIMARY_HOSTNAME
115 | PayloadOrganization
116 |
117 | PayloadRemovalDisallowed
118 |
119 | PayloadType
120 | Configuration
121 | PayloadUUID
122 | UUID4
123 | PayloadVersion
124 | 1
125 |
126 |
127 |
--------------------------------------------------------------------------------
/conf/mailinabox.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Mail-in-a-Box System Management Service
3 | After=multi-user.target
4 |
5 | [Service]
6 | Type=idle
7 | IgnoreSIGPIPE=False
8 | ExecStart=/usr/local/lib/mailinabox/start
9 |
10 | [Install]
11 | WantedBy=multi-user.target
12 |
--------------------------------------------------------------------------------
/conf/mozilla-autoconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | PRIMARY_HOSTNAME
5 |
6 | PRIMARY_HOSTNAME (Mail-in-a-Box)
7 | PRIMARY_HOSTNAME
8 |
9 |
10 | PRIMARY_HOSTNAME
11 | 993
12 | SSL
13 | %EMAILADDRESS%
14 | password-cleartext
15 |
16 |
17 |
18 | PRIMARY_HOSTNAME
19 | 465
20 | SSL
21 | %EMAILADDRESS%
22 | password-cleartext
23 | true
24 | false
25 |
26 |
27 |
28 | PRIMARY_HOSTNAME website.
29 |
30 |
31 |
32 |
33 |
34 |
35 | %EMAILADDRESS%
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/conf/mta-sts.txt:
--------------------------------------------------------------------------------
1 | version: STSv1
2 | mode: MODE
3 | mx: PRIMARY_HOSTNAME
4 | max_age: 604800
5 |
--------------------------------------------------------------------------------
/conf/munin.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Munin System Monitoring Startup Script
3 | After=multi-user.target
4 |
5 | [Service]
6 | Type=idle
7 | ExecStart=/usr/local/lib/mailinabox/munin_start.sh
8 |
9 | [Install]
10 | WantedBy=multi-user.target
11 |
--------------------------------------------------------------------------------
/conf/nginx-alldomains.conf:
--------------------------------------------------------------------------------
1 | # Expose this directory as static files.
2 | root $ROOT;
3 | index index.html index.htm;
4 |
5 | location = /robots.txt {
6 | log_not_found off;
7 | access_log off;
8 | }
9 |
10 | location = /favicon.ico {
11 | log_not_found off;
12 | access_log off;
13 | }
14 |
15 | location = /mailinabox.mobileconfig {
16 | alias /var/lib/mailinabox/mobileconfig.xml;
17 | }
18 | location = /.well-known/autoconfig/mail/config-v1.1.xml {
19 | alias /var/lib/mailinabox/mozilla-autoconfig.xml;
20 | }
21 | location = /mail/config-v1.1.xml {
22 | alias /var/lib/mailinabox/mozilla-autoconfig.xml;
23 | }
24 | location = /.well-known/mta-sts.txt {
25 | alias /var/lib/mailinabox/mta-sts.txt;
26 | }
27 |
28 | # Roundcube Webmail configuration.
29 | rewrite ^/mail$ /mail/ redirect;
30 | rewrite ^/mail/$ /mail/index.php;
31 | location /mail/ {
32 | index index.php;
33 | alias /usr/local/lib/roundcubemail/;
34 | }
35 | location ~ /mail/config/.* {
36 | # A ~-style location is needed to give this precedence over the next block.
37 | return 403;
38 | }
39 | location ~ /mail/.*\.php {
40 | # note: ~ has precedence over a regular location block
41 | include fastcgi_params;
42 | fastcgi_split_path_info ^/mail(/.*)()$;
43 | fastcgi_index index.php;
44 | fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name;
45 | fastcgi_pass php-fpm;
46 |
47 | # Outgoing mail also goes through this endpoint, so increase the maximum
48 | # file upload limit to match the corresponding Postfix limit.
49 | client_max_body_size 128M;
50 | }
51 |
52 | # Z-Push (Microsoft Exchange ActiveSync)
53 | location /Microsoft-Server-ActiveSync {
54 | include /etc/nginx/fastcgi_params;
55 | fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/index.php;
56 | fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
57 | fastcgi_read_timeout 630;
58 | fastcgi_pass php-fpm;
59 |
60 | # Outgoing mail also goes through this endpoint, so increase the maximum
61 | # file upload limit to match the corresponding Postfix limit.
62 | client_max_body_size 128M;
63 | }
64 | location ~* ^/autodiscover/autodiscover.xml$ {
65 | include fastcgi_params;
66 | fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/autodiscover/autodiscover.php;
67 | fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
68 | fastcgi_pass php-fpm;
69 | }
70 |
71 |
72 | # ADDITIONAL DIRECTIVES HERE
73 |
74 | # Disable viewing dotfiles (.htaccess, .svn, .git, etc.)
75 | # This block is placed at the end. Nginx's precedence rules means this block
76 | # takes precedence over all non-regex matches and only regex matches that
77 | # come after it (i.e. none of those, since this is the last one.) That means
78 | # we're blocking dotfiles in the static hosted sites but not the FastCGI-
79 | # handled locations for Nextcloud (which serves user-uploaded files that might
80 | # have this pattern, see #414) or some of the other services.
81 | location ~ /\.(ht|svn|git|hg|bzr) {
82 | log_not_found off;
83 | access_log off;
84 | deny all;
85 | }
86 |
--------------------------------------------------------------------------------
/conf/nginx-primaryonly.conf:
--------------------------------------------------------------------------------
1 | # Control Panel
2 | # Proxy /admin to our Python based control panel daemon. It is
3 | # listening on IPv4 only so use an IP address and not 'localhost'.
4 | location /admin/assets {
5 | alias /usr/local/lib/mailinabox/vendor/assets;
6 | }
7 | rewrite ^/admin$ /admin/;
8 | rewrite ^/admin/munin$ /admin/munin/ redirect;
9 | location /admin/ {
10 | proxy_pass http://127.0.0.1:10222/;
11 | proxy_set_header X-Forwarded-For $remote_addr;
12 | add_header X-Frame-Options "DENY";
13 | add_header X-Content-Type-Options nosniff;
14 | add_header Content-Security-Policy "frame-ancestors 'none';";
15 | }
16 |
17 | # Nextcloud configuration.
18 | rewrite ^/cloud$ /cloud/ redirect;
19 | rewrite ^/cloud/$ /cloud/index.php;
20 | rewrite ^/cloud/(contacts|calendar|files)$ /cloud/index.php/apps/$1/ redirect;
21 | rewrite ^(/cloud/core/doc/[^\/]+/)$ $1/index.html;
22 | rewrite ^(/cloud/oc[sm]-provider)/$ $1/index.php redirect;
23 | location /cloud/ {
24 | alias /usr/local/lib/owncloud/;
25 | location ~ ^/cloud/(build|tests|config|lib|3rdparty|templates|data|README)/ {
26 | deny all;
27 | }
28 | location ~ ^/cloud/(?:\.|autotest|occ|issue|indie|db_|console) {
29 | deny all;
30 | }
31 | # Enable paths for service and cloud federation discovery
32 | # Resolves warning in Nextcloud Settings panel
33 | location ~ ^/cloud/(oc[sm]-provider)?/([^/]+\.php)$ {
34 | index index.php;
35 | include fastcgi_params;
36 | fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$1/$2;
37 | fastcgi_pass php-fpm;
38 | }
39 | }
40 | location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ {
41 | # note: ~ has precedence over a regular location block
42 | # Accept URLs like:
43 | # /cloud/index.php/apps/files/
44 | # /cloud/index.php/apps/files/ajax/scan.php (it's really index.php; see 6fdef379adfdeac86cc2220209bdf4eb9562268d)
45 | # /cloud/ocs/v1.php/apps/files_sharing/api/v1 (see #240)
46 | # /cloud/remote.php/webdav/yourfilehere...
47 | include fastcgi_params;
48 | fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$2;
49 | fastcgi_param SCRIPT_NAME $1$2;
50 | fastcgi_param PATH_INFO $3;
51 | fastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on;
52 | fastcgi_param MOD_X_ACCEL_REDIRECT_PREFIX /owncloud-xaccel;
53 | fastcgi_read_timeout 630;
54 | fastcgi_pass php-fpm;
55 | client_max_body_size 1G;
56 | fastcgi_buffers 64 4K;
57 | }
58 | location ^~ /owncloud-xaccel/ {
59 | # This directory is for MOD_X_ACCEL_REDIRECT_ENABLED. Nextcloud sends the full file
60 | # path on disk as a subdirectory under this virtual path.
61 | # We must only allow 'internal' redirects within nginx so that the filesystem
62 | # is not exposed to the world.
63 | internal;
64 | alias /;
65 | }
66 | location ~ ^/((caldav|carddav|webdav).*)$ {
67 | # Z-Push doesn't like getting a redirect, and a plain rewrite didn't work either.
68 | # Properly proxying like this seems to work fine.
69 | proxy_pass https://127.0.0.1/cloud/remote.php/$1;
70 | }
71 | rewrite ^/.well-known/host-meta /cloud/public.php?service=host-meta last;
72 | rewrite ^/.well-known/host-meta.json /cloud/public.php?service=host-meta-json last;
73 | rewrite ^/.well-known/carddav /cloud/remote.php/carddav/ redirect;
74 | rewrite ^/.well-known/caldav /cloud/remote.php/caldav/ redirect;
75 |
76 | # This addresses those service discovery issues mentioned in:
77 | # https://docs.nextcloud.com/server/23/admin_manual/issues/general_troubleshooting.html#service-discovery
78 | rewrite ^/.well-known/webfinger /cloud/index.php/.well-known/webfinger redirect;
79 | rewrite ^/.well-known/nodeinfo /cloud/index.php/.well-known/nodeinfo redirect;
80 |
81 | # ADDITIONAL DIRECTIVES HERE
82 |
--------------------------------------------------------------------------------
/conf/nginx-ssl.conf:
--------------------------------------------------------------------------------
1 | # We track the Mozilla "intermediate" compatibility TLS recommendations.
2 | # Note that these settings are repeated in the SMTP and IMAP configuration.
3 | # ssl_protocols has moved to nginx.conf in bionic, check there for enabled protocols.
4 | ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
5 | ssl_dhparam STORAGE_ROOT/ssl/dh2048.pem;
6 |
7 | # as recommended by http://nginx.org/en/docs/http/configuring_https_servers.html
8 | ssl_session_cache shared:SSL:50m;
9 | ssl_session_timeout 1d;
10 |
11 | # Buffer size of 1400 bytes fits in one MTU.
12 | # nginx 1.5.9+ ONLY
13 | ssl_buffer_size 1400;
14 |
15 | ssl_stapling on;
16 | ssl_stapling_verify on;
17 | resolver 127.0.0.1 valid=86400;
18 | resolver_timeout 10;
19 |
20 | # h/t https://gist.github.com/konklone/6532544
21 |
--------------------------------------------------------------------------------
/conf/nginx-top.conf:
--------------------------------------------------------------------------------
1 | ## NOTE: This file is automatically generated by Mail-in-a-Box.
2 | ## Do not edit this file. It is continually updated by
3 | ## Mail-in-a-Box and your changes will be lost.
4 | ##
5 | ## Mail-in-a-Box machines are not meant to be modified.
6 | ## If you modify any system configuration you are on
7 | ## your own --- please do not ask for help from us.
8 |
9 | upstream php-fpm {
10 | server unix:/var/run/php/php8.0-fpm.sock;
11 | }
12 |
13 |
--------------------------------------------------------------------------------
/conf/nginx.conf:
--------------------------------------------------------------------------------
1 | ## $HOSTNAME
2 |
3 | # Redirect all HTTP to HTTPS *except* the ACME challenges (Let's Encrypt TLS certificate
4 | # domain validation challenges) path, which must be served over HTTP per the ACME spec
5 | # (due to some Apache vulnerability).
6 | server {
7 | listen 80;
8 | listen [::]:80;
9 |
10 | server_name $HOSTNAME;
11 | root /tmp/invalid-path-nothing-here;
12 |
13 | # Improve privacy: Hide version an OS information on
14 | # error pages and in the "Server" HTTP-Header.
15 | server_tokens off;
16 |
17 | location / {
18 | # Redirect using the 'return' directive and the built-in
19 | # variable '$request_uri' to avoid any capturing, matching
20 | # or evaluation of regular expressions.
21 | return 301 https://$HOSTNAME$request_uri;
22 | }
23 |
24 | location /.well-known/acme-challenge/ {
25 | # This path must be served over HTTP for ACME domain validation.
26 | # We map this to a special path where our TLS cert provisioning
27 | # tool knows to store challenge response files.
28 | alias $STORAGE_ROOT/ssl/lets_encrypt/webroot/.well-known/acme-challenge/;
29 | }
30 | }
31 |
32 | # The secure HTTPS server.
33 | server {
34 | listen 443 ssl http2;
35 | listen [::]:443 ssl http2;
36 |
37 | server_name $HOSTNAME;
38 |
39 | # Improve privacy: Hide version an OS information on
40 | # error pages and in the "Server" HTTP-Header.
41 | server_tokens off;
42 |
43 | ssl_certificate $SSL_CERTIFICATE;
44 | ssl_certificate_key $SSL_KEY;
45 |
46 | # ADDITIONAL DIRECTIVES HERE
47 | }
48 |
--------------------------------------------------------------------------------
/conf/postfix_outgoing_mail_header_filters:
--------------------------------------------------------------------------------
1 | # Remove the first line of the Received: header. Note that we cannot fully remove the Received: header
2 | # because OpenDKIM requires that a header be present when signing outbound mail. The first line is
3 | # where the user's home IP address would be.
4 | /^\s*Received:[^\n]*(.*)/ REPLACE Received: from authenticated-user (PRIMARY_HOSTNAME [PUBLIC_IP])$1
5 |
6 | # Remove other typically private information.
7 | /^\s*User-Agent:/ IGNORE
8 | /^\s*X-Enigmail:/ IGNORE
9 | /^\s*X-Mailer:/ IGNORE
10 | /^\s*X-Originating-IP:/ IGNORE
11 | /^\s*X-Pgp-Agent:/ IGNORE
12 |
13 | # 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\)).
14 | /^\s*(Mime-Version:\s*[0-9\.]+)\s.+/ REPLACE $1
15 |
--------------------------------------------------------------------------------
/conf/sieve-spam.txt:
--------------------------------------------------------------------------------
1 | require ["regex", "fileinto", "imap4flags"];
2 |
3 | if allof (header :regex "X-Spam-Status" "^Yes") {
4 | fileinto "Spam";
5 | stop;
6 | }
7 |
8 |
--------------------------------------------------------------------------------
/conf/www_default.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | this is a mail-in-a-box
4 |
5 |
6 |
7 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/conf/zpush/autodiscover_config.php:
--------------------------------------------------------------------------------
1 |
27 |
--------------------------------------------------------------------------------
/conf/zpush/backend_caldav.php:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/conf/zpush/backend_carddav.php:
--------------------------------------------------------------------------------
1 |
32 |
--------------------------------------------------------------------------------
/conf/zpush/backend_combined.php:
--------------------------------------------------------------------------------
1 | array(
13 | 'i' => array(
14 | 'name' => 'BackendIMAP',
15 | ),
16 | 'c' => array(
17 | 'name' => 'BackendCalDAV',
18 | ),
19 | 'd' => array(
20 | 'name' => 'BackendCardDAV',
21 | ),
22 | ),
23 | 'delimiter' => '/',
24 | 'folderbackend' => array(
25 | SYNC_FOLDER_TYPE_INBOX => 'i',
26 | SYNC_FOLDER_TYPE_DRAFTS => 'i',
27 | SYNC_FOLDER_TYPE_WASTEBASKET => 'i',
28 | SYNC_FOLDER_TYPE_SENTMAIL => 'i',
29 | SYNC_FOLDER_TYPE_OUTBOX => 'i',
30 | SYNC_FOLDER_TYPE_TASK => 'c',
31 | SYNC_FOLDER_TYPE_APPOINTMENT => 'c',
32 | SYNC_FOLDER_TYPE_CONTACT => 'd',
33 | SYNC_FOLDER_TYPE_NOTE => 'c',
34 | SYNC_FOLDER_TYPE_JOURNAL => 'c',
35 | SYNC_FOLDER_TYPE_OTHER => 'i',
36 | SYNC_FOLDER_TYPE_USER_MAIL => 'i',
37 | SYNC_FOLDER_TYPE_USER_APPOINTMENT => 'c',
38 | SYNC_FOLDER_TYPE_USER_CONTACT => 'd',
39 | SYNC_FOLDER_TYPE_USER_TASK => 'c',
40 | SYNC_FOLDER_TYPE_USER_JOURNAL => 'c',
41 | SYNC_FOLDER_TYPE_USER_NOTE => 'c',
42 | SYNC_FOLDER_TYPE_UNKNOWN => 'i',
43 | ),
44 | 'rootcreatefolderbackend' => 'i',
45 | );
46 | }
47 | }
48 |
49 | ?>
50 |
--------------------------------------------------------------------------------
/conf/zpush/backend_imap.php:
--------------------------------------------------------------------------------
1 | true)));
33 | define('IMAP_FROM_SQL_QUERY', "SELECT name, email FROM identities i INNER JOIN users u ON i.user_id = u.user_id WHERE u.username = '#username' AND i.standard = 1 AND i.del = 0 AND i.name <> ''");
34 | define('IMAP_FROM_SQL_FIELDS', serialize(array('name', 'email')));
35 | define('IMAP_FROM_SQL_FROM', '#name <#email>');
36 | define('IMAP_FROM_SQL_FULLNAME', '#name');
37 |
38 | // not used
39 | define('IMAP_FROM_LDAP_SERVER', '');
40 | define('IMAP_FROM_LDAP_SERVER_PORT', '389');
41 | define('IMAP_FROM_LDAP_USER', 'cn=zpush,ou=servers,dc=zpush,dc=org');
42 | define('IMAP_FROM_LDAP_PASSWORD', 'password');
43 | define('IMAP_FROM_LDAP_BASE', 'dc=zpush,dc=org');
44 | define('IMAP_FROM_LDAP_QUERY', '(mail=#username@#domain)');
45 | define('IMAP_FROM_LDAP_FIELDS', serialize(array('givenname', 'sn', 'mail')));
46 | define('IMAP_FROM_LDAP_FROM', '#givenname #sn <#mail>');
47 | define('IMAP_FROM_LDAP_FULLNAME', '#givenname #sn');
48 |
49 | define('IMAP_SMTP_METHOD', 'sendmail');
50 |
51 | global $imap_smtp_params;
52 | $imap_smtp_params = array('host' => 'ssl://127.0.0.1', 'port' => 465, 'auth' => true, 'username' => 'imap_username', 'password' => 'imap_password');
53 |
54 | define('MAIL_MIMEPART_CRLF', "\r\n");
55 | define('IMAP_MEETING_USE_CALDAV', true);
56 |
57 | ?>
58 |
--------------------------------------------------------------------------------
/management/auth.py:
--------------------------------------------------------------------------------
1 | import base64, hmac, json, secrets
2 | from datetime import timedelta
3 |
4 | from expiringdict import ExpiringDict
5 |
6 | import utils
7 | from mailconfig import get_mail_password, get_mail_user_privileges
8 | from mfa import get_hash_mfa_state, validate_auth_mfa
9 |
10 | DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
11 | DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
12 |
13 | class AuthService:
14 | def __init__(self):
15 | self.auth_realm = DEFAULT_AUTH_REALM
16 | self.key_path = DEFAULT_KEY_PATH
17 | self.max_session_duration = timedelta(days=2)
18 |
19 | self.init_system_api_key()
20 | self.sessions = ExpiringDict(max_len=64, max_age_seconds=self.max_session_duration.total_seconds())
21 |
22 | def init_system_api_key(self):
23 | """Write an API key to a local file so local processes can use the API"""
24 |
25 | with open(self.key_path, encoding='utf-8') as file:
26 | self.key = file.read()
27 |
28 | def authenticate(self, request, env, login_only=False, logout=False):
29 | """Test if the HTTP Authorization header's username matches the system key, a session key,
30 | or if the username/password passed in the header matches a local user.
31 | Returns a tuple of the user's email address and list of user privileges (e.g.
32 | ('my@email', []) or ('my@email', ['admin']); raises a ValueError on login failure.
33 | If the user used the system API key, the user's email is returned as None since
34 | this key is not associated with a user."""
35 |
36 | def parse_http_authorization_basic(header):
37 | def decode(s):
38 | return base64.b64decode(s.encode('ascii')).decode('ascii')
39 | if " " not in header:
40 | return None, None
41 | scheme, credentials = header.split(maxsplit=1)
42 | if scheme != 'Basic':
43 | return None, None
44 | credentials = decode(credentials)
45 | if ":" not in credentials:
46 | return None, None
47 | username, password = credentials.split(':', maxsplit=1)
48 | return username, password
49 |
50 | username, password = parse_http_authorization_basic(request.headers.get('Authorization', ''))
51 | if username in {None, ""}:
52 | msg = "Authorization header invalid."
53 | raise ValueError(msg)
54 |
55 | if username.strip() == "" and password.strip() == "":
56 | msg = "No email address, password, session key, or API key provided."
57 | raise ValueError(msg)
58 |
59 | # If user passed the system API key, grant administrative privs. This key
60 | # is not associated with a user.
61 | if username == self.key and not login_only:
62 | return (None, ["admin"])
63 |
64 | # If the password corresponds with a session token for the user, grant access for that user.
65 | if self.get_session(username, password, "login", env) and not login_only:
66 | sessionid = password
67 | session = self.sessions[sessionid]
68 | if logout:
69 | # Clear the session.
70 | del self.sessions[sessionid]
71 | else:
72 | # Re-up the session so that it does not expire.
73 | self.sessions[sessionid] = session
74 |
75 | # If no password was given, but a username was given, we're missing some information.
76 | elif password.strip() == "":
77 | msg = "Enter a password."
78 | raise ValueError(msg)
79 |
80 | else:
81 | # The user is trying to log in with a username and a password
82 | # (and possibly a MFA token). On failure, an exception is raised.
83 | self.check_user_auth(username, password, request, env)
84 |
85 | # Get privileges for authorization. This call should never fail because by this
86 | # point we know the email address is a valid user --- unless the user has been
87 | # deleted after the session was granted. On error the call will return a tuple
88 | # of an error message and an HTTP status code.
89 | privs = get_mail_user_privileges(username, env)
90 | if isinstance(privs, tuple): raise ValueError(privs[0])
91 |
92 | # Return the authorization information.
93 | return (username, privs)
94 |
95 | def check_user_auth(self, email, pw, request, env):
96 | # Validate a user's login email address and password. If MFA is enabled,
97 | # check the MFA token in the X-Auth-Token header.
98 | #
99 | # On login failure, raises a ValueError with a login error message. On
100 | # success, nothing is returned.
101 |
102 | # Authenticate.
103 | try:
104 | # Get the hashed password of the user. Raise a ValueError if the
105 | # email address does not correspond to a user. But wrap it in the
106 | # same exception as if a password fails so we don't easily reveal
107 | # if an email address is valid.
108 | pw_hash = get_mail_password(email, env)
109 |
110 | # Use 'doveadm pw' to check credentials. doveadm will return
111 | # a non-zero exit status if the credentials are no good,
112 | # and check_call will raise an exception in that case.
113 | utils.shell('check_call', [
114 | "/usr/bin/doveadm", "pw",
115 | "-p", pw,
116 | "-t", pw_hash,
117 | ])
118 | except:
119 | # Login failed.
120 | msg = "Incorrect email address or password."
121 | raise ValueError(msg)
122 |
123 | # If MFA is enabled, check that MFA passes.
124 | status, hints = validate_auth_mfa(email, request, env)
125 | if not status:
126 | # Login valid. Hints may have more info.
127 | raise ValueError(",".join(hints))
128 |
129 | def create_user_password_state_token(self, email, env):
130 | # Create a token that changes if the user's password or MFA options change
131 | # so that sessions become invalid if any of that information changes.
132 | msg = get_mail_password(email, env).encode("utf8")
133 |
134 | # Add to the message the current MFA state, which is a list of MFA information.
135 | # Turn it into a string stably.
136 | msg += b" " + json.dumps(get_hash_mfa_state(email, env), sort_keys=True).encode("utf8")
137 |
138 | # Make a HMAC using the system API key as a hash key.
139 | hash_key = self.key.encode('ascii')
140 | return hmac.new(hash_key, msg, digestmod="sha256").hexdigest()
141 |
142 | def create_session_key(self, username, env, type=None):
143 | # Create a new session.
144 | token = secrets.token_hex(32)
145 | self.sessions[token] = {
146 | "email": username,
147 | "password_token": self.create_user_password_state_token(username, env),
148 | "type": type,
149 | }
150 | return token
151 |
152 | def get_session(self, user_email, session_key, session_type, env):
153 | if session_key not in self.sessions: return None
154 | session = self.sessions[session_key]
155 | if session_type == "login" and session["email"] != user_email: return None
156 | if session["type"] != session_type: return None
157 | if session["password_token"] != self.create_user_password_state_token(session["email"], env): return None
158 | return session
159 |
--------------------------------------------------------------------------------
/management/cli.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | #
3 | # This is a command-line script for calling management APIs
4 | # on the Mail-in-a-Box control panel backend. The script
5 | # reads /var/lib/mailinabox/api.key for the backend's
6 | # root API key. This file is readable only by root, so this
7 | # tool can only be used as root.
8 |
9 | import sys, getpass, urllib.request, urllib.error, json, csv
10 | import contextlib
11 |
12 | def mgmt(cmd, data=None, is_json=False):
13 | # The base URL for the management daemon. (Listens on IPv4 only.)
14 | mgmt_uri = 'http://127.0.0.1:10222'
15 |
16 | setup_key_auth(mgmt_uri)
17 |
18 | req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None)
19 | try:
20 | response = urllib.request.urlopen(req)
21 | except urllib.error.HTTPError as e:
22 | if e.code == 401:
23 | with contextlib.suppress(Exception):
24 | print(e.read().decode("utf8"))
25 | print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr)
26 | elif hasattr(e, 'read'):
27 | print(e.read().decode('utf8'), file=sys.stderr)
28 | else:
29 | print(e, file=sys.stderr)
30 | sys.exit(1)
31 | resp = response.read().decode('utf8')
32 | if is_json: resp = json.loads(resp)
33 | return resp
34 |
35 | def read_password():
36 | while True:
37 | first = getpass.getpass('password: ')
38 | if len(first) < 8:
39 | print("Passwords must be at least eight characters.")
40 | continue
41 | second = getpass.getpass(' (again): ')
42 | if first != second:
43 | print("Passwords not the same. Try again.")
44 | continue
45 | break
46 | return first
47 |
48 | def setup_key_auth(mgmt_uri):
49 | with open('/var/lib/mailinabox/api.key', encoding='utf-8') as f:
50 | key = f.read().strip()
51 |
52 | auth_handler = urllib.request.HTTPBasicAuthHandler()
53 | auth_handler.add_password(
54 | realm='Mail-in-a-Box Management Server',
55 | uri=mgmt_uri,
56 | user=key,
57 | passwd='')
58 | opener = urllib.request.build_opener(auth_handler)
59 | urllib.request.install_opener(opener)
60 |
61 | if len(sys.argv) < 2:
62 | print("""Usage:
63 | {cli} user (lists users)
64 | {cli} user add user@domain.com [password]
65 | {cli} user password user@domain.com [password]
66 | {cli} user remove user@domain.com
67 | {cli} user make-admin user@domain.com
68 | {cli} user quota user@domain [new-quota] (get or set user quota)
69 | {cli} user remove-admin user@domain.com
70 | {cli} user admins (lists admins)
71 | {cli} user mfa show user@domain.com (shows MFA devices for user, if any)
72 | {cli} user mfa disable user@domain.com [id] (disables MFA for user)
73 | {cli} alias (lists aliases)
74 | {cli} alias add incoming.name@domain.com sent.to@other.domain.com
75 | {cli} alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'
76 | {cli} alias remove incoming.name@domain.com
77 |
78 | Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.
79 | """.format(
80 | cli="management/cli.py"
81 | ))
82 |
83 | elif sys.argv[1] == "user" and len(sys.argv) == 2:
84 | # Dump a list of users, one per line. Mark admins with an asterisk.
85 | users = mgmt("/mail/users?format=json", is_json=True)
86 | for domain in users:
87 | for user in domain["users"]:
88 | if user['status'] == 'inactive': continue
89 | print(user['email'], end='')
90 | if "admin" in user['privileges']:
91 | print("*", end='')
92 | print()
93 |
94 | elif sys.argv[1] == "user" and sys.argv[2] in {"add", "password"}:
95 | if len(sys.argv) < 5:
96 | email = input('email: ') if len(sys.argv) < 4 else sys.argv[3]
97 | pw = read_password()
98 | else:
99 | email, pw = sys.argv[3:5]
100 |
101 | if sys.argv[2] == "add":
102 | print(mgmt("/mail/users/add", { "email": email, "password": pw }))
103 | elif sys.argv[2] == "password":
104 | print(mgmt("/mail/users/password", { "email": email, "password": pw }))
105 |
106 | elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
107 | print(mgmt("/mail/users/remove", { "email": sys.argv[3] }))
108 |
109 | elif sys.argv[1] == "user" and sys.argv[2] in {"make-admin", "remove-admin"} and len(sys.argv) == 4:
110 | action = 'add' if sys.argv[2] == 'make-admin' else 'remove'
111 | print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" }))
112 |
113 | elif sys.argv[1] == "user" and sys.argv[2] == "admins":
114 | # Dump a list of admin users.
115 | users = mgmt("/mail/users?format=json", is_json=True)
116 | for domain in users:
117 | for user in domain["users"]:
118 | if "admin" in user['privileges']:
119 | print(user['email'])
120 |
121 | elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 4:
122 | # Get a user's quota
123 | print(mgmt("/mail/users/quota?text=1&email=%s" % sys.argv[3]))
124 |
125 | elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 5:
126 | # Set a user's quota
127 | users = mgmt("/mail/users/quota", { "email": sys.argv[3], "quota": sys.argv[4] })
128 |
129 | elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]:
130 | # Show MFA status for a user.
131 | status = mgmt("/mfa/status", { "user": sys.argv[4] }, is_json=True)
132 | W = csv.writer(sys.stdout)
133 | W.writerow(["id", "type", "label"])
134 | for mfa in status["enabled_mfa"]:
135 | W.writerow([mfa["id"], mfa["type"], mfa["label"]])
136 |
137 | elif sys.argv[1] == "user" and len(sys.argv) in {5, 6} and sys.argv[2:4] == ["mfa", "disable"]:
138 | # Disable MFA (all or a particular device) for a user.
139 | print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None }))
140 |
141 | elif sys.argv[1] == "alias" and len(sys.argv) == 2:
142 | print(mgmt("/mail/aliases"))
143 |
144 | elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
145 | print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
146 |
147 | elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
148 | print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
149 |
150 | else:
151 | print("Invalid command-line arguments.")
152 | sys.exit(1)
153 |
--------------------------------------------------------------------------------
/management/csr_country_codes.tsv:
--------------------------------------------------------------------------------
1 | # This list is derived from https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2.
2 | # The columns are ISO_3166-1_alpha-2 code, display name, Wikipedia page name.
3 | # The top 21 countries by number of Internet users are grouped first, see
4 | # https://en.wikipedia.org/wiki/List_of_countries_by_number_of_Internet_users.
5 | CN China
6 | IN India
7 | US United States
8 | JP Japan
9 | BR Brazil
10 | RU Russian Federation Russia
11 | DE Germany
12 | NG Nigeria
13 | GB United Kingdom
14 | FR France
15 | MX Mexico
16 | EG Egypt
17 | KR South Korea
18 | VN Vietnam
19 | ID Indonesia
20 | PH Philippines
21 | TR Turkey
22 | IT Italy
23 | PK Pakistan
24 | ES Spain
25 | CA Canada
26 | AD Andorra
27 | AE United Arab Emirates
28 | AF Afghanistan
29 | AG Antigua and Barbuda
30 | AI Anguilla
31 | AL Albania
32 | AM Armenia
33 | AO Angola
34 | AQ Antarctica
35 | AR Argentina
36 | AS American Samoa
37 | AT Austria
38 | AU Australia
39 | AW Aruba
40 | AX Åland Islands
41 | AZ Azerbaijan
42 | BA Bosnia and Herzegovina
43 | BB Barbados
44 | BD Bangladesh
45 | BE Belgium
46 | BF Burkina Faso
47 | BG Bulgaria
48 | BH Bahrain
49 | BI Burundi
50 | BJ Benin
51 | BL Saint Barthélemy
52 | BM Bermuda
53 | BN Brunei
54 | BO Bolivia
55 | BQ Bonaire, Sint Eustatius and Saba Caribbean Netherlands
56 | BS Bahamas The Bahamas
57 | BT Bhutan
58 | BV Bouvet Island
59 | BW Botswana
60 | BY Belarus
61 | BZ Belize
62 | CC Cocos (Keeling) Islands
63 | CD Congo, the Democratic Republic of the Democratic Republic of the Congo
64 | CF Central African Republic
65 | CG Congo Republic of the Congo
66 | CH Switzerland
67 | CI Côte d'Ivoire
68 | CK Cook Islands
69 | CL Chile
70 | CM Cameroon
71 | CO Colombia
72 | CR Costa Rica
73 | CU Cuba
74 | CV Cabo Verde
75 | CW Curaçao
76 | CX Christmas Island
77 | CY Cyprus
78 | CZ Czech Republic
79 | DJ Djibouti
80 | DK Denmark
81 | DM Dominica
82 | DO Dominican Republic
83 | DZ Algeria
84 | EC Ecuador
85 | EE Estonia
86 | EH Western Sahara
87 | ER Eritrea
88 | ET Ethiopia
89 | FI Finland
90 | FJ Fiji
91 | FK Falkland Islands (Malvinas) Falkland Islands
92 | FM Federated States of Micronesia
93 | FO Faroe Islands
94 | GA Gabon
95 | GD Grenada
96 | GE Georgia Georgia (country)
97 | GF French Guiana
98 | GG Guernsey
99 | GH Ghana
100 | GI Gibraltar
101 | GL Greenland
102 | GM Gambia The Gambia
103 | GN Guinea
104 | GP Guadeloupe
105 | GQ Equatorial Guinea
106 | GR Greece
107 | GS South Georgia and the South Sandwich Islands
108 | GT Guatemala
109 | GU Guam
110 | GW Guinea-Bissau
111 | GY Guyana
112 | HK Hong Kong
113 | HM Heard Island and McDonald Islands
114 | HN Honduras
115 | HR Croatia
116 | HT Haiti
117 | HU Hungary
118 | IE Ireland Republic of Ireland
119 | IL Israel
120 | IM Isle of Man
121 | IO British Indian Ocean Territory
122 | IQ Iraq
123 | IR Iran
124 | IS Iceland
125 | JE Jersey
126 | JM Jamaica
127 | JO Jordan
128 | KE Kenya
129 | KG Kyrgyzstan
130 | KH Cambodia
131 | KI Kiribati
132 | KM Comoros
133 | KN Saint Kitts and Nevis
134 | KP North Korea
135 | KW Kuwait
136 | KY Cayman Islands
137 | KZ Kazakhstan
138 | LA Laos
139 | LB Lebanon
140 | LC Saint Lucia
141 | LI Liechtenstein
142 | LK Sri Lanka
143 | LR Liberia
144 | LS Lesotho
145 | LT Lithuania
146 | LU Luxembourg
147 | LV Latvia
148 | LY Libya
149 | MA Morocco
150 | MC Monaco
151 | MD Moldova
152 | ME Montenegro
153 | MF Saint Martin (French part) Collectivity of Saint Martin
154 | MG Madagascar
155 | MH Marshall Islands
156 | MK Macedonia Republic of Macedonia
157 | ML Mali
158 | MM Myanmar
159 | MN Mongolia
160 | MO Macao Macau
161 | MP Northern Mariana Islands
162 | MQ Martinique
163 | MR Mauritania
164 | MS Montserrat
165 | MT Malta
166 | MU Mauritius
167 | MV Maldives
168 | MW Malawi
169 | MY Malaysia
170 | MZ Mozambique
171 | NA Namibia
172 | NC New Caledonia
173 | NE Niger
174 | NF Norfolk Island
175 | NI Nicaragua
176 | NL Netherlands
177 | NO Norway
178 | NP Nepal
179 | NR Nauru
180 | NU Niue
181 | NZ New Zealand
182 | OM Oman
183 | PA Panama
184 | PE Peru
185 | PF French Polynesia
186 | PG Papua New Guinea
187 | PL Poland
188 | PM Saint Pierre and Miquelon
189 | PN Pitcairn Pitcairn Islands
190 | PR Puerto Rico
191 | PS Palestine State of Palestine
192 | PT Portugal
193 | PW Palau
194 | PY Paraguay
195 | QA Qatar
196 | RE Réunion
197 | RO Romania
198 | RS Serbia
199 | RW Rwanda
200 | SA Saudi Arabia
201 | SB Solomon Islands
202 | SC Seychelles
203 | SD Sudan
204 | SE Sweden
205 | SG Singapore
206 | SH Saint Helena, Ascension and Tristan da Cunha
207 | SI Slovenia
208 | SJ Svalbard and Jan Mayen
209 | SK Slovakia
210 | SL Sierra Leone
211 | SM San Marino
212 | SN Senegal
213 | SO Somalia
214 | SR Suriname
215 | SS South Sudan
216 | ST Sao Tome and Principe
217 | SV El Salvador
218 | SX Sint Maarten (Dutch part) Sint Maarten
219 | SY Syria
220 | SZ Swaziland
221 | TC Turks and Caicos Islands
222 | TD Chad
223 | TF French Southern Territories French Southern and Antarctic Lands
224 | TG Togo
225 | TH Thailand
226 | TJ Tajikistan
227 | TK Tokelau
228 | TL Timor-Leste East Timor
229 | TM Turkmenistan
230 | TN Tunisia
231 | TO Tonga
232 | TT Trinidad and Tobago
233 | TV Tuvalu
234 | TW Taiwan
235 | TZ Tanzania
236 | UA Ukraine
237 | UG Uganda
238 | UM United States Minor Outlying Islands
239 | UY Uruguay
240 | UZ Uzbekistan
241 | VA Vatican City
242 | VC Saint Vincent and the Grenadines
243 | VE Venezuela
244 | VG Virgin Islands, British British Virgin Islands
245 | VI Virgin Islands, U.S. United States Virgin Islands
246 | VU Vanuatu
247 | WF Wallis and Futuna
248 | WS Samoa
249 | YE Yemen
250 | YT Mayotte
251 | ZA South Africa
252 | ZM Zambia
253 | ZW Zimbabwe
254 |
--------------------------------------------------------------------------------
/management/daily_tasks.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # This script is run daily (at 3am each night).
3 |
4 | # Set character encoding flags to ensure that any non-ASCII
5 | # characters don't cause problems. See setup/start.sh and
6 | # the management daemon startup script.
7 | export LANGUAGE=en_US.UTF-8
8 | export LC_ALL=en_US.UTF-8
9 | export LANG=en_US.UTF-8
10 | export LC_TYPE=en_US.UTF-8
11 |
12 | # On Mondays, i.e. once a week, send the administrator a report of total emails
13 | # sent and received so the admin might notice server abuse.
14 | if [ "$(date "+%u")" -eq 1 ]; then
15 | management/mail_log.py -t week | management/email_administrator.py "Mail-in-a-Box Usage Report"
16 | fi
17 |
18 | # Take a backup.
19 | management/backup.py 2>&1 | management/email_administrator.py "Backup Status"
20 |
21 | # Provision any new certificates for new domains or domains with expiring certificates.
22 | management/ssl_certificates.py -q 2>&1 | management/email_administrator.py "TLS Certificate Provisioning Result"
23 |
24 | # Run status checks and email the administrator if anything changed.
25 | management/status_checks.py --show-changes 2>&1 | management/email_administrator.py "Status Checks Change Notice"
26 |
--------------------------------------------------------------------------------
/management/email_administrator.py:
--------------------------------------------------------------------------------
1 | #!/usr/local/lib/mailinabox/env/bin/python
2 |
3 | # Reads in STDIN. If the stream is not empty, mail it to the system administrator.
4 |
5 | import sys
6 |
7 | import html
8 | import smtplib
9 |
10 | from email.mime.multipart import MIMEMultipart
11 | from email.mime.text import MIMEText
12 |
13 | # In Python 3.6:
14 | #from email.message import Message
15 |
16 | from utils import load_environment
17 |
18 | # Load system environment info.
19 | env = load_environment()
20 |
21 | # Process command line args.
22 | subject = sys.argv[1]
23 |
24 | # Administrator's email address.
25 | admin_addr = "administrator@" + env['PRIMARY_HOSTNAME']
26 |
27 | # Read in STDIN.
28 | content = sys.stdin.read().strip()
29 |
30 | # If there's nothing coming in, just exit.
31 | if content == "":
32 | sys.exit(0)
33 |
34 | # create MIME message
35 | msg = MIMEMultipart('alternative')
36 |
37 | # In Python 3.6:
38 | #msg = Message()
39 |
40 | msg['From'] = '"{}" <{}>'.format(env['PRIMARY_HOSTNAME'], admin_addr)
41 | msg['To'] = admin_addr
42 | msg['Subject'] = "[{}] {}".format(env['PRIMARY_HOSTNAME'], subject)
43 |
44 | content_html = f'
{html.escape(content)}
'
45 |
46 | msg.attach(MIMEText(content, 'plain'))
47 | msg.attach(MIMEText(content_html, 'html'))
48 |
49 | # In Python 3.6:
50 | #msg.set_content(content)
51 | #msg.add_alternative(content_html, "html")
52 |
53 | # send
54 | smtpclient = smtplib.SMTP('127.0.0.1', 25)
55 | smtpclient.ehlo()
56 | smtpclient.sendmail(
57 | admin_addr, # MAIL FROM
58 | admin_addr, # RCPT TO
59 | msg.as_string())
60 | smtpclient.quit()
61 |
--------------------------------------------------------------------------------
/management/mfa.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import hmac
3 | import io
4 | import os
5 | import pyotp
6 | import qrcode
7 |
8 | from mailconfig import open_database
9 |
10 | def get_user_id(email, c):
11 | c.execute('SELECT id FROM users WHERE email=?', (email,))
12 | r = c.fetchone()
13 | if not r: raise ValueError("User does not exist.")
14 | return r[0]
15 |
16 | def get_mfa_state(email, env):
17 | c = open_database(env)
18 | c.execute('SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?', (get_user_id(email, c),))
19 | return [
20 | { "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3], "label": r[4] }
21 | for r in c.fetchall()
22 | ]
23 |
24 | def get_public_mfa_state(email, env):
25 | mfa_state = get_mfa_state(email, env)
26 | return [
27 | { "id": s["id"], "type": s["type"], "label": s["label"] }
28 | for s in mfa_state
29 | ]
30 |
31 | def get_hash_mfa_state(email, env):
32 | mfa_state = get_mfa_state(email, env)
33 | return [
34 | { "id": s["id"], "type": s["type"], "secret": s["secret"] }
35 | for s in mfa_state
36 | ]
37 |
38 | def enable_mfa(email, type, secret, token, label, env):
39 | if type == "totp":
40 | validate_totp_secret(secret)
41 | # Sanity check with the provide current token.
42 | totp = pyotp.TOTP(secret)
43 | if not totp.verify(token, valid_window=1):
44 | msg = "Invalid token."
45 | raise ValueError(msg)
46 | else:
47 | msg = "Invalid MFA type."
48 | raise ValueError(msg)
49 |
50 | conn, c = open_database(env, with_connection=True)
51 | c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label))
52 | conn.commit()
53 |
54 | def set_mru_token(email, mfa_id, token, env):
55 | conn, c = open_database(env, with_connection=True)
56 | c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?', (token, get_user_id(email, c), mfa_id))
57 | conn.commit()
58 |
59 | def disable_mfa(email, mfa_id, env):
60 | conn, c = open_database(env, with_connection=True)
61 | if mfa_id is None:
62 | # Disable all MFA for a user.
63 | c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c),))
64 | else:
65 | # Disable a particular MFA mode for a user.
66 | c.execute('DELETE FROM mfa WHERE user_id=? AND id=?', (get_user_id(email, c), mfa_id))
67 | conn.commit()
68 | return c.rowcount > 0
69 |
70 | def validate_totp_secret(secret):
71 | if not isinstance(secret, str) or secret.strip() == "":
72 | msg = "No secret provided."
73 | raise ValueError(msg)
74 | if len(secret) != 32:
75 | msg = "Secret should be a 32 characters base32 string"
76 | raise ValueError(msg)
77 |
78 | def provision_totp(email, env):
79 | # Make a new secret.
80 | secret = base64.b32encode(os.urandom(20)).decode('utf-8')
81 | validate_totp_secret(secret) # sanity check
82 |
83 | # Make a URI that we encode within a QR code.
84 | uri = pyotp.TOTP(secret).provisioning_uri(
85 | name=email,
86 | issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel"
87 | )
88 |
89 | # Generate a QR code as a base64-encode PNG image.
90 | qr = qrcode.make(uri)
91 | byte_arr = io.BytesIO()
92 | qr.save(byte_arr, format='PNG')
93 | png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8')
94 |
95 | return {
96 | "type": "totp",
97 | "secret": secret,
98 | "qr_code_base64": png_b64
99 | }
100 |
101 | def validate_auth_mfa(email, request, env):
102 | # Validates that a login request satisfies any MFA modes
103 | # that have been enabled for the user's account. Returns
104 | # a tuple (status, [hints]). status is True for a successful
105 | # MFA login, False for a missing token. If status is False,
106 | # hints is an array of codes that indicate what the user
107 | # can try. Possible codes are:
108 | # "missing-totp-token"
109 | # "invalid-totp-token"
110 |
111 | mfa_state = get_mfa_state(email, env)
112 |
113 | # If no MFA modes are added, return True.
114 | if len(mfa_state) == 0:
115 | return (True, [])
116 |
117 | # Try the enabled MFA modes.
118 | hints = set()
119 | for mfa_mode in mfa_state:
120 | if mfa_mode["type"] == "totp":
121 | # Check that a token is present in the X-Auth-Token header.
122 | # If not, give a hint that one can be supplied.
123 | token = request.headers.get('x-auth-token')
124 | if not token:
125 | hints.add("missing-totp-token")
126 | continue
127 |
128 | # Check for a replay attack.
129 | if hmac.compare_digest(token, mfa_mode['mru_token'] or ""):
130 | # If the token fails, skip this MFA mode.
131 | hints.add("invalid-totp-token")
132 | continue
133 |
134 | # Check the token.
135 | totp = pyotp.TOTP(mfa_mode["secret"])
136 | if not totp.verify(token, valid_window=1):
137 | hints.add("invalid-totp-token")
138 | continue
139 |
140 | # On success, record the token to prevent a replay attack.
141 | set_mru_token(email, mfa_mode['id'], token, env)
142 | return (True, [])
143 |
144 | # On a failed login, indicate failure and any hints for what the user can do instead.
145 | return (False, list(hints))
146 |
--------------------------------------------------------------------------------
/management/munin_start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | mkdir -p /var/run/munin && chown munin /var/run/munin
3 |
--------------------------------------------------------------------------------
/management/templates/external-dns.html:
--------------------------------------------------------------------------------
1 |
28 |
29 |
External DNS
30 |
31 |
This is an advanced configuration page.
32 |
33 |
Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere — such as in the DNS control panel provided by your domain name registrar or virtual cloud provider — by copying the DNS zone information shown in the table below into your external DNS server’s control panel.
34 |
35 |
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.
36 |
37 |
38 |
39 |
40 | You may encounter zone file errors when attempting to create a TXT record with a long string.
41 | RFC 4408 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.
42 | You may need to adopt this technique when adding DomainKeys. Use a tool like named-checkzone to validate your zone file.
43 |
44 |
45 |
Download zonefile
46 |
You can download your zonefiles here or use the table of records below.
27 |
28 | {% if no_users_exist or no_admins_exist %}
29 |
30 |
31 | {% if no_users_exist %}
32 |
There are no users on this system! To make an administrative user,
33 | log into this machine using SSH (like when you first set it up) and run:
34 |
cd mailinabox
35 | sudo management/cli.py user add me@{{hostname}}
36 | sudo management/cli.py user make-admin me@{{hostname}}
37 | {% else %}
38 |
There are no administrative users on this system! To make an administrative user,
39 | log into this machine using SSH (like when you first set it up) and run:
40 |
cd mailinabox
41 | sudo management/cli.py user make-admin me@{{hostname}}
iOS and macOS only: Open this configuration link 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.
20 |
21 |
Manual configuration
22 |
23 |
Use the following settings when you set up your email on your phone, desktop, or other device:
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.
42 |
43 |
Exchange/ActiveSync settings
44 |
45 |
On iOS devices, devices on this compatibility list, 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’ve found this to be more buggy than using IMAP as described above. If you encounter any problems, please use the manual settings above.
46 |
47 |
48 |
Server
{{hostname}}
49 |
Options
Secure Connection
50 |
51 |
52 |
Your device should also provide a contacts list and calendar that syncs to this box when you use this method.
53 |
54 |
55 |
56 |
57 |
58 |
Other information about mail on your box
59 |
60 |
61 |
Greylisting
62 |
Your box uses a technique called greylisting to cut down on spam. Greylisting works by initially rejecting mail from people you haven’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.
63 |
64 |
+tag addresses
65 |
Every incoming email address also receives mail for +tag addresses. If your email address is you@yourdomain.com, you’ll also automatically get mail sent to you+anythinghere@yourdomain.com. Use this as a fast way to segment incoming mail for your own filtering rules without having to create aliases in this control panel.
66 |
67 |
Use only this box to send as you
68 |
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.
This machine is serving a simple, static website at https://{{hostname}} and at all domain names that you set up an email user or alias for.
7 |
8 |
Uploading web files
9 |
10 |
You can replace the default website with your own HTML pages and other static files. This control panel won’t help you design a website, but once you have .html files you can upload them following these instructions:
11 |
12 |
13 |
Ensure that any domains you are publishing a website for have no problems on the Status Checks page.
14 |
15 |
On your personal computer, install an SSH file transfer program such as FileZilla or scp.
16 |
17 |
Log in to this machine with the file transfer program. The server is {{hostname}}, the protocol is SSH or SFTP, and use the SSH login credentials that you used when you originally created this machine at your cloud host provider. This is not what you use to log in either for email or this control panel. Your SSH credentials probably involves a private key file.
18 |
19 |
Upload your .html or other files to the directory {{storage_root}}/www/default on this machine. They will appear directly and immediately on the web.
20 |
21 |
The websites set up on this machine are listed in the table below with where to put the files for each website.
22 |
23 |
24 |
25 |
26 |
Site
27 |
Directory for Files
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
To add a domain to this table, create a dummy mail user or alias on the domain first and see the setup guide for adding nameserver records to the new domain at your registrar (but not glue records).
16 |
17 |
--------------------------------------------------------------------------------
/management/utils.py:
--------------------------------------------------------------------------------
1 | import os.path
2 |
3 | # DO NOT import non-standard modules. This module is imported by
4 | # migrate.py which runs on fresh machines before anything is installed
5 | # besides Python.
6 |
7 | # THE ENVIRONMENT FILE AT /etc/mailinabox.conf
8 |
9 | def load_environment():
10 | # Load settings from /etc/mailinabox.conf.
11 | return load_env_vars_from_file("/etc/mailinabox.conf")
12 |
13 | def load_env_vars_from_file(fn):
14 | # Load settings from a KEY=VALUE file.
15 | import collections
16 | env = collections.OrderedDict()
17 | with open(fn, encoding="utf-8") as f:
18 | for line in f:
19 | env.setdefault(*line.strip().split("=", 1))
20 | return env
21 |
22 | def save_environment(env):
23 | with open("/etc/mailinabox.conf", "w", encoding="utf-8") as f:
24 | for k, v in env.items():
25 | f.write(f"{k}={v}\n")
26 |
27 | # THE SETTINGS FILE AT STORAGE_ROOT/settings.yaml.
28 |
29 | def write_settings(config, env):
30 | import rtyaml
31 | fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
32 | with open(fn, "w", encoding="utf-8") as f:
33 | f.write(rtyaml.dump(config))
34 |
35 | def load_settings(env):
36 | import rtyaml
37 | fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
38 | try:
39 | with open(fn, encoding="utf-8") as f:
40 | config = rtyaml.load(f)
41 | if not isinstance(config, dict): raise ValueError # caught below
42 | return config
43 | except:
44 | return { }
45 |
46 | # UTILITIES
47 |
48 | def safe_domain_name(name):
49 | # Sanitize a domain name so it is safe to use as a file name on disk.
50 | import urllib.parse
51 | return urllib.parse.quote(name, safe='')
52 |
53 | def sort_domains(domain_names, env):
54 | # Put domain names in a nice sorted order.
55 |
56 | # The nice order will group domain names by DNS zone, i.e. the top-most
57 | # domain name that we serve that ecompasses a set of subdomains. Map
58 | # each of the domain names to the zone that contains them. Walk the domains
59 | # from shortest to longest since zones are always shorter than their
60 | # subdomains.
61 | zones = { }
62 | for domain in sorted(domain_names, key=len):
63 | for z in zones.values():
64 | if domain.endswith("." + z):
65 | # We found a parent domain already in the list.
66 | zones[domain] = z
67 | break
68 | else:
69 | # 'break' did not occur: there is no parent domain, so it is its
70 | # own zone.
71 | zones[domain] = domain
72 |
73 | # Sort the zones.
74 | zone_domains = sorted(zones.values(),
75 | key = lambda d : (
76 | # PRIMARY_HOSTNAME or the zone that contains it is always first.
77 | not (d == env['PRIMARY_HOSTNAME'] or env['PRIMARY_HOSTNAME'].endswith("." + d)),
78 |
79 | # Then just dumb lexicographically.
80 | d,
81 | ))
82 |
83 | # Now sort the domain names that fall within each zone.
84 | return sorted(domain_names,
85 | key = lambda d : (
86 | # First by zone.
87 | zone_domains.index(zones[d]),
88 |
89 | # PRIMARY_HOSTNAME is always first within the zone that contains it.
90 | d != env['PRIMARY_HOSTNAME'],
91 |
92 | # Followed by any of its subdomains.
93 | not d.endswith("." + env['PRIMARY_HOSTNAME']),
94 |
95 | # Then in right-to-left lexicographic order of the .-separated parts of the name.
96 | list(reversed(d.split("."))),
97 | ))
98 |
99 |
100 | def sort_email_addresses(email_addresses, env):
101 | email_addresses = set(email_addresses)
102 | domains = {email.split("@", 1)[1] for email in email_addresses if "@" in email}
103 | ret = []
104 | for domain in sort_domains(domains, env):
105 | domain_emails = {email for email in email_addresses if email.endswith("@" + domain)}
106 | ret.extend(sorted(domain_emails))
107 | email_addresses -= domain_emails
108 | ret.extend(sorted(email_addresses)) # whatever is left
109 | return ret
110 |
111 | def shell(method, cmd_args, env=None, capture_stderr=False, return_bytes=False, trap=False, input=None):
112 | # A safe way to execute processes.
113 | # Some processes like apt-get require being given a sane PATH.
114 | import subprocess
115 |
116 | if env is None:
117 | env = {}
118 | env.update({ "PATH": "/sbin:/bin:/usr/sbin:/usr/bin" })
119 | kwargs = {
120 | 'env': env,
121 | 'stderr': None if not capture_stderr else subprocess.STDOUT,
122 | }
123 | if method == "check_output" and input is not None:
124 | kwargs['input'] = input
125 |
126 | if not trap:
127 | ret = getattr(subprocess, method)(cmd_args, **kwargs)
128 | else:
129 | try:
130 | ret = getattr(subprocess, method)(cmd_args, **kwargs)
131 | code = 0
132 | except subprocess.CalledProcessError as e:
133 | ret = e.output
134 | code = e.returncode
135 | if not return_bytes and isinstance(ret, bytes): ret = ret.decode("utf8")
136 | if not trap:
137 | return ret
138 | else:
139 | return code, ret
140 |
141 | def create_syslog_handler():
142 | import logging.handlers
143 | handler = logging.handlers.SysLogHandler(address='/dev/log')
144 | handler.setLevel(logging.WARNING)
145 | return handler
146 |
147 | def du(path):
148 | # Computes the size of all files in the path, like the `du` command.
149 | # Based on http://stackoverflow.com/a/17936789. Takes into account
150 | # soft and hard links.
151 | total_size = 0
152 | seen = set()
153 | for dirpath, _dirnames, filenames in os.walk(path):
154 | for f in filenames:
155 | fp = os.path.join(dirpath, f)
156 | try:
157 | stat = os.lstat(fp)
158 | except OSError:
159 | continue
160 | if stat.st_ino in seen:
161 | continue
162 | seen.add(stat.st_ino)
163 | total_size += stat.st_size
164 | return total_size
165 |
166 | def wait_for_service(port, public, env, timeout):
167 | # Block until a service on a given port (bound privately or publicly)
168 | # is taking connections, with a maximum timeout.
169 | import socket, time
170 | start = time.perf_counter()
171 | while True:
172 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
173 | s.settimeout(timeout/3)
174 | try:
175 | s.connect(("127.0.0.1" if not public else env['PUBLIC_IP'], port))
176 | return True
177 | except OSError:
178 | if time.perf_counter() > start+timeout:
179 | return False
180 | time.sleep(min(timeout/4, 1))
181 |
182 | def get_ssh_port():
183 | port_value = get_ssh_config_value("port")
184 |
185 | if port_value:
186 | return int(port_value)
187 |
188 | return None
189 |
190 | def get_ssh_config_value(parameter_name):
191 | # Returns ssh configuration value for the provided parameter
192 | import subprocess
193 | try:
194 | output = shell('check_output', ['sshd', '-T'])
195 | except FileNotFoundError:
196 | # sshd is not installed. That's ok.
197 | return None
198 | except subprocess.CalledProcessError:
199 | # error while calling shell command
200 | return None
201 |
202 | for line in output.split("\n"):
203 | if " " not in line: continue # there's a blank line at the end
204 | key, values = line.split(" ", 1)
205 | if key == parameter_name:
206 | return values # space-delimited if there are multiple values
207 |
208 | # Did not find the parameter!
209 | return None
210 |
211 | if __name__ == "__main__":
212 | from web_update import get_web_domains
213 | env = load_environment()
214 | domains = get_web_domains(env)
215 | for domain in domains:
216 | print(domain)
217 |
--------------------------------------------------------------------------------
/management/wsgi.py:
--------------------------------------------------------------------------------
1 | from daemon import app
2 | import utils
3 |
4 | app.logger.addHandler(utils.create_syslog_handler())
5 |
6 | if __name__ == "__main__":
7 | app.run(port=10222)
8 |
--------------------------------------------------------------------------------
/setup/bootstrap.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #########################################################
3 | # This script is intended to be run like this:
4 | #
5 | # curl https://mailinabox.email/setup.sh | sudo bash
6 | #
7 | #########################################################
8 |
9 | if [ -z "$TAG" ]; then
10 | # If a version to install isn't explicitly given as an environment
11 | # variable, then install the latest version. But the latest version
12 | # depends on the machine's version of Ubuntu. Existing users need to
13 | # be able to upgrade to the latest version available for that version
14 | # of Ubuntu to satisfy the migration requirements.
15 | #
16 | # Also, the system status checks read this script for TAG = (without the
17 | # space, but if we put it in a comment it would confuse the status checks!)
18 | # to get the latest version, so the first such line must be the one that we
19 | # want to display in status checks.
20 | #
21 | # Allow point-release versions of the major releases, e.g. 22.04.1 is OK.
22 | UBUNTU_VERSION=$( lsb_release -d | sed 's/.*:\s*//' | sed 's/\([0-9]*\.[0-9]*\)\.[0-9]/\1/' )
23 | if [ "$UBUNTU_VERSION" == "Ubuntu 22.04 LTS" ]; then
24 | # This machine is running Ubuntu 22.04, which is supported by
25 | # Mail-in-a-Box versions 60 and later.
26 | TAG=v71a
27 | elif [ "$UBUNTU_VERSION" == "Ubuntu 18.04 LTS" ]; then
28 | # This machine is running Ubuntu 18.04, which is supported by
29 | # Mail-in-a-Box versions 0.40 through 5x.
30 | echo "Support is ending for Ubuntu 18.04."
31 | echo "Please immediately begin to migrate your data to"
32 | echo "a new machine running Ubuntu 22.04. See:"
33 | echo "https://mailinabox.email/maintenance.html#upgrade"
34 | TAG=v57a
35 | elif [ "$UBUNTU_VERSION" == "Ubuntu 14.04 LTS" ]; then
36 | # This machine is running Ubuntu 14.04, which is supported by
37 | # Mail-in-a-Box versions 1 through v0.30.
38 | echo "Ubuntu 14.04 is no longer supported."
39 | echo "The last version of Mail-in-a-Box supporting Ubuntu 14.04 will be installed."
40 | TAG=v0.30
41 | else
42 | echo "This script may be used only on a machine running Ubuntu 14.04, 18.04, or 22.04."
43 | exit 1
44 | fi
45 | fi
46 |
47 | # Are we running as root?
48 | if [[ $EUID -ne 0 ]]; then
49 | echo "This script must be run as root. Did you leave out sudo?"
50 | exit 1
51 | fi
52 |
53 | # Clone the Mail-in-a-Box repository if it doesn't exist.
54 | if [ ! -d "$HOME/mailinabox" ]; then
55 | if [ ! -f /usr/bin/git ]; then
56 | echo "Installing git . . ."
57 | apt-get -q -q update
58 | DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y git < /dev/null
59 | echo
60 | fi
61 |
62 | if [ "$SOURCE" == "" ]; then
63 | SOURCE=https://github.com/mail-in-a-box/mailinabox
64 | fi
65 |
66 | echo "Downloading Mail-in-a-Box $TAG. . ."
67 | git clone \
68 | -b "$TAG" --depth 1 \
69 | "$SOURCE" \
70 | "$HOME/mailinabox" \
71 | < /dev/null 2> /dev/null
72 |
73 | echo
74 | fi
75 |
76 | # Change directory to it.
77 | cd "$HOME/mailinabox" || exit
78 |
79 | # Update it.
80 | if [ "$TAG" != "$(git describe --always)" ]; then
81 | echo "Updating Mail-in-a-Box to $TAG . . ."
82 | git fetch --depth 1 --force --prune origin tag "$TAG"
83 | if ! git checkout -q "$TAG"; then
84 | echo "Update failed. Did you modify something in $PWD?"
85 | exit 1
86 | fi
87 | echo
88 | fi
89 |
90 | # Start setup script.
91 | setup/start.sh
92 |
--------------------------------------------------------------------------------
/setup/dkim.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # OpenDKIM
3 | # --------
4 | #
5 | # OpenDKIM provides a service that puts a DKIM signature on outbound mail.
6 | #
7 | # The DNS configuration for DKIM is done in the management daemon.
8 |
9 | source setup/functions.sh # load our functions
10 | source /etc/mailinabox.conf # load global vars
11 |
12 | # Install DKIM...
13 | echo "Installing OpenDKIM/OpenDMARC..."
14 | apt_install opendkim opendkim-tools opendmarc
15 |
16 | # Make sure configuration directories exist.
17 | mkdir -p /etc/opendkim;
18 | mkdir -p "$STORAGE_ROOT/mail/dkim"
19 |
20 | # Used in InternalHosts and ExternalIgnoreList configuration directives.
21 | # Not quite sure why.
22 | echo "127.0.0.1" > /etc/opendkim/TrustedHosts
23 |
24 | # We need to at least create these files, since we reference them later.
25 | # Otherwise, opendkim startup will fail
26 | touch /etc/opendkim/KeyTable
27 | touch /etc/opendkim/SigningTable
28 |
29 | if grep -q "ExternalIgnoreList" /etc/opendkim.conf; then
30 | true # already done #NODOC
31 | else
32 | # Add various configuration options to the end of `opendkim.conf`.
33 | cat >> /etc/opendkim.conf << EOF;
34 | Canonicalization relaxed/simple
35 | MinimumKeyBits 1024
36 | ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
37 | InternalHosts refile:/etc/opendkim/TrustedHosts
38 | KeyTable refile:/etc/opendkim/KeyTable
39 | SigningTable refile:/etc/opendkim/SigningTable
40 | Socket inet:8891@127.0.0.1
41 | RequireSafeKeys false
42 | EOF
43 | fi
44 |
45 | # Create a new DKIM key. This creates mail.private and mail.txt
46 | # in $STORAGE_ROOT/mail/dkim. The former is the private key and
47 | # the latter is the suggested DNS TXT entry which we'll include
48 | # in our DNS setup. Note that the files are named after the
49 | # 'selector' of the key, which we can change later on to support
50 | # key rotation.
51 | #
52 | # A 1024-bit key is seen as a minimum standard by several providers
53 | # such as Google. But they and others use a 2048 bit key, so we'll
54 | # do the same. Keys beyond 2048 bits may exceed DNS record limits.
55 | if [ ! -f "$STORAGE_ROOT/mail/dkim/mail.private" ]; then
56 | opendkim-genkey -b 2048 -r -s mail -D "$STORAGE_ROOT/mail/dkim"
57 | fi
58 |
59 | # Ensure files are owned by the opendkim user and are private otherwise.
60 | chown -R opendkim:opendkim "$STORAGE_ROOT/mail/dkim"
61 | chmod go-rwx "$STORAGE_ROOT/mail/dkim"
62 |
63 | tools/editconf.py /etc/opendmarc.conf -s \
64 | "Syslog=true" \
65 | "Socket=inet:8893@[127.0.0.1]" \
66 | "FailureReports=false"
67 |
68 | # SPFIgnoreResults causes the filter to ignore any SPF results in the header
69 | # of the message. This is useful if you want the filter to perform SPF checks
70 | # itself, or because you don't trust the arriving header. This added header is
71 | # used by spamassassin to evaluate the mail for spamminess.
72 |
73 | tools/editconf.py /etc/opendmarc.conf -s \
74 | "SPFIgnoreResults=true"
75 |
76 | # SPFSelfValidate causes the filter to perform a fallback SPF check itself
77 | # when it can find no SPF results in the message header. If SPFIgnoreResults
78 | # is also set, it never looks for SPF results in headers and always performs
79 | # the SPF check itself when this is set. This added header is used by
80 | # spamassassin to evaluate the mail for spamminess.
81 |
82 | tools/editconf.py /etc/opendmarc.conf -s \
83 | "SPFSelfValidate=true"
84 |
85 | # Disables generation of failure reports for sending domains that publish a
86 | # "none" policy.
87 |
88 | tools/editconf.py /etc/opendmarc.conf -s \
89 | "FailureReportsOnNone=false"
90 |
91 | # AlwaysAddARHeader Adds an "Authentication-Results:" header field even to
92 | # unsigned messages from domains with no "signs all" policy. The reported DKIM
93 | # result will be "none" in such cases. Normally unsigned mail from non-strict
94 | # domains does not cause the results header field to be added. This added header
95 | # is used by spamassassin to evaluate the mail for spamminess.
96 |
97 | tools/editconf.py /etc/opendkim.conf -s \
98 | "AlwaysAddARHeader=true"
99 |
100 | # Add OpenDKIM and OpenDMARC as milters to postfix, which is how OpenDKIM
101 | # intercepts outgoing mail to perform the signing (by adding a mail header)
102 | # and how they both intercept incoming mail to add Authentication-Results
103 | # headers. The order possibly/probably matters: OpenDMARC relies on the
104 | # OpenDKIM Authentication-Results header already being present.
105 | #
106 | # Be careful. If we add other milters later, this needs to be concatenated
107 | # on the smtpd_milters line.
108 | #
109 | # The OpenDMARC milter is skipped in the SMTP submission listener by
110 | # configuring smtpd_milters there to only list the OpenDKIM milter
111 | # (see mail-postfix.sh).
112 | tools/editconf.py /etc/postfix/main.cf \
113 | "smtpd_milters=inet:127.0.0.1:8891 inet:127.0.0.1:8893"\
114 | non_smtpd_milters=\$smtpd_milters \
115 | milter_default_action=accept
116 |
117 | # We need to explicitly enable the opendmarc service, or it will not start
118 | hide_output systemctl enable opendmarc
119 |
120 | # Restart services.
121 | restart_service opendkim
122 | restart_service opendmarc
123 | restart_service postfix
124 |
125 |
--------------------------------------------------------------------------------
/setup/dns.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # DNS
3 | # -----------------------------------------------
4 |
5 | # This script installs packages, but the DNS zone files are only
6 | # created by the /dns/update API in the management server because
7 | # the set of zones (domains) hosted by the server depends on the
8 | # mail users & aliases created by the user later.
9 |
10 | source setup/functions.sh # load our functions
11 | source /etc/mailinabox.conf # load global vars
12 |
13 | # Prepare nsd's configuration.
14 | # We configure nsd before installation as we only want it to bind to some addresses
15 | # and it otherwise will have port / bind conflicts with bind9 used as the local resolver
16 | mkdir -p /var/run/nsd
17 | mkdir -p /etc/nsd
18 | mkdir -p /etc/nsd/zones
19 | touch /etc/nsd/zones.conf
20 |
21 | cat > /etc/nsd/nsd.conf << EOF;
22 | # Do not edit. Overwritten by Mail-in-a-Box setup.
23 | server:
24 | hide-version: yes
25 | logfile: "/var/log/nsd.log"
26 |
27 | # identify the server (CH TXT ID.SERVER entry).
28 | identity: ""
29 |
30 | # The directory for zonefile: files.
31 | zonesdir: "/etc/nsd/zones"
32 |
33 | # Allows NSD to bind to IP addresses that are not (yet) added to the
34 | # network interface. This allows nsd to start even if the network stack
35 | # isn't fully ready, which apparently happens in some cases.
36 | # See https://www.nlnetlabs.nl/projects/nsd/nsd.conf.5.html.
37 | ip-transparent: yes
38 |
39 | EOF
40 |
41 | # Since we have bind9 listening on localhost for locally-generated
42 | # DNS queries that require a recursive nameserver, and the system
43 | # might have other network interfaces for e.g. tunnelling, we have
44 | # to be specific about the network interfaces that nsd binds to.
45 | for ip in $PRIVATE_IP $PRIVATE_IPV6; do
46 | echo " ip-address: $ip" >> /etc/nsd/nsd.conf;
47 | done
48 |
49 | # Create a directory for additional configuration directives, including
50 | # the zones.conf file written out by our management daemon.
51 | echo "include: /etc/nsd/nsd.conf.d/*.conf" >> /etc/nsd/nsd.conf;
52 |
53 | # Remove the old location of zones.conf that we generate. It will
54 | # now be stored in /etc/nsd/nsd.conf.d.
55 | rm -f /etc/nsd/zones.conf
56 |
57 | # Add log rotation
58 | cat > /etc/logrotate.d/nsd < "$STORAGE_ROOT/dns/dnssec/$algo.conf" << EOF;
130 | KSK=$KSK
131 | ZSK=$ZSK
132 | EOF
133 | fi
134 |
135 | # And loop to do the next algorithm...
136 | done
137 |
138 | # Force the dns_update script to be run every day to re-sign zones for DNSSEC
139 | # before they expire. When we sign zones (in `dns_update.py`) we specify a
140 | # 30-day validation window, so we had better re-sign before then.
141 | cat > /etc/cron.daily/mailinabox-dnssec << EOF;
142 | #!/bin/bash
143 | # Mail-in-a-Box
144 | # Re-sign any DNS zones with DNSSEC because the signatures expire periodically.
145 | $PWD/tools/dns_update
146 | EOF
147 | chmod +x /etc/cron.daily/mailinabox-dnssec
148 |
149 | # Permit DNS queries on TCP/UDP in the firewall.
150 |
151 | ufw_allow domain
152 |
153 |
--------------------------------------------------------------------------------
/setup/firstuser.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # If there aren't any mail users yet, create one.
3 | if [ -z "$(management/cli.py user)" ]; then
4 | # The output of "management/cli.py user" is a list of mail users. If there
5 | # aren't any yet, it'll be empty.
6 |
7 | # If we didn't ask for an email address at the start, do so now.
8 | if [ -z "${EMAIL_ADDR:-}" ]; then
9 | # In an interactive shell, ask the user for an email address.
10 | if [ -z "${NONINTERACTIVE:-}" ]; then
11 | input_box "Mail Account" \
12 | "Let's create your first mail account.
13 | \n\nWhat email address do you want?" \
14 | "me@$(get_default_hostname)" \
15 | EMAIL_ADDR
16 |
17 | if [ -z "$EMAIL_ADDR" ]; then
18 | # user hit ESC/cancel
19 | exit
20 | fi
21 | while ! management/mailconfig.py validate-email "$EMAIL_ADDR"
22 | do
23 | input_box "Mail Account" \
24 | "That's not a valid email address.
25 | \n\nWhat email address do you want?" \
26 | "$EMAIL_ADDR" \
27 | EMAIL_ADDR
28 | if [ -z "$EMAIL_ADDR" ]; then
29 | # user hit ESC/cancel
30 | exit
31 | fi
32 | done
33 |
34 | # But in a non-interactive shell, just make something up.
35 | # This is normally for testing.
36 | else
37 | # Use me@PRIMARY_HOSTNAME
38 | EMAIL_ADDR=me@$PRIMARY_HOSTNAME
39 | EMAIL_PW=12345678
40 | echo
41 | echo "Creating a new administrative mail account for $EMAIL_ADDR with password $EMAIL_PW."
42 | echo
43 | fi
44 | else
45 | echo
46 | echo "Okay. I'm about to set up $EMAIL_ADDR for you. This account will also"
47 | echo "have access to the box's control panel."
48 | fi
49 |
50 | # Create the user's mail account. This will ask for a password if none was given above.
51 | management/cli.py user add "$EMAIL_ADDR" ${EMAIL_PW:+"$EMAIL_PW"}
52 |
53 | # Make it an admin.
54 | hide_output management/cli.py user make-admin "$EMAIL_ADDR"
55 |
56 | # Create an alias to which we'll direct all automatically-created administrative aliases.
57 | management/cli.py alias add "administrator@$PRIMARY_HOSTNAME" "$EMAIL_ADDR" > /dev/null
58 | fi
59 |
--------------------------------------------------------------------------------
/setup/mail-users.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # User Authentication and Destination Validation
4 | # ----------------------------------------------
5 | #
6 | # This script configures user authentication for Dovecot
7 | # and Postfix (which relies on Dovecot) and destination
8 | # validation by querying an Sqlite3 database of mail users.
9 |
10 | source setup/functions.sh # load our functions
11 | source /etc/mailinabox.conf # load global vars
12 |
13 | # ### User and Alias Database
14 |
15 | # The database of mail users (i.e. authenticated users, who have mailboxes)
16 | # and aliases (forwarders).
17 |
18 | db_path=$STORAGE_ROOT/mail/users.sqlite
19 |
20 | # Create an empty database if it doesn't yet exist.
21 | if [ ! -f "$db_path" ]; then
22 | echo "Creating new user database: $db_path";
23 | echo "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";
24 | echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 "$db_path";
25 | echo "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";
26 | echo "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 "$db_path";
27 | fi
28 |
29 | # ### User Authentication
30 |
31 | # Have Dovecot query our database, and not system users, for authentication.
32 | sed -i "s/#*\(\!include auth-system.conf.ext\)/#\1/" /etc/dovecot/conf.d/10-auth.conf
33 | sed -i "s/#\(\!include auth-sql.conf.ext\)/\1/" /etc/dovecot/conf.d/10-auth.conf
34 |
35 | # Specify how the database is to be queried for user authentication (passdb)
36 | # and where user mailboxes are stored (userdb).
37 | cat > /etc/dovecot/conf.d/auth-sql.conf.ext << EOF;
38 | passdb {
39 | driver = sql
40 | args = /etc/dovecot/dovecot-sql.conf.ext
41 | }
42 | userdb {
43 | driver = sql
44 | args = /etc/dovecot/dovecot-sql.conf.ext
45 | }
46 | EOF
47 |
48 | # Configure the SQL to query for a user's metadata and password.
49 | cat > /etc/dovecot/dovecot-sql.conf.ext << EOF;
50 | driver = sqlite
51 | connect = $db_path
52 | default_pass_scheme = SHA512-CRYPT
53 | password_query = SELECT email as user, password FROM users WHERE email='%u';
54 | user_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';
55 | iterate_query = SELECT email AS user FROM users;
56 | EOF
57 | chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
58 |
59 | # Have Dovecot provide an authorization service that Postfix can access & use.
60 | cat > /etc/dovecot/conf.d/99-local-auth.conf << EOF;
61 | service auth {
62 | unix_listener /var/spool/postfix/private/auth {
63 | mode = 0666
64 | user = postfix
65 | group = postfix
66 | }
67 | }
68 | EOF
69 |
70 | # And have Postfix use that service. We *disable* it here
71 | # so that authentication is not permitted on port 25 (which
72 | # does not run DKIM on relayed mail, so outbound mail isn't
73 | # correct, see #830), but we enable it specifically for the
74 | # submission port.
75 | tools/editconf.py /etc/postfix/main.cf \
76 | smtpd_sasl_type=dovecot \
77 | smtpd_sasl_path=private/auth \
78 | smtpd_sasl_auth_enable=no
79 |
80 | # ### Sender Validation
81 |
82 | # We use Postfix's reject_authenticated_sender_login_mismatch filter to
83 | # prevent intra-domain spoofing by logged in but untrusted users in outbound
84 | # email. In all outbound mail (the sender has authenticated), the MAIL FROM
85 | # address (aka envelope or return path address) must be "owned" by the user
86 | # who authenticated. An SQL query will find who are the owners of any given
87 | # address.
88 | tools/editconf.py /etc/postfix/main.cf \
89 | smtpd_sender_login_maps=sqlite:/etc/postfix/sender-login-maps.cf
90 |
91 | # Postfix will query the exact address first, where the priority will be alias
92 | # records first, then user records. If there are no matches for the exact
93 | # address, then Postfix will query just the domain part, which we call
94 | # catch-alls and domain aliases. A NULL permitted_senders column means to
95 | # take the value from the destination column.
96 | cat > /etc/postfix/sender-login-maps.cf << EOF;
97 | dbpath=$db_path
98 | query = 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;
99 | EOF
100 |
101 | # ### Destination Validation
102 |
103 | # Use a Sqlite3 database to check whether a destination email address exists,
104 | # and to perform any email alias rewrites in Postfix. Additionally, we disable
105 | # SMTPUTF8 because Dovecot's LMTP server that delivers mail to inboxes does
106 | # not support it, and if a message is received with the SMTPUTF8 flag it will
107 | # bounce.
108 | tools/editconf.py /etc/postfix/main.cf \
109 | smtputf8_enable=no \
110 | virtual_mailbox_domains=sqlite:/etc/postfix/virtual-mailbox-domains.cf \
111 | virtual_mailbox_maps=sqlite:/etc/postfix/virtual-mailbox-maps.cf \
112 | virtual_alias_maps=sqlite:/etc/postfix/virtual-alias-maps.cf \
113 | local_recipient_maps=\$virtual_mailbox_maps
114 |
115 | # SQL statement to check if we handle incoming mail for a domain, either for users or aliases.
116 | cat > /etc/postfix/virtual-mailbox-domains.cf << EOF;
117 | dbpath=$db_path
118 | query = 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'
119 | EOF
120 |
121 | # SQL statement to check if we handle incoming mail for a user.
122 | cat > /etc/postfix/virtual-mailbox-maps.cf << EOF;
123 | dbpath=$db_path
124 | query = SELECT 1 FROM users WHERE email='%s'
125 | EOF
126 |
127 | # SQL statement to rewrite an email address if an alias is present.
128 | #
129 | # Postfix makes multiple queries for each incoming mail. It first
130 | # queries the whole email address, then just the user part in certain
131 | # locally-directed cases (but we don't use this), then just `@`+the
132 | # domain part. The first query that returns something wins. See
133 | # http://www.postfix.org/virtual.5.html.
134 | #
135 | # virtual-alias-maps has precedence over virtual-mailbox-maps, but
136 | # we don't want catch-alls and domain aliases to catch mail for users
137 | # that have been defined on those domains. To fix this, we not only
138 | # query the aliases table but also the users table when resolving
139 | # aliases, i.e. we turn users into aliases from themselves to
140 | # themselves. That means users will match in postfix's first query
141 | # before postfix gets to the third query for catch-alls/domain alises.
142 | #
143 | # If there is both an alias and a user for the same address either
144 | # might be returned by the UNION, so the whole query is wrapped in
145 | # another select that prioritizes the alias definition to preserve
146 | # postfix's preference for aliases for whole email addresses.
147 | #
148 | # Since we might have alias records with an empty destination because
149 | # it might have just permitted_senders, skip any records with an
150 | # empty destination here so that other lower priority rules might match.
151 | cat > /etc/postfix/virtual-alias-maps.cf << EOF;
152 | dbpath=$db_path
153 | query = 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;
154 | EOF
155 |
156 | # Restart Services
157 | ##################
158 |
159 | restart_service postfix
160 | restart_service dovecot
161 |
162 | # force a recalculation of all user quotas
163 | doveadm quota recalc -A
164 |
--------------------------------------------------------------------------------
/setup/management.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | source setup/functions.sh
4 | source /etc/mailinabox.conf # load global vars
5 |
6 | echo "Installing Mail-in-a-Box system management daemon..."
7 |
8 | # DEPENDENCIES
9 |
10 | # duplicity is used to make backups of user data.
11 | #
12 | # virtualenv is used to isolate the Python 3 packages we
13 | # install via pip from the system-installed packages.
14 | #
15 | # certbot installs EFF's certbot which we use to
16 | # provision free TLS certificates.
17 | apt_install duplicity python3-pip virtualenv certbot rsync
18 |
19 | # b2sdk is used for backblaze backups.
20 | # boto3 is used for amazon aws backups.
21 | # Both are installed outside the pipenv, so they can be used by duplicity
22 | hide_output pip3 install --upgrade b2sdk boto3
23 |
24 | # Create a virtualenv for the installation of Python 3 packages
25 | # used by the management daemon.
26 | inst_dir=/usr/local/lib/mailinabox
27 | mkdir -p $inst_dir
28 | venv=$inst_dir/env
29 | if [ ! -d $venv ]; then
30 | # A bug specific to Ubuntu 22.04 and Python 3.10 requires
31 | # forcing a virtualenv directory layout option (see #2335
32 | # and https://github.com/pypa/virtualenv/pull/2415). In
33 | # our issue, reportedly installing python3-distutils didn't
34 | # fix the problem.)
35 | export DEB_PYTHON_INSTALL_LAYOUT='deb'
36 | hide_output virtualenv -ppython3 $venv
37 | fi
38 |
39 | # Upgrade pip because the Ubuntu-packaged version is out of date.
40 | hide_output $venv/bin/pip install --upgrade pip
41 |
42 | # Install other Python 3 packages used by the management daemon.
43 | # The first line is the packages that Josh maintains himself!
44 | # NOTE: email_validator is repeated in setup/questions.sh, so please keep the versions synced.
45 | hide_output $venv/bin/pip install --upgrade \
46 | rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
47 | flask dnspython python-dateutil expiringdict gunicorn \
48 | qrcode[pil] pyotp \
49 | "idna>=2.0.0" "cryptography==37.0.2" psutil postfix-mta-sts-resolver \
50 | b2sdk boto3
51 |
52 | # CONFIGURATION
53 |
54 | # Create a backup directory and a random key for encrypting backups.
55 | mkdir -p "$STORAGE_ROOT/backup"
56 | if [ ! -f "$STORAGE_ROOT/backup/secret_key.txt" ]; then
57 | (umask 077; openssl rand -base64 2048 > "$STORAGE_ROOT/backup/secret_key.txt")
58 | fi
59 |
60 |
61 | # Download jQuery and Bootstrap local files
62 |
63 | # Make sure we have the directory to save to.
64 | assets_dir=$inst_dir/vendor/assets
65 | rm -rf $assets_dir
66 | mkdir -p $assets_dir
67 |
68 | # jQuery CDN URL
69 | jquery_version=2.2.4
70 | jquery_url=https://code.jquery.com
71 |
72 | # Get jQuery
73 | wget_verify $jquery_url/jquery-$jquery_version.min.js 69bb69e25ca7d5ef0935317584e6153f3fd9a88c $assets_dir/jquery.min.js
74 |
75 | # Bootstrap CDN URL
76 | bootstrap_version=3.4.1
77 | bootstrap_url=https://github.com/twbs/bootstrap/releases/download/v$bootstrap_version/bootstrap-$bootstrap_version-dist.zip
78 |
79 | # Get Bootstrap
80 | wget_verify $bootstrap_url 0bb64c67c2552014d48ab4db81c2e8c01781f580 /tmp/bootstrap.zip
81 | unzip -q /tmp/bootstrap.zip -d $assets_dir
82 | mv $assets_dir/bootstrap-$bootstrap_version-dist $assets_dir/bootstrap
83 | rm -f /tmp/bootstrap.zip
84 |
85 | # Create an init script to start the management daemon and keep it
86 | # running after a reboot.
87 | # Set a long timeout since some commands take a while to run, matching
88 | # the timeout we set for PHP (fastcgi_read_timeout in the nginx confs).
89 | # Note: Authentication currently breaks with more than 1 gunicorn worker.
90 | cat > $inst_dir/start < /var/lib/mailinabox/api.key
100 | chmod 640 /var/lib/mailinabox/api.key
101 |
102 | source $venv/bin/activate
103 | export PYTHONPATH=$PWD/management
104 | exec gunicorn -b localhost:10222 -w 1 --timeout 630 wsgi:app
105 | EOF
106 | chmod +x $inst_dir/start
107 | cp --remove-destination conf/mailinabox.service /lib/systemd/system/mailinabox.service # target was previously a symlink so remove it first
108 | hide_output systemctl link -f /lib/systemd/system/mailinabox.service
109 | hide_output systemctl daemon-reload
110 | hide_output systemctl enable mailinabox.service
111 |
112 | # Perform nightly tasks at 3am in system time: take a backup, run
113 | # status checks and email the administrator any changes.
114 |
115 | minute=$((RANDOM % 60)) # avoid overloading mailinabox.email
116 | cat > /etc/cron.d/mailinabox-nightly << EOF;
117 | # Mail-in-a-Box --- Do not edit / will be overwritten on update.
118 | # Run nightly tasks: backup, status checks.
119 | $minute 1 * * * root (cd $PWD && management/daily_tasks.sh)
120 | EOF
121 |
122 | # Start the management server.
123 | restart_service mailinabox
124 |
--------------------------------------------------------------------------------
/setup/munin.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Munin: resource monitoring tool
3 | #################################################
4 |
5 | source setup/functions.sh # load our functions
6 | source /etc/mailinabox.conf # load global vars
7 |
8 | # install Munin
9 | echo "Installing Munin (system monitoring)..."
10 | apt_install munin munin-node libcgi-fast-perl
11 | # libcgi-fast-perl is needed by /usr/lib/munin/cgi/munin-cgi-graph
12 |
13 | # edit config
14 | cat > /etc/munin/munin.conf </dev/null | sh || /bin/true
48 |
49 | # Deactivate monitoring of NTP peers. Not sure why anyone would want to monitor a NTP peer. The addresses seem to change
50 | # (which is taken care of my munin-node-configure, but only when we re-run it.)
51 | find /etc/munin/plugins/ -lname /usr/share/munin/plugins/ntp_ -print0 | xargs -0 /bin/rm -f
52 |
53 | # Deactivate monitoring of network interfaces that are not up. Otherwise we can get a lot of empty charts.
54 | for 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
55 | IF=$(echo "$f" | sed s/.*_//);
56 | if ! grep -qFx up "/sys/class/net/$IF/operstate" 2>/dev/null; then
57 | rm "$f";
58 | fi;
59 | done
60 |
61 | # Create a 'state' directory. Not sure why we need to do this manually.
62 | mkdir -p /var/lib/munin-node/plugin-state/
63 |
64 | # Create a systemd service for munin.
65 | ln -sf "$PWD/management/munin_start.sh" /usr/local/lib/mailinabox/munin_start.sh
66 | chmod 0744 /usr/local/lib/mailinabox/munin_start.sh
67 | cp --remove-destination conf/munin.service /lib/systemd/system/munin.service # target was previously a symlink so remove first
68 | hide_output systemctl link -f /lib/systemd/system/munin.service
69 | hide_output systemctl daemon-reload
70 | hide_output systemctl unmask munin.service
71 | hide_output systemctl enable munin.service
72 |
73 | # Restart services.
74 | restart_service munin
75 | restart_service munin-node
76 |
77 | # generate initial statistics so the directory isn't empty
78 | # (We get "Pango-WARNING **: error opening config file '/root/.config/pango/pangorc': Permission denied"
79 | # if we don't explicitly set the HOME directory when sudo'ing.)
80 | # We check to see if munin-cron is already running, if it is, there is no need to run it simultaneously
81 | # generating an error.
82 | if [ ! -f /var/run/munin/munin-update.lock ]; then
83 | sudo -H -u munin munin-cron
84 | fi
85 |
--------------------------------------------------------------------------------
/setup/network-checks.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Install the 'host', 'sed', and and 'nc' tools. This script is run before
3 | # the rest of the system setup so we may not yet have things installed.
4 | apt_get_quiet install bind9-host sed netcat-openbsd
5 |
6 | # Stop if the PRIMARY_HOSTNAME is listed in the Spamhaus Domain Block List.
7 | # The user might have chosen a name that was previously in use by a spammer
8 | # and will not be able to reliably send mail. Do this after any automatic
9 | # choices made above.
10 | if host "$PRIMARY_HOSTNAME.dbl.spamhaus.org" > /dev/null; then
11 | echo
12 | echo "The hostname you chose '$PRIMARY_HOSTNAME' is listed in the"
13 | echo "Spamhaus Domain Block List. See http://www.spamhaus.org/dbl/"
14 | echo "and http://www.spamhaus.org/query/domain/$PRIMARY_HOSTNAME."
15 | echo
16 | echo "You will not be able to send mail using this domain name, so"
17 | echo "setup cannot continue."
18 | echo
19 | exit 1
20 | fi
21 |
22 | # Stop if the IPv4 address is listed in the ZEN Spamhouse Block List.
23 | # The user might have ended up on an IP address that was previously in use
24 | # by a spammer, or the user may be deploying on a residential network. We
25 | # will not be able to reliably send mail in these cases.
26 | REVERSED_IPV4=$(echo "$PUBLIC_IP" | sed "s/\([0-9]*\).\([0-9]*\).\([0-9]*\).\([0-9]*\)/\4.\3.\2.\1/")
27 | if host "$REVERSED_IPV4.zen.spamhaus.org" > /dev/null; then
28 | echo
29 | echo "The IP address $PUBLIC_IP is listed in the Spamhaus Block List."
30 | echo "See http://www.spamhaus.org/query/ip/$PUBLIC_IP."
31 | echo
32 | echo "You will not be able to send mail using this machine, so setup"
33 | echo "cannot continue."
34 | echo
35 | echo "Associate a different IP address with this machine if possible."
36 | echo "Many residential network IP addresses are listed, so Mail-in-a-Box"
37 | echo "typically cannot be used on a residential Internet connection."
38 | echo
39 | exit 1
40 | fi
41 |
42 | # Stop if we cannot make an outbound connection on port 25. Many residential
43 | # networks block outbound port 25 to prevent their network from sending spam.
44 | # See if we can reach one of Google's MTAs with a 5-second timeout.
45 | if ! nc -z -w5 aspmx.l.google.com 25; then
46 | echo
47 | echo "Outbound mail (port 25) seems to be blocked by your network."
48 | echo
49 | echo "You will not be able to send mail using this machine, so setup"
50 | echo "cannot continue."
51 | echo
52 | echo "Many residential networks block port 25 to prevent hijacked"
53 | echo "machines from being able to send spam. I just tried to connect"
54 | echo "to Google's mail server on port 25 but the connection did not"
55 | echo "succeed."
56 | echo
57 | exit 1
58 | fi
59 |
--------------------------------------------------------------------------------
/setup/preflight.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Are we running as root?
3 | if [[ $EUID -ne 0 ]]; then
4 | echo "This script must be run as root. Please re-run like this:"
5 | echo
6 | echo "sudo $0"
7 | echo
8 | exit 1
9 | fi
10 |
11 | # Check that we are running on Ubuntu 22.04 LTS (or 22.04.xx).
12 | # Pull in the variables defined in /etc/os-release but in a
13 | # namespace to avoid polluting our variables.
14 | source <(cat /etc/os-release | sed s/^/OS_RELEASE_/)
15 | if [ "${OS_RELEASE_ID:-}" != "ubuntu" ] || [ "${OS_RELEASE_VERSION_ID:-}" != "22.04" ]; then
16 | echo "Mail-in-a-Box only supports being installed on Ubuntu 22.04, sorry. You are running:"
17 | echo
18 | echo "${OS_RELEASE_ID:-"Unknown linux distribution"} ${OS_RELEASE_VERSION_ID:-}"
19 | echo
20 | echo "We can't write scripts that run on every possible setup, sorry."
21 | exit 1
22 | fi
23 |
24 | # Check that we have enough memory.
25 | #
26 | # /proc/meminfo reports free memory in kibibytes. Our baseline will be 512 MB,
27 | # which is 500000 kibibytes.
28 | #
29 | # We will display a warning if the memory is below 768 MB which is 750000 kibibytes
30 | #
31 | # Skip the check if we appear to be running inside of Vagrant, because that's really just for testing.
32 | TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}')
33 | if [ "$TOTAL_PHYSICAL_MEM" -lt 490000 ]; then
34 | if [ ! -d /vagrant ]; then
35 | TOTAL_PHYSICAL_MEM=$(( TOTAL_PHYSICAL_MEM * 1024 / 1000 / 1000 ))
36 | echo "Your Mail-in-a-Box needs more memory (RAM) to function properly."
37 | echo "Please provision a machine with at least 512 MB, 1 GB recommended."
38 | echo "This machine has $TOTAL_PHYSICAL_MEM MB memory."
39 | exit
40 | fi
41 | fi
42 | if [ "$TOTAL_PHYSICAL_MEM" -lt 750000 ]; then
43 | echo "WARNING: Your Mail-in-a-Box has less than 768 MB of memory."
44 | echo " It might run unreliably when under heavy load."
45 | fi
46 |
47 | # Check that tempfs is mounted with exec
48 | MOUNTED_TMP_AS_NO_EXEC=$(grep "/tmp.*noexec" /proc/mounts || /bin/true)
49 | if [ -n "$MOUNTED_TMP_AS_NO_EXEC" ]; then
50 | echo "Mail-in-a-Box has to have exec rights on /tmp, please mount /tmp with exec"
51 | exit
52 | fi
53 |
54 | # Check that no .wgetrc exists
55 | if [ -e ~/.wgetrc ]; then
56 | echo "Mail-in-a-Box expects no overrides to wget defaults, ~/.wgetrc exists"
57 | exit
58 | fi
59 |
60 | # Check that we are running on x86_64 or i686 architecture, which are the only
61 | # ones we support / test.
62 | ARCHITECTURE=$(uname -m)
63 | if [ "$ARCHITECTURE" != "x86_64" ] && [ "$ARCHITECTURE" != "i686" ]; then
64 | echo
65 | echo "WARNING:"
66 | echo "Mail-in-a-Box has only been tested on x86_64 and i686 platform"
67 | echo "architectures. Your architecture, $ARCHITECTURE, may not work."
68 | echo "You are on your own."
69 | echo
70 | fi
71 |
--------------------------------------------------------------------------------
/setup/ssl.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # RSA private key, SSL certificate, Diffie-Hellman bits files
4 | # -------------------------------------------
5 |
6 | # Create an RSA private key, a self-signed SSL certificate, and some
7 | # Diffie-Hellman cipher bits, if they have not yet been created.
8 | #
9 | # The RSA private key and certificate are used for:
10 | #
11 | # * DNSSEC DANE TLSA records
12 | # * IMAP
13 | # * SMTP (opportunistic TLS for port 25 and submission on ports 465/587)
14 | # * HTTPS
15 | #
16 | # The certificate is created with its CN set to the PRIMARY_HOSTNAME. It is
17 | # also used for other domains served over HTTPS until the user installs a
18 | # better certificate for those domains.
19 | #
20 | # The Diffie-Hellman cipher bits are used for SMTP and HTTPS, when a
21 | # Diffie-Hellman cipher is selected during TLS negotiation. Diffie-Hellman
22 | # provides Perfect Forward Secrecy.
23 |
24 | source setup/functions.sh # load our functions
25 | source /etc/mailinabox.conf # load global vars
26 |
27 | # Show a status line if we are going to take any action in this file.
28 | if [ ! -f /usr/bin/openssl ] \
29 | || [ ! -f "$STORAGE_ROOT/ssl/ssl_private_key.pem" ] \
30 | || [ ! -f "$STORAGE_ROOT/ssl/ssl_certificate.pem" ] \
31 | || [ ! -f "$STORAGE_ROOT/ssl/dh2048.pem" ]; then
32 | echo "Creating initial SSL certificate and perfect forward secrecy Diffie-Hellman parameters..."
33 | fi
34 |
35 | # Install openssl.
36 |
37 | apt_install openssl
38 |
39 | # Create a directory to store TLS-related things like "SSL" certificates.
40 |
41 | mkdir -p "$STORAGE_ROOT/ssl"
42 |
43 | # Generate a new private key.
44 | #
45 | # The key is only as good as the entropy available to openssl so that it
46 | # can generate a random key. "OpenSSL’s built-in RSA key generator ....
47 | # is seeded on first use with (on Linux) 32 bytes read from /dev/urandom,
48 | # the process ID, user ID, and the current time in seconds. [During key
49 | # generation OpenSSL] mixes into the entropy pool the current time in seconds,
50 | # the process ID, and the possibly uninitialized contents of a ... buffer
51 | # ... dozens to hundreds of times."
52 | #
53 | # A perfect storm of issues can cause the generated key to be not very random:
54 | #
55 | # * improperly seeded /dev/urandom, but see system.sh for how we mitigate this
56 | # * the user ID of this process is always the same (we're root), so that seed is useless
57 | # * zero'd memory (plausible on embedded systems, cloud VMs?)
58 | # * a predictable process ID (likely on an embedded/virtualized system)
59 | # * a system clock reset to a fixed time on boot
60 | #
61 | # Since we properly seed /dev/urandom in system.sh we should be fine, but I leave
62 | # in the rest of the notes in case that ever changes.
63 | if [ ! -f "$STORAGE_ROOT/ssl/ssl_private_key.pem" ]; then
64 | # Set the umask so the key file is never world-readable.
65 | (umask 077; hide_output \
66 | openssl genrsa -out "$STORAGE_ROOT/ssl/ssl_private_key.pem" 2048)
67 | fi
68 |
69 | # Generate a self-signed SSL certificate because things like nginx, dovecot,
70 | # etc. won't even start without some certificate in place, and we need nginx
71 | # so we can offer the user a control panel to install a better certificate.
72 | if [ ! -f "$STORAGE_ROOT/ssl/ssl_certificate.pem" ]; then
73 | # Generate a certificate signing request.
74 | CSR=/tmp/ssl_cert_sign_req-$$.csr
75 | hide_output \
76 | openssl req -new -key "$STORAGE_ROOT/ssl/ssl_private_key.pem" -out $CSR \
77 | -sha256 -subj "/CN=$PRIMARY_HOSTNAME"
78 |
79 | # Generate the self-signed certificate.
80 | CERT=$STORAGE_ROOT/ssl/$PRIMARY_HOSTNAME-selfsigned-$(date --rfc-3339=date | sed s/-//g).pem
81 | hide_output \
82 | openssl x509 -req -days 365 \
83 | -in $CSR -signkey "$STORAGE_ROOT/ssl/ssl_private_key.pem" -out "$CERT"
84 |
85 | # Delete the certificate signing request because it has no other purpose.
86 | rm -f $CSR
87 |
88 | # Symlink the certificate into the system certificate path, so system services
89 | # can find it.
90 | ln -s "$CERT" "$STORAGE_ROOT/ssl/ssl_certificate.pem"
91 | fi
92 |
93 | # Generate some Diffie-Hellman cipher bits.
94 | # openssl's default bit length for this is 1024 bits, but we'll create
95 | # 2048 bits of bits per the latest recommendations.
96 | if [ ! -f "$STORAGE_ROOT/ssl/dh2048.pem" ]; then
97 | openssl dhparam -out "$STORAGE_ROOT/ssl/dh2048.pem" 2048
98 | fi
99 |
100 | # Cleanup expired SSL certificates from $STORAGE_ROOT/ssl daily
101 | cat > /etc/cron.daily/mailinabox-ssl-cleanup << EOF;
102 | #!/bin/bash
103 | # Mail-in-a-Box
104 | # Cleanup expired SSL certificates
105 | $(pwd)/tools/ssl_cleanup
106 | EOF
107 | chmod +x /etc/cron.daily/mailinabox-ssl-cleanup
108 |
--------------------------------------------------------------------------------
/setup/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # This is the entry point for configuring the system.
3 | #####################################################
4 |
5 | source setup/functions.sh # load our functions
6 |
7 | # Check system setup: Are we running as root on Ubuntu 18.04 on a
8 | # machine with enough memory? Is /tmp mounted with exec.
9 | # If not, this shows an error and exits.
10 | source setup/preflight.sh
11 |
12 | # Ensure Python reads/writes files in UTF-8. If the machine
13 | # triggers some other locale in Python, like ASCII encoding,
14 | # Python may not be able to read/write files. This is also
15 | # in the management daemon startup script and the cron script.
16 |
17 | if ! locale -a | grep en_US.utf8 > /dev/null; then
18 | # Generate locale if not exists
19 | hide_output locale-gen en_US.UTF-8
20 | fi
21 |
22 | export LANGUAGE=en_US.UTF-8
23 | export LC_ALL=en_US.UTF-8
24 | export LANG=en_US.UTF-8
25 | export LC_TYPE=en_US.UTF-8
26 |
27 | # Fix so line drawing characters are shown correctly in Putty on Windows. See #744.
28 | export NCURSES_NO_UTF8_ACS=1
29 |
30 | # Recall the last settings used if we're running this a second time.
31 | if [ -f /etc/mailinabox.conf ]; then
32 | # Run any system migrations before proceeding. Since this is a second run,
33 | # we assume we have Python already installed.
34 | setup/migrate.py --migrate || exit 1
35 |
36 | # Load the old .conf file to get existing configuration options loaded
37 | # into variables with a DEFAULT_ prefix.
38 | cat /etc/mailinabox.conf | sed s/^/DEFAULT_/ > /tmp/mailinabox.prev.conf
39 | source /tmp/mailinabox.prev.conf
40 | rm -f /tmp/mailinabox.prev.conf
41 | else
42 | FIRST_TIME_SETUP=1
43 | fi
44 |
45 | # Put a start script in a global location. We tell the user to run 'mailinabox'
46 | # in the first dialog prompt, so we should do this before that starts.
47 | cat > /usr/local/bin/mailinabox << EOF;
48 | #!/bin/bash
49 | cd $PWD
50 | source setup/start.sh
51 | EOF
52 | chmod +x /usr/local/bin/mailinabox
53 |
54 | # Ask the user for the PRIMARY_HOSTNAME, PUBLIC_IP, and PUBLIC_IPV6,
55 | # if values have not already been set in environment variables. When running
56 | # non-interactively, be sure to set values for all! Also sets STORAGE_USER and
57 | # STORAGE_ROOT.
58 | source setup/questions.sh
59 |
60 | # Run some network checks to make sure setup on this machine makes sense.
61 | # Skip on existing installs since we don't want this to block the ability to
62 | # upgrade, and these checks are also in the control panel status checks.
63 | if [ -z "${DEFAULT_PRIMARY_HOSTNAME:-}" ]; then
64 | if [ -z "${SKIP_NETWORK_CHECKS:-}" ]; then
65 | source setup/network-checks.sh
66 | fi
67 | fi
68 |
69 | # Create the STORAGE_USER and STORAGE_ROOT directory if they don't already exist.
70 | #
71 | # Set the directory and all of its parent directories' permissions to world
72 | # readable since it holds files owned by different processes.
73 | #
74 | # If the STORAGE_ROOT is missing the mailinabox.version file that lists a
75 | # migration (schema) number for the files stored there, assume this is a fresh
76 | # installation to that directory and write the file to contain the current
77 | # migration number for this version of Mail-in-a-Box.
78 | if ! id -u "$STORAGE_USER" >/dev/null 2>&1; then
79 | useradd -m "$STORAGE_USER"
80 | fi
81 | if [ ! -d "$STORAGE_ROOT" ]; then
82 | mkdir -p "$STORAGE_ROOT"
83 | fi
84 | f=$STORAGE_ROOT
85 | while [[ $f != / ]]; do chmod a+rx "$f"; f=$(dirname "$f"); done;
86 | if [ ! -f "$STORAGE_ROOT/mailinabox.version" ]; then
87 | setup/migrate.py --current > "$STORAGE_ROOT/mailinabox.version"
88 | chown "$STORAGE_USER:$STORAGE_USER" "$STORAGE_ROOT/mailinabox.version"
89 | fi
90 |
91 | # Save the global options in /etc/mailinabox.conf so that standalone
92 | # tools know where to look for data. The default MTA_STS_MODE setting
93 | # is blank unless set by an environment variable, but see web.sh for
94 | # how that is interpreted.
95 | cat > /etc/mailinabox.conf << EOF;
96 | STORAGE_USER=$STORAGE_USER
97 | STORAGE_ROOT=$STORAGE_ROOT
98 | PRIMARY_HOSTNAME=$PRIMARY_HOSTNAME
99 | PUBLIC_IP=$PUBLIC_IP
100 | PUBLIC_IPV6=$PUBLIC_IPV6
101 | PRIVATE_IP=$PRIVATE_IP
102 | PRIVATE_IPV6=$PRIVATE_IPV6
103 | MTA_STS_MODE=${DEFAULT_MTA_STS_MODE:-enforce}
104 | EOF
105 |
106 | # Start service configuration.
107 | source setup/system.sh
108 | source setup/ssl.sh
109 | source setup/dns.sh
110 | source setup/mail-postfix.sh
111 | source setup/mail-dovecot.sh
112 | source setup/mail-users.sh
113 | source setup/dkim.sh
114 | source setup/spamassassin.sh
115 | source setup/web.sh
116 | source setup/webmail.sh
117 | source setup/nextcloud.sh
118 | source setup/zpush.sh
119 | source setup/management.sh
120 | source setup/munin.sh
121 |
122 | # Wait for the management daemon to start...
123 | until nc -z -w 4 127.0.0.1 10222
124 | do
125 | echo "Waiting for the Mail-in-a-Box management daemon to start..."
126 | sleep 2
127 | done
128 |
129 | # ...and then have it write the DNS and nginx configuration files and start those
130 | # services.
131 | tools/dns_update
132 | tools/web_update
133 |
134 | # Give fail2ban another restart. The log files may not all have been present when
135 | # fail2ban was first configured, but they should exist now.
136 | restart_service fail2ban
137 |
138 | # If there aren't any mail users yet, create one.
139 | source setup/firstuser.sh
140 |
141 | # Register with Let's Encrypt, including agreeing to the Terms of Service.
142 | # We'd let certbot ask the user interactively, but when this script is
143 | # run in the recommended curl-pipe-to-bash method there is no TTY and
144 | # certbot will fail if it tries to ask.
145 | if [ ! -d "$STORAGE_ROOT/ssl/lets_encrypt/accounts/acme-v02.api.letsencrypt.org/" ]; then
146 | echo
147 | echo "-----------------------------------------------"
148 | echo "Mail-in-a-Box uses Let's Encrypt to provision free SSL/TLS certificates"
149 | echo "to enable HTTPS connections to your box. We're automatically"
150 | echo "agreeing you to their subscriber agreement. See https://letsencrypt.org."
151 | echo
152 | certbot register --register-unsafely-without-email --agree-tos --config-dir "$STORAGE_ROOT/ssl/lets_encrypt"
153 | fi
154 |
155 | # Done.
156 | echo
157 | echo "-----------------------------------------------"
158 | echo
159 | echo "Your Mail-in-a-Box is running."
160 | echo
161 | echo "Please log in to the control panel for further instructions at:"
162 | echo
163 | if management/status_checks.py --check-primary-hostname; then
164 | # Show the nice URL if it appears to be resolving and has a valid certificate.
165 | echo "https://$PRIMARY_HOSTNAME/admin"
166 | echo
167 | echo "If you have a DNS problem put the box's IP address in the URL"
168 | echo "(https://$PUBLIC_IP/admin) but then check the TLS fingerprint:"
169 | openssl x509 -in "$STORAGE_ROOT/ssl/ssl_certificate.pem" -noout -fingerprint -sha256\
170 | | sed "s/SHA256 Fingerprint=//i"
171 | else
172 | echo "https://$PUBLIC_IP/admin"
173 | echo
174 | echo "You will be alerted that the website has an invalid certificate. Check that"
175 | echo "the certificate fingerprint matches:"
176 | echo
177 | openssl x509 -in "$STORAGE_ROOT/ssl/ssl_certificate.pem" -noout -fingerprint -sha256\
178 | | sed "s/SHA256 Fingerprint=//i"
179 | echo
180 | echo "Then you can confirm the security exception and continue."
181 | echo
182 | fi
183 |
--------------------------------------------------------------------------------
/setup/web.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # HTTP: Turn on a web server serving static files
3 | #################################################
4 |
5 | source setup/functions.sh # load our functions
6 | source /etc/mailinabox.conf # load global vars
7 |
8 | # Some Ubuntu images start off with Apache. Remove it since we
9 | # will use nginx. Use autoremove to remove any Apache dependencies.
10 | if [ -f /usr/sbin/apache2 ]; then
11 | echo "Removing apache..."
12 | hide_output apt-get -y purge apache2 apache2-*
13 | hide_output apt-get -y --purge autoremove
14 | fi
15 |
16 | # Install nginx and a PHP FastCGI daemon.
17 | #
18 | # Turn off nginx's default website.
19 |
20 | echo "Installing Nginx (web server)..."
21 |
22 | apt_install nginx php"${PHP_VER}"-cli php"${PHP_VER}"-fpm idn2
23 |
24 | rm -f /etc/nginx/sites-enabled/default
25 |
26 | # Copy in a nginx configuration file for common and best-practices
27 | # SSL settings from @konklone. Replace STORAGE_ROOT so it can find
28 | # the DH params.
29 | rm -f /etc/nginx/nginx-ssl.conf # we used to put it here
30 | sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
31 | conf/nginx-ssl.conf > /etc/nginx/conf.d/ssl.conf
32 |
33 | # Fix some nginx defaults.
34 | #
35 | # The server_names_hash_bucket_size seems to prevent long domain names!
36 | # The default, according to nginx's docs, depends on "the size of the
37 | # processor’s cache line." It could be as low as 32. We fixed it at
38 | # 64 in 2014 to accommodate a long domain name (20 characters?). But
39 | # even at 64, a 58-character domain name won't work (#93), so now
40 | # we're going up to 128.
41 | #
42 | # Drop TLSv1.0, TLSv1.1, following the Mozilla "Intermediate" recommendations
43 | # at https://ssl-config.mozilla.org/#server=nginx&server-version=1.17.0&config=intermediate&openssl-version=1.1.1.
44 | tools/editconf.py /etc/nginx/nginx.conf -s \
45 | server_names_hash_bucket_size="128;" \
46 | ssl_protocols="TLSv1.2 TLSv1.3;"
47 |
48 | # Tell PHP not to expose its version number in the X-Powered-By header.
49 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/php.ini -c ';' \
50 | expose_php=Off
51 |
52 | # Set PHPs default charset to UTF-8, since we use it. See #367.
53 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/php.ini -c ';' \
54 | default_charset="UTF-8"
55 |
56 | # Configure the path environment for php-fpm
57 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
58 | env[PATH]=/usr/local/bin:/usr/bin:/bin \
59 |
60 | # Configure php-fpm based on the amount of memory the machine has
61 | # This is based on the nextcloud manual for performance tuning: https://docs.nextcloud.com/server/17/admin_manual/installation/server_tuning.html
62 | # Some synchronisation issues can occur when many people access the site at once.
63 | # The pm=ondemand setting is used for memory constrained machines < 2GB, this is copied over from PR: 1216
64 | TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}' || /bin/true)
65 | if [ "$TOTAL_PHYSICAL_MEM" -lt 1000000 ]
66 | then
67 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
68 | pm=ondemand \
69 | pm.max_children=8 \
70 | pm.start_servers=2 \
71 | pm.min_spare_servers=1 \
72 | pm.max_spare_servers=3
73 | elif [ "$TOTAL_PHYSICAL_MEM" -lt 2000000 ]
74 | then
75 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
76 | pm=ondemand \
77 | pm.max_children=16 \
78 | pm.start_servers=4 \
79 | pm.min_spare_servers=1 \
80 | pm.max_spare_servers=6
81 | elif [ "$TOTAL_PHYSICAL_MEM" -lt 3000000 ]
82 | then
83 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
84 | pm=dynamic \
85 | pm.max_children=60 \
86 | pm.start_servers=6 \
87 | pm.min_spare_servers=3 \
88 | pm.max_spare_servers=9
89 | else
90 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
91 | pm=dynamic \
92 | pm.max_children=120 \
93 | pm.start_servers=12 \
94 | pm.min_spare_servers=6 \
95 | pm.max_spare_servers=18
96 | fi
97 |
98 | # Other nginx settings will be configured by the management service
99 | # since it depends on what domains we're serving, which we don't know
100 | # until mail accounts have been created.
101 |
102 | # Create the iOS/OS X Mobile Configuration file which is exposed via the
103 | # nginx configuration at /mailinabox-mobileconfig.
104 | mkdir -p /var/lib/mailinabox
105 | chmod a+rx /var/lib/mailinabox
106 | cat conf/ios-profile.xml \
107 | | sed "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" \
108 | | sed "s/UUID1/$(cat /proc/sys/kernel/random/uuid)/" \
109 | | sed "s/UUID2/$(cat /proc/sys/kernel/random/uuid)/" \
110 | | sed "s/UUID3/$(cat /proc/sys/kernel/random/uuid)/" \
111 | | sed "s/UUID4/$(cat /proc/sys/kernel/random/uuid)/" \
112 | > /var/lib/mailinabox/mobileconfig.xml
113 | chmod a+r /var/lib/mailinabox/mobileconfig.xml
114 |
115 | # Create the Mozilla Auto-configuration file which is exposed via the
116 | # nginx configuration at /.well-known/autoconfig/mail/config-v1.1.xml.
117 | # The format of the file is documented at:
118 | # https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat
119 | # and https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration/FileFormat/HowTo.
120 | cat conf/mozilla-autoconfig.xml \
121 | | sed "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" \
122 | > /var/lib/mailinabox/mozilla-autoconfig.xml
123 | chmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml
124 |
125 | # Create a generic mta-sts.txt file which is exposed via the
126 | # nginx configuration at /.well-known/mta-sts.txt
127 | # more documentation is available on:
128 | # https://www.uriports.com/blog/mta-sts-explained/
129 | # default mode is "enforce". In /etc/mailinabox.conf change
130 | # "MTA_STS_MODE=testing" which means "Messages will be delivered
131 | # as though there was no failure but a report will be sent if
132 | # TLS-RPT is configured" if you are not sure you want this yet. Or "none".
133 | PUNY_PRIMARY_HOSTNAME=$(echo "$PRIMARY_HOSTNAME" | idn2)
134 | cat conf/mta-sts.txt \
135 | | sed "s/MODE/${MTA_STS_MODE}/" \
136 | | sed "s/PRIMARY_HOSTNAME/$PUNY_PRIMARY_HOSTNAME/" \
137 | > /var/lib/mailinabox/mta-sts.txt
138 | chmod a+r /var/lib/mailinabox/mta-sts.txt
139 |
140 | # make a default homepage
141 | if [ -d "$STORAGE_ROOT/www/static" ]; then mv "$STORAGE_ROOT/www/static" "$STORAGE_ROOT/www/default"; fi # migration #NODOC
142 | mkdir -p "$STORAGE_ROOT/www/default"
143 | if [ ! -f "$STORAGE_ROOT/www/default/index.html" ]; then
144 | cp conf/www_default.html "$STORAGE_ROOT/www/default/index.html"
145 | fi
146 | chown -R "$STORAGE_USER" "$STORAGE_ROOT/www"
147 |
148 | # Start services.
149 | restart_service nginx
150 | restart_service php"$PHP_VER"-fpm
151 |
152 | # Open ports.
153 | ufw_allow http
154 | ufw_allow https
155 |
--------------------------------------------------------------------------------
/setup/zpush.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # Z-Push: The Microsoft Exchange protocol server
4 | # ----------------------------------------------
5 | #
6 | # Mostly for use on iOS which doesn't support IMAP IDLE.
7 | #
8 | # Although Ubuntu ships Z-Push (as d-push) it has a dependency on Apache
9 | # so we won't install it that way.
10 | #
11 | # Thanks to http://frontender.ch/publikationen/push-mail-server-using-nginx-and-z-push.html.
12 |
13 | source setup/functions.sh # load our functions
14 | source /etc/mailinabox.conf # load global vars
15 |
16 | # Prereqs.
17 |
18 | echo "Installing Z-Push (Exchange/ActiveSync server)..."
19 | apt_install \
20 | php"${PHP_VER}"-soap php"${PHP_VER}"-imap libawl-php php"$PHP_VER"-xml php"${PHP_VER}"-intl
21 |
22 | phpenmod -v "$PHP_VER" imap
23 |
24 | # Copy Z-Push into place.
25 | VERSION=2.7.5
26 | TARGETHASH=f0b0b06e255f3496173ab9d28a4f2d985184720e
27 | needs_update=0 #NODOC
28 | if [ ! -f /usr/local/lib/z-push/version ]; then
29 | needs_update=1 #NODOC
30 | elif [[ $VERSION != $(cat /usr/local/lib/z-push/version) ]]; then
31 | # checks if the version
32 | needs_update=1 #NODOC
33 | fi
34 | if [ $needs_update == 1 ]; then
35 | # Download
36 | wget_verify "https://github.com/Z-Hub/Z-Push/archive/refs/tags/$VERSION.zip" $TARGETHASH /tmp/z-push.zip
37 |
38 | # Extract into place.
39 | rm -rf /usr/local/lib/z-push /tmp/z-push
40 | unzip -q /tmp/z-push.zip -d /tmp/z-push
41 | mv /tmp/z-push/*/src /usr/local/lib/z-push
42 | rm -rf /tmp/z-push.zip /tmp/z-push
43 |
44 | # Create admin and top scripts with PHP_VER
45 | rm -f /usr/sbin/z-push-{admin,top}
46 | echo '#!/bin/bash' > /usr/sbin/z-push-admin
47 | echo php"$PHP_VER" /usr/local/lib/z-push/z-push-admin.php '"$@"' >> /usr/sbin/z-push-admin
48 | chmod 755 /usr/sbin/z-push-admin
49 | echo '#!/bin/bash' > /usr/sbin/z-push-top
50 | echo php"$PHP_VER" /usr/local/lib/z-push/z-push-top.php '"$@"' >> /usr/sbin/z-push-top
51 | chmod 755 /usr/sbin/z-push-top
52 |
53 | echo $VERSION > /usr/local/lib/z-push/version
54 | fi
55 |
56 | # Configure default config.
57 | sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/config.php
58 | sed -i "s/define('BACKEND_PROVIDER', .*/define('BACKEND_PROVIDER', 'BackendCombined');/" /usr/local/lib/z-push/config.php
59 | sed -i "s/define('USE_FULLEMAIL_FOR_LOGIN', .*/define('USE_FULLEMAIL_FOR_LOGIN', true);/" /usr/local/lib/z-push/config.php
60 | sed -i "s/define('LOGLEVEL', .*/define('LOGLEVEL', LOGLEVEL_ERROR);/" /usr/local/lib/z-push/config.php
61 |
62 | # Configure BACKEND
63 | rm -f /usr/local/lib/z-push/backend/combined/config.php
64 | cp conf/zpush/backend_combined.php /usr/local/lib/z-push/backend/combined/config.php
65 |
66 | # Configure IMAP
67 | rm -f /usr/local/lib/z-push/backend/imap/config.php
68 | cp conf/zpush/backend_imap.php /usr/local/lib/z-push/backend/imap/config.php
69 | sed -i "s%STORAGE_ROOT%$STORAGE_ROOT%" /usr/local/lib/z-push/backend/imap/config.php
70 |
71 | # Configure CardDav
72 | rm -f /usr/local/lib/z-push/backend/carddav/config.php
73 | cp conf/zpush/backend_carddav.php /usr/local/lib/z-push/backend/carddav/config.php
74 |
75 | # Configure CalDav
76 | rm -f /usr/local/lib/z-push/backend/caldav/config.php
77 | cp conf/zpush/backend_caldav.php /usr/local/lib/z-push/backend/caldav/config.php
78 |
79 | # Configure Autodiscover
80 | rm -f /usr/local/lib/z-push/autodiscover/config.php
81 | cp conf/zpush/autodiscover_config.php /usr/local/lib/z-push/autodiscover/config.php
82 | sed -i "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" /usr/local/lib/z-push/autodiscover/config.php
83 | sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/autodiscover/config.php
84 |
85 | # Some directories it will use.
86 |
87 | mkdir -p /var/log/z-push
88 | mkdir -p /var/lib/z-push
89 | chmod 750 /var/log/z-push
90 | chmod 750 /var/lib/z-push
91 | chown www-data:www-data /var/log/z-push
92 | chown www-data:www-data /var/lib/z-push
93 |
94 | # Add log rotation
95 |
96 | cat > /etc/logrotate.d/z-push < within_seconds:
201 | raise Exception("Test failed to make %s requests in %d seconds." % (count, within_seconds))
202 |
203 | # Wait a moment for the block to be put into place.
204 | time.sleep(4)
205 |
206 | # The next call should fail.
207 | print("*", end=" ", flush=True)
208 | try:
209 | testfunc(*args)
210 | except IsBlocked:
211 | # Success -- this one is supposed to be refused.
212 | print("blocked [OK]")
213 | return True # OK
214 |
215 | print("not blocked!")
216 | return False
217 |
218 | ######################################################################
219 |
220 | if __name__ == "__main__":
221 | # run tests
222 |
223 | # SMTP bans at 10 even though we say 20 in the config because we get
224 | # doubled-up warnings in the logs, we'll let that be for now
225 | run_test(smtp_test, [], 10, 30, 8)
226 |
227 | # IMAP
228 | run_test(imap_test, [], 20, 30, 4)
229 |
230 | # POP
231 | run_test(pop_test, [], 20, 30, 4)
232 |
233 | # Managesieve
234 | run_test(managesieve_test, [], 20, 30, 4)
235 |
236 | # Mail-in-a-Box control panel
237 | run_test(http_test, ["/admin/login", 200], 20, 30, 1)
238 |
239 | # Munin via the Mail-in-a-Box control panel
240 | run_test(http_test, ["/admin/munin/", 401], 20, 30, 1)
241 |
242 | # ownCloud
243 | run_test(http_test, ["/cloud/remote.php/webdav", 401, None, None, [owncloud_user, "aa"]], 20, 120, 1)
244 |
245 | # restart fail2ban so that this client machine is no longer blocked
246 | restart_fail2ban_service(final=True)
247 |
--------------------------------------------------------------------------------
/tests/pip-requirements.txt:
--------------------------------------------------------------------------------
1 | dnspython3
2 |
--------------------------------------------------------------------------------
/tests/test_dns.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | #
3 | # Tests the DNS configuration of a Mail-in-a-Box.
4 | #
5 | # tests/dns.py ipaddr hostname
6 | #
7 | # where ipaddr is the IP address of your Mail-in-a-Box
8 | # and hostname is the domain name to check the DNS for.
9 |
10 | import sys, re
11 | import dns.reversename, dns.resolver
12 |
13 | if len(sys.argv) < 3:
14 | print("Usage: tests/dns.py ipaddress hostname [primary hostname]")
15 | sys.exit(1)
16 |
17 | ipaddr, hostname = sys.argv[1:3]
18 | primary_hostname = hostname
19 | if len(sys.argv) == 4:
20 | primary_hostname = sys.argv[3]
21 |
22 | def test(server, description):
23 | tests = [
24 | (hostname, "A", ipaddr),
25 | #(hostname, "NS", "ns1.%s.;ns2.%s." % (primary_hostname, primary_hostname)),
26 | ("ns1." + primary_hostname, "A", ipaddr),
27 | ("ns2." + primary_hostname, "A", ipaddr),
28 | ("www." + hostname, "A", ipaddr),
29 | (hostname, "MX", "10 " + primary_hostname + "."),
30 | (hostname, "TXT", '"v=spf1 mx -all"'),
31 | ("mail._domainkey." + hostname, "TXT", '"v=DKIM1; k=rsa; s=email; " "p=__KEY__"'),
32 | #("_adsp._domainkey." + hostname, "TXT", "\"dkim=all\""),
33 | ("_dmarc." + hostname, "TXT", '"v=DMARC1; p=quarantine;"'),
34 | ]
35 | return test2(tests, server, description)
36 |
37 | def test_ptr(server, description):
38 | ipaddr_rev = dns.reversename.from_address(ipaddr)
39 | tests = [
40 | (ipaddr_rev, "PTR", hostname+'.'),
41 | ]
42 | return test2(tests, server, description)
43 |
44 | def test2(tests, server, description):
45 | first = True
46 | resolver = dns.resolver.get_default_resolver()
47 | resolver.nameservers = [server]
48 | for qname, rtype, expected_answer in tests:
49 | # do the query and format the result as a string
50 | try:
51 | response = dns.resolver.resolve(qname, rtype)
52 | except dns.resolver.NoNameservers:
53 | # host did not have an answer for this query
54 | print("Could not connect to %s for DNS query." % server)
55 | sys.exit(1)
56 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
57 | # host did not have an answer for this query; not sure what the
58 | # difference is between the two exceptions
59 | response = ["[no value]"]
60 | response = ";".join(str(r) for r in response)
61 | response = re.sub(r"(\"p=).*(\")", r"\1__KEY__\2", response) # normalize DKIM key
62 | response = response.replace('"" ', "") # normalize TXT records (DNSSEC signing inserts empty text string components)
63 |
64 | # is it right?
65 | if response == expected_answer:
66 | #print(server, ":", qname, rtype, "?", response)
67 | continue
68 |
69 | # show problem
70 | if first:
71 | print("Incorrect DNS Response from", description)
72 | print()
73 | print("QUERY ", "RESPONSE ", "CORRECT VALUE", sep='\t')
74 | first = False
75 |
76 | print((qname + "/" + rtype).ljust(20), response.ljust(12), expected_answer, sep='\t')
77 | return first # success
78 |
79 | # Test the response from the machine itself.
80 | if not test(ipaddr, "Mail-in-a-Box"):
81 | print ()
82 | print ("Please run the Mail-in-a-Box setup script on %s again." % hostname)
83 | sys.exit(1)
84 | else:
85 | print ("The Mail-in-a-Box provided correct DNS answers.")
86 | print ()
87 |
88 | # If those settings are OK, also test Google's Public DNS
89 | # to see if the machine is hooked up to recursive DNS properly.
90 | if not test("8.8.8.8", "Google Public DNS"):
91 | print ()
92 | print ("Check that the nameserver settings for %s 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." % hostname)
93 | sys.exit(1)
94 | else:
95 | print ("Your domain registrar or DNS host appears to be configured correctly as well. Public DNS provides the same answers.")
96 | print ()
97 |
98 | # And if that's OK, also check reverse DNS (the PTR record).
99 | if not test_ptr("8.8.8.8", "Google Public DNS (Reverse DNS)"):
100 | print ()
101 | print (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}.")
102 | sys.exit(1)
103 | else:
104 | print ("And the reverse DNS for the domain is correct.")
105 | print ()
106 | print ("DNS is OK.")
107 |
--------------------------------------------------------------------------------
/tests/test_mail.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # Tests sending and receiving mail by sending a test message to yourself.
3 |
4 | import sys, imaplib, smtplib, uuid, time
5 | import socket, dns.reversename, dns.resolver
6 |
7 | if len(sys.argv) < 3:
8 | print("Usage: tests/mail.py hostname emailaddress password")
9 | sys.exit(1)
10 |
11 | host, emailaddress, pw = sys.argv[1:4]
12 |
13 | # Attempt to login with IMAP. Our setup uses email addresses
14 | # as IMAP/SMTP usernames.
15 | try:
16 | M = imaplib.IMAP4_SSL(host)
17 | M.login(emailaddress, pw)
18 | except OSError as e:
19 | print("Connection error:", e)
20 | sys.exit(1)
21 | except imaplib.IMAP4.error as e:
22 | # any sort of login error
23 | e = ", ".join(a.decode("utf8") for a in e.args)
24 | print("IMAP error:", e)
25 | sys.exit(1)
26 |
27 | M.select()
28 | print("IMAP login is OK.")
29 |
30 | # Attempt to send a mail to ourself.
31 | mailsubject = "Mail-in-a-Box Automated Test Message " + uuid.uuid4().hex
32 | emailto = emailaddress
33 | msg = f"""From: {emailaddress}
34 | To: {emailto}
35 | Subject: {mailsubject}
36 |
37 | This is a test message. It should be automatically deleted by the test script."""
38 |
39 | # Connect to the server on the SMTP submission TLS port.
40 | server = smtplib.SMTP_SSL(host)
41 | #server.set_debuglevel(1)
42 |
43 | # Verify that the EHLO name matches the server's reverse DNS.
44 | ipaddr = socket.gethostbyname(host) # IPv4 only!
45 | reverse_ip = dns.reversename.from_address(ipaddr) # e.g. "1.0.0.127.in-addr.arpa."
46 | try:
47 | reverse_dns = dns.resolver.resolve(reverse_ip, 'PTR')[0].target.to_text(omit_final_dot=True) # => hostname
48 | except dns.resolver.NXDOMAIN:
49 | print("Reverse DNS lookup failed for %s. SMTP EHLO name check skipped." % ipaddr)
50 | reverse_dns = None
51 | if reverse_dns is not None:
52 | server.ehlo_or_helo_if_needed() # must send EHLO before getting the server's EHLO name
53 | helo_name = server.ehlo_resp.decode("utf8").split("\n")[0] # first line is the EHLO name
54 | if helo_name != reverse_dns:
55 | print("The server's EHLO name does not match its reverse hostname. Check DNS settings.")
56 | else:
57 | print("SMTP EHLO name (%s) is OK." % helo_name)
58 |
59 | # Login and send a test email.
60 | server.login(emailaddress, pw)
61 | server.sendmail(emailaddress, [emailto], msg)
62 | server.quit()
63 | print("SMTP submission is OK.")
64 |
65 | while True:
66 | # Wait so the message can propagate to the inbox.
67 | time.sleep(10)
68 |
69 | # Read the subject lines of all of the emails in the inbox
70 | # to find our test message, and then delete it.
71 | found = False
72 | typ, data = M.search(None, 'ALL')
73 | for num in data[0].split():
74 | typ, data = M.fetch(num, '(BODY[HEADER.FIELDS (SUBJECT)])')
75 | imapsubjectline = data[0][1].strip().decode("utf8")
76 | if imapsubjectline == "Subject: " + mailsubject:
77 | # We found our test message.
78 | found = True
79 |
80 | # To test DKIM, download the whole mssage body. Unfortunately,
81 | # pydkim doesn't actually work.
82 | # You must 'sudo apt-get install python3-dkim python3-dnspython' first.
83 | #typ, msgdata = M.fetch(num, '(RFC822)')
84 | #msg = msgdata[0][1]
85 | #if dkim.verify(msg):
86 | # print("DKIM signature on the test message is OK (verified).")
87 | #else:
88 | # print("DKIM signature on the test message failed verification.")
89 |
90 | # Delete the test message.
91 | M.store(num, '+FLAGS', '\\Deleted')
92 | M.expunge()
93 |
94 | break
95 |
96 | if found:
97 | break
98 |
99 | print("Test message not present in the inbox yet...")
100 |
101 | M.close()
102 | M.logout()
103 |
104 | print("Test message sent & received successfully.")
105 |
--------------------------------------------------------------------------------
/tests/test_smtp_server.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import smtplib, sys
3 |
4 | if len(sys.argv) < 3:
5 | print("Usage: tests/smtp_server.py host email.to email.from")
6 | sys.exit(1)
7 |
8 | host, toaddr, fromaddr = sys.argv[1:4]
9 | msg = f"""From: {fromaddr}
10 | To: {toaddr}
11 | Subject: SMTP server test
12 |
13 | This is a test message."""
14 |
15 | server = smtplib.SMTP(host, 25)
16 | server.set_debuglevel(1)
17 | server.sendmail(fromaddr, [toaddr], msg)
18 | server.quit()
19 |
20 |
--------------------------------------------------------------------------------
/tests/tls.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 |
3 | # Runs SSLyze on the TLS endpoints of a box and outputs
4 | # the results so we can inspect the settings and compare
5 | # against a known good version in tls_results.txt.
6 | #
7 | # Make sure you have SSLyze available:
8 | # wget https://github.com/nabla-c0d3/sslyze/releases/download/release-0.11/sslyze-0_11-linux64.zip
9 | # unzip sslyze-0_11-linux64.zip
10 | #
11 | # Then run:
12 | #
13 | # python3 tls.py yourservername
14 | #
15 | # If you are on a residential network that blocks outbound
16 | # port 25 connections, then you can proxy the connections
17 | # through some other host you can ssh into (maybe the box
18 | # itself?):
19 | #
20 | # python3 tls.py --proxy user@ssh_host yourservername
21 | #
22 | # (This will launch "ssh -N -L10023:yourservername:testport user@ssh_host"
23 | # to create a tunnel.)
24 |
25 | import sys, subprocess, re, time, json, csv, io, urllib.request
26 |
27 | ######################################################################
28 |
29 | # PARSE COMMAND LINE
30 |
31 | proxy = None
32 | args = list(sys.argv[1:])
33 | while len(args) > 0:
34 | if args[0] == "--proxy":
35 | args.pop(0)
36 | proxy = args.pop(0)
37 | break
38 |
39 | if len(args) == 0:
40 | print("Usage: python3 tls.py [--proxy ssh_host] hostname")
41 | sys.exit(0)
42 |
43 | host = args[0]
44 |
45 | ######################################################################
46 |
47 | SSLYZE = "sslyze-0_11-linux64/sslyze/sslyze.py"
48 |
49 | common_opts = ["--sslv2", "--sslv3", "--tlsv1", "--tlsv1_1", "--tlsv1_2", "--reneg", "--resum",
50 | "--hide_rejected_ciphers", "--compression", "--heartbleed"]
51 |
52 | # Recommendations from Mozilla as of May 20, 2015 at
53 | # https://wiki.mozilla.org/Security/Server_Side_TLS.
54 | #
55 | # The 'modern' ciphers support Firefox 27, Chrome 22, IE 11,
56 | # Opera 14, Safari 7, Android 4.4, Java 8. Assumes TLSv1.1,
57 | # TLSv1.2 only, though we may also be allowing TLSv3.
58 | #
59 | # The 'intermediate' ciphers support Firefox 1, Chrome 1, IE 7,
60 | # Opera 5, Safari 1, Windows XP IE8, Android 2.3, Java 7.
61 | # Assumes TLSv1, TLSv1.1, TLSv1.2.
62 | #
63 | # The 'old' ciphers bring compatibility back to Win XP IE 6.
64 | MOZILLA_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"
65 | MOZILLA_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"
66 | MOZILLA_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"
67 |
68 | ######################################################################
69 |
70 | def sslyze(opts, port, ok_ciphers):
71 | # Print header.
72 | header = ("PORT %d" % port)
73 | print(header)
74 | print("-" * (len(header)))
75 |
76 | # What ciphers should we expect?
77 | ok_ciphers = subprocess.check_output(["openssl", "ciphers", ok_ciphers]).decode("utf8").strip().split(":")
78 |
79 | # Form the SSLyze connection string.
80 | connection_string = host + ":" + str(port)
81 |
82 | # Proxy via SSH.
83 | proxy_proc = None
84 | if proxy:
85 | connection_string = "localhost:10023"
86 | proxy_proc = subprocess.Popen(["ssh", "-N", "-L10023:%s:%d" % (host, port), proxy])
87 | time.sleep(3)
88 |
89 | try:
90 | # Execute SSLyze.
91 | out = subprocess.check_output([SSLYZE, *common_opts, *opts, connection_string])
92 | out = out.decode("utf8")
93 |
94 | # Trim output to make better for storing in git.
95 | if "SCAN RESULTS FOR" not in out:
96 | # Failed. Just output the error.
97 | out = re.sub("[\\w\\W]*CHECKING HOST\\(S\\) AVAILABILITY\n\\s*-+\n", "", out) # chop off header that shows the host we queried
98 | out = re.sub("[\\w\\W]*SCAN RESULTS FOR.*\n\\s*-+\n", "", out) # chop off header that shows the host we queried
99 | out = re.sub("SCAN COMPLETED IN .*", "", out)
100 | out = out.rstrip(" \n-") + "\n"
101 |
102 | # Print.
103 | print(out)
104 |
105 | # Pull out the accepted ciphers list for each SSL/TLS protocol
106 | # version outputted.
107 | accepted_ciphers = set()
108 | for ciphers in re.findall(" Accepted:([\\w\\W]*?)\n *\n", out):
109 | accepted_ciphers |= set(re.findall("\n\\s*(\\S*)", ciphers))
110 |
111 | # Compare to what Mozilla recommends, for a given modernness-level.
112 | print(" Should Not Offer: " + (", ".join(sorted(accepted_ciphers-set(ok_ciphers))) or "(none -- good)"))
113 | print(" Could Also Offer: " + (", ".join(sorted(set(ok_ciphers)-accepted_ciphers)) or "(none -- good)"))
114 |
115 | # What clients does that mean we support on this protocol?
116 | supported_clients = { }
117 | for cipher in accepted_ciphers:
118 | if cipher in cipher_clients:
119 | for client in cipher_clients[cipher]:
120 | supported_clients[client] = supported_clients.get(client, 0) + 1
121 | print(" Supported Clients: " + (", ".join(sorted(supported_clients.keys(), key = lambda client : -supported_clients[client]))))
122 |
123 | # Blank line.
124 | print()
125 |
126 | finally:
127 | if proxy_proc:
128 | proxy_proc.terminate()
129 | try:
130 | proxy_proc.wait(5)
131 | except subprocess.TimeoutExpired:
132 | proxy_proc.kill()
133 |
134 | # Get a list of OpenSSL cipher names.
135 | cipher_names = { }
136 | for 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"))):
137 | # not sure why there are some multi-line values, use first line:
138 | cipher["OpenSSL"] = cipher["OpenSSL"].split("\n")[0]
139 | cipher_names[cipher["IANA"]] = cipher["OpenSSL"]
140 |
141 | # Get a list of what clients support what ciphers, using OpenSSL cipher names.
142 | client_compatibility = json.loads(urllib.request.urlopen("https://raw.githubusercontent.com/mail-in-a-box/user-agent-tls-capabilities/master/clients.json").read().decode("utf8"))
143 | cipher_clients = { }
144 | for client in client_compatibility:
145 | if len(set(client['protocols']) & {"TLS 1.0", "TLS 1.1", "TLS 1.2"}) == 0: continue # does not support TLS
146 | for cipher in client['ciphers']:
147 | cipher_clients.setdefault(cipher_names.get(cipher), set()).add("/".join(x for x in [client['client']['name'], client['client']['version'], client['client']['platform']] if x))
148 |
149 | # Run SSLyze on various ports.
150 |
151 | # SMTP
152 | sslyze(["--starttls=smtp"], 25, MOZILLA_CIPHERS_OLD)
153 |
154 | # SMTP Submission
155 | sslyze(["--starttls=smtp"], 587, MOZILLA_CIPHERS_MODERN)
156 |
157 | # HTTPS
158 | sslyze(["--http_get", "--chrome_sha1", "--hsts"], 443, MOZILLA_CIPHERS_INTERMEDIATE)
159 |
160 | # IMAP
161 | sslyze([], 993, MOZILLA_CIPHERS_MODERN)
162 |
163 | # POP3
164 | sslyze([], 995, MOZILLA_CIPHERS_MODERN)
165 |
--------------------------------------------------------------------------------
/tools/archive_conf_files.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Use this script to make an archive of the contents of all
3 | # of the configuration files we edit with editconf.py.
4 | for fn in $(grep -hr editconf.py setup | sed "s/tools\/editconf.py //" | sed "s/ .*//" | sort | uniq); do
5 | echo ======================================================================
6 | echo "$fn"
7 | echo ======================================================================
8 | cat "$fn"
9 | done
10 |
11 |
--------------------------------------------------------------------------------
/tools/dns_update:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | POSTDATA=dummy
3 | if [ "$1" == "--force" ]; then
4 | POSTDATA=force=1
5 | fi
6 | curl -s -d $POSTDATA --user "$(] [-t] NAME=VAL [NAME=VAL ...]")
34 | sys.exit(1)
35 |
36 | # parse command line arguments
37 | filename = sys.argv[1]
38 | settings = sys.argv[2:]
39 |
40 | delimiter = "="
41 | delimiter_re = r"\s*=\s*"
42 | erase_setting = False
43 | comment_char = "#"
44 | folded_lines = False
45 | testing = False
46 | while settings[0][0] == "-" and settings[0] != "--":
47 | opt = settings.pop(0)
48 | if opt == "-s":
49 | # Space is the delimiter
50 | delimiter = " "
51 | delimiter_re = r"\s+"
52 | elif opt == "-e":
53 | # Erase settings that have empty values.
54 | erase_setting = True
55 | elif opt == "-w":
56 | # Line folding is possible in this file.
57 | folded_lines = True
58 | elif opt == "-c":
59 | # Specifies a different comment character.
60 | comment_char = settings.pop(0)
61 | elif opt == "-t":
62 | testing = True
63 | else:
64 | print("Invalid option.")
65 | sys.exit(1)
66 |
67 | # sanity check command line
68 | for setting in settings:
69 | try:
70 | name, value = setting.split("=", 1)
71 | except:
72 | import subprocess
73 | print("Invalid command line: ", subprocess.list2cmdline(sys.argv))
74 |
75 | # create the new config file in memory
76 |
77 | found = set()
78 | buf = ""
79 | with open(filename, encoding="utf-8") as f:
80 | input_lines = list(f)
81 |
82 | while len(input_lines) > 0:
83 | line = input_lines.pop(0)
84 |
85 | # If this configuration file uses folded lines, append any folded lines
86 | # into our input buffer.
87 | if folded_lines and line[0] not in {comment_char, " ", ""}:
88 | while len(input_lines) > 0 and input_lines[0][0] in " \t":
89 | line += input_lines.pop(0)
90 |
91 | # See if this line is for any settings passed on the command line.
92 | for i in range(len(settings)):
93 | # Check if this line contain this setting from the command-line arguments.
94 | name, val = settings[i].split("=", 1)
95 | m = re.match(
96 | r"(\s*)"
97 | "(" + re.escape(comment_char) + r"\s*)?"
98 | + re.escape(name) + delimiter_re + r"(.*?)\s*$",
99 | line, re.S)
100 | if not m: continue
101 | indent, is_comment, existing_val = m.groups()
102 |
103 | # If this is already the setting, keep it in the file, except:
104 | # * If we've already seen it before, then remove this duplicate line.
105 | # * If val is empty and erase_setting is on, then comment it out.
106 | if is_comment is None and existing_val == val and not (not val and erase_setting):
107 | # It may be that we've already inserted this setting higher
108 | # in the file so check for that first.
109 | if i in found: break
110 | buf += line
111 | found.add(i)
112 | break
113 |
114 | # comment-out the existing line (also comment any folded lines)
115 | if is_comment is None:
116 | buf += comment_char + line.rstrip().replace("\n", "\n" + comment_char) + "\n"
117 | else:
118 | # the line is already commented, pass it through
119 | buf += line
120 |
121 | # if this option already is set don't add the setting again,
122 | # or if we're clearing the setting with -e, don't add it
123 | if (i in found) or (not val and erase_setting):
124 | break
125 |
126 | # add the new setting
127 | buf += indent + name + delimiter + val + "\n"
128 |
129 | # note that we've applied this option
130 | found.add(i)
131 |
132 | break
133 | else:
134 | # If did not match any setting names, pass this line through.
135 | buf += line
136 |
137 | # Put any settings we didn't see at the end of the file,
138 | # except settings being cleared.
139 | for i in range(len(settings)):
140 | if i not in found:
141 | name, val = settings[i].split("=", 1)
142 | if not (not val and erase_setting):
143 | buf += name + delimiter + val + "\n"
144 |
145 | if not testing:
146 | # Write out the new file.
147 | with open(filename, "w", encoding="utf-8") as f:
148 | f.write(buf)
149 | else:
150 | # Just print the new file to stdout.
151 | print(buf)
152 |
--------------------------------------------------------------------------------
/tools/mail.py:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # This script has moved.
3 | management/cli.py "$@"
4 |
--------------------------------------------------------------------------------
/tools/owncloud-restore.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # This script will restore the backup made during an installation
4 | source /etc/mailinabox.conf # load global vars
5 |
6 | if [ -z "$1" ]; then
7 | echo "Usage: owncloud-restore.sh "
8 | echo
9 | echo "WARNING: This will restore the database to the point of the installation!"
10 | echo " This means that you will lose all changes made by users after that point"
11 | echo
12 | echo
13 | echo "Backups are stored here: $STORAGE_ROOT/owncloud-backup/"
14 | echo
15 | echo "Available backups:"
16 | echo
17 | find "$STORAGE_ROOT/owncloud-backup/"* -maxdepth 0 -type d
18 | echo
19 | echo "Supply the directory that was created during the last installation as the only commandline argument"
20 | exit
21 | fi
22 |
23 | if [ ! -f "$1/config.php" ]; then
24 | echo "This isn't a valid backup location"
25 | exit 1
26 | fi
27 |
28 | echo "Restoring backup from $1"
29 | service php8.0-fpm stop
30 |
31 | # remove the current ownCloud/Nextcloud installation
32 | rm -rf /usr/local/lib/owncloud/
33 | # restore the current ownCloud/Nextcloud application
34 | cp -r "$1/owncloud-install" /usr/local/lib/owncloud
35 |
36 | # restore access rights
37 | chmod 750 /usr/local/lib/owncloud/{apps,config}
38 |
39 | cp "$1/owncloud.db" "$STORAGE_ROOT/owncloud/"
40 | cp "$1/config.php" "$STORAGE_ROOT/owncloud/"
41 |
42 | ln -sf "$STORAGE_ROOT/owncloud/config.php" /usr/local/lib/owncloud/config/config.php
43 | chown -f -R www-data:www-data "$STORAGE_ROOT/owncloud" /usr/local/lib/owncloud
44 | chown www-data:www-data "$STORAGE_ROOT/owncloud/config.php"
45 |
46 | sudo -u www-data "php$PHP_VER" /usr/local/lib/owncloud/occ maintenance:mode --off
47 |
48 | service php8.0-fpm start
49 | echo "Done"
50 |
--------------------------------------------------------------------------------
/tools/owncloud-unlockadmin.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # This script will give you administrative access to the Nextcloud
4 | # instance running here.
5 | #
6 | # Run this at your own risk. This is for testing & experimentation
7 | # purpopses only. After this point you are on your own.
8 |
9 | source /etc/mailinabox.conf # load global vars
10 |
11 | ADMIN=$(./mail.py user admins | head -n 1)
12 | test -z "$1" || ADMIN=$1
13 |
14 | echo "I am going to unlock admin features for $ADMIN."
15 | echo "You can provide another user to unlock as the first argument of this script."
16 | echo
17 | echo "WARNING: you could break mail-in-a-box when fiddling around with Nextcloud's admin interface"
18 | echo "If in doubt, press CTRL-C to cancel."
19 | echo
20 | echo "Press enter to continue."
21 | read
22 |
23 | sudo -u www-data "php$PHP_VER" /usr/local/lib/owncloud/occ group:adduser admin "$ADMIN" && echo "Done."
24 |
--------------------------------------------------------------------------------
/tools/parse-nginx-log-bootstrap-accesses.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | #
3 | # This is a tool Josh uses on his box serving mailinabox.email to parse the nginx
4 | # access log to see how many people are installing Mail-in-a-Box each day, by
5 | # looking at accesses to the bootstrap.sh script (which is currently at the URL
6 | # .../setup.sh).
7 |
8 | import re, glob, gzip, os.path, json
9 | import dateutil.parser
10 |
11 | outfn = "/home/user-data/www/mailinabox.email/install-stats.json"
12 |
13 | # Make a unique list of (date, ip address) pairs so we don't double-count
14 | # accesses that are for the same install.
15 | accesses = set()
16 |
17 | # Scan the current and rotated access logs.
18 | for fn in glob.glob("/var/log/nginx/access.log*"):
19 | # Gunzip if necessary.
20 | # Loop through the lines in the access log.
21 | with (gzip.open if fn.endswith(".gz") else open)(fn, "rb") as f:
22 | for line in f:
23 | # Find lines that are GETs on the bootstrap script by either curl or wget.
24 | # (Note that we purposely skip ...?ping=1 requests which is the admin panel querying us for updates.)
25 | # (Also, the URL changed in January 2016, but we'll accept both.)
26 | m = re.match(rb"(?P\S+) - - \[(?P.*?)\] \"GET /(bootstrap.sh|setup.sh) HTTP/.*\" 200 \d+ .* \"(?:curl|wget)", line, re.I)
27 | if m:
28 | date, time = m.group("date").decode("ascii").split(":", 1)
29 | date = dateutil.parser.parse(date).date().isoformat()
30 | ip = m.group("ip").decode("ascii")
31 | accesses.add( (date, ip) )
32 |
33 | # Aggregate by date.
34 | by_date = { }
35 | for date, ip in accesses:
36 | by_date[date] = by_date.get(date, 0) + 1
37 |
38 | # Since logs are rotated, store the statistics permanently in a JSON file.
39 | # Load in the stats from an existing file.
40 | if os.path.exists(outfn):
41 | with open(outfn, encoding="utf-8") as f:
42 | existing_data = json.load(f)
43 | for date, count in existing_data:
44 | if date not in by_date:
45 | by_date[date] = count
46 |
47 | # Turn into a list rather than a dict structure to make it ordered.
48 | by_date = sorted(by_date.items())
49 |
50 | # Pop the last one because today's stats are incomplete.
51 | by_date.pop(-1)
52 |
53 | # Write out.
54 | with open(outfn, "w", encoding="utf-8") as f:
55 | json.dump(by_date, f, sort_keys=True, indent=True)
56 |
--------------------------------------------------------------------------------
/tools/ssl_cleanup:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Cleanup SSL certificates which expired more than 7 days ago from $STORAGE_ROOT/ssl and move them to $STORAGE_ROOT/ssl.expired
3 |
4 | source /etc/mailinabox.conf
5 | shopt -s extglob nullglob
6 |
7 | retain_after="$(date --date="7 days ago" +%Y%m%d)"
8 |
9 | mkdir -p $STORAGE_ROOT/ssl.expired
10 | for file in $STORAGE_ROOT/ssl/*-+([0-9])-+([0-9a-f]).pem; do
11 | pem="$(basename "$file")"
12 | not_valid_after="$(cut -d- -f1 <<< "${pem: -21}")"
13 |
14 | if [ "$not_valid_after" -lt "$retain_after" ]; then
15 | mv "$file" "$STORAGE_ROOT/ssl.expired/${pem}"
16 | fi
17 | done
18 |
--------------------------------------------------------------------------------
/tools/web_update:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | curl -s -d POSTDATA --user "$(