Zum Inhalt springen

Backends

Als Markdown ansehen

Das Backend einer App ist eine Docker-Compose-Datei im Paket, die der Daemon gegen eine Allowlist prüft, umschreibt und ausführt. Browser erreichen es nur über den Proxy des Daemons, jede Anfrage trägt die Identität des angemeldeten Benutzers in Headern, die der Proxy setzt, und die Container laufen in einem privaten Netzwerk, mit eigener Benutzer-ID, auf dem Pool, den der Administrator gewählt hat.

Die Datei ist backend/compose.yaml. Die Regeln stehen in sdk/schema/compose.v1.rules.json und werden vom CLI und vom Daemon gemeinsam genutzt, sodass pithyx validate die Antwort der Box gibt. Ein Schlüssel, der nicht auf der Liste steht, wird abgelehnt (app.compose.key_unsupported). Die Box schreibt die Datei um, bevor sie sie ausführt.

Grenzen: 65.536 Bytes pro Datei, Verschachtelungstiefe 10, 256 Einträge, 16 Services, 32 Volumes, Speicherlimits von 6 MiB bis 256 GiB, höchstens 256 CPUs. YAML-Anker, Aliase, Tags, Merge-Schlüssel, doppelte Schlüssel und x-*-Schlüssel werden abgelehnt.

Oberste Ebene: services (Pflicht) und volumes.

Pro Service:

  • image ist Pflicht und per Digest festgelegt (name@sha256:...).
  • Erlaubt: command, entrypoint, environment, working_dir, volumes, tmpfs, depends_on, healthcheck, expose, read_only, init, stop_signal, stop_grace_period, shm_size, mem_limit, cpus, cap_drop und labels (nicht mit den Präfixen com.pithyx oder com.docker).
  • Braucht ein erhöhtes Recht, das diese Version nicht vergibt (abgelehnt mit app.elevated.unsupported): network_mode: host, pid: host, devices, cap_add und privileged.
  • Mounts sind benannte Volumes oder tmpfs. Bind-Mounts werden abgelehnt, weil Host-Pfade nie eingehängt werden. Die Ziele /proc, /sys und /dev sowie die Docker-Sockets werden abgelehnt.
  • Mit einer Begründung im Fehlertext abgelehnt: ports (Backends sind nur über den Proxy erreichbar), build (die Box baut nie), networks, links, env_file, secrets, configs, user (die Box setzt die Benutzer-ID), restart (die Box steuert den Lebenszyklus), logging, deploy (nutze mem_limit und cpus), security_opt, sysctls, ulimits, container_name, extends, profiles und mehr.

Quellform. Während der Entwicklung darfst du backend/compose.dev.yaml behalten, die denselben Regeln folgt, dazu build: (ein String oder context, dockerfile, target, args). Jeder Service hat genau eines von image oder build. pithyx build macht daraus compose.yaml und images.lock.json. Die Box liest die Dev-Datei nie.

Image-Sperrdatei. backend/images.lock.json listet für jedes Image (bis zu 16) die Manifest- und Config-Digests für amd64 und arm64 auf. Die Box prüft jedes Image, das sie lädt, gegen den Config-Digest (app.image.mismatch). Eine fehlende oder veraltete Sperrdatei ergibt app.package.lock_invalid. Images kommen aus backend/images/<arch>/<digest>.tar im Paket oder werden per Digest gezogen.

Was die Box beim Umschreiben der Datei hinzufügt:

  • user: <uid>:<uid> (oder 0:0 mit backend.root) und die Gruppen-IDs zugeordneter freigegebener Ordner,
  • ein privates Netzwerk für die App und ein zweites aus der Box hinaus nur, wenn die App network.internet oder network.lan hat (für die Services in backend.network, ohne die Angabe für alle),
  • cap_drop: [ALL] für jeden Service, außer die App läuft als root (backend.root), dann bleibt Dockers Standardsatz; ein eigenes cap_drop wird ersetzt,
  • restart: "no" (der Daemon startet und stoppt das Backend), Logging nach journald, pids_limit: 1024, oom_score_adj: 500, no-new-privileges,
  • die Labels com.pithyx.app, com.pithyx.service und com.pithyx.version,
  • benannte Volumes als Ordner unter <pool>/.pithyx/apps/<id>/volumes/<name> und zugeordnete freigegebene Ordner als Bind-Mounts (schreibgeschützt, wenn der Administrator weniger erlaubt hat).

Ein Endpunkt ordnet im Manifest einem Namen einen Service und einen Container-Port zu. Die Route enthält nie den Port:

  • Endpunkt api: /apps/<id>/api/...
  • Endpunkt <name>: /apps/<id>/api-<name>/...

Der Pfad nach dem Präfix wird weitergeleitet. Das SDK bietet app.backend.fetch(path, { endpoint }), url, socket und token; siehe @pithyx/sdk.

Authentifizierung am Proxy. HTTP-Anfragen tragen Authorization: Bearer <token> oder ?pithyx-token=<token>. WebSockets tragen ein einmaliges ?pithyx-ticket=<ticket> für diesen Endpunkt. Ein Cookie authentifiziert nie. Ein Token für eine andere App oder ein ungültiges Token ergibt 401 app.token.invalid.

Verhalten:

  • Ein Preflight des Browsers (OPTIONS mit Origin: null) wird mit Access-Control-Allow-Origin: * beantwortet; andere Origins bekommen 403.
  • Request-Bodys haben am Proxy keine Größenbegrenzung; das Backend entscheidet.
  • Das Verbindungs-Timeout beträgt 5 Sekunden. Inaktive WebSockets bleiben bis zu 24 Stunden ohne Aktivität bestehen.
  • Ist das Backend gestoppt oder startet es gerade, weckt die Anfrage es und wartet bis zu 30 Sekunden. Läuft es nicht, wird es aktualisiert oder ist es nicht verfügbar, lautet die Antwort 503 app.backend.unavailable.
  • Der Proxy entfernt aus der Antwort Header, die gefährlich wären (zum Beispiel Set-Cookie, Clear-Site-Data, HSTS), und fügt die Sandbox-Header hinzu.

Der Proxy entfernt aus der eingehenden Anfrage Cookie, Authorization, Proxy-Authorization, Referer, Forwarded, jeden X-Pithyx-*- und X-Forwarded-*-Header sowie die Query-Parameter pithyx-token und pithyx-ticket. Danach setzt er:

Header Wert
X-Pithyx-User der Benutzername, der Linux-Benutzername in Kleinbuchstaben
X-Pithyx-User-Id die Benutzer-ID: die Linux-UID, dieselbe wie context.user.id und DirectoryUser.id, nie an einen anderen Benutzer vergeben
X-Pithyx-Groups die Gruppennamen, durch Kommas getrennt; leer für einen Benutzer ohne Gruppe
X-Pithyx-Admin true oder false
traceparent der Trace der Anfrage
X-Forwarded-Prefix /apps/<id>/api oder /apps/<id>/api-<name>
X-Forwarded-For, -Host, -Proto der ursprüngliche Client und Host

Ein eingehendes traceparent oder tracestate fällt ebenfalls weg: Der Proxy schreibt den Trace seiner eigenen Anfrage, ein Fenster kann ihn also nicht wählen. Jeder Endpunkt und jedes WebSocket-Upgrade bekommt dieselben Header. Ein Backend kann diesen Headern nur vertrauen, weil es ausschließlich über den Proxy erreichbar ist.

Zeitlimits des Proxys. Er gibt den Verbindungsaufbau zu einem Backend nach 5 Sekunden auf und wartet bei jedem Lesen der Antwort bis zu 100 Sekunden (eine lange Antwort, die weiter sendet, bleibt offen); danach bekommt das Fenster 502 oder 504. Ein ruhender WebSocket bleibt 24 Stunden offen.

Zeitpläne. Der Aufruf eines Zeitplans kommt nicht über den Proxy und hat keinen Benutzer: Der Daemon sendet POST <path> mit leerem Body und nur X-Pithyx-Schedule: <ID des Zeitplans> und traceparent, ohne Benutzer-Header. Weil der Proxy jeden eingehenden X-Pithyx-*-Header entfernt, kann eine Anfrage aus einem Fenster nie X-Pithyx-Schedule tragen; ein Backend kann sich also darauf verlassen: Nimm einen Zeitplan-Pfad nur mit diesem Header an und beantworte ihn ohne mit 403. Ein Lauf erscheint im Log Center (Quelle daemon, mit seinem Statuscode), nicht in der Auftragsliste.

Tokens. Der Desktop fordert sie mit POST /api/apps/{appId}/tokens an (Cookie und CSRF-Header). Ein Token gilt 5 Minuten, hat 256 Zufallsbits und ist an Benutzer, Sitzung, App und Fenster gebunden. host-Tokens funktionieren nur für /api/app/**, backend-Tokens nur für den Proxy. Ein Ticket gilt 30 Sekunden und für eine Verwendung. Jede Verwendung prüft Sitzung, Benutzer und Berechtigung erneut; wird eine Berechtigung entzogen oder meldet sich der Benutzer ab, werden die weitergeleiteten Sockets geschlossen. Tokens und Tickets werden in Logs maskiert.

  • Jede App hat ein Dataset auf dem App-Pool, den der Administrator bei der ersten Installation gewählt hat: /<pool>/.pithyx/apps/<id>/ mit versions/<version>, volumes/ und Laufzeitdateien. Die eigenen Daten von Docker liegen auf demselben Pool. Auf der Systemfestplatte wird nichts gespeichert.
  • Deklariere volumes: auf oberster Ebene in Compose und hänge sie per Namen ein; sie werden zu Ordnern, die der Benutzer-ID der App gehören.
  • Speicheranforderungen aus dem Manifest erscheinen als Compose-Volume mit der id der Anforderung. Eine optionale Anforderung, die niemand zugeordnet hat, wird zu einem Ordner im Dataset der App. Eine erforderliche, nicht zugeordnete Anforderung verhindert den Start des Backends; das Fenster sieht dann unavailable mit dem Grund storage.
  • Ist der Pool gesperrt oder fehlt er, startet das Backend nicht, der Store nennt den Grund, und das Fenster sieht unavailable mit dem Grund pool.
  • Ein Update erstellt einen Snapshot des Datasets; ein Rollback stellt ihn wieder her. Bei der Deinstallation wählt der Administrator, ob die Daten behalten werden.
  • app.storage des SDK (Schlüssel-Wert-Speicher pro Benutzer) ist davon getrennt: Schlüssel bis 128 Zeichen, Werte bis 64 KB, 1 MB pro Benutzer und App.

Eine Speicheranfrage im Manifest und ihr Volume in Compose, für einen Dienst:

"storage": [{ "id": "clips", "name": { "en": "Clips" }, "access": "readWrite", "required": true, "services": ["api"] }]
services:
api:
volumes:
- clips:/clips
volumes:
clips:

Was das Backend in einem solchen Ordner sieht:

  • Es erkennt nicht, ob zugeordnet ist. Eine optionale Anfrage, die niemand zugeordnet hat, ist ein Ordner im Dataset der App, und der Dienst sieht in beiden Fällen einen leeren oder der App gehörenden Ordner. Nur das Fenster weiß es (app.context.storage, storageChange); ein Backend, das es wissen muss, fragt sein Frontend oder legt sich eine eigene Markierungsdatei an.
  • Späteres Zuordnen fängt neu an. Ordnet ein Administrator eine optionale Anfrage später zu, startet das Backend mit dem neuen Ordner neu. Was es in den internen Ordner geschrieben hat, bleibt dort und wird nicht verschoben; kopiere es selbst, wenn es wichtig ist.
  • Dateien gehören der App und folgen den Rechten der Freigabe. Eine Datei, die das Backend in einen zugeordneten Ordner schreibt, gehört der Benutzer-ID der App und erbt die Standard-ACL des Ordners. Benutzer mit Schreibrecht auf die Freigabe können sie also über SMB, SFTP, WebDAV und FTP ändern und löschen. Lege den eigenen Zustand der App nie in einen zugeordneten Ordner, sondern in ein benanntes Volume.
  • Jede App bekommt ein eigenes internes Docker-Bridge-Netzwerk ohne Internet. Services erreichen sich über den Service-Namen.
  • Mit network.internet oder network.lan treten die Services aus backend.network.services des Manifests (ohne die Angabe jeder Service) zusätzlich einem zweiten, nicht internen Netzwerk bei. Halte die anderen davon fern: In einer App, die Seiten abruft, braucht nur der abrufende Service den Weg nach draußen.
  • Wohin dieses Netzwerk führt, entscheidet die Firewall der Box: mit network.internet ins Internet, mit network.lan ins lokale Netzwerk (private, geteilte und Link-local-Bereiche: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, 169.254.0.0/16, fc00::/7, fe80::/10, dazu Loopback und Multicast), mit beiden zu beidem. network.internet allein erreicht weder Router noch Drucker noch andere Server. DNS zu den Resolvern der Box geht immer, Namen lösen sich also auch ohne network.lan auf.
  • Eine App, die Adressen abruft, die Benutzer eintippen, sollte sie trotzdem prüfen: Mit network.lan könnte ein Benutzer sie auf jedes Gerät im Netzwerk richten.
  • Auf dem Host werden keine Ports veröffentlicht, und eine nftables-Regel verwirft neue Verbindungen aus den App-Netzwerken zum Host, auch mit network.lan.
  • Jede App hat ihre eigene Benutzer-ID (und Gruppen-ID) von 40000 bis 49999. Mit root: true ist der Benutzer im Container root, aber die Remapping-Funktion der User-Namespaces hält ihn auf dem Host ohne Rechte.
  • Die Härtung ist immer aktiv: no-new-privileges, ein Prozesslimit von 1024, ein erhöhter Out-of-Memory-Score und keine Linux-Capabilities für Services, die nicht als root laufen.
  • Docker-Socket und privileged (die Rechte docker.socket und host.full) werden in dieser Version abgelehnt. Host-Netzwerk, Host-PID, Geräte und Capabilities brauchen das passende erhöhte Recht, das der Installationsdialog anzeigt.
  • always: läuft, sobald es kann.
  • onDemand: läuft, solange ein Benutzer ein Fenster-Lease hält, eine Anfrage oder ein Zeitplan es hält, und idleTimeout Sekunden (Standard 600) nach dem letzten davon. Der Desktop abonniert das Topic apps/<id>/context einmal pro App und Tab; das Schließen des letzten Fensters beendet das Lease sofort, und eine verlorene Verbindung hält es noch 2 Minuten.
  • Installieren, Aktualisieren oder Aktivieren startet ein onDemand-Backend einmal, damit ein kaputtes Backend die Installation scheitern lässt; danach läuft es nach idleTimeout leer wie nach einem geschlossenen Fenster.
  • Ein fehlgeschlagener Start wird nach 1 Minute wiederholt; das Öffnen eines Fensters wiederholt ihn sofort.
  • Start-Timeouts: Compose up --wait wartet bis zu 10 Minuten, danach der Health-Check des Manifests bis zu 60 Sekunden.
  • Stopp: docker compose down --timeout 30. Container bekommen ihr stop_signal (standardmäßig SIGTERM) und werden nach 30 Sekunden beendet; der Timeout ersetzt ein stop_grace_period der Compose-Datei. Volumes und Images bleiben.
  • Der Health-Aufruf ist ein einfaches GET <path> an seinen Endpunkt ohne Benutzer-Header. Jeder Versuch darf 5 Sekunden dauern; er läuft nur, während das Backend startet, nicht danach.
  • Health: Nimm lieber den health des Manifests (eine HTTP-Prüfung durch den Daemon, während des Starts alle 100 ms) als einen Compose-healthcheck. Eine Compose-Prüfung startet bei jedem Intervall einen Prozess im Container, und up --wait wartet auf ihren ersten Erfolg. Gemessen mit einem kleinen Node-Backend auf der Box: Ein Kaltstart dauerte 2,5 Sekunden mit einer Node-Prüfung jede Sekunde während start_period und 1,05 Sekunden nur mit dem health des Manifests; im Leerlauf kostete die Compose-Prüfung etwa 2 Prozent eines Kerns (im Container und in Docker), ohne sie etwa 0,15 Prozent. pithyx validate warnt vor einem Compose-healthcheck neben health (cli.compose.healthcheck_redundant). Ein HEALTHCHECK des Images läuft ebenfalls; schalte ihn mit healthcheck: { disable: true } ab.
  • Ein Browser auf der Box selbst sieht jeden Start eines Backends als Änderung des Netzwerks (Docker hängt den Container an die Bridge der App), und Chromium bricht laufende Anfragen mit net::ERR_NETWORK_CHANGED ab. Browser auf anderen Geräten sehen das nicht. Lade neu, wenn backendStatus zu running wird (siehe die Sonderfälle im Backend-Guide).
  • Zeitpläne wecken das Backend ohne Fenster, rufen POST path auf (das Backend bleibt dafür bis zu 10 Minuten gehalten) und lassen es dann leerlaufen.
  • Das Fenster sieht den Status stopped, starting, running, updating oder unavailable, mit dem Grund storage oder pool, wenn es nicht starten kann (SDK-Ereignis backendStatus).
  • Docker startet nur, solange eine Backend-App installiert und ihr Pool eingehängt ist.

Pithyx hat kein Keep-alive-Lease für Hintergrundarbeit; nutze always oder Zeitpläne.