/* * Das Terminal im eigenen Fenster. * * Eigener Einstiegspunkt, nicht Teil von app.js: nicht weil diese Seite ohne * Livewire liefe — sie ist eine Vollseiten-Livewire-Komponente und lädt * ``, das app.js über bare.blade.php ohnehin mitzieht —, * sondern damit der Terminalcode (xterm.js) nicht in app.js landet und die * übrigen Konsolenseiten nicht mitschleppen, und umgekehrt app.js nicht in * dieses schlanke Fenster. */ import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import '@xterm/xterm/css/xterm.css' const root = document.querySelector('[data-terminal]') const stage = root?.querySelector('[data-terminal-stage]') // Der Knopf gehört zur Bühne, nicht zur Sitzung, und wird deshalb VOR der // Weiche unten verdrahtet: bei einem Grund, den schon der Server kennt, gibt es // gar kein Ticket — und der Knopf steht trotzdem da und muss tun. // // Neu laden statt neu verbinden, weil ein Ticket dreißig Sekunden gilt und genau // einmal: derselbe Wert ein zweites Mal wäre kein zweiter Versuch. stage?.querySelector('[data-stage-retry-button]')?.addEventListener('click', () => location.reload()) // Ohne Ticket gibt es nichts zu verbinden: die Seite hat dann schon // hingeschrieben, woran es liegt (HostTerminal::mount, `$problem`), und ein // Socket, den wir trotzdem aufmachten, überschriebe diesen Satz mit einem // zweiten, ungenaueren. if (root && root.dataset.ticket) { const screen = root.querySelector('[data-terminal-screen]') const stageTitle = stage.querySelector('[data-stage-title]') const stageNote = stage.querySelector('[data-stage-note]') const stageRetry = stage.querySelector('[data-stage-retry]') // Der Hostname steht nirgends im Skript: er ist schon in die Meldungen // eingesetzt, die der Server mitgegeben hat. Tunneladresse und Schlüssel // sieht dieses Fenster ohnehin nie. const messages = JSON.parse(stage.dataset.stageMessages) /** * Die Bühne zeigen. `which` ist einer der Schlüssel aus * `data-stage-messages`; `offerRetry` entscheidet über den Knopf — er kommt * nur, wo ein zweiter Anlauf überhaupt etwas ändern kann. */ const showStage = (which, offerRetry) => { stageTitle.textContent = messages[which].title stageNote.textContent = messages[which].note stageRetry.classList.toggle('hidden', !offerRetry) stage.classList.remove('hidden') } const term = new Terminal({ convertEol: true, fontFamily: 'ui-monospace, monospace', fontSize: 13 }) const fit = new FitAddon() term.loadAddon(fit) const scheme = location.protocol === 'https:' ? 'wss' : 'ws' // Das Ticket reist als Unterprotokoll, NICHT in der Adresszeile: die Adresse // eines Upgrade-Antrags schreibt jeder Reverse Proxy auf der Strecke mit // (nginx hier, Caddy davor), ein Kopffeld nicht. Die Brücke spiegelt den // Wert zurück, sonst bricht der Browser den Handschlag ab. // // `/terminal/ws` steht fest an der Wurzel und folgt AdminArea::prefix() // bewusst nicht: die Stelle wird von nginx durchgereicht und sieht PHP nie, // also muss sie ein fester Text sein — in docker/nginx/default.conf steht // derselbe, mit derselben Begründung. const socket = new WebSocket(`${scheme}://${location.host}/terminal/ws`, [root.dataset.ticket]) socket.binaryType = 'arraybuffer' // xterm hängt sofort im Schirm, nicht erst beim ersten Byte: das Element hat // von Anfang an seine volle Größe (die Bühne liegt darüber, nicht daneben), // und `fit()` rechnet nur an einem Element richtig, das schon Maße hat. term.open(screen) fit.fit() // Die Bühne weicht beim ERSTEN BYTE, nicht bei `onopen`: ein stehender // Socket sagt noch nichts darüber, ob am anderen Ende eine Sitzung entstanden // ist, und der Unterschied fiele sonst erst auf, wenn jemand ins Leere tippt. let received = false const reveal = () => { if (received) return received = true stage.classList.add('hidden') fit.fit() } socket.onmessage = (event) => { reveal() // Kommt vom Container ein Textrahmen statt eines Binärrahmens, ist // `new Uint8Array(event.data)` bei einem String ein leeres Array — // `binaryType = 'arraybuffer'` oben regelt nur Binärrahmen. // // Die Brücke hat sich inzwischen festgelegt: sie sendet ausschließlich // Binärrahmen (docker/terminal/bridge.py, to_browser). Diese Verzweigung // bleibt trotzdem stehen — sie kostet nichts und ist die einzige Stelle, // an der ein Rückfall nicht als stumme leere Ausgabe ankäme. term.write(typeof event.data === 'string' ? event.data : new Uint8Array(event.data)) } term.onData((data) => socket.readyState === WebSocket.OPEN && socket.send(data)) // Die Schließcodes kommen aus docker/terminal/bridge.py und sind die einzige // Auskunft, die die Brücke über den Grund gibt — absichtlich: was genau am // Host schiefging, steht in ihrem Log, nicht in diesem Fenster. // // 4401 Ticket unbekannt, abgelaufen oder schon eingelöst // 4502 kein SSH zum Host (keine Antwort, oder Fingerabdruck weicht ab) // // Alles andere ist ein gewöhnliches Ende: die Gegenseite hat aufgelegt. // Das kann die Sitzung selbst sein (`exit`), der Leerlaufwächter der Brücke // oder eine Leitung, die abgerissen ist — für den Betreiber ist der // Unterschied derselbe: hier läuft nichts mehr, und ein neuer Anlauf hilft. socket.onclose = (event) => { if (event.code === 4401) return showStage('rejected', true) if (event.code === 4502) return showStage('unreachable', true) if (received) return showStage('ended', true) // Nie eine Sitzung gehabt — und genau hier wusste das Fenster bisher // nichts und riet. // // Scheitert ein WebSocket schon am Handschlag, bekommt die Seite laut // Norm KEINEN HTTP-Status: `event.code` ist 1006, sonst nichts. Das ist // Absicht (sonst wäre ein Socket ein Portscanner), macht aber genau die // Auskunft unmöglich, die ein Betreiber hier braucht — läuft die Brücke // nicht, oder ist die Leitung weg? Beides sah gleich aus. // // Eine gewöhnliche Anfrage an dieselbe Stelle darf den Status sehen. // nginx antwortet dort mit 502, solange die Brücke nicht erreichbar ist // (`resolver` + Variable, docker/nginx/default.conf), mit 404 auf einem // öffentlichen Namen, und sonst mit dem, was die Brücke selbst sagt — // ein Upgrade-Fehler, aber eben von ihr. Das reicht zum Unterscheiden. showStage('lost', true) fetch('/terminal/ws', { cache: 'no-store' }) .then((probe) => { if ([502, 503, 504].includes(probe.status)) showStage('service_down', true) else if (probe.status === 404) showStage('not_here', false) }) .catch(() => { // Die Anfrage kam nicht einmal los: dann ist wirklich die // Leitung weg, und „Keine Verbindung" von oben stimmt. }) } // Kein `onerror`: ein Fehler zieht immer ein `close` nach sich, und dort oben // steht die Meldung — mit einem Code, der etwas aussagt. Zwei Handler // schrieben zwei Sätze über denselben Vorgang, und genau das stand vorher da. addEventListener('resize', () => fit.fit()) }