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.
# 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.
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 meinesorgfältigkuratiertenKonfigurationsdateien 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.
aufschlussreiche Auswertungen möglich sind, hätte ich nicht 16 Jahre ohne jegliche Traffic-Analyse verstreichen lassen, zumal sich die monatliche Auswertung mit einem entsprechenden Skript –
– automatisieren lässt. Das importierte Mail-Skript stützt sich auf python-dotenv (pip3 install python-dotenv):
#!/home/snafu/venv/bin/python3
import os
import datetime
import __main__ as main
import smtplib
from email.message import EmailMessage
from email.headerregistry import Address
from email.utils import make_msgid
from dotenv import dotenv_values
smtp_config = dotenv_values('/root/.env_smtp')
recipient = Address("Jan Eden", "mailer", "eden.one")
sender = Address("Script Status", "noreply", "eden.one")
def send_message(subject, body):
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
subject = f'{os.path.basename(main.__file__)}: {subject}'
body = f'{body}\n\n{now}'
s = smtplib.SMTP(host=smtp_config['server'], local_hostname=smtp_config['localhost'], port=smtp_config['port'])
s.starttls()
s.login(smtp_config['user'], smtp_config['password'])
msg = EmailMessage()
msg['From'] = sender
msg['To'] = recipient
msg['Subject'] = subject
msg['Message-ID'] = make_msgid(domain=smtp_config['localhost'])
msg.set_content(body)
s.send_message(msg)
del msg
Leider fordert GoAccess eine ausgesprochen riskanteContent Security Policyein (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.
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 ausgesprochen wacklig. Zum Glück muss ich weiterhin nicht auf Perl-Skripte zurückgreifen, um response header im Rahmen von HTTP-Kommunikationen darzustellen. Todd selbst empfiehlt andere (teilweise erstaunlich kostspielige) Anwendungen, von denen RapidAI (vormals Paw) einen sehr guten Eindruck macht.
Parallel dazu werde ich durch ein Missgeschick von Jakub Roztočil auf das Python-basierte HTTPie for Terminal aufmerksam, das meiner Arbeitsweise noch etwas besser entspricht:
Nicht nur kann man mit dem Parameter --print=Hhm die gewünschten Informationen (request header, response header, response metadata) präzise angeben, HTTPie sendet auch standardmäßig einen Accept-Encoding-Header, den ich mit anderen Werkzeugen manuell eingeben muss. Eine GUI-Version von HTTPie befindet sich im β-Stadium und kann sich in dieser Umgebung noch nicht mit RapidAI messen. Einziger Nachteil der CLI-Version ist der fehlende (bzw. experimentelle) Support für HTTP/2.
Diese Lücke (wie eigentlich jede Lücke im Zusammenhang mit URLs und Datentransfer) kann curl schließen, entweder mit einer Beschränkung auf response header –
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).
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.
Rollierende Backups lassen sich mit einem Shell-Skript realisieren –
# directory to backup
BDIR=/home/
# exclude the backup user's home directory (contains the private ssh key!)
EXCLUDES='backup'
# name of the backup user
BUSER=backup_user
# the name of the backup machine
BSERVER=rsync.backupservice.com
########################################################################
BACKUPDIR=`date +%A`
OPTS="--force --ignore-errors --delete-excluded --exclude $EXCLUDES
--delete --backup --backup-dir=/$BACKUPDIR -a"
export PATH=$PATH:/bin:/usr/bin:/usr/local/bin
# the following line clears the last weeks incremental directory
[ -d $HOME/emptydir ] || mkdir $HOME/emptydir
rsync --delete -a $HOME/emptydir/ $BUSER@$BSERVER:/users/backup/$BACKUPDIR/
rmdir $HOME/emptydir
# now the actual transfer
rsync $OPTS $BDIR $BUSER@$BSERVER:/users/backup/current
– aber für ein echtes inkrementelles Backup bietet rsync mit dem 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. Um diesen Parameter zu nutzen, muss mein Wrapper rbackup wie folgt ergänzt werden:
../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
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.) –
– 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...')
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.
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/servicesmitgeteiltwerden, ü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
Ein einigermaßen rationales Bemühen um Autonomie endet vor dem Betrieb eines eigenen Rechenzentrums, so dass mein frischer Server heute ohne erkennbaren Anlass die Arbeit einstellt (Critical Alert: TCP Port 25 not responding). Die Status-Seite von IONOS räumt lediglich Probleme mit unseren E-Mail-Systemen bzw. eine Störung in einigen Bereichen des Kundenkontos ein, und nur auf Twitter (@ionos_de) wird das Ausmaß des Zwischenfalls deutlich:
Update: In einem unserer Rechenzentren gab es Probleme bei der Stromversorgung. Dadurch kam es zum Ausfall einiger Server-Systeme. Wir haben die Stromversorgung wiederhergestellt und untersuchen die Ursache der Störung.
Offenbar ist die deutsche Netzinfrastruktur selbst ohne einen Angriff der Вооружённые силы России ziemlich fragil, eventuell sollte ich über einen zweiten Standort nachdenken.