Skip to content

Commit ddf8e85

Browse files
authored
Support Ubuntu 22.04 Jammy Jellyfish (#2083)
2 parents d7244ed + 4d5ff02 commit ddf8e85

32 files changed

+332
-296
lines changed

CHANGELOG.md

+28
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,34 @@
11
CHANGELOG
22
=========
33

4+
Version 60 (October 11, 2022)
5+
-----------------------------
6+
7+
This is the first release for Ubuntu 22.04.
8+
9+
**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.)
10+
11+
For complete upgrade instructions, see:
12+
13+
https://discourse.mailinabox.email/t/version-60-for-ubuntu-22-04-is-about-to-be-released/9558
14+
15+
No major features of Mail-in-a-Box have changed in this release, although some minor fixes were made.
16+
17+
With the newer version of Ubuntu the following software packages we use are updated:
18+
19+
* 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).
20+
* Nextcloud is upgraded to 23.0.4.
21+
* Roundcube is upgraded to 1.6.0.
22+
* certbot is upgraded to 1.21 (via the Ubuntu repository instead of a PPA).
23+
* fail2ban is upgraded to 0.11.2.
24+
* nginx is upgraded to 1.18.
25+
* PHP is upgraded from 7.2 to 8.0.
26+
27+
Also:
28+
29+
* Roundcube's login session cookie was tightened. Existing sessions may require a manual logout.
30+
* Moved Postgrey's database under $STORAGE_ROOT.
31+
432
Version 57a (June 19, 2022)
533
---------------------------
634

README.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Additionally, this project has a [Code of Conduct](CODE_OF_CONDUCT.md), which su
2323
In The Box
2424
----------
2525

26-
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.
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.
2727

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

@@ -54,13 +54,13 @@ Installation
5454

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

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

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

6161
$ git clone https://github.com/mail-in-a-box/mailinabox
6262
$ cd mailinabox
63-
$ git checkout v57a
63+
$ git checkout v60
6464

6565
Begin the installation.
6666

Vagrantfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# vi: set ft=ruby :
33

44
Vagrant.configure("2") do |config|
5-
config.vm.box = "ubuntu/bionic64"
5+
config.vm.box = "ubuntu/jammy64"
66

77
# Network config: Since it's a mail server, the machine must be connected
88
# to the public web. However, we currently don't want to expose SSH since

conf/mailinabox.service

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ After=multi-user.target
44

55
[Service]
66
Type=idle
7+
IgnoreSIGPIPE=False
78
ExecStart=/usr/local/lib/mailinabox/start
89

910
[Install]

conf/nginx-top.conf

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
## your own --- please do not ask for help from us.
88

99
upstream php-fpm {
10-
server unix:/var/run/php/php7.2-fpm.sock;
10+
server unix:/var/run/php/php8.0-fpm.sock;
1111
}
1212

management/auth.py

+2-14
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,8 @@ def __init__(self):
2222
def init_system_api_key(self):
2323
"""Write an API key to a local file so local processes can use the API"""
2424

25-
def create_file_with_mode(path, mode):
26-
# Based on answer by A-B-B: http://stackoverflow.com/a/15015748
27-
old_umask = os.umask(0)
28-
try:
29-
return os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, mode), 'w')
30-
finally:
31-
os.umask(old_umask)
32-
33-
self.key = secrets.token_hex(32)
34-
35-
os.makedirs(os.path.dirname(self.key_path), exist_ok=True)
36-
37-
with create_file_with_mode(self.key_path, 0o640) as key_file:
38-
key_file.write(self.key + '\n')
25+
with open(self.key_path, 'r') as file:
26+
self.key = file.read()
3927

4028
def authenticate(self, request, env, login_only=False, logout=False):
4129
"""Test if the HTTP Authorization header's username matches the system key, a session key,

management/backup.py

+19-38
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import rtyaml
1313
from exclusiveprocess import Lock
1414

15-
from utils import load_environment, shell, wait_for_service, fix_boto
15+
from utils import load_environment, shell, wait_for_service
1616

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

200-
# Duplicity now defaults to boto3 as the backend for S3, but we have
201-
# legacy boto installed (boto3 doesn't support Ubuntu 18.04) so
202-
# we retarget for classic boto.
203-
target[0] = "boto+" + target[0]
204-
205-
# In addition, although we store the S3 hostname in the target URL,
200+
# Although we store the S3 hostname in the target URL,
206201
# duplicity no longer accepts it in the target URL. The hostname in
207202
# the target URL must be the bucket name. The hostname is passed
208203
# via get_duplicity_additional_args. Move the first part of the
@@ -283,9 +278,10 @@ def service_command(service, command, quit=None):
283278
if quit:
284279
sys.exit(code)
285280

286-
service_command("php7.2-fpm", "stop", quit=True)
281+
service_command("php8.0-fpm", "stop", quit=True)
287282
service_command("postfix", "stop", quit=True)
288283
service_command("dovecot", "stop", quit=True)
284+
service_command("postgrey", "stop", quit=True)
289285

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

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

453450
elif target.scheme == "s3":
454-
# match to a Region
455-
fix_boto() # must call prior to importing boto
456-
import boto.s3
457-
from boto.exception import BotoServerError
458-
custom_region = False
459-
for region in boto.s3.regions():
460-
if region.endpoint == target.hostname:
461-
break
462-
else:
463-
# If region is not found this is a custom region
464-
custom_region = True
465-
451+
import boto3.s3
452+
from botocore.exceptions import ClientError
453+
454+
# separate bucket from path in target
466455
bucket = target.path[1:].split('/')[0]
467456
path = '/'.join(target.path[1:].split('/')[1:]) + '/'
468457

469-
# Create a custom region with custom endpoint
470-
if custom_region:
471-
from boto.s3.connection import S3Connection
472-
region = boto.s3.S3RegionInfo(name=bucket, endpoint=target.hostname, connection_cls=S3Connection)
473-
474458
# If no prefix is specified, set the path to '', otherwise boto won't list the files
475459
if path == '/':
476460
path = ''
@@ -480,18 +464,15 @@ def list_target_files(config):
480464

481465
# connect to the region & bucket
482466
try:
483-
conn = region.connect(aws_access_key_id=config["target_user"], aws_secret_access_key=config["target_pass"])
484-
bucket = conn.get_bucket(bucket)
485-
except BotoServerError as e:
486-
if e.status == 403:
487-
raise ValueError("Invalid S3 access key or secret access key.")
488-
elif e.status == 404:
489-
raise ValueError("Invalid S3 bucket name.")
490-
elif e.status == 301:
491-
raise ValueError("Incorrect region for this bucket.")
492-
raise ValueError(e.reason)
493-
494-
return [(key.name[len(path):], key.size) for key in bucket.list(prefix=path)]
467+
s3 = boto3.client('s3', \
468+
endpoint_url=f'https://{target.hostname}', \
469+
aws_access_key_id=config['target_user'], \
470+
aws_secret_access_key=config['target_pass'])
471+
bucket_objects = s3.list_objects_v2(Bucket=bucket, Prefix=path)['Contents']
472+
backup_list = [(key['Key'][len(path):], key['Size']) for key in bucket_objects]
473+
except ClientError as e:
474+
raise ValueError(e)
475+
return backup_list
495476
elif target.scheme == 'b2':
496477
from b2sdk.v1 import InMemoryAccountInfo, B2Api
497478
from b2sdk.v1.exception import NonExistentBucket

management/daemon.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,9 @@ def index():
121121
no_users_exist = (len(get_mail_users(env)) == 0)
122122
no_admins_exist = (len(get_admins(env)) == 0)
123123

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

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

576578
@app.route('/system/updates')

management/dns_update.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,9 @@ def do_dns_update(env, force=False):
9696
if len(updated_domains) == 0:
9797
updated_domains.append("DNS configuration")
9898

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

103103
# Write the OpenDKIM configuration tables for all of the mail domains.
104104
from mailconfig import get_mail_domains
@@ -1000,9 +1000,9 @@ def get_secondary_dns(custom_dns, mode=None):
10001000
# doesn't.
10011001
if not hostname.startswith("xfr:"):
10021002
if mode == "xfr":
1003-
response = dns.resolver.query(hostname+'.', "A", raise_on_no_answer=False)
1003+
response = dns.resolver.resolve(hostname+'.', "A", raise_on_no_answer=False)
10041004
values.extend(map(str, response))
1005-
response = dns.resolver.query(hostname+'.', "AAAA", raise_on_no_answer=False)
1005+
response = dns.resolver.resolve(hostname+'.', "AAAA", raise_on_no_answer=False)
10061006
values.extend(map(str, response))
10071007
continue
10081008
values.append(hostname)
@@ -1025,10 +1025,10 @@ def set_secondary_dns(hostnames, env):
10251025
if not item.startswith("xfr:"):
10261026
# Resolve hostname.
10271027
try:
1028-
response = resolver.query(item, "A")
1028+
response = resolver.resolve(item, "A")
10291029
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
10301030
try:
1031-
response = resolver.query(item, "AAAA")
1031+
response = resolver.resolve(item, "AAAA")
10321032
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
10331033
raise ValueError("Could not resolve the IP address of %s." % item)
10341034
else:

management/ssl_certificates.py

+15-18
Original file line numberDiff line numberDiff line change
@@ -58,36 +58,33 @@ def get_file_list():
5858
# Not a valid PEM format for a PEM type we care about.
5959
continue
6060

61-
# Remember where we got this object.
62-
pem._filename = fn
63-
6461
# Is it a private key?
6562
if isinstance(pem, RSAPrivateKey):
66-
private_keys[pem.public_key().public_numbers()] = pem
63+
private_keys[pem.public_key().public_numbers()] = { "filename": fn, "key": pem }
6764

6865
# Is it a certificate?
6966
if isinstance(pem, Certificate):
70-
certificates.append(pem)
67+
certificates.append({ "filename": fn, "cert": pem })
7168

7269
# Process the certificates.
7370
domains = { }
7471
for cert in certificates:
7572
# What domains is this certificate good for?
76-
cert_domains, primary_domain = get_certificate_domains(cert)
77-
cert._primary_domain = primary_domain
73+
cert_domains, primary_domain = get_certificate_domains(cert["cert"])
74+
cert["primary_domain"] = primary_domain
7875

7976
# Is there a private key file for this certificate?
80-
private_key = private_keys.get(cert.public_key().public_numbers())
77+
private_key = private_keys.get(cert["cert"].public_key().public_numbers())
8178
if not private_key:
8279
continue
83-
cert._private_key = private_key
80+
cert["private_key"] = private_key
8481

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

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

105102
# prefer one that is not self-signed
106-
cert.issuer != cert.subject,
103+
cert["cert"].issuer != cert["cert"].subject,
107104

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

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

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

129126
# in case a certificate is installed in multiple paths,
130127
# prefer the... lexicographically last one?
131-
cert._filename,
128+
cert["filename"],
132129

133130
), reverse=True)
134131
cert = cert_list.pop(0)
135132
ret[domain] = {
136-
"private-key": cert._private_key._filename,
137-
"certificate": cert._filename,
138-
"primary-domain": cert._primary_domain,
139-
"certificate_object": cert,
133+
"private-key": cert["private_key"]["filename"],
134+
"certificate": cert["filename"],
135+
"primary-domain": cert["primary_domain"],
136+
"certificate_object": cert["cert"],
140137
}
141138

142139
return ret

management/status_checks.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,7 @@ def check_mail_domain(domain, env, output):
715715
output.print_ok(good_news)
716716

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

798798
# Do the query.
799799
try:
800-
response = resolver.query(qname, rtype)
800+
response = resolver.resolve(qname, rtype)
801801
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
802802
# Host did not have an answer for this query; not sure what the
803803
# difference is between the two exceptions.

management/templates/system-backup.html

+1
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ <h3>Available backups</h3>
269269
$("#backup-target-type").val("s3");
270270
var hostpath = r.target.substring(5).split('/');
271271
var host = hostpath.shift();
272+
$("#backup-target-s3-host-select").val(host);
272273
$("#backup-target-s3-host").val(host);
273274
$("#backup-target-s3-path").val(hostpath.join('/'));
274275
} else if (r.target.substring(0, 5) == "b2://") {

0 commit comments

Comments
 (0)