Skip to content

Commit

Permalink
Support Ubuntu 22.04 Jammy Jellyfish (#2083)
Browse files Browse the repository at this point in the history
  • Loading branch information
JoshData authored Oct 12, 2022
2 parents d7244ed + 4d5ff02 commit ddf8e85
Show file tree
Hide file tree
Showing 32 changed files with 332 additions and 296 deletions.
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
CHANGELOG
=========

Version 60 (October 11, 2022)
-----------------------------

This is the first release for Ubuntu 22.04.

**Before upgrading**, you must **first upgrade your existing Ubuntu 18.04 box to Mail-in-a-Box v0.51 or later**, if you haven't already done so. That may not be possible after Ubuntu 18.04 reaches its end of life in April 2023, so please complete the upgrade well before then. (If you are not using Nextcloud's contacts or calendar, you can migrate to the latest version of Mail-in-a-Box from any previous version.)

For complete upgrade instructions, see:

https://discourse.mailinabox.email/t/version-60-for-ubuntu-22-04-is-about-to-be-released/9558

No major features of Mail-in-a-Box have changed in this release, although some minor fixes were made.

With the newer version of Ubuntu the following software packages we use are updated:

* dovecot is upgraded to 2.3.16, postfix to 3.6.4, opendmark to 1.4 (which adds ARC-Authentication-Results headers), and spampd to 2.53 (alleviating a mail delivery rate limiting bug).
* Nextcloud is upgraded to 23.0.4.
* Roundcube is upgraded to 1.6.0.
* certbot is upgraded to 1.21 (via the Ubuntu repository instead of a PPA).
* fail2ban is upgraded to 0.11.2.
* nginx is upgraded to 1.18.
* PHP is upgraded from 7.2 to 8.0.

Also:

* Roundcube's login session cookie was tightened. Existing sessions may require a manual logout.
* Moved Postgrey's database under $STORAGE_ROOT.

Version 57a (June 19, 2022)
---------------------------

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Additionally, this project has a [Code of Conduct](CODE_OF_CONDUCT.md), which su
In The Box
----------

Mail-in-a-Box turns a fresh Ubuntu 18.04 LTS 64-bit machine into a working mail server by installing and configuring various components.
Mail-in-a-Box turns a fresh Ubuntu 22.04 LTS 64-bit machine into a working mail server by installing and configuring various components.

It is a one-click email appliance. There are no user-configurable setup options. It "just works."

Expand Down Expand Up @@ -54,13 +54,13 @@ Installation

See the [setup guide](https://mailinabox.email/guide.html) for detailed, user-friendly instructions.

For experts, start with a completely fresh (really, I mean it) Ubuntu 18.04 LTS 64-bit machine. On the machine...
For experts, start with a completely fresh (really, I mean it) Ubuntu 22.04 LTS 64-bit machine. On the machine...

Clone this repository and checkout the tag corresponding to the most recent release:

$ git clone https://github.com/mail-in-a-box/mailinabox
$ cd mailinabox
$ git checkout v57a
$ git checkout v60

Begin the installation.

Expand Down
2 changes: 1 addition & 1 deletion Vagrantfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# vi: set ft=ruby :

Vagrant.configure("2") do |config|
config.vm.box = "ubuntu/bionic64"
config.vm.box = "ubuntu/jammy64"

# Network config: Since it's a mail server, the machine must be connected
# to the public web. However, we currently don't want to expose SSH since
Expand Down
1 change: 1 addition & 0 deletions conf/mailinabox.service
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ After=multi-user.target

[Service]
Type=idle
IgnoreSIGPIPE=False
ExecStart=/usr/local/lib/mailinabox/start

[Install]
Expand Down
2 changes: 1 addition & 1 deletion conf/nginx-top.conf
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
## your own --- please do not ask for help from us.

upstream php-fpm {
server unix:/var/run/php/php7.2-fpm.sock;
server unix:/var/run/php/php8.0-fpm.sock;
}

16 changes: 2 additions & 14 deletions management/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,8 @@ def __init__(self):
def init_system_api_key(self):
"""Write an API key to a local file so local processes can use the API"""

def create_file_with_mode(path, mode):
# Based on answer by A-B-B: http://stackoverflow.com/a/15015748
old_umask = os.umask(0)
try:
return os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, mode), 'w')
finally:
os.umask(old_umask)

self.key = secrets.token_hex(32)

os.makedirs(os.path.dirname(self.key_path), exist_ok=True)

with create_file_with_mode(self.key_path, 0o640) as key_file:
key_file.write(self.key + '\n')
with open(self.key_path, 'r') as file:
self.key = file.read()

def authenticate(self, request, env, login_only=False, logout=False):
"""Test if the HTTP Authorization header's username matches the system key, a session key,
Expand Down
57 changes: 19 additions & 38 deletions management/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import rtyaml
from exclusiveprocess import Lock

from utils import load_environment, shell, wait_for_service, fix_boto
from utils import load_environment, shell, wait_for_service

def backup_status(env):
# If backups are dissbled, return no status.
Expand Down Expand Up @@ -197,12 +197,7 @@ def get_duplicity_target_url(config):
from urllib.parse import urlsplit, urlunsplit
target = list(urlsplit(target))

# Duplicity now defaults to boto3 as the backend for S3, but we have
# legacy boto installed (boto3 doesn't support Ubuntu 18.04) so
# we retarget for classic boto.
target[0] = "boto+" + target[0]

# In addition, although we store the S3 hostname in the target URL,
# Although we store the S3 hostname in the target URL,
# duplicity no longer accepts it in the target URL. The hostname in
# the target URL must be the bucket name. The hostname is passed
# via get_duplicity_additional_args. Move the first part of the
Expand Down Expand Up @@ -283,9 +278,10 @@ def service_command(service, command, quit=None):
if quit:
sys.exit(code)

service_command("php7.2-fpm", "stop", quit=True)
service_command("php8.0-fpm", "stop", quit=True)
service_command("postfix", "stop", quit=True)
service_command("dovecot", "stop", quit=True)
service_command("postgrey", "stop", quit=True)

# Execute a pre-backup script that copies files outside the homedir.
# Run as the STORAGE_USER user, not as root. Pass our settings in
Expand Down Expand Up @@ -315,9 +311,10 @@ def service_command(service, command, quit=None):
get_duplicity_env_vars(env))
finally:
# Start services again.
service_command("postgrey", "start", quit=False)
service_command("dovecot", "start", quit=False)
service_command("postfix", "start", quit=False)
service_command("php7.2-fpm", "start", quit=False)
service_command("php8.0-fpm", "start", quit=False)

# Remove old backups. This deletes all backup data no longer needed
# from more than 3 days ago.
Expand Down Expand Up @@ -451,26 +448,13 @@ def list_target_files(config):
raise ValueError("Connection to rsync host failed: {}".format(reason))

elif target.scheme == "s3":
# match to a Region
fix_boto() # must call prior to importing boto
import boto.s3
from boto.exception import BotoServerError
custom_region = False
for region in boto.s3.regions():
if region.endpoint == target.hostname:
break
else:
# If region is not found this is a custom region
custom_region = True

import boto3.s3
from botocore.exceptions import ClientError

# separate bucket from path in target
bucket = target.path[1:].split('/')[0]
path = '/'.join(target.path[1:].split('/')[1:]) + '/'

# Create a custom region with custom endpoint
if custom_region:
from boto.s3.connection import S3Connection
region = boto.s3.S3RegionInfo(name=bucket, endpoint=target.hostname, connection_cls=S3Connection)

# If no prefix is specified, set the path to '', otherwise boto won't list the files
if path == '/':
path = ''
Expand All @@ -480,18 +464,15 @@ def list_target_files(config):

# connect to the region & bucket
try:
conn = region.connect(aws_access_key_id=config["target_user"], aws_secret_access_key=config["target_pass"])
bucket = conn.get_bucket(bucket)
except BotoServerError as e:
if e.status == 403:
raise ValueError("Invalid S3 access key or secret access key.")
elif e.status == 404:
raise ValueError("Invalid S3 bucket name.")
elif e.status == 301:
raise ValueError("Incorrect region for this bucket.")
raise ValueError(e.reason)

return [(key.name[len(path):], key.size) for key in bucket.list(prefix=path)]
s3 = boto3.client('s3', \
endpoint_url=f'https://{target.hostname}', \
aws_access_key_id=config['target_user'], \
aws_secret_access_key=config['target_pass'])
bucket_objects = s3.list_objects_v2(Bucket=bucket, Prefix=path)['Contents']
backup_list = [(key['Key'][len(path):], key['Size']) for key in bucket_objects]
except ClientError as e:
raise ValueError(e)
return backup_list
elif target.scheme == 'b2':
from b2sdk.v1 import InMemoryAccountInfo, B2Api
from b2sdk.v1.exception import NonExistentBucket
Expand Down
8 changes: 5 additions & 3 deletions management/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@ def index():
no_users_exist = (len(get_mail_users(env)) == 0)
no_admins_exist = (len(get_admins(env)) == 0)

utils.fix_boto() # must call prior to importing boto
import boto.s3
backup_s3_hosts = [(r.name, r.endpoint) for r in boto.s3.regions()]
import boto3.s3
backup_s3_hosts = [(r, f"s3.{r}.amazonaws.com") for r in boto3.session.Session().get_available_regions('s3')]


return render_template('index.html',
hostname=env['PRIMARY_HOSTNAME'],
Expand Down Expand Up @@ -571,6 +571,8 @@ def print_line(self, message, monospace=False):
# Create a temporary pool of processes for the status checks
with multiprocessing.pool.Pool(processes=5) as pool:
run_checks(False, env, output, pool)
pool.close()
pool.join()
return json_response(output.items)

@app.route('/system/updates')
Expand Down
12 changes: 6 additions & 6 deletions management/dns_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ def do_dns_update(env, force=False):
if len(updated_domains) == 0:
updated_domains.append("DNS configuration")

# Kick nsd if anything changed.
# Tell nsd to reload changed zone files.
if len(updated_domains) > 0:
shell('check_call', ["/usr/sbin/service", "nsd", "restart"])
shell('check_call', ["/usr/sbin/nsd-control", "reload"])

# Write the OpenDKIM configuration tables for all of the mail domains.
from mailconfig import get_mail_domains
Expand Down Expand Up @@ -1000,9 +1000,9 @@ def get_secondary_dns(custom_dns, mode=None):
# doesn't.
if not hostname.startswith("xfr:"):
if mode == "xfr":
response = dns.resolver.query(hostname+'.', "A", raise_on_no_answer=False)
response = dns.resolver.resolve(hostname+'.', "A", raise_on_no_answer=False)
values.extend(map(str, response))
response = dns.resolver.query(hostname+'.', "AAAA", raise_on_no_answer=False)
response = dns.resolver.resolve(hostname+'.', "AAAA", raise_on_no_answer=False)
values.extend(map(str, response))
continue
values.append(hostname)
Expand All @@ -1025,10 +1025,10 @@ def set_secondary_dns(hostnames, env):
if not item.startswith("xfr:"):
# Resolve hostname.
try:
response = resolver.query(item, "A")
response = resolver.resolve(item, "A")
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
try:
response = resolver.query(item, "AAAA")
response = resolver.resolve(item, "AAAA")
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
raise ValueError("Could not resolve the IP address of %s." % item)
else:
Expand Down
33 changes: 15 additions & 18 deletions management/ssl_certificates.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,36 +58,33 @@ def get_file_list():
# Not a valid PEM format for a PEM type we care about.
continue

# Remember where we got this object.
pem._filename = fn

# Is it a private key?
if isinstance(pem, RSAPrivateKey):
private_keys[pem.public_key().public_numbers()] = pem
private_keys[pem.public_key().public_numbers()] = { "filename": fn, "key": pem }

# Is it a certificate?
if isinstance(pem, Certificate):
certificates.append(pem)
certificates.append({ "filename": fn, "cert": pem })

# Process the certificates.
domains = { }
for cert in certificates:
# What domains is this certificate good for?
cert_domains, primary_domain = get_certificate_domains(cert)
cert._primary_domain = primary_domain
cert_domains, primary_domain = get_certificate_domains(cert["cert"])
cert["primary_domain"] = primary_domain

# Is there a private key file for this certificate?
private_key = private_keys.get(cert.public_key().public_numbers())
private_key = private_keys.get(cert["cert"].public_key().public_numbers())
if not private_key:
continue
cert._private_key = private_key
cert["private_key"] = private_key

# Add this cert to the list of certs usable for the domains.
for domain in cert_domains:
# The primary hostname can only use a certificate mapped
# to the system private key.
if domain == env['PRIMARY_HOSTNAME']:
if cert._private_key._filename != os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'):
if cert["private_key"]["filename"] != os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'):
continue

domains.setdefault(domain, []).append(cert)
Expand All @@ -100,10 +97,10 @@ def get_file_list():
#for c in cert_list: print(domain, c.not_valid_before, c.not_valid_after, "("+str(now)+")", c.issuer, c.subject, c._filename)
cert_list.sort(key = lambda cert : (
# must be valid NOW
cert.not_valid_before <= now <= cert.not_valid_after,
cert["cert"].not_valid_before <= now <= cert["cert"].not_valid_after,

# prefer one that is not self-signed
cert.issuer != cert.subject,
cert["cert"].issuer != cert["cert"].subject,

###########################################################
# The above lines ensure that valid certificates are chosen
Expand All @@ -113,7 +110,7 @@ def get_file_list():

# prefer one with the expiration furthest into the future so
# that we can easily rotate to new certs as we get them
cert.not_valid_after,
cert["cert"].not_valid_after,

###########################################################
# We always choose the certificate that is good for the
Expand All @@ -128,15 +125,15 @@ def get_file_list():

# in case a certificate is installed in multiple paths,
# prefer the... lexicographically last one?
cert._filename,
cert["filename"],

), reverse=True)
cert = cert_list.pop(0)
ret[domain] = {
"private-key": cert._private_key._filename,
"certificate": cert._filename,
"primary-domain": cert._primary_domain,
"certificate_object": cert,
"private-key": cert["private_key"]["filename"],
"certificate": cert["filename"],
"primary-domain": cert["primary_domain"],
"certificate_object": cert["cert"],
}

return ret
Expand Down
4 changes: 2 additions & 2 deletions management/status_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@ def check_mail_domain(domain, env, output):
output.print_ok(good_news)

# Check MTA-STS policy.
loop = asyncio.get_event_loop()
loop = asyncio.new_event_loop()
sts_resolver = postfix_mta_sts_resolver.resolver.STSResolver(loop=loop)
valid, policy = loop.run_until_complete(sts_resolver.resolve(domain))
if valid == postfix_mta_sts_resolver.resolver.STSFetchResult.VALID:
Expand Down Expand Up @@ -797,7 +797,7 @@ def query_dns(qname, rtype, nxdomain='[Not Set]', at=None, as_list=False):

# Do the query.
try:
response = resolver.query(qname, rtype)
response = resolver.resolve(qname, rtype)
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
# Host did not have an answer for this query; not sure what the
# difference is between the two exceptions.
Expand Down
1 change: 1 addition & 0 deletions management/templates/system-backup.html
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ <h3>Available backups</h3>
$("#backup-target-type").val("s3");
var hostpath = r.target.substring(5).split('/');
var host = hostpath.shift();
$("#backup-target-s3-host-select").val(host);
$("#backup-target-s3-host").val(host);
$("#backup-target-s3-path").val(hostpath.join('/'));
} else if (r.target.substring(0, 5) == "b2://") {
Expand Down
Loading

0 comments on commit ddf8e85

Please sign in to comment.