-
-
Notifications
You must be signed in to change notification settings - Fork 295
/
Copy pathregenconf.py
724 lines (602 loc) · 25.8 KB
/
regenconf.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
#!/usr/bin/env python3
#
# Copyright (c) 2024 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import hashlib
import json
import os
import shutil
from datetime import datetime
from difflib import unified_diff
from logging import getLogger
import yaml
from moulinette import m18n
from moulinette.utils.filesystem import mkdir
from moulinette.utils.process import check_output
from yunohost.hook import hook_callback, hook_list
from yunohost.log import is_unit_operation
from yunohost.utils.error import YunohostError
BASE_CONF_PATH = "/var/cache/yunohost/regenconf"
BACKUP_CONF_DIR = os.path.join(BASE_CONF_PATH, "backup")
PENDING_CONF_DIR = os.path.join(BASE_CONF_PATH, "pending")
REGEN_CONF_FILE = "/etc/yunohost/regenconf.yml"
logger = getLogger("yunohost.regenconf")
# FIXME : those ain't just services anymore ... what are we supposed to do with this ...
# FIXME : check for all reference of 'service' close to operation_logger stuff
@is_unit_operation([("names", "configuration")])
def regen_conf(
operation_logger,
names=None,
with_diff=False,
force=False,
dry_run=False,
list_pending=False,
):
"""
Regenerate the configuration file(s)
Keyword argument:
names -- Categories to regenerate configuration of
with_diff -- Show differences in case of configuration changes
force -- Override all manual modifications in configuration files
dry_run -- Show what would have been regenerated
list_pending -- List pending configuration files and exit
"""
from yunohost.settings import settings_get
if names is None:
names = []
result = {}
# Return the list of pending conf
if list_pending:
pending_conf = _get_pending_conf(names)
if not with_diff:
return pending_conf
for category, conf_files in pending_conf.items():
for system_path, pending_path in conf_files.items():
pending_conf[category][system_path] = {
"pending_conf": pending_path,
"diff": _get_files_diff(system_path, pending_path, True),
}
return pending_conf
if not dry_run:
operation_logger.related_to = [("configuration", x) for x in names]
if not names:
operation_logger.name_parameter_override = "all"
elif len(names) != 1:
operation_logger.name_parameter_override = (
str(len(operation_logger.related_to)) + "_categories"
)
operation_logger.start()
# Clean pending conf directory
if os.path.isdir(PENDING_CONF_DIR):
if not names:
shutil.rmtree(PENDING_CONF_DIR, ignore_errors=True)
else:
for name in names:
shutil.rmtree(os.path.join(PENDING_CONF_DIR, name), ignore_errors=True)
else:
mkdir(PENDING_CONF_DIR, 0o755, True)
# Execute hooks for pre-regen
# element 2 and 3 with empty string is because of legacy...
pre_args = ["pre", "", ""]
def _pre_call(name, priority, path, args):
# create the pending conf directory for the category
category_pending_path = os.path.join(PENDING_CONF_DIR, name)
mkdir(category_pending_path, 0o755, True, uid="root")
# return the arguments to pass to the script
return pre_args + [
category_pending_path,
]
ssh_explicitly_specified = isinstance(names, list) and "ssh" in names
# By default, we regen everything
if not names:
names = hook_list("conf_regen", list_by="name", show_info=False)["hooks"]
# [Optimization] We compute and feed the domain list to the conf regen
# hooks to avoid having to call "yunohost domain list" so many times which
# ends up in wasted time (about 3~5 seconds per call on a RPi2)
from yunohost.domain import domain_list
env = {}
# Well we can only do domain_list() if postinstall is done ...
# ... but hooks that effectively need the domain list are only
# called only after the 'installed' flag is set so that's all good,
# though kinda tight-coupled to the postinstall logic :s
if os.path.exists("/etc/yunohost/installed"):
env["YNH_DOMAINS"] = " ".join(domain_list()["domains"])
env["YNH_MAIN_DOMAINS"] = " ".join(
domain_list(exclude_subdomains=True)["domains"]
)
env["YNH_CONTEXT"] = "regenconf"
# perf: Export all global settings as a environment variable
# so that scripts dont have to call 'yunohost settings get' manually
# which is painful performance-wise
env["YNH_SETTINGS"] = json.dumps(settings_get("", export=True))
env["FORCE"] = "true" if force else "false"
pre_result = hook_callback("conf_regen", names, pre_callback=_pre_call, env=env)
# Keep only the hook names with at least one success
names = [
hook
for hook, infos in pre_result.items()
if any(result["state"] == "succeed" for result in infos.values())
]
# FIXME : what do in case of partial success/failure ...
if not names:
ret_failed = [
hook
for hook, infos in pre_result.items()
if any(result["state"] == "failed" for result in infos.values())
]
raise YunohostError("regenconf_failed", categories=", ".join(ret_failed))
# Set the processing method
_regen = _process_regen_conf if not dry_run else lambda *a, **k: True
operation_logger.related_to = []
# Iterate over categories and process pending conf
for category, conf_files in _get_pending_conf(names).items():
if not dry_run:
operation_logger.related_to.append(("configuration", category))
if dry_run:
logger.debug(m18n.n("regenconf_pending_applying", category=category))
else:
logger.debug(m18n.n("regenconf_dry_pending_applying", category=category))
conf_hashes = _get_conf_hashes(category)
succeed_regen = {}
failed_regen = {}
# Here we are doing some weird legacy shit
# The thing is, on some very old or specific setup, the sshd_config file
# was absolutely not managed by the regenconf ...
# But we now want to make sure that this file is managed.
# However, we don't want to overwrite a specific custom sshd_config
# which may make the admin unhappy ...
# So : if the hash for this file does not exists, we set the hash as the
# hash of the pending configuration ...
# That way, the file will later appear as manually modified.
sshd_config = "/etc/ssh/sshd_config"
if (
category == "ssh"
and sshd_config not in conf_hashes
and sshd_config in conf_files
):
conf_hashes[sshd_config] = _calculate_hash(conf_files[sshd_config])
_update_conf_hashes(category, conf_hashes)
# Consider the following scenario:
# - you add a domain foo.bar
# - the regen-conf creates file /etc/dnsmasq.d/foo.bar
# - the admin manually *deletes* /etc/dnsmasq.d/foo.bar
# - the file is now understood as manually deleted because there's the old file hash in regenconf.yml
#
# ... so far so good, that's the expected behavior.
#
# But then:
# - the admin remove domain foo.bar entirely
# - but now the hash for /etc/dnsmasq.d/foo.bar is *still* in
# regenconf.yml and and the file is still flagged as manually
# modified/deleted... And the user cannot even do anything about it
# except removing the hash in regenconf.yml...
#
# Expected behavior: it should forget about that
# hash because dnsmasq's regen-conf doesn't say anything about what's
# the state of that file so it should assume that it should be deleted.
#
# - then the admin tries to *re-add* foo.bar !
# - ... but because the file is still flagged as manually modified
# the regen-conf refuses to re-create the file.
#
# Excepted behavior : the regen-conf should have forgot about the hash
# from earlier and this wouldnt happen.
# ------
# conf_files contain files explicitly set by the current regen conf run
# conf_hashes contain all files known from the past runs
# we compare these to get the list of stale hashes and flag the file as
# "should be removed"
stale_files = set(conf_hashes.keys()) - set(conf_files.keys())
stale_files_with_non_empty_hash = [f for f in stale_files if conf_hashes.get(f)]
for f in stale_files_with_non_empty_hash:
conf_files[f] = None
# </> End discussion about stale file hashes
force_update_hashes_for_this_category = False
for system_path, pending_path in conf_files.items():
logger.debug(
"processing pending conf '%s' to system conf '%s'",
pending_path,
system_path,
)
conf_status = None
regenerated = False
# Get the diff between files
conf_diff = (
_get_files_diff(system_path, pending_path, True) if with_diff else None
)
# Check if the conf must be removed
to_remove = (
True if pending_path and os.path.getsize(pending_path) == 0 else False
)
# Retrieve and calculate hashes
system_hash = _calculate_hash(system_path)
saved_hash = conf_hashes.get(system_path, None)
new_hash = None if to_remove else _calculate_hash(pending_path)
# -> configuration was previously managed by yunohost but should now
# be removed / unmanaged
if system_path in stale_files_with_non_empty_hash:
# File is already deleted, so let's just silently forget about this hash entirely
if not system_hash:
logger.debug("> forgetting about stale file/hash")
conf_hashes[system_path] = None
conf_status = "forget-about-it"
regenerated = True
# Otherwise there's still a file on the system but it's not managed by
# Yunohost anymore... But if user requested --force we shall
# force-erase it
elif force:
logger.debug("> force-remove stale file")
regenerated = _regen(system_path)
conf_status = "force-removed"
# Otherwise, flag the file as manually modified
else:
logger.warning(
m18n.n("regenconf_file_manually_modified", conf=system_path)
)
conf_status = "modified"
# -> system conf does not exists
elif not system_hash:
if to_remove:
logger.debug("> system conf is already removed")
os.remove(pending_path)
conf_hashes[system_path] = None
conf_status = "forget-about-it"
force_update_hashes_for_this_category = True
continue
elif not saved_hash or force:
if force:
logger.debug("> system conf has been manually removed")
conf_status = "force-created"
else:
logger.debug("> system conf does not exist yet")
conf_status = "created"
regenerated = _regen(system_path, pending_path, save=False)
else:
logger.info(
m18n.n("regenconf_file_manually_removed", conf=system_path)
)
conf_status = "removed"
# -> system conf is not managed yet
elif not saved_hash:
logger.debug("> system conf is not managed yet")
if system_hash == new_hash:
logger.debug("> no changes to system conf has been made")
conf_status = "managed"
regenerated = True
elif not to_remove:
# If the conf exist but is not managed yet, and is not to be removed,
# we assume that it is safe to regen it, since the file is backuped
# anyway (by default in _regen), as long as we warn the user
# appropriately.
logger.info(
m18n.n(
"regenconf_now_managed_by_yunohost",
conf=system_path,
category=category,
)
)
regenerated = _regen(system_path, pending_path)
conf_status = "new"
elif force:
regenerated = _regen(system_path)
conf_status = "force-removed"
else:
logger.info(
m18n.n(
"regenconf_file_kept_back",
conf=system_path,
category=category,
)
)
conf_status = "unmanaged"
# -> system conf has not been manually modified
elif system_hash == saved_hash:
if to_remove:
regenerated = _regen(system_path)
conf_status = "removed"
elif system_hash != new_hash:
regenerated = _regen(system_path, pending_path)
conf_status = "updated"
else:
logger.debug("> system conf is already up-to-date")
os.remove(pending_path)
continue
else:
logger.debug("> system conf has been manually modified")
if system_hash == new_hash:
logger.debug("> new conf is as current system conf")
conf_status = "managed"
regenerated = True
elif (
force
and system_path == sshd_config
and not ssh_explicitly_specified
):
logger.warning(m18n.n("regenconf_need_to_explicitly_specify_ssh"))
conf_status = "modified"
elif force:
regenerated = _regen(system_path, pending_path)
conf_status = "force-updated"
else:
logger.warning(
m18n.n("regenconf_file_manually_modified", conf=system_path)
)
conf_status = "modified"
# Store the result
conf_result = {"status": conf_status}
if conf_diff is not None:
conf_result["diff"] = conf_diff
if regenerated:
succeed_regen[system_path] = conf_result
conf_hashes[system_path] = new_hash
if pending_path and os.path.isfile(pending_path):
os.remove(pending_path)
else:
failed_regen[system_path] = conf_result
# Check for category conf changes
if not succeed_regen and not failed_regen:
logger.debug(m18n.n("regenconf_up_to_date", category=category))
continue
elif not failed_regen:
if not dry_run:
logger.success(m18n.n("regenconf_updated", category=category))
else:
logger.success(m18n.n("regenconf_would_be_updated", category=category))
if (succeed_regen or force_update_hashes_for_this_category) and not dry_run:
_update_conf_hashes(category, conf_hashes)
# Append the category results
result[category] = {"applied": succeed_regen, "pending": failed_regen}
# Return in case of dry run
if dry_run:
return result
# Execute hooks for post-regen
# element 2 and 3 with empty string is because of legacy...
post_args = ["post", "", ""]
def _pre_call(name, priority, path, args):
# append coma-separated applied changes for the category
if name in result and result[name]["applied"]:
regen_conf_files = ",".join(result[name]["applied"].keys())
else:
regen_conf_files = ""
return post_args + [
regen_conf_files,
]
hook_callback("conf_regen", names, pre_callback=_pre_call, env=env)
operation_logger.success()
return result
def _get_regenconf_infos():
"""
Get a dict of regen conf informations
"""
try:
with open(REGEN_CONF_FILE, "r") as f:
data = yaml.safe_load(f)
# Cleanup legacy
if "metronome" in data:
del data["metronome"]
if "rspamd" in data:
del data["rspamd"]
return data
except Exception:
return {}
def _save_regenconf_infos(infos):
"""
Save the regen conf informations
Keyword argument:
categories -- A dict containing the regenconf infos
"""
try:
with open(REGEN_CONF_FILE, "w") as f:
yaml.safe_dump(infos, f, default_flow_style=False)
except Exception as e:
logger.warning(
f"Error while saving regenconf infos, exception: {e}", exc_info=1
)
raise
def _get_files_diff(orig_file, new_file, as_string=False, skip_header=True):
"""Compare two files and return the differences
Read and compare two files. The differences are returned either as a delta
in unified diff format or a formatted string if as_string is True. The
header can also be removed if skip_header is True.
"""
if orig_file and os.path.exists(orig_file):
with open(orig_file, "r") as orig_file:
orig_file = orig_file.readlines()
else:
orig_file = []
if new_file and os.path.exists(new_file):
with open(new_file, "r") as new_file:
new_file = new_file.readlines()
else:
new_file = []
# Compare files and format output
diff = unified_diff(orig_file, new_file)
if skip_header:
try:
next(diff)
next(diff)
except Exception:
pass
if as_string:
return "".join(diff).rstrip()
return diff
def _calculate_hash(path):
"""Calculate the MD5 hash of a file"""
if not path or not os.path.exists(path):
return None
hasher = hashlib.md5()
try:
with open(path, "rb") as f:
hasher.update(f.read())
return hasher.hexdigest()
except IOError as e:
logger.warning(f"Error while calculating file '{path}' hash: {e}", exc_info=1)
return None
def _get_pending_conf(categories=[]):
"""Get pending configuration for categories
Iterate over the pending configuration directory for given categories - or
all if empty - and look for files inside. Each file is considered as a
pending configuration file and therefore must be in the same directory
tree than the system file that it replaces.
The result is returned as a dict of categories with pending configuration as
key and a dict of `system_conf_path` => `pending_conf_path` as value.
"""
result = {}
if not os.path.isdir(PENDING_CONF_DIR):
return result
if not categories:
categories = os.listdir(PENDING_CONF_DIR)
for name in categories:
category_pending_path = os.path.join(PENDING_CONF_DIR, name)
if not os.path.isdir(category_pending_path):
continue
path_index = len(category_pending_path)
category_conf = {}
for root, dirs, files in os.walk(category_pending_path):
for filename in files:
pending_path = os.path.join(root, filename)
category_conf[pending_path[path_index:]] = pending_path
if category_conf:
result[name] = category_conf
else:
# remove empty directory
shutil.rmtree(category_pending_path, ignore_errors=True)
return result
def _get_conf_hashes(category):
"""Get the registered conf hashes for a category"""
categories = _get_regenconf_infos()
if category not in categories:
logger.debug(f"category {category} is not in categories.yml yet.")
return {}
elif categories[category] is None or "conffiles" not in categories[category]:
logger.debug(f"No configuration files for category {category}.")
return {}
else:
return categories[category]["conffiles"]
def _update_conf_hashes(category, hashes):
"""Update the registered conf hashes for a category"""
logger.debug(f"updating conf hashes for '{category}' with: {hashes}")
categories = _get_regenconf_infos()
category_conf = categories.get(category, {})
# Handle the case where categories[category] is set to null in the yaml
if category_conf is None:
category_conf = {}
# If a file shall be removed and is indeed removed, forget entirely about
# that path.
# It avoid keeping weird old entries like
# /etc/nginx/conf.d/some.domain.that.got.removed.conf
hashes = {
path: hash_
for path, hash_ in hashes.items()
if hash_ is not None or os.path.exists(path)
}
category_conf["conffiles"] = hashes
categories[category] = category_conf
_save_regenconf_infos(categories)
def _force_clear_hashes(paths):
categories = _get_regenconf_infos()
for path in paths:
for category in categories.keys():
if path in categories[category]["conffiles"]:
logger.debug(
f"force-clearing old conf hash for {path} in category {category}"
)
del categories[category]["conffiles"][path]
_save_regenconf_infos(categories)
def _process_regen_conf(system_conf, new_conf=None, save=True):
"""Regenerate a given system configuration file
Replace a given system configuration file by a new one or delete it if
new_conf is None. A backup of the file - keeping its directory tree - will
be done in the backup conf directory before any operation if save is True.
"""
if save:
system_conf_ = system_conf.lstrip("/")
now_ = datetime.utcnow().strftime("%Y%m%d.%H%M%S")
backup_path = os.path.join(BACKUP_CONF_DIR, f"{system_conf_}-{now_}")
backup_dir = os.path.dirname(backup_path)
if not os.path.isdir(backup_dir):
mkdir(backup_dir, 0o755, True)
shutil.copy2(system_conf, backup_path)
logger.debug(
m18n.n("regenconf_file_backed_up", conf=system_conf, backup=backup_path)
)
try:
if not new_conf:
os.remove(system_conf)
logger.debug(m18n.n("regenconf_file_removed", conf=system_conf))
else:
system_dir = os.path.dirname(system_conf)
if not os.path.isdir(system_dir):
mkdir(system_dir, 0o755, True)
shutil.copyfile(new_conf, system_conf)
logger.debug(m18n.n("regenconf_file_updated", conf=system_conf))
except Exception as e:
logger.warning(
f"Exception while trying to regenerate conf '{system_conf}': {e}",
exc_info=1,
)
if not new_conf and os.path.exists(system_conf):
logger.warning(
m18n.n("regenconf_file_remove_failed", conf=system_conf), exc_info=1
)
return False
elif new_conf:
try:
# From documentation:
# Raise an exception if an os.stat() call on either pathname fails.
# (os.stats returns a series of information from a file like type, size...)
copy_succeed = os.path.samefile(system_conf, new_conf)
except Exception:
copy_succeed = False
finally:
if not copy_succeed:
logger.warning(
m18n.n(
"regenconf_file_copy_failed", conf=system_conf, new=new_conf
),
exc_info=1,
)
return False
return True
def manually_modified_files():
output = []
regenconf_categories = _get_regenconf_infos()
for category, infos in regenconf_categories.items():
conffiles = infos["conffiles"]
for path, hash_ in conffiles.items():
if hash_ != _calculate_hash(path):
output.append(path)
return output
def manually_modified_files_compared_to_debian_default(
ignore_handled_by_regenconf=False,
):
# from https://serverfault.com/a/90401
files = check_output(
"dpkg-query -W -f='${Conffiles}\n' '*' \
| awk 'OFS=\" \"{print $2,$1}' \
| md5sum -c 2>/dev/null \
| awk -F': ' '$2 !~ /OK/{print $1}'"
)
files = files.strip().split("\n")
if ignore_handled_by_regenconf:
regenconf_categories = _get_regenconf_infos()
regenconf_files = []
for infos in regenconf_categories.values():
regenconf_files.extend(infos["conffiles"].keys())
files = [f for f in files if f not in regenconf_files]
return files