Installation¶
Ein Server pro Kunde. Er braucht Docker (oder Podman), eine feste IPv4-Adresse und eine Domain. Für zehn Benutzer reicht ein kleiner VPS (2 vCPU, 4 GB RAM, 80 GB SSD). Einen neuen VPS zuerst absichern: VPS einrichten – erste Schritte.
1. Server und DNS¶
Vorher prüfen: Der Anbieter darf Port 25 nicht sperren, und der Reverse-DNS-Eintrag (PTR) der IP muss sich setzen lassen.
| Eintrag | Name | Wert |
|---|---|---|
| A | mail.firma.de |
IP des Servers |
| A | kontext.firma.de |
IP des Servers |
| A | dokumente.firma.de |
IP des Servers (nur mit Dokumente) |
| A | mta-sts.firma.de |
IP des Servers (MTA-STS, siehe unten) |
| A | openpgpkey.firma.de |
IP des Servers (OpenPGP-Schlüssel per WKD) |
| MX | firma.de |
10 mail.firma.de |
| TXT | firma.de |
v=spf1 mx -all |
| TXT | <selector>._domainkey.firma.de |
DKIM-Schlüssel aus Stalwart |
| TXT | _dmarc.firma.de |
v=DMARC1; p=none; rua=mailto:dmarc@firma.de (später quarantine) |
| PTR | IP des Servers | mail.firma.de (beim Anbieter einstellen) |
Dazu für die Verschlüsselung unterwegs (Konzept): MTA-STS und TLS-RPT
verlangen von anderen Mailservern, nur verschlüsselt an firma.de zuzustellen, und melden
Probleme. Stalwart zeigt die passenden Einträge unter Domains → firma.de → DNS-Einträge an
(_mta-sts.firma.de, mta-sts.firma.de, _smtp._tls.firma.de) und schreibt die Richtlinie;
Caddy liefert sie unter https://mta-sts.firma.de aus (dafür MAIL_DOMAIN=firma.de in
deploy/.env). Mit DNSSEC beim Anbieter kommt DANE dazu (ein
TLSA-Eintrag, ebenfalls aus Stalwart). Die Firmenverwaltung prüft, was davon gesetzt ist.
Cloudflare
Liegt das DNS bei Cloudflare, müssen alle Einträge für Mail und Kontext „DNS only“ sein (graue Wolke). Durch den Cloudflare-Proxy kommen keine Mails und kein JMAP-Push.
2. Starten¶
# Die Konfiguration (compose.yaml, .env.example, Caddyfile, Backups); die Programme kommen von Docker Hub
git clone https://github.com/firsttris/kontext /opt/kontext && cd /opt/kontext
cp deploy/.env.example deploy/.env
chmod 600 deploy/.env # nur root darf die Geheimnisse lesen
nano deploy/.env # Domain, Mailserver-Name, Geheimnis, Backup-Ziel
docker compose -f deploy/compose.yaml up -d # lädt die Images von Docker Hub und startet sie
Für KONTEXT_SECRET eine lange Zufallszeichenfolge nehmen (openssl rand -base64 48); damit
sind die Sitzungen signiert und die Stalwart-Tokens verschlüsselt. Alle change-me-Platzhalter
ersetzen: Kontext startet nicht mit ihnen, backup.sh sichert nicht. STALWART_ADMIN_KEY bleibt
vorerst leer; er kommt in Schritt 3 dazu (Firmenverwaltung einschalten).
STALWART_BLOB_KEY (openssl rand -base64 32) verschlüsselt den Speicher; ihn im Passwortmanager
ablegen (Speicher verschlüsseln und komprimieren).
Alle Einstellungen stehen unter Einstellungen im Überblick. Für Anrufe im Chat ebenso
LIVEKIT_SECRET setzen und in der Firewall TCP 7881 und UDP 7882 öffnen (siehe
Anrufe). Caddy holt das Zertifikat für
KONTEXT_DOMAIN selbst. Mit Podman liegen die Quadlet-Dateien in
deploy/quadlet/ (nach /etc/containers/systemd/ kopieren, systemctl daemon-reload); Domain
und Adressen stehen dort in jeder Datei selbst, jeder Dienst bekommt nur seine eigenen
Einstellungen (kontext.env, livekit.env … mit chmod 600), keiner deploy/.env.
3. Stalwart einrichten¶
Stalwarts eigene Verwaltung lauscht nur auf dem Server selbst (127.0.0.1:8080), nie im
Internet. Vom eigenen Rechner über einen SSH-Tunnel, dann http://localhost:8080 im Browser:
ssh -L 8080:127.0.0.1:8080 root@<server>
Wer Tailscale nutzt, trägt stattdessen die Tailscale-Adresse des Servers ein (TAILSCALE_IP in
deploy/.env, bei Quadlet in stalwart.container) und erreicht sie unter
http://<tailscale-ip>:8080. Beim ersten Start steht ein vorläufiges Admin-Passwort im Log
(docker compose -f deploy/compose.yaml logs stalwart, mit Podman podman logs stalwart); nach
jedem Neustart vor der Einrichtung gibt es ein neues.
- Domain
firma.deanlegen, DKIM-Schlüssel erzeugen und als TXT-Eintrag ins DNS. - TLS für
mail.firma.deper ACME, am einfachsten mit DNS-01 (Cloudflare-API-Token), weil Port 443 bei Caddy liegt. Vorsicht: Mit DNS-Verwaltung veröffentlicht Stalwart selbst die Einträge, die unter DNS-Einträge veröffentlichen angehakt sind – nach der Ersteinrichtung mit Token auch MX, SPF und DMARCp=reject. Läuft die Mail der Domain noch woanders, ist sie damit sofort umgestellt. Bis zum Umzug nur DKIM anhaken (ohne Oberfläche). - Port 587 (Submission mit STARTTLS) unter Netzwerk → Listener anlegen: Stalwarts Standard hat nur 465, viele Mailprogramme nehmen 587.
- Den Einrichtungsbefehl ausführen – nötig, nicht optional: Er trägt
Kontext als OAuth-Client ein (Schritt 12), erlaubt Verzeichnisabfragen (Schritt 13) und legt
Rolle, Konto und API-Schlüssel für die Firmenverwaltung an;
den Schlüssel als
STALWART_ADMIN_KEYeintragen, dann das Konto des Chefs anlegen. Weitere Mitarbeiter und gemeinsame Postfächer legt der Chef danach selbst in Kontext an. - Unter Settings → Network → HTTP → General, Abschnitt Proxy, als Client IP source
X-Forwarded-For header wählen (
clientIpSource: xForwardedFor) und Trusted proxy hops auf 1 lassen (forwardedHops), damit Stalwart bei CalDAV/CardDAV und Autoconfig (über Caddy) die echten IP-Adressen sieht. Stalwart nimmt den letzten Eintrag, den Caddy setzt. - Unter Sicherheit → Erlaubte IPs das Container-Netz
10.89.250.0/24eintragen (AllowedIp). Kontext meldet alle Personen von seiner einen Adresse an; ohne den Eintrag sperrt Stalwart nach 100 Fehlversuchen am Tag (authBanRate) diese Adresse – und damit Kontext für alle. Gegen das Raten von Passwörtern bremst Kontext selbst: 10 Fehlversuche je Anmeldename in 15 Minuten, dazu 10 Anmeldungen je IP-Adresse in der Minute. - Unter Sicherheit eine Sperrdauer setzen (
authBanPeriod,scanBanPeriod, z. B. ein Tag). Ohne Dauer bleibt eine Sperre für immer. - Unter Suche die Standardsprache auf Deutsch (
de-DE) stellen. Stalwart bildet damit die Wortstämme deutsch, auch bei kurzen Mails, deren Sprache es nicht sicher erkennt. - Unter JMAP die gleichzeitigen Anfragen (
maxConcurrentRequests) auf 16 stellen. Stalwart erlaubt sonst 4 je Person – für alle Tabs und Geräte zusammen; Kontext fragt beim Start mehr auf einmal (Ordner jedes Postfachs, Liste, Unterhaltungen), und was darüber liegt, lehnt Stalwart ab. Eine so abgelehnte Anfrage stellt Kontext nach einer kurzen Pause noch ein paar Mal (src/jmap/client.ts); erst dann zeigt es einen Fehler. - In den Kalender-Einstellungen (
x:CalendarScheduling) HTTP-RSVP ausschalten (httpRsvpEnable: false), das Scheduling selbst (enable) aber anlassen. Einladungen verschickt Kontext mit eigenen Knöpfen; Stalwarts Knöpfe zeigten auf eine Adresse, unter der Caddy antwortet (siehe Einladungen). Die Knöpfe in Kontexts Einladungen brauchen die Firmenverwaltung (den Schlüssel unten). - In den SMTP-Anmeldeeinstellungen (
x:MtaStageAuth,saslMechanisms) die zweite Regel löschen, die auf 587oauthbearerundxoauth2ohne TLS anbietet, sodass nur nochlocal_port != 25 && is_tls→[plain, login, oauthbearer, xoauth2]stehen bleibt. Ein Token ginge sonst im Klartext, wenn jemand STARTTLS aus der Verbindung streicht. Kontext selbst verschickt über JMAP, nicht über SMTP. - Kontext als OAuth-Client – nötig, sonst meldet sich niemand an; das erledigt der
Einrichtungsbefehl. Von Hand: unter Management → Directory → OAuth
Clients einen Client mit der Client ID
kontext, der Redirect URIhttps://kontext.invalid/oauth(wird nie aufgerufen) und dem Scope Full (full) anlegen, ohne Secret. Stalwart 1.0 nimmt nur Clients an, die es kennt, und gibt einem Token nur die Scopes, die der Client erlaubt;fullgibt Kontexts Token alle Rechte des Kontos, die die Einstellungen und die Firmenverwaltung brauchen (siehe Anmeldung). Den Scopefulldarf nur ein Superuser setzen. Fehlt der Eintrag, schreibt Kontext beim AnmeldenStalwart lehnt die Anmeldung für Kontext ab (Unknown client id)ins Log, fehlt der Scope,… ab (invalid_scope). - Verzeichnisabfragen erlauben – erledigt ebenfalls der Einrichtungsbefehl; von Hand unter
Settings → Sharing (
x:Sharing,allowDirectoryQueries: true), danach die Einstellungen neu laden (Reload). Nötig: Ohne findet Kontext keine Kollegen (Teilen, neuer Chat, freie Zeiten beim Einladen); Stalwart 1.0 lehntPrincipal/querysonst ab (The administrator has disabled directory queries).
Einrichtungsbefehl¶
Was Kontext in Stalwart braucht, richtet ein Befehl im Kontext-Container ein. Er fragt nach dem
Admin-Zugang von Stalwart (Name, z. B. admin@firma.de, und Passwort, das nicht angezeigt
wird); der Zugang bleibt nur im Speicher, nichts davon wird geschrieben oder ausgegeben:
docker compose -f deploy/compose.yaml exec kontext bun server/setup-stalwart.ts
podman exec -it kontext bun server/setup-stalwart.ts # mit Quadlet
Er sorgt dafür, dass
- der OAuth-Client
kontextmit der Redirect-URIhttps://kontext.invalid/oauthund dem Scopefullda ist (Schritt 12); einem älteren Client ohnefullergänzt er den Scope; - Verzeichnisabfragen erlaubt sind, und lädt dann die Einstellungen neu (Schritt 13);
- die Rolle
Kontext-Servermit genau den Rechten aus Firmenverwaltung einschalten da ist (sie erbt User), dazu das Kontokontext-server@firma.demit dieser Rolle und einem Zufallspasswort und für dieses Konto ein API-Schlüssel; - der Speicher verschlüsselt und komprimiert ist (Speicher verschlüsseln und komprimieren).
Den neuen Schlüssel gibt er einmal aus, als Zeile STALWART_ADMIN_KEY=API_…: in deploy/.env
eintragen (mit Quadlet in deploy/kontext.env) und Kontext neu starten
(docker compose -f deploy/compose.yaml up -d, mit Quadlet systemctl restart kontext).
Der Befehl ändert nur, was fehlt: Gibt es Client, Rolle, Konto oder Schlüssel schon, sagt er das
und ergänzt höchstens fehlende Rechte oder den Scope; einen zweiten Schlüssel legt er nicht an.
Darum gehört er nach jedem Update dazu (Updates). Ist der Schlüssel verloren, ihn
in Stalwart beim Konto kontext-server@ löschen und den Befehl noch einmal ausführen. Hat
Stalwart mehrere Domains, wählt --domain firma.de beim ersten Mal die für das Konto; danach
bleibt er bei der Domain des Kontos. Ohne Terminal (etwa in
einem Skript) nimmt er den Zugang aus der Umgebungsvariablen STALWART_ADMIN (name:passwort).
Was er genau tut, steht in server/stalwart-setup.ts.
Stalwart lässt den Admin nur Rechte weitergeben, die er selbst hat. Wurde Stalwart von einer
Version vor 1.0.2 aktualisiert, fehlen seiner Admin-Rolle die Rechte für die Verschlüsselung
(sysEncryption…, sysExternalPublicKey…). Der Befehl richtet dann alles andere ein und lässt
nur die Rechte für das Verschlüsseln durch Stalwart
weg; ein Hinweis sagt das. Um es zu nutzen, der Admin-Rolle diese Rechte geben, wie es Stalwarts
Changelog zu 1.0.2 beschreibt,
und den Befehl erneut ausführen.
Danach unter https://kontext.firma.de anmelden. Am Handy im Browser „Zum Startbildschirm
hinzufügen“ wählen: Kontext startet dann wie eine App und bleibt angemeldet.
Anrufe¶
Anrufe und Besprechungen im Chat laufen über LiveKit, das compose.yaml mit dem Profil calls
startet (COMPOSE_PROFILES=calls in .env; .env.example hat calls,documents). Dafür braucht es:
LIVEKIT_SECRETin.env: mindestens 32 Zeichen (openssl rand -base64 48). Kontext und LiveKit nutzen denselben.- In der Firewall des Servers (und beim Anbieter) TCP 7881 und UDP 7882 offen. Darüber
gehen Ton und Bild; die Anmeldung am Gespräch läuft über Caddy (
https://kontext.firma.de/rtc). - LiveKit findet die öffentliche IP des Servers selbst (STUN). Sitzt der Server hinter NAT, in
deploy/livekit.yamlunterrtcnode_ip: <öffentliche IP>eintragen unduse_external_ip: falsesetzen.
Mit Podman: deploy/quadlet/livekit.container mitkopieren, LIVEKIT_KEYS="kontext: <secret>" in
/opt/kontext/deploy/livekit.env und dasselbe Geheimnis als LIVEKIT_API_SECRET in
kontext.env. Ohne Geheimnis bleiben die Anrufe aus, alles andere läuft wie gehabt.
Für zehn Leute reicht der kleine VPS; ein Videogespräch zu dritt braucht am Server etwa 3–5 Mbit/s je Teilnehmer in jede Richtung.
Telefonie¶
Damit die Firmennummer auch in Kontext klingelt und man aus Kontext anrufen kann, braucht es
LiveKit SIP und Redis auf dem Server (im Profil calls schon dabei) und eine Telefonbox im Büro.
Auf dem Server: in der Firewall zusätzlich TCP 5060 (SIP von den Boxen) und
UDP 20000–20100 (Sprache) öffnen. Kontext meldet die Boxen bei LiveKit SIP selbst an; unter
Einstellungen → Telefonie steht, ob das geklappt hat. Die Telefonie-Einstellungen gehören zur
Firmenverwaltung und brauchen deshalb auch STALWART_ADMIN_KEY.
Mit Podman kommen zu livekit.container (siehe Anrufe) redis.container und
livekit-sip.container nach /etc/containers/systemd/, dazu /opt/kontext/deploy/livekit-sip.env
mit SIP_CONFIG_BODY in einer Zeile und dem LiveKit-Geheimnis als api_secret. Die Befehle
dafür stehen in VPS einrichten, Schritt 9; danach
systemctl daemon-reload und systemctl start redis livekit livekit-sip.
In der Fritzbox: Telefonie → Telefoniegeräte → Neues Gerät einrichten → „Telefon (mit und ohne Anrufbeantworter)“ → „LAN/WLAN (IP-Telefon)“, Name „Kontext“. Benutzername und Kennwort merken und festlegen, auf welche Rufnummern das Gerät reagiert und mit welcher es anruft. Für Durchwahlen je Nummer ein weiteres Gerät dieser Art anlegen, das nur auf diese Nummer reagiert und mit ihr anruft, und es in Kontext unter „Weitere Rufnummern“ eintragen.
In Kontext: Einstellungen → Telefonie: als Adresse der Fritzbox ihre IP (meist
192.168.178.1), Benutzername und Kennwort aus der Fritzbox eintragen, auswählen, wer klingeln
soll, dann „Box koppeln“. Ein Name wie fritz.box geht auch: Die Box löst ihn selbst nach IPv4
auf, denn er zeigt auch auf die IPv6-Adresse der Fritzbox, und Asterisk spricht nur IPv4. Die
Seite zeigt von selbst, sobald die Box verbunden und an der Fritzbox angemeldet ist.
Die Box: ein Gerät im Büronetz, das immer läuft – gebrauchter Thin Client (Futro S740,
Wyse 5070), Raspberry Pi mit SSD, der Firmenserver oder ein NAS mit Docker. Am besten per Kabel.
Auf das Gerät kommt nur eine Datei, deploy/box.compose.yaml – heruntergeladen (die Version im
Pfad passend zum Kontext-Server) oder mit dem ganzen Repository (git clone, dann
-f deploy/box.compose.yaml):
mkdir -p /opt/kontext-box && cd /opt/kontext-box
curl -fsSLO https://raw.githubusercontent.com/firsttris/kontext/v0.1.6/deploy/box.compose.yaml
echo KONTEXT_VERSION=0.1.6 > .env # dieselbe Version wie der Server; ohne Zeile: latest
KONTEXT_URL=https://kontext.firma.de PAIRING_CODE=K7QM-4XPA \
docker compose -f box.compose.yaml up -d
Der Code steht in Kontext unter Einstellungen → Telefonie → „Box koppeln“ und wird nur beim
ersten Start gebraucht. box.compose.yaml nimmt das Image tristanteu/kontext-box in der Version
aus KONTEXT_VERSION (Compose liest die .env im Ordner der Datei) und sonst latest. Für ein
Update dort die Version heben, dann docker compose -f box.compose.yaml pull und
docker compose -f box.compose.yaml up -d; von selbst aktualisiert sich die Box nicht.
Die Box braucht eine feste Adresse im LAN (in der Fritzbox unter Heimnetz → Netzwerk → Gerät → „Diesem Netzwerkgerät immer die gleiche IPv4-Adresse zuweisen“). Nach außen braucht sie keine offenen Ports, nur ausgehend Kontext (HTTPS, 443) und den Kontext-Server auf TCP 5060 und UDP 20000–20100. Läuft auf dem Gerät eine Firewall, aus dem LAN 5070 TCP+UDP, 10000–10200 UDP und 8089 TCP erlauben. SIP auf 5070 lauscht erst nach dem Koppeln; davor gibt es nur die Statusseite.
Tischtelefone (Snom): in Kontext unter Telefonie → Tischtelefone mit MAC-Adresse anlegen,
dann im Telefon (Weboberfläche → Erweitert → Update) als Einstellungs-URL
http://<box>:8089/snom/{mac}.xml eintragen und neu starten; es holt sich Konto und Einstellungen
von der Box. Die Box braucht dafür eine feste Adresse im LAN. Das Passwort gibt sie nur beim
ersten Abruf heraus; nach einem Zurücksetzen des Telefons das Tischtelefon in Kontext löschen und
neu anlegen.
Mit Podman: deploy/quadlet/kontext-box.container nach /etc/containers/systemd/ kopieren
(auch einzeln herunterladen wie oben), systemctl daemon-reload, systemctl start kontext-box;
nach einem Neustart startet die Box von selbst. Die Datei nennt Image=…/kontext-box:latest; besser
dort die Version des Servers eintragen (kontext-box:0.1.6). Für ein Update die Version heben,
systemctl daemon-reload, podman pull docker.io/tristanteu/kontext-box:<version> und
systemctl restart kontext-box – mit latest holt ein Neustart kein neues Image. Ohne Code beim Start zeigt die Box auf http://<box>:8089 ein Formular für Kontexts
Adresse und den Code; mit Code stehen KONTEXT_URL und PAIRING_CODE in /etc/kontext-box.env.
Sie läuft im Host-Netz (SIP mit der Fritzbox auf 5070, Sprache auf 10000–10200 im LAN).
Die Box selbst bauen (etwa für eine Version, die noch nicht auf Docker Hub ist), ab v0.1.4 – die Box bis v0.1.3 lädt die Konfiguration aus Kontext nicht und meldet sich nie an der Fritzbox an:
git clone --depth 1 --branch v0.1.4 https://github.com/firsttris/kontext.git /opt/kontext-box-src
cd /opt/kontext-box-src
podman build -f box/Dockerfile -t kontext-box:0.1.4 .
Im Quadlet dann Image=localhost/kontext-box:0.1.4 (Podman setzt localhost/ vor lokal gebaute
Images); mit Docker heißt es kontext-box:0.1.4, und docker build statt podman build.
Prüfen: curl http://127.0.0.1:8089/ zeigt „Kontext-Telefonbox“, und
podman exec kontext-box asterisk -rx "module show like res_pjsip.so" zeigt res_pjsip.so.
Die Meldung „There are no local system nameservers configured, resorting to system resolution“
beim Start ist harmlos: Asterisk auf Alpine liest die Nameserver nicht selbst und nimmt dann die
des Systems.
Wenn die Box sich nicht an der Fritzbox anmeldet (mit Docker docker statt podman):
podman exec kontext-box asterisk -rx "pjsip show registrations"zeigt die Anmeldung. „No objects found“: Asterisk hat die Konfiguration nicht geladen – auf der Statusseite steht dann bei „Konfiguration“ der Grund.Registered: alles gut.- „Rejected“ mit „No response received“ im Log (
podman logs kontext-box): Die Fritzbox hat nie geantwortet. Als Adresse der Fritzbox ihre IP eintragen;ping 192.168.178.1prüft, ob sie erreichbar ist. - „Rejected“ mit
403 Forbidden: Benutzername oder Kennwort passen nicht zum IP-Telefon in der Fritzbox (die Box versucht es dann erst nach 5 Minuten wieder). ss -lunp | grep 5070zeigt, ob die Box auf 5070 lauscht; eine Firewall auf dem Gerät muss 5070 und 10000–10200 aus dem LAN durchlassen.
Dokumente¶
Das Modul „Dokumente“ ist Schreibzeug, ein eigener
Dienst mit eigenen Daten und Versionen. compose.yaml startet ihn mit dem Profil documents
(COMPOSE_PROFILES=calls,documents in .env, so in .env.example). Dafür braucht es:
- eine eigene Adresse unter derselben Domain, etwa
dokumente.firma.de(A-Eintrag wie Kontext), alsSCHREIBZEUG_URL=https://dokumente.firma.dein.env. Caddy holt das Zertifikat dafür selbst. Dieselbe Domain hält Rahmen und Anmeldung ohne Umwege (keine Drittanbieter-Cookies). SCHREIBZEUG_CLIENT_SECRETin.env: mindestens 32 Zeichen (openssl rand -base64 48). Damit meldet sich Schreibzeug bei Kontext an; Kontext trägt Schreibzeug beim Start als vertrauten Client ein, ohne Zustimmungsseite.
Wer in Kontext angemeldet ist, ist es dann auch in Dokumente; Anhänge lassen sich dort ablegen und
Dokumente an Mails hängen. Jedes gemeinsame Postfach bekommt dort einen eigenen Ordner für seine
Leute; dafür liest Kontext die Mitglieder mit STALWART_ADMIN_KEY, wie für die Firmenverwaltung. Ohne SCHREIBZEUG_URL fehlt das Modul, alles andere läuft wie gehabt.
Schreibzeug läuft auch für sich unter seiner Adresse, mit „Anmelden mit Kontext“.
Mit Podman: deploy/quadlet/schreibzeug.container mitkopieren, in
/opt/kontext/deploy/schreibzeug.env SCHREIBZEUG_URL, SCHREIBZEUG_EMBED_ORIGIN=https://kontext.firma.de,
OIDC_ISSUER=https://kontext.firma.de/api/auth und OIDC_CLIENT_SECRET (dasselbe Geheimnis)
eintragen, SCHREIBZEUG_URL und SCHREIBZEUG_CLIENT_SECRET auch in kontext.env.
Ohne Dokumente: mit Compose documents aus COMPOSE_PROFILES nehmen und die beiden Zeilen
SCHREIBZEUG_URL und SCHREIBZEUG_CLIENT_SECRET aus .env löschen; leer ginge auch, denn
compose.yaml gibt Caddy dann einen Platzhalter (https://schreibzeug.internal), der niemandem
antwortet. Mit Quadlet schreibzeug.container nicht kopieren, die beiden Zeilen in kontext.env
weglassen und in caddy.container die Zeile Environment=SCHREIBZEUG_URL=… löschen: Caddy liest
die Adresse dort, nicht aus kontext.env, und holte sonst weiter ein Zertifikat für sie.
Der Server bleibt schlank: Bearbeiten und Texterkennung laufen im Browser.
Ortsvorschläge¶
Im Feld „Ort“ eines Termins schlägt Kontext Adressen vor. Dafür fragt der Kontext-Server
Photon (komoot, Daten von OpenStreetMap) unter
https://photon.komoot.io/api/ – ohne Einstellung ist das an. Was jemand als Ort tippt, geht so
vom Server an komoot (nicht die Adresse des Browsers). Mit PLACES_URL= (leer) in .env bzw.
kontext.env sind die Vorschläge aus, das Feld nimmt weiter jeden Text. Ein eigenes Photon
(etwa nur mit den Daten für Deutschland) trägt man als PLACES_URL=https://photon.firma.de/api/
ein. Der öffentliche Dienst von komoot ist für maßvolle Nutzung gedacht; Kontext fragt erst nach
einer Tipp-Pause und ab drei Buchstaben.
Firmenverwaltung einschalten¶
Unter Einstellungen → Firma legt der Chef (oder die Büroleitung) Mitarbeiter und gemeinsame
Postfächer an, setzt Passwörter und die Anmeldung mit Code zurück und sperrt Zugänge. Dafür
braucht der Kontext-Server einen API-Schlüssel von Stalwart, der nur Konten verwalten darf
(STALWART_ADMIN_KEY).
Ohne den Schlüssel fehlt viel
Kontext startet auch ohne STALWART_ADMIN_KEY, aber dann fehlen:
- die Firmenverwaltung ganz – Mitarbeiter, gemeinsame Postfächer, Firmen-Signatur, Datensicherung, Telefonie-Einstellungen und der Umzug;
- Weitere Postfächer (Gmail, GMX …): der Eintrag fehlt in den Einstellungen, weil der Server die abgerufenen Mails mit dem Schlüssel ins Postfach legt;
- die Antwort-Knöpfe in Einladungen: Kontext verschickt sie ohne Ja / Nein / Vielleicht, denn eingetragen wird die Antwort mit dem Schlüssel (Einladungen);
- in Dokumente die Ordner je gemeinsamem Postfach: Kontext liest die Mitglieder mit dem Schlüssel.
Darum gehört er zur Einrichtung dazu (Schritt 4 unter Stalwart einrichten).
Rolle, Konto und Schlüssel (Schritte 1–3) legt der Einrichtungsbefehl an; von Hand in Stalwarts Verwaltung geht es so:
- Eine Rolle
Kontext-Serveranlegen: sie erbt die Rolle User und bekommt dazu die RechtesysAccountGet,sysAccountQuery,sysAccountCreate,sysAccountUpdate,sysRoleGet,sysRoleCreate,sysDomainGet,sysAuthenticationGet,sysIndexQueueStatusGet,sysEncryptionGet,sysEncryptionUpdate,sysExternalPublicKeyGet,sysExternalPublicKeyCreate,sysExternalPublicKeyDestroy,sysActionCreate,actionReloadSettingsundimpersonate. MitsysIndexQueueStatusGetzeigt die Firmenverwaltung, solange Stalwart den Suchindex aufbaut. Mit densysEncryption…- undsysExternalPublicKey…-Rechten schaltet sie das Verschlüsseln durch Stalwart und verwaltet die Schlüssel der Empfänger;sysActionCreatemitactionReloadSettingslässt sie Stalwart danach die Einstellungen neu laden. Mitimpersonatearbeitet der Server in den Postfächern der Mitarbeiter (Abwesenheit, Leserechte für gemeinsame Postfächer) und trägt Antworten auf Einladungen ein; die App zeigt dem Chef dabei nie fremde Mails. - Ein Konto
kontext-server@firma.demit dieser Rolle (statt User) und einem langen Zufallspasswort anlegen. - Mit diesem Konto einen API-Schlüssel erzeugen (Credentials → API Key, Rechte
Inherit). Er beginnt mit
API_; indeploy/.envalsSTALWART_ADMIN_KEYeintragen und Kontext neu starten (docker compose -f deploy/compose.yaml up -d); mit Quadlet indeploy/kontext.env, dannsystemctl restart kontext. Ohne Oberfläche geht dasselbe über Stalwarts API (VPS einrichten, Schritt 10). - Beim ersten Aufruf legt Kontext die Rolle
Firmenverwaltung (Kontext)an. Dem Konto des Chefs in Stalwart diese Rolle zusätzlich zu User geben. Danach kann er sie in Kontext selbst an weitere Personen weitergeben.
Stalwart lässt den Schlüssel keine Rechte vergeben, die er selbst nicht hat; ein Mitarbeiter wird darüber also nie Admin. Kontext zeigt und ändert nur Konten mit der normalen Rolle User. Er kann aber alle Postfächer lesen und Admin-Konten von Stalwart im Passwort ändern (Stalwart prüft dabei die Rechte des Zielkontos nicht) – den Schlüssel deshalb wie ein Admin-Passwort behandeln.
Umzug¶
Unter Einstellungen → Firma → Umzug übernimmt der Chef die Daten der Mitarbeiter:
- Postfach verbinden für Gmail, GMX, WEB.DE, Telekom, IONOS, STRATO oder einen anderen IMAP-Server. Gmail braucht ein App-Passwort (im Google-Konto Bestätigung in zwei Schritten einschalten, dann App-Passwörter), GMX und WEB.DE den eingeschalteten IMAP-Zugriff. Der Abgleich läuft alle 15 Minuten, bis der Umzug abgeschlossen ist; danach beim alten Anbieter eine Weiterleitung einrichten oder den MX-Eintrag der eigenen Domain umstellen. Der Server braucht dafür ausgehend Port 993.
- Microsoft 365: einmal pro Kunde im
Microsoft Entra Admin Center unter App-Registrierungen eine App
„Kontext Umzug“ anlegen, unter API-Berechtigungen die Anwendungsberechtigungen
Mail.Read,Contacts.Read,Calendars.ReadundUser.Read.Allfür Microsoft Graph (optionalMailboxSettings.Read, damit Outlook-Kategorien ihre Farben behalten) hinzufügen und die Administratorzustimmung erteilen, unter Zertifikate & Geheimnisse einen geheimen Clientschlüssel erzeugen. Verzeichnis-ID, Anwendungs-ID und Schlüssel beim ersten „Postfach verbinden → Microsoft 365“ eintragen. Die App kann alle Postfächer des Mandanten lesen; nach dem Umzug den Schlüssel löschen oder die App entfernen. - Datei hochladen: Outlook-Datendatei (
.pst), Kontakte (.vcf) und Kalender (.ics), etwa aus dem Google-Export (Google Kontakte → Exportieren → vCard, Google Kalender → Einstellungen → Exportieren). Die Datei liegt bis zum Ende des Umzugs im Datenvolume und wird dann entpackt: Für eine 10-GB-PST also vorübergehend gut 20 GB frei haben.
Ein großer Umzug läuft mehrere Stunden, bei Gmail wegen dessen Tageslimit mehrere Tage, bei Microsoft 365 bremst Graph bei vielen Abfragen; der Fortschritt steht auf derselben Seite, auch nach einem Neustart.
Wenn jemand sein Handy mit der Authenticator-App verliert¶
Mitarbeiter schalten die Anmeldung mit Code selbst unter Einstellungen → Passwort und Sicherheit ein. Geht das Handy verloren, setzt der Chef sie unter Einstellungen → Firma → Mitarbeiter beim Mitarbeiter zurück; danach meldet sich der Mitarbeiter wieder nur mit Passwort an und richtet den Code neu ein.
KI-Agenten¶
Agenten wie Claude verbinden sich über MCP mit https://<KONTEXT_DOMAIN>/mcp; einzurichten ist
nichts. Jede Person verbindet ihren Agenten selbst unter Einstellungen → KI-Agenten (Schlüssel)
oder trägt die Adresse in Claude als Connector ein und meldet sich im Browser an (OAuth). Dafür
gilt:
- Caddy gibt
/mcpund die OAuth-Discovery (/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-server) an Kontext, die übrigen/.well-known-Pfade weiter an Stalwart (deploy/Caddyfile). - Der Kontext-Server muss ins Internet dürfen (HTTPS): Claude und andere Clients melden sich mit einer Adresse an, unter der ihre Beschreibung liegt (Client ID Metadata Document), und Kontext holt sie dort ab – nur öffentliche Adressen, ohne Weiterleitungen.
- Wer einen Agenten nicht mehr will, trennt ihn in den Einstellungen; das Sperren eines Mitarbeiters trennt alle seine Agenten.
Den Assistenten im Chat (KI-Agenten → Assistent im Chat) gibt es mit einem Schlüssel der Claude API: unter console.anthropic.com anlegen und in der Firmenverwaltung unter Chat → Assistent im Chat eintragen, dazu das Modell wählen (empfohlen Claude Sonnet 5.5). Kontext speichert den Schlüssel verschlüsselt und zeigt danach nur noch seine letzten vier Zeichen. Jede Person darf zehn Fragen in der Minute stellen. Die Fragen und was der Assistent nachschlägt, gehen an Anthropic in die USA: Das gehört in die Datenschutzinformation, mit Anthropics Auftragsverarbeitungsvertrag.
4. Platte verschlüsseln¶
Mails, Chat, Aufgaben und Dateien liegen auf der Platte des Servers. Verschlüsselt ist sie
nutzlos für jemanden, der den Server oder eine Platte mitnimmt: beim Einrichten die Datenplatte
mit LUKS verschlüsseln (bei vielen VPS-Anbietern als Option beim Anlegen, sonst eine zweite
Platte mit cryptsetup luksFormat und darauf die Docker-Volumes). Den Schlüssel nicht auf dem
Server ablegen, sondern beim Neustart eingeben oder per Netzwerk entsperren (Clevis/Tang).
Speicher verschlüsseln und komprimieren¶
Dazu verschlüsselt Stalwart selbst, was es speichert: Mails, Anhänge und Dateien mit einem
Serverschlüssel (AES-256-GCM), vorher mit Zstandard komprimiert, was Platz und damit
Backup-Speicher spart. Der Schlüssel steht nicht in Stalwarts Datenbank, sondern in der
Umgebungsvariablen STALWART_BLOB_KEY des Stalwart-Containers; ein Abzug der Platte oder eine
Sicherung allein verrät die Mails also nicht.
-
Einen Schlüssel erzeugen und als
STALWART_BLOB_KEY=…indeploy/.enveintragen (mit Quadlet indeploy/stalwart.env,chmod 600, und instalwart.containerdie ZeileEnvironmentFile=/opt/kontext/deploy/stalwart.enveinkommentieren):openssl rand -base64 32 -
Den Schlüssel in den Passwortmanager (Zugangsdaten sichern). Ist er einmal gesetzt, darf er sich nie ändern und nicht verloren gehen: Ohne ihn kann Stalwart die Mails nicht mehr lesen, auch nicht aus der Sicherung.
- Stalwart neu starten (
docker compose -f deploy/compose.yaml up -d, mit Quadletsystemctl daemon-reload && systemctl restart stalwart) und den Einrichtungsbefehl ausführen.
Der Befehl legt in Stalwart den Schlüssel Kontext an (ein SymmetricKey, der sein Geheimnis aus
der Variablen liest), stellt Mails (Email) und Dateien (FileStorage) auf Zstandard und diesen
Schlüssel um, lädt die Einstellungen neu und startet die Aufgabe BlobRecode, die alles schon
Gespeicherte im Hintergrund neu schreibt. Das läuft je nach Menge Minuten bis Stunden, die Mails
bleiben dabei lesbar. Bei jedem weiteren Lauf sieht er, dass alles da ist, und tut nichts.
Fehlt die Variable, weist Stalwart den Schlüssel ab: Der Befehl komprimiert dann nur und sagt das
mit einem Hinweis. Hat sich der Admin schon selbst einen Schlüssel eingestellt, bleibt der. Wurde
Stalwart von einer Version vor 1.0.3 aktualisiert, fehlen seiner Admin-Rolle womöglich die Rechte
für Schlüssel (sysSymmetricKey…) und das Umschreiben (taskBlobRecode): Der Befehl läuft dann
durch und sagt das mit einem Hinweis; Stalwart einmal mit
STALWART_UPDATE_ROLE_PERMISSIONS=true starten (Stalwart 1.0.3) und den Befehl
erneut ausführen; blieb das Umschreiben aus, mit --recode, das es auch ohne geänderte
Einstellungen startet. Was der Befehl
genau tut, steht in server/stalwart-storage.ts.
5. Backups¶
deploy/backup.sh sichert mit Restic auf einen S3-Speicher, am einfachsten
Cloudflare R2 (S3-kompatibel, 10 GB frei) – Restic
verschlüsselt jede Sicherung mit dem Passwort aus deploy/.env, der Speicheranbieter sieht nur
Verschlüsseltes; das Passwort gehört zusätzlich an einen sicheren Ort außerhalb des Servers. Gesichert werden Stalwarts
Datenvolume (Mails, Kalender, Kontakte, Konten), eine Kopie der Kontext-Datenbank
(Anmeldungen, Firmen-Signatur, Aufgaben, Chat, Anrufliste, Stand der Umzüge), die Fotos und Dateien aus dem
Chat, Ansagen, Mailbox-Nachrichten, Faxe und Aufnahmen der Telefonie und die vertraulich gesendeten
Mails, mit Dokumente auch Schreibzeugs Datenbank (Kopie) und Dateien. Hochgeladene PST-Dateien bleiben draußen. Am besten einen Bucket mit Object Lock nehmen, dann kann auch ein Angreifer mit
Zugriff auf den Server die Sicherungen nicht löschen. Ein GoBD-Archiv für Mails ersetzt das nicht. Die Sperrfrist
im Bucket höchstens 14 Tage: backup.sh hebt 14 Tage, 8 Wochen und 12 Monate auf und räumt den
Rest weg; länger gesperrte Dateien lassen sich nicht löschen. Das meldet das Skript, prüft die
Sicherung aber trotzdem.
Das Ziel liegt nicht auf dem Server selbst: Fällt er aus oder wird er übernommen, wäre ein Backup daneben mit weg, und es füllte die Platte, auf der Stalwart die Mails annimmt. Geeignet:
| Ziel | RESTIC_REPOSITORY |
Kosten |
|---|---|---|
| Cloudflare R2 (S3) | s3:https://<konto-id>.r2.cloudflarestorage.com/kontext-firma |
10 GB frei, danach etwa 1,5 Cent je GB und Monat |
| Backblaze B2 (S3) | s3:https://s3.eu-central-003.backblazeb2.com/kontext-firma |
10 GB frei |
| anderer S3-Speicher | s3:https://…/kontext-firma |
je nach Anbieter |
| NAS oder Speicherbox per SFTP | sftp:backup@nas.firma.de:/kontext |
eigene Hardware bzw. Storage Box |
Restic komprimiert und legt Gleiches nur einmal ab; die erste Sicherung ist so groß wie die
Daten, jede weitere nur so groß wie das Neue. Für S3 setzt man AWS_ACCESS_KEY_ID und
AWS_SECRET_ACCESS_KEY (bei R2 ein API-Token mit Object Read & Write für den einen Bucket); per
SFTP meldet sich root mit einem SSH-Schlüssel an (ssh-keygen, den öffentlichen Teil beim Ziel
hinterlegen, einmal ssh backup@nas.firma.de zum Bestätigen des Host-Schlüssels).
Bei R2 den Bucket mit Standort EU anlegen; der Endpoint heißt dann
https://<konto-id>.eu.r2.cloudflarestorage.com (der ohne .eu findet EU-Buckets nicht). Ein
IP-Filter am Token braucht die IPv4-Adresse und das IPv6-Netz des Servers: Restic geht
bevorzugt über IPv6, sonst antwortet R2 mit Access Denied.
Nach jeder Sicherung misst backup.sh die Größe und schreibt ab 8 GB eine Warnung ins Log
(BACKUP_WARN_GB in den Einstellungen ändert die Grenze) – vor dem Ende der freien 10 GB. Bei R2
zusätzlich unter Notifications → Usage Based Billing eine Mail ab etwa 8 GB einrichten; die kommt
auch, wenn der Server schweigt.
Restic muss auf dem Server installiert sein (apt-get install restic).
Jede Nacht starten systemd-Units die Sicherung (Vorlagen in deploy/systemd/). Mit ihnen zeigt
die Firmenverwaltung unter Datensicherung den letzten Lauf und den belegten Speicher, stellt die
Uhrzeit und sichert auf Knopfdruck. Kontext bekommt dafür keinen Zugriff auf den Server: Es
legt nur Wünsche in seinem Volume ab, die die Units abholen
(Architektur).
cd /opt/kontext/deploy
./backup-control.sh install # Units nach /etc/systemd/system, Volume-Pfad eingesetzt
systemctl enable --now kontext-backup.timer kontext-backup-control.path
crontab -l | grep -v 'deploy/backup.sh' | crontab - # alter Cron-Eintrag, falls vorhanden
systemctl start kontext-backup.service # erste Sicherung, Protokoll: journalctl -u kontext-backup
Die Units:
| Unit | Aufgabe |
|---|---|
kontext-backup.service |
ruft backup.sh auf (das sperrt per flock: nie zwei Läufe gleichzeitig, auch nicht von Hand oder per Cron), Ausgabe ins Journal und an Kontext, Status am Anfang und am Ende |
kontext-backup.timer |
jede Nacht um 03:00 (Europe/Berlin), holt eine verpasste Nacht nach (Persistent=true) |
kontext-backup-control.path |
beobachtet die Uhrzeit und „Jetzt sichern“ aus Kontext und startet kontext-backup-control.service |
install findet das Volume kontext-data (Quadlet) bzw. kontext_kontext-data (Compose) über
podman volume inspect bzw. docker volume inspect. Die Uhrzeit aus Kontext landet in
/etc/systemd/system/kontext-backup.timer.d/time.conf; wird sie auf eine Zeit vorgezogen, die
heute schon vorbei ist, sichert der Timer gleich einmal (Persistent=true). Nach einem git pull
mit geänderten Units ./backup-control.sh install erneut ausführen.
Ohne die Units geht es weiter mit Cron; die Firmenverwaltung zeigt dann „Steuerung auf dem Server nicht eingerichtet“:
crontab -e
0 3 * * * /opt/kontext/deploy/backup.sh >> /var/log/kontext-backup.log 2>&1
Mit Podman (Quadlet) gibt es kein deploy/.env; das Skript erkennt das an
/etc/containers/systemd/kontext.container und liest seine Einstellungen aus
deploy/backup.env (chmod 600):
KONTEXT_DOMAIN=kontext.firma.de
RESTIC_REPOSITORY=s3:https://s3.eu-central-1.example.com/kontext-firma
RESTIC_PASSWORD=…
AWS_ACCESS_KEY_ID=…
AWS_SECRET_ACCESS_KEY=…
Der Mailserver hält dafür ein paar Sekunden an; absendende Server versuchen es einfach
erneut. Die Kontext-Datenbank wird im laufenden Betrieb kopiert (VACUUM INTO). Einmal im Monat
eine Wiederherstellung auf einem Testserver üben, wie unten beschrieben.
Wiederherstellen¶
Restic legt je Lauf drei Sicherungen an, erkennbar am Tag: stalwart (Stalwarts Datenvolume),
kontext (die Kopie der Datenbank unter backup/kontext.db und die Ordner chat-files,
photos, phone, secure) und mit Dokumente schreibzeug (backup/schreibzeug.db, blobs,
auth-secret). Gesichert sind die Volumes unter ihrem Pfad auf dem Server, etwa
/var/lib/docker/volumes/kontext_stalwart-data/_data (Compose mit Docker) oder
/var/lib/containers/storage/volumes/stalwart-data/_data (Quadlet). Die Volumes heißen mit
Compose kontext_stalwart-data, kontext_kontext-data, kontext_schreibzeug-data, mit Quadlet
ohne Vorsilbe stalwart-data, kontext-data, schreibzeug-data.
Nicht in der Sicherung: die Einstellungen (deploy/.env bzw. die Quadlet-Dateien und
kontext.env … – darum gehören sie in den Passwortmanager, siehe
Zugangsdaten sichern) und Stalwarts Volume stalwart-config.
Dazu gehört der Speicherschlüssel STALWART_BLOB_KEY: Ohne denselben
Schlüssel kann der neue Stalwart die zurückgeholten Mails nicht lesen.
Darin steht nur config.json mit dem Speicherort; alles andere (Domains, Konten, DKIM-Schlüssel,
Einstellungen) liegt in Stalwarts Datenbank und kommt mit stalwart zurück.
1. Server bereitstellen. Auf einem neuen Server Kontext wie in den Schritten 1 und 2 mit
denselben Einstellungen installieren und starten, dann Stalwarts Ersteinrichtung durchlaufen
(Benutzer admin, Passwort aus dem Log), mit dem vorgegebenen Speicher (RocksDB unter
/var/lib/stalwart/) – erst dann gibt es config.json. Was man dabei einträgt, ersetzt gleich
die Sicherung. Auf dem alten Server entfällt dieser Schritt.
2. Restic die Einstellungen geben – dieselben, mit denen backup.sh sichert, als root:
apt-get install -y restic
cd /opt/kontext/deploy
set -a; . ./.env; set +a # mit Quadlet: . ./backup.env
restic snapshots # alle Sicherungen mit Tag, Zeit und Pfad
3. Dienste anhalten, damit nichts in die Volumes schreibt:
docker compose -f compose.yaml stop # Compose
systemctl stop caddy schreibzeug kontext stalwart # Quadlet
4. Zurückholen in einen leeren Ordner, dann in die Volumes. vol nennt den Pfad eines
Volumes; mit Podman Compose podman statt docker:
vol() { docker volume inspect -f '{{ .Mountpoint }}' "kontext_$1"; } # Compose
vol() { podman volume inspect -f '{{ .Mountpoint }}' "$1"; } # Quadlet
restic restore latest --tag stalwart --target /tmp/restore
restic restore latest --tag kontext --target /tmp/restore
restic restore latest --tag schreibzeug --target /tmp/restore # nur mit Dokumente
S=$(vol stalwart-data)
find "$S" -mindepth 1 -delete && cp -a "/tmp/restore$S/." "$S/"
K=$(vol kontext-data)
rm -f "$K/kontext.db" "$K/kontext.db-wal" "$K/kontext.db-shm"
cp -a "/tmp/restore$K/backup/kontext.db" "$K/kontext.db"
for d in chat-files photos phone secure; do rm -rf "${K:?}/$d"; cp -a "/tmp/restore$K/$d" "$K/"; done
D=$(vol schreibzeug-data) # nur mit Dokumente
rm -f "$D/schreibzeug.db" "$D/schreibzeug.db-wal" "$D/schreibzeug.db-shm"
cp -a "/tmp/restore$D/backup/schreibzeug.db" "$D/schreibzeug.db"
rm -rf "${D:?}/blobs" "$D/auth-secret"
cp -a "/tmp/restore$D/blobs" "/tmp/restore$D/auth-secret" "$D/"
Die Pfade in der Sicherung sind die des alten Servers. Läuft der neue genauso (Compose mit Docker
bzw. Quadlet), sind sie gleich; sonst zeigt restic snapshots sie in der Spalte Paths, und statt
/tmp/restore$S steht dann /tmp/restore<alter Pfad>. Restic stellt Besitzer und Rechte mit
her, cp -a behält sie.
Mit Quadlet in Schritt 3 und 5 nur die Units nennen, die es auf dem Server gibt (ohne Dokumente
kein schreibzeug, ohne Anrufe kein redis, livekit, livekit-sip).
5. Starten und prüfen:
docker compose -f compose.yaml up -d # Compose
systemctl start stalwart redis livekit livekit-sip kontext schreibzeug caddy # Quadlet
Danach in Kontext anmelden: Mails, Termine, Aufgaben und Chat sind wieder da. Ändern sich auf
einem neuen Server die IP-Adressen, die DNS-Einträge (A, PTR) nachziehen. /tmp/restore zum
Schluss löschen.
Fehlt die Kopie der Kontext-Datenbank, geht nichts in Stalwart verloren: alle melden sich einmal neu an, die Vorlage der Firmen-Signatur ist neu einzutragen; Aufgaben und Chat sind dann leer.
6. Updates¶
Was sich in jeder Version geändert hat und welche Versionen einen Schritt mehr brauchen, steht im
Changelog. Vorher ein Backup machen
(systemctl start kontext-backup.service oder „Jetzt sichern“ in der Firmenverwaltung).
Mit Compose:
cd /opt/kontext && git pull
git diff ORIG_HEAD -- deploy/ # was sich in deploy/ geändert hat: .env.example, compose.yaml, Caddyfile, Units
nano deploy/.env # KONTEXT_VERSION auf die neue Version, z. B. 0.1.6; neue Zeilen aus .env.example übernehmen
docker compose -f deploy/compose.yaml pull && docker compose -f deploy/compose.yaml up -d
deploy/.envist die eigene Kopie von.env.exampleund ändert sich mitgit pullnicht. Neue Einstellungen zeigtgit diff ORIG_HEAD -- deploy/.env.example(oder ein Vergleich beider Dateien); was dort neu ist, in.envübernehmen.KONTEXT_VERSIONhebt Kontext; die aktuelle Version steht in.env.exampleund unter Releases.SCHREIBZEUG_VERSIONhebt Schreibzeug, das eigene Versionen hat (Releases von Schreibzeug); fehlt die Zeile, nimmtcompose.yamllatest.STALWART_VERSIONhebt Stalwart, ebenfalls fest und von Hand:git pulländert den Mailserver nie von selbst. Die Version aus.env.examplemit demKONTEXT_VERSIONderselben Kontext-Version heben, und vorher die Schritte machen, die das Changelog dafür nennt (wie den Scope bei Stalwart 1.0.3).- LiveKit, LiveKit SIP, Redis und Caddy stehen mit festen Versionen in
deploy/compose.yaml; neue kommen mitgit pull, undpullholt sie. - Hat sich
deploy/Caddyfilegeändert, Caddy neu laden:docker compose -f deploy/compose.yaml restart caddy. - Haben sich die Units in
deploy/systemd/geändert, indeploy/./backup-control.sh installerneut ausführen.
Mit Quadlet: Die Dateien unter /etc/containers/systemd/ sind Kopien aus deploy/quadlet/
mit der eigenen Domain (VPS einrichten, Schritt 9); git
pull ändert sie nicht.
cd /opt/kontext && git pull
git diff ORIG_HEAD -- deploy/ # neue Image-Versionen, Einstellungen, Units
cd /etc/containers/systemd
nano kontext.container schreibzeug.container # Image=…/kontext:<neue Version>, …/schreibzeug:<neue Version>
systemctl daemon-reload
for i in $(grep -h '^Image=' *.container | cut -d= -f2); do podman pull $i; done
systemctl restart kontext schreibzeug # und jeder Dienst, dessen Image sich geändert hat
Hat sich in deploy/quadlet/ mehr geändert als die Version (etwa eine neue Environment=-Zeile),
das auch in die Kopie übernehmen; deploy/Caddyfile wird direkt eingebunden, dann
systemctl restart caddy.
Die Images aktualisieren sich nicht selbst (auch nicht mit Podman-Auto-Update): Ein neues kommt
nur, wenn man die Version hebt. Ausnahmen, die auf latest stehen, bis man eine Version einträgt:
Schreibzeug mit Compose ohne SCHREIBZEUG_VERSION und die Telefonbox (siehe
Telefonie).
Seit 0.1.3 läuft der Kontext-Server nicht mehr als root, sondern als Benutzer bun. Ein Volume aus
einer älteren Version gehört noch root; einmal übergeben, dann starten:
docker compose -f deploy/compose.yaml run --rm --user root --entrypoint chown kontext -R bun:bun /data
docker compose -f deploy/compose.yaml up -d
Vor einem Stalwart-Update mit neuer Hauptversion die UPGRADING-Hinweise von Stalwart lesen und
vorher ein Backup machen. Nach jedem Update den Einrichtungsbefehl noch
einmal ausführen; er ergänzt nur, was eine neue Version braucht und noch fehlt.
Stalwart 1.0.3¶
Stalwart 1.0.3 kennt keine First-party-Clients mehr: Ein eingetragener Client bekommt nur noch
die Scopes, die er anfordert und die er erlaubt. Kontext fordert dafür ab dieser Version den
Scope urn:stalwart:oauth:scope:full an. Beim Client kontext muss darum der Scope Full
stehen, sonst lehnt Stalwart jede neue Anmeldung ab. Den ergänzt der
Einrichtungsbefehl: nach dem Update von Kontext und Stalwart einmal
ausführen. Von Hand geht es unter Management → Directory → OAuth Clients (als Superuser) oder
mit dem Helfer aus VPS einrichten, Schritt 10:
/root/stalwart-api.sh '[["x:OAuthClient/get",{"ids":null,"properties":["clientId","scopes"]},"0"]]'
/root/stalwart-api.sh '[["x:OAuthClient/set",{"update":{"<id von kontext>":{"scopes/full":true}}},"0"]]'
Kontext und Stalwart gehören beim Update zusammen, mit Compose KONTEXT_VERSION und
STALWART_VERSION=1.0.3 in deploy/.env gemeinsam gehoben: Stalwart 1.0.3
mit einem Kontext bis 0.1.22 lässt niemanden mehr neu anmelden, weil der alte Kontext den Scope
nicht anfordert. Alle melden sich einmal neu an: Freigaben aus der Zeit als
First-party-Client verwirft Stalwart, sobald Kontext den Token erneuert (invalid_grant,
crates/http/src/auth/oauth/token.rs); Kontext beendet dann die Sitzung und zeigt die
Anmeldung. Einmal mit der
Umgebungsvariablen STALWART_UPDATE_ROLE_PERMISSIONS=true gestartet, ergänzt Stalwart die
Standardrollen um die Rechte, die seit 1.0.0 dazugekommen sind (etwa die Verschlüsselung in der
Verwaltung); danach die Variable wieder entfernen. Kontext braucht das nur für den
Speicherschlüssel, falls der Einrichtungsbefehl darauf hinweist.
Stalwart 0.16 auf 1.0 heben¶
Kontext ab dieser Version braucht Stalwart 1.0. Stalwart 1.0 legt seine Daten anders ab und
öffnet die Datenbank von 0.16 nicht (The data store was created by a Stalwart version prior to
1.0 and cannot be opened by this version): Ein git pull mit up -d allein lässt Stalwart nicht
mehr starten, es gehen dabei aber keine Daten verloren. Die Daten kopiert Stalwarts
Migrationswerkzeug stalwart-migrate in
eine neue, leere Datenbank; die alte liest es nur. Ausführlich:
Stalwarts Anleitung.
stalwart-migrate ab Version 1.0.4 nehmen: 1.0.0 bis 1.0.3 brechen run --final bei der
Prüfung ab, sobald es Kontakte gibt (contactCard … differs in the target: vcard, für jede vCard,
deren N leere Felder am Ende hat; so speichert 0.16 sie). Die alte Datenbank bleibt dabei
unberührt, Stalwart 0.16 wieder starten genügt.
- Backup machen (Backups) und prüfen, dass Stalwart 0.16.27 läuft (frühere 0.16 erst auf 0.16.27 heben). Kontext und Stalwart bleiben vorerst auf der alten Version.
- Mit dem Docker-Image
stalwartlabs/stalwart-migrate:1.0.4(Netz, Volumes und Umgebung von Stalwart, neue Volumes für die 1.0-Daten und den Stand der Migration) zuerstprepareausführen. Es fragt nach, was sich nicht von selbst übersetzen lässt – unter anderem die Client-Adresse hinter Caddy: X-Forwarded-For mit einem Proxy, wie in Schritt 5. - Stalwart anhalten,
run --finalausführen und den Bericht lesen. - Das neue Daten-Volume an Stalwarts Stelle setzen,
git pull(Kontext und Stalwart 1.0.1), starten. - In Stalwart 1.0 Kontext als OAuth-Client eintragen und Verzeichnisabfragen erlauben (Schritte 12 und 13).
- Alle melden sich einmal neu an: Stalwarts Tokens wandern nicht mit, Kontext nimmt die
abgelehnten Sitzungen zurück. Mailprogramme mit Passwort laufen weiter; CalDAV und CardDAV
laden Kalender und Kontakte einmal neu, die Volltextsuche baut Stalwart im Hintergrund neu auf.
Kontext bis 0.1.16 reicht die Ablehnung nur weiter: Die App versucht es mit dem alten Token
immer wieder, bis Stalwart die Adresse der Person für einen Tag sperrt (die App zeigt nur
„Something went wrong“, siehe Fehlersuche). Dann vor dem Start von Kontext die
alten Tokens löschen:
… exec kontext bun -e 'import {Database} from "bun:sqlite"; new Database("/data/kontext.db").run("delete from stalwart_grant")'(docker compose -f deploy/compose.yamloderpodmanfür…).
Mit Quadlet sah das auf einem Server so aus (Volumes stalwart-config und stalwart-data,
Netz kontext). Die Werte der beiden Environment=-Zeilen aus stalwart.container übernehmen:
mig() { podman run --rm --network kontext \
-e STALWART_HOSTNAME=mail.firma.de -e STALWART_PUBLIC_URL=https://kontext.firma.de \
-v stalwart-config:/etc/stalwart:ro -v stalwart-data:/var/lib/stalwart \
-v stalwart-v1-config:/etc/stalwart-v1 -v stalwart-v1-data:/var/lib/stalwart-v1 \
-v stalwart-migrate-state:/var/lib/stalwart-migrate \
docker.io/stalwartlabs/stalwart-migrate:1.0.4 "$@" \
--source /etc/stalwart/config.json --state /var/lib/stalwart-migrate; }
V=/var/lib/containers/storage/volumes
for v in stalwart-v1-config stalwart-v1-data stalwart-migrate-state; do podman volume create $v; done
# Die neue Datenbank liegt im 1.0-Container später wieder unter /var/lib/stalwart
echo '{"@type":"RocksDb","path":"/var/lib/stalwart/"}' > $V/stalwart-v1-config/_data/config.json
chown -R 2000:2000 $V/stalwart-v1-config/_data $V/stalwart-v1-data/_data
mig prepare --non-interactive # schreibt answers.toml mit den offenen Fragen
nano $V/stalwart-migrate-state/_data/answers.toml # die Tabellen einkommentieren (Empfehlungen)
T="--target /etc/stalwart-v1/config.json --target-path-map /var/lib/stalwart=/var/lib/stalwart-v1"
mig prepare --non-interactive # „Every question has an answer“
mig run $T && mig verify --full $T # Probelauf am laufenden Server, ohne Ausfall
systemctl start kontext-backup
systemctl stop schreibzeug kontext stalwart
mig run --final --yes $T # muss mit „verify: … 0 mismatches“ enden
# Die 0.16-Daten aufheben (Rollback), die 1.0-Daten an ihre Stelle
for v in config data; do
podman volume create stalwart-016-$v
cp -a $V/stalwart-$v/_data/. $V/stalwart-016-$v/_data/
rm -rf $V/stalwart-$v/_data/*
cp -a $V/stalwart-v1-$v/_data/. $V/stalwart-$v/_data/
done
Danach in /etc/containers/systemd/ die Images heben wie unter Updates, systemctl
daemon-reload, systemctl start stalwart, die Schritte 5 und 6 oben, dann Kontext und
Schreibzeug starten. stalwart-v1-* werden danach nicht mehr gebraucht; stalwart-016-* und
stalwart-migrate-state (für rollback --forward-state) bleiben, bis 1.0 eine Weile läuft.
Das alte Volume erst löschen, wenn 1.0 eine Weile läuft: stalwart-migrate rollback kopiert die
Daten notfalls zurück nach 0.16.
Kontext und Schreibzeug haben getrennte Images und Versionen (KONTEXT_VERSION,
SCHREIBZEUG_VERSION); beim Start prüft Kontext nur, ob Schreibzeug antwortet, und schreibt sonst
eine Warnung ins Log.
Einstellungen im Überblick¶
deploy/.env (Compose)¶
compose.yaml liest diese Datei und reicht daraus an jeden Dienst nur, was er braucht;
backup.sh liest sie ganz. Mit Quadlet gibt es sie nicht: Die Werte stehen dort in den
.container-Dateien und in kontext.env, livekit.env …, die Backup-Werte in backup.env.
| Variable | Standard | Wofür |
|---|---|---|
KONTEXT_DOMAIN |
– | Adresse von Kontext im Browser; Caddy holt das Zertifikat, Kontext bekommt https://<…> als BETTER_AUTH_URL, Stalwart als öffentliche Adresse |
STALWART_HOSTNAME |
– | Name des Mailservers (MX, Reverse DNS) |
MAIL_DOMAIN |
leer | die Mail-Domain: Caddy liefert mta-sts.<…> (MTA-STS) und openpgpkey.<…> (WKD) |
ACME_EMAIL |
– | Adresse für Let's Encrypt |
TAILSCALE_IP |
127.0.0.1 |
wo Stalwarts Verwaltung (Port 8080) lauscht; mit Tailscale die Tailscale-Adresse des Servers |
KONTEXT_SECRET |
– | Pflicht, mindestens 32 Zeichen; wird zu BETTER_AUTH_SECRET |
STALWART_ADMIN_KEY |
leer | API-Schlüssel für die Firmenverwaltung |
STALWART_BLOB_KEY |
leer | Schlüssel, mit dem Stalwart Mails und Dateien verschlüsselt (openssl rand -base64 32); einmal gesetzt nie ändern (Speicher verschlüsseln) |
KONTEXT_VERSION |
– | Pflicht: Version des Images tristanteu/kontext |
STALWART_VERSION |
– | Pflicht: Version des Images stalwartlabs/stalwart (ohne „v“), mit KONTEXT_VERSION gehoben |
COMPOSE_PROFILES |
– | calls (LiveKit, LiveKit SIP, Redis) und documents (Schreibzeug) |
LIVEKIT_SECRET |
leer | Geheimnis für LiveKit und LiveKit SIP; leer: keine Anrufe |
SCHREIBZEUG_URL |
leer | Adresse von Dokumente; leer: kein Modul |
SCHREIBZEUG_CLIENT_SECRET |
leer | Geheimnis, mit dem sich Schreibzeug bei Kontext anmeldet |
SCHREIBZEUG_VERSION |
latest |
Version des Images tristanteu/schreibzeug |
PLACES_URL |
Photon von komoot | Ortsvorschläge; leer: aus (Ortsvorschläge) |
RESTIC_REPOSITORY, RESTIC_PASSWORD |
– | Ziel und Passwort der Backups |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
– | Schlüssel für ein S3-Ziel |
BACKUP_WARN_GB |
8 |
ab dieser Größe des Backups schreibt backup.sh eine Warnung |
Der Kontext-Server¶
Was der Server selbst liest (server/env.ts). compose.yaml setzt davon nur die Zeilen unter
kontext: environment:; mit Quadlet stehen sie in kontext.container und kontext.env. Das
Image setzt PORT=3000, STATIC_DIR=/app/dist, DATABASE_PATH=/data/kontext.db und
NODE_ENV=production. Die Standardwerte in der Tabelle sind die ohne Image (Entwicklung).
| Variable | Standard | Wofür |
|---|---|---|
PORT |
18090 |
Port des Servers (im Image 3000) |
STALWART_URL |
http://127.0.0.1:18080 |
Stalwarts HTTP, direkt (in Compose http://stalwart:8080) |
BETTER_AUTH_URL |
http://localhost:5173 |
öffentliche Adresse von Kontext |
BETTER_AUTH_SECRET |
– | Pflicht, mindestens 32 Zeichen, kein change-me: signiert die Sitzungen, verschlüsselt die Stalwart-Tokens |
DATABASE_PATH |
.dev/server/kontext.db |
die SQLite-Datenbank (im Image /data/kontext.db) |
SIGN_IN_PER_MINUTE |
10 |
Anmeldungen je Minute und IP-Adresse |
STALWART_ADMIN_KEY |
leer | API-Schlüssel (API_…) oder name:passwort für die Firmenverwaltung |
DOH_URL |
https://cloudflare-dns.com/dns-query |
DNS-over-HTTPS (JSON-API) für „Sicherheit der Mails“ und die Suche nach S/MIME-Zertifikaten |
SMIME_ROOTS |
leer | Pfad zu einer PEM-Datei mit weiteren anerkannten S/MIME-Stellen, etwa der Verwaltungs-PKI; einfacher in der Firmenverwaltung unter „S/MIME-Stellen“ |
LIVEKIT_URL |
leer | LiveKits API, wie der Server sie erreicht (in Compose http://livekit:7880) |
LIVEKIT_API_KEY |
leer | LiveKit-Schlüssel (in Compose kontext) |
LIVEKIT_API_SECRET |
leer | LiveKit-Geheimnis, mindestens 32 Zeichen; ohne die drei keine Anrufe |
LIVEKIT_PUBLIC_URL |
leer | wo Browser LiveKit erreichen, wenn nicht über Kontexts Adresse (/rtc über Caddy) |
PHONE_SIP_HOST |
Host von BETTER_AUTH_URL |
wo die Telefonboxen LiveKit SIP erreichen |
PHONE_SIP_PORT |
5060 |
SIP-Port (TCP) dafür |
SCHREIBZEUG_URL |
leer | Adresse von Schreibzeug; damit gibt es „Dokumente“ |
SCHREIBZEUG_CLIENT_ID |
schreibzeug |
Client-ID von Schreibzeug (dessen OIDC_CLIENT_ID) |
SCHREIBZEUG_CLIENT_SECRET |
leer | dessen OIDC_CLIENT_SECRET, mindestens 32 Zeichen |
PLACES_URL |
https://photon.komoot.io/api/ |
Photon für Ortsvorschläge; leer: aus |
STATIC_DIR |
leer | die gebaute App; gesetzt, liefert der Server sie mit aus |
NODE_ENV |
– | production im Image; sonst nimmt der Server Entwicklungswerte (ein festes Geheimnis, Admin und LiveKit aus dev/config.ts) |
Nur für Tests: MAIL_SYNC_ALLOW_PLAIN, UNSUBSCRIBE_ANY_TARGET, CALENDAR_SUBSCRIBE_ANY_TARGET, PUSH_ANY_ENDPOINT,
SNOOZE_TICK_MS, REMINDER_TICK_MS, RULE_TASK_TICK_MS, M365_LOGIN_URL und GRAPH_URL
(siehe Entwicklung).
Variablen, die compose.yaml nicht weitergibt (DOH_URL, SMIME_ROOTS,
LIVEKIT_PUBLIC_URL, PHONE_SIP_HOST, PHONE_SIP_PORT, SIGN_IN_PER_MINUTE): In .env
eingetragen kommen sie nicht im Container an. Mit Compose gehören sie als Zeile unter
kontext: environment: in deploy/compose.yaml – eine lokale Änderung; stößt ein git pull auf
dieselbe Stelle, vorher git stash, danach git stash pop. Eine PEM-Datei für SMIME_ROOTS muss
außerdem in den Container:
kontext:
environment:
# … die vorhandenen Zeilen
SMIME_ROOTS: /etc/kontext/smime-roots.pem
DOH_URL: https://<eigener DoH-Dienst mit JSON-API>/dns-query
volumes:
- kontext-data:/data
- ./smime-roots.pem:/etc/kontext/smime-roots.pem:ro
Die Datei liegt dann als deploy/smime-roots.pem daneben und muss für den Benutzer bun im
Container lesbar sein (chmod 644). Mit Quadlet kommen die Variablen in kontext.env und die
Datei per Volume=/opt/kontext/deploy/smime-roots.pem:/etc/kontext/smime-roots.pem:ro in
kontext.container; danach systemctl daemon-reload und systemctl restart kontext.
Fehlersuche¶
Die Logs: docker compose -f deploy/compose.yaml logs <dienst> (Compose) bzw.
podman logs <dienst> (Quadlet), für die Sicherung journalctl -u kontext-backup.
- Niemand kann sich mehr anmelden, auch mit richtigem Passwort: Stalwart hat Kontexts Adresse
gesperrt. Kontext meldet alle von einer Adresse an; ohne den Eintrag
10.89.250.0/24unter den erlaubten IPs (Stalwart einrichten, Schritt 6) sperrt Stalwart sie nach 100 Fehlversuchen am Tag. Den Eintrag nachholen und die Sperre löschen: Stalwart führt sie alsBlockedIp(über die APIx:BlockedIp/get, dannx:BlockedIp/setmitdestroy; Helfer in VPS einrichten, Schritt 10). Mit einer Sperrdauer (Schritt 7) läuft sie von selbst ab. Steht im Log von KontextStalwart lehnt die Anmeldung für Kontext ab, fehlt der OAuth-Clientkontextoder sein Scopefull(Schritt 12, Stalwart 1.0.3); stehtinvalid_scopedabei, ist es der Scope. - Für eine Person zeigt die App nur „Something went wrong“: Stalwart hat die Adresse dieser
Person gesperrt (
Banned due to authentication errors … remoteIp = <ihre Adresse>im Log von Stalwart), etwa weil Kontext bis 0.1.16 nach dem Umzug auf Stalwart 1.0 alte Tokens weiterschickte. Die Sperre mitx:BlockedIp/setunddestroylöschen und danach die AktionReloadBlockedIpsauslösen (x:Action/setmit{"@type":"ReloadBlockedIps"}). Ohne sie hält Stalwart 1.0 die Sperre im Speicher und antwortet weiter mit 403, obwohl sie in der Liste nicht mehr steht. - Kein Zertifikat, der Browser warnt: Caddy holt die Zertifikate beim Start
(
logs caddy). Die A-Einträge müssen vorher auf den Server zeigen und dürfen bei Cloudflare nicht über den Proxy laufen (siehe Server und DNS); Ports 80 und 443 müssen offen sein. Fragte der Server, bevor es die Einträge gab, merkt sich sein Resolver das eine Weile (VPS einrichten, Schritt 7). - Mails kommen nicht an oder landen im Spam: Port 25 beim Anbieter freigegeben, Reverse DNS gesetzt (VPS einrichten, Schritt 5), SPF, DKIM und DMARC gesetzt; die Firmenverwaltung prüft unter „Sicherheit der Mails“, was fehlt. Sperrlisten: VPS einrichten, Schritt 12.
- „Schreibzeug antwortet nicht“ im Log von Kontext: Beim ersten Start harmlos, wenn
Schreibzeug Kontext noch nicht fand (VPS einrichten, Schritt 9).
Bleibt es, prüfen, ob
SCHREIBZEUG_URLim DNS steht (getent hosts dokumente.firma.deauf dem Server) und der Dienst läuft (logs schreibzeug). - Neue Mails erscheinen erst nach dem Neuladen: Push braucht eine Verbindung, die ein Proxy
nicht puffert. Durch den Cloudflare-Proxy geht sie nicht (siehe Server und DNS);
ein eigener Proxy vor Caddy muss
/api/liveund/jmap/eventsourceungepuffert und unkomprimiert durchreichen, wie esdeploy/Caddyfileselbst tut. - Anrufe ohne Ton oder Bild: TCP 7881 und UDP 7882 offen, hinter NAT
node_ipsetzen (Anrufe). - Die Telefonbox meldet sich nicht an der Fritzbox an: siehe Telefonie, „Wenn die Box sich nicht an der Fritzbox anmeldet“; eine Box vor v0.1.4 kann es nicht.
- „Steuerung auf dem Server nicht eingerichtet“ unter Datensicherung: die systemd-Units sind nicht installiert oder laufen nicht (Backups).
- Weitere Postfächer fehlen in den Einstellungen, Einladungen haben keine Knöpfe:
STALWART_ADMIN_KEYfehlt (Firmenverwaltung einschalten). Fehlt nur die Firmenverwaltung, hat das eigene Konto in Stalwart die RolleFirmenverwaltung (Kontext)noch nicht (dort Schritt 4).