Der Eintrag von Servernamen in /etc/hosts
genügt, um aus einem Redaktionssystem aus Sicht des Browsers einen namentlich ansprechbaren Webserver zu machen, aber auch ambitionierte Amateurinnen sollten nicht auf ein getrenntes System verzichten. Statt DNSMasq oder einem teuren Router kann man unter Ubuntu Bind9 konfigurieren, um eine eigene TLD im lokalen Netz zu etablieren:
# /etc/bind/named.conf.local
zone "mytld" {
type master;
file "/etc/bind/db.mytld";
};
# /etc/bind/named.conf.options
options {
directory "/var/cache/bind";
dnssec-validation auto;
validate-except { "mytld"; };
forwarders { 1.1.1.1; };
listen-on { 127.0.0.1; 192.168.78.0/24; };
listen-on-v6 { any; };
};
# /etc/bind/db.mytld
$TTL 604800
@ IN SOA ns.mytld. root.mytld. (
5 ; Serial
604800 ; Refresh
86400 ; Retry
2419200 ; Expire
604800 ) ; Negative Cache TTL
;
@ IN NS ns.mytld.
* IN A 192.168.78.42
Als Nameserver für alle anderen Domains (forwarders
) kommt Cloudflare (1.1.1.1) zum Einsatz, und die DNSSEC-Validierung wird für die neue TLD deaktiviert. Interessanterweise hält diese Konfiguration Bind9 nicht davon ab, sich bei Cloudflare (erfolglos) nach dem Delegation Signer zu erkundigen:
16:28:34.165569 IP myserver.49728 > one.one.one.one.domain: 45669+% [1au] DS? mytld. (46)
16:28:34.179688 IP one.one.one.one.domain > myserver.49728: 45669 NXDomain* 0/6/1 (1026)
Wenn nun der neue DNS-Server auf einem lokalen Netgear-Router hinterlegt wird, bringt man sich um die Segnungen von DNSSEC, weil Netgear DNSSEC-Unterstützung im privaten Bereich offenbar für unnötig hält. Stattdessen müssen alle lokalen Endgeräte (solange sie sich im heimischen LAN/WLAN aufhalten) direkt mit dem DNS-Server und Cloudflare als Fallback interagieren.
Erstaunlich schwierig ist es, den Server zum dogfooding zu bewegen: Er erhält per DHCP die IP-Adresse des Routers und verwendet sie auch für DNS-Anfragen. Trägt man die Loopback-Adresse 127.0.0.1 in /etc/systemd/resolved.conf
ein, meldet resolvectl status
zwar:
Global
Protocols: -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
resolv.conf mode: stub
Current DNS Server: 127.0.0.1
DNS Servers: 127.0.0.1
aber wegen der fehlenden netplan-Konfiguration auch:
Link 2 (eno1)
Current Scopes: DNS
Protocols: +DefaultRoute +LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Current DNS Server: 192.168.78.1
DNS Servers: 192.168.78.1
Abhilfe schaffen – anders als von Kumar getestet – nur folgende Einträge in /etc/netplan/00-installer-config.yaml
:
network:
ethernets:
eno1:
dhcp4: true
dhcp4-overrides:
use-dns: false
nameservers:
addresses: [127.0.0.1]
version: 2
in Verbindung mit DNSSEC=yes
in /etc/systemd/resolved.conf
:
Global
Protocols: -LLMNR -mDNS -DNSOverTLS DNSSEC=yes/supported
resolv.conf mode: stub
Link 2 (eno1)
Current Scopes: DNS
Protocols: +DefaultRoute +LLMNR -mDNS -DNSOverTLS DNSSEC=yes/supported
Current DNS Server: 127.0.0.1
DNS Servers: 127.0.0.1
Den autoritativen Nameserver für eine TLD zu betreiben, ist ein erhebendes Gefühl (trotz kleiner Schwierigkeiten mit modernen HTTPS-Einträgen).
Gut ein Jahr nach dem Rückbau des Webstacks auf meinem hessischen Server siegt mein Basteltrieb über den Homo oeconomicus, und ich repliziere meine Django-Betriebsumgebung auf einem lokalen Ubuntu-Server. Die Installation gestaltet sich zunächst sehr einfach –
apt install nginx
apt install postgresql
– bis der Datenbankkonnektor über eine fehlende Header-Bibliothek stolpert. Daher:
apt install install libpq-dev
apt install psycopg2
In Bezug auf das Sorgenkind uwsgi bietet apt (anders als Homebrew) ein Plugin-Paket für Python 3, so dass ich nicht auf pip und die Definition einer eigenen Service Unit zurückgreifen muss:
apt install uwsgi
apt install uwsgi-plugin-python3
Die initiale Anmeldung am PostgreSQL-Server erfolgt neuerdings über das Peer-Verfahren, so dass ich zunächst das Systempasswort für postgres
ändern muss, um mich anschließend in der Rolle dieser Nutzerin passwortlos anmelden zu können:
sudo passwd postgres
su - postgres
psql
postgres=# CREATE USER myuser WITH PASSWORD 'snafu';
postgres=# GRANT ALL ON DATABASE db1 TO myuser;
postgres=# \q
exit
psql db1 < ~/var/dbdump/db1.sql
Die Änderung der Authentifikationsmethode von peer
auf scram-sha-256
in /etc/postgresql/14/main/pg_hba.conf
für lokale Nutzerinnen ermöglicht die Verwendung des Passworts snafu
auch ohne eine Systemnutzerin myuser
.
Nun folgt die Installation des Webframeworks –
pip3 install Django
und der Transfer der Projektdaten auf den Server. Abschließend müssen lediglich geeignete Konfigurationsdateien in /etc/uwsgi/apps-enabled
bzw. /etc/nginx/sites/enabled
hinterlegt und alle relevanten Services gestartet, ~/.pgpass
angelegt und die Umgebungsvariablen PGHOST
, PGUSER
und PGDATABASE
in ~/.bashrc
eingetragen werden.
Natürlich sollte die Synchronisation des Datenbestandes zwischen Redaktionssystem und Server weitgehend automatisiert ablaufen:
#!/usr/local/bin/python3
import os
import subprocess
import argparse
import paramiko
local_dir = '/Users/snafu/website/dbdump/'
remote_dir = '/var/www/djangosite/dbdump/'
db = 'mydb'
dbuser = 'myuser'
extension = '.sql'
dumpfile = local_dir + db + extension
targetfile = remote_dir + db + extension
configparser = paramiko.SSHConfig()
configparser.parse(open('/Users/snafu/.ssh/config'))
ssh_config = configparser.lookup('myserver')
ssh_key = paramiko.ECDSAKey.from_private_key(open(ssh_config['identityfile'][0]))
def print_status(return_code):
if return_code == 0:
print('done.')
else:
print('failed!')
def dump_db():
print('Dumping local database...')
with open(dumpfile, 'w') as writefile:
completed_process = subprocess.run(['pg_dump', '-E', 'UTF8', '-c', db], stdout=writefile)
print_status(completed_process.returncode)
def transfer_db():
print('Sending database dump to server ...')
transport = paramiko.Transport((ssh_config['hostname'],int(ssh_config['port'])))
transport.connect(username=ssh_config['user'], pkey=ssh_key)
sftp = paramiko.SFTPClient.from_transport(transport)
try:
sftp.put(dumpfile,targetfile)
print_status(0)
except:
print_status(1)
if sftp: sftp.close()
if transport: transport.close()
def update_db():
print('Updating remote database ...')
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(ssh_config['hostname'], username=ssh_config['user'], port=ssh_config['port'], key_filename=ssh_config['identityfile'][0])
stdin, stdout, stderr = client.exec_command(f'psql -U {dbuser} {db} < {targetfile}')
return_code = stdout.channel.recv_exit_status()
print_status(return_code)
client.close()
def call_func(function_name):
dispatcher = { 'dump_db' : dump_db, 'transfer_db' : transfer_db, 'update_db' : update_db }
return dispatcher[function_name]()
def main():
p = argparse.ArgumentParser(description='Synchronize website database')
p.add_argument('-d', '--dump', dest='actions', action='append_const', const='dump_db')
p.add_argument('-t', '--transfer', dest='actions', action='append_const', const='transfer_db')
p.add_argument('-u', '--update', dest='actions', action='append_const', const='update_db')
arguments = p.parse_args()
if arguments.actions:
arguments.actions.sort()
for action in arguments.actions:
call_func(action)
if __name__=='__main__':
main()
Die Abfrage des return codes (return_code = stdout.channel.recv_exit_status()
) in update_db()
dient nicht nur dazu, das Skript kommunikativer zu gestalten, sondern wandelt exec_command()
auch in einen blocking call (alternativ könnte man den psql
-Aufruf auch mit nohup
dekorieren, damit die Datenbankaktualisierung auch nach dem Schließen der SSH-Verbindung ausgeführt wird). Warum Transport()
einen bereits ausgelesenen SSH-Schlüssel als Parameter benötigt, SSHClient()
aber nur den Dateinamen, bleibt dagegen das Geheimnis der Paramiko-Entwicklerinnen.
Eigentlich ist die Verheißung einer stabilen Terminal-Verbindung bei wechselnden Internet-Anbindungen für meine relativ statischen SSH-Gepflogenheiten nicht relevant, aber mit einigen Jahren Verspätung möchte ich natürlich auch den hot shit von 2012 verwenden. Die Installation von mosh ist mit Homebrew oder apt auf Client und Server denkbar einfach, und in der Firewall des kontaktierten Servers müssen lediglich einige UDP-Ports (60000-61000) geöffnet werden, um eine mosh-Session zu erlauben.
Mit einem MacBook als Server ist es etwas komplizierter. Der Eintrag von mosh-server
in die Firewall-Konfiguration auf dem üblichen Weg (System Preferences → Security & Privacy → Firewall → Firewall Options) hat unter macOS Monterey keinen Effekt, während eine Konfiguration per CLI funktioniert. Ich verzichte auf die Erstellung eines LaunchDaemons und kombiniere stattdessen die Aktivierung des SSH-Daemons, die Firewall-Konfiguration und die Konfiguration servergemäßer Schlafgewohnheiten in zwei Shell-Skripten:
# moshon.sh
#!/bin/zsh
sudo systemsetup -f -setremotelogin on
local fw='/usr/libexec/ApplicationFirewall/socketfilterfw'
local mosh_sym="$(which mosh-server)"
local mosh_abs="$(readlink -f $mosh_sym)"
sudo "$fw" --setglobalstate off
sudo "$fw" --add "$mosh_sym"
sudo "$fw" --unblockapp "$mosh_sym"
sudo "$fw" --add "$mosh_abs"
sudo "$fw" --unblockapp "$mosh_abs"
sudo "$fw" --setglobalstate on
sudo pmset -a sleep 0
sudo pmset -a hibernatemode 0
sudo pmset -a disablesleep 1;
# moshoff.sh
#!/bin/zsh
sudo systemsetup -f -setremotelogin off
local fw='/usr/libexec/ApplicationFirewall/socketfilterfw'
local mosh_sym="$(which mosh-server)"
local mosh_abs="$(readlink -f $mosh_sym)"
sudo "$fw" --setglobalstate off
sudo "$fw" --remove "$mosh_sym"
sudo "$fw" --remove "$mosh_abs"
sudo "$fw" --setglobalstate on
sudo pmset -a sleep 1
sudo pmset -a hibernatemode 3
sudo pmset -a disablesleep 0;
Endlich kann ich von meinem privaten Golfplatz aus eine mosh-Session zwischen iPad und MacBook aufbauen, während bislang mein Caddy das MacBook mitführen musste. In der Realität ist mosh für Terminal-Verbindungen im lokalen Netzwerk (oder auch zu einem gut angebundenen Rechenzentrum in Baden-Baden) recht überflüssig, aber für künftige Services mit sehr hohen Latenzen oder Reisen durch die deutsche Provinz ist mosh die perfekte Lösung. Mit Blink gibt es auch eine iOS-App mit mosh-Unterstützung, die das schöne, aber hakelige Prompt ablösen kann, das aktuell an SSH-Verbindungen zu Ubuntu 22.04 mit ECDSA-Schlüsseln scheitert.
Wenige Tage nach dem Umstieg von der Frettkatze auf die Qualle ereilt mich ein Problem, das durch die unterschiedliche Konfiguration von rsync bei zwei aufeinander folgenden Aufrufen verursacht wird: Der erste Durchlauf (rsync -av
) überträgt den Symlink /etc/postfix/makedefs.out -> /usr/share/postfix/makedefs.out
als Symlink, während der zweite Durchlauf (rsync -ivrLtzS
) auf einem anderen System den Symlink auflösen möchte und am fehlenden Referenten scheitert.
Entgegen meiner generellen Risikoaversion aktualisiere ich am Veröffentlichungstag von Ubuntu 22.04 LTS nicht nur meine Homeserver, sondern auch die Mail- und Webserver (do-release-upgrade -d
). Zum Glück gibt es keine besonderen Vorkommnisse. Ich muss lediglich meine sorgfältig kuratierten Konfigurationsdateien vor dem Überschreiben durch Standard-Konfigurationsdateien schützen (keep the local version currently installed) und eine obsolete Datei (/etc/dovecot/conf.d/auth-vpopmail.conf.ext
) entfernen. Zeitgleich mit dem Upgrade liefert mir der Certbot erstmals neue Schlüssel und damit die Gelegenheit zum Test der Fingerabdruckautomatik unter realen Bedingungen. Die neue nginx-Konfigurationsdatei warnt vor einer Sicherheitslücke durch die Verbindung von Kompression und sensiblen Eingaben, aber dank des Verzichts auf jegliche Interaktivität ist meine Website nicht betroffen.
Apple nimmt die Veröffentlichung der glücklichen Qualle übrigens zum Anlass, die Reste seines eigenen Server-Betriebssystems zu den Fischen zu schicken.
Wenn ich gewusst hätte, dass mit einer kleinen Änderung in /etc/logrotate.d/nginx
(rotate 14
→ rotate 31
) und dem monatlich ausgeführten Befehl
gunzip -c /var/log/nginx/access.log.*.gz | goaccess --log-format=COMBINED -o analytics_2022-02.html
aufschlussreiche Auswertungen möglich sind, hätte ich nicht 16 Jahre ohne jegliche Traffic-Analyse verstreichen lassen, zumal sich die monatliche Auswertung natürlich automatisieren lässt:
#!/usr/local/bin/python3
import os
import shutil
import glob
import subprocess
import datetime
import gzip
logfiles_pattern = '/var/log/nginx/access.log.*.gz'
log_archive = '/var/www/analytics'
last_month = datetime.date.today().replace(day=1) - datetime.timedelta(days=1)
last_month_string = last_month.strftime('%Y-%m')
target_dir = f'{log_archive}/{last_month_string}'
combined_log = f'{target_dir}/combined.log'
report = f'{log_archive}/{last_month_string}_report.html'
try:
os.mkdir(target_dir)
except:
print(f'{target_dir} exists, exiting.')
exit()
file_content = b''
for file in glob.glob(logfiles_pattern):
with gzip.open(file, 'rb') as f:
file_content += f.read()
shutil.move(file, target_dir)
with open(combined_log, 'wb') as f:
f.write(file_content)
command_list = ['goaccess', combined_log, '--log-format=COMBINED', '-o', report]
subprocess.run(command_list)
os.remove(combined_log)
Leider fordert GoAccess eine ausgesprochen riskante Content Security Policy ein (Content-Security-Policy "script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'"
), um seine dynamischen HTML-Reports per Webserver ausliefern und darstellen zu lassen, aber der lokale Aufruf funktioniert auch ohne Sicherheitsrisiken.
Jedenfalls zeigt der Report in unangenehmer Deutlichkeit, wie konstant die Popularität meiner Website seit der letzten Auswertung mit Webalizer im Jahr 2006 ist: Von knapp 1400 Besucherinnen/Tag habe ich mich auf rund 1500 Besucherinnen/Tag gesteigert. Zwar haben sich die täglichen Aufrufe etwa verdoppelt (von 10.000 auf 20.000), aber das ist eher als Rückschritt zu betrachten. Außerdem weist GoAccess den Anteil der Crawler (54,12% der Besucherinnen, 82,83% der Aufrufe, 76,39% des Traffics) aus, so dass ich mich wohl von meinem Berufsziel Influencer verabschieden kann.
Wann immer man glaubt, nun könne das Erzbistum Köln seine Kirchenaustrittskampagne nicht mehr intensivieren, überrascht Rainer Maria Woelki die Öffentlichkeit mit neuen Ideen. Im aktuellen Fall wurde die Fürsorgepflicht für einen gestrauchelten Mitbruder sehr weit ausgelegt, um zunächst dessen Spielschulden und anschließend die aus dieser Zahlung erwachsene Steuerschuld zu begleichen, natürlich an den mittlerweile auch in Kirchenkreisen existierenden Kontrollgremien vorbei, und teilweise (500.000 Euro) aus dem Sondervermögen für Ausgleichszahlungen an Opfer sexuellen Missbrauchs.
Dank der überzeugenden Argumente gegen die Zahlung von Kirchensteuern und der großzügigen Alimentation des Kirchenpersonals dürfte der schweigsame Herr Woelki bald zu Verhandlungen mit RTL gezwungen sein (Arbeitstitel: Sodom und Gomorrha op Kölsch
). Wenn der Kirchendude
das Ruder nicht noch herumreißt.
Obwohl respektable deutsche Hosting-Provider in kurzen Abständen schlecht kommunizierte (wenn auch vergleichsweise kurze) Strom- und Hardware-Ausfälle verzeichnen, sind sie nach wie vor vertrauenswürdiger als cloudgestützte Datenhändler. Ich glaube zwar nicht, dass sich Milliarden Nutzerinnen sozialer Netzwerke demnächst Wordpress-Instanzen einrichten, aber vielleicht ist Anil Dashs Optimismus angesichts der wachsenden Unzufriedenheit mit aufmerksamkeitsorientierten Algorithmen und den auf ihnen basierenden Geschäftsmodellen nicht völlig unbegründet:
On your own site, though, under your own control, you can do things differently. Build the community you want. I'm not a pollyanna about this; people are still going to spend lots of times on the giant tech platforms, and not everybody who embraces the open web is instantly going to become some huge hit. Get your own site going, though, and you’ll have a sustainable way of being in control of your own destiny online.
Todd Ditchendorfs praktischer HTTP Client für macOS wurde seit vielen Jahren nicht aktualisiert und agiert mittlerweile so wacklig, dass Todd selbst andere (und teilweise erstaunlich kostspielige) Anwendungen empfiehlt. In dieser Situation kommt ein Missgeschick von Jakub Roztočil wie gerufen, indem es mich auf das Python-basierte HTTPie for Terminal aufmerksam macht.
John Regehr hat schon vor vielen Jahren beobachtet, dass erfahrene und erfolgreiche Software-Nutzerinnen vor allem gut dressiert sind. Mit den folgenden Verhaltensweisen –
- periodically restart operating systems and applications to avoid software aging effects,
- avoid interrupting the computer when it is working (especially when it is installing or updating programs) since early-exit code is pretty much always wrong,
- do things more slowly when the computer appears overloaded—in contrast, computer novices often make overload worse by clicking on things more and more times,
- avoid too much multitasking,
- avoid esoteric configuration options,
- avoid relying on implicit operations, such as the fact that MS Word is supposed to ask us if we want to save a document on quit if unsaved changes exist.
– unterscheiden sie sich von störrischen Menschen, die auf instabile Zustände von Computersystemen nicht mit Geduld und Vorsicht reagieren, und die sich weigern, Problemvermeidungsstrategien zu erlernen. Unglücklicherweise arbeiten diese Menschen selten als Software-Testerinnen, sondern leben ihre besondere Begabung aus, indem sie Help Desk-Mitarbeiterinnen mit vagen Beschreibungen schlecht reproduzierbarer Fehler beschäftigen.
uwsgi bleibt das Sorgenkind in meinem Webstack. Mit dem heutigen Update der Homebrew-Pakete wird neben Python 3.10.2 auch ein von Python 3.10.2 abhängiges uwsgi 2.0.20 installiert, was im Zusammenspiel mit Python 3.9.12 und Django 4.0.2 zu Problemen führt. Zum Glück hilft die bereits erprobte Deinstallation (brew uninstall uwsgi; pip3 uninstall uwsgi
), diesmal in Verbindung mit einem rein pip-basierten Neuanfang (pip3 install uwsgi --no-cache
), denn pip3 fühlt sich Python 3.9.12 weiterhin verbunden.
Für den automatischen Start von uwsgi übertrage ich die Inhalte von homebrew.mxcl.uwsgi.plist
nach snafu.localhotel.uwsgi.plist
und kann fürderhin auf Homebrew verzichten (jedenfalls in Bezug auf uwsgi).
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
<string>snafu.localhotel.uwsgi</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/uwsgi</string>
<string>--uid</string>
<string>_www</string>
<string>--gid</string>
<string>_www</string>
<string>--master</string>
<string>--die-on-term</string>
<string>--autoload</string>
<string>--logto</string>
<string>/opt/homebrew/var/log/uwsgi.log</string>
<string>--emperor</string>
<string>/opt/homebrew/etc/uwsgi/apps-enabled</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>WorkingDirectory</key>
<string>/opt/homebrew</string>
</dict>
</plist>
BBEdit wird heute 30 Jahre alt, und es gab eine Zeit, in der allein dieser Editor ein wichtiges Argument gegen einen Wechsel auf andere Plattformen war (heute binden mich vor allem mobile Endgeräte und Cloud-Dienste). Seit mehr als 10 Jahren sind nun vim und mutt meine wichtigsten Werkzeuge, zumal der Slogan This one just sucks less
besser zu meinem empirischen Realismus passt als das apodiktische It doesn't suck
.
Trotzdem erwerbe ich eine Lizenz für jede neue BBEdit-Version, weil Barebones Software ausgesprochen freundlich und großzügig ist. Apple dagegen zeigt mit jeder lizenzbedingten Entscheidung, dass gute Texteditoren keine relevante Kategorie für absurd profitable IT-Unternehmen sind. Positiv betrachtet: Veraltete Unix-Tools im macOS-Lieferumfang kollidieren immer seltener mit aktuellen Homebrew-Versionen.
Unter tausenden Wikipedia-Änderungen aus deutschen Behördennetzen finden sich neben einigen Rechtsverstößen auch verzweifelte Versuche, den ehrbaren Beamtinnenstand vor Karrieristinnen zu bewahren:
Mit Ausnahme der Besoldungsgruppe B 1, die der A 15 in der Endstufe entspricht, sind in der Besoldungsordnung B die Ämter des Spitzenpersonals des öffentlichen Dienstes zu finden. Die Besoldung aller nach B 2 aufwärts eingestuften Ämter ist höher als die der Ämter nach der Besoldungsordnung A. Nur ein Bruchteil der Beamten des höheren Dienstes erreicht im Laufe ihrer Karriere Ämter, die nach B 2 aufwärts eingestuft sind. Das Erreichen solcher Ämter bedeutet daher eine Karriere, die eher außergewöhnlich gut verläuft. Man sollte beim Eintritt in den öffentlichen Dienst nicht davon ausgehen, solche Ämter erreichen zu können.
Die Sichtung dieser wichtigen Ergänzung vom 5. März 2022, 21:33 Uhr, seitens der IP-Adresse 2003:c7:729:4639:3124:c70c:df83:5dc9 steht noch aus.
Leider kann ein Staat wie Deutschland bei sinkendem Wohlstand kaum mehr tun als Armenspeisungen nicht zu behindern und Fruchtbarkeit in den unteren Klassen zu belohnen, aber vielleicht ist die gehobene Mittelschicht ja bereit, sich mildtätig zu zeigen.
Die Absicherung von Backups gegen heimtückische Modifikationen von Informationswerten hilft natürlich nicht gegen versehentliche Löschungen und andere Ungeschicklichkeiten, wenn jeweils das bestehende Backup durch ein neues vollständig überschrieben wird. Eine inkrementelle Backup-Methode ermöglicht dagegen die regelmäßige Sicherung eines veränderten Datenbestands, ohne die vorangehenden Sicherungen zu beeinträchtigen.
Bis vor kurzem bin ich davon ausgegangen, dass ich entweder rsync nutzen oder inkrementelle Backups erstellen kann. Tatsächlich bietet rsync (natürlich!) mit dem unscheinbaren Parameter --link-dest
eine Möglichkeit, die zu sichernden Daten mit einem bestehenden Ziel-Verzeichnis abzugleichen und für alle unveränderten Dateien lediglich einen zusätzlichen hard link zu setzen. Mein Wrapper rbackup muss wie folgt ergänzt werden:
class BackupProfile:
# ...
@property
def target_dir(self):
if self.incremental:
return f'{self.target_path}/{datetime.date.today().strftime("%Y-%m-%d")}'
else:
return self.target_path
@property
def link_dest(self):
if self.incremental:
return [f'--link-dest=../latest']
else:
return []
# ...
def backup(self):
print(f'Executing backup profile "{self.label}" ...')
self.ransom()
command_list = ["/opt/homebrew/bin/rsync", "-e", "ssh"]
command_list.extend(self.options)
command_list.extend(self.link_dest)
command_list.extend(self.excludepath)
command_list.extend([self.source, self.target_dir])
return_code = subprocess.call(command_list)
self.rotate()
../latest
ist ein Symlink auf das jeweils aktuellste Zielverzeichnis, er muss also initial von Hand erstellt (ln -s /volume1/backup/YYYY-MM-DD latest
) und nach jeder Sicherung aktualisiert werden (mit der Funktion self.rotate()
). Für lokale Speichermedien ist das sehr simpel (os.unlink()
und os.symlink()
), aber für serverbasierte Backups muss eine SFTP-Verbindung her:
def ssh_key(self, id_file):
if hasattr(self, 'keytype'):
if self.keytype == 'ecdsa':
return paramiko.ECDSAKey.from_private_key(open(id_file))
else:
return paramiko.RSAKey.from_private_key(open(id_file))
else:
return False
def rotate(self):
if self.incremental:
if hasattr(self, 'target_host'):
my_config = paramiko.SSHConfig()
my_config.parse(open('/Users/jan/.ssh/config'))
conf = my_config.lookup(self.target_host)
transport = paramiko.Transport((conf['hostname'],int(conf['port'])))
transport.connect(username=conf['user'], pkey=self.ssh_key(conf['identityfile'][0]))
sftp = paramiko.SFTPClient.from_transport(transport)
sftp.unlink(self.sftp_latest_dir)
sftp.symlink(self.target_dir, self.sftp_latest_dir)
sftp.close()
transport.close()
else:
os.unlink(self.latest_dir)
os.symlink(self.target_dir, self.latest_dir)
Wenn man ein Synology NAS zur Sicherung nutzt, gibt es eine weitere Besonderheit: SSH-Verbindungen erfordern eine vollständige Pfadangabe samt Volume (z.B. /volume1/backup
), für SFTP-Verbindungen dagegen wird der jeweilige shared folder als Root-Verzeichnis betrachtet (/backup
). Der Symlink in unserer Konstellation muss daher als /backup/latest
aktualisiert werden, aber auf /volume1/backup/YYYY-MM-DD
verweisen (andernfalls findet rsync den Vergleichsordner beim nächsten Durchlauf nicht). Deshalb bedarf es eines ausgesprochen schmutzigen Hacks, um self.sftp_latest_dir
abhängig vom Zielsystem zu generieren (während self.target_dir
für jedes Zielsystem unverändert genutzt werden kann):
@property
def sftp_latest_dir(self):
if hasattr(self, 'target_host'):
if self.target_host.startswith('synology'):
return self.latest_dir[8:]
else:
return self.latest_dir
Eventuell werde ich demnächst vom interstellaren Servicedesk hinter Synology abgeholt.
Um den Platzbedarf der einzelnen Backup-Verzeichnisse zu eruieren, lässt sich das Verhalten eines altgedienten Unix-Tools bei Verwendung mehrerer Argumente ausnutzen (Files having multiple hard links are counted (and displayed) a single time per du execution.
) –
snafu@localhost % du -sh . 2022-04-05 2022-04-07 2022-04-08
27G .
27G 2022-04-05
60M 2022-04-07
177M 2022-04-08
– und neue/geänderte Dateien im aktuellsten Backup-Verzeichnis lassen sich als diejenigen identifizieren, für die nur ein Link existiert (find . -type f -links 1 -print
).
Eine Backup-Strategie, die mit maximalem Hardware-Einsatz regelmäßig einen korrumpierten Datenbestand sichert, wiegt die Nutzerin in trügerischer Sicherheit. Um unautorisierte Manipulationen von Dateien vor einem Backup zu erkennen, bietet sich daher ein Integritätsmonitor auf Basis kryptographischer Hashes an. Zunächst werden die Hashes aller relevanten Dateien gebildet und gespeichert (in der Hoffnung, dass zu diesem Zeitpunkt noch keine Ransomware auf dem System gewütet hat):
#!/usr/local/bin/python3
import hashlib
import json
import os
hashpath = '/hashes.json'
docpath = '/home/snafu/documents'
def get_hash(file_path):
myhash = hashlib.sha256()
with open(file_path,'rb') as file:
content = file.read()
myhash.update(content)
return myhash.hexdigest()
def create_baseline():
with open(hashpath, 'r') as f:
hash_dict = json.load(f)
for current_dir, subdirs, files in os.walk(docpath):
for filename in files:
relative_path = os.path.join(current_dir, filename)
absolute_path = os.path.abspath(relative_path)
barename, extension = os.path.splitext(filename)
if not filename.startswith('.') and os.path.isfile(absolute_path):
if absolute_path in hash_dict:
if hash_dict[absolute_path] == get_hash(absolute_path):
continue
else:
selection = input(f'Hash for file {absolute_path} does not match baseline. Update hash and proceed (y) or cancel (n)? ')
if selection == 'n':
print(f'Not updating hash for file {absolute_path}')
continue
print(f'Updating hash/creating new hash for file {absolute_path}...')
hash_dict[absolute_path] = get_hash(absolute_path)
for absolute_path in list(hash_dict):
if not os.path.isfile(absolute_path):
print(f'Removing hash for deleted file {absolute_path}...')
del hash_dict[absolute_path]
with open(hashpath, 'w') as f:
json.dump(hash_dict, f)
if __name__ == '__main__':
create_baseline()
Ab dem zweiten Durchlauf enthält die Datei hashes.json
den Maßstab für nicht manipulierte Dateien; jede Änderung muss bei der Aktualisierung von hashes.json
manuell akzeptiert werden. Diese Herangehensweise eignet sich offensichtlich nur für relativ statische Verzeichnisse, in denen vor allem neue Dateien abgelegt werden, und sie ist inkompatibel mit der automatischen Aktualisierung von Dateien (launchctl unload my.localhotel.serversync.plist
).
Mit Hilfe von hashes.json
kann jedenfalls zum Backup-Zeitpunkt geprüft werden, ob alle Dateien integer sind (unter Verwendung der Funktion get_hash()
aus dem obigen Skript):
#!/usr/local/bin/python3
import os
import json
import pathlib
from fhashlib import get_hash
import scriptmail
hashpath = '/hashes.json'
docpath = '/home/snafu/documents'
def compare_to_baseline():
print('Checking for hash mismatches...')
warnings = ''
with open(hashpath, 'r') as f:
hash_baseline = json.load(f)
for current_dir, subdirs, files in os.walk(docpath):
for filename in files:
relative_path = os.path.join(current_dir, filename)
absolute_path = os.path.abspath(relative_path)
barename, extension = os.path.splitext(filename)
if not filename.startswith('.') and os.path.isfile(absolute_path):
if absolute_path in hash_baseline and hash_baseline[absolute_path] != get_hash(absolute_path):
warnings += f'File {absolute_path} has been tampered with.\n'
if warnings:
subject = 'Alert – file hash mismatch!'
message = warnings
return_code = 1
else:
subject = 'No hash mismatch.'
message = subject
return_code = 0
scriptmail.send_message(subject, message)
return return_code
Ein gesundes Misstrauen prüft aber nicht nur bekannte Dateien, sondern sucht auch nach zwielichtigen Mustern (*want your files back.*
):
patternpath = '/patterns.json'
with open(patternpath, 'r') as f:
patterns = json.load(f)
def check_files():
print('Checking for ransomware file patterns...')
warnings = ''
p = pathlib.Path(docpath)
for pattern in patterns:
result_list = sorted(p.rglob(pattern))
if result_list:
warnings += f'Ransomware filename pattern {pattern} is present in {docpath}:\n{result_list}\n'
if warnings:
subject = 'Alert – ransomware file pattern detected!'
message = warnings
return_code = 1
else:
subject = 'No ransomware file pattern detected.'
message = subject
return_code = 0
scriptmail.send_message(subject, message)
return return_code
Die beiden Funktionen check_files()
und compare_to_baseline()
können in rbackup integriert werden und verhindern bei Bedarf die Datensicherung:
import ransomguard
def ransom(self):
if hasattr(self, 'ransomware_check'):
if ransomguard.check_files():
print('Ransomware pattern found, not backing up!')
exit(1)
if ransomguard.compare_to_baseline():
print('Hash mismatch, not backing up!')
exit(1)
print('Proceeding to backup...')
Der zeitliche Aufwand für check_files()
lässt sich wieder einsparen, indem man ohne Integritätseinbuße bei der Dateiübertragung auf den rechenintensiven rsync-Parameter --checksum
–
Generating the checksums means that both sides will expend a lot of disk I/O reading all the data in the files in the transfer, so this can slow things down significantly (and this is prior to any reading that will be done to transfer changed files)
– verzichtet, und die Verwendung von SHA256 statt MD5 in get_hash()
spart ebenfalls wertvolle Sekunden (create_baseline()
– MD5: 4.50s user 0.76s system 72% cpu 7.263 total | SHA256: 1.11s user 0.84s system 48% cpu 4.035 total).
Ein gezielter Angriff würde sich den beiden Verteidigungslinien widmen und die Skripte selbst bzw. die JSON-Dateien manipulieren, aber ich vertraue darauf, dass ich kein lohnendes Ziel für PLA Unit 61398 oder Fancy Bear bin.
Wenn man Kriegsverbrechen vor einem internationalen Gericht anklagen lassen möchte, sollte man die Legitimität und Autorität dieses Gerichtes anerkennen.
Das Erzbistum Köln wird nicht müde, negative Nachrichten zu produzieren. Neben Meldungen aus den Registern bestürzend/abstoßend und erbärmlich wird die über Jahrtausende perfektionierte üble Nachrede auch gegenüber dem Papst –
Mir liegt es vollkommen fern, den Papst als einen alten und realitätsfremden Mann darzustellen
– zelebriert, und der großzügige Umgang mit staatlichen Mitteln korrespondiert mit einer ausgesprochen klandestinen Haushaltsführung. Immerhin: Durch einen Verkauf der Fernsehrechte könnte Herr Woelki sich langfristig aller finanziellen Sorgen entledigen.
Offenbar musste ich durch meinen Studienabschluss in den Wirren der Nachkriegszeit nicht nur auf Segnungen wie ein Transcript of Records oder ein Diploma Supplement verzichten, sondern auch auf eine individuelle Wortverantwortung.
Die Änderung des SSH-Ports ist auf normalen Systemen eine Banalität (sudo vi /etc/ssh/sshd_config
), erfordert aber unter macOS Monterey gewisse Verrenkungen. Änderungen in /private/etc/ssh/sshd_config
helfen allein nicht, stattdessen muss dem System sehr grundsätzlich in /etc/services
mitgeteilt werden, über welchen Port SSH-Verbindungen ablaufen sollen.
Als Alternative hätte eine Modifikation des zuständigen LaunchDaemons (/System/LaunchDaemon/ssh.plist
) in früheren Zeiten funktioniert, aber dank der System Integrity Protection sind die Möglichkeiten im Bereich /System
mittlerweile sehr begrenzt.
Die Änderung in /etc/services
hat den Seiteneffekt, dass auch ausgehende SSH-Verbindungen standardmäßig den neuen Port nutzen. Für die in ~/.ssh/config
eingetragenen Verbindungen ist das unerheblich, aber beim Kontakt zu fremden Systemen ist eine vorgegebene Abweichung vom Standardport ein support incident waiting to happen. Es bleibt daher bei Port 22 mit einem schlüsselbasierten Zugang:
# /private/etc/ssh/sshd_config
PasswordAuthentication no
ChallengeResponseAuthentication no
PermitRootLogin no
PubkeyAuthentication yes