mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2024-11-23 02:27:05 +00:00
Merge branch 'main' into dnstimeouthandling
This commit is contained in:
commit
8c6d822a49
27
CHANGELOG.md
27
CHANGELOG.md
@ -1,6 +1,33 @@
|
|||||||
CHANGELOG
|
CHANGELOG
|
||||||
=========
|
=========
|
||||||
|
|
||||||
|
Version 61.1 (January 28, 2022)
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
* Fixed rsync backups not working with the default port.
|
||||||
|
* Reverted "Improve error messages in the management tools when external command-line tools are run." because of the possibility of user secrets being included in error messages.
|
||||||
|
* Fix for TLS certificate SHA fingerprint not being displayed during setup.
|
||||||
|
|
||||||
|
Version 61 (January 21, 2023)
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
System:
|
||||||
|
|
||||||
|
* fail2ban didn't start after setup.
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* Disable Roundcube password plugin since it was corrupting the user database.
|
||||||
|
|
||||||
|
Control panel:
|
||||||
|
|
||||||
|
* Fix changing existing backup settings when the rsync type is used.
|
||||||
|
* Allow setting a custom port for rsync backups.
|
||||||
|
* Fixes to DNS lookups during status checks when there are timeouts, enforce timeouts better.
|
||||||
|
* A new check is added to ensure fail2ban is running.
|
||||||
|
* Fixed a color.
|
||||||
|
* Improve error messages in the management tools when external command-line tools are run.
|
||||||
|
|
||||||
Version 60.1 (October 30, 2022)
|
Version 60.1 (October 30, 2022)
|
||||||
-------------------------------
|
-------------------------------
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ Clone this repository and checkout the tag corresponding to the most recent rele
|
|||||||
|
|
||||||
$ git clone https://github.com/mail-in-a-box/mailinabox
|
$ git clone https://github.com/mail-in-a-box/mailinabox
|
||||||
$ cd mailinabox
|
$ cd mailinabox
|
||||||
$ git checkout v60.1
|
$ git checkout v61.1
|
||||||
|
|
||||||
Begin the installation.
|
Begin the installation.
|
||||||
|
|
||||||
|
@ -213,9 +213,20 @@ def get_duplicity_additional_args(env):
|
|||||||
config = get_backup_config(env)
|
config = get_backup_config(env)
|
||||||
|
|
||||||
if get_target_type(config) == 'rsync':
|
if get_target_type(config) == 'rsync':
|
||||||
|
# Extract a port number for the ssh transport. Duplicity accepts the
|
||||||
|
# optional port number syntax in the target, but it doesn't appear to act
|
||||||
|
# on it, so we set the ssh port explicitly via the duplicity options.
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
try:
|
||||||
|
port = urlsplit(config["target"]).port
|
||||||
|
except ValueError:
|
||||||
|
port = 22
|
||||||
|
if port is None:
|
||||||
|
port = 22
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"--ssh-options= -i /root/.ssh/id_rsa_miab",
|
f"--ssh-options= -i /root/.ssh/id_rsa_miab -p {port}",
|
||||||
"--rsync-options= -e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p 22 -i /root/.ssh/id_rsa_miab\"",
|
f"--rsync-options= -e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p {port} -i /root/.ssh/id_rsa_miab\"",
|
||||||
]
|
]
|
||||||
elif get_target_type(config) == 's3':
|
elif get_target_type(config) == 's3':
|
||||||
# See note about hostname in get_duplicity_target_url.
|
# See note about hostname in get_duplicity_target_url.
|
||||||
@ -408,6 +419,16 @@ def list_target_files(config):
|
|||||||
rsync_fn_size_re = re.compile(r'.* ([^ ]*) [^ ]* [^ ]* (.*)')
|
rsync_fn_size_re = re.compile(r'.* ([^ ]*) [^ ]* [^ ]* (.*)')
|
||||||
rsync_target = '{host}:{path}'
|
rsync_target = '{host}:{path}'
|
||||||
|
|
||||||
|
# Strip off any trailing port specifier because it's not valid in rsync's
|
||||||
|
# DEST syntax. Explicitly set the port number for the ssh transport.
|
||||||
|
user_host, *_ = target.netloc.rsplit(':', 1)
|
||||||
|
try:
|
||||||
|
port = target.port
|
||||||
|
except ValueError:
|
||||||
|
port = 22
|
||||||
|
if port is None:
|
||||||
|
port = 22
|
||||||
|
|
||||||
target_path = target.path
|
target_path = target.path
|
||||||
if not target_path.endswith('/'):
|
if not target_path.endswith('/'):
|
||||||
target_path = target_path + '/'
|
target_path = target_path + '/'
|
||||||
@ -416,11 +437,11 @@ def list_target_files(config):
|
|||||||
|
|
||||||
rsync_command = [ 'rsync',
|
rsync_command = [ 'rsync',
|
||||||
'-e',
|
'-e',
|
||||||
'/usr/bin/ssh -i /root/.ssh/id_rsa_miab -oStrictHostKeyChecking=no -oBatchMode=yes',
|
f'/usr/bin/ssh -i /root/.ssh/id_rsa_miab -oStrictHostKeyChecking=no -oBatchMode=yes -p {port}',
|
||||||
'--list-only',
|
'--list-only',
|
||||||
'-r',
|
'-r',
|
||||||
rsync_target.format(
|
rsync_target.format(
|
||||||
host=target.netloc,
|
host=user_host,
|
||||||
path=target_path)
|
path=target_path)
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -531,7 +552,8 @@ def get_backup_config(env, for_save=False, for_ui=False):
|
|||||||
|
|
||||||
# Merge in anything written to custom.yaml.
|
# Merge in anything written to custom.yaml.
|
||||||
try:
|
try:
|
||||||
custom_config = rtyaml.load(open(os.path.join(backup_root, 'custom.yaml')))
|
with open(os.path.join(backup_root, 'custom.yaml'), 'r') as f:
|
||||||
|
custom_config = rtyaml.load(f)
|
||||||
if not isinstance(custom_config, dict): raise ValueError() # caught below
|
if not isinstance(custom_config, dict): raise ValueError() # caught below
|
||||||
config.update(custom_config)
|
config.update(custom_config)
|
||||||
except:
|
except:
|
||||||
@ -556,7 +578,8 @@ def get_backup_config(env, for_save=False, for_ui=False):
|
|||||||
config["target"] = "file://" + config["file_target_directory"]
|
config["target"] = "file://" + config["file_target_directory"]
|
||||||
ssh_pub_key = os.path.join('/root', '.ssh', 'id_rsa_miab.pub')
|
ssh_pub_key = os.path.join('/root', '.ssh', 'id_rsa_miab.pub')
|
||||||
if os.path.exists(ssh_pub_key):
|
if os.path.exists(ssh_pub_key):
|
||||||
config["ssh_pub_key"] = open(ssh_pub_key, 'r').read()
|
with open(ssh_pub_key, 'r') as f:
|
||||||
|
config["ssh_pub_key"] = f.read()
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
@ -47,7 +47,8 @@ def read_password():
|
|||||||
return first
|
return first
|
||||||
|
|
||||||
def setup_key_auth(mgmt_uri):
|
def setup_key_auth(mgmt_uri):
|
||||||
key = open('/var/lib/mailinabox/api.key').read().strip()
|
with open('/var/lib/mailinabox/api.key', 'r') as f:
|
||||||
|
key = f.read().strip()
|
||||||
|
|
||||||
auth_handler = urllib.request.HTTPBasicAuthHandler()
|
auth_handler = urllib.request.HTTPBasicAuthHandler()
|
||||||
auth_handler.add_password(
|
auth_handler.add_password(
|
||||||
|
3
management/dns_update.py
Executable file → Normal file
3
management/dns_update.py
Executable file → Normal file
@ -815,7 +815,8 @@ def write_opendkim_tables(domains, env):
|
|||||||
|
|
||||||
def get_custom_dns_config(env, only_real_records=False):
|
def get_custom_dns_config(env, only_real_records=False):
|
||||||
try:
|
try:
|
||||||
custom_dns = rtyaml.load(open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml')))
|
with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), 'r') as f:
|
||||||
|
custom_dns = rtyaml.load(f)
|
||||||
if not isinstance(custom_dns, dict): raise ValueError() # caught below
|
if not isinstance(custom_dns, dict): raise ValueError() # caught below
|
||||||
except:
|
except:
|
||||||
return [ ]
|
return [ ]
|
||||||
|
@ -73,7 +73,8 @@ def scan_files(collector):
|
|||||||
continue
|
continue
|
||||||
elif fn[-3:] == '.gz':
|
elif fn[-3:] == '.gz':
|
||||||
tmp_file = tempfile.NamedTemporaryFile()
|
tmp_file = tempfile.NamedTemporaryFile()
|
||||||
shutil.copyfileobj(gzip.open(fn), tmp_file)
|
with gzip.open(fn, 'rb') as f:
|
||||||
|
shutil.copyfileobj(f, tmp_file)
|
||||||
|
|
||||||
if VERBOSE:
|
if VERBOSE:
|
||||||
print("Processing file", fn, "...")
|
print("Processing file", fn, "...")
|
||||||
|
@ -535,7 +535,8 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
|
|||||||
# Second, check that the certificate matches the private key.
|
# Second, check that the certificate matches the private key.
|
||||||
if ssl_private_key is not None:
|
if ssl_private_key is not None:
|
||||||
try:
|
try:
|
||||||
priv_key = load_pem(open(ssl_private_key, 'rb').read())
|
with open(ssl_private_key, 'rb') as f:
|
||||||
|
priv_key = load_pem(f.read())
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return ("The private key file %s is not a private key file: %s" % (ssl_private_key, str(e)), None)
|
return ("The private key file %s is not a private key file: %s" % (ssl_private_key, str(e)), None)
|
||||||
|
|
||||||
|
@ -95,6 +95,12 @@ def run_services_checks(env, output, pool):
|
|||||||
fatal = fatal or fatal2
|
fatal = fatal or fatal2
|
||||||
output2.playback(output)
|
output2.playback(output)
|
||||||
|
|
||||||
|
# Check fail2ban.
|
||||||
|
code, ret = shell('check_output', ["fail2ban-client", "status"], capture_stderr=True, trap=True)
|
||||||
|
if code != 0:
|
||||||
|
output.print_error("fail2ban is not running.")
|
||||||
|
all_running = False
|
||||||
|
|
||||||
if all_running:
|
if all_running:
|
||||||
output.print_ok("All system services are running.")
|
output.print_ok("All system services are running.")
|
||||||
|
|
||||||
@ -207,7 +213,8 @@ def check_ssh_password(env, output):
|
|||||||
# the configuration file.
|
# the configuration file.
|
||||||
if not os.path.exists("/etc/ssh/sshd_config"):
|
if not os.path.exists("/etc/ssh/sshd_config"):
|
||||||
return
|
return
|
||||||
sshd = open("/etc/ssh/sshd_config").read()
|
with open("/etc/ssh/sshd_config", "r") as f:
|
||||||
|
sshd = f.read()
|
||||||
if re.search("\nPasswordAuthentication\s+yes", sshd) \
|
if re.search("\nPasswordAuthentication\s+yes", sshd) \
|
||||||
or not re.search("\nPasswordAuthentication\s+no", sshd):
|
or not re.search("\nPasswordAuthentication\s+no", sshd):
|
||||||
output.print_error("""The SSH server on this machine permits password-based login. A more secure
|
output.print_error("""The SSH server on this machine permits password-based login. A more secure
|
||||||
@ -594,7 +601,8 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
|
|||||||
# record that we suggest using is for the KSK (and that's how the DS records were generated).
|
# record that we suggest using is for the KSK (and that's how the DS records were generated).
|
||||||
# We'll also give the nice name for the key algorithm.
|
# We'll also give the nice name for the key algorithm.
|
||||||
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % alg_name_map[ds_alg]))
|
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % alg_name_map[ds_alg]))
|
||||||
dnsssec_pubkey = open(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys['KSK'] + '.key')).read().split("\t")[3].split(" ")[3]
|
with open(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys['KSK'] + '.key'), 'r') as f:
|
||||||
|
dnsssec_pubkey = f.read().split("\t")[3].split(" ")[3]
|
||||||
|
|
||||||
expected_ds_records[ (ds_keytag, ds_alg, ds_digalg, ds_digest) ] = {
|
expected_ds_records[ (ds_keytag, ds_alg, ds_digalg, ds_digest) ] = {
|
||||||
"record": rr_ds,
|
"record": rr_ds,
|
||||||
@ -966,7 +974,8 @@ def run_and_output_changes(env, pool):
|
|||||||
# Load previously saved status checks.
|
# Load previously saved status checks.
|
||||||
cache_fn = "/var/cache/mailinabox/status_checks.json"
|
cache_fn = "/var/cache/mailinabox/status_checks.json"
|
||||||
if os.path.exists(cache_fn):
|
if os.path.exists(cache_fn):
|
||||||
prev = json.load(open(cache_fn))
|
with open(cache_fn, 'r') as f:
|
||||||
|
prev = json.load(f)
|
||||||
|
|
||||||
# Group the serial output into categories by the headings.
|
# Group the serial output into categories by the headings.
|
||||||
def group_by_heading(lines):
|
def group_by_heading(lines):
|
||||||
|
@ -73,11 +73,6 @@
|
|||||||
filter: invert(100%) hue-rotate(180deg);
|
filter: invert(100%) hue-rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Set explicit background color (necessary for Firefox) */
|
|
||||||
html {
|
|
||||||
background-color: #111;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Override Boostrap theme here to give more contrast. The black turns to white by the filter. */
|
/* Override Boostrap theme here to give more contrast. The black turns to white by the filter. */
|
||||||
.form-control {
|
.form-control {
|
||||||
color: black !important;
|
color: black !important;
|
||||||
|
@ -45,6 +45,10 @@
|
|||||||
<label for="backup-target-rsync-host" class="col-sm-2 control-label">Hostname</label>
|
<label for="backup-target-rsync-host" class="col-sm-2 control-label">Hostname</label>
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
<input type="text" placeholder="hostname.local" class="form-control" rows="1" id="backup-target-rsync-host">
|
<input type="text" placeholder="hostname.local" class="form-control" rows="1" id="backup-target-rsync-host">
|
||||||
|
<div class="small" style="margin-top: 2px">
|
||||||
|
The hostname at your rsync provider, e.g. <tt>da2327.rsync.net</tt>. Optionally includes a colon
|
||||||
|
and the provider's non-standard ssh port number, e.g. <tt>u215843.your-storagebox.de:23</tt>.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group backup-target-rsync">
|
<div class="form-group backup-target-rsync">
|
||||||
@ -259,12 +263,11 @@ function show_custom_backup() {
|
|||||||
} else if (r.target == "off") {
|
} else if (r.target == "off") {
|
||||||
$("#backup-target-type").val("off");
|
$("#backup-target-type").val("off");
|
||||||
} else if (r.target.substring(0, 8) == "rsync://") {
|
} else if (r.target.substring(0, 8) == "rsync://") {
|
||||||
$("#backup-target-type").val("rsync");
|
const spec = url_split(r.target);
|
||||||
var path = r.target.substring(8).split('//');
|
$("#backup-target-type").val(spec.scheme);
|
||||||
var host_parts = path.shift().split('@');
|
$("#backup-target-rsync-user").val(spec.user);
|
||||||
$("#backup-target-rsync-user").val(host_parts[0]);
|
$("#backup-target-rsync-host").val(spec.host);
|
||||||
$("#backup-target-rsync-host").val(host_parts[1]);
|
$("#backup-target-rsync-path").val(spec.path);
|
||||||
$("#backup-target-rsync-path").val('/'+path[0]);
|
|
||||||
} else if (r.target.substring(0, 5) == "s3://") {
|
} else if (r.target.substring(0, 5) == "s3://") {
|
||||||
$("#backup-target-type").val("s3");
|
$("#backup-target-type").val("s3");
|
||||||
var hostpath = r.target.substring(5).split('/');
|
var hostpath = r.target.substring(5).split('/');
|
||||||
@ -344,4 +347,31 @@ function init_inputs(target_type) {
|
|||||||
set_host($('#backup-target-s3-host-select').val());
|
set_host($('#backup-target-s3-host-select').val());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return a two-element array of the substring preceding and the substring following
|
||||||
|
// the first occurence of separator in string. Return [undefined, string] if the
|
||||||
|
// separator does not appear in string.
|
||||||
|
const split1_rest = (string, separator) => {
|
||||||
|
const index = string.indexOf(separator);
|
||||||
|
return (index >= 0) ? [string.substring(0, index), string.substring(index + separator.length)] : [undefined, string];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: The manifest JS URL class does not work in some security-conscious
|
||||||
|
// settings, e.g. Brave browser, so we roll our own that handles only what we need.
|
||||||
|
//
|
||||||
|
// Use greedy separator parsing to get parts of a MIAB backup target url.
|
||||||
|
// Note: path will not include a leading forward slash '/'
|
||||||
|
const url_split = url => {
|
||||||
|
const [ scheme, scheme_rest ] = split1_rest(url, '://');
|
||||||
|
const [ user, user_rest ] = split1_rest(scheme_rest, '@');
|
||||||
|
const [ host, path ] = split1_rest(user_rest, '/');
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheme,
|
||||||
|
user,
|
||||||
|
host,
|
||||||
|
path,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
@ -10,13 +10,13 @@
|
|||||||
border-top: none;
|
border-top: none;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
#system-checks .status-error td {
|
#system-checks .status-error td, .summary-error {
|
||||||
color: #733;
|
color: #733;
|
||||||
}
|
}
|
||||||
#system-checks .status-warning td {
|
#system-checks .status-warning td, .summary-warning {
|
||||||
color: #770;
|
color: #770;
|
||||||
}
|
}
|
||||||
#system-checks .status-ok td {
|
#system-checks .status-ok td, .summary-ok {
|
||||||
color: #040;
|
color: #040;
|
||||||
}
|
}
|
||||||
#system-checks div.extra {
|
#system-checks div.extra {
|
||||||
@ -52,6 +52,9 @@
|
|||||||
</div> <!-- /col -->
|
</div> <!-- /col -->
|
||||||
<div class="col-md-pull-3 col-md-8">
|
<div class="col-md-pull-3 col-md-8">
|
||||||
|
|
||||||
|
<div id="system-checks-summary">
|
||||||
|
</div>
|
||||||
|
|
||||||
<table id="system-checks" class="table" style="max-width: 60em">
|
<table id="system-checks" class="table" style="max-width: 60em">
|
||||||
<thead>
|
<thead>
|
||||||
</thead>
|
</thead>
|
||||||
@ -64,6 +67,9 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_system_status() {
|
function show_system_status() {
|
||||||
|
const summary = $('#system-checks-summary');
|
||||||
|
summary.html("");
|
||||||
|
|
||||||
$('#system-checks tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
$('#system-checks tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||||
|
|
||||||
api(
|
api(
|
||||||
@ -93,6 +99,12 @@ function show_system_status() {
|
|||||||
{ },
|
{ },
|
||||||
function(r) {
|
function(r) {
|
||||||
$('#system-checks tbody').html("");
|
$('#system-checks tbody').html("");
|
||||||
|
const ok_symbol = "✓";
|
||||||
|
const error_symbol = "✖";
|
||||||
|
const warning_symbol = "?";
|
||||||
|
|
||||||
|
let count_by_status = { ok: 0, error: 0, warning: 0 };
|
||||||
|
|
||||||
for (var i = 0; i < r.length; i++) {
|
for (var i = 0; i < r.length; i++) {
|
||||||
var n = $("<tr><td class='status'/><td class='message'><p style='margin: 0'/><div class='extra'/><a class='showhide' href='#'/></tr>");
|
var n = $("<tr><td class='status'/><td class='message'><p style='margin: 0'/><div class='extra'/><a class='showhide' href='#'/></tr>");
|
||||||
if (i == 0) n.addClass('first')
|
if (i == 0) n.addClass('first')
|
||||||
@ -100,9 +112,12 @@ function show_system_status() {
|
|||||||
n.addClass(r[i].type)
|
n.addClass(r[i].type)
|
||||||
else
|
else
|
||||||
n.addClass("status-" + r[i].type)
|
n.addClass("status-" + r[i].type)
|
||||||
if (r[i].type == "ok") n.find('td.status').text("✓")
|
|
||||||
if (r[i].type == "error") n.find('td.status').text("✖")
|
if (r[i].type == "ok") n.find('td.status').text(ok_symbol);
|
||||||
if (r[i].type == "warning") n.find('td.status').text("?")
|
if (r[i].type == "error") n.find('td.status').text(error_symbol);
|
||||||
|
if (r[i].type == "warning") n.find('td.status').text(warning_symbol);
|
||||||
|
count_by_status[r[i].type]++;
|
||||||
|
|
||||||
n.find('td.message p').text(r[i].text)
|
n.find('td.message p').text(r[i].text)
|
||||||
$('#system-checks tbody').append(n);
|
$('#system-checks tbody').append(n);
|
||||||
|
|
||||||
@ -122,8 +137,17 @@ function show_system_status() {
|
|||||||
n.find('> td.message > div').append(m);
|
n.find('> td.message > div').append(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
|
// Summary counts
|
||||||
|
summary.html("Summary: ");
|
||||||
|
if (count_by_status['error'] + count_by_status['warning'] == 0) {
|
||||||
|
summary.append($('<span class="summary-ok"/>').text(`All ${count_by_status['ok']} ${ok_symbol} OK`));
|
||||||
|
} else {
|
||||||
|
summary.append($('<span class="summary-ok"/>').text(`${count_by_status['ok']} ${ok_symbol} OK, `));
|
||||||
|
summary.append($('<span class="summary-error"/>').text(`${count_by_status['error']} ${error_symbol} Error, `));
|
||||||
|
summary.append($('<span class="summary-warning"/>').text(`${count_by_status['warning']} ${warning_symbol} Warning`));
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var current_privacy_setting = null;
|
var current_privacy_setting = null;
|
||||||
|
@ -14,7 +14,9 @@ def load_env_vars_from_file(fn):
|
|||||||
# Load settings from a KEY=VALUE file.
|
# Load settings from a KEY=VALUE file.
|
||||||
import collections
|
import collections
|
||||||
env = collections.OrderedDict()
|
env = collections.OrderedDict()
|
||||||
for line in open(fn): env.setdefault(*line.strip().split("=", 1))
|
with open(fn, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
env.setdefault(*line.strip().split("=", 1))
|
||||||
return env
|
return env
|
||||||
|
|
||||||
def save_environment(env):
|
def save_environment(env):
|
||||||
@ -34,7 +36,8 @@ def load_settings(env):
|
|||||||
import rtyaml
|
import rtyaml
|
||||||
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
|
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
|
||||||
try:
|
try:
|
||||||
config = rtyaml.load(open(fn, "r"))
|
with open(fn, "r") as f:
|
||||||
|
config = rtyaml.load(f)
|
||||||
if not isinstance(config, dict): raise ValueError() # caught below
|
if not isinstance(config, dict): raise ValueError() # caught below
|
||||||
return config
|
return config
|
||||||
except:
|
except:
|
||||||
|
@ -63,7 +63,8 @@ def get_web_domains_with_root_overrides(env):
|
|||||||
root_overrides = { }
|
root_overrides = { }
|
||||||
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
||||||
if os.path.exists(nginx_conf_custom_fn):
|
if os.path.exists(nginx_conf_custom_fn):
|
||||||
custom_settings = rtyaml.load(open(nginx_conf_custom_fn))
|
with open(nginx_conf_custom_fn, 'r') as f:
|
||||||
|
custom_settings = rtyaml.load(f)
|
||||||
for domain, settings in custom_settings.items():
|
for domain, settings in custom_settings.items():
|
||||||
for type, value in [('redirect', settings.get('redirects', {}).get('/')),
|
for type, value in [('redirect', settings.get('redirects', {}).get('/')),
|
||||||
('proxy', settings.get('proxies', {}).get('/'))]:
|
('proxy', settings.get('proxies', {}).get('/'))]:
|
||||||
@ -75,13 +76,18 @@ def do_web_update(env):
|
|||||||
# Pre-load what SSL certificates we will use for each domain.
|
# Pre-load what SSL certificates we will use for each domain.
|
||||||
ssl_certificates = get_ssl_certificates(env)
|
ssl_certificates = get_ssl_certificates(env)
|
||||||
|
|
||||||
|
# Helper for reading config files and templates
|
||||||
|
def read_conf(conf_fn):
|
||||||
|
with open(os.path.join(os.path.dirname(__file__), "../conf", conf_fn), "r") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
# Build an nginx configuration file.
|
# Build an nginx configuration file.
|
||||||
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
|
nginx_conf = read_conf("nginx-top.conf")
|
||||||
|
|
||||||
# Load the templates.
|
# Load the templates.
|
||||||
template0 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read()
|
template0 = read_conf("nginx.conf")
|
||||||
template1 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-alldomains.conf")).read()
|
template1 = read_conf("nginx-alldomains.conf")
|
||||||
template2 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-primaryonly.conf")).read()
|
template2 = read_conf("nginx-primaryonly.conf")
|
||||||
template3 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n"
|
template3 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n"
|
||||||
|
|
||||||
# Add the PRIMARY_HOST configuration first so it becomes nginx's default server.
|
# Add the PRIMARY_HOST configuration first so it becomes nginx's default server.
|
||||||
@ -141,11 +147,8 @@ def make_domain_config(domain, templates, ssl_certificates, env):
|
|||||||
def hashfile(filepath):
|
def hashfile(filepath):
|
||||||
import hashlib
|
import hashlib
|
||||||
sha1 = hashlib.sha1()
|
sha1 = hashlib.sha1()
|
||||||
f = open(filepath, 'rb')
|
with open(filepath, 'rb') as f:
|
||||||
try:
|
|
||||||
sha1.update(f.read())
|
sha1.update(f.read())
|
||||||
finally:
|
|
||||||
f.close()
|
|
||||||
return sha1.hexdigest()
|
return sha1.hexdigest()
|
||||||
nginx_conf_extra += "\t# ssl files sha1: %s / %s\n" % (hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
|
nginx_conf_extra += "\t# ssl files sha1: %s / %s\n" % (hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
|
||||||
|
|
||||||
@ -153,7 +156,8 @@ def make_domain_config(domain, templates, ssl_certificates, env):
|
|||||||
hsts = "yes"
|
hsts = "yes"
|
||||||
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
||||||
if os.path.exists(nginx_conf_custom_fn):
|
if os.path.exists(nginx_conf_custom_fn):
|
||||||
yaml = rtyaml.load(open(nginx_conf_custom_fn))
|
with open(nginx_conf_custom_fn, 'r') as f:
|
||||||
|
yaml = rtyaml.load(f)
|
||||||
if domain in yaml:
|
if domain in yaml:
|
||||||
yaml = yaml[domain]
|
yaml = yaml[domain]
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
Mail-in-a-Box Security Guide
|
Mail-in-a-Box Security Guide
|
||||||
============================
|
============================
|
||||||
|
|
||||||
Mail-in-a-Box turns a fresh Ubuntu 18.04 LTS 64-bit machine into a mail server appliance by installing and configuring various components.
|
Mail-in-a-Box turns a fresh Ubuntu 22.04 LTS 64-bit machine into a mail server appliance by installing and configuring various components.
|
||||||
|
|
||||||
This page documents the security posture of Mail-in-a-Box. The term “box” is used below to mean a configured Mail-in-a-Box.
|
This page documents the security posture of Mail-in-a-Box. The term “box” is used below to mean a configured Mail-in-a-Box.
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ if [ -z "$TAG" ]; then
|
|||||||
if [ "$UBUNTU_VERSION" == "Ubuntu 22.04 LTS" ]; then
|
if [ "$UBUNTU_VERSION" == "Ubuntu 22.04 LTS" ]; then
|
||||||
# This machine is running Ubuntu 22.04, which is supported by
|
# This machine is running Ubuntu 22.04, which is supported by
|
||||||
# Mail-in-a-Box versions 60 and later.
|
# Mail-in-a-Box versions 60 and later.
|
||||||
TAG=v60.1
|
TAG=v61.1
|
||||||
elif [ "$UBUNTU_VERSION" == "Ubuntu 18.04 LTS" ]; then
|
elif [ "$UBUNTU_VERSION" == "Ubuntu 18.04 LTS" ]; then
|
||||||
# This machine is running Ubuntu 18.04, which is supported by
|
# This machine is running Ubuntu 18.04, which is supported by
|
||||||
# Mail-in-a-Box versions 0.40 through 5x.
|
# Mail-in-a-Box versions 0.40 through 5x.
|
||||||
|
@ -202,13 +202,13 @@ chmod -R o-rwx /etc/dovecot
|
|||||||
|
|
||||||
# Ensure mailbox files have a directory that exists and are owned by the mail user.
|
# Ensure mailbox files have a directory that exists and are owned by the mail user.
|
||||||
mkdir -p $STORAGE_ROOT/mail/mailboxes
|
mkdir -p $STORAGE_ROOT/mail/mailboxes
|
||||||
chown -R mail.mail $STORAGE_ROOT/mail/mailboxes
|
chown -R mail:mail $STORAGE_ROOT/mail/mailboxes
|
||||||
|
|
||||||
# Same for the sieve scripts.
|
# Same for the sieve scripts.
|
||||||
mkdir -p $STORAGE_ROOT/mail/sieve
|
mkdir -p $STORAGE_ROOT/mail/sieve
|
||||||
mkdir -p $STORAGE_ROOT/mail/sieve/global_before
|
mkdir -p $STORAGE_ROOT/mail/sieve/global_before
|
||||||
mkdir -p $STORAGE_ROOT/mail/sieve/global_after
|
mkdir -p $STORAGE_ROOT/mail/sieve/global_after
|
||||||
chown -R mail.mail $STORAGE_ROOT/mail/sieve
|
chown -R mail:mail $STORAGE_ROOT/mail/sieve
|
||||||
|
|
||||||
# Allow the IMAP/POP ports in the firewall.
|
# Allow the IMAP/POP ports in the firewall.
|
||||||
ufw_allow imaps
|
ufw_allow imaps
|
||||||
|
@ -34,8 +34,8 @@ contact.admin.always_send warning critical
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
# The Debian installer touches these files and chowns them to www-data:adm for use with spawn-fcgi
|
# The Debian installer touches these files and chowns them to www-data:adm for use with spawn-fcgi
|
||||||
chown munin. /var/log/munin/munin-cgi-html.log
|
chown munin /var/log/munin/munin-cgi-html.log
|
||||||
chown munin. /var/log/munin/munin-cgi-graph.log
|
chown munin /var/log/munin/munin-cgi-graph.log
|
||||||
|
|
||||||
# ensure munin-node knows the name of this machine
|
# ensure munin-node knows the name of this machine
|
||||||
# and reduce logging level to warning
|
# and reduce logging level to warning
|
||||||
|
@ -110,7 +110,7 @@ InstallNextcloud() {
|
|||||||
# Make sure permissions are correct or the upgrade step won't run.
|
# Make sure permissions are correct or the upgrade step won't run.
|
||||||
# $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress
|
# $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress
|
||||||
# that error.
|
# that error.
|
||||||
chown -f -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud || /bin/true
|
chown -f -R www-data:www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud || /bin/true
|
||||||
|
|
||||||
# If this isn't a new installation, immediately run the upgrade script.
|
# If this isn't a new installation, immediately run the upgrade script.
|
||||||
# Then check for success (0=ok and 3=no upgrade needed, both are success).
|
# Then check for success (0=ok and 3=no upgrade needed, both are success).
|
||||||
@ -259,7 +259,7 @@ EOF
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Set permissions
|
# Set permissions
|
||||||
chown -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
|
chown -R www-data:www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
|
||||||
|
|
||||||
# Execute Nextcloud's setup step, which creates the Nextcloud sqlite database.
|
# Execute Nextcloud's setup step, which creates the Nextcloud sqlite database.
|
||||||
# It also wipes it if it exists. And it updates config.php with database
|
# It also wipes it if it exists. And it updates config.php with database
|
||||||
@ -311,7 +311,7 @@ var_export(\$CONFIG);
|
|||||||
echo ";";
|
echo ";";
|
||||||
?>
|
?>
|
||||||
EOF
|
EOF
|
||||||
chown www-data.www-data $STORAGE_ROOT/owncloud/config.php
|
chown www-data:www-data $STORAGE_ROOT/owncloud/config.php
|
||||||
|
|
||||||
# Enable/disable apps. Note that this must be done after the Nextcloud setup.
|
# Enable/disable apps. Note that this must be done after the Nextcloud setup.
|
||||||
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
|
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
|
||||||
|
@ -85,7 +85,7 @@ f=$STORAGE_ROOT
|
|||||||
while [[ $f != / ]]; do chmod a+rx "$f"; f=$(dirname "$f"); done;
|
while [[ $f != / ]]; do chmod a+rx "$f"; f=$(dirname "$f"); done;
|
||||||
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
|
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
|
||||||
setup/migrate.py --current > $STORAGE_ROOT/mailinabox.version
|
setup/migrate.py --current > $STORAGE_ROOT/mailinabox.version
|
||||||
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
chown $STORAGE_USER:$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Save the global options in /etc/mailinabox.conf so that standalone
|
# Save the global options in /etc/mailinabox.conf so that standalone
|
||||||
@ -167,7 +167,7 @@ if management/status_checks.py --check-primary-hostname; then
|
|||||||
echo "If you have a DNS problem put the box's IP address in the URL"
|
echo "If you have a DNS problem put the box's IP address in the URL"
|
||||||
echo "(https://$PUBLIC_IP/admin) but then check the TLS fingerprint:"
|
echo "(https://$PUBLIC_IP/admin) but then check the TLS fingerprint:"
|
||||||
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint -sha256\
|
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint -sha256\
|
||||||
| sed "s/SHA256 Fingerprint=//"
|
| sed "s/SHA256 Fingerprint=//i"
|
||||||
else
|
else
|
||||||
echo https://$PUBLIC_IP/admin
|
echo https://$PUBLIC_IP/admin
|
||||||
echo
|
echo
|
||||||
@ -175,7 +175,7 @@ else
|
|||||||
echo the certificate fingerprint matches:
|
echo the certificate fingerprint matches:
|
||||||
echo
|
echo
|
||||||
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint -sha256\
|
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint -sha256\
|
||||||
| sed "s/SHA256 Fingerprint=//"
|
| sed "s/SHA256 Fingerprint=//i"
|
||||||
echo
|
echo
|
||||||
echo Then you can confirm the security exception and continue.
|
echo Then you can confirm the security exception and continue.
|
||||||
echo
|
echo
|
||||||
|
@ -373,3 +373,5 @@ cp -f conf/fail2ban/filter.d/* /etc/fail2ban/filter.d/
|
|||||||
# scripts will ensure the files exist and then fail2ban is given another
|
# scripts will ensure the files exist and then fail2ban is given another
|
||||||
# restart at the very end of setup.
|
# restart at the very end of setup.
|
||||||
restart_service fail2ban
|
restart_service fail2ban
|
||||||
|
|
||||||
|
systemctl enable fail2ban
|
||||||
|
@ -134,7 +134,7 @@ cat > $RCM_CONFIG <<EOF;
|
|||||||
\$config['product_name'] = '$PRIMARY_HOSTNAME Webmail';
|
\$config['product_name'] = '$PRIMARY_HOSTNAME Webmail';
|
||||||
\$config['cipher_method'] = 'AES-256-CBC'; # persistent login cookie and potentially other things
|
\$config['cipher_method'] = 'AES-256-CBC'; # persistent login cookie and potentially other things
|
||||||
\$config['des_key'] = '$SECRET_KEY'; # 37 characters -> ~256 bits for AES-256, see above
|
\$config['des_key'] = '$SECRET_KEY'; # 37 characters -> ~256 bits for AES-256, see above
|
||||||
\$config['plugins'] = array('html5_notifier', 'archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'persistent_login', 'carddav');
|
\$config['plugins'] = array('html5_notifier', 'archive', 'zipdownload', 'managesieve', 'jqueryui', 'persistent_login', 'carddav');
|
||||||
\$config['skin'] = 'elastic';
|
\$config['skin'] = 'elastic';
|
||||||
\$config['login_autocomplete'] = 2;
|
\$config['login_autocomplete'] = 2;
|
||||||
\$config['login_username_filter'] = 'email';
|
\$config['login_username_filter'] = 'email';
|
||||||
@ -170,7 +170,7 @@ EOF
|
|||||||
|
|
||||||
# Create writable directories.
|
# Create writable directories.
|
||||||
mkdir -p /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
mkdir -p /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
||||||
chown -R www-data.www-data /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
chown -R www-data:www-data /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
||||||
|
|
||||||
# Ensure the log file monitored by fail2ban exists, or else fail2ban can't start.
|
# Ensure the log file monitored by fail2ban exists, or else fail2ban can't start.
|
||||||
sudo -u www-data touch /var/log/roundcubemail/errors.log
|
sudo -u www-data touch /var/log/roundcubemail/errors.log
|
||||||
@ -194,14 +194,14 @@ usermod -a -G dovecot www-data
|
|||||||
|
|
||||||
# set permissions so that PHP can use users.sqlite
|
# set permissions so that PHP can use users.sqlite
|
||||||
# could use dovecot instead of www-data, but not sure it matters
|
# could use dovecot instead of www-data, but not sure it matters
|
||||||
chown root.www-data $STORAGE_ROOT/mail
|
chown root:www-data $STORAGE_ROOT/mail
|
||||||
chmod 775 $STORAGE_ROOT/mail
|
chmod 775 $STORAGE_ROOT/mail
|
||||||
chown root.www-data $STORAGE_ROOT/mail/users.sqlite
|
chown root:www-data $STORAGE_ROOT/mail/users.sqlite
|
||||||
chmod 664 $STORAGE_ROOT/mail/users.sqlite
|
chmod 664 $STORAGE_ROOT/mail/users.sqlite
|
||||||
|
|
||||||
# Fix Carddav permissions:
|
# Fix Carddav permissions:
|
||||||
chown -f -R root.www-data ${RCM_PLUGIN_DIR}/carddav
|
chown -f -R root:www-data ${RCM_PLUGIN_DIR}/carddav
|
||||||
# root.www-data need all permissions, others only read
|
# root:www-data need all permissions, others only read
|
||||||
chmod -R 774 ${RCM_PLUGIN_DIR}/carddav
|
chmod -R 774 ${RCM_PLUGIN_DIR}/carddav
|
||||||
|
|
||||||
# Run Roundcube database migration script (database is created if it does not exist)
|
# Run Roundcube database migration script (database is created if it does not exist)
|
||||||
|
@ -76,7 +76,8 @@ for setting in settings:
|
|||||||
|
|
||||||
found = set()
|
found = set()
|
||||||
buf = ""
|
buf = ""
|
||||||
input_lines = list(open(filename))
|
with open(filename, "r") as f:
|
||||||
|
input_lines = list(f)
|
||||||
|
|
||||||
while len(input_lines) > 0:
|
while len(input_lines) > 0:
|
||||||
line = input_lines.pop(0)
|
line = input_lines.pop(0)
|
||||||
|
@ -40,8 +40,8 @@ cp "$1/owncloud.db" $STORAGE_ROOT/owncloud/
|
|||||||
cp "$1/config.php" $STORAGE_ROOT/owncloud/
|
cp "$1/config.php" $STORAGE_ROOT/owncloud/
|
||||||
|
|
||||||
ln -sf $STORAGE_ROOT/owncloud/config.php /usr/local/lib/owncloud/config/config.php
|
ln -sf $STORAGE_ROOT/owncloud/config.php /usr/local/lib/owncloud/config/config.php
|
||||||
chown -f -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
|
chown -f -R www-data:www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
|
||||||
chown www-data.www-data $STORAGE_ROOT/owncloud/config.php
|
chown www-data:www-data $STORAGE_ROOT/owncloud/config.php
|
||||||
|
|
||||||
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ maintenance:mode --off
|
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ maintenance:mode --off
|
||||||
|
|
||||||
|
@ -17,13 +17,8 @@ accesses = set()
|
|||||||
# Scan the current and rotated access logs.
|
# Scan the current and rotated access logs.
|
||||||
for fn in glob.glob("/var/log/nginx/access.log*"):
|
for fn in glob.glob("/var/log/nginx/access.log*"):
|
||||||
# Gunzip if necessary.
|
# Gunzip if necessary.
|
||||||
if fn.endswith(".gz"):
|
|
||||||
f = gzip.open(fn)
|
|
||||||
else:
|
|
||||||
f = open(fn, "rb")
|
|
||||||
|
|
||||||
# Loop through the lines in the access log.
|
# Loop through the lines in the access log.
|
||||||
with f:
|
with (gzip.open if fn.endswith(".gz") else open)(fn, "rb") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
# Find lines that are GETs on the bootstrap script by either curl or wget.
|
# Find lines that are GETs on the bootstrap script by either curl or wget.
|
||||||
# (Note that we purposely skip ...?ping=1 requests which is the admin panel querying us for updates.)
|
# (Note that we purposely skip ...?ping=1 requests which is the admin panel querying us for updates.)
|
||||||
@ -43,7 +38,8 @@ for date, ip in accesses:
|
|||||||
# Since logs are rotated, store the statistics permanently in a JSON file.
|
# Since logs are rotated, store the statistics permanently in a JSON file.
|
||||||
# Load in the stats from an existing file.
|
# Load in the stats from an existing file.
|
||||||
if os.path.exists(outfn):
|
if os.path.exists(outfn):
|
||||||
existing_data = json.load(open(outfn))
|
with open(outfn, "r") as f:
|
||||||
|
existing_data = json.load(f)
|
||||||
for date, count in existing_data:
|
for date, count in existing_data:
|
||||||
if date not in by_date:
|
if date not in by_date:
|
||||||
by_date[date] = count
|
by_date[date] = count
|
||||||
|
@ -124,7 +124,8 @@ def generate_documentation():
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
parser = Source.parser()
|
parser = Source.parser()
|
||||||
for line in open("setup/start.sh"):
|
with open("setup/start.sh", "r") as start_file:
|
||||||
|
for line in start_file:
|
||||||
try:
|
try:
|
||||||
fn = parser.parse_string(line).filename()
|
fn = parser.parse_string(line).filename()
|
||||||
except:
|
except:
|
||||||
@ -401,7 +402,8 @@ class BashScript(Grammar):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def parse(fn):
|
def parse(fn):
|
||||||
if fn in ("setup/functions.sh", "/etc/mailinabox.conf"): return ""
|
if fn in ("setup/functions.sh", "/etc/mailinabox.conf"): return ""
|
||||||
string = open(fn).read()
|
with open(fn, "r") as f:
|
||||||
|
string = f.read()
|
||||||
|
|
||||||
# tokenize
|
# tokenize
|
||||||
string = re.sub(".* #NODOC\n", "", string)
|
string = re.sub(".* #NODOC\n", "", string)
|
||||||
|
Loading…
Reference in New Issue
Block a user