happyin.work / Blog

English · Русский · Español · Deutsch · Português (Brasil) · 简体中文 · 日本語

Ein ComfyUI-Workflow als öffentlicher Dienst, ohne ComfyUI offenzulegen

Autorin: Anastasiia Butova, Ingenieurin für ComfyUI und Diffusionsmodelle, Belgrad · veröffentlicht 2026-09-23

Übersetzung der englischen Fassung: English

Das Autostudio auf /mashinki/ erlaubt es jedem, ein Auto in einen Showroom zu setzen, auf Rendern zu drücken und ein Bild aus der echten Pipeline zurückzubekommen: FLUX.2 Klein 9B mit unserem LoRA für die Wiederherstellung der Geometrie, auf der GPU-Kiste von mashinki, in 30 bis 60 Sekunden. Das ComfyUI, das es rendert, ist überhaupt nicht im Internet veröffentlicht. Dazwischen sitzt ein kleiner Python-Dienst, und dieser Beitrag handelt davon, was er annimmt, was er ablehnt und den Tunnel, der an der Stelle, wo ich ihn zuerst aufgestellt hatte, nicht funktionierte.

Jede Produktions-ComfyUI-Pipeline, die ich gebaut habe, läuft so: Der Graph wird per Code eingereicht, nicht im Editor angeklickt (mehr zu dieser Art Arbeit auf /comfyui/). Meist ist der Aufrufer ein Backend. Hier ist der Aufrufer jeder mit einem Browser, was jede Entwurfsfrage schärfer macht.

Warum /prompt nie ins Internet ragt

Die HTTP-API von ComfyUI ist für die Person gebaut, der die Maschine gehört. POST /prompt nimmt einen ganzen Graphen im API-Format entgegen und führt ihn aus. Ein Graph ist ein Programm: Ein Lade-Node nimmt einen Dateinamen, ein Speicher-Node schreibt eine Datei, und jeder installierte Custom Node auf dem Server ist eine weitere Funktion, die der Aufrufer mit selbst gewählten Argumenten aufrufen kann. Wer also /prompt erreicht, kann jedes Modell auf dem Rechner laden, alles lesen, was der ComfyUI-Prozess lesen darf, und überall dorthin schreiben, wo er schreiben darf. Es gibt kein Feld in dieser Anfrage, das man validieren könnte, denn die Anfrage ist der Code.

Für mich ist das nicht theoretisch. Im Februar 2026 minte ein ComfyUI-Container auf einem GPU-Server, mit dem ich arbeitete, Monero. Sein API-Port war ohne Authentifizierung im Internet offen, jemand schickte einen Workflow mit einem Eval-Node aus einem Drittanbieter-Pack, und der Python-Code dieser Person schrieb ComfyUIs eigene execution.py um. Das Aufräum-Runbook habe ich geschrieben, die ganze Geschichte steht in dem Beitrag über Custom Nodes in der Produktion. Im April beschrieb Censys eine Kampagne gegen öffentlich erreichbares ComfyUI, von dem sie noch immer mehr als tausend Instanzen findet, und derselbe Node steht auf ihrer Liste.

Deshalb ist das ComfyUI auf der mashinki-Kiste nirgends veröffentlicht. Der Dienst erreicht es auf der Kiste selbst, unter 127.0.0.1, und die Öffentlichkeit spricht nur mit dem Dienst, der keine Möglichkeit hat, einen Graphen auszudrücken.

Anfragepfad für ein Render: Browser, Cloudflare-Tunnel auf einem Relay-Host, Tailscale, dann studio-api auf der GPU-Kiste, die mit 413, 400, 429 oder 503 antwortet oder einen festen Graphen für ComfyUI unter 127.0.0.1 baut; das Internet hat keine Route zu /prompt

Sechs Werte, und der Graph wird auf unserer Seite gebaut

Der Render-Knopf sendet POST /compose mit fünf Feldern. Der Dienst kennt sechs, weil ein Saal und dein eigener Hintergrund Alternativen sind. Jedes wird begrenzt, bevor es irgendetwas berührt:

Feld akzeptiert
car eine von vier IDs, als Schlüssel im Wörterbuch verwendet
scene eine von sieben Saal-IDs, als Schlüssel im Wörterbuch verwendet; erforderlich, außer bg wird gesendet
x Zahl, begrenzt auf 12–88 (Prozent der Bildbreite)
y Zahl, begrenzt auf 40–96 (die Bodenlinie, Prozent der Höhe)
scale Zahl, begrenzt auf 34–94 (Autobreite, Prozent des Bildes)
bg optionales Base64-Standbild, siehe unten

Ein unbekanntes Auto oder ein unbekannter Saal, JSON, das sich nicht parsen lässt, oder eine Koordinate, die keine Zahl ist, bekommt ein 400. Felder, die der Dienst nicht kennt, werden nie gelesen. Die Prüfung ist so schlicht, wie sie aussieht; das ist der Produktionscode:

car = str(spec.get("car", ""))
scene = str(spec.get("scene", ""))
bg = spec.get("bg")
if bg is not None and not isinstance(bg, str):
    return self._json(400, {"error": "bg must be a base64 string"})
if car not in CARS:
    return self._json(400, {"error": "unknown car"})
# A hall is required only when the caller did not bring their own plate.
if not bg and scene not in SCENES:
    return self._json(400, {"error": "unknown scene"})
try:
    x = float(spec.get("x", 50)); y = float(spec.get("y", 82)); sc = float(spec.get("scale", 62))
except (TypeError, ValueError):
    return self._json(400, {"error": "x, y and scale must be numbers"})
x = max(12.0, min(88.0, x)); y = max(40.0, min(96.0, y)); sc = max(34.0, min(94.0, sc))

Der Graph ist ein Python-Dictionary im Dienst, und alle seine Werte bis auf einen stammen aus der Produktion und nicht von mir. Die Modelldateien, das LoRA bei Schritt 2.000, 6 Schritte, der Sampler euler mit dem Scheduler beta und der Produktions-Prompt Wort für Wort wurden am 28. August aus dem laufenden Dispatcher-Container kopiert, und der Code nennt die Datei, aus der jeder Wert stammt. Der Einfluss des Aufrufers auf diesen Graphen ist gering und indirekt: Die Komposition, die er lädt, ist eine Datei, die der Dienst unter einem selbst erzeugten Namen geschrieben hat, und Körpertyp und Abmessungen im Prompt stammen aus einer Tabelle mit vier Einträgen, den Werten, die unser Autoidentitätsdienst für diese vier Bilder zurückgab. Der Seed ist auch unserer.

Ein Unterschied zur Produktion ist bewusst. Die Produktion fährt den Adapter mit 0,7. Auf der Seite fahre ich ihn mit 1,0, weil ein Demobild stärker ausfallen darf als ein Stapel für einen Händler, und die Seite schreibt diese Zahl hin, während der Auftrag rendert.

Früh ablehnen, spät dekodieren

Das meiste Nachdenken steckte in der Reihenfolge der Prüfungen. Sie laufen von billig nach teuer:

  1. Content-Length über 8 MB: 413, ohne den Body zu parsen.
  2. Eine Adresse, die ihre Stunde bereits aufgebraucht hat und mehr als 2 KB sendet: 429, ebenfalls ohne Parsen des Bodys. Diese Prüfung verbraucht nichts.
  3. JSON parsen und die Felder validieren.
  4. Das Budget: acht Aufträge pro Adresse und Stunde, sonst 429. Die Adresse ist die, die Cloudflare in CF-Connecting-IP übergibt. Ein Auftrag wird hier gezählt, sodass ein 503 im nächsten Schritt trotzdem einen der acht verbraucht.
  5. Die Warteschlange: ein Auftrag läuft, höchstens drei warten, sonst 503.
  6. Erst jetzt, falls ein Hintergrund gesendet wurde, dekodieren.

Ein Bild ist das Teuerste, was der Dienst für einen Fremden tut, und wir schulden es niemandem, den wir ohnehin abweisen würden.

Der 413 hat mich etwas gelehrt, das ich nicht erwartet hatte. Antwortet der Dienst, bevor der Aufrufer den Upload beendet hat, wird der Upload zurückgesetzt, und der Tunnel davor meldet seinen eigenen 502. Gemessen: Ein Body von 20 MB bekam einen Cloudflare-502 statt unserem 413, der Aufrufer sah also ein kaputtes Gateway statt des Grundes. Jetzt liest der Dienst den Body und verwirft ihn in 64-KB-Stücken, bis zu 64 MB, und antwortet dann mit seinem eigenen 413; bei einer angekündigten Größe darüber stoppt er bei 64 MB, antwortet und schließt die Verbindung. Ein Socket-Timeout von 20 Sekunden wirft einen Aufrufer hinaus, der mitten im Upload verstummt.

Das Einzige, was ein Aufrufer senden darf

Ein Betrachter kann ein Foto seines eigenen Saals mitbringen. Es kommt als Base64 im selben JSON an und wird nie so weitergegeben, wie es ankam. Gekürzt aus save_plate, mit drei kurzen Kommentaren von mir:

im = Image.open(io.BytesIO(raw))              # reads the header, not the pixels
if im.format not in PLATE_FORMATS:            # JPEG, PNG, WEBP
    raise PlateRefused(400, "background must be a jpeg, png or webp still")
if getattr(im, "n_frames", 1) > 1:
    raise PlateRefused(400, "background must be a still image, not an animation")
w, h = im.size
if w < 64 or h < 64:
    raise PlateRefused(400, "background is too small to place a car in")
if w > MAX_SIDE or h > MAX_SIDE:              # 6,000 px
    raise PlateRefused(413, "background is larger than %d px on a side" % MAX_SIDE)
im = im.convert("RGB")                        # decodes here, inside the bound checked above
im.save(path, "JPEG", quality=92)             # our encoder, our bytes, from this point on

Die Größe wird aus dem Header gelesen, bevor auch nur ein Pixel entkomprimiert wird, sodass eine 79-KB-Datei, die 8000×8000 angibt, abgelehnt wird, ohne je aufgeblasen zu werden. Nur drei Formate werden akzeptiert, weil das die Formate sind, die ein Dateiauswahldialog im Browser für ein Foto realistisch liefert, und jeder andere Decoder, den PIL mitbringt, wäre eine Angriffsfläche, die wir niemandem zuliebe öffnen. Eine Animation wird abgelehnt, sodass Bild zwei von irgendetwas nie dekodiert wird. Dann wird das Bild als unser eigenes JPEG neu kodiert. Was auch immer im Container versteckt war, eine Nutzlast nach den Bilddaten oder ein feindseliger Metadatenblock, überlebt das nicht, weil nichts davon Pixel ist.

Das Hintergrundbild wird unter der Auftrags-ID geschrieben und gelöscht, wenn der Auftrag endet, erfolgreich oder nicht. Ein Auftrag, der noch in der Warteschlange steht, wenn der Prozess neu startet, erreicht seine Aufräumstufe nie, deshalb entfernt ein Sweep jedes Hintergrundbild, das älter als eine Stunde ist.

Ein weiteres Detail hat den Prompt verändert. Für unsere sieben Säle trägt der Prompt einen Hintergrundhinweis, wo wir einen gemessen haben: Die zwei dunklen Säle bekommen „preserve the dark drapes and dark ceiling exposure“. Bei einem Foto eines Fremden bleibt der Hinweis leer, statt zu raten. Wir haben über diesen Raum nichts gemessen, und ein falscher Hinweis würde die Belichtung eines Raumes verschieben, den das Modell bereits sehen kann.

Was aufbewahrt wird, und wie lange

Eine Auftrags-ID sind 16 Hex-Zeichen, aus einer zufälligen UUID gekürzt. /job/<id> und /img/<id>.jpg entsprechen einem strikten Muster, und /img/ liefert nichts, was nicht ein fertiger Auftrag dieses Prozesses ist. Das JPEG, das es ausliefert, wird eine Stunde aufbewahrt; ein Sweep alle fünf Minuten löscht es und vergisst den Auftrag. Die 2048-Pixel-Komposition, die in den Eingabeordner von ComfyUI ging, wird in einem finally-Block gelöscht. Vor dieser Korrektur blieb die Komposition eines fehlgeschlagenen Renders dauerhaft liegen.

Access-Control-Allow-Origin ist auf https://happyin.work festgelegt. Es lohnt sich, genau zu sagen, was das bringt. CORS verhindert nur, dass ein Skript auf einer anderen Website unsere Antworten im Browser lesen kann; es hält curl nicht auf. Missbrauch begrenzen tatsächlich die schmale Eingabe, das stündliche Budget und die Warteschlange. CORS sorgt nur dafür, dass die Seite eines anderen das Studio nicht als ihr eigenes Backend steuern kann.

Zustände, die die Seite zeigen kann

POST /compose antwortet sofort mit 202 und einer Auftrags-ID; die GPU-Arbeit passiert später. Die Seite fragt GET /job/<id> alle 2,5 Sekunden ab, und die Beschriftung unter dem Bild ist der eigene Zustand der Kiste: waiting, compositing, queued-on-gpu, rendering, dann done oder error. Ich wollte, dass die Wörter von der Kiste kommen, weil ein Timer und die GPU nicht übereinstimmen, sobald die GPU ausgelastet ist. Der Fortschrittsbalken folgt nur der verstrichenen Zeit, auf einer Kurve, die nie 100 % erreicht: Nur ein zurückgegebenes Bild darf ihn beenden.

Intern fragt der Dienst alle halbe Sekunde ComfyUIs /history ab. Früher war es alle vier Sekunden. Der Produktions-Dispatcher fragt mit 0,2 s ab, und bei vier Sekunden wartete der Betrachter bis zu vier zusätzliche Sekunden, nachdem die GPU schon fertig war, was bei einem Durchlauf von etwa 45 Sekunden den größten Teil des Mehraufwands ausmachte, den er sehen konnte. Zwei am 28. August gemessene Durchläufe dauerten 29,5 s und 57,2 s.

Wenn die Kiste ablehnt oder schweigt, komponiert die Seite das Auto stattdessen im Browser und sagt es auf dem Bild. Ein zurückgegebenes Render ist beschriftet als rendered on the gpu, ein lokales als browser composite · draft, und die Bildunterschrift nennt den Grund anhand des Statuscodes: Die acht Bilder dieser Stunde sind verbraucht, die GPU-Warteschlange ist voll, die Datei ist zu groß, die Kiste hat dieses Bild abgelehnt, oder die GPU hat nicht geantwortet. Nach vier Minuten Abfrage hört die Seite auf zu warten. Ein Betrachter sollte eine im Browser zusammengesetzte Montage nie für eine Modellausgabe halten.

Der Tunnel, und wo er nicht laufen konnte

Der öffentliche Name ist studio.happyin.app, bereitgestellt über einen Cloudflare-Tunnel. Der naheliegende Ort für cloudflared war die GPU-Kiste selbst: Ein Tunnel wählt sich nach außen ein, also sollte NAT keine Rolle spielen. Es spielte eine. Hinter dem NAT des mashinki-Hosts registrierte cloudflared Verbindungen und verlor sie binnen Sekunden, über QUIC (failed to dial to edge with quic: timeout: no recent network activity) und ebenso über http2, und der Edge antwortete die ganze Zeit mit 1033 und 502.

Ich habe das NAT nicht bekämpft. Der Tunnel läuft jetzt auf einer anderen Maschine von uns mit stabiler Verbindung, als systemd-Unit um das offizielle cloudflared-Image mit Host-Netzwerk, und erreicht die Kiste über Tailscale. Der Dienst weiß nicht und kümmert sich nicht darum, wo der Tunnel läuft.

Das NAT war allerdings nicht der teuerste Fehler. Bevor irgendetwas davon funktionierte, hatte ich notiert, der Blocker sei ein fehlender Cloudflare-Token-Scope für die Zone happyin.work. Das war er nicht. Ein Token, das ich schon hatte, konnte Tunnel und DNS-Einträge auf einem anderen Konto anlegen, und der Endpunkt musste nie unter happyin.work liegen: Ein Hostname auf happyin.app tut es. Der Blocker war eine Annahme über meinen eigenen Zugriff, und ich hätte prüfen sollen, was meine Zugangsdaten können, bevor ich „blockiert“ geschrieben habe.

Grenzen

  • Das Budget und die Auftragstabelle liegen im Speicher. Ein Neustart vergisst, wer was ausgegeben hat, und verwirft Aufträge in der Warteschlange; der Sweep garantiert nur, dass ihre hochgeladenen Hintergrundbilder nicht länger als eine Stunde überleben.
  • Beim erneuten Lesen des Codes für diesen Beitrag fand ich eine Datei, die nie gelöscht wurde: das PNG, das der Speicher-Node von ComfyUI in seinen Ausgabeordner schreibt, auch über dem eigenen Foto eines Betrachters. Der Dienst löscht es jetzt direkt nach dem Kopieren des Bildes. Der erste Versuch scheiterte lautlos, denn ComfyUI läuft als root und legt diesen Ordner root-eigen an; das Löschen warf einen Berechtigungsfehler, und der Code schluckte ihn. Gezählt wurde der Ordner vor und nach einem Render (7 Dateien, dann 8), und das hat es aufgedeckt. Der Ordner gehört jetzt dem Benutzer des Dienstes, und ein fehlgeschlagenes Löschen wird ausgegeben, statt ignoriert zu werden.
  • Auto-Renders laufen nacheinander. Mit einem laufenden und drei wartenden Aufträgen bekommt ein fünfter Auftrag ein 503, und der Betrachter bekommt einen Entwurf.
  • Das Socket-Timeout fängt Stille ab, nicht ein langsames Tröpfeln, sodass eine Verbindung auf diese Weise offen gehalten werden könnte. Der Body hat jetzt eine eigene Frist, 90 Sekunden für die ganze Anfrage statt 20 für jedes Lesen.
  • Es gibt keine Konten. Jeder kann von einer Adresse aus acht Bilder pro Stunde verbrauchen, was für eine Demo der richtige Preis ist.

Was ich wieder so machen würde

Den Graphen aus Konstanten bauen, die aus der laufenden Pipeline kopiert wurden, mit einem Kommentar, der die Datei nennt, aus der jeder Wert stammt; ändert sich die Produktion, zeigt der Diff es. In der Reihenfolge der Kosten prüfen und zuletzt dekodieren. Alles neu kodieren, was ein Fremder hochlädt: Es sind ein paar Zeilen, und es entfernt eine ganze Kategorie von Fragen darüber, was sonst noch in der Datei war. Jede Ausgabe danach kennzeichnen, woher sie tatsächlich stammt.

Und ComfyUI vom Internet fernhalten. Nichts an seiner API wurde für Fremde entworfen, und der schmale Dienst davor ist ein kleiner Preis dafür. Wenn du einen eigenen Workflow als API oder öffentliches Werkzeug brauchst, ist das die Arbeit, die auf /comfyui/ beschrieben ist.

Weitere Beiträge

Mehr auf dieser Website

Anastasiia Butova - ML engineer, Belgrade, Serbia. Email [email protected] · LinkedIn · GitHub · Telegram · Hugging Face