Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f59ba42e55 | ||
|
|
b1275b2c71 | ||
|
|
1d8f92fd2d | ||
|
|
5324174f90 | ||
|
|
d69d177995 | ||
|
|
f481a91773 | ||
|
|
970f45cdf7 | ||
|
|
c9ceb871b6 | ||
|
|
37c508297f | ||
|
|
13bf07fecf | ||
|
|
849b821501 | ||
|
|
00a27018d9 | ||
|
|
95238b781f | ||
|
|
3ea7937b5b | ||
|
|
80d9e30bd4 | ||
|
|
bde2df2b44 | ||
|
|
76a73017db | ||
|
|
db06ddc054 | ||
|
|
e0a048fbcc | ||
|
|
9d736bb91f | ||
|
|
914609b758 | ||
|
|
af81c92695 | ||
|
|
a528e9f3b2 | ||
|
|
3b3ce96678 | ||
|
|
42316ba137 | ||
|
|
d34baa67be | ||
|
|
491724beb5 | ||
|
|
b54b87ca6f | ||
|
|
270ba455a8 | ||
|
|
f3b312046f | ||
|
|
436b080deb | ||
|
|
85c40ccfbd | ||
|
|
0ac651a1b9 | ||
|
|
4275c84a6c | ||
|
|
4f4310bfe4 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.8.8"
|
||||
version = "2.8.43"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -100,6 +100,7 @@ def _register_blueprints(app: Flask) -> None:
|
||||
settings,
|
||||
switches,
|
||||
hostkeys,
|
||||
screen,
|
||||
terminal,
|
||||
)
|
||||
|
||||
@@ -116,6 +117,7 @@ def _register_blueprints(app: Flask) -> None:
|
||||
app.register_blueprint(license_bp.bp)
|
||||
app.register_blueprint(backup.bp)
|
||||
terminal.init_app(app)
|
||||
screen.init_app(app)
|
||||
|
||||
# Die Statusuebersicht ist die Startseite; ``index`` bleibt als Endpunkt
|
||||
# bestehen, weil der Kern (Weiterleitungen, Fehlerseiten) darauf verweist.
|
||||
|
||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, render_template, request
|
||||
from flask import Blueprint, abort, render_template, request, url_for
|
||||
from tesm_core.security.sessions import fragment
|
||||
from tesm_core.extension import core, db
|
||||
|
||||
@@ -39,11 +39,28 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
||||
|
||||
from ..services import inventory, restart
|
||||
|
||||
from ..services import vnc as vnc_dienst
|
||||
|
||||
conn = db()
|
||||
counts = monitor.dashboard_counts(conn)
|
||||
rows = inventory.list_devices(conn, only_active=not authenticated)
|
||||
|
||||
detailed = authenticated and current_user.has("devices.read")
|
||||
# Einmal je Seitenaufbau, nicht je Kachel: der Port ist fuer alle derselbe,
|
||||
# und ohne das Recht wird gar nicht erst nachgesehen.
|
||||
vnc_erlaubt = detailed and current_user.has("device_vnc.execute")
|
||||
# Das Fenster setzt eine frische Passwortbestaetigung voraus -- dieselbe
|
||||
# Schwelle wie die Geraeteseite. Fehlt sie, fuehrt der Knopf dorthin: dort
|
||||
# wird danach gefragt, und ein Fenster, das sich mit "bitte erst
|
||||
# bestaetigen" oeffnet, waere ein Klick fuer nichts.
|
||||
from tesm_core.auth.service import reauth_is_fresh
|
||||
|
||||
vnc_fenster = vnc_erlaubt and reauth_is_fresh()
|
||||
vnc_port = (
|
||||
int(core().settings.get(conn, "vnc_port") or vnc_dienst.STANDARD_PORT)
|
||||
if vnc_erlaubt and core().settings.get(conn, "vnc_enabled")
|
||||
else 0
|
||||
)
|
||||
tiles: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
entry: dict[str, Any] = {
|
||||
@@ -74,9 +91,27 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
||||
maintenance_active=_maintenance_active(),
|
||||
darf=current_user.has,
|
||||
),
|
||||
# Der Knopf oeffnet die Sitzung in TESM selbst. Ein
|
||||
# vnc://-Link setzte einen eingerichteten Viewer auf dem
|
||||
# Arbeitsplatz voraus -- und tat dort, wo keiner
|
||||
# registriert ist, schlicht nichts.
|
||||
#
|
||||
# Ob dort ein Server antwortet, steht aus dem letzten
|
||||
# Durchlauf in der Datenbank. Die Uebersicht zeigt viele
|
||||
# Kacheln und laedt sich selbst nach; ein
|
||||
# Verbindungsversuch je Kachel und Aufbau waere genau die
|
||||
# Art Hintergrundlast, die niemand bestellt hat.
|
||||
"vnc": bool(vnc_port and row["vnc_open"]),
|
||||
"vnc_quelle": (
|
||||
url_for("screen.session_json", device_id=int(row["id"]))
|
||||
if vnc_port and row["vnc_open"]
|
||||
else ""
|
||||
),
|
||||
}
|
||||
)
|
||||
tiles.append(entry)
|
||||
# Merker fuer die Vorlage: nur mit frischer Bestaetigung gibt es das
|
||||
# Fenster; sonst zeigt die Kachel auf die Seite.
|
||||
|
||||
sections = [
|
||||
{
|
||||
@@ -121,6 +156,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
||||
"counts": counts,
|
||||
"sections": sections,
|
||||
"detailed": detailed,
|
||||
"vnc_fenster": vnc_fenster,
|
||||
"interval": int(core().settings.get(conn, "monitor_interval_seconds") or 300),
|
||||
}
|
||||
|
||||
|
||||
@@ -11,14 +11,23 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import inventory, monitor, restart, ssh
|
||||
from ..services import inventory, monitor, restart, ssh, vnc
|
||||
|
||||
bp = Blueprint("devices", __name__, url_prefix="/monitoring/clients")
|
||||
|
||||
@@ -309,12 +318,16 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
|
||||
# Der Auftrag laeuft im Hintergrund und meldet sein Ergebnis erst, wenn das
|
||||
# Geraet zurueck ist. Der Zustand muss aber sofort umschlagen -- sonst sieht
|
||||
# die Uebersicht aus wie eine, in der nichts passiert ist.
|
||||
live = extension.registry.get("live_log")
|
||||
with transaction():
|
||||
monitor.mark_restarting(conn, int(device["id"]))
|
||||
monitor.mark_restarting(
|
||||
conn, int(device["id"]), live=live, trigger="manuell", method=method.key
|
||||
)
|
||||
|
||||
maintenance_service.start_background(
|
||||
maintenance_service.run_reboot,
|
||||
database=extension.database,
|
||||
live=live,
|
||||
job_id=handle.id,
|
||||
device=dict(device),
|
||||
username=username,
|
||||
@@ -360,6 +373,70 @@ def detail(device_id: int):
|
||||
host_keys=host_keys,
|
||||
method_labels=monitor.METHOD_LABELS,
|
||||
terminal_enabled=bool(extension.settings.get(conn, "terminal_clients_enabled")),
|
||||
vnc=_vnc_zugang(conn, device),
|
||||
)
|
||||
|
||||
|
||||
def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None:
|
||||
"""Der Bildschirmzugriff dieses Clients -- aus dem, was bekannt ist.
|
||||
|
||||
Diese Seite fragt das Geraet **nicht**. Erreichbarkeit kommt aus dem
|
||||
Ueberwachungsdurchlauf, die Anmeldeverfahren aus der letzten
|
||||
ausdruecklichen Pruefung. Der Grund steht in :func:`vnc.zugang_gemerkt`:
|
||||
jede Messung ist eine angefangene Protokollverhandlung, und ein RealVNC
|
||||
Server zaehlt so etwas mit.
|
||||
"""
|
||||
extension = core()
|
||||
if not current_user.has("device_vnc.execute"):
|
||||
return None
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
return None
|
||||
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||
return vnc.zugang_gemerkt(device, port=port)
|
||||
|
||||
|
||||
@bp.get("/<int:device_id>/vnc")
|
||||
@permission_required("device_vnc.execute")
|
||||
def vnc_connection(device_id: int):
|
||||
"""Die Verbindungsdatei -- drei Zeilen, sonst nichts.
|
||||
|
||||
Kein Passwort, kein Benutzername, keine Verschluesselungsvorgabe: was beim
|
||||
Verbinden verlangt wird, entscheidet der VNC-Server auf dem Geraet. Die
|
||||
Datei traegt nur, wohin es geht und wie die Verbindung im Viewer heisst.
|
||||
"""
|
||||
conn = db()
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
extension = core()
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
abort(404)
|
||||
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||
ziel = vnc.ziel(device)
|
||||
if not ziel:
|
||||
abort(404)
|
||||
# Kein Eintrag in der Aenderungskette: eine Verbindungsdatei ist derselbe
|
||||
# Vorgang wie die Sitzung im Browser, nur fuer einen Viewer auf dem
|
||||
# Arbeitsplatz -- und sie aendert nichts. Wer zusieht, steht im laufenden
|
||||
# Protokoll; das Aenderungsprotokoll bleibt die Liste der Eingriffe.
|
||||
live = core().registry.get("live_log")
|
||||
if live is not None:
|
||||
live.info(
|
||||
"vnc", f"{current_user.username} lädt die Verbindungsdatei für {device['name']}"
|
||||
)
|
||||
return Response(
|
||||
vnc.verbindungsdatei(
|
||||
device,
|
||||
port=port,
|
||||
warnung_aus=bool(extension.settings.get(conn, "vnc_warn_unencrypted_off")),
|
||||
),
|
||||
mimetype="application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{vnc.dateiname(device)}"',
|
||||
# Eine Verbindungsdatei ist kein Dokument, das ein Proxy aufheben
|
||||
# soll -- und sie ist billig genug, sie jedes Mal neu zu erzeugen.
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ einen Dienst starten.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
@@ -20,7 +21,7 @@ from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import kea
|
||||
from ..services import inventory, kea
|
||||
from ..permissions import DHCP_PAGE
|
||||
|
||||
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
|
||||
@@ -73,6 +74,12 @@ def overview():
|
||||
# Zustand aus den laufenden Leases -- wie im Windows-DHCP.
|
||||
reservations = kea.annotate_reservations(reservations, leases)
|
||||
reserved_ips = {str(entry["ip"]) for entry in reservations}
|
||||
# Wozu es schon einen Client gibt, ist nicht mehr zu uebernehmen -- auch
|
||||
# wenn dessen Adresse (noch) keine Reservierung hat.
|
||||
bekannte_macs = {
|
||||
inventory.normalize_mac(str(zeile["mac"]))
|
||||
for zeile in Database.all(conn, "SELECT mac FROM devices WHERE deleted_at IS NULL")
|
||||
}
|
||||
|
||||
return render_template(
|
||||
"tesm/dhcp.html",
|
||||
@@ -90,6 +97,7 @@ def overview():
|
||||
manual_reservations=Database.all(conn, "SELECT * FROM dhcp_reservations ORDER BY ip"),
|
||||
leases=leases,
|
||||
reserved_ips=reserved_ips,
|
||||
bekannte_macs=bekannte_macs,
|
||||
networks=networks,
|
||||
default_gateway=kea.default_gateway(sysops) if sysops else "",
|
||||
service=kea.service_state(sysops) if sysops else {},
|
||||
@@ -97,10 +105,31 @@ def overview():
|
||||
preview=kea.config_to_text(config),
|
||||
warnings=warnings,
|
||||
lease_file=kea.LEASE_FILE,
|
||||
# Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen --
|
||||
# je Stil einmal, damit die Seite umschalten kann, ohne nachzuladen.
|
||||
client_stile=kea.CLIENT_STILE,
|
||||
client_konfig={
|
||||
stil: kea.client_konfiguration(conn, stil) for stil, _, _ in kea.CLIENT_STILE
|
||||
},
|
||||
sysops_available=bool(sysops and sysops.available),
|
||||
# "gespeichert" heisst nicht "wirksam": lehnt Kea die Datei ab, bleibt
|
||||
# der Dienst auf dem alten Stand, waehrend die Seite den neuen Wert
|
||||
# zeigt. Deshalb wird hier verglichen, was auf der Platte liegt.
|
||||
konfig_stand=_konfig_stand(config),
|
||||
)
|
||||
|
||||
|
||||
def _konfig_stand(gewuenscht: dict[str, Any]) -> str:
|
||||
"""``aktuell``, ``abweichend`` oder ``unbekannt``."""
|
||||
sysops = core().sysops
|
||||
if sysops is None or not sysops.available:
|
||||
return "unbekannt"
|
||||
vorhanden = kea.laufende_konfiguration(sysops)
|
||||
if vorhanden is None:
|
||||
return "unbekannt"
|
||||
return "aktuell" if vorhanden == gewuenscht else "abweichend"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Einstellungen und Subnetze
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -134,7 +163,13 @@ def add_subnet():
|
||||
start = (request.form.get("range_start") or "").strip()
|
||||
end = (request.form.get("range_end") or "").strip()
|
||||
|
||||
problem = kea.validate_subnet(networks, interface, start, end)
|
||||
problem = kea.validate_subnet(
|
||||
networks,
|
||||
interface,
|
||||
start,
|
||||
end,
|
||||
vorhandene=Database.all(conn, "SELECT * FROM dhcp_subnets"),
|
||||
)
|
||||
if problem:
|
||||
flash(problem, "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
@@ -169,7 +204,14 @@ def update_subnet(subnet_id: int):
|
||||
interface = (request.form.get("interface") or "").strip()
|
||||
start = (request.form.get("range_start") or "").strip()
|
||||
end = (request.form.get("range_end") or "").strip()
|
||||
problem = kea.validate_subnet(networks, interface, start, end)
|
||||
problem = kea.validate_subnet(
|
||||
networks,
|
||||
interface,
|
||||
start,
|
||||
end,
|
||||
vorhandene=Database.all(conn, "SELECT * FROM dhcp_subnets"),
|
||||
eigene_id=subnet_id,
|
||||
)
|
||||
if problem:
|
||||
flash(problem, "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
@@ -447,6 +489,91 @@ def add_reservation():
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/lease/adopt")
|
||||
# Zwei Rechte, mit Absicht: die Uebernahme ist eine eigene Funktion, das
|
||||
# Ergebnis aber ein Client. Wer keine Clients anlegen darf, legt auch hier
|
||||
# keine an -- sonst waere diese Schaltflaeche eine Hintertuer an der
|
||||
# Geraeteverwaltung vorbei.
|
||||
@permission_required("dhcp_lease_adopt.execute", "devices.create")
|
||||
def adopt_lease():
|
||||
"""Macht aus einer dynamisch vergebenen Adresse einen ueberwachten Client.
|
||||
|
||||
Die Angaben kommen **nicht** aus dem Formular, sondern aus der Lease-Datei:
|
||||
geschickt wird nur die MAC, alles Weitere liest TESM frisch nach. Damit
|
||||
heisst "Uebernahme" auch Uebernahme -- und eine Lease, die zwischen
|
||||
Seitenaufbau und Klick abgelaufen oder umgezogen ist, faellt auf, statt
|
||||
stillschweigend einen falschen Datensatz anzulegen.
|
||||
"""
|
||||
extension = core()
|
||||
conn = db()
|
||||
sysops = extension.sysops
|
||||
gesucht = inventory.normalize_mac(request.form.get("mac") or "")
|
||||
leases = kea.read_leases(sysops) if sysops else []
|
||||
lease = next((eintrag for eintrag in leases if eintrag.mac == gesucht), None)
|
||||
if lease is None:
|
||||
flash(
|
||||
"Zu dieser MAC liegt keine Lease mehr vor. Die Liste war nicht mehr aktuell.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
# Nur dynamische. Eine Adresse, die schon reserviert ist, gehoert bereits
|
||||
# zu einem gepflegten Datensatz -- da gibt es nichts zu uebernehmen.
|
||||
networks = kea.detect_networks(sysops) if sysops else []
|
||||
reserviert = {
|
||||
str(eintrag["ip"])
|
||||
for eintrag in kea.collect_reservations(
|
||||
conn, only_networks=kea.configured_networks(conn, networks)
|
||||
)
|
||||
}
|
||||
if lease.address in reserviert:
|
||||
flash(
|
||||
f"{lease.address} ist bereits reserviert – diese Lease ist nicht dynamisch.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
# Der Hostname aus der Lease ist der Name, den das Geraet selbst nennt.
|
||||
# Fehlt er, steht die Adresse da: ein Name wie "Gerät 7" sagt weniger.
|
||||
name = (lease.hostname or "").strip() or f"Client {lease.address}"
|
||||
try:
|
||||
with transaction():
|
||||
device_id = inventory.create_device(
|
||||
conn,
|
||||
name=name,
|
||||
mac=lease.mac,
|
||||
ip=lease.address,
|
||||
switch_id=None,
|
||||
port="",
|
||||
credential_id=None,
|
||||
ssh_port=None,
|
||||
# Ohne Switch und Port gibt es keinen PoE-Neustart. Mit
|
||||
# eingeschaltetem Automatismus schriebe die Ueberwachung bei
|
||||
# jedem Ausfall einen Fehlschlag ins Protokoll, den niemand
|
||||
# abstellen kann. Einschalten kann man ihn, sobald der Port
|
||||
# eingetragen ist.
|
||||
auto_restart=False,
|
||||
actor=current_user.username,
|
||||
)
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
_audit(
|
||||
"device.created",
|
||||
name,
|
||||
f"aus dynamischer Lease {lease.address} ({lease.mac}) übernommen",
|
||||
severity="notice",
|
||||
)
|
||||
flash(
|
||||
f"{name} wurde als Client übernommen. Switch, Port und Zugangsdaten fehlen noch – "
|
||||
"ohne sie gibt es keinen PoE-Neustart und keine Wartung. Die feste Adresse entsteht "
|
||||
"beim nächsten Schreiben der DHCP-Konfiguration.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
|
||||
|
||||
@bp.post("/reservation/<int:reservation_id>/delete")
|
||||
@permission_required("dhcp_reservations.delete")
|
||||
def delete_reservation(reservation_id: int):
|
||||
@@ -509,12 +636,114 @@ def _write_and_redirect():
|
||||
for warning in result.get("warnings", []):
|
||||
flash(warning, "warning")
|
||||
if not result["ok"] and result.get("message"):
|
||||
flash(result["message"], "warning")
|
||||
# Rot, nicht orange: die Aenderung steht in der Datenbank und auf der
|
||||
# Seite, wirkt aber nicht. Das ist der Zustand, in dem man stundenlang
|
||||
# sucht, warum ein Client die Option nicht bekommt.
|
||||
flash(
|
||||
result["message"] + " Die Änderung ist gespeichert, aber noch nicht wirksam.",
|
||||
"danger",
|
||||
)
|
||||
elif result["ok"]:
|
||||
flash(result["message"], "info")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
|
||||
@bp.post("/client-config/push")
|
||||
# Verteilt wird ueber dieselbe Maschinerie wie ein Massenbefehl: Zugangsdaten
|
||||
# aus dem Datensatz, ein Auftrag je Geraet, Ausgabe zum Mitlesen. Deshalb
|
||||
# haengt es auch an demselben Recht -- wer Befehle auf Clients ausfuehren darf,
|
||||
# darf auch diese Datei schreiben; es ist dieselbe Wirkung.
|
||||
@permission_required("servicing_command.execute")
|
||||
def push_client_config():
|
||||
"""Schreibt die Client-Konfiguration per SSH auf ausgewaehlte Clients.
|
||||
|
||||
Der Inhalt ist fuer alle gleich -- angefordert wird, was der Server sendet,
|
||||
und das haengt nicht am einzelnen Geraet. Die **Werte** kommen weiterhin
|
||||
vom Server; hier geht es nur darum, dass der Client danach fragt.
|
||||
"""
|
||||
from ..services import maintenance as wartung
|
||||
|
||||
extension = core()
|
||||
conn = db()
|
||||
stil = (request.form.get("stil") or "").strip()
|
||||
if stil not in dict((s[0], s) for s in kea.CLIENT_STILE):
|
||||
flash("Unbekannter Client-Stil.", "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
konfig = kea.client_konfiguration(conn, stil)
|
||||
if not konfig["optionen"]:
|
||||
flash(
|
||||
"Es gibt nichts zu verteilen: dieser Server sendet derzeit keine Option, "
|
||||
"die ein Client eigens anfordern müsste.",
|
||||
"info",
|
||||
)
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
device_ids = [int(v) for v in request.form.getlist("device_ids") if v.isdigit()]
|
||||
if not device_ids:
|
||||
flash("Es wurde kein Gerät ausgewählt.", "warning")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
# Ueber base64 statt ueber ein Here-Dokument: der Inhalt enthaelt
|
||||
# Anfuehrungszeichen, Rauten und Zeilenumbrueche, und er geht durch SSH,
|
||||
# eine Shell und moeglicherweise sudo. Was kodiert reist, kommt an.
|
||||
inhalt = base64.b64encode(konfig["inhalt"].encode("utf-8")).decode("ascii")
|
||||
verzeichnis = str(konfig["pfad"]).rsplit("/", 1)[0]
|
||||
befehl = (
|
||||
f"{{sudo}} mkdir -p {verzeichnis} && "
|
||||
f"echo {inhalt} | base64 -d | {{sudo}} tee {konfig['pfad']} >/dev/null && "
|
||||
f"{{sudo}} sh -c '{konfig['nachher']}' || true"
|
||||
)
|
||||
|
||||
gestartet = 0
|
||||
for device_id in device_ids:
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None or not device["credential_id"]:
|
||||
continue
|
||||
try:
|
||||
username, password = inventory.credential_secret(
|
||||
conn, extension.keystore, int(device["credential_id"])
|
||||
)
|
||||
except inventory.ValidationError as exc:
|
||||
flash(f"{device['name']}: {exc}", "danger")
|
||||
continue
|
||||
with transaction():
|
||||
auftrag = wartung.create_job(
|
||||
conn,
|
||||
kind=wartung.JOB_KIND_COMMAND,
|
||||
subject=str(device["name"]),
|
||||
actor=current_user.username,
|
||||
message="Wartet auf Start",
|
||||
)
|
||||
wartung.start_background(
|
||||
wartung.run_command,
|
||||
database=extension.database,
|
||||
job_id=auftrag.id,
|
||||
device=dict(device),
|
||||
username=username,
|
||||
password=password,
|
||||
befehl=befehl,
|
||||
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
|
||||
)
|
||||
gestartet += 1
|
||||
|
||||
if gestartet:
|
||||
_audit(
|
||||
"dhcp.client_config_pushed",
|
||||
f"{gestartet} Client(s)",
|
||||
f"Stil {stil}, Datei {konfig['pfad']}",
|
||||
severity="notice",
|
||||
)
|
||||
flash(
|
||||
f"{gestartet} Auftrag/Aufträge gestartet. Der Verlauf und die Ausgabe stehen "
|
||||
"unter Wartung.",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
flash("Kein Gerät mit hinterlegten Zugangsdaten ausgewählt.", "warning")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
|
||||
@bp.post("/write")
|
||||
@permission_required("dhcp_config.execute")
|
||||
def write_config():
|
||||
|
||||
@@ -19,7 +19,11 @@ from tesm_core.auth.models import (
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
|
||||
from tesm_core.licensing_client import (
|
||||
ABFRAGE_WARNSCHWELLE_TAGE,
|
||||
LICENSED_FEATURES,
|
||||
LicenseError,
|
||||
)
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
@@ -50,8 +54,29 @@ def overview():
|
||||
manager = extension.license
|
||||
state = manager.reload(conn) if manager else None
|
||||
pending = manager._pending(conn) if manager else {} # noqa: SLF001 - eigenes Paket
|
||||
# Der Befehl, den ein Agent auf dieser Maschine ausfuehrt. Zusammengesetzt
|
||||
# aus dem, was hier wirklich installiert ist -- der Shim traegt den Namen
|
||||
# der Instanz, und der Dienstbenutzer auch. Abgeschrieben aus der Doku
|
||||
# waere er auf einer benannten Instanz schlicht falsch.
|
||||
werkzeug = f"/usr/local/bin/{extension.config.site_key}-admin"
|
||||
# Der Dienstbenutzer traegt den Namen der **Anwendung**, nicht den der
|
||||
# Instanz: install.sh setzt SERVICE_USER="$APP". Auf "tesm-test" heisst er
|
||||
# also "tesm" -- und auf dem Lizenzserver "tesm-license". Aus dem
|
||||
# Instanznamen ableiten waere genau dort falsch.
|
||||
dienstbenutzer = extension.config.app_key
|
||||
rmm_befehl = f"sudo -u {dienstbenutzer} {werkzeug} license"
|
||||
return render_template(
|
||||
"tesm/license.html",
|
||||
rmm_befehl=rmm_befehl,
|
||||
rmm_befehl_tage=f"{rmm_befehl} --days",
|
||||
rmm_warnschwelle=ABFRAGE_WARNSCHWELLE_TAGE,
|
||||
rmm_powershell=(
|
||||
f'$j = (& {rmm_befehl} 2>$null) | ConvertFrom-Json' + chr(10)
|
||||
+ 'if (-not $j.valid) { exit 2 }' + chr(10)
|
||||
+ 'if ($j.lifetime) { exit 0 }' + chr(10)
|
||||
+ f'if ($j.days_left -le {ABFRAGE_WARNSCHWELLE_TAGE}) ' + '{ exit 1 }' + chr(10)
|
||||
+ 'exit 0'
|
||||
),
|
||||
state=state,
|
||||
pending=pending,
|
||||
server_certificate=extension.settings.get(conn, "license_server_ca") or "",
|
||||
|
||||
@@ -66,4 +66,5 @@ def restarts():
|
||||
method=method,
|
||||
methods=tuple(monitor.METHOD_LABELS.items()),
|
||||
method_labels=monitor.METHOD_LABELS,
|
||||
trigger_labels=monitor.TRIGGER_LABELS,
|
||||
)
|
||||
@@ -18,7 +18,7 @@ from __future__ import annotations
|
||||
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
|
||||
from tesm_core.security.sessions import fragment
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
@@ -77,18 +77,20 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required(*SERVICING_PAGE, mode="any")
|
||||
def overview():
|
||||
def overview(): # noqa: C901 - eine Seite, drei Aufgaben
|
||||
conn = db()
|
||||
return render_template(
|
||||
"tesm/maintenance.html",
|
||||
devices=_eligible_devices(conn),
|
||||
jobs=service.list_jobs(conn),
|
||||
auftragslage=service.letzter_auftrag_je_geraet(conn),
|
||||
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
|
||||
category_effects=inventory.CATEGORY_EFFECTS,
|
||||
update_category=inventory.MAINTENANCE_CATEGORY,
|
||||
terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
|
||||
windows_tool_missing=_windows_tool_missing(conn),
|
||||
windows_package=windowsops.REQUIRED_PACKAGE,
|
||||
befehlsvorlagen=service.BEFEHLSVORLAGEN,
|
||||
)
|
||||
|
||||
|
||||
@@ -144,12 +146,19 @@ def install_windows_tool():
|
||||
def status():
|
||||
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
|
||||
conn = db()
|
||||
jobs = service.list_jobs(conn, limit=30)
|
||||
jobs = service.laufende_und_letzte(conn, limit=30)
|
||||
running = any(job["state"] in {"pending", "running"} for job in jobs)
|
||||
return jsonify(
|
||||
{
|
||||
"running": running,
|
||||
"poll_ms": 2000 if running else 10000,
|
||||
# Der Zustand der Geraete gehoert dazu: die Seite steht sonst mit
|
||||
# dem Stand ihres Aufbaus da, und wer nach einem Neustart auf die
|
||||
# Rueckkehr wartet, sieht "unbekannt", bis er von Hand neu laedt.
|
||||
"devices": [
|
||||
{"id": int(row["id"]), "state": str(row["state"] or "unknown")}
|
||||
for row in _eligible_devices(conn)
|
||||
],
|
||||
"jobs": [
|
||||
{
|
||||
"id": job["id"],
|
||||
@@ -159,6 +168,16 @@ def status():
|
||||
"progress": job["progress"],
|
||||
"message": job["message"],
|
||||
"updated_at": job["updated_at"],
|
||||
# Die letzten Zeilen -- nur von dem, was gerade laeuft.
|
||||
# Mitlesen heisst mitlesen: wer nach der Wartung wissen
|
||||
# will, was war, oeffnet den Auftrag. Alles mitzuschicken
|
||||
# hiesse, alle zwei Sekunden das gesamte Protokoll aller
|
||||
# Geraete zu uebertragen.
|
||||
"output": (
|
||||
service.letzte_zeilen(str(job["output"] or ""))
|
||||
if service.eben_fertig(dict(job))
|
||||
else ""
|
||||
),
|
||||
}
|
||||
for job in jobs
|
||||
],
|
||||
@@ -187,7 +206,7 @@ def job_output(job_id: str):
|
||||
return render_template("tesm/_maintenance_output.html", job=job)
|
||||
|
||||
|
||||
def _start(kind: str) -> None:
|
||||
def _start(kind: str, befehl: str = "") -> None:
|
||||
extension = core()
|
||||
conn = db()
|
||||
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
|
||||
@@ -233,12 +252,27 @@ def _start(kind: str) -> None:
|
||||
actor_id=current_user.id,
|
||||
action=kind,
|
||||
target=str(device["name"]),
|
||||
detail=f"Auftrag {handle.id}",
|
||||
# Beim Befehl gehoert sein Wortlaut hierher und nicht nur die
|
||||
# Auftragsnummer: was gelaufen ist, muss aus dem Protokoll
|
||||
# hervorgehen, auch wenn der Auftrag laengst aus dem Verlauf
|
||||
# gerollt ist.
|
||||
detail=f"Auftrag {handle.id}" + (f": {befehl}" if befehl else ""),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
severity="warning" if befehl else "notice",
|
||||
)
|
||||
|
||||
if kind == service.JOB_KIND_UPDATE:
|
||||
if kind == service.JOB_KIND_COMMAND:
|
||||
service.start_background(
|
||||
service.run_command,
|
||||
database=extension.database,
|
||||
job_id=handle.id,
|
||||
device=dict(device),
|
||||
username=username,
|
||||
password=password,
|
||||
befehl=befehl,
|
||||
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
|
||||
)
|
||||
elif kind == service.JOB_KIND_UPDATE:
|
||||
service.start_background(
|
||||
service.run_update,
|
||||
database=extension.database,
|
||||
@@ -249,27 +283,35 @@ def _start(kind: str) -> None:
|
||||
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
|
||||
)
|
||||
else:
|
||||
weg = (
|
||||
service.METHOD_RPC
|
||||
if str(device["category"]) == inventory.NON_SSH_CATEGORY
|
||||
else service.METHOD_SSH
|
||||
)
|
||||
# Sofort sichtbar, nicht erst wenn das Geraet zurueck ist.
|
||||
with transaction() as verbindung:
|
||||
monitor.mark_restarting(verbindung, int(device["id"]))
|
||||
monitor.mark_restarting(
|
||||
verbindung,
|
||||
int(device["id"]),
|
||||
live=extension.registry.get("live_log"),
|
||||
trigger="maintenance",
|
||||
method=weg,
|
||||
)
|
||||
service.start_background(
|
||||
service.run_reboot,
|
||||
database=extension.database,
|
||||
live=extension.registry.get("live_log"),
|
||||
job_id=handle.id,
|
||||
device=dict(device),
|
||||
username=username,
|
||||
password=password,
|
||||
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
|
||||
actor=current_user.username,
|
||||
# Hier steht in ``category`` die Kategorie der *Zugangsdaten*
|
||||
# (siehe _query_devices) -- daraus folgt der Weg. Uebergeben
|
||||
# statt in run_reboot erraten: dort bedeutet derselbe
|
||||
# Schluessel je Herkunft etwas anderes.
|
||||
method=(
|
||||
service.METHOD_RPC
|
||||
if str(device["category"]) == inventory.NON_SSH_CATEGORY
|
||||
else service.METHOD_SSH
|
||||
),
|
||||
# Der Weg steht oben: in ``category`` liegt die Kategorie der
|
||||
# *Zugangsdaten* (siehe _query_devices), daraus folgt er.
|
||||
# Uebergeben statt in run_reboot erraten -- dort bedeutet
|
||||
# derselbe Schluessel je Herkunft etwas anderes.
|
||||
method=weg,
|
||||
)
|
||||
started += 1
|
||||
|
||||
@@ -289,3 +331,22 @@ def start_update():
|
||||
def start_reboot():
|
||||
_start(service.JOB_KIND_REBOOT)
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
|
||||
|
||||
@bp.post("/command")
|
||||
@permission_required("servicing_command.execute")
|
||||
@reauth_required
|
||||
def start_command():
|
||||
"""Eine Befehlszeile auf allen ausgewaehlten Clients.
|
||||
|
||||
Dieselbe Schwelle wie die Konsole: eigenes Recht **und** eine frische
|
||||
Passwortbestaetigung. Wer hier etwas eintippt, tut es auf einem Dutzend
|
||||
Maschinen gleichzeitig, und niemand sieht dabei zu.
|
||||
"""
|
||||
try:
|
||||
befehl = service.befehl_pruefen(request.form.get("befehl", ""))
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
_start(service.JOB_KIND_COMMAND, befehl=befehl)
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
@@ -0,0 +1,484 @@
|
||||
"""Bildschirm eines Clients im Browser -- noVNC ueber eine WebSocket-Strecke.
|
||||
|
||||
Warum ueberhaupt im Browser und nicht mit dem Viewer auf dem Arbeitsplatz: der
|
||||
Viewer muss installiert, gepflegt und mit Zugangsdaten versorgt werden, und vom
|
||||
Telefon aus gibt es ihn gar nicht. Hier genuegt die Anmeldung an TESM -- die
|
||||
Rechte, die Protokollierung und die Frage "darf dieser Mensch auf diesen
|
||||
Bildschirm?" haengen ohnehin schon dort.
|
||||
|
||||
**Die Strecke reicht durch, sie versteht nichts.** Der Server oeffnet eine
|
||||
gewoehnliche TCP-Verbindung zum VNC-Server und schaufelt Bytes in beide
|
||||
Richtungen; das RFB-Protokoll samt Authentifizierung handelt der Browser
|
||||
unmittelbar mit dem Geraet aus. Das ist Absicht: eine Gegenstelle, die das
|
||||
Protokoll auspackt, muesste die Verschluesselung aufbrechen und wieder
|
||||
aufbauen -- mehr Code an der empfindlichsten Stelle, ohne dass jemand etwas
|
||||
davon haette.
|
||||
|
||||
**Was daraus folgt** und nicht verschwiegen werden soll: weil der Browser die
|
||||
Anmeldung fuehrt, muss er die Zugangsdaten kennen. Hinterlegte Zugangsdaten
|
||||
gibt TESM deshalb nur an einen Menschen heraus, der Geheimnisse ohnehin sehen
|
||||
darf (``credentials.secrets``) -- jede Herausgabe steht im Aenderungsprotokoll.
|
||||
Wer das Recht nicht hat, tippt sie in der Seite selbst ein; sie gehen dann
|
||||
durch den Browser, aber nie durch unsere Datenbank.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||
from tesm_core.auth.service import client_ip, reauth_is_fresh
|
||||
from tesm_core.extension import core, db
|
||||
|
||||
from ..services import inventory, vnc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("screen", __name__)
|
||||
|
||||
#: Wie lange auf den VNC-Server gewartet wird, bis die Strecke steht.
|
||||
VERBINDUNGSLIMIT = 6.0
|
||||
|
||||
#: Groesse der Haeppchen, in denen vom Geraet gelesen wird. Ein Bildschirm
|
||||
#: liefert in Schueben; kleiner hiesse mehr Runden, groesser mehr Wartezeit,
|
||||
#: bis ein Schub weitergereicht wird.
|
||||
BLOCK = 32 * 1024
|
||||
|
||||
#: Wie oft nachgesehen wird, ob die Sitzung noch etwas tut. Kurz genug, dass
|
||||
#: das Trennen puenktlich ist, lang genug, dass das Nachsehen nichts kostet.
|
||||
TAKT = 1.0
|
||||
|
||||
|
||||
def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None:
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None or device.get("deleted_at"):
|
||||
return None
|
||||
return device
|
||||
|
||||
|
||||
def _erklaerung(fehler: OSError) -> str:
|
||||
"""Die Fehlernummer in einem Satz, der weiterhilft.
|
||||
|
||||
"No route to host" und "Connection refused" sehen im Protokoll gleich
|
||||
harmlos aus und bedeuten das Gegenteil voneinander: einmal kommt man gar
|
||||
nicht hin, einmal kommt man hin und niemand hoert zu. Und beides hat mit
|
||||
einem gelungenen Ping nichts zu tun -- der prueft eine andere Schicht.
|
||||
"""
|
||||
import errno as fehlernummern
|
||||
|
||||
nummer = getattr(fehler, "errno", None)
|
||||
if nummer == fehlernummern.EHOSTUNREACH:
|
||||
return (
|
||||
f"{fehler} – kein Weg dorthin. Eine Firewall oder eine Route steht "
|
||||
"dazwischen; ein gelungener Ping sagt darüber nichts, der prüft eine "
|
||||
"andere Schicht"
|
||||
)
|
||||
if nummer == fehlernummern.ECONNREFUSED:
|
||||
return f"{fehler} – das Gerät ist erreichbar, aber auf diesem Port hört niemand"
|
||||
if nummer == fehlernummern.ETIMEDOUT:
|
||||
return f"{fehler} – keine Antwort; Paketfilter verwerfen meist stillschweigend"
|
||||
return str(fehler)
|
||||
|
||||
|
||||
def _port(conn: Any) -> int:
|
||||
extension = core()
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
return 0
|
||||
return int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||
|
||||
|
||||
@bp.get("/monitoring/clients/<int:device_id>/screen")
|
||||
@permission_required("device_vnc.execute")
|
||||
@reauth_required
|
||||
def screen(device_id: int):
|
||||
"""Die Seite mit der Leinwand.
|
||||
|
||||
Sie beruehrt das Geraet nicht: die Sitzung selbst ist der einzige Anlass,
|
||||
dort anzuklopfen. Was ueber die Anmeldeverfahren bekannt ist, steht in der
|
||||
Datenbank -- aus der letzten ausdruecklichen Pruefung (Knopf "Verfahren
|
||||
pruefen"). Dass diese Seite frueher selbst gemessen hat, war ein Fehler:
|
||||
jede Messung ist eine angefangene und abgebrochene Protokollverhandlung,
|
||||
und ein RealVNC Server zaehlt so etwas -- das Oeffnen der Seite hat der
|
||||
Sitzung also selbst im Weg gestanden.
|
||||
"""
|
||||
conn = db()
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
port = _port(conn)
|
||||
if not port:
|
||||
abort(404)
|
||||
|
||||
zugang = vnc.zugang_gemerkt(device, port=port)
|
||||
return render_template(
|
||||
"tesm/screen.html",
|
||||
device=device,
|
||||
zugang=zugang,
|
||||
quelle=url_for("screen.session_json", device_id=device_id),
|
||||
darf_geheimnis=bool(current_user.has("credentials.secrets")),
|
||||
verfahren_name=vnc.verfahren_name,
|
||||
)
|
||||
|
||||
|
||||
def _zugangsdaten(conn: Any, device: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Hinterlegte Zugangsdaten -- nur fuer den, der Geheimnisse sehen darf.
|
||||
|
||||
Weil der Browser die Anmeldung fuehrt, muessen sie in die Seite. Wer
|
||||
Geheimnisse nicht sehen darf, bekommt sie auch hier nicht: sonst waere die
|
||||
Bildschirmsitzung der Umweg, auf dem jeder das Passwort liest. Jede
|
||||
Herausgabe steht im Aenderungsprotokoll -- das ist kein Zusehen, sondern
|
||||
ein Zugriff auf etwas, das sonst niemand sieht.
|
||||
"""
|
||||
if not device.get("credential_id") or not current_user.has("credentials.secrets"):
|
||||
return "", ""
|
||||
try:
|
||||
benutzer, passwort = inventory.credential_secret(
|
||||
conn, core().keystore, int(device["credential_id"])
|
||||
)
|
||||
except inventory.ValidationError:
|
||||
return "", ""
|
||||
if passwort:
|
||||
with core().database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="device.vnc_credentials_used",
|
||||
target=str(device["name"]),
|
||||
detail=(
|
||||
f"Hinterlegte Zugangsdaten ({benutzer}) an die Bildschirmsitzung "
|
||||
"übergeben"
|
||||
),
|
||||
ip=client_ip(),
|
||||
severity="warning",
|
||||
)
|
||||
return benutzer, passwort
|
||||
|
||||
|
||||
@bp.post("/monitoring/clients/<int:device_id>/screen/probe")
|
||||
@permission_required("device_vnc.execute")
|
||||
@reauth_required
|
||||
def probe(device_id: int):
|
||||
"""Fragt das Geraet ausdruecklich, wie es sich authentifizieren will.
|
||||
|
||||
Ein Knopf und keine Nebenwirkung des Seitenaufbaus: die Messung bricht eine
|
||||
Protokollverhandlung ab, und RealVNC zaehlt das. Sie gehoert in die Hand
|
||||
dessen, der gerade sucht -- nicht in jeden Aufruf.
|
||||
"""
|
||||
conn = db()
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
port = _port(conn)
|
||||
if not port:
|
||||
abort(404)
|
||||
|
||||
from ..services import monitor
|
||||
|
||||
zugang = vnc.zugang(device, port=port)
|
||||
with core().database.transaction(conn):
|
||||
monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel)
|
||||
monitor.merke_verfahren(conn, int(device["id"]), zugang.verfahren, zugang.absage)
|
||||
if zugang.absage:
|
||||
flash(f"Der Server antwortet: „{zugang.absage}“", "warning")
|
||||
elif zugang.verfahren:
|
||||
flash(
|
||||
"Angebotene Anmeldeverfahren: "
|
||||
+ ", ".join(vnc.verfahren_name(v) for v in zugang.verfahren),
|
||||
"info",
|
||||
)
|
||||
else:
|
||||
flash(f"{zugang.ziel}:{port} hat nicht geantwortet.", "danger")
|
||||
return redirect(url_for("screen.screen", device_id=device_id))
|
||||
|
||||
|
||||
@bp.get("/monitoring/clients/<int:device_id>/screen/session")
|
||||
@permission_required("device_vnc.execute")
|
||||
def session_json(device_id: int):
|
||||
"""Alles, was der Client fuer eine Sitzung braucht -- als Auskunft.
|
||||
|
||||
Warum nicht in die Seite geschrieben: dieselbe Sitzung laeuft auch im
|
||||
Fenster der Statusuebersicht, und dort stuenden sonst die Zugangsdaten
|
||||
**jedes** Geraets im Quelltext der Seite. So holt sie sich nur, wer eine
|
||||
Sitzung wirklich oeffnet -- und nur fuer das eine Geraet.
|
||||
|
||||
Die frische Passwortbestaetigung wird hier von Hand geprueft statt ueber
|
||||
den Dekorator: der wuerde auf eine Seite umleiten, und eine Umleitung ist
|
||||
fuer einen Abruf im Hintergrund keine Auskunft. 409 mit einem Hinweis ist
|
||||
eine.
|
||||
"""
|
||||
conn = db()
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
port = _port(conn)
|
||||
if not port:
|
||||
abort(404)
|
||||
if not reauth_is_fresh():
|
||||
return {"reauth": True}, 409
|
||||
|
||||
zugang = vnc.zugang_gemerkt(device, port=port)
|
||||
benutzer, passwort = _zugangsdaten(conn, device)
|
||||
return {
|
||||
"name": str(device["name"]),
|
||||
"socket": f"/ws/screen/{device_id}",
|
||||
"ziel": f"{zugang.ziel}:{zugang.port}",
|
||||
"verfahren": list(zugang.verfahren),
|
||||
"bewaehrt": zugang.bewaehrt,
|
||||
"hinderungsgrund": zugang.hinderungsgrund,
|
||||
"meldeweg": url_for("screen.merke_verfahren", device_id=device_id),
|
||||
"vorgaben": {"username": benutzer, "password": passwort},
|
||||
}
|
||||
|
||||
|
||||
@bp.post("/monitoring/clients/<int:device_id>/screen/method")
|
||||
@permission_required("device_vnc.execute")
|
||||
def merke_verfahren(device_id: int):
|
||||
"""Der Browser meldet, womit die Sitzung zustande kam.
|
||||
|
||||
Nur er weiss es: die Strecke reicht Bytes durch und liest das Protokoll
|
||||
nicht. Beim naechsten Mal faengt die Seite damit an, statt wieder bei der
|
||||
ersten Wahl des Servers -- die hier schon einmal abgerissen ist und deren
|
||||
Absage sich ein sperrender RealVNC Server Minuten Zeit laesst.
|
||||
"""
|
||||
conn = db()
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
try:
|
||||
verfahren = int(request.json.get("verfahren", 0)) if request.json else 0
|
||||
except (TypeError, ValueError):
|
||||
verfahren = 0
|
||||
if not 0 < verfahren < 256:
|
||||
return {"ok": False}, 400
|
||||
|
||||
from ..services import monitor
|
||||
|
||||
with core().database.transaction(conn):
|
||||
monitor.merke_verfahren_das_traegt(conn, int(device["id"]), verfahren)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def init_app(app) -> None: # type: ignore[no-untyped-def]
|
||||
"""Meldet Seite und -- falls verfuegbar -- die WebSocket-Strecke an."""
|
||||
app.register_blueprint(bp)
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
except ImportError: # pragma: no cover - optionale Abhaengigkeit
|
||||
logger.info("flask-sock fehlt – die Bildschirmsitzung bleibt abgeschaltet.")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route("/ws/screen/<int:device_id>")
|
||||
def screen_socket(ws, device_id: int): # type: ignore[no-untyped-def]
|
||||
_strecke(ws, device_id)
|
||||
|
||||
@sock.route("/ws/probe")
|
||||
def probe_socket(ws): # type: ignore[no-untyped-def]
|
||||
"""Sagt "da", und mehr nicht.
|
||||
|
||||
Wozu: bricht eine Bildschirmsitzung ab, gibt es zwei sehr verschiedene
|
||||
Ursachen -- der VNC-Server mag nicht, oder die WebSocket-Verbindung
|
||||
kommt gar nicht erst durch (ein Proxy zwischen Browser und TESM, der
|
||||
"Upgrade" nicht weiterreicht, ist der haeufigste Fall). Von aussen
|
||||
sehen beide gleich aus: ein Abbruch ohne Grund. Diese Probe trennt
|
||||
sie, ohne dass jemand ein Protokoll lesen muss.
|
||||
"""
|
||||
if not current_user.is_authenticated:
|
||||
ws.close(1008, "Nicht angemeldet.")
|
||||
return
|
||||
try:
|
||||
ws.send("da")
|
||||
ws.receive(timeout=2)
|
||||
except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung
|
||||
pass
|
||||
|
||||
|
||||
def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
|
||||
"""Bytes hin, Bytes her -- und sonst nichts.
|
||||
|
||||
Die Pruefungen stehen hier ausdruecklich noch einmal: ein WebSocket geht an
|
||||
den Dekoratoren der HTTP-Route vorbei.
|
||||
"""
|
||||
extension = core()
|
||||
conn = db()
|
||||
|
||||
if not current_user.is_authenticated or not current_user.has("device_vnc.execute"):
|
||||
ws.close(1008, "Keine Berechtigung für den Bildschirm dieses Clients.")
|
||||
return
|
||||
if not reauth_is_fresh():
|
||||
ws.close(1008, "Die Passwortbestätigung ist abgelaufen.")
|
||||
return
|
||||
port = _port(conn)
|
||||
if not port:
|
||||
ws.close(1008, "Der Bildschirmzugriff ist in dieser Installation abgeschaltet.")
|
||||
return
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
ws.close(1008, "Client nicht gefunden.")
|
||||
return
|
||||
host = vnc.ziel(device)
|
||||
if not host:
|
||||
ws.close(1008, "Für diesen Client ist kein Ziel bekannt.")
|
||||
return
|
||||
|
||||
gegenstelle = None
|
||||
ausweich = str(device.get("ip") or "").strip()
|
||||
for versuch, ziel_host in enumerate([host] + ([ausweich] if ausweich != host else [])):
|
||||
try:
|
||||
gegenstelle = socket.create_connection((ziel_host, port), timeout=VERBINDUNGSLIMIT)
|
||||
if versuch:
|
||||
# Der Name fuehrte ins Leere, die Adresse nicht. Das ist eine
|
||||
# Auskunft ueber das Netz, keine Nebensaechlichkeit: irgendwo
|
||||
# zeigt ein Eintrag woandershin.
|
||||
if live_log := extension.registry.get("live_log"):
|
||||
live_log.warning(
|
||||
"vnc",
|
||||
f"{host} war nicht erreichbar – Sitzung läuft über {ziel_host}",
|
||||
)
|
||||
host = ziel_host
|
||||
break
|
||||
except OSError as exc:
|
||||
letzter = exc
|
||||
if gegenstelle is None:
|
||||
ws.close(1011, f"{host}:{port} antwortet nicht ({_erklaerung(letzter)}).")
|
||||
return
|
||||
# Das Zeitlimit galt dem *Aufbau* -- als Abbruchgrund taugt es nicht.
|
||||
#
|
||||
# ``create_connection`` setzt es an den Socket, und dort gilt es fuer jede
|
||||
# weitere Leseoperation. Ein Bildschirm, auf dem sich gerade nichts tut,
|
||||
# schickt sekundenlang nichts; nach sechs Sekunden Stille brach das Lesen
|
||||
# mit einer Zeitueberschreitung ab, und die Sitzung war weg. Genau so
|
||||
# beobachtet: Bild steht, nach fuenf bis zehn Sekunden Schluss.
|
||||
#
|
||||
# Stattdessen ein kurzer Takt: das Lesen laeuft immer wieder in ein
|
||||
# Zeitlimit, und *das* ist kein Abbruch, sondern der Anlass nachzusehen,
|
||||
# ob die Sitzung ueberhaupt noch etwas tut.
|
||||
# Ohne das sammelt der Kern kleine Pakete, bis genug beisammen ist
|
||||
# (Nagle) -- bei einem Bildschirm sind Tastendruck und Mausbewegung genau
|
||||
# solche kleinen Pakete, und das Warten darauf faellt als Ruckeln auf.
|
||||
gegenstelle.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
gegenstelle.settimeout(TAKT)
|
||||
leerlauf = int(extension.settings.get(conn, "vnc_idle_timeout") or 0)
|
||||
|
||||
# Ins **laufende** Protokoll, nicht in die Aenderungskette: eine
|
||||
# Bildschirmsitzung aendert nichts an der Anlage. Das Aenderungsprotokoll
|
||||
# soll beantworten, wer was umgestellt hat -- wer wann zugesehen hat,
|
||||
# gehoert nicht in dieselbe Liste und macht sie in einem Betrieb mit
|
||||
# ein paar Dutzend Geraeten unlesbar. Sichtbar ist die Sitzung trotzdem,
|
||||
# solange sie laeuft.
|
||||
begonnen = time.monotonic()
|
||||
live = extension.registry.get("live_log")
|
||||
if live is not None:
|
||||
live.info("vnc", f"{current_user.username} öffnet den Bildschirm von {device['name']}")
|
||||
|
||||
laeuft = threading.Event()
|
||||
laeuft.set()
|
||||
#: Wann zuletzt etwas floss -- in *irgendeine* Richtung. Eine Liste, weil
|
||||
#: beide Richtungen daran schreiben und eine Zuweisung in Python nicht in
|
||||
#: die andere hineinlaufen kann.
|
||||
zuletzt = [time.monotonic()]
|
||||
#: Grund des Endes, falls wir selbst getrennt haben.
|
||||
schluss = [""]
|
||||
#: Kam ueberhaupt schon ein Byte vom Geraet?
|
||||
gehoert = [False]
|
||||
|
||||
def vom_geraet() -> None:
|
||||
"""Alles, was der VNC-Server schickt, geht als Binaerrahmen weiter."""
|
||||
try:
|
||||
while laeuft.is_set():
|
||||
try:
|
||||
daten = gegenstelle.recv(BLOCK)
|
||||
except socket.timeout:
|
||||
# Kein Fehler, nur Stille -- und die bedeutet zweierlei,
|
||||
# je nachdem, ob die Sitzung schon stand.
|
||||
#
|
||||
# **Vor dem ersten Byte** ist Stille ein Befund: der Port
|
||||
# nimmt an, aber es kommt nichts. Genau so verhaelt sich
|
||||
# ein RealVNC Server, der die Adresse gerade aussperrt. Wer
|
||||
# das nicht erfaehrt, sieht ewig "Verbindung wird
|
||||
# aufgebaut" -- und sucht den Fehler bei uns.
|
||||
if not gehoert[0] and time.monotonic() - begonnen > VERBINDUNGSLIMIT:
|
||||
schluss[0] = (
|
||||
f"{host}:{port} nimmt die Verbindung an, schickt aber nichts. "
|
||||
"So verhält sich ein VNC-Server, der die Adresse gerade "
|
||||
"aussperrt (RealVNC: BlacklistTimeout) – ein Neustart des "
|
||||
"VNC-Dienstes auf dem Gerät räumt das ab."
|
||||
)
|
||||
break
|
||||
# **Danach** ist Stille der Normalfall: ein Bildschirm, auf
|
||||
# dem sich nichts tut, schickt nichts.
|
||||
if gehoert[0] and leerlauf and time.monotonic() - zuletzt[0] > leerlauf:
|
||||
schluss[0] = (
|
||||
f"Sitzung nach {leerlauf} Sekunden ohne Bewegung getrennt"
|
||||
)
|
||||
break
|
||||
continue
|
||||
if not daten:
|
||||
if not gehoert[0]:
|
||||
schluss[0] = (
|
||||
f"{host}:{port} hat die Verbindung sofort wieder geschlossen."
|
||||
)
|
||||
break
|
||||
gehoert[0] = True
|
||||
zuletzt[0] = time.monotonic()
|
||||
ws.send(daten)
|
||||
except Exception: # noqa: BLE001 - Abbruch ist der Normalfall, nicht die Stoerung
|
||||
pass
|
||||
finally:
|
||||
laeuft.clear()
|
||||
try:
|
||||
if schluss[0]:
|
||||
ws.close(1000 if gehoert[0] else 1011, schluss[0])
|
||||
else:
|
||||
ws.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
leser = threading.Thread(target=vom_geraet, name=f"vnc-{device_id}", daemon=True)
|
||||
leser.start()
|
||||
|
||||
try:
|
||||
while laeuft.is_set():
|
||||
nachricht = ws.receive()
|
||||
if nachricht is None:
|
||||
break
|
||||
if isinstance(nachricht, str):
|
||||
# Der Browser-Client schickt ausschliesslich Binaerrahmen. Text
|
||||
# waere entweder ein Irrtum oder ein Versuch -- beides endet
|
||||
# hier und nicht auf dem Geraet.
|
||||
continue
|
||||
zuletzt[0] = time.monotonic()
|
||||
gegenstelle.sendall(nachricht)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Bildschirmsitzung beendet (%s)", device["name"], exc_info=True)
|
||||
finally:
|
||||
laeuft.clear()
|
||||
try:
|
||||
gegenstelle.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
gegenstelle.close()
|
||||
dauer = int(time.monotonic() - begonnen)
|
||||
if live is not None:
|
||||
live.info(
|
||||
"vnc",
|
||||
f"Bildschirmsitzung {device['name']} beendet ({dauer}s)"
|
||||
+ (f" – {schluss[0]}" if schluss[0] else ""),
|
||||
)
|
||||
@@ -103,6 +103,10 @@ def system():
|
||||
"monitor_failures_before_restart",
|
||||
"monitor_auto_restart",
|
||||
"dashboard_public",
|
||||
"vnc_enabled",
|
||||
"vnc_port",
|
||||
"vnc_idle_timeout",
|
||||
"vnc_warn_unencrypted_off",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
@@ -143,6 +147,10 @@ def system_save():
|
||||
"monitor_failures_before_restart",
|
||||
"monitor_auto_restart",
|
||||
"dashboard_public",
|
||||
"vnc_enabled",
|
||||
"vnc_port",
|
||||
"vnc_idle_timeout",
|
||||
"vnc_warn_unencrypted_off",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
@@ -305,7 +313,7 @@ def run_check_now():
|
||||
extension = core()
|
||||
|
||||
def worker() -> None:
|
||||
from ..services import inventory, monitor
|
||||
from ..services import inventory, monitor, vnc
|
||||
|
||||
allowed = extension.license is None or extension.license.feature_allowed(
|
||||
"auto_poe_restart"
|
||||
@@ -315,6 +323,7 @@ def run_check_now():
|
||||
live=extension.registry["live_log"],
|
||||
keystore=extension.keystore,
|
||||
license_allows_auto_restart=allowed,
|
||||
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||
)
|
||||
|
||||
threading.Thread(target=worker, name="manual-sweep", daemon=True).start()
|
||||
|
||||
@@ -76,6 +76,16 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
|
||||
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
|
||||
("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
|
||||
("uname -a", "Kernel und Architektur"),
|
||||
# Die beiden schreiben -- deshalb stehen sie am Ende und sagen es im Namen.
|
||||
# Eine Vorlage fuellt nur das Eingabefeld; abgeschickt wird von Hand.
|
||||
(
|
||||
'echo "Encryption=AlwaysOff" | sudo tee -a /etc/vnc/config.d/common.custom',
|
||||
"VNC: Streckenverschlüsselung abschalten (schreibt)",
|
||||
),
|
||||
(
|
||||
"sudo systemctl restart vncserver-x11-serviced",
|
||||
"VNC-Dienst neu starten (schreibt)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -110,6 +110,36 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_license(args: argparse.Namespace) -> int:
|
||||
"""Die Lizenz als Auskunft fuer ein Ueberwachungswerkzeug.
|
||||
|
||||
Gedacht fuer einen RMM-Agenten, der auf dieser Maschine laeuft: kein Netz,
|
||||
kein Token, keine Anmeldung. Die Antwort steht auf **stdout**, alles andere
|
||||
(Protokollzeilen des Starts) auf stderr -- ein ``| ConvertFrom-Json`` soll
|
||||
nicht daran scheitern, dass eine Migration etwas erzaehlt hat.
|
||||
|
||||
Der Ausstiegscode traegt die Aussage: 0 heil, 1 laeuft bald ab, 2
|
||||
ungueltig. Das ist die Sprache, in der ein RMM Alarm schlaegt -- Text
|
||||
auszuwerten waere der Umweg.
|
||||
"""
|
||||
from tesm_core.licensing_client import abfrage_ausstiegscode, zustand_als_auskunft
|
||||
|
||||
app, extension = _app_and_core()
|
||||
if extension.license is None:
|
||||
print("{}")
|
||||
return 2
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
auskunft = zustand_als_auskunft(extension.license.reload(conn))
|
||||
if getattr(args, "days", False):
|
||||
# Eine Zahl, sonst nichts -- fuer Werkzeuge, die keinen JSON-Parser
|
||||
# mitbringen. Unbefristet hat keine Zahl und sagt das auch.
|
||||
tage = auskunft.get("days_left")
|
||||
print("unbefristet" if auskunft.get("lifetime") else ("" if tage is None else int(tage)))
|
||||
else:
|
||||
print(json.dumps(auskunft, ensure_ascii=False, sort_keys=True))
|
||||
return abfrage_ausstiegscode(auskunft)
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
@@ -219,7 +249,7 @@ def cmd_restore_signatures(_args: argparse.Namespace) -> int:
|
||||
|
||||
def cmd_check_now(_args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
from .services import inventory, monitor
|
||||
from .services import inventory, monitor, vnc
|
||||
|
||||
with app.app_context():
|
||||
allowed = extension.license is None or extension.license.feature_allowed(
|
||||
@@ -230,6 +260,7 @@ def cmd_check_now(_args: argparse.Namespace) -> int:
|
||||
live=extension.registry["live_log"],
|
||||
keystore=extension.keystore,
|
||||
license_allows_auto_restart=allowed,
|
||||
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||
)
|
||||
print(
|
||||
f"{report.checked} geprüft, {report.online} online, {report.offline} offline, "
|
||||
@@ -551,6 +582,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
status.add_argument("--json", action="store_true")
|
||||
status.set_defaults(func=cmd_status)
|
||||
|
||||
lizenz = sub.add_parser(
|
||||
"license", help="Lizenzzustand als JSON ausgeben (für Überwachungswerkzeuge)"
|
||||
)
|
||||
lizenz.add_argument(
|
||||
"--days", action="store_true", help="Nur die Restlaufzeit in Tagen ausgeben"
|
||||
)
|
||||
lizenz.set_defaults(func=cmd_license)
|
||||
|
||||
house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufräumen")
|
||||
house.set_defaults(func=cmd_housekeeping)
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ NAVIGATION = NavTree(
|
||||
children=(
|
||||
NavItem(
|
||||
key="logs_live",
|
||||
label="Laufend",
|
||||
label="Live",
|
||||
icon="terminal",
|
||||
endpoint="logs.live",
|
||||
permissions=("logs_live.read",),
|
||||
|
||||
@@ -78,6 +78,26 @@ DEVICE_RESTART_RPC = Resource(
|
||||
action_hints={A.EXECUTE: "Neustartauftrag anlegen (wie 'shutdown /m')."},
|
||||
)
|
||||
|
||||
#: Bildschirmzugriff ist **nicht** dasselbe wie die Konsole, auch wenn beides
|
||||
#: "auf das Geraet" fuehrt: hier sieht jemand den Bildschirm, wie er ist --
|
||||
#: samt dem, was ein Mensch dort gerade offen hat. Das ist eine eigene
|
||||
#: Entscheidung, also eine eigene Zeile. Sie wird **niemandem** mitgegeben:
|
||||
#: keine Migration setzt sie, auch nicht bei denen, die schon eine Konsole
|
||||
#: oeffnen duerfen. Wer sie haben soll, bekommt sie in der Matrix -- einmal,
|
||||
#: sichtbar, mit Zeile im Aenderungsprotokoll.
|
||||
DEVICE_VNC = Resource(
|
||||
key="device_vnc",
|
||||
parent="devices",
|
||||
label="Clients: Bildschirm (VNC)",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Verbindungsdatei bzw. vnc://-Adresse für den Bildschirm des Clients",
|
||||
action_hints={
|
||||
A.READ: "Den Knopf sehen, wo ein VNC-Server antwortet.",
|
||||
A.EXECUTE: "Verbindung öffnen. Die Datei enthält nur Ziel und Namen – "
|
||||
"was beim Verbinden verlangt wird, entscheidet der VNC-Server auf dem Gerät.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Die Client-Konsole lief unter ``maintenance.execute`` mit -- demselben Recht
|
||||
#: wie die Paketaktualisierung. Eine Sitzung auf einem Geraet ist aber etwas
|
||||
#: anderes als ein Auftrag, den der Server abarbeitet: hier tippt ein Mensch.
|
||||
@@ -167,6 +187,24 @@ SERVICING_TOOL = Resource(
|
||||
action_hints={A.EXECUTE: "Installation auslösen."},
|
||||
)
|
||||
|
||||
#: Ein freier Befehl auf vielen Geraeten ist das Maechtigste, was diese
|
||||
#: Anwendung kann -- und das Einzige, dessen Wirkung sie nicht kennt. Deshalb
|
||||
#: ein eigenes Recht, das niemand nebenbei bekommt: wer Pakete aktualisieren
|
||||
#: oder neu starten darf, tut etwas Umrissenes; wer hier etwas eintippt, tut
|
||||
#: alles, was ein Konto auf dem Geraet tun kann. Die Vorlagen nehmen dem den
|
||||
#: Alltag ab, nicht die Verantwortung.
|
||||
SERVICING_COMMAND = Resource(
|
||||
key="servicing_command",
|
||||
label="Befehl auf mehreren Geräten",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Eine Befehlszeile über SSH, auf allen ausgewählten Clients",
|
||||
action_hints={
|
||||
A.READ: "Die Karte sehen – und damit, was zuletzt lief.",
|
||||
A.EXECUTE: "Befehl absenden. Verlangt eine frische Passwortbestätigung; "
|
||||
"jeder Auftrag steht mit seinem Wortlaut im Änderungsprotokoll.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon
|
||||
#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen
|
||||
#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander.
|
||||
@@ -174,6 +212,7 @@ SERVICING_PAGE: tuple[str, ...] = (
|
||||
"servicing_update.read",
|
||||
"servicing_reboot.read",
|
||||
"servicing_tool.read",
|
||||
"servicing_command.read",
|
||||
)
|
||||
|
||||
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
|
||||
@@ -225,6 +264,31 @@ DHCP_SERVICE = Resource(
|
||||
action_hints={A.EXECUTE: "Dienstzustand ändern."},
|
||||
)
|
||||
|
||||
#: Eine dynamische Lease in einen Client verwandeln.
|
||||
#:
|
||||
#: Eigene Zeile, weil es eine eigene Entscheidung ist: hier wird aus einer
|
||||
#: Beobachtung ("dieses Geraet hat sich eine Adresse geholt") ein gepflegter
|
||||
#: Datensatz, den die Ueberwachung ab sofort anpingt und der beim naechsten
|
||||
#: Schreiben der Konfiguration eine feste Adresse bekommt. Wer Leases sehen
|
||||
#: darf, soll deshalb nicht automatisch welche uebernehmen duerfen.
|
||||
#:
|
||||
#: **Und es reicht allein nicht.** Die Uebernahme legt einen Client an, also
|
||||
#: verlangt die Route zusaetzlich "Clients: Anlegen". Sonst waere dies eine
|
||||
#: Hintertuer an der Geraeteverwaltung vorbei -- dieselbe Wirkung, anderes
|
||||
#: Formular. Beide Haken zusammen, oder die Schaltflaeche erscheint nicht.
|
||||
DHCP_LEASE_ADOPT = Resource(
|
||||
key="dhcp_lease_adopt",
|
||||
parent="dhcp_subnets",
|
||||
label="DHCP: Lease als Client übernehmen",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Aus einer dynamisch vergebenen Adresse einen überwachten Client machen",
|
||||
action_hints={
|
||||
A.READ: "Die Schaltfläche an dynamischen Leases sehen.",
|
||||
A.EXECUTE: "Client aus der Lease anlegen. Verlangt zusätzlich "
|
||||
"'Clients: Anlegen' – es entsteht ein Gerätedatensatz.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Die DHCP-Seite zeigt alle Abschnitte; jeder Abschnitt haengt an seiner
|
||||
#: eigenen Zeile. Geoeffnet wird sie, wer einen davon sehen darf.
|
||||
DHCP_PAGE: tuple[str, ...] = (
|
||||
@@ -316,6 +380,7 @@ MONITORING_AREA = Area(
|
||||
DEVICE_RESTART_SSH,
|
||||
DEVICE_RESTART_RPC,
|
||||
DEVICE_CONSOLE,
|
||||
DEVICE_VNC,
|
||||
SWITCHES,
|
||||
SWITCH_CONSOLE,
|
||||
HOSTKEYS,
|
||||
@@ -333,14 +398,21 @@ MAINTENANCE_AREA = Area(
|
||||
icon="wrench",
|
||||
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
|
||||
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
|
||||
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_TOOL),
|
||||
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_COMMAND, SERVICING_TOOL),
|
||||
)
|
||||
|
||||
NETWORK_AREA = Area(
|
||||
key="network",
|
||||
label="Netzwerkdienste",
|
||||
icon="network",
|
||||
resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE),
|
||||
resources=(
|
||||
DHCP_SUBNETS,
|
||||
DHCP_OPTIONS,
|
||||
DHCP_RESERVATIONS,
|
||||
DHCP_LEASE_ADOPT,
|
||||
DHCP_CONFIG,
|
||||
DHCP_SERVICE,
|
||||
),
|
||||
)
|
||||
|
||||
FILES_AREA = Area(
|
||||
|
||||
@@ -490,6 +490,68 @@ def eingefrorene_vorlagen(
|
||||
return vorlagen
|
||||
|
||||
|
||||
# Version 10 ----------------------------------------------------------------
|
||||
#
|
||||
# Wo antwortet ein VNC-Server? Die Frage beantwortet der Ueberwachungs-
|
||||
# durchlauf mit, der ohnehin jedes Geraet anfasst -- einmal je Intervall statt
|
||||
# einmal je Seitenaufruf. Die Kacheln der Statusuebersicht koennen den Knopf
|
||||
# damit zeigen, ohne dass das Rendern der Seite ein Dutzend Verbindungen
|
||||
# aufbaut.
|
||||
V10 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_open INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE device_status ADD COLUMN vnc_checked_at TEXT;
|
||||
"""
|
||||
|
||||
|
||||
# Version 11 ----------------------------------------------------------------
|
||||
#
|
||||
# Nicht nur *ob* dort ein VNC-Server antwortet, sondern *wohin* gefragt wurde.
|
||||
# Die Kacheln der Statusuebersicht verlinken die Verbindung unmittelbar
|
||||
# (vnc://hap02); das Ziel dafuer aus dem Namen neu herzuleiten hiesse, bei
|
||||
# jedem Seitenaufbau je Kachel eine Namensaufloesung zu versuchen. Der
|
||||
# Durchlauf hat sie ohnehin schon gemacht -- also schreibt er sie hin.
|
||||
V11 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_target TEXT NOT NULL DEFAULT '';
|
||||
"""
|
||||
|
||||
|
||||
# Version 12 ----------------------------------------------------------------
|
||||
#
|
||||
# Welche Sicherheitsverfahren der VNC-Server angeboten hat. Daran entscheidet
|
||||
# sich, ob eine Sitzung im Browser ueberhaupt zustande kommen kann -- und wer
|
||||
# das erst erfaehrt, wenn er vor einer schwarzen Flaeche sitzt, sucht den
|
||||
# Fehler bei uns.
|
||||
V12 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_types TEXT NOT NULL DEFAULT '';
|
||||
"""
|
||||
|
||||
|
||||
# Version 13 ----------------------------------------------------------------
|
||||
#
|
||||
# Die Absage eines VNC-Servers und der Zeitpunkt der Verfahrensmessung. Beide
|
||||
# gehoeren getrennt vom Ueberwachungsdurchlauf gefuehrt: der fragt nur noch,
|
||||
# ob der Port annimmt (eine abgebrochene Protokollverhandlung sperrt bei
|
||||
# RealVNC die eigene Adresse aus), die Verfahren misst die Seite -- und auch
|
||||
# die nur, wenn die letzte Messung alt ist.
|
||||
V13 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_reason TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE device_status ADD COLUMN vnc_types_at TEXT;
|
||||
"""
|
||||
|
||||
|
||||
# Version 14 ----------------------------------------------------------------
|
||||
#
|
||||
# Welches Anmeldeverfahren zuletzt wirklich getragen hat. Der Server nennt
|
||||
# seine Reihenfolge, und die gilt -- aber wenn seine erste Wahl schon einmal
|
||||
# abgerissen ist und eine andere zum Bild gefuehrt hat, ist das ein Befund.
|
||||
# Ihn zu vergessen kostet bei jedem Oeffnen einen Fehlversuch, und RealVNC
|
||||
# laesst sich mit der Absage Zeit: gemessen zwei bis drei Minuten, bis das
|
||||
# Bild stand.
|
||||
V14 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_method INTEGER NOT NULL DEFAULT 0;
|
||||
"""
|
||||
|
||||
|
||||
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
|
||||
|
||||
@@ -532,4 +594,9 @@ TESM_MIGRATIONS: tuple[Migration, ...] = (
|
||||
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
|
||||
Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
|
||||
Migration(version=9, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
|
||||
Migration(version=10, name="vnc-erreichbarkeit", sql=V10),
|
||||
Migration(version=11, name="vnc-ziel", sql=V11),
|
||||
Migration(version=12, name="vnc-verfahren", sql=V12),
|
||||
Migration(version=13, name="vnc-absage", sql=V13),
|
||||
Migration(version=14, name="vnc-verfahren-das-traegt", sql=V14),
|
||||
)
|
||||
@@ -542,7 +542,11 @@ def list_devices(
|
||||
WHERE k.host = d.ip AND k.port = d.ssh_port) AS host_key_known,
|
||||
COALESCE(st.state,'unknown') AS state, st.latency_ms, st.last_check_at,
|
||||
st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count,
|
||||
st.fail_count
|
||||
st.fail_count, COALESCE(st.vnc_open, 0) AS vnc_open, st.vnc_checked_at,
|
||||
COALESCE(st.vnc_target, '') AS vnc_target,
|
||||
COALESCE(st.vnc_types, '') AS vnc_types,
|
||||
COALESCE(st.vnc_reason, '') AS vnc_reason, st.vnc_types_at,
|
||||
COALESCE(st.vnc_method, 0) AS vnc_method
|
||||
FROM devices d
|
||||
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
|
||||
|
||||
@@ -186,9 +186,20 @@ def matching_network(
|
||||
|
||||
|
||||
def validate_subnet(
|
||||
networks: Iterable[InterfaceNetwork], interface: str, range_start: str, range_end: str
|
||||
networks: Iterable[InterfaceNetwork],
|
||||
interface: str,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
*,
|
||||
vorhandene: Iterable[Any] = (),
|
||||
eigene_id: int | None = None,
|
||||
) -> str:
|
||||
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
|
||||
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung.
|
||||
|
||||
``vorhandene`` sind die schon eingetragenen Bereiche. Ueberschneidet sich
|
||||
der neue mit einem davon, entsteht eine Konfiguration, die Kea als Ganzes
|
||||
ablehnt -- besser hier sagen als spaeter beim Schreiben.
|
||||
"""
|
||||
if not interface:
|
||||
return "Es ist kein Interface ausgewählt."
|
||||
try:
|
||||
@@ -203,9 +214,41 @@ def validate_subnet(
|
||||
f"Der Bereich {range_start}-{range_end} passt in kein Netz von {interface}. "
|
||||
"Subnetz und Maske werden vom laufenden System gelesen."
|
||||
)
|
||||
for zeile in vorhandene:
|
||||
if eigene_id is not None and int(zeile["id"]) == eigene_id:
|
||||
continue
|
||||
if str(zeile["interface"]) != interface:
|
||||
continue
|
||||
try:
|
||||
anfang = int(ipaddress.ip_address(str(zeile["range_start"])))
|
||||
ende = int(ipaddress.ip_address(str(zeile["range_end"])))
|
||||
except ValueError:
|
||||
continue
|
||||
if int(start) <= ende and int(end) >= anfang:
|
||||
return (
|
||||
f"Der Bereich überschneidet sich mit {zeile['range_start']}-"
|
||||
f"{zeile['range_end']} auf {interface}. Erweitern Sie den vorhandenen "
|
||||
"Bereich, statt einen zweiten anzulegen."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def laufende_konfiguration(sysops: SysOps) -> dict[str, Any] | None:
|
||||
"""Die Konfiguration, die tatsaechlich auf der Platte liegt.
|
||||
|
||||
``None`` heisst: nicht lesbar (kein Helfer, Datei fehlt, kein JSON) -- das
|
||||
ist etwas anderes als "weicht ab" und wird auch anders angezeigt.
|
||||
"""
|
||||
result = sysops.read_privileged_file(DEFAULT_CONFIG_PATH)
|
||||
if not result.ok or not (result.stdout or "").strip():
|
||||
return None
|
||||
try:
|
||||
geladen = json.loads(result.stdout)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return geladen if isinstance(geladen, dict) else None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reservierungen
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -393,8 +436,16 @@ def render_config(
|
||||
return entries
|
||||
|
||||
subnet_entries: list[dict[str, Any]] = []
|
||||
# Ein Netz, ein Eintrag. Kea lehnt die **ganze** Konfiguration ab, sobald
|
||||
# derselbe Praefix zweimal darin steht ("subnet with the prefix of
|
||||
# '192.168.82.0/24' already exists") -- und dann laeuft der Dienst
|
||||
# unbemerkt mit dem alten Stand weiter. Zwei Zeilen im selben Netz sind
|
||||
# aber nichts Unsinniges: es sind zwei Bereiche. Sie werden deshalb zu
|
||||
# einem Subnetz mit mehreren Pools zusammengefasst.
|
||||
nach_netz: dict[str, dict[str, Any]] = {}
|
||||
belegte_bereiche: dict[str, list[tuple[int, int]]] = {}
|
||||
interfaces: set[str] = set()
|
||||
for index, subnet in enumerate(subnets, start=1):
|
||||
for subnet in subnets:
|
||||
interface = str(subnet["interface"])
|
||||
match = matching_network(
|
||||
networks, interface, str(subnet["range_start"]), str(subnet["range_end"])
|
||||
@@ -433,15 +484,40 @@ def render_config(
|
||||
entry["option-data"] = option_data(overrides)
|
||||
subnet_reservations.append(entry)
|
||||
|
||||
subnet_entries.append(
|
||||
{
|
||||
"id": index,
|
||||
"subnet": str(network),
|
||||
"pools": [{"pool": f"{subnet['range_start']} - {subnet['range_end']}"}],
|
||||
schluessel = str(network)
|
||||
eintrag = nach_netz.get(schluessel)
|
||||
if eintrag is None:
|
||||
eintrag = {
|
||||
# Die Nummer zaehlt die Eintraege, nicht die Zeilen der
|
||||
# Tabelle -- sonst haette ein uebersprungenes Subnetz eine
|
||||
# Luecke hinterlassen, und Kea verlangt eindeutige IDs.
|
||||
"id": len(subnet_entries) + 1,
|
||||
"subnet": schluessel,
|
||||
"pools": [],
|
||||
**({"option-data": subnet_options} if subnet_options else {}),
|
||||
"reservations": subnet_reservations,
|
||||
}
|
||||
nach_netz[schluessel] = eintrag
|
||||
belegte_bereiche[schluessel] = []
|
||||
subnet_entries.append(eintrag)
|
||||
elif subnet_options and subnet_options != eintrag.get("option-data", []):
|
||||
warnings.append(
|
||||
f"Für {schluessel} sind mehrere Bereiche mit unterschiedlichem Gateway oder "
|
||||
"DNS eingetragen. Ein Netz kann nur eines haben | es gilt der erste Eintrag."
|
||||
)
|
||||
|
||||
# Auch zwei Pools, die sich ueberschneiden, bringen Kea zu Fall.
|
||||
von, bis = int(ipaddress.ip_address(str(subnet["range_start"]))), int(
|
||||
ipaddress.ip_address(str(subnet["range_end"]))
|
||||
)
|
||||
if any(von <= ende and bis >= anfang for anfang, ende in belegte_bereiche[schluessel]):
|
||||
warnings.append(
|
||||
f"Der Bereich {subnet['range_start']}-{subnet['range_end']} überschneidet sich "
|
||||
"mit einem bereits eingetragenen und wurde übersprungen."
|
||||
)
|
||||
continue
|
||||
belegte_bereiche[schluessel].append((von, bis))
|
||||
eintrag["pools"].append({"pool": f"{subnet['range_start']} - {subnet['range_end']}"})
|
||||
|
||||
if not subnet_entries:
|
||||
warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.")
|
||||
@@ -653,9 +729,157 @@ def read_leases(sysops: SysOps, *, path: str = LEASE_FILE) -> list[Lease]:
|
||||
return sorted(latest.values(), key=lambda entry: tuple(int(p) for p in entry.address.split(".") if p.isdigit()))
|
||||
|
||||
|
||||
#: Optionen, die jeder DHCP-Client von sich aus erfragt. Sie in die
|
||||
#: Client-Konfiguration zu schreiben waere Ballast -- und wer Ballast liest,
|
||||
#: liest irgendwann gar nicht mehr.
|
||||
IMMER_ANGEFRAGT = frozenset({1, 3, 6, 12, 15, 28, 42, 51, 54, 58, 59, 119, 121})
|
||||
|
||||
#: Die Stile, in denen eine Client-Konfiguration ausgegeben wird.
|
||||
#:
|
||||
#: Drei, weil es auf den Zielsystemen drei gibt, und die Wahl nicht am
|
||||
#: Geschmack haengt, sondern daran, was die Distribution mitbringt:
|
||||
#:
|
||||
#: * **networkd** -- Ubuntu-Server 22.04 bis 26.04 (netplan erzeugt die
|
||||
#: .network-Dateien), auch auf Debian verbreitet. Angeforderte Fremdoptionen
|
||||
#: landen als ``OPTION_<code>`` in der Lease unter /run/systemd/netif/leases.
|
||||
#: * **dhcpcd** -- Debian 12/13, Raspberry Pi OS, ab Ubuntu 24.04 verfuegbar.
|
||||
#: * **dhclient** -- Debian 11 bis 13 und Ubuntu 22.04, dort wo isc-dhcp-client
|
||||
#: noch installiert ist.
|
||||
CLIENT_STILE: tuple[tuple[str, str, str], ...] = (
|
||||
("networkd", "systemd-networkd", "/etc/systemd/network/10-tesm.network.d/50-tesm.conf"),
|
||||
("dhcpcd", "dhcpcd", "/etc/dhcpcd.conf"),
|
||||
("dhclient", "dhclient (isc-dhcp-client)", "/etc/dhcp/dhclient.conf"),
|
||||
)
|
||||
|
||||
#: Wie ein Typ aus dem Optionskatalog in den jeweiligen Clients heisst.
|
||||
_DHCPCD_TYPEN = {
|
||||
"string": "string",
|
||||
"ipv4-address": "ipaddress",
|
||||
"uint8": "byte",
|
||||
"uint16": "uint16",
|
||||
"uint32": "uint32",
|
||||
"boolean": "byte",
|
||||
}
|
||||
_DHCLIENT_TYPEN = {
|
||||
"string": "text",
|
||||
"ipv4-address": "ip-address",
|
||||
"uint8": "unsigned integer 8",
|
||||
"uint16": "unsigned integer 16",
|
||||
"uint32": "unsigned integer 32",
|
||||
"boolean": "boolean",
|
||||
}
|
||||
|
||||
|
||||
def gesendete_optionen(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
"""Die Optionen, die dieser Server tatsaechlich ausliefert.
|
||||
|
||||
Gemeint ist: wofuer irgendwo ein Wert gesetzt ist -- global oder an einem
|
||||
einzelnen Geraet. Was im Katalog steht, aber leer ist, wird nicht gesendet
|
||||
und muss vom Client auch nicht angefordert werden.
|
||||
"""
|
||||
return Database.all(
|
||||
conn,
|
||||
"""
|
||||
SELECT DISTINCT o.code, o.name, o.type, o.description, o.is_standard
|
||||
FROM dhcp_options o
|
||||
JOIN dhcp_option_values v ON v.option_id = o.id
|
||||
WHERE TRIM(COALESCE(v.value, '')) <> ''
|
||||
ORDER BY o.code
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
|
||||
"""Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen.
|
||||
|
||||
**Warum es das braucht.** Ein DHCP-Client bekommt nur, wonach er fragt.
|
||||
Standardoptionen (Gateway, DNS, Domain) fragt er von sich aus; alles
|
||||
darueber hinaus -- und dazu gehoert jede eigene Option -- muss er
|
||||
ausdruecklich anfordern. Ohne diese Zeilen sendet der Server brav und der
|
||||
Client wirft es weg, ohne dass irgendwo etwas danebengeht.
|
||||
|
||||
Rueckgabe: ``{"pfad", "inhalt", "nachher", "optionen"}`` -- Datei, Inhalt,
|
||||
der Befehl zum Uebernehmen und die Optionen, um die es geht.
|
||||
"""
|
||||
optionen = [
|
||||
eintrag
|
||||
for eintrag in gesendete_optionen(conn)
|
||||
if int(eintrag["code"]) not in IMMER_ANGEFRAGT
|
||||
]
|
||||
codes = [int(eintrag["code"]) for eintrag in optionen]
|
||||
pfad = dict((s[0], s[2]) for s in CLIENT_STILE).get(stil, "")
|
||||
|
||||
if stil == "networkd":
|
||||
zeilen = [
|
||||
"# Von TESM erzeugt. Fordert die Optionen an, die dieser Server sendet --",
|
||||
"# ohne diese Zeilen verwirft der Client sie stillschweigend.",
|
||||
"#",
|
||||
"# Der Dateiname muss zur .network-Datei der Schnittstelle passen:",
|
||||
"# netplan legt sie unter /run/systemd/network/10-netplan-<if>.network ab,",
|
||||
"# der Zusatz gehoert also nach",
|
||||
"# /etc/systemd/network/10-netplan-<if>.network.d/50-tesm.conf.",
|
||||
"#",
|
||||
"# Die Werte stehen danach in /run/systemd/netif/leases/<ifindex>",
|
||||
"# als OPTION_<code>=...",
|
||||
"[DHCPv4]",
|
||||
]
|
||||
zeilen.append("RequestOptions=" + " ".join(str(code) for code in codes))
|
||||
nachher = "networkctl reload && networkctl reconfigure <schnittstelle>"
|
||||
elif stil == "dhcpcd":
|
||||
zeilen = [
|
||||
"# Von TESM erzeugt -- an das Ende von /etc/dhcpcd.conf.",
|
||||
"# 'define' macht die Option bekannt, 'option' fordert sie an.",
|
||||
"# Danach steht der Wert in der Lease (/var/lib/dhcpcd/<if>.lease)",
|
||||
"# und laesst sich mit 'dhcpcd -U <if>' als Variable auslesen.",
|
||||
]
|
||||
for eintrag in optionen:
|
||||
typ = _DHCPCD_TYPEN.get(str(eintrag["type"]), "string")
|
||||
zeilen.append(f"define {eintrag['code']} {typ} {eintrag['name']}")
|
||||
for eintrag in optionen:
|
||||
zeilen.append(f"option {eintrag['name']}")
|
||||
nachher = "systemctl restart dhcpcd"
|
||||
else:
|
||||
zeilen = [
|
||||
"# Von TESM erzeugt -- an das Ende von /etc/dhcp/dhclient.conf.",
|
||||
"# Die Werte stehen danach in /var/lib/dhcp/dhclient.leases,",
|
||||
"# und in Hooks unter $new_<name>.",
|
||||
]
|
||||
for eintrag in optionen:
|
||||
typ = _DHCLIENT_TYPEN.get(str(eintrag["type"]), "text")
|
||||
zeilen.append(f"option {eintrag['name']} code {eintrag['code']} = {typ};")
|
||||
if optionen:
|
||||
zeilen.append(
|
||||
"also request " + ", ".join(str(eintrag["name"]) for eintrag in optionen) + ";"
|
||||
)
|
||||
nachher = "systemctl restart networking || dhclient -r && dhclient"
|
||||
|
||||
return {
|
||||
"pfad": pfad,
|
||||
"inhalt": chr(10).join(zeilen) + chr(10),
|
||||
"nachher": nachher,
|
||||
"optionen": optionen,
|
||||
}
|
||||
|
||||
|
||||
def service_state(sysops: SysOps) -> dict[str, str]:
|
||||
state = sysops.service_state(SERVICE_UNIT)
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
state["binary"] = "vorhanden" if shutil.which("kea-dhcp4") else "fehlt"
|
||||
pfad = shutil.which("kea-dhcp4")
|
||||
state["binary"] = "vorhanden" if pfad else "fehlt"
|
||||
# Die Fassung gehoert auf die Seite: zwischen Kea 2.4 (Ubuntu 24.04) und
|
||||
# 3.0 (26.04) liegen Aenderungen, und wer eine Konfiguration beurteilt,
|
||||
# will nicht erst auf der Kommandozeile nachsehen muessen, wogegen.
|
||||
state["version"] = ""
|
||||
if pfad:
|
||||
try:
|
||||
fertig = subprocess.run(
|
||||
[pfad, "-V"], capture_output=True, text=True, timeout=5, check=False
|
||||
)
|
||||
# ``kea-dhcp4 -V`` antwortet mit "3.0.3 (3.0.3 (tarball))" -- auf
|
||||
# einer Kachel steht davon die Nummer, nicht die Herkunft.
|
||||
state["version"] = (fertig.stdout or "").strip().split()[0].strip()
|
||||
except (OSError, subprocess.SubprocessError, IndexError):
|
||||
state["version"] = ""
|
||||
return state
|
||||
@@ -29,7 +29,7 @@ import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from tesm_core import audit
|
||||
@@ -44,6 +44,51 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
JOB_KIND_UPDATE = "maintenance.update"
|
||||
JOB_KIND_REBOOT = "maintenance.reboot"
|
||||
JOB_KIND_COMMAND = "maintenance.command"
|
||||
|
||||
#: Vorlagen fuer den Massenbefehl: Name, Befehl, Erklaerung.
|
||||
#:
|
||||
#: ``{sudo}`` wird zur Laufzeit ersetzt -- mit ``sudo -n``, wo es ohne Passwort
|
||||
#: geht, sonst mit ``sudo -S`` und dem hinterlegten Passwort ueber stdin. Wer
|
||||
#: eine Vorlage waehlt, sieht den Befehl im Feld und kann ihn aendern: eine
|
||||
#: Vorlage ist ein Vorschlag, kein Automat.
|
||||
BEFEHLSVORLAGEN: tuple[tuple[str, str, str], ...] = (
|
||||
(
|
||||
"VNC: Streckenverschlüsselung abschalten",
|
||||
'echo "Encryption=AlwaysOff" | {sudo} tee -a /etc/vnc/config.d/common.custom',
|
||||
"Danach bietet RealVNC auch RA2ne und „ohne Authentifizierung“ an – nur damit "
|
||||
"kommt eine Sitzung im Browser zustande. Die Strecke ist dann unverschlüsselt; "
|
||||
"im Netz zwischen Server und Gerät ist sie es ohnehin.",
|
||||
),
|
||||
(
|
||||
"VNC-Dienst neu starten",
|
||||
"{sudo} systemctl restart vncserver-x11-serviced",
|
||||
"Räumt auch eine Sperre ab, die der Server nach zu vielen abgebrochenen "
|
||||
"Verbindungsversuchen gesetzt hat.",
|
||||
),
|
||||
(
|
||||
"VNC: aktuelle Einstellungen zeigen",
|
||||
"cat /etc/vnc/config.d/common.custom 2>/dev/null || echo '(keine eigene Konfiguration)'",
|
||||
"Liest nur – gut, um vor dem Ändern zu sehen, was schon dasteht.",
|
||||
),
|
||||
(
|
||||
"Zeit und Zeitzone",
|
||||
"timedatectl",
|
||||
"Liest nur.",
|
||||
),
|
||||
(
|
||||
"Belegung der Dateisysteme",
|
||||
"df -h",
|
||||
"Liest nur.",
|
||||
),
|
||||
)
|
||||
|
||||
#: Was in einer Befehlszeile nichts zu suchen hat. Nicht als Schutz vor einem
|
||||
#: Menschen mit diesem Recht -- er darf ohnehin alles, was sein Konto auf dem
|
||||
#: Geraet darf --, sondern gegen Unfaelle: ein Zeilenumbruch aus der
|
||||
#: Zwischenablage macht aus einem Befehl unbemerkt zwei.
|
||||
VERBOTENE_ZEICHEN = ("\n", "\r", "\x00")
|
||||
MAX_BEFEHLSLAENGE = 500
|
||||
|
||||
MAX_OUTPUT_LINES = 400
|
||||
CONNECT_TIMEOUT = 25
|
||||
@@ -231,16 +276,89 @@ def update_job(
|
||||
|
||||
|
||||
def list_jobs(
|
||||
conn: sqlite3.Connection, *, kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT), limit: int = 60
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND),
|
||||
limit: int = 60,
|
||||
) -> list[dict[str, Any]]:
|
||||
placeholders = ",".join("?" * len(kinds))
|
||||
return Database.all(
|
||||
conn,
|
||||
f"SELECT * FROM jobs WHERE kind IN ({placeholders}) ORDER BY created_at DESC LIMIT ?",
|
||||
# ``rowid`` als zweites Ordnungsmerkmal: ``created_at`` hat
|
||||
# Sekundenaufloesung, und ein Massenbefehl legt vierzig Auftraege in
|
||||
# derselben Sekunde an. Ohne das entschied bei Gleichstand die
|
||||
# Speicherreihenfolge -- die Liste sah bei jedem Aufruf anders aus.
|
||||
f"SELECT * FROM jobs WHERE kind IN ({placeholders}) "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||
(*kinds, limit),
|
||||
)
|
||||
|
||||
|
||||
def laufende_und_letzte(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND),
|
||||
limit: int = 30,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Alles, was laeuft oder eben fertig wurde -- und dazu die letzten Auftraege.
|
||||
|
||||
Fuer die Statusabfrage der Wartungsseite. Eine feste Obergrenze allein
|
||||
reicht dort nicht, und das war ein echter Fehler: ein Massenbefehl auf
|
||||
vierzig Geraete legt vierzig Auftraege an, ``LIMIT 30`` schnitt zehn davon
|
||||
ab -- und weil alle denselben Zeitstempel auf die Sekunde tragen, waren es
|
||||
zehn willkuerliche. Auf der Seite sah das aus, als haetten die letzten
|
||||
Geraete den Befehl nie bekommen: sie liefen, ihre Ausgabe kam nur nie an.
|
||||
|
||||
Was laeuft, faellt hier deshalb nie heraus, und was gerade fertig wurde,
|
||||
behaelt seine letzten Zeilen so lange wie ``eben_fertig`` es zusagt.
|
||||
"""
|
||||
platzhalter = ",".join("?" * len(kinds))
|
||||
schwelle = (
|
||||
datetime.now(timezone.utc) - timedelta(seconds=EBEN_FERTIG_SEKUNDEN)
|
||||
).isoformat(timespec="seconds")
|
||||
gesammelt: dict[str, dict[str, Any]] = {}
|
||||
for sql, params in (
|
||||
(
|
||||
f"SELECT * FROM jobs WHERE kind IN ({platzhalter}) "
|
||||
"AND state IN ('pending','running') ORDER BY created_at DESC, rowid DESC",
|
||||
(*kinds,),
|
||||
),
|
||||
(
|
||||
f"SELECT * FROM jobs WHERE kind IN ({platzhalter}) "
|
||||
"AND COALESCE(finished_at, updated_at) >= ? ORDER BY created_at DESC, rowid DESC",
|
||||
(*kinds, schwelle),
|
||||
),
|
||||
(
|
||||
f"SELECT * FROM jobs WHERE kind IN ({platzhalter}) "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||
(*kinds, limit),
|
||||
),
|
||||
):
|
||||
for job in Database.all(conn, sql, params):
|
||||
gesammelt.setdefault(str(job["id"]), job)
|
||||
return sorted(
|
||||
gesammelt.values(), key=lambda job: str(job["created_at"] or ""), reverse=True
|
||||
)
|
||||
|
||||
|
||||
def letzter_auftrag_je_geraet(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND),
|
||||
limit: int = 400,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Zu jedem Geraetenamen sein neuester Auftrag.
|
||||
|
||||
Damit die Wartungsseite schon beim Aufbau zeigt, was wo laeuft -- und nicht
|
||||
erst, wenn die erste Statusabfrage zurueck ist. Der Schluessel ist der
|
||||
Name, denn genau den traegt ein Auftrag als ``subject``.
|
||||
"""
|
||||
stand: dict[str, dict[str, Any]] = {}
|
||||
for job in list_jobs(conn, kinds=kinds, limit=limit):
|
||||
stand.setdefault(str(job["subject"]), job)
|
||||
return stand
|
||||
|
||||
|
||||
def get_job(conn: sqlite3.Connection, job_id: str) -> dict[str, Any] | None:
|
||||
return Database.one(conn, "SELECT * FROM jobs WHERE id=?", (job_id,))
|
||||
|
||||
@@ -447,6 +565,149 @@ def run_update(
|
||||
)
|
||||
|
||||
|
||||
#: So lange gilt ein Auftrag als "eben fertig geworden" und schickt seine
|
||||
#: letzten Zeilen noch mit. Ohne das verschwaende die Ausgabe genau in dem
|
||||
#: Augenblick, in dem sie interessant wird: wenn der Befehl durch ist.
|
||||
EBEN_FERTIG_SEKUNDEN = 120
|
||||
|
||||
#: So viele Zeilen gehen ins Mitlesen. Genug, um zu sehen, was gerade
|
||||
#: passiert; wenig genug, dass eine Abfrage alle zwei Sekunden nichts kostet.
|
||||
MITLESEN_ZEILEN = 40
|
||||
|
||||
|
||||
def letzte_zeilen(ausgabe: str, anzahl: int = MITLESEN_ZEILEN) -> str:
|
||||
"""Das Ende einer Ausgabe -- das ist der Teil, der gerade entsteht."""
|
||||
zeilen = [zeile for zeile in ausgabe.splitlines() if zeile.strip()]
|
||||
return "\n".join(zeilen[-anzahl:])
|
||||
|
||||
|
||||
def eben_fertig(job: dict[str, Any], sekunden: int = EBEN_FERTIG_SEKUNDEN) -> bool:
|
||||
"""Ist dieser Auftrag gerade eben fertig geworden?"""
|
||||
if job.get("state") in {"pending", "running"}:
|
||||
return True
|
||||
stempel = str(job.get("finished_at") or job.get("updated_at") or "")
|
||||
if not stempel:
|
||||
return False
|
||||
try:
|
||||
moment = datetime.fromisoformat(stempel)
|
||||
except ValueError:
|
||||
return False
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
return (datetime.now(timezone.utc) - moment).total_seconds() < sekunden
|
||||
|
||||
|
||||
def befehl_pruefen(befehl: str) -> str:
|
||||
"""Gibt den bereinigten Befehl zurueck -- oder wirft mit Klartext.
|
||||
|
||||
Eine eigene Funktion, weil genau diese Frage sich pruefen lassen muss,
|
||||
ohne ein Geraet zu brauchen.
|
||||
"""
|
||||
sauber = (befehl or "").strip()
|
||||
if not sauber:
|
||||
raise ValueError("Es wurde kein Befehl eingegeben.")
|
||||
if len(sauber) > MAX_BEFEHLSLAENGE:
|
||||
raise ValueError(
|
||||
f"Der Befehl ist zu lang (mehr als {MAX_BEFEHLSLAENGE} Zeichen). "
|
||||
"Für längere Abläufe ist ein Skript auf dem Gerät der bessere Weg."
|
||||
)
|
||||
for zeichen in VERBOTENE_ZEICHEN:
|
||||
if zeichen in sauber:
|
||||
raise ValueError(
|
||||
"Der Befehl enthält einen Zeilenumbruch. Hier läuft genau eine "
|
||||
"Befehlszeile – alles danach würde stillschweigend mitlaufen."
|
||||
)
|
||||
return sauber
|
||||
|
||||
|
||||
def run_command(
|
||||
database: Any,
|
||||
*,
|
||||
job_id: str,
|
||||
device: dict[str, Any],
|
||||
username: str,
|
||||
password: str,
|
||||
befehl: str,
|
||||
timeout: int,
|
||||
) -> None:
|
||||
"""Fuehrt **eine** Befehlszeile auf einem Geraet aus. Eigener Thread.
|
||||
|
||||
Bewusst ohne Deutung des Ergebnisses: was der Befehl tut, weiss der
|
||||
Mensch, der ihn geschrieben hat. Festgehalten wird, was zurueckkam und mit
|
||||
welchem Rueckgabewert -- mehr kann ein Werkzeug hier nicht beitragen, und
|
||||
weniger waere zu wenig.
|
||||
"""
|
||||
name = str(device.get("name") or device.get("ip"))
|
||||
|
||||
def emit(line: str) -> None:
|
||||
stamp = datetime.now().strftime("%H:%M:%S")
|
||||
update_job(database, job_id, append_output=f"{stamp} {line}\n")
|
||||
|
||||
update_job(
|
||||
database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut"
|
||||
)
|
||||
try:
|
||||
with database.session() as conn:
|
||||
transport = open_transport(
|
||||
conn,
|
||||
host=str(device["ip"]),
|
||||
port=int(device.get("ssh_port") or 22),
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
except SshError as exc:
|
||||
emit(f"FEHLER: {exc}")
|
||||
update_job(database, job_id, state="failed", message=str(exc), finished=True)
|
||||
return
|
||||
|
||||
status = 1
|
||||
try:
|
||||
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
|
||||
# Nur wer {sudo} benutzt, bekommt es auch: ein Befehl ohne diese Marke
|
||||
# laeuft als der angemeldete Benutzer, und das ist die richtige
|
||||
# Vorgabe -- Rechte holt man sich hier ausdruecklich.
|
||||
ausgefuehrt = befehl.replace("{sudo}", sudo) if "{sudo}" in befehl else befehl
|
||||
emit(f"$ {ausgefuehrt}")
|
||||
update_job(database, job_id, progress=40, message="Befehl läuft")
|
||||
status = _run_remote_command(
|
||||
transport,
|
||||
ausgefuehrt,
|
||||
timeout=timeout,
|
||||
on_line=emit,
|
||||
stdin_text=sudo_stdin if "{sudo}" in befehl else "",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - der Auftrag soll das Ergebnis tragen
|
||||
emit(f"FEHLER: {exc}")
|
||||
finally:
|
||||
try:
|
||||
transport.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
emit(COMPLETION_MARKER)
|
||||
update_job(
|
||||
database,
|
||||
job_id,
|
||||
state="succeeded" if status == 0 else "failed",
|
||||
progress=100,
|
||||
message=(
|
||||
"Befehl ausgeführt."
|
||||
if status == 0
|
||||
else f"Der Befehl endete mit Rückgabewert {status}."
|
||||
),
|
||||
finished=True,
|
||||
)
|
||||
with database.session() as conn, database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="maintenance.command_finished",
|
||||
target=name,
|
||||
detail=f"Rückgabewert {status}",
|
||||
severity="notice" if status == 0 else "warning",
|
||||
)
|
||||
|
||||
|
||||
def _reboot_windows(
|
||||
database: Any,
|
||||
*,
|
||||
@@ -457,6 +718,7 @@ def _reboot_windows(
|
||||
actor: str,
|
||||
emit: Callable[[str], None],
|
||||
elapsed: Callable[[], int],
|
||||
live: Any = None,
|
||||
) -> bool:
|
||||
"""Neustart eines Windows-Geraets ueber RPC. ``True`` = weiter warten.
|
||||
|
||||
@@ -487,6 +749,7 @@ def _reboot_windows(
|
||||
duration_ms=elapsed(),
|
||||
detail=detail,
|
||||
method=METHOD_RPC,
|
||||
live=live,
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -501,6 +764,7 @@ def run_reboot(
|
||||
wait_seconds: int,
|
||||
actor: str = "system",
|
||||
method: str = METHOD_SSH,
|
||||
live: Any = None,
|
||||
) -> None:
|
||||
"""Loest einen Neustart aus und wartet auf die Rueckkehr.
|
||||
|
||||
@@ -553,6 +817,7 @@ def run_reboot(
|
||||
actor=actor,
|
||||
emit=emit,
|
||||
elapsed=elapsed,
|
||||
live=live,
|
||||
):
|
||||
return
|
||||
else:
|
||||
@@ -576,6 +841,7 @@ def run_reboot(
|
||||
duration_ms=elapsed(),
|
||||
detail=str(exc),
|
||||
method=method,
|
||||
live=live,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -618,6 +884,7 @@ def run_reboot(
|
||||
duration_ms=elapsed(),
|
||||
detail=problem,
|
||||
method=method,
|
||||
live=live,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -658,6 +925,7 @@ def run_reboot(
|
||||
duration_ms=elapsed(),
|
||||
detail="Neustart über SSH, Gerät ist zurückgekehrt.",
|
||||
method=method,
|
||||
live=live,
|
||||
)
|
||||
return
|
||||
time.sleep(REBOOT_POLL_SECONDS)
|
||||
@@ -682,7 +950,8 @@ def run_reboot(
|
||||
f"{wait_seconds}s nicht wieder geantwortet."
|
||||
),
|
||||
method=method,
|
||||
)
|
||||
live=live,
|
||||
)
|
||||
|
||||
|
||||
def _log_restart(
|
||||
@@ -694,6 +963,7 @@ def _log_restart(
|
||||
duration_ms: int,
|
||||
detail: str,
|
||||
method: str = METHOD_SSH,
|
||||
live: Any = None,
|
||||
) -> None:
|
||||
"""Traegt den Neustart in die geraeteweite Neustart-Historie ein.
|
||||
|
||||
@@ -716,6 +986,7 @@ def _log_restart(
|
||||
duration_ms=duration_ms,
|
||||
detail=detail,
|
||||
method=method,
|
||||
live=live,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
|
||||
logger.exception("Neustart konnte nicht protokolliert werden (Gerät %s)", device_id)
|
||||
|
||||
@@ -156,6 +156,16 @@ class LiveLog:
|
||||
def info(self, scope: str, message: str) -> None:
|
||||
self.write("INFO", scope, message)
|
||||
|
||||
def notice(self, scope: str, message: str) -> None:
|
||||
"""Etwas ist passiert, und es ist in Ordnung -- aber es gehoert gesehen.
|
||||
|
||||
Zwischen "laeuft" und "stimmt etwas nicht" fehlte eine Stufe. Ein
|
||||
Geraet, das neu gestartet wird, ist kein Fehler; es einfach als INFO
|
||||
zwischen zweihundert Erreichbarkeitsmeldungen zu stellen heisst aber,
|
||||
dass es niemand findet.
|
||||
"""
|
||||
self.write("HINWEIS", scope, message)
|
||||
|
||||
def warning(self, scope: str, message: str) -> None:
|
||||
self.write("WARNUNG", scope, message)
|
||||
|
||||
@@ -278,6 +288,39 @@ METHOD_LABELS = {
|
||||
METHOD_RPC: "Neustart über RPC",
|
||||
}
|
||||
|
||||
#: Wer den Neustart ausgeloest hat -- in Worten statt in Schluesseln.
|
||||
#:
|
||||
#: Die Schluessel stehen so in der Datenbank und bleiben dort; eine Spalte
|
||||
#: umzuschreiben, um eine Anzeige zu verbessern, waere der falsche Weg. Die
|
||||
#: Uebersetzung gehoert an genau eine Stelle, sonst heisst derselbe Vorgang im
|
||||
#: Protokoll "auto" und auf der Seite "automatisch".
|
||||
TRIGGER_LABELS = {
|
||||
"auto": "automatisch",
|
||||
"manuell": "manuell",
|
||||
"maintenance": "aus der Wartung",
|
||||
}
|
||||
|
||||
|
||||
def trigger_label(trigger: str) -> str:
|
||||
"""Der Ausloeser als Wort -- unbekannte Schluessel bleiben, wie sie sind."""
|
||||
return TRIGGER_LABELS.get(str(trigger or ""), str(trigger or "unbekannt"))
|
||||
|
||||
|
||||
def _geraet_kurz(conn: sqlite3.Connection, device_id: int) -> str:
|
||||
"""``Name (IP)`` fuer das laufende Protokoll.
|
||||
|
||||
Die Neustart-Funktionen bekommen nur eine Kennung; im Protokoll steht aber
|
||||
eine Zeile, die ein Mensch liest -- und "Geraet 17 wird neu gestartet"
|
||||
hilft niemandem.
|
||||
"""
|
||||
row = conn.execute("SELECT name, ip FROM devices WHERE id=?", (int(device_id),)).fetchone()
|
||||
if row is None:
|
||||
return f"Gerät {device_id}"
|
||||
name = str(row["name"] or "").strip() or f"Gerät {device_id}"
|
||||
ip = str(row["ip"] or "").strip()
|
||||
return f"{name} ({ip})" if ip else name
|
||||
|
||||
|
||||
#: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
|
||||
#:
|
||||
#: Ohne ihn blieb die Kachel auf "online" stehen: der Zustand wechselt erst
|
||||
@@ -303,6 +346,7 @@ def record_restart(
|
||||
duration_ms: int,
|
||||
detail: str,
|
||||
method: str = METHOD_POE,
|
||||
live: Any = None,
|
||||
) -> None:
|
||||
"""Schreibt einen Neustart ins Protokoll -- unabhaengig davon, wie er lief.
|
||||
|
||||
@@ -311,6 +355,12 @@ def record_restart(
|
||||
nur in ``method``. Vorher gab es nur die PoE-Haelfte, und ein per Wartung
|
||||
neu gestartetes Geraet tauchte in der Neustart-Historie ueberhaupt nicht
|
||||
auf.
|
||||
|
||||
Mit ``live`` steht das Ergebnis auch im laufenden Protokoll. Diese
|
||||
Funktion ist der Trichter, durch den **jeder** Neustart laeuft -- PoE
|
||||
automatisch wie von Hand, SSH und RPC aus der Wartung. Die Zeile hier zu
|
||||
schreiben statt bei jeder Quelle einzeln ist der Unterschied zwischen
|
||||
"alle Wege sind abgedeckt" und "alle Wege, an die jemand gedacht hat".
|
||||
"""
|
||||
conn.execute(
|
||||
"INSERT INTO restart_events "
|
||||
@@ -318,6 +368,17 @@ def record_restart(
|
||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method),
|
||||
)
|
||||
if live is not None:
|
||||
wer = _geraet_kurz(conn, device_id)
|
||||
weg = METHOD_LABELS.get(method, method)
|
||||
if result == "ok":
|
||||
live.info("neustart", f"{wer}: Neustart über {weg} erfolgreich")
|
||||
elif result == "skipped":
|
||||
# Warnstufe: ein Geraet, das haette neu gestartet werden sollen und
|
||||
# es nicht wurde, ist kein Hinweis am Rande.
|
||||
live.warning("neustart", f"{wer}: Neustart nicht ausgeführt – {detail}")
|
||||
else:
|
||||
live.error("neustart", f"{wer}: Neustart fehlgeschlagen – {detail}")
|
||||
if result == "ok":
|
||||
# Hier faellt nur noch der Zaehler und der Zeitstempel an. Den Zustand
|
||||
# setzt ``mark_restarting`` schon beim Ausloesen -- dieses Ergebnis
|
||||
@@ -330,7 +391,14 @@ def record_restart(
|
||||
)
|
||||
|
||||
|
||||
def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None:
|
||||
def mark_restarting(
|
||||
conn: sqlite3.Connection,
|
||||
device_id: int,
|
||||
*,
|
||||
live: Any = None,
|
||||
trigger: str = "",
|
||||
method: str = METHOD_POE,
|
||||
) -> None:
|
||||
"""Setzt den Zustand auf "startet neu" -- beim **Ausloesen**, nicht am Ende.
|
||||
|
||||
Ein Neustart ueber SSH oder RPC laeuft als Auftrag: Kommando schicken, dann
|
||||
@@ -342,6 +410,12 @@ def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None:
|
||||
Absicht. Und ohne ``last_restart_at``: das Kulanzfenster laeuft ab dem
|
||||
Zeitpunkt, an dem der Neustart tatsaechlich ausgeloest wurde
|
||||
(``record_restart``).
|
||||
|
||||
Mit ``live`` geht der Beginn ins laufende Protokoll -- als Hinweis, nicht
|
||||
als Warnung: ein Neustart ist kein Stoerfall, aber er erklaert die Luecke,
|
||||
die gleich in der Erreichbarkeit steht. Dass die Zeile hier entsteht und
|
||||
nicht bei den Aufrufern, ist Absicht: dies ist der Punkt, an dem ein
|
||||
Neustart **ausgeloest** wird, gleich von welcher Quelle.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -355,6 +429,12 @@ def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None:
|
||||
""",
|
||||
(device_id, STATE_RESTARTING, _now(), _now()),
|
||||
)
|
||||
if live is not None:
|
||||
weg = METHOD_LABELS.get(method, method)
|
||||
woher = f" ({trigger_label(trigger)})" if trigger else ""
|
||||
live.notice(
|
||||
"neustart", f"{_geraet_kurz(conn, device_id)} wird neu gestartet – {weg}{woher}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -402,7 +482,9 @@ def restart_device_poe(
|
||||
# wie fuer den automatischen Neustart, denn beide laufen hier durch.
|
||||
if port and switch_ip:
|
||||
with write():
|
||||
mark_restarting(conn, int(device["id"]))
|
||||
mark_restarting(
|
||||
conn, int(device["id"]), live=live, trigger=trigger, method=METHOD_POE
|
||||
)
|
||||
|
||||
if not port or not switch_ip:
|
||||
message = (
|
||||
@@ -418,6 +500,7 @@ def restart_device_poe(
|
||||
actor=actor,
|
||||
duration_ms=0,
|
||||
detail=message,
|
||||
live=live,
|
||||
)
|
||||
return {"ok": False, "message": message, "transcript": ""}
|
||||
|
||||
@@ -462,9 +545,8 @@ def restart_device_poe(
|
||||
actor=actor,
|
||||
duration_ms=duration,
|
||||
detail=str(exc),
|
||||
live=live,
|
||||
)
|
||||
if live:
|
||||
live.error("poe", f"{name}: {exc}")
|
||||
return {"ok": False, "message": str(exc), "transcript": ""}
|
||||
|
||||
duration = int((time.monotonic() - started) * 1000)
|
||||
@@ -478,12 +560,8 @@ def restart_device_poe(
|
||||
actor=actor,
|
||||
duration_ms=duration,
|
||||
detail=(outcome.error or "; ".join(outcome.steps))[:8000],
|
||||
live=live,
|
||||
)
|
||||
if live:
|
||||
if outcome.ok:
|
||||
live.info("poe", f"{name}: PoE-Neustart auf {device.get('switch_hostname')} {port} erfolgreich")
|
||||
else:
|
||||
live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}")
|
||||
return {
|
||||
"ok": outcome.ok,
|
||||
"message": outcome.error or "PoE-Neustart durchgeführt.",
|
||||
@@ -507,6 +585,48 @@ class SweepReport:
|
||||
duration_ms: int = 0
|
||||
|
||||
|
||||
def merke_vnc(conn: sqlite3.Connection, device_id: int, offen: bool, ziel: str = "") -> None:
|
||||
"""Haelt fest, ob dort ein VNC-Server antwortet -- und unter welchem Ziel.
|
||||
|
||||
Das Ziel gehoert dazu, weil die Statusuebersicht die Verbindung unmittelbar
|
||||
verlinkt. Es aus dem Namen neu herzuleiten hiesse, je Kachel und
|
||||
Seitenaufbau eine Namensaufloesung zu versuchen; hier steht es einmal
|
||||
geschrieben, von dem, der ohnehin gefragt hat.
|
||||
|
||||
Die Anmeldeverfahren bleiben unberuehrt: sie kommen aus einer anderen
|
||||
Quelle (:func:`merke_verfahren`) und haetten hier nur den Wert "nicht
|
||||
gefragt" -- der wuerde eine gute Messung ueberschreiben.
|
||||
"""
|
||||
conn.execute(
|
||||
"UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=? WHERE device_id=?",
|
||||
(1 if offen else 0, _now(), ziel, device_id),
|
||||
)
|
||||
|
||||
|
||||
def merke_verfahren(
|
||||
conn: sqlite3.Connection, device_id: int, verfahren: tuple[int, ...], absage: str = ""
|
||||
) -> None:
|
||||
"""Haelt fest, wie sich der VNC-Server authentifizieren will.
|
||||
|
||||
Mit Zeitstempel, denn diese Messung kostet eine angefangene
|
||||
Protokollverhandlung -- und die soll sich nicht bei jedem Seitenaufbau
|
||||
wiederholen.
|
||||
"""
|
||||
conn.execute(
|
||||
"UPDATE device_status SET vnc_types=?, vnc_reason=?, vnc_types_at=? WHERE device_id=?",
|
||||
(",".join(str(v) for v in verfahren), absage, _now(), device_id),
|
||||
)
|
||||
|
||||
|
||||
def merke_verfahren_das_traegt(
|
||||
conn: sqlite3.Connection, device_id: int, verfahren: int
|
||||
) -> None:
|
||||
"""Haelt fest, womit die letzte Sitzung wirklich zustande kam."""
|
||||
conn.execute(
|
||||
"UPDATE device_status SET vnc_method=? WHERE device_id=?", (int(verfahren), device_id)
|
||||
)
|
||||
|
||||
|
||||
def run_sweep(
|
||||
*,
|
||||
database: Any,
|
||||
@@ -514,13 +634,25 @@ def run_sweep(
|
||||
keystore: Any,
|
||||
license_allows_auto_restart: bool,
|
||||
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
|
||||
vnc_port: int = 0,
|
||||
) -> SweepReport:
|
||||
"""Ein vollstaendiger Ueberwachungsdurchlauf.
|
||||
|
||||
Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge
|
||||
danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und
|
||||
keine halb geschriebenen Durchlaeufe.
|
||||
|
||||
``vnc_port`` (0 = aus) haengt eine zweite, sehr kurze Frage an jedes
|
||||
erreichbare Geraet: nimmt dort ein VNC-Server Verbindungen an? Sie gehoert
|
||||
hierher und nicht in den Seitenaufbau -- einmal je Intervall statt einmal
|
||||
je Aufruf, und nur fuer Geraete, die ohnehin gerade antworten.
|
||||
|
||||
**Nur die Frage nach dem Port**, kein Wort Protokoll: ein RealVNC Server
|
||||
zaehlt abgebrochene Verhandlungen und sperrt die Adresse eine Weile aus.
|
||||
Welche Anmeldeverfahren er anbietet, misst die Seite, die es braucht.
|
||||
"""
|
||||
from . import vnc as vnc_dienst # spaet: vnc benutzt tcp_reachable von hier
|
||||
|
||||
report = SweepReport()
|
||||
started = time.monotonic()
|
||||
|
||||
@@ -531,15 +663,37 @@ def run_sweep(
|
||||
report.duration_ms = int((time.monotonic() - started) * 1000)
|
||||
return report
|
||||
|
||||
def pruefen(
|
||||
device: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], PingResult, vnc_dienst.Zugang | None]:
|
||||
ergebnis = ping(str(device["ip"]))
|
||||
if not vnc_port or not ergebnis.ok:
|
||||
# Kein Ergebnis heisst "nicht gefragt" -- und laesst den zuletzt
|
||||
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
|
||||
# seinen Knopf nicht verlieren.
|
||||
return device, ergebnis, None
|
||||
try:
|
||||
return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port)
|
||||
except Exception: # noqa: BLE001
|
||||
# Die Ueberwachung ist die Kernaufgabe, der Bildschirmzugriff eine
|
||||
# Zugabe. Was hier schiefgeht -- ein Name, den der Resolver nicht
|
||||
# verdaut, ein Netz, das seltsam antwortet --, darf niemals den
|
||||
# ganzen Durchlauf kosten: sonst stuenden alle Geraete auf
|
||||
# "unbekannt", und niemand brächte das mit VNC in Verbindung.
|
||||
logger.warning("VNC-Pruefung fuer %s uebersprungen", device.get("name"), exc_info=True)
|
||||
return device, ergebnis, None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
|
||||
results = list(pool.map(lambda device: (device, ping(str(device["ip"]))), devices))
|
||||
results = list(pool.map(pruefen, devices))
|
||||
|
||||
restart_candidates: list[dict[str, Any]] = []
|
||||
with database.session() as conn:
|
||||
with database.transaction(conn):
|
||||
for device, result in results:
|
||||
for device, result, vnc_offen in results:
|
||||
report.checked += 1
|
||||
previous, current = record_status(conn, int(device["id"]), result)
|
||||
if vnc_offen is not None:
|
||||
merke_vnc(conn, int(device["id"]), vnc_offen.erreichbar, vnc_offen.ziel)
|
||||
if result.ok:
|
||||
report.online += 1
|
||||
else:
|
||||
@@ -565,10 +719,22 @@ def run_sweep(
|
||||
name = str(device["name"])
|
||||
if not license_allows_auto_restart:
|
||||
report.skipped_unlicensed += 1
|
||||
live.warning(
|
||||
"poe",
|
||||
f"{name}: automatischer PoE-Neustart übersprungen – keine gültige Lizenz.",
|
||||
)
|
||||
# Als Zeile in der Neustart-Historie, nicht nur als Protokollsatz:
|
||||
# wer auf der Seite "Neustarts" nachsieht, warum ein Geraet nicht
|
||||
# zurueckkam, soll dort auch lesen, dass TESM es versucht haette.
|
||||
# Ein leerer Verlauf sieht sonst aus wie "es war nichts noetig".
|
||||
with database.session() as conn, database.transaction(conn):
|
||||
record_restart(
|
||||
conn,
|
||||
device_id=int(device["id"]),
|
||||
action="cycle",
|
||||
result="skipped",
|
||||
trigger="auto",
|
||||
actor="system",
|
||||
duration_ms=0,
|
||||
detail="keine gültige Lizenz für den automatischen PoE-Neustart",
|
||||
live=live,
|
||||
)
|
||||
continue
|
||||
report.restarts_triggered += 1
|
||||
# Bewusst **keine** umschliessende Transaktion: der Neustart oeffnet eine
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Bildschirmzugriff per VNC: Ziel bestimmen, Erreichbarkeit pruefen, Datei bauen.
|
||||
|
||||
Auf den HAPs (Raspberry Pis) laeuft ein VNC-Server. TESM kennt von jedem
|
||||
Client Namen und Adresse -- daraus laesst sich die Verbindung erzeugen, statt
|
||||
sie in jedem Viewer von Hand zu pflegen.
|
||||
|
||||
Zwei Wege, beide aus denselben zwei Angaben:
|
||||
|
||||
* eine **Verbindungsdatei** (``.vnc``), wie der RealVNC Viewer sie schreibt.
|
||||
Sie enthaelt genau drei Zeilen -- ``ConnMethod``, ``FriendlyName``, ``Host``
|
||||
-- und **nichts weiter**. Jede zusaetzliche Angabe ist eine Gelegenheit,
|
||||
etwas zu verlangen, das hier niemand verlangen soll: keine
|
||||
Verschluesselungsvorgabe, keine Identitaetspruefung, kein Benutzername, kein
|
||||
Passwortfeld. Was am Ende gefragt wird, entscheidet ohnehin der VNC-Server
|
||||
auf dem Geraet; die Datei fragt von sich aus nichts.
|
||||
* eine **Adresse** (``vnc://host``) fuer den Fall, dass der Viewer als
|
||||
Handler registriert ist -- ein Klick statt Herunterladen und Oeffnen.
|
||||
|
||||
Der Zugang erscheint nur dort, wo wirklich ein VNC-Server antwortet
|
||||
(:func:`erreichbar`). Das ist der Unterschied zu einem Schalter am Geraet:
|
||||
niemand muss ihn pflegen, und er zeigt nie ins Leere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .monitor import tcp_reachable
|
||||
|
||||
#: Vorgabeport von VNC (Anzeige :0). Ueberschreibbar in den Einstellungen.
|
||||
STANDARD_PORT = 5900
|
||||
|
||||
#: So kurz, dass die Detailseite es nicht merkt, und so lang, dass ein Geraet
|
||||
#: im selben Netz antworten kann. Gefragt wird ohnehin nur, wenn der Client
|
||||
#: gerade als erreichbar gilt.
|
||||
ZEITLIMIT = 0.6
|
||||
|
||||
#: Ein Name, den ein Rechner aufloesen kann: Buchstaben, Ziffern, Bindestrich,
|
||||
#: Punkt als Trenner. "HAP 02" oder "Kamera Eingang" ist keiner -- dann bleibt
|
||||
#: die IP-Adresse, die im Datensatz ohnehin steht.
|
||||
HOSTNAME_RE = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$")
|
||||
|
||||
#: Zeilenende der Datei: CRLF. Der Viewer liest beides, aber die Datei landet
|
||||
#: auf einem Windows-Arbeitsplatz -- und dort soll sie in jedem Editor
|
||||
#: vernuenftig aussehen, wenn jemand nachsieht, was drinsteht.
|
||||
ZEILE = "\r\n"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Zugang:
|
||||
"""Was die Detailseite ueber den Bildschirmzugriff eines Clients weiss."""
|
||||
|
||||
ziel: str
|
||||
port: int
|
||||
erreichbar: bool
|
||||
#: Die Sicherheitsverfahren, die der Server angeboten hat (leer: nicht
|
||||
#: gemessen). Daraus entsteht der Satz, warum eine Sitzung im Browser
|
||||
#: gehen wird oder nicht.
|
||||
verfahren: tuple[int, ...] = ()
|
||||
#: Der Text, mit dem der Server die Verbindung abgelehnt hat -- er nennt
|
||||
#: dann kein Verfahren, sondern eine 0 und einen Grund.
|
||||
absage: str = ""
|
||||
#: Das Verfahren, mit dem zuletzt wirklich eine Sitzung zustande kam
|
||||
#: (0: noch keine). Damit faengt der naechste Versuch an.
|
||||
bewaehrt: int = 0
|
||||
|
||||
@property
|
||||
def browsertauglich(self) -> bool:
|
||||
"""Ist mindestens ein angebotenes Verfahren eines, das noVNC kann?"""
|
||||
return bool(set(self.verfahren) & BROWSER_VERFAHREN)
|
||||
|
||||
@property
|
||||
def hinderungsgrund(self) -> str:
|
||||
"""Warum eine Sitzung im Browser nicht zustande kaeme -- im Klartext."""
|
||||
if not self.erreichbar:
|
||||
return f"Auf {self.ziel}:{self.port} antwortet kein VNC-Server."
|
||||
if self.absage:
|
||||
return (
|
||||
f"Der VNC-Server auf {self.ziel} weist die Verbindung ab: "
|
||||
f"„{self.absage}“. Das ist seine Auskunft, nicht unsere – "
|
||||
"typisch sind eine Sperre nach zu vielen Fehlversuchen "
|
||||
"(RealVNC: BlacklistTimeout), eine Adressfilterung oder eine "
|
||||
"belegte Sitzung."
|
||||
)
|
||||
if not self.verfahren:
|
||||
return ""
|
||||
if self.browsertauglich:
|
||||
return ""
|
||||
angeboten = ", ".join(verfahren_name(v) for v in self.verfahren)
|
||||
return (
|
||||
f"Der VNC-Server bietet nur {angeboten} an. Der Browser-Client kann "
|
||||
"davon keines: herstellereigene Verfahren sind nicht offengelegt, "
|
||||
"und RA2 (5) verschlüsselt die ganze Strecke mit einem Verfahren, "
|
||||
"das er nicht mitbringt. Auf dem Gerät hilft eine Zeile: "
|
||||
"Encryption=AlwaysOff (dann bietet der Server RA2ne an) oder "
|
||||
"Authentication=VncAuth samt vncpasswd (dann das klassische "
|
||||
"VNC-Passwort)."
|
||||
)
|
||||
|
||||
@property
|
||||
def adresse(self) -> str:
|
||||
"""``vnc://host`` -- den Standardport schreibt niemand dazu."""
|
||||
if self.port == STANDARD_PORT:
|
||||
return f"vnc://{self.ziel}"
|
||||
return f"vnc://{self.ziel}:{self.port}"
|
||||
|
||||
|
||||
#: Aufloesungen, die wir schon einmal versucht haben: ``name -> (Zeit, Ergebnis)``.
|
||||
#:
|
||||
#: Nicht wegen der Geschwindigkeit -- ein arbeitender Resolver antwortet in
|
||||
#: Bruchteilen einer Millisekunde --, sondern wegen des Gegenteils: faellt der
|
||||
#: Nameserver aus, laeuft jede Abfrage in sein eigenes Zeitlimit. Ohne diesen
|
||||
#: Puffer haette das der Seitenaufbau ausgebadet, und zwar bei jedem Aufruf.
|
||||
_AUFLOESUNG: dict[str, tuple[float, bool]] = {}
|
||||
|
||||
#: Fuenf Minuten. Lang genug, damit ein kaputter Resolver nicht jede Seite
|
||||
#: aufhaelt; kurz genug, dass ein neuer DNS-Eintrag nicht bis zum Neustart
|
||||
#: wartet.
|
||||
AUFLOESUNG_GILT = 300.0
|
||||
|
||||
|
||||
def aufloesbar(name: str) -> bool:
|
||||
"""Kennt der Resolver diesen Namen?"""
|
||||
jetzt = time.monotonic()
|
||||
gemerkt = _AUFLOESUNG.get(name)
|
||||
if gemerkt is not None and jetzt - gemerkt[0] < AUFLOESUNG_GILT:
|
||||
return gemerkt[1]
|
||||
try:
|
||||
socket.getaddrinfo(name, None)
|
||||
ergebnis = True
|
||||
except Exception: # noqa: BLE001
|
||||
# Nicht nur OSError: ein Name mit Sonderzeichen wirft UnicodeError,
|
||||
# und der ist kein OSError. Aufloesbar ist er so oder so nicht.
|
||||
ergebnis = False
|
||||
_AUFLOESUNG[name] = (jetzt, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def ziel(device: Mapping[str, Any]) -> str:
|
||||
"""Der Name des Clients, sonst seine IP-Adresse.
|
||||
|
||||
Der Name ist die bessere Wahl, wo er gilt: er ueberlebt einen
|
||||
Adresswechsel, und genau so stehen die gewachsenen Verbindungen im Viewer
|
||||
(``Host=hap02``). Zwei Dinge muessen dafuer stimmen -- er muss als Hostname
|
||||
**taugen** ("Kamera Eingang" tut es nicht) und er muss sich **aufloesen**
|
||||
lassen. Sonst zeigte die Datei auf einen Namen, den der Arbeitsplatz nicht
|
||||
kennt, und niemand saehe, woran es liegt; dann ist die Adresse richtig, die
|
||||
ohnehin im Datensatz steht.
|
||||
|
||||
Loest der Name auf eine **andere** Adresse auf als die gespeicherte, gewinnt
|
||||
trotzdem der Name: dann ist der Eintrag im Bestand veraltet, nicht das DNS.
|
||||
"""
|
||||
name = str(device.get("name") or "").strip()
|
||||
if name and HOSTNAME_RE.match(name) and aufloesbar(name):
|
||||
return name.lower()
|
||||
return str(device.get("ip") or "").strip()
|
||||
|
||||
|
||||
def erreichbar(host: str, port: int = STANDARD_PORT) -> bool:
|
||||
"""Nimmt auf diesem Host ein VNC-Server Verbindungen an?"""
|
||||
if not host:
|
||||
return False
|
||||
return tcp_reachable(host, port, timeout=ZEITLIMIT)
|
||||
|
||||
|
||||
#: Was der Browser-Client (noVNC 1.6) aushandeln kann. Aus dessen
|
||||
#: ``_isSupportedSecurityType`` uebernommen -- steht hier, weil die Oberflaeche
|
||||
#: **vor** dem Verbinden sagen koennen soll, ob es klappen wird.
|
||||
BROWSER_VERFAHREN: frozenset[int] = frozenset({1, 2, 6, 16, 19, 22, 30, 113})
|
||||
|
||||
#: Die Namen dazu. Was nicht drinsteht, ist herstellereigen -- die Nummern
|
||||
#: 7 bis 15 gehoeren RealVNC, 3 und 4 ebenfalls.
|
||||
VERFAHREN_NAMEN: dict[int, str] = {
|
||||
1: "ohne Authentifizierung",
|
||||
2: "VNC-Passwort",
|
||||
5: "RA2 (RSA-AES, verschlüsselt)",
|
||||
6: "RA2ne (RSA-AES, unverschlüsselt)",
|
||||
16: "Tight",
|
||||
18: "TLS",
|
||||
19: "VeNCrypt",
|
||||
22: "xvp",
|
||||
30: "Apple ARD",
|
||||
113: "MS-Logon II",
|
||||
}
|
||||
|
||||
|
||||
def verfahren_name(nummer: int) -> str:
|
||||
"""Lesbarer Name eines Sicherheitsverfahrens."""
|
||||
if nummer in VERFAHREN_NAMEN:
|
||||
return f"{nummer} ({VERFAHREN_NAMEN[nummer]})"
|
||||
if 3 <= nummer <= 15:
|
||||
return f"{nummer} (RealVNC-eigen)"
|
||||
return str(nummer)
|
||||
|
||||
|
||||
def _absage(sock: Any) -> str:
|
||||
"""Der Grund, den ein Server einer abgelehnten Verbindung mitgibt.
|
||||
|
||||
Auf eine 0 -- ob als Anzahl (ab 3.7) oder als bestimmtes Verfahren (3.3) --
|
||||
folgt eine Laenge und ein Text. Genau dieser Satz ist die Auskunft, die
|
||||
sonst niemand bekommt: "zu viele Fehlversuche", "Verbindung von dieser
|
||||
Adresse nicht erlaubt", "kein gemeinsames Verfahren". Ohne ihn steht da
|
||||
nur, dass es nicht geht.
|
||||
"""
|
||||
try:
|
||||
roh = sock.recv(4)
|
||||
if len(roh) < 4:
|
||||
return ""
|
||||
laenge = int.from_bytes(roh, "big")
|
||||
if not 0 < laenge <= 1024:
|
||||
return ""
|
||||
text = b""
|
||||
while len(text) < laenge:
|
||||
stueck = sock.recv(laenge - len(text))
|
||||
if not stueck:
|
||||
break
|
||||
text += stueck
|
||||
return text.decode("utf-8", "replace").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def sicherheitsverfahren(
|
||||
host: str, port: int = STANDARD_PORT
|
||||
) -> tuple[str, tuple[int, ...], str]:
|
||||
"""Fragt den Server, **wie** er sich authentifizieren will.
|
||||
|
||||
Der Anfang einer VNC-Verbindung ist fuer jeden Server derselbe: er nennt
|
||||
seine Protokollfassung, der Client die seine, dann kommt die Liste der
|
||||
Sicherheitsverfahren. Weiter gehen wir nicht -- die Verbindung wird sofort
|
||||
geschlossen, es wird nichts angemeldet und nichts geraten.
|
||||
|
||||
Das ist die Angabe, an der eine Bildschirmsitzung im Browser scheitert oder
|
||||
gelingt, und sie hier zu messen ist der Unterschied zwischen einer
|
||||
schwarzen Flaeche und dem Satz "dieser Server bietet nur Verfahren an, die
|
||||
der Browser nicht kennt".
|
||||
|
||||
Gibt ``(Fassung, Verfahren, Absage)`` zurueck. Die Absage ist der Text, den
|
||||
ein Server mitschickt, wenn er die Verbindung **ablehnt** -- er meldet dann
|
||||
kein Verfahren, sondern eine 0 und einen Grund. Bei einem Fehler
|
||||
``("", (), "")``.
|
||||
"""
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=ZEITLIMIT) as sock:
|
||||
sock.settimeout(ZEITLIMIT)
|
||||
gruss = sock.recv(12)
|
||||
if not gruss.startswith(b"RFB "):
|
||||
return "", (), ""
|
||||
fassung = gruss[4:11].decode("ascii", "replace")
|
||||
# Wir antworten mit 3.8 -- der Fassung, die auch der Browser
|
||||
# spricht. Ein Server, der mehr kann, faellt darauf zurueck und
|
||||
# nennt trotzdem seine Verfahren.
|
||||
# Antworten wird mit *seiner* Fassung, hoechstens 3.8. Wer einem
|
||||
# 3.3-Server mit 3.8 antwortet, bekommt keine Liste, sondern eine
|
||||
# 32-Bit-Zahl -- und liest sie als "keine Verfahren".
|
||||
eigene = "003.003" if fassung == "003.003" else "003.008"
|
||||
sock.sendall(b"RFB " + eigene.encode("ascii") + bytes([10]))
|
||||
|
||||
if eigene == "003.003":
|
||||
# RFB 3.3: der Server bestimmt allein und schickt eine
|
||||
# 32-Bit-Zahl. Das ist kein Sonderfall aus der Vorzeit --
|
||||
# RealVNC faellt darauf zurueck, sobald keine Anmeldung
|
||||
# verlangt wird.
|
||||
rohwert = b""
|
||||
while len(rohwert) < 4:
|
||||
stueck = sock.recv(4 - len(rohwert))
|
||||
if not stueck:
|
||||
break
|
||||
rohwert += stueck
|
||||
if len(rohwert) < 4:
|
||||
return fassung, (), ""
|
||||
bestimmt = int.from_bytes(rohwert, "big")
|
||||
if bestimmt == 0:
|
||||
return fassung, (), _absage(sock)
|
||||
return fassung, (bestimmt,), ""
|
||||
|
||||
anzahl = sock.recv(1)
|
||||
if not anzahl:
|
||||
return fassung, (), ""
|
||||
wie_viele = anzahl[0]
|
||||
if wie_viele == 0:
|
||||
# Null heisst: der Server lehnt ab und schickt den Grund.
|
||||
return fassung, (), _absage(sock)
|
||||
liste = b""
|
||||
while len(liste) < wie_viele:
|
||||
stueck = sock.recv(wie_viele - len(liste))
|
||||
if not stueck:
|
||||
break
|
||||
liste += stueck
|
||||
return fassung, tuple(liste), ""
|
||||
except OSError:
|
||||
return "", (), ""
|
||||
|
||||
|
||||
def zugang_kurz(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Zugang:
|
||||
"""Nur die Frage, ob dort jemand annimmt -- ohne ein Wort Protokoll.
|
||||
|
||||
Das ist die Fassung fuer den Ueberwachungsdurchlauf. Er laeuft alle paar
|
||||
Minuten ueber jedes Geraet, und eine angefangene und sofort wieder
|
||||
abgebrochene Protokollverhandlung ist nicht nur unhoeflich: ein RealVNC
|
||||
Server zaehlt abgebrochene Versuche und sperrt die Adresse eine Weile aus
|
||||
(``BlacklistTimeout``). Eine Ueberwachung, die ihr eigenes Ziel aussperrt,
|
||||
waere ein schoener Eigentor -- also fragt sie nur, ob der Port annimmt.
|
||||
|
||||
Welche Anmeldeverfahren der Server kann, misst erst die Seite, die es
|
||||
wirklich braucht -- und auch die nicht oefter als noetig.
|
||||
"""
|
||||
host = ziel(device)
|
||||
return Zugang(ziel=host, port=port, erreichbar=erreichbar(host, port))
|
||||
|
||||
|
||||
def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: bool = True) -> Zugang:
|
||||
"""Ziel, Erreichbarkeit und Verfahren in einem Stueck -- so nimmt es die Vorlage."""
|
||||
host = ziel(device)
|
||||
if not pruefen:
|
||||
return Zugang(ziel=host, port=port, erreichbar=False)
|
||||
fassung, verfahren, absage = sicherheitsverfahren(host, port)
|
||||
return Zugang(
|
||||
ziel=host,
|
||||
port=port,
|
||||
erreichbar=bool(fassung),
|
||||
verfahren=verfahren,
|
||||
absage=absage,
|
||||
)
|
||||
|
||||
|
||||
def zugang_gemerkt(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Zugang:
|
||||
"""Der Zugang, wie er in der Datenbank steht -- **ohne** das Geraet zu fragen.
|
||||
|
||||
Frueher hat diese Funktion selbst gemessen, wenn die letzte Messung alt war.
|
||||
Das ist gestrichen, und zwar aus Erfahrung: jede Messung ist eine
|
||||
angefangene und abgebrochene Protokollverhandlung, und ein RealVNC Server
|
||||
zaehlt so etwas. Wer eine Bildschirmseite oeffnet, klopfte damit zweimal an
|
||||
-- einmal zum Messen, einmal fuer die Sitzung --, und das zweite Klopfen
|
||||
fiel dem ersten zum Opfer.
|
||||
|
||||
Jetzt beruehrt das Oeffnen einer Seite das Geraet gar nicht mehr. Die
|
||||
Erreichbarkeit kommt aus dem Ueberwachungsdurchlauf (ein blosser
|
||||
Verbindungsaufbau), die Anmeldeverfahren aus der letzten **ausdruecklichen**
|
||||
Pruefung -- der Knopf dafuer steht auf der Seite und wird gedrueckt, wenn
|
||||
etwas nicht geht.
|
||||
"""
|
||||
gespeichert = str(device.get("vnc_types") or "")
|
||||
return Zugang(
|
||||
ziel=ziel(device),
|
||||
port=port,
|
||||
erreichbar=bool(device.get("vnc_open")),
|
||||
verfahren=tuple(
|
||||
int(teil) for teil in gespeichert.split(",") if teil.strip().isdigit()
|
||||
),
|
||||
absage=str(device.get("vnc_reason") or ""),
|
||||
bewaehrt=int(device.get("vnc_method") or 0),
|
||||
)
|
||||
|
||||
|
||||
def verbindungsdatei(
|
||||
device: Mapping[str, Any], *, port: int = STANDARD_PORT, warnung_aus: bool = False
|
||||
) -> str:
|
||||
"""Der Inhalt der ``.vnc``-Datei -- drei Zeilen, keine vierte.
|
||||
|
||||
``ConnMethod=tcp`` ist die direkte Verbindung (nicht ueber den Cloud-Dienst
|
||||
von RealVNC), ``FriendlyName`` der Name, den der Viewer anzeigt, ``Host``
|
||||
das Ziel. Ein abweichender Port gehoert in dieser Datei an den Host, mit
|
||||
Doppelpunkt -- einen eigenen Schluessel dafuer gibt es nicht.
|
||||
|
||||
``warnung_aus`` haengt eine vierte Zeile an: ``WarnUnencrypted=False``. Sie
|
||||
betrifft **den Viewer**, nicht den Server -- er zeigt sonst bei jeder
|
||||
Verbindung zu einem Geraet ohne Streckenverschluesselung einen roten
|
||||
Hinweis, den man wegklicken muss. Wer ``Encryption=AlwaysOff`` auf dem
|
||||
Geraet gesetzt hat (damit der Browser ueberhaupt verbinden kann), sieht
|
||||
diesen Hinweis fortan bei jedem Start. Er bleibt in der Vorgabe stehen:
|
||||
eine unverschluesselte Strecke soll man einmal bewusst hinnehmen, nicht
|
||||
stillschweigend. Abschaltbar unter Verwaltung -> System.
|
||||
"""
|
||||
host = ziel(device)
|
||||
if port != STANDARD_PORT:
|
||||
host = f"{host}:{port}"
|
||||
zeilen = [
|
||||
"ConnMethod=tcp",
|
||||
f"FriendlyName={str(device.get('name') or host).strip()}",
|
||||
f"Host={host}",
|
||||
]
|
||||
if warnung_aus:
|
||||
zeilen.append("WarnUnencrypted=False")
|
||||
return ZEILE.join(zeilen) + ZEILE
|
||||
|
||||
|
||||
def dateiname(device: Mapping[str, Any]) -> str:
|
||||
"""``HAP02.vnc`` -- aus dem Namen, auf Unbedenkliches beschraenkt.
|
||||
|
||||
Auch der Punkt faellt weg, nicht nur der Schraegstrich: ein Geraetename ist
|
||||
Freitext, und ein Dateiname, in dem ".." vorkommen kann, ist eine Frage,
|
||||
die man sich gar nicht erst stellen muss.
|
||||
"""
|
||||
name = re.sub(r"[^A-Za-z0-9_-]+", "-", str(device.get("name") or "client")).strip("-")
|
||||
return f"{name or 'client'}.vnc"
|
||||
|
||||
|
||||
def port_aus_einstellungen(extension: Any) -> int:
|
||||
"""Der eingestellte Port -- oder 0, wenn der Zugang abgeschaltet ist.
|
||||
|
||||
Nimmt die Erweiterung statt der Verbindung, weil der Ueberwachungsprozess
|
||||
ausserhalb eines Anfragekontextes laeuft und seine eigene Sitzung oeffnet.
|
||||
"""
|
||||
with extension.database.session() as conn:
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
return 0
|
||||
return int(extension.settings.get(conn, "vnc_port") or STANDARD_PORT)
|
||||
@@ -45,6 +45,49 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
||||
section="monitoring",
|
||||
),
|
||||
# -- Bildschirmzugriff (VNC) -------------------------------------------- #
|
||||
SettingSpec(
|
||||
"vnc_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Bildschirmzugriff (VNC) anbieten",
|
||||
hint="Der Überwachungsdurchlauf fragt bei jedem erreichbaren Client kurz, ob "
|
||||
"auf diesem Port ein VNC-Server antwortet; nur dort erscheint der Knopf. "
|
||||
"Sehen darf ihn trotzdem nur, wer das Recht dafür hat.",
|
||||
section="monitoring",
|
||||
),
|
||||
SettingSpec(
|
||||
"vnc_port",
|
||||
5900,
|
||||
"int",
|
||||
"VNC-Port",
|
||||
hint="5900 ist Anzeige :0 – die Vorgabe eines Raspberry Pi.",
|
||||
section="monitoring",
|
||||
minimum=1,
|
||||
maximum=65535,
|
||||
),
|
||||
SettingSpec(
|
||||
"vnc_idle_timeout",
|
||||
300,
|
||||
"int",
|
||||
"Bildschirmsitzung bei Untätigkeit trennen nach (Sekunden)",
|
||||
hint="Gezählt wird, wenn in **keine** Richtung mehr etwas fließt: der Bildschirm "
|
||||
"ändert sich nicht und es kommt keine Eingabe. Eine Sitzung, die jemand offen "
|
||||
"vergisst, hält sonst die Verbindung zum Gerät. 0 schaltet die Trennung ab.",
|
||||
section="monitoring",
|
||||
minimum=0,
|
||||
maximum=86400,
|
||||
),
|
||||
SettingSpec(
|
||||
"vnc_warn_unencrypted_off",
|
||||
False,
|
||||
"bool",
|
||||
"Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken",
|
||||
hint="Setzt WarnUnencrypted=False in die Verbindungsdatei. Betrifft nur den "
|
||||
"RealVNC Viewer auf dem Arbeitsplatz, nicht die Verbindung selbst: die bleibt "
|
||||
"unverschlüsselt, wenn das Gerät es so anbietet.",
|
||||
section="monitoring",
|
||||
),
|
||||
# -- DHCP --------------------------------------------------------------- #
|
||||
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
|
||||
SettingSpec(
|
||||
|
||||
@@ -36,14 +36,13 @@
|
||||
{% if section.tiles %}
|
||||
<details class="card" data-tile-section="{{ section.key }}"
|
||||
data-persist="dash-{{ section.key }}" {% if section.key != 'disabled' %}open{% endif %}>
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<summary class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ pill(section.tiles | length, section.kind if section.kind != 'muted' else '') }}
|
||||
{{ section.label }}
|
||||
</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
<div class="card__body">
|
||||
<div class="grid grid--tiles">
|
||||
@@ -84,21 +83,6 @@
|
||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
|
||||
Details
|
||||
</a>
|
||||
{% if tile.restart_methods %}
|
||||
{% set method = tile.restart_methods[0] %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="method" value="{{ method.key }}">
|
||||
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
|
||||
data-busy-label="Startet ..."
|
||||
data-confirm="{{ tile.name }} über {{ method.label }} neu starten? {{ method.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('power', size=14) }} {{ method.label }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if current_user.has('devices.write') and tile.state == 'disabled' %}
|
||||
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
|
||||
data-no-guard>
|
||||
@@ -108,6 +92,54 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Die beiden Eingriffe stehen unten und immer an derselben
|
||||
Stelle: links der Bildschirm, rechts der Neustart. Wer
|
||||
eine Reihe Kacheln abarbeitet, trifft denselben Knopf,
|
||||
ohne hinzusehen -- und verwechselt ihn nicht mit dem
|
||||
anderen. #}
|
||||
{% if tile.vnc or tile.restart_methods %}
|
||||
<div class="tile__actions">
|
||||
{% if tile.vnc %}
|
||||
{# Die Sitzung laeuft in TESM selbst: ein Klick, kein
|
||||
Viewer auf dem Arbeitsplatz, keine Datei.
|
||||
|
||||
Mit frischer Passwortbestätigung öffnet sie sich hier
|
||||
im Fenster -- man bleibt bei der Übersicht und sieht
|
||||
die anderen Geräte weiter. Ohne sie führt der Knopf
|
||||
auf die Geräteseite, wo danach gefragt wird. #}
|
||||
{% if vnc_fenster %}
|
||||
<button class="btn btn--sm" type="button"
|
||||
data-vnc-oeffnen="{{ tile.vnc_quelle }}"
|
||||
data-vnc-name="{{ tile.name }}"
|
||||
title="Bildschirm im Fenster öffnen">
|
||||
{{ icon('monitor', size=14) }} VNC
|
||||
</button>
|
||||
{% else %}
|
||||
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=tile.id) }}"
|
||||
title="Bildschirm im Browser öffnen">
|
||||
{{ icon('monitor', size=14) }} VNC
|
||||
</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="tile__spacer" aria-hidden="true"></span>
|
||||
{% endif %}
|
||||
{% if tile.restart_methods %}
|
||||
{% set method = tile.restart_methods[0] %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="method" value="{{ method.key }}">
|
||||
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
|
||||
data-busy-label="Startet ..."
|
||||
data-confirm="{{ tile.name }} über {{ method.label }} neu starten? {{ method.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('power', size=14) }} {{ method.label }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{# Die Bildschirmfläche -- einmal geschrieben, an zwei Orten benutzt: auf der
|
||||
Geräteseite und im Fenster der Statusübersicht.
|
||||
|
||||
Die Teile finden sich über `data-rolle`, nicht über Kennungen: im Fenster
|
||||
gäbe es sie sonst zweimal auf derselben Seite, und die zweite wäre die, die
|
||||
niemand findet. #}
|
||||
{% macro vnc_flaeche(quelle='') %}
|
||||
<div class="screen" {% if quelle %}data-sitzung="{{ quelle }}"{% endif %}>
|
||||
<div data-rolle="leinwand" class="screen__canvas" hidden></div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro vnc_status() %}
|
||||
<div data-rolle="status" class="screen__status">Wird vorbereitet …</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro vnc_zugangsdaten() %}
|
||||
<form data-rolle="zugangsdaten" class="row row--tight" hidden onsubmit="return false">
|
||||
<label class="field">
|
||||
<span class="field__label">Benutzer</span>
|
||||
<input class="input" type="text" data-rolle="benutzer" autocomplete="off">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Passwort</span>
|
||||
<input class="input" type="password" data-rolle="passwort" autocomplete="off">
|
||||
</label>
|
||||
<button class="btn btn--primary" type="button" data-rolle="anmelden">Anmelden</button>
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
||||
{# Tasten, die der Arbeitsplatz selbst abfängt und die deshalb einen Knopf
|
||||
brauchen: Strg+Alt+Entf schaltet der Browser gar nicht erst durch, Alt+F4
|
||||
schlösse das Fenster hier statt das Fenster dort. #}
|
||||
{% macro vnc_tasten() %}
|
||||
<button class="btn btn--sm" type="button" data-rolle="taste-cad" hidden
|
||||
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
|
||||
<button class="btn btn--sm" type="button" data-rolle="taste-altf4" hidden
|
||||
title="Alt+F4 an das Gerät senden – schließt dort das oberste Fenster">Alt+F4</button>
|
||||
<button class="btn btn--sm" type="button" data-rolle="taste-f5" hidden
|
||||
title="F5 an das Gerät senden – der Browser des Arbeitsplatzes würde sonst diese Seite neu laden">F5</button>
|
||||
<button class="btn btn--sm" type="button" data-rolle="trennen" hidden>Trennen</button>
|
||||
{% endmacro %}
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import search_box %}
|
||||
{% from "tesm_core/macros.html" import search_box, modal %}
|
||||
{% from "tesm/_vnc_flaeche.html" import vnc_flaeche, vnc_status, vnc_zugangsdaten, vnc_tasten %}
|
||||
|
||||
{% block page_title %}Statusübersicht{% endblock %}
|
||||
{% block page_sub %}
|
||||
@@ -19,4 +20,27 @@
|
||||
data-refresh-interval="20000">
|
||||
{% include "tesm/_dashboard_tiles.html" %}
|
||||
</div>
|
||||
|
||||
{% if vnc_fenster %}
|
||||
{# Ein Fenster für alle Kacheln: welches Gerät gemeint ist, bringt der Knopf
|
||||
beim Öffnen mit. Ein Dialog je Kachel wäre dieselbe Sache dutzendfach im
|
||||
Quelltext -- und dutzendfach eine Sitzung, die niemand aufräumt. #}
|
||||
{% call modal('vnc-fenster', 'Bildschirm', 'Tastatur und Maus gehen an das Gerät. Die Zwischenablage bleibt getrennt.', 'wide') %}
|
||||
<div class="modal__body stack" data-rolle="behaelter">
|
||||
<div class="row row--tight">
|
||||
<strong data-rolle="titel" class="flex-1"></strong>
|
||||
{{ vnc_tasten() }}
|
||||
</div>
|
||||
{{ vnc_status() }}
|
||||
{{ vnc_zugangsdaten() }}
|
||||
{{ vnc_flaeche() }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if vnc_fenster %}
|
||||
<script type="module" src="{{ asset_url('js/screen.js') }}" nonce="{{ csp_nonce }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -16,6 +16,12 @@
|
||||
{{ icon('terminal', size=15) }} Konsole
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if vnc and vnc.erreichbar %}
|
||||
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=device.id) }}"
|
||||
title="Bildschirm im Browser öffnen">
|
||||
{{ icon('monitor', size=15) }} VNC
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if restart_methods %}
|
||||
{% for method in restart_methods %}
|
||||
<form method="post" style="display:inline"
|
||||
@@ -225,6 +231,29 @@
|
||||
<dt>Kategorie</dt><dd>{{ device.category or '–' | safe }}</dd>
|
||||
<dt>Standort</dt><dd>{{ device.location or '–' | safe }}</dd>
|
||||
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
|
||||
{% if vnc %}
|
||||
<dt>Bildschirm (VNC)</dt>
|
||||
<dd>
|
||||
{% if vnc.erreichbar %}
|
||||
<span class="mono">{{ vnc.ziel }}:{{ vnc.port }}</span>
|
||||
<div class="fs-xs faint">
|
||||
<a href="{{ url_for('screen.screen', device_id=device.id) }}">
|
||||
Im Browser öffnen</a> – oder die
|
||||
<a href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||
Verbindungsdatei</a> für einen Viewer auf dem Arbeitsplatz; sie
|
||||
enthält nur Ziel und Namen.
|
||||
{% if vnc.hinderungsgrund %}
|
||||
<div class="mt-1">{{ vnc.hinderungsgrund }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="faint">
|
||||
kein VNC-Server auf {{ vnc.ziel }}:{{ vnc.port }}
|
||||
{%- if device.state != 'online' %} (Client antwortet gerade nicht){% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
|
||||
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
|
||||
<dt>Geändert</dt>
|
||||
|
||||
@@ -7,6 +7,29 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Der wichtigste Satz der Seite, wenn er zutrifft: was hier steht, ist nicht,
|
||||
womit der Dienst arbeitet. Lehnt Kea eine Datei ab, bleibt der alte Stand
|
||||
aktiv -- die Oberflaeche zeigte bisher trotzdem den neuen Wert, und man
|
||||
sucht den Fehler beim Client. #}
|
||||
{% if konfig_stand == 'abweichend' %}
|
||||
<div class="alert alert--danger">
|
||||
{{ icon('alert', size=18) }}
|
||||
<div class="alert__body flex-1">
|
||||
<div class="alert__title">Der Dienst arbeitet nicht mit dieser Konfiguration</div>
|
||||
Auf der Platte liegt ein anderer Stand als der hier gezeigte – entweder wurde
|
||||
die Datei zuletzt abgelehnt oder seither nichts geschrieben. Änderungen an Optionen
|
||||
und Reservierungen sind gespeichert, aber <strong>nicht wirksam</strong>.
|
||||
{% if current_user.has('dhcp_config.execute') %}
|
||||
<form method="post" action="{{ url_for('dhcp.write_config') }}" class="mt-2" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--primary" type="submit" data-busy-label="Schreibt ...">
|
||||
Jetzt schreiben und Fehler anzeigen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %}
|
||||
{% if not sysops_available %}
|
||||
{{ alert('Systemhelfer nicht verfügbar -- Konfiguration und Dienstzustand können hier nur
|
||||
@@ -21,8 +44,13 @@
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Programm</span>
|
||||
<span class="stat__value fs-lg">{{ service.get('binary', '?') }}</span>
|
||||
<span class="stat__hint">kea-dhcp4</span>
|
||||
{# Die Fassung statt nur "vorhanden": zwischen Kea 2.4 (Ubuntu 24.04) und
|
||||
3.0 (26.04) liegen Aenderungen, und wer eine Konfiguration beurteilt,
|
||||
will wissen, wogegen. #}
|
||||
<span class="stat__value fs-lg">{{ service.get('version') or service.get('binary', '?') }}</span>
|
||||
<span class="stat__hint">
|
||||
kea-dhcp4{% if service.get('version') %} · {{ service.get('binary', '?') }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Reservierungen</span>
|
||||
@@ -39,6 +67,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Dienst und Grundeinstellungen sind beides Kleinkram, den man selten
|
||||
anfasst -- nebeneinander statt untereinander, damit die Seite mit dem
|
||||
anfaengt, worum es geht: den Subnetzen und Optionen. #}
|
||||
<div class="grid grid--2">
|
||||
{% if current_user.has('dhcp_service.execute') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
@@ -66,6 +98,31 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('dhcp_subnets.write') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('dhcp.save_settings') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ field('dhcp_domain', 'Domainname', value=values.dhcp_domain) }}
|
||||
{{ field('dhcp_lease_default', 'Lease-Dauer (s)', value=values.dhcp_lease_default, type='number') }}
|
||||
{{ field('dhcp_lease_max', 'Maximale Lease-Dauer (s)', value=values.dhcp_lease_max, type='number') }}
|
||||
{{ field('dhcp_output_path', 'Zielpfad der Konfiguration', value=values.dhcp_output_path,
|
||||
extra_class='field--full') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Speichern und schreiben</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{# -- Subnetze ---------------------------------------------------------- #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
@@ -134,45 +191,111 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# -- Globale Einstellungen --------------------------------------------- #}
|
||||
{% if current_user.has('dhcp_subnets.write') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('dhcp.save_settings') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ field('dhcp_domain', 'Domainname', value=values.dhcp_domain) }}
|
||||
{{ field('dhcp_lease_default', 'Lease-Dauer (s)', value=values.dhcp_lease_default, type='number') }}
|
||||
{{ field('dhcp_lease_max', 'Maximale Lease-Dauer (s)', value=values.dhcp_lease_max, type='number') }}
|
||||
{{ field('dhcp_output_path', 'Zielpfad der Konfiguration', value=values.dhcp_output_path,
|
||||
extra_class='field--full') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Speichern und schreiben</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# -- Optionen ---------------------------------------------------------- #}
|
||||
|
||||
{# Zwei Makros, weil dieselbe Zeile zweimal vorkommt: einmal in der kurzen
|
||||
Liste dessen, was wirklich gesendet wird, und einmal im vollen Katalog. #}
|
||||
{% macro optionswert(option) %}
|
||||
{% if current_user.has('dhcp_options.write') %}
|
||||
<form method="post" action="{{ url_for('dhcp.set_option_value') }}"
|
||||
class="row row--tight" style="flex-wrap:nowrap" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="option_id" value="{{ option.id }}">
|
||||
{# min-width:0 und flex:1 statt einer festen Mindestbreite: sonst passte
|
||||
das Feld nicht in die Spalte, die Zeile brach um, und der Haken stand
|
||||
allein darunter -- es sah aus wie ein Bedienfehler der Seite. #}
|
||||
<input class="input input--mono" type="text" name="value"
|
||||
value="{{ option.value or '' }}" style="min-width:0;flex:1"
|
||||
placeholder="leer = nicht senden">
|
||||
<button class="btn btn--sm" type="submit" title="Wert übernehmen">
|
||||
{{ icon('check', size=14) }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="mono fs-sm">{{ option.value or '–' | safe }}</span>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro optionsloeschen(option) %}
|
||||
{% if current_user.has('dhcp_options.write') and not option.is_standard %}
|
||||
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Eigene Option {{ option.name }} löschen?"
|
||||
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
|
||||
{{ icon('trash', size=14) }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% set gesetzte = options | selectattr('value') | list %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('puzzle', size=17) }} DHCP-Optionen</div>
|
||||
<div class="card__title">{{ icon('puzzle', size=17) }} Gesendete Optionen</div>
|
||||
<div class="card__sub">
|
||||
{{ options | length }} Optionen · Standardoptionen sind nicht löschbar, ihr Wert
|
||||
lässt sich leeren
|
||||
{% if gesetzte %}
|
||||
<strong>{{ gesetzte | length }}</strong> von {{ options | length }} Optionen tragen
|
||||
einen Wert. Was leer ist, wird nicht gesendet.
|
||||
{% else %}
|
||||
Derzeit trägt keine Option einen Wert – es wird also nichts über die
|
||||
Grundangaben hinaus gesendet.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{{ search_box('options-table', 'Option suchen ...') }}
|
||||
</div>
|
||||
<div class="table-wrap" style="max-height:420px;overflow:auto">
|
||||
|
||||
{% if gesetzte %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr>
|
||||
<th class="numeric">Code</th><th>Name</th><th>Typ</th>
|
||||
<th style="width:42%">Wert</th><th class="actions"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for option in gesetzte %}
|
||||
<tr>
|
||||
<td class="numeric fs-sm">{{ option.code }}</td>
|
||||
<td class="fs-sm">
|
||||
<div class="mono">{{ option.name }}</div>
|
||||
<div class="fs-xs faint">{{ option.description }}</div>
|
||||
</td>
|
||||
<td class="fs-xs mono">{{ option.type }}</td>
|
||||
<td>{{ optionswert(option) }}</td>
|
||||
<td class="actions">{{ optionsloeschen(option) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Nichts gesetzt', 'Tragen Sie unten im Katalog einen Wert ein.', 'puzzle') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# Der Katalog hat 57 Zeilen. Er stand vorher ganz oben, in einem eigenen
|
||||
Rollbereich innerhalb der scrollenden Seite -- und das Gesuchte ging darin
|
||||
unter. Jetzt liegt er hinter einem Klick, mit eigener Suche. #}
|
||||
<details class="card" data-persist="dhcp-katalog">
|
||||
<summary class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('list', size=17) }} Optionskatalog</div>
|
||||
<div class="card__sub">
|
||||
Alle {{ options | length }} bekannten Optionen. Standardoptionen sind nicht löschbar,
|
||||
ihr Wert lässt sich leeren.
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="card__body pt-0">{{ search_box('options-table', 'Option suchen ...') }}</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact" id="options-table">
|
||||
<thead><tr><th class="numeric">Code</th><th>Name</th><th>Typ</th><th>Wert</th><th class="actions"></th></tr></thead>
|
||||
<thead><tr>
|
||||
<th class="numeric">Code</th><th>Name</th><th>Typ</th>
|
||||
<th style="width:42%">Wert</th><th class="actions"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for option in options %}
|
||||
<tr data-filter-text="{{ option.code }} {{ option.name }} {{ option.description }}">
|
||||
@@ -182,43 +305,30 @@
|
||||
<div class="fs-xs faint">{{ option.description }}</div>
|
||||
</td>
|
||||
<td class="fs-xs mono">{{ option.type }}</td>
|
||||
<td>
|
||||
{% if current_user.has('dhcp_options.write') %}
|
||||
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="option_id" value="{{ option.id }}">
|
||||
<input class="input input--mono" type="text" name="value" value="{{ option.value or '' }}"
|
||||
style="min-width:180px" placeholder="leer = nicht senden">
|
||||
<button class="btn btn--sm" type="submit">{{ icon('check', size=14) }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="mono fs-sm">{{ option.value or '–' | safe }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if current_user.has('dhcp_options.write') and not option.is_standard %}
|
||||
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Eigene Option {{ option.name }} löschen?"
|
||||
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
|
||||
{{ icon('trash', size=14) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ optionswert(option) }}</td>
|
||||
<td class="actions">{{ optionsloeschen(option) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if current_user.has('dhcp_options.write') %}
|
||||
</details>
|
||||
|
||||
{% if current_user.has('dhcp_options.write') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('plus', size=17) }} Eigene Option anlegen</div>
|
||||
<div class="card__sub">
|
||||
Für alles jenseits des Katalogs – etwa eine URL, die der Client beim Start öffnet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ field('code', 'Code', type='number', required=True) }}
|
||||
{{ field('code', 'Code', type='number', required=True, placeholder='225') }}
|
||||
{{ field('name', 'Name', required=True, placeholder='meine-option') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Typ</span>
|
||||
@@ -231,13 +341,15 @@
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn" type="submit">Eigene Option anlegen</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{# Der Wert einer Option gilt sonst für alle. Genau dafür gibt es
|
||||
``dhcp_option_values.device_id``: ein eigener Wert je Gerät, der in
|
||||
dessen Reservierung geschrieben wird -- etwa eine Terminal-URL, die je
|
||||
Gerät anders lautet. Auswertbar war das von Anfang an, eingeben liess
|
||||
es sich nicht. #}
|
||||
<div class="card__header" style="border-top:1px solid var(--border-subtle)">
|
||||
{# Der Wert einer Option gilt sonst für alle. Genau dafür gibt es
|
||||
``dhcp_option_values.device_id``: ein eigener Wert je Gerät, der in dessen
|
||||
Reservierung geschrieben wird -- etwa eine Terminal-URL, die je Gerät
|
||||
anders lautet. Auswertbar war das von Anfang an, eingeben liess es sich
|
||||
nicht. #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('sliders', size=17) }} Option nur für ein Gerät</div>
|
||||
<div class="card__sub">
|
||||
@@ -276,6 +388,93 @@
|
||||
<button class="btn" type="submit">Für dieses Gerät speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# -- Was der Client dafür braucht -------------------------------------- #}
|
||||
{#
|
||||
Ein DHCP-Client bekommt nur, wonach er fragt. Gateway, DNS und Domain
|
||||
fragt er von sich aus; alles darüber hinaus -- und dazu gehört jede eigene
|
||||
Option -- muss er ausdrücklich anfordern. Ohne diese Zeilen sendet der
|
||||
Server brav, und der Client wirft es weg, ohne dass irgendwo etwas
|
||||
danebengeht. Genau deshalb steht das hier und nicht in einer Anleitung.
|
||||
#}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Konfiguration für Linux-Clients</div>
|
||||
<div class="card__sub">
|
||||
Ein Client erhält nur die Optionen, die er anfordert. Diese Zeilen sorgen dafür –
|
||||
erzeugt aus dem, was dieser Server tatsächlich sendet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set beispiel = client_konfig.get('networkd') %}
|
||||
{% if not beispiel or not beispiel.optionen %}
|
||||
<div class="card__body">
|
||||
{{ alert('Dieser Server sendet derzeit keine Option, die ein Client eigens anfordern
|
||||
müsste. Sobald eine eigene Option einen Wert trägt, steht hier die passende
|
||||
Konfiguration.', 'info') }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card__body pt-0">
|
||||
<p class="fs-sm faint">
|
||||
Betrifft
|
||||
{% for eintrag in beispiel.optionen %}
|
||||
<code>{{ eintrag.code }} {{ eintrag.name }}</code>{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}.
|
||||
Welcher Abschnitt gilt, entscheidet die Distribution des Clients.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% for stil, bezeichnung, pfad in client_stile %}
|
||||
{% set konfig = client_konfig.get(stil) %}
|
||||
<details class="card__body pt-0">
|
||||
<summary class="row row--tight">
|
||||
<strong class="fs-sm">{{ bezeichnung }}</strong>
|
||||
<span class="fs-xs faint mono">{{ pfad }}</span>
|
||||
</summary>
|
||||
<div class="stack stack--tight mt-2">
|
||||
<div class="row row--tight">
|
||||
<span class="fs-xs faint flex-1">Inhalt der Datei</span>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-copy="#konfig-{{ stil }}"
|
||||
data-copy-message="Konfiguration kopiert.">
|
||||
{{ icon('copy', size=14) }} Kopieren
|
||||
</button>
|
||||
</div>
|
||||
<pre class="code-block code-block--wrap" id="konfig-{{ stil }}">{{ konfig.inhalt }}</pre>
|
||||
<div class="fs-xs faint">Danach: <code>{{ konfig.nachher }}</code></div>
|
||||
|
||||
{% if current_user.has('servicing_command.execute') %}
|
||||
<form method="post" action="{{ url_for('dhcp.push_client_config') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="stil" value="{{ stil }}">
|
||||
<div class="divider"></div>
|
||||
<div class="fs-xs faint mb-2">
|
||||
Oder direkt schreiben lassen – über SSH, mit den hinterlegten Zugangsdaten.
|
||||
Jeder Client bekommt einen eigenen Auftrag; die Ausgabe steht unter Wartung.
|
||||
</div>
|
||||
<div class="row row--tight" style="flex-wrap:wrap">
|
||||
{% for reservation in reservations if reservation.device_id %}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="device_ids" value="{{ reservation.device_id }}">
|
||||
<span>{{ reservation.name or reservation.hostname }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
data-confirm="Die Konfiguration für {{ bezeichnung }} auf die ausgewählten Clients schreiben ({{ pfad }})? Der DHCP-Client wird dort anschließend neu gestartet."
|
||||
data-confirm-title="Konfiguration verteilen" data-confirm-ok="Verteilen">
|
||||
{{ icon('upload', size=15) }} An ausgewählte Clients senden
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
@@ -377,6 +576,10 @@
|
||||
<div class="card__title">{{ icon('clock', size=17) }} Leases</div>
|
||||
<div class="card__sub">
|
||||
Direkt aus {{ lease_file }} gelesen – je Adresse gilt der letzte Eintrag.
|
||||
{% if current_user.has('dhcp_lease_adopt.read') and current_user.has('devices.create') %}
|
||||
Eine <strong>dynamische</strong> Lease lässt sich als Client übernehmen; sie bekommt
|
||||
damit eine feste Adresse und wird überwacht.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -394,7 +597,7 @@
|
||||
<div class="table-wrap" style="max-height:420px;overflow:auto">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Art</th><th>Zustand</th>
|
||||
<th>Läuft ab</th></tr></thead>
|
||||
<th>Läuft ab</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for lease in leases %}
|
||||
<tr>
|
||||
@@ -413,6 +616,34 @@
|
||||
<td class="fs-sm">{{ lease.expires_text }}
|
||||
<div class="fs-xs faint">{{ lease.remaining_text }}</div>
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
{# Übernehmen gibt es nur für dynamische Leases: was reserviert
|
||||
ist, gehört schon zu einem gepflegten Datensatz. Und nur,
|
||||
wenn beide Rechte stehen -- hier entsteht ein Client. #}
|
||||
{% if lease.address not in reserved_ips
|
||||
and current_user.has('dhcp_lease_adopt.read')
|
||||
and current_user.has('devices.create') %}
|
||||
{% if lease.mac in bekannte_macs %}
|
||||
<span class="faint fs-xs" title="Zu dieser MAC gibt es bereits einen Client">
|
||||
bereits Client
|
||||
</span>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('dhcp.adopt_lease') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="mac" value="{{ lease.mac }}">
|
||||
{# Kein Geisterknopf mehr: die Uebernahme ist die eine
|
||||
Handlung in dieser Tabelle, und sie war als
|
||||
"btn--ghost" kaum von der Zeile zu unterscheiden. #}
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
data-confirm="{{ lease.hostname or lease.address }} als überwachten Client anlegen? Switch, Port und Zugangsdaten tragen Sie danach nach."
|
||||
data-confirm-title="Als Client übernehmen"
|
||||
data-confirm-ok="Übernehmen">
|
||||
{{ icon('plus', size=14) }} Als Client
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -426,12 +657,11 @@
|
||||
|
||||
{# -- Vorschau ---------------------------------------------------------- #}
|
||||
<details class="card" data-persist="dhcp-preview">
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<summary class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration</div>
|
||||
<div class="card__sub">Vorschau – so würde die Datei geschrieben</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
<pre class="log-view">{{ preview }}</pre>
|
||||
{% if current_user.has('dhcp_config.execute') %}
|
||||
|
||||
@@ -242,8 +242,7 @@
|
||||
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
|
||||
data-viewer-konto="{{ current_user.username }}"
|
||||
data-viewer-zeuge="{{ '1' if signature_witness else '0' }}"
|
||||
data-viewer-zeit="{{ serverzeit }}"
|
||||
data-viewer-ordnerliste="{{ verzeichnisse | tojson }}">
|
||||
data-viewer-zeit="{{ serverzeit }}">
|
||||
<div class="viewer">
|
||||
<div class="viewer__stage" data-viewer-bildbuehne hidden>
|
||||
<img class="viewer__img" alt="" data-viewer-bild>
|
||||
|
||||
@@ -249,6 +249,39 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Fuer ein Ueberwachungswerkzeug, das auf dieser Maschine laeuft. Bewusst
|
||||
kein HTTP-Endpunkt mit Token: der Agent sitzt ohnehin hier, und was ohne
|
||||
Netz auskommt, kann auch niemand aus dem Netz abfragen. #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Abfrage durch ein RMM</div>
|
||||
<div class="card__sub">
|
||||
Ein Agent auf dieser Maschine liest die Restlaufzeit ohne Anmeldung und ohne Netz.
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-copy="#rmm-json"
|
||||
data-copy-message="Befehl kopiert.">{{ icon('copy', size=14) }} Kopieren</button>
|
||||
</div>
|
||||
<div class="card__body stack stack--tight">
|
||||
<pre class="code-block code-block--wrap" id="rmm-json">{{ rmm_befehl }}</pre>
|
||||
<p class="fs-xs faint">
|
||||
Antwortet mit einer Zeile JSON auf <strong>stdout</strong>
|
||||
(<code>valid</code>, <code>days_left</code>, <code>expires_at</code>,
|
||||
<code>lifetime</code>, <code>modules</code>). Der <strong>Ausstiegscode</strong> trägt
|
||||
die Aussage: <strong>0</strong> in Ordnung, <strong>1</strong> läuft in
|
||||
{{ rmm_warnschwelle }} Tagen oder weniger ab, <strong>2</strong> ungültig oder nicht
|
||||
vorhanden. Meldungen des Starts gehen nach stderr und stören das Auswerten nicht.
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
<div class="fs-xs faint">Nur die Restlaufzeit, ohne JSON-Auswertung:</div>
|
||||
<pre class="code-block code-block--wrap" id="rmm-tage">{{ rmm_befehl_tage }}</pre>
|
||||
<div class="divider"></div>
|
||||
<div class="fs-xs faint">Beispiel für einen Agenten mit PowerShell:</div>
|
||||
<pre class="code-block code-block--wrap">{{ rmm_powershell }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
{% block content %}
|
||||
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
|
||||
wie manuell, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
|
||||
Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||
Auch ein Neustart, den die Überwachung auslösen wollte und nicht durfte, steht hier:
|
||||
als "übersprungen" mit dem Grund. Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
@@ -32,7 +33,7 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr data-filter-text="{{ row.device_name }} {{ row.result }} {{ row.actor }} {{ method_labels.get(row.method, row.method) }} {{ row.detail }}">
|
||||
<tr data-filter-text="{{ row.device_name }} {{ row.result }} {{ row.actor }} {{ method_labels.get(row.method, row.method) }} {{ trigger_labels.get(row.trigger, row.trigger) }} {{ row.detail }}">
|
||||
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
|
||||
<td class="fw-600 fs-sm">{{ row.device_name or '–' | safe }}</td>
|
||||
<td class="fs-sm">
|
||||
@@ -41,10 +42,15 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||
jahrelang als PoE ausgegeben. #}
|
||||
{% if row.method == 'ssh' %}{{ pill('SSH', 'info') }}
|
||||
{% elif row.method == 'rpc' %}{{ pill('RPC', 'accent') }}
|
||||
{% elif row.method == 'pö' %}{{ pill('PoE-Port', '') }}
|
||||
{# 'poe', nicht 'pö'. Der Umlaut stand hier durch eine
|
||||
Ersetzung, die "oe" fuer eine Umschrift hielt -- und traf den
|
||||
haeufigsten Fall von allen: jeder automatische Neustart laeuft
|
||||
ueber den PoE-Port. Sie standen damit als orangefarbenes
|
||||
"poe" da, wo "PoE-Port" stehen sollte. #}
|
||||
{% elif row.method == 'poe' %}{{ pill('PoE-Port', '') }}
|
||||
{% else %}{{ pill(row.method or 'unbekannt', 'warning') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ row.trigger }}</td>
|
||||
<td class="fs-sm">{{ trigger_labels.get(row.trigger, row.trigger) }}</td>
|
||||
<td class="fs-sm">{{ row.actor }}</td>
|
||||
<td>
|
||||
{% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
|
||||
|
||||
@@ -45,15 +45,78 @@ brauchen dafür weder SSH noch einen Host-Schlüssel, sondern die offene Datei-
|
||||
Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Port.',
|
||||
'info') }}
|
||||
|
||||
<form method="post" id="maintenance-form" action="{{ url_for('maintenance.start_update') }}">
|
||||
{# ``stack``: Massenbefehl und Geräteauswahl sind zwei Bereiche und sollen auch
|
||||
so aussehen. Ohne das stossen die beiden Karten aneinander. #}
|
||||
<form method="post" id="maintenance-form" class="stack"
|
||||
action="{{ url_for('maintenance.start_update') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
|
||||
{% if current_user.has('servicing_command.read') %}
|
||||
{# Zugeklappt beim Laden: der Massenbefehl ist das seltenere Werkzeug, die
|
||||
Geräteliste darunter das tägliche. Aufgeklappt schob er sie aus dem
|
||||
Bild. #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<button class="btn btn--ghost btn--sm" type="button" data-befehl-schalter
|
||||
aria-expanded="false" title="Massenbefehl ein- und ausklappen">
|
||||
{{ icon('chevron-right', size=14) }}
|
||||
</button>
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Massenbefehl</div>
|
||||
<div class="card__sub">
|
||||
Eine Befehlszeile über SSH, auf allen unten ausgewählten Clients – jeder als
|
||||
eigener Auftrag mit eigener Ausgabe. <strong>{{ '{sudo}' }}</strong> im Befehl
|
||||
wird ersetzt: mit <code>sudo -n</code>, wo es ohne Passwort geht, sonst mit dem
|
||||
hinterlegten Passwort über stdin. Ohne diese Marke läuft der Befehl als das
|
||||
angemeldete Konto.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack" data-befehl-feld hidden>
|
||||
<label class="field">
|
||||
<span class="field__label">Vorlage</span>
|
||||
<select class="select" id="befehlsvorlage">
|
||||
<option value="">– eigene Eingabe –</option>
|
||||
{% for name, befehl, erklaerung in befehlsvorlagen %}
|
||||
<option value="{{ befehl }}" data-erklaerung="{{ erklaerung }}">{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="field__hint" id="befehlserklaerung">
|
||||
Eine Vorlage füllt nur das Feld darunter – abgeschickt wird von Hand.
|
||||
</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Befehl</span>
|
||||
<input class="input mono" type="text" name="befehl" id="befehl"
|
||||
maxlength="500" autocomplete="off"
|
||||
placeholder='echo "Encryption=AlwaysOff" | {sudo} tee -a /etc/vnc/config.d/common.custom'
|
||||
{% if not current_user.has('servicing_command.execute') %}disabled{% endif %}>
|
||||
<span class="field__hint">
|
||||
Genau eine Zeile. Was der Befehl bewirkt, weiß der, der ihn schreibt – TESM
|
||||
führt ihn aus und hält fest, was zurückkam.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<section class="card">
|
||||
<div class="table-toolbar">
|
||||
<label class="check">
|
||||
<input type="checkbox" id="select-all">
|
||||
<span>Alle auswählen</span>
|
||||
</label>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-alle-einklappen>
|
||||
{{ icon('chevron-up', size=14) }} Alle einklappen
|
||||
</button>
|
||||
<span class="table-toolbar__spacer"></span>
|
||||
{% if current_user.has('servicing_command.execute') %}
|
||||
<button class="btn btn--sm" type="submit"
|
||||
formaction="{{ url_for('maintenance.start_command') }}"
|
||||
data-confirm="Den eingetragenen Befehl auf allen ausgewählten Geräten ausführen? Was er bewirkt, entscheidet er selbst – TESM führt ihn nur aus."
|
||||
data-confirm-title="Befehl senden" data-confirm-ok="Senden" data-confirm-style="danger">
|
||||
{{ icon('terminal', size=15) }} Befehl senden
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('servicing_update.execute') %}
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
formaction="{{ url_for('maintenance.start_update') }}"
|
||||
@@ -79,6 +142,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
<th class="sortable">Gerät</th>
|
||||
<th class="sortable">IP</th>
|
||||
<th class="sortable">Zustand</th>
|
||||
<th class="sortable">Auftrag</th>
|
||||
<th class="sortable">Zugangsdaten</th>
|
||||
<th class="sortable">Host-Schlüssel</th>
|
||||
<th></th>
|
||||
@@ -95,13 +159,36 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
<span class="sr-only">{{ device.name }} auswählen</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="fw-600">{{ device.name }}</td>
|
||||
<td class="fw-600">
|
||||
{# Mitlesen, ohne die Seite zu verlassen -- so war es in der
|
||||
Fassung 1.2.8, und es fehlte seither. Die Zeile darunter
|
||||
klappt auf, sobald für dieses Gerät etwas läuft. #}
|
||||
<button class="btn btn--ghost btn--sm" type="button"
|
||||
data-ausgabe-schalter="{{ device.name }}"
|
||||
title="Ausgabe dieses Geräts mitlesen">
|
||||
{{ icon('chevron-right', size=13) }}
|
||||
</button>
|
||||
{{ device.name }}
|
||||
</td>
|
||||
<td class="mono fs-sm">{{ device.ip }}</td>
|
||||
<td>
|
||||
<td data-zustand="{{ device.id }}">
|
||||
{% if device.state == 'online' %}{{ pill('online', 'online') }}
|
||||
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
||||
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
||||
</td>
|
||||
{# Wie weit ist der Auftrag dieses Geräts? Ohne diese Spalte
|
||||
blieb nur die Auftragsliste weiter unten -- und dort steht
|
||||
nicht, welche Zeile zu welchem Gerät gehört, wenn vierzig
|
||||
Aufträge in derselben Sekunde starten. #}
|
||||
<td data-auftragslage="{{ device.name }}">
|
||||
{% set lage = auftragslage.get(device.name) %}
|
||||
{% if not lage %}<span class="faint fs-xs">–</span>
|
||||
{% elif lage.state == 'running' %}{{ pill('läuft', 'info') }}
|
||||
{% elif lage.state == 'pending' %}{{ pill('wartet', '') }}
|
||||
{% elif lage.state == 'succeeded' %}{{ pill('fertig', 'online') }}
|
||||
{% elif lage.state == 'failed' %}{{ pill('Fehler', 'danger') }}
|
||||
{% else %}{{ pill('unklar', 'warning') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">
|
||||
{{ device.credential_name }}
|
||||
<div class="fs-xs faint">{{ category_labels.get(device.category, device.category) }}</div>
|
||||
@@ -129,6 +216,11 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-ausgabe-zeile="{{ device.name }}" hidden>
|
||||
<td colspan="8" class="p-0">
|
||||
<pre class="job-output" data-ausgabe="{{ device.name }}">Noch keine Ausgabe.</pre>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -161,13 +253,13 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
<thead><tr><th>Gestartet</th><th>Gerät</th><th>Art</th><th>Zustand</th><th>Fortschritt</th><th>Meldung</th></tr></thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<tr data-auftrag="{{ job.id }}">
|
||||
<td class="fs-sm nowrap">{{ job.created_at | dt }}</td>
|
||||
<td class="fs-sm fw-600">
|
||||
<a href="{{ url_for('maintenance.job_detail', job_id=job.id) }}">{{ job.subject }}</a>
|
||||
</td>
|
||||
<td class="fs-sm">{{ 'Aktualisierung' if job.kind.endswith('update') else 'Neustart' }}</td>
|
||||
<td>
|
||||
<td data-auftrag-zustand>
|
||||
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
|
||||
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
|
||||
{% elif job.state == 'running' %}{{ pill('läuft', 'info') }}
|
||||
@@ -175,11 +267,12 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
{% else %}{{ pill('wartet', '') }}{% endif %}
|
||||
</td>
|
||||
<td style="min-width:120px">
|
||||
<div class="progress {{ 'progress--success' if job.state == 'succeeded' else ('progress--danger' if job.state == 'failed' else '') }}">
|
||||
<div class="progress {{ 'progress--success' if job.state == 'succeeded' else ('progress--danger' if job.state == 'failed' else '') }}"
|
||||
data-auftrag-balken>
|
||||
<div class="progress__bar" style="width: {{ job.progress }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="fs-xs muted">{{ job.message }}</td>
|
||||
<td class="fs-xs muted" data-auftrag-meldung>{{ job.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -193,6 +286,18 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" nonce="{{ csp_nonce }}">
|
||||
// Eine Vorlage füllt das Feld und erklärt sich -- abgeschickt wird von Hand.
|
||||
const vorlage = document.getElementById("befehlsvorlage");
|
||||
const befehl = document.getElementById("befehl");
|
||||
const erklaerung = document.getElementById("befehlserklaerung");
|
||||
vorlage?.addEventListener("change", () => {
|
||||
const gewaehlt = vorlage.selectedOptions[0];
|
||||
if (vorlage.value) befehl.value = vorlage.value;
|
||||
erklaerung.textContent =
|
||||
gewaehlt?.dataset.erklaerung ||
|
||||
"Eine Vorlage füllt nur das Feld darunter – abgeschickt wird von Hand.";
|
||||
});
|
||||
|
||||
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
|
||||
// sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
|
||||
// versehentlich das falsche Gerät erwischt.
|
||||
@@ -205,14 +310,172 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
.forEach((box) => (box.checked = all.checked));
|
||||
});
|
||||
|
||||
// Auftragsliste nachladen, solange etwas läuft.
|
||||
// Mitlesen: die Zeile unter dem Gerät zeigt, was dort gerade passiert.
|
||||
//
|
||||
// Vorher lud diese Seite sich alle zwei Sekunden komplett neu, solange etwas
|
||||
// lief -- das verwarf jede Auswahl, jeden Tastendruck im Befehlsfeld und
|
||||
// sprang nach oben. Jetzt kommen nur die letzten Zeilen, und die Seite
|
||||
// bleibt stehen.
|
||||
// Der Massenbefehl: zugeklappt geladen, per Kopfzeile aufgeklappt.
|
||||
const befehlSchalter = document.querySelector("[data-befehl-schalter]");
|
||||
const befehlFeld = document.querySelector("[data-befehl-feld]");
|
||||
befehlSchalter?.addEventListener("click", () => {
|
||||
const zu = befehlFeld.hidden;
|
||||
befehlFeld.hidden = !zu;
|
||||
befehlSchalter.setAttribute("aria-expanded", zu ? "true" : "false");
|
||||
befehlSchalter.style.transform = zu ? "rotate(90deg)" : "";
|
||||
if (zu) document.getElementById("befehl")?.focus();
|
||||
});
|
||||
|
||||
const schalter = Array.from(document.querySelectorAll("[data-ausgabe-schalter]"));
|
||||
// Wer von Hand zuklappt, will es zu haben. Ohne dieses Gedaechtnis riss die
|
||||
// naechste Statusabfrage die Zeile wieder auf, weil dort noch Ausgabe stand
|
||||
// -- zwei Sekunden spaeter, jedes Mal.
|
||||
const zugeklappt = new Set();
|
||||
// Beim ersten Fuellen ans Ende springen, danach nur, wenn man ohnehin unten
|
||||
// steht. So steht nach dem Neuladen der Seite das Aktuelle im Bild und nicht
|
||||
// der Anfang von vorgestern -- und wer hochscrollt, um etwas zu lesen, wird
|
||||
// nicht wieder heruntergezogen.
|
||||
const gefuellt = new Set();
|
||||
|
||||
function klappen(name, zu) {
|
||||
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
|
||||
if (!zeile) return;
|
||||
zeile.hidden = zu;
|
||||
if (zu) zugeklappt.add(name);
|
||||
else zugeklappt.delete(name);
|
||||
}
|
||||
|
||||
schalter.forEach((knopf) => {
|
||||
knopf.addEventListener("click", () => {
|
||||
const name = knopf.dataset.ausgabeSchalter;
|
||||
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
|
||||
if (zeile) klappen(name, !zeile.hidden);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("[data-alle-einklappen]")?.addEventListener("click", () => {
|
||||
schalter.forEach((knopf) => klappen(knopf.dataset.ausgabeSchalter, true));
|
||||
});
|
||||
|
||||
function zeigen(name, text) {
|
||||
const feld = document.querySelector(`[data-ausgabe="${CSS.escape(name)}"]`);
|
||||
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
|
||||
if (!feld || !zeile) return;
|
||||
const erstmals = !gefuellt.has(name);
|
||||
const unten = feld.scrollTop + feld.clientHeight >= feld.scrollHeight - 24;
|
||||
feld.textContent = text || "Noch keine Ausgabe.";
|
||||
if (text) gefuellt.add(name);
|
||||
if (erstmals || unten) feld.scrollTop = feld.scrollHeight;
|
||||
// Was läuft, klappt von selbst auf -- wer zusieht, will nicht erst suchen.
|
||||
if (text && zeile.hidden && !zugeklappt.has(name)) zeile.hidden = false;
|
||||
}
|
||||
|
||||
// Zustand, Fortschritt und Meldung eines Auftrags nachziehen.
|
||||
//
|
||||
// Vorher lud die Seite sich dafür komplett neu, sobald der letzte Auftrag
|
||||
// durch war -- und riss damit genau das weg, was man lesen wollte: die
|
||||
// Ausgabe stand da, dann sprang die Seite, und danach war sie leer.
|
||||
const ZUSTAENDE = {
|
||||
succeeded: ["erfolgreich", "pill--online", "progress--success"],
|
||||
failed: ["fehlgeschlagen", "pill--danger", "progress--danger"],
|
||||
running: ["läuft", "pill--info", ""],
|
||||
unknown: ["unklar", "pill--warning", ""],
|
||||
pending: ["wartet", "", ""],
|
||||
};
|
||||
|
||||
function auftragZeigen(job) {
|
||||
const zeile = document.querySelector(`[data-auftrag="${CSS.escape(job.id)}"]`);
|
||||
if (!zeile) return false;
|
||||
const [text, pillenKlasse, balkenKlasse] = ZUSTAENDE[job.state] || ZUSTAENDE.pending;
|
||||
const pille = zeile.querySelector("[data-auftrag-zustand] .pill");
|
||||
if (pille && pille.textContent.trim() !== text) {
|
||||
pille.textContent = text;
|
||||
pille.className = `pill ${pillenKlasse}`.trim();
|
||||
}
|
||||
const balken = zeile.querySelector("[data-auftrag-balken]");
|
||||
if (balken) {
|
||||
balken.className = `progress ${balkenKlasse}`.trim();
|
||||
const inner = balken.querySelector(".progress__bar");
|
||||
if (inner) inner.style.width = `${job.progress || 0}%`;
|
||||
}
|
||||
const meldung = zeile.querySelector("[data-auftrag-meldung]");
|
||||
if (meldung && meldung.textContent.trim() !== (job.message || "")) {
|
||||
meldung.textContent = job.message || "";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Dieselben fünf Zustände wie in der Auftragsliste -- hier aber neben dem
|
||||
// Gerät, wo man sie sucht.
|
||||
const LAGE = {
|
||||
pending: ["wartet", ""],
|
||||
running: ["läuft", "pill--info"],
|
||||
succeeded: ["fertig", "pill--online"],
|
||||
failed: ["Fehler", "pill--danger"],
|
||||
unknown: ["unklar", "pill--warning"],
|
||||
};
|
||||
|
||||
function lageZeigen(name, zustand) {
|
||||
const zelle = document.querySelector(`[data-auftragslage="${CSS.escape(name)}"]`);
|
||||
if (!zelle) return;
|
||||
const [text, klasse] = LAGE[zustand] || LAGE.pending;
|
||||
const vorhanden = zelle.querySelector(".pill");
|
||||
if (vorhanden && vorhanden.textContent.trim() === text) return;
|
||||
const pille = document.createElement("span");
|
||||
pille.className = `pill ${klasse}`.trim();
|
||||
pille.textContent = text;
|
||||
zelle.replaceChildren(pille);
|
||||
}
|
||||
|
||||
let neueAuftraege = false;
|
||||
async function poll() {
|
||||
let weiter = 10000;
|
||||
try {
|
||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
|
||||
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
|
||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}", { auto: true });
|
||||
weiter = state.poll_ms || 10000;
|
||||
// Der neueste Auftrag je Gerät gewinnt; die Liste kommt neueste zuerst.
|
||||
const gesehen = new Set();
|
||||
state.jobs.forEach((job) => {
|
||||
if (!gesehen.has(job.subject)) {
|
||||
gesehen.add(job.subject);
|
||||
lageZeigen(job.subject, job.state);
|
||||
}
|
||||
if (job.output) zeigen(job.subject, job.output);
|
||||
// Ein Auftrag, der in der Tabelle noch gar nicht steht, ist neu --
|
||||
// dafür braucht es die Seite einmal frisch, aber erst, wenn nichts
|
||||
// mehr läuft.
|
||||
if (!auftragZeigen(job)) neueAuftraege = true;
|
||||
});
|
||||
// Erreichbarkeit mitziehen -- sonst steht die Seite mit dem Stand ihres
|
||||
// Aufbaus da, und wer nach einem Neustart auf die Rückkehr wartet, sieht
|
||||
// "unbekannt", bis er von Hand neu lädt.
|
||||
(state.devices || []).forEach((geraet) => {
|
||||
const zelle = document.querySelector(`[data-zustand="${geraet.id}"]`);
|
||||
if (!zelle) return;
|
||||
const [text, art] =
|
||||
geraet.state === "online"
|
||||
? ["online", "pill--online"]
|
||||
: geraet.state === "offline"
|
||||
? ["offline", "pill--offline"]
|
||||
: ["unbekannt", "pill--warning"];
|
||||
const pille = zelle.querySelector(".pill");
|
||||
if (pille && pille.textContent.trim() !== text) {
|
||||
pille.textContent = text;
|
||||
pille.className = `pill ${art}`;
|
||||
}
|
||||
});
|
||||
// Neu laden nur, wenn Aufträge dazugekommen sind, die in der Tabelle
|
||||
// fehlen -- und auch dann erst, wenn nichts mehr läuft. Wer zusieht,
|
||||
// soll nicht mitten im Lesen weggerissen werden.
|
||||
if (neueAuftraege && !state.running) {
|
||||
window.setTimeout(() => location.reload(), 8000);
|
||||
neueAuftraege = false;
|
||||
}
|
||||
} catch {
|
||||
/* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
|
||||
}
|
||||
window.setTimeout(poll, weiter);
|
||||
}
|
||||
poll();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import alert, pill %}
|
||||
{% from "tesm/_vnc_flaeche.html" import vnc_flaeche, vnc_status, vnc_zugangsdaten, vnc_tasten %}
|
||||
|
||||
{% block page_title %}Bildschirm: {{ device.name }}{% endblock %}
|
||||
{% block page_sub %}{{ zugang.ziel }}:{{ zugang.port }}{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('devices.overview') }}">Clients</a></span>
|
||||
<span><a href="{{ url_for('devices.detail', device_id=device.id) }}">{{ device.name }}</a></span>
|
||||
<span>Bildschirm</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="stack">
|
||||
{% if zugang.hinderungsgrund %}
|
||||
{{ alert(zugang.hinderungsgrund, 'warning') }}
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('monitor', size=17) }} {{ device.name }}
|
||||
{% if zugang.absage %}{{ pill('Verbindung abgelehnt', 'warning') }}
|
||||
{% elif zugang.verfahren %}
|
||||
{% if zugang.browsertauglich %}{{ pill('Browser-Sitzung möglich', 'online') }}
|
||||
{% else %}{{ pill('Verfahren nicht unterstützt', 'warning') }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">
|
||||
Tastatur und Maus gehen an das Gerät, sobald das Bild steht. Die Zwischenablage
|
||||
bleibt getrennt.
|
||||
{% if zugang.verfahren %}
|
||||
Zuletzt angebotene Anmeldeverfahren:
|
||||
{% for nummer in zugang.verfahren %}{{ verfahren_name(nummer) }}{% if not loop.last %}, {% endif %}{% endfor %}.
|
||||
{% else %}
|
||||
Welche Anmeldeverfahren das Gerät anbietet, ist nicht bekannt – die Seite
|
||||
fragt es nicht von sich aus.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row--tight">
|
||||
{{ vnc_tasten() }}
|
||||
{# Ausdrücklich ein Knopf und keine Nebenwirkung des Seitenaufbaus: die
|
||||
Prüfung bricht eine Protokollverhandlung ab, und RealVNC zählt das.
|
||||
Wer sucht, drückt ihn; wer nur arbeiten will, merkt nichts davon. #}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('screen.probe', device_id=device.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
title="Fragt das Gerät, welche Anmeldeverfahren es anbietet. Das ist
|
||||
eine eigene Verbindung, die abgebrochen wird – ein RealVNC Server zählt so etwas mit.
|
||||
Nicht unmittelbar vor dem Verbinden drücken.">
|
||||
Verfahren prüfen
|
||||
</button>
|
||||
</form>
|
||||
<a class="btn btn--sm btn--ghost"
|
||||
href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||
{{ icon('download', size=14) }} Verbindungsdatei
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card__body stack" id="screen" data-sitzung="{{ quelle }}">
|
||||
{{ vnc_status() }}
|
||||
{{ vnc_zugangsdaten() }}
|
||||
{{ vnc_flaeche() }}
|
||||
|
||||
{% if not darf_geheimnis and device.credential_id %}
|
||||
<p class="fs-xs faint">
|
||||
Für diesen Client sind Zugangsdaten hinterlegt. TESM gibt sie nur an Konten
|
||||
heraus, die Geheimnisse ohnehin einsehen dürfen – sonst wären sie über den
|
||||
Umweg einer Bildschirmsitzung für jeden lesbar, der sie öffnen darf. Tragen
|
||||
Sie sie oben ein; sie werden nicht gespeichert.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" src="{{ asset_url('js/screen.js') }}" nonce="{{ csp_nonce }}"></script>
|
||||
{% endblock %}
|
||||
@@ -106,6 +106,39 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('monitor', size=17) }} Bildschirm (VNC)</div>
|
||||
<div class="card__sub">
|
||||
Dieselben Regeln wie bei der Konsole: eigenes Recht, frische
|
||||
Passwortbestätigung, getrennte Zwischenablage. Der Knopf erscheint nur, wo
|
||||
ein VNC-Server antwortet – das prüft der Überwachungsdurchlauf mit.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ checkbox('vnc_enabled', 'Bildschirmzugriff (VNC) anbieten',
|
||||
checked=values.vnc_enabled,
|
||||
hint_text=specs['vnc_enabled'].hint) }}
|
||||
<div class="form-grid">
|
||||
{{ field('vnc_port', 'VNC-Port', value=values.vnc_port, type='number',
|
||||
hint_text=specs['vnc_port'].hint) }}
|
||||
{{ field('vnc_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
|
||||
value=values.vnc_idle_timeout, type='number',
|
||||
hint_text=specs['vnc_idle_timeout'].hint) }}
|
||||
</div>
|
||||
{{ checkbox('vnc_warn_unencrypted_off',
|
||||
'Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken',
|
||||
checked=values.vnc_warn_unencrypted_off,
|
||||
hint_text=specs['vnc_warn_unencrypted_off'].hint) }}
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
|
||||
@@ -22,7 +22,7 @@ from types import FrameType
|
||||
from tesm_core.extension import core
|
||||
|
||||
from . import create_app
|
||||
from .services import inventory, monitor
|
||||
from .services import inventory, monitor, vnc
|
||||
|
||||
logger = logging.getLogger("tesm.monitor")
|
||||
|
||||
@@ -82,6 +82,7 @@ class MonitorDaemon:
|
||||
keystore=self.extension.keystore,
|
||||
license_allows_auto_restart=self._auto_restart_allowed(),
|
||||
failures_before_restart=self._failures_before_restart(),
|
||||
vnc_port=vnc.port_aus_einstellungen(self.extension),
|
||||
)
|
||||
logger.info(
|
||||
"Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
||||
@@ -112,7 +113,11 @@ class MonitorDaemon:
|
||||
time.sleep(min(1.0, remaining))
|
||||
remaining -= 1.0
|
||||
|
||||
live.info("monitor", "Überwachungsprozess beendet")
|
||||
# Warnstufe, nicht INFO: dass die Ueberwachung *laeuft*, ist der
|
||||
# Normalfall und darf gruen sein. Dass sie **steht**, ist der Zustand,
|
||||
# in dem niemand mehr erfaehrt, dass ein Geraet weg ist -- und genau
|
||||
# diese Zeile sucht man hinterher im Protokoll.
|
||||
live.warning("monitor", "Überwachungsprozess beendet")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+87
-20
@@ -26,6 +26,23 @@
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
|
||||
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
|
||||
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
|
||||
# nicht erzeugt, warnt **jede neu gestartete Shell**:
|
||||
#
|
||||
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
|
||||
#
|
||||
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
|
||||
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
|
||||
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
|
||||
# klingt, liest sich schlecht.
|
||||
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
|
||||
export LC_ALL=C.UTF-8 LANG=C.UTF-8
|
||||
else
|
||||
export LC_ALL=C LANG=C
|
||||
fi
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
|
||||
@@ -39,6 +56,7 @@ HTTPS=0
|
||||
HTTPS_PORT=""
|
||||
DOMAIN=""
|
||||
ACME_EMAIL=""
|
||||
ERZWINGEN=0
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -49,6 +67,39 @@ warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
|
||||
# -- Welche Fassung liegt hier, und welche laeuft schon? ---------------------
|
||||
#
|
||||
# Ein Installationslauf auf dieselbe Fassung macht nichts besser: er baut die
|
||||
# Python-Umgebung neu, kopiert dieselben Dateien, laedt nginx neu und startet
|
||||
# den Dienst -- mit der einzigen Wirkung, dass die Anwendung ein paar Sekunden
|
||||
# weg ist. Wer "update.sh" in einen Zeitplan haengt, bekaeme das taeglich.
|
||||
#
|
||||
# Verglichen wird die Anwendung **und** der Kern: die beiden Nummern laufen
|
||||
# absichtlich auseinander (siehe pyproject.toml), und eine Auslieferung kann
|
||||
# denselben Anwendungsstand mit neuem Kern enthalten.
|
||||
|
||||
#: Die Nummer aus einer pyproject.toml. Die erste Zeile "version = ..." gilt --
|
||||
#: weitere Vorkommen stehen in Abhaengigkeiten und gehen uns nichts an.
|
||||
paket_version() {
|
||||
sed -n 's/^version = "\([^"]*\)"/\1/p' "$1" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
#: Was in dieser Installation laeuft: "<anwendung> <kern>", sonst leer.
|
||||
laufende_version() {
|
||||
local wurzel="$1" modul="$2"
|
||||
[[ -x "$wurzel/venv/bin/python" ]] || return 0
|
||||
"$wurzel/venv/bin/python" -c \
|
||||
"import $modul, tesm_core; print($modul.__version__, tesm_core.__version__)" \
|
||||
2>/dev/null || true
|
||||
}
|
||||
|
||||
#: Ist das, was hier liegt, schon installiert? Eine eigene Funktion, weil genau
|
||||
#: diese Frage sich pruefen lassen muss -- ohne einen Server aufzusetzen.
|
||||
gleiche_version() {
|
||||
local laeuft="$1" liegt="$2"
|
||||
[[ -n "$laeuft" && "$laeuft" == "$liegt" ]]
|
||||
}
|
||||
|
||||
#: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist.
|
||||
SICHERUNG_STEHT=0
|
||||
INSTALL_FERTIG=0
|
||||
@@ -129,6 +180,7 @@ Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
||||
--acme-email <a> Kontaktadresse fuer Let's Encrypt.
|
||||
--skip-packages Keine Systempakete installieren
|
||||
--yes Rueckfragen ueberspringen
|
||||
--force Auch dann einrichten, wenn dieselbe Fassung schon laeuft
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -144,6 +196,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
|
||||
--skip-packages) SKIP_PACKAGES=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
--force) ERZWINGEN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) fail "unbekannte Option: $1" ;;
|
||||
esac
|
||||
@@ -261,6 +314,23 @@ FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
|
||||
IS_UPDATE=0
|
||||
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
|
||||
|
||||
# Laeuft hier schon genau das, was hier liegt? Dann ist Schluss, bevor
|
||||
# irgendetwas angefasst wird -- und zwar mit Ausstiegscode 0: es ist nichts
|
||||
# schiefgegangen, es war nur nichts zu tun.
|
||||
MODUL="tesm"
|
||||
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
|
||||
LIEGT="$(paket_version "$REPO_DIR/apps/$APP/pyproject.toml") $(
|
||||
paket_version "$REPO_DIR/packages/tesm-core/pyproject.toml")"
|
||||
LAEUFT="$(laufende_version "$ROOT" "$MODUL")"
|
||||
if [[ $IS_UPDATE -eq 1 && $ERZWINGEN -eq 0 ]] && gleiche_version "$LAEUFT" "$LIEGT"; then
|
||||
step "Nichts zu tun"
|
||||
info "$APP ${LIEGT%% *} (tesm-core ${LIEGT##* }) laeuft hier bereits."
|
||||
info "Ein zweiter Durchlauf wuerde nur die Umgebung neu bauen und den"
|
||||
info "Dienst neu starten -- ohne dass sich etwas aendert."
|
||||
info "Trotzdem neu einrichten: --force"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
step "TESM-Installation"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Ziel : $ROOT"
|
||||
@@ -278,6 +348,7 @@ else
|
||||
info "nginx : Port $HTTP_PORT (nur HTTP)"
|
||||
fi
|
||||
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
|
||||
info "Fassung : ${LIEGT%% *} (tesm-core ${LIEGT##* })${LAEUFT:+, bisher ${LAEUFT%% *}}"
|
||||
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Fortfahren? [j/N] " answer
|
||||
@@ -364,26 +435,16 @@ info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)"
|
||||
|
||||
# -- venv --------------------------------------------------------------------
|
||||
step "Python-Umgebung"
|
||||
if [[ ! -x "$ROOT/venv/bin/python3" ]]; then
|
||||
python3 -m venv "$ROOT/venv"
|
||||
info "venv erzeugt"
|
||||
fi
|
||||
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten
|
||||
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht.
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel
|
||||
PAKETE=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
|
||||
# Die Serverhaelfte des Lizenzprotokolls -- ausstellen, erneuern, antworten --
|
||||
# gehoert ausschliesslich zum Lizenzserver. Ein Client verifiziert Lizenzen und
|
||||
# stellt Anfragen; den Code zum Ausstellen soll er nicht einmal mitbringen.
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
[[ -d "$ROOT/packages/tesm-licensing-server" ]] ||
|
||||
fail "packages/tesm-licensing-server fehlt -- ohne sie kann der Lizenzserver nichts ausstellen"
|
||||
PAKETE+=("$ROOT/packages/tesm-licensing-server")
|
||||
fi
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet "${PAKETE[@]}" "$ROOT/apps/$APP" gunicorn
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
|
||||
fi
|
||||
# Aufbauen, pruefen und reparieren steht in genau einem Programm -- und
|
||||
# dasselbe laeuft als ExecStartPre vor jedem Dienststart. Zwei getrennte
|
||||
# Paketlisten waeren zwei Listen, die auseinanderlaufen: die Reparatur baute
|
||||
# dann eine Umgebung, die nicht mehr der Installation entspricht.
|
||||
# Ueber "bash" und nicht als Programm: Dateien im Baum tragen kein
|
||||
# Ausfuehrungsrecht -- git kennt auf einer Windows-Buildmaschine keines, und
|
||||
# install.sh, tesm-helper und tesm-motd werden aus demselben Grund ebenso
|
||||
# aufgerufen oder mit ausdruecklichem Modus installiert. Ohne das brach die
|
||||
# Aktualisierung mit "Permission denied" ab (und rollte zurueck).
|
||||
bash "$ROOT/deploy/tesm-venv" "$ROOT" "$APP" --immer
|
||||
info "Abhaengigkeiten installiert"
|
||||
|
||||
# -- Statische Dateien fuer nginx --------------------------------------------
|
||||
@@ -413,6 +474,12 @@ install_if_changed() {
|
||||
install_if_changed "$ROOT/deploy/tesm-helper" /usr/local/lib/tesm/tesm-helper 0755 && \
|
||||
info "Helfer aktualisiert" || info "Helfer unveraendert"
|
||||
|
||||
# Derselbe Baustein, der oben die Umgebung gebaut hat: die Dienste rufen ihn
|
||||
# vor jedem Start auf und reparieren damit, was ein Release-Upgrade des
|
||||
# Betriebssystems ihnen unter den Fuessen weggezogen hat.
|
||||
install_if_changed "$ROOT/deploy/tesm-venv" /usr/local/lib/tesm/tesm-venv 0755 >/dev/null \
|
||||
&& info "Umgebungspfleger aktualisiert" || true
|
||||
|
||||
# visudo -c prueft die Datei, bevor sie aktiv wird -- eine kaputte sudoers-Datei
|
||||
# waere sonst ein Totalausfall der Administration.
|
||||
TMP_SUDO="$(mktemp)"
|
||||
|
||||
@@ -15,11 +15,23 @@ Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
|
||||
Environment=TESM_LICENSE_WEB_PROCESS=1
|
||||
Environment=TESM_LICENSE_COOKIE_SECURE=1
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
|
||||
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
|
||||
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
|
||||
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
|
||||
# sonst baut sie die Umgebung neu, einmal je Systemstart.
|
||||
#
|
||||
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
|
||||
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
|
||||
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm-license tesm-license
|
||||
ExecStart=/srv/tesm-license/venv/bin/gunicorn \
|
||||
--workers 1 --worker-class gthread --threads 8 \
|
||||
--bind 127.0.0.1:5001 \
|
||||
--access-logfile - --error-logfile - \
|
||||
tesm_license.wsgi:app
|
||||
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
|
||||
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
|
||||
TimeoutStartSec=900
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
|
||||
@@ -12,7 +12,19 @@ Environment=TESM_BASE_DIR=/srv/tesm
|
||||
Environment=TESM_DATA_DIR=/srv/tesm/data
|
||||
Environment=TESM_LOG_DIR=/var/log/tesm
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
|
||||
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
|
||||
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
|
||||
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
|
||||
# sonst baut sie die Umgebung neu, einmal je Systemstart.
|
||||
#
|
||||
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
|
||||
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
|
||||
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
|
||||
ExecStart=/srv/tesm/venv/bin/tesm-monitor
|
||||
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
|
||||
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
|
||||
TimeoutStartSec=900
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
|
||||
@@ -15,11 +15,23 @@ Environment=TESM_LOG_DIR=/var/log/tesm
|
||||
Environment=TESM_WEB_PROCESS=1
|
||||
Environment=TESM_COOKIE_SECURE=1
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
|
||||
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
|
||||
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
|
||||
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
|
||||
# sonst baut sie die Umgebung neu, einmal je Systemstart.
|
||||
#
|
||||
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
|
||||
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
|
||||
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
|
||||
ExecStart=/srv/tesm/venv/bin/gunicorn \
|
||||
--workers 1 --worker-class gthread --threads 16 --timeout 0 \
|
||||
--bind 127.0.0.1:5000 \
|
||||
--access-logfile - --error-logfile - \
|
||||
tesm.wsgi:app
|
||||
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
|
||||
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
|
||||
TimeoutStartSec=900
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
|
||||
@@ -100,6 +100,10 @@ check_readable_path() {
|
||||
case "$1" in
|
||||
/var/log/kea/kea-dhcp4.log) ;;
|
||||
/var/lib/kea/kea-leases4.csv) ;;
|
||||
# Die eigene Kea-Konfiguration: 0640 root:_kea, also fuer den
|
||||
# Dienstbenutzer nicht lesbar. Die Anwendung braucht sie, um zu sehen, ob
|
||||
# der Dienst ueberhaupt mit dem laeuft, was sie geschrieben hat.
|
||||
/etc/kea/kea-dhcp4.conf) ;;
|
||||
/etc/netplan/90-*.yaml)
|
||||
check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;;
|
||||
/etc/nginx/sites-available/*)
|
||||
|
||||
@@ -150,6 +150,22 @@ def main(argv: list[str] | None = None) -> int:
|
||||
globals().update(AMBER="", DIM="", BOLD="", RESET="")
|
||||
|
||||
root = Path(args.root)
|
||||
|
||||
# Gibt es diese Installation ueberhaupt noch? Wenn nicht, ist hier nichts
|
||||
# zu melden -- auch kein Fehler.
|
||||
#
|
||||
# Der Hook unter /etc/update-motd.d bleibt liegen, wenn jemand eine
|
||||
# Installation von Hand entfernt; einen Rueckbau bringt TESM nicht mit.
|
||||
# Vorher druckte jeder dieser Hooks trotzdem seinen vollen Banner und
|
||||
# darunter "Datenbank nicht lesbar". Auf dem Testserver standen so
|
||||
# vierzehn Bloecke fuer vierzehn laengst geloeschte Probeinstallationen
|
||||
# vor jeder Anmeldung -- und der echte Banner ging darin unter.
|
||||
#
|
||||
# Eine entfernte Installation ist keine kaputte. Eine Datenbank, die da
|
||||
# ist und sich nicht lesen laesst, dagegen schon: die meldet sich weiter.
|
||||
if not (root / "data" / "app.db").is_file():
|
||||
return 0
|
||||
|
||||
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
|
||||
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
|
||||
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Die Python-Umgebung einer Installation -- aufbauen, pruefen, reparieren.
|
||||
#
|
||||
# tesm-venv /srv/tesm tesm # reparieren, falls noetig (Dienststart)
|
||||
# tesm-venv /srv/tesm tesm --immer # neu bestuecken (Installation)
|
||||
# tesm-venv /srv/tesm tesm --pruefen # nur melden: 0 heil, 1 kaputt
|
||||
#
|
||||
# **Warum es das gibt.** Ein venv haengt an der Minor-Fassung von Python: die
|
||||
# Pakete liegen unter lib/python3.X. Ein Release-Upgrade des Betriebssystems
|
||||
# tauscht diese Fassung aus (Ubuntu 24.04 bringt 3.12, 26.04 bringt 3.14) --
|
||||
# und danach zeigt venv/bin/python3 weiter auf /usr/bin/python3, also auf den
|
||||
# **neuen** Interpreter. Der venv ist ausfuehrbar und trotzdem tot: er findet
|
||||
# kein einziges Paket mehr, nicht einmal pip. Jeder Dienst scheitert mit
|
||||
# "ModuleNotFoundError: No module named 'gunicorn'", nginx meldet 502.
|
||||
#
|
||||
# Real passiert, auf beiden Testservern, am 16.09.2026.
|
||||
#
|
||||
# Dieses Programm laeuft deshalb als ExecStartPre vor jedem Dienststart. Ist
|
||||
# alles heil, endet es nach Millisekunden. Sonst baut es die Umgebung neu --
|
||||
# aus denselben Paketen, die auch die Installation einspielt, denn es ist
|
||||
# dasselbe Programm: install.sh ruft es mit --immer auf. Zwei getrennte Listen
|
||||
# waeren zwei Listen, die auseinanderlaufen.
|
||||
#
|
||||
# **Was es nicht tut:** die alten Pakete unter lib/python3.X uebernehmen.
|
||||
# cryptography, argon2-cffi und bcrypt sind kompiliert und fuer die alte
|
||||
# Fassung gebaut; ein halb uebernommener venv waere schlimmer als ein neuer.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# **Der venv muss fuer den Dienstbenutzer lesbar sein.**
|
||||
#
|
||||
# Dieses Programm laeuft als ExecStartPre der Dienst-Unit -- und erbt damit
|
||||
# deren UMask=0027. Alles, was es anlegt, gehoerte root und waere 0750: der
|
||||
# Dienst selbst laeuft unprivilegiert und koennte sein eigenes site-packages
|
||||
# nicht einmal betreten. Gemessen: nach der Reparatur ging "import gunicorn"
|
||||
# als root, als Benutzer tesm nicht -- die Reparatur baute etwas, das genau
|
||||
# der Prozess nicht benutzen konnte, fuer den sie gedacht war.
|
||||
#
|
||||
# Im venv liegt nichts Geheimes; Schluessel und Datenbank stehen woanders.
|
||||
umask 022
|
||||
|
||||
ROOT="${1:-}"
|
||||
APP="${2:-}"
|
||||
MODUS="reparieren"
|
||||
[[ "${3:-}" == "--immer" ]] && MODUS="immer"
|
||||
[[ "${3:-}" == "--pruefen" ]] && MODUS="pruefen"
|
||||
|
||||
[[ -n "$ROOT" && -n "$APP" ]] || {
|
||||
echo "Aufruf: tesm-venv <verzeichnis> <tesm|tesm-license> [--immer|--pruefen]" >&2
|
||||
exit 2
|
||||
}
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "tesm-venv: unbekannte Anwendung '$APP'" >&2
|
||||
exit 2
|
||||
}
|
||||
[[ -d "$ROOT" ]] || { echo "tesm-venv: $ROOT gibt es nicht" >&2; exit 2; }
|
||||
|
||||
NAME="$(basename "$ROOT")"
|
||||
VENV="$ROOT/venv"
|
||||
PY="$VENV/bin/python3"
|
||||
MODUL="tesm"; [[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
|
||||
LOG_DIR="${TESM_LOG_DIR:-/var/log/$NAME}"
|
||||
#: Ein Versuch je Systemstart. /run ist ein tmpfs, der Merker verschwindet
|
||||
#: also beim naechsten Start von selbst. Ohne ihn wuerde "Restart=always" nach
|
||||
#: einem misslungenen Neuaufbau (kein Netz, kein PyPI) alle fuenf Sekunden
|
||||
#: einen neuen pip-Lauf starten -- gegen einen Server, der ohnehin gerade
|
||||
#: nicht erreichbar ist.
|
||||
MERKER="/run/tesm-venv-$NAME.versucht"
|
||||
|
||||
melden() {
|
||||
local stufe="$1" text="$2"
|
||||
# Ins Journal: ExecStartPre-Ausgabe landet dort ohnehin.
|
||||
printf 'tesm-venv: %s\n' "$text" >&2
|
||||
# Und ins laufende Protokoll, damit es in der Oberflaeche sichtbar ist.
|
||||
# Fehlschlaege hier duerfen den Start nicht kosten -- ein Protokoll ist
|
||||
# kein Grund, einen Dienst nicht zu starten.
|
||||
if [[ -d "$LOG_DIR" ]]; then
|
||||
printf '%s %-7s %-10s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$stufe" "venv" "$text" \
|
||||
>> "$LOG_DIR/live.log" 2>/dev/null || true
|
||||
chown "$APP:$APP" "$LOG_DIR/live.log" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
#: Traegt diese Umgebung? Gefragt wird nicht nach der Versionsnummer, sondern
|
||||
#: nach dem, was gebraucht wird -- so faellt auch ein venv auf, dem jemand ein
|
||||
#: Paket herausgeloescht hat.
|
||||
traegt() {
|
||||
[[ -x "$PY" ]] || return 1
|
||||
"$PY" -c "import pip" >/dev/null 2>&1 || return 1
|
||||
# Beim allerersten Lauf ist die Anwendung noch nicht eingespielt -- dann
|
||||
# zaehlt allein, dass pip benutzbar ist.
|
||||
[[ "$MODUS" == "immer" ]] && return 0
|
||||
"$PY" -c "import $MODUL" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
bestuecken() {
|
||||
# **Jeder pip-Lauf wird einzeln geprueft.**
|
||||
#
|
||||
# "set -e" hilft hier nicht: diese Funktion wird in einer Bedingung
|
||||
# aufgerufen ("if ! bestuecken"), und darin ist errexit fuer den **ganzen**
|
||||
# Rumpf abgeschaltet. Ein gescheitertes pip lief deshalb einfach weiter bis
|
||||
# zum "return 0" -- der Lauf meldete "Fertig", und im venv lagen weiter die
|
||||
# alten Pakete.
|
||||
#
|
||||
# Real passiert: der Lizenzserver blieb ueber drei Auslieferungen hinweg auf
|
||||
# 2.8.16 stehen, weil eine Abhaengigkeit nicht aufloesbar war. Gesehen hat es
|
||||
# niemand, denn die Fehlerzeile von pip ging im Lauf unter und der
|
||||
# Ausstiegscode war 0.
|
||||
"$PY" -m pip install --quiet --upgrade pip setuptools wheel || return 1
|
||||
local pakete=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
[[ -d "$ROOT/packages/tesm-licensing-server" ]] || {
|
||||
echo "tesm-venv: packages/tesm-licensing-server fehlt" >&2
|
||||
return 1
|
||||
}
|
||||
pakete+=("$ROOT/packages/tesm-licensing-server")
|
||||
fi
|
||||
"$PY" -m pip install --quiet "${pakete[@]}" "$ROOT/apps/$APP" gunicorn || return 1
|
||||
# Die WebSocket-Bruecke braucht nur TESM (Terminal und Bildschirm).
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
"$PY" -m pip install --quiet flask-sock || return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
if [[ "$MODUS" == "pruefen" ]]; then
|
||||
traegt && exit 0 || exit 1
|
||||
fi
|
||||
|
||||
if traegt && [[ "$MODUS" != "immer" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$MODUS" != "immer" && -e "$MERKER" ]]; then
|
||||
melden "WARNUNG" \
|
||||
"Die Python-Umgebung von $NAME ist unbrauchbar, und der Neuaufbau ist seit dem letzten Systemstart schon einmal gescheitert. Erneuter Versuch: 'bash $ROOT/deploy/install.sh --app $APP --yes'."
|
||||
exit 1
|
||||
fi
|
||||
touch "$MERKER" 2>/dev/null || true
|
||||
|
||||
if ! [[ -x "$PY" ]] || ! "$PY" -c "import pip" >/dev/null 2>&1; then
|
||||
# Ohne --clear: ein erneutes "venv" ergaenzt das fehlende lib-Verzeichnis
|
||||
# und holt pip zurueck. Gemessen, nicht angenommen.
|
||||
python3 -m venv "$VENV"
|
||||
[[ "$MODUS" == "immer" ]] ||
|
||||
melden "HINWEIS" \
|
||||
"Python-Umgebung von $NAME neu aufgebaut ($(python3 -V 2>&1)) -- das System hat seine Python-Fassung gewechselt."
|
||||
fi
|
||||
|
||||
if ! bestuecken; then
|
||||
melden "FEHLER" \
|
||||
"Die Pakete fuer $NAME liessen sich nicht einspielen. Hat dieser Server Zugriff auf das Paketverzeichnis?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Und noch einmal ausdruecklich, fuer den Fall, dass ein frueherer Lauf mit
|
||||
# strengerer Maske gebaut hat: was hier liegt, muss lesbar sein. Idempotent
|
||||
# und in einer Sekunde durch.
|
||||
chmod -R a+rX "$VENV" 2>/dev/null || true
|
||||
|
||||
# Vertrauen ist gut: nach dem Einspielen muss die Anwendung auch importierbar
|
||||
# sein. Ohne diese Probe hiesse "Fertig" nur, dass pip nicht geschimpft hat.
|
||||
if ! "$PY" -c "import $MODUL" >/dev/null 2>&1; then
|
||||
melden "FEHLER" "Die Pakete fuer $NAME wurden eingespielt, aber '$MODUL' laesst sich nicht importieren."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[[ "$MODUS" == "immer" ]] || melden "INFO" "Python-Umgebung von $NAME ist wieder vollstaendig."
|
||||
rm -f "$MERKER" 2>/dev/null || true
|
||||
exit 0
|
||||
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Rueckbau einer Installation von TESM oder TESM-Lizenzserver.
|
||||
#
|
||||
# Aufruf:
|
||||
# sudo bash deploy/uninstall.sh --app tesm
|
||||
# sudo bash deploy/uninstall.sh --app tesm-license --instance test
|
||||
# sudo bash deploy/uninstall.sh --app tesm --dry-run
|
||||
#
|
||||
# Das genaue Gegenstueck zu install.sh -- und es fehlte. Wer eine Installation
|
||||
# von Hand loeschte ("rm -rf /srv/tesm-probe"), liess zurueck: die systemd-Unit
|
||||
# (die systemd danach in einer Dauerschleife neu zu starten versuchte), den
|
||||
# Begruessungshaken (der bei jeder Anmeldung seinen Banner druckte), den
|
||||
# Shim unter /usr/local/bin, die Umgebungsdatei, die nginx-Site und den
|
||||
# Dienstbenutzer. Auf dem Testserver standen so neun tote Dienste und vierzehn
|
||||
# Banner. Genau das raeumt dieses Skript ab.
|
||||
#
|
||||
# Zwei Regeln, die den Unterschied machen:
|
||||
#
|
||||
# * **Daten bleiben.** Datenbank, Schluessel und Lizenz werden vorher in ein
|
||||
# Archiv gelegt, nicht geloescht. Wer sie wirklich los sein will, sagt es
|
||||
# mit --purge. Ein Rueckbau, der beilaeufig die Datenbank mitnimmt, ist
|
||||
# keiner, sondern ein Unfall.
|
||||
# * **Geteiltes bleibt, solange es jemand braucht.** sudoers-Datei und
|
||||
# Dienstbenutzer gehoeren der *Anwendung* (nicht der Instanz), der Helfer
|
||||
# unter /usr/local/lib/tesm allen Installationen beider Anwendungen. Sie
|
||||
# verschwinden erst, wenn die letzte geht -- sonst legt der Rueckbau einer
|
||||
# Testinstanz die Produktion daneben lahm.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
|
||||
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
|
||||
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
|
||||
# nicht erzeugt, warnt **jede neu gestartete Shell**:
|
||||
#
|
||||
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
|
||||
#
|
||||
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
|
||||
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
|
||||
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
|
||||
# klingt, liest sich schlecht.
|
||||
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
|
||||
export LC_ALL=C.UTF-8 LANG=C.UTF-8
|
||||
else
|
||||
export LC_ALL=C LANG=C
|
||||
fi
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
PURGE=0
|
||||
ASSUME_YES=0
|
||||
DRY_RUN=0
|
||||
|
||||
#: Ueberschreibbar, damit sich die Entscheidungen dieses Skripts in einem Test
|
||||
#: wirklich ausfuehren lassen -- und nicht nur als Textmuster geprueft werden.
|
||||
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
|
||||
SRV_DIR="${TESM_SRV_DIR:-/srv}"
|
||||
UNIT_DIR="${TESM_SYSTEMD_DIR:-/etc/systemd/system}"
|
||||
MOTD_DIR="${TESM_MOTD_DIR:-/etc/update-motd.d}"
|
||||
BIN_DIR="${TESM_BIN_DIR:-/usr/local/bin}"
|
||||
LIB_DIR="${TESM_LIB_DIR:-/usr/local/lib/tesm}"
|
||||
SUDOERS_DIR="${TESM_SUDOERS_DIR:-/etc/sudoers.d}"
|
||||
LOGROTATE_DIR="${TESM_LOGROTATE_DIR:-/etc/logrotate.d}"
|
||||
NGINX_DIR="${TESM_NGINX_DIR:-/etc/nginx}"
|
||||
LOG_BASE="${TESM_LOG_BASE:-/var/log}"
|
||||
|
||||
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
|
||||
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
|
||||
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*" ; }
|
||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
cat <<'TEXT'
|
||||
Rueckbau einer Installation.
|
||||
|
||||
--app <tesm|tesm-license> welche Anwendung (Pflicht)
|
||||
--instance <name> benannte Instanz (sonst die Hauptinstallation)
|
||||
--purge Daten nicht sichern, sondern loeschen
|
||||
--dry-run nur zeigen, was geschehen wuerde
|
||||
--yes, -y nicht nachfragen
|
||||
TEXT
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--app) APP="${2:?}"; shift 2 ;;
|
||||
--instance) INSTANCE="${2:?}"; shift 2 ;;
|
||||
--purge) PURGE=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) fail "unbekannte Option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$APP" ]] || { usage; fail "--app fehlt"; }
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || fail "--app muss tesm oder tesm-license sein"
|
||||
[[ -z "$INSTANCE" || "$INSTANCE" =~ ^[a-z0-9-]{1,16}$ ]] || fail "ungueltiger Instanzname"
|
||||
# Root braucht es wegen /srv, /etc und systemd. Wer die Pfade umbiegt (Test),
|
||||
# fasst nichts davon an -- dann ist die Huerde keine Sicherheit, sondern nur im
|
||||
# Weg, und ein Rueckbau, der sich nicht pruefen laesst, ist ein Risiko fuer sich.
|
||||
if [[ $DRY_RUN -eq 0 && "$SRV_DIR" == "/srv" && "$(id -u)" -ne 0 ]]; then
|
||||
fail "bitte mit sudo ausfuehren"
|
||||
fi
|
||||
|
||||
NAME="$APP"
|
||||
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||
ROOT="$SRV_DIR/$NAME"
|
||||
|
||||
# Das Datenverzeichnis kann verschoben sein -- dann steht es in der
|
||||
# Umgebungsdatei, und nur dort. Ein Rueckbau, der stattdessen "$ROOT/data"
|
||||
# annimmt, sichert ein leeres Verzeichnis und laesst die echte Datenbank liegen.
|
||||
ENV_FILE="$ETC_TESM/$NAME.env"
|
||||
DATEN_DIR="$(sed -n 's/^TESM_DATA_DIR=//p' "$ENV_FILE" 2>/dev/null | tail -1 || true)"
|
||||
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Wer teilt sich was?
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
#: Gibt es neben dieser noch eine weitere Installation **derselben** Anwendung?
|
||||
#: Daran haengen sudoers-Datei und Dienstbenutzer -- beide tragen den Namen der
|
||||
#: Anwendung, nicht den der Instanz.
|
||||
weitere_dieser_anwendung() {
|
||||
local eintrag name
|
||||
for eintrag in "$ETC_TESM/$APP.env" "$ETC_TESM/$APP-"*.env; do
|
||||
[[ -f "$eintrag" ]] || continue
|
||||
name="$(basename "$eintrag" .env)"
|
||||
[[ "$name" == "$NAME" ]] && continue
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
#: Gibt es ueberhaupt noch irgendeine Installation? Daran haengen der Helfer
|
||||
#: und das Banner-Programm unter /usr/local/lib/tesm -- die gehoeren allen.
|
||||
weitere_ueberhaupt() {
|
||||
local eintrag name
|
||||
for eintrag in "$ETC_TESM"/*.env; do
|
||||
[[ -f "$eintrag" ]] || continue
|
||||
name="$(basename "$eintrag" .env)"
|
||||
[[ "$name" == "$NAME" ]] && continue
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Der Plan -- erst zeigen, dann tun
|
||||
# --------------------------------------------------------------------------- #
|
||||
UNITS=("$NAME.service" "$NAME-monitor.service" "$NAME-cleanup.service" "$NAME-cleanup.timer")
|
||||
DATEIEN=(
|
||||
"$NGINX_DIR/sites-enabled/$NAME"
|
||||
"$NGINX_DIR/sites-available/$NAME"
|
||||
"$MOTD_DIR/50-$NAME"
|
||||
"$BIN_DIR/$NAME-admin"
|
||||
"$LOGROTATE_DIR/$NAME"
|
||||
"$ENV_FILE"
|
||||
)
|
||||
VERZEICHNISSE=("$ETC_TESM/certs/$NAME" "$LOG_BASE/$NAME")
|
||||
|
||||
gefunden=0
|
||||
step "Rueckbau von $NAME"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Verzeichnis : $ROOT"
|
||||
|
||||
echo
|
||||
echo " Dienste:"
|
||||
for unit in "${UNITS[@]}"; do
|
||||
if [[ -f "$UNIT_DIR/$unit" ]]; then info "- $unit"; gefunden=1; fi
|
||||
done
|
||||
[[ $gefunden -eq 1 ]] || info "(keine)"
|
||||
|
||||
echo
|
||||
echo " Dateien und Verzeichnisse:"
|
||||
for pfad in "${DATEIEN[@]}" "${VERZEICHNISSE[@]}"; do
|
||||
if [[ -e "$pfad" ]]; then info "- $pfad"; gefunden=1; fi
|
||||
done
|
||||
if [[ -d "$ROOT" ]]; then info "- $ROOT"; gefunden=1; fi
|
||||
|
||||
if [[ $gefunden -eq 0 ]]; then
|
||||
echo
|
||||
info "Von '$NAME' ist nichts mehr da -- nichts zu tun."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo " Daten (Datenbank, Schluessel, Lizenz):"
|
||||
if [[ -d "$DATEN_DIR" ]]; then
|
||||
if [[ $PURGE -eq 1 ]]; then
|
||||
warn "$DATEN_DIR wird GELOESCHT (--purge)"
|
||||
else
|
||||
info "$DATEN_DIR wird vorher gesichert"
|
||||
fi
|
||||
else
|
||||
info "(kein Datenverzeichnis gefunden)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo " Bleibt stehen:"
|
||||
if weitere_dieser_anwendung; then
|
||||
info "sudoers und Dienstbenutzer '$APP' -- eine weitere Installation braucht sie"
|
||||
else
|
||||
info "- (sudoers und Dienstbenutzer '$APP' werden entfernt)"
|
||||
fi
|
||||
if weitere_ueberhaupt; then
|
||||
info "Helfer unter $LIB_DIR -- andere Installationen brauchen ihn"
|
||||
else
|
||||
info "- (Helfer unter $LIB_DIR wird entfernt)"
|
||||
fi
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
echo
|
||||
info "Probelauf -- es wurde nichts geaendert."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
echo
|
||||
read -r -p "Fortfahren? [j/N] " antwort
|
||||
[[ "$antwort" =~ ^[jJyY]$ ]] || fail "abgebrochen"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Ausfuehren
|
||||
# --------------------------------------------------------------------------- #
|
||||
step "Daten"
|
||||
if [[ -d "$DATEN_DIR" && $PURGE -eq 0 ]]; then
|
||||
ARCHIV="$SRV_DIR/$NAME-daten-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||
tar -czf "$ARCHIV" -C "$(dirname "$DATEN_DIR")" "$(basename "$DATEN_DIR")"
|
||||
chmod 600 "$ARCHIV"
|
||||
info "gesichert: $ARCHIV"
|
||||
elif [[ -d "$DATEN_DIR" ]]; then
|
||||
info "wird mit dem Verzeichnis geloescht (--purge)"
|
||||
else
|
||||
info "nichts zu sichern"
|
||||
fi
|
||||
|
||||
step "Dienste"
|
||||
for unit in "${UNITS[@]}"; do
|
||||
[[ -f "$UNIT_DIR/$unit" ]] || continue
|
||||
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||
rm -f "$UNIT_DIR/$unit"
|
||||
# Ohne das bleibt eine entfernte Unit als "not-found" im Fehlerzustand
|
||||
# stehen und faerbt "systemctl --failed" rot.
|
||||
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
|
||||
info "entfernt: $unit"
|
||||
done
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
|
||||
step "Webserver"
|
||||
if [[ -e "$NGINX_DIR/sites-enabled/$NAME" || -e "$NGINX_DIR/sites-available/$NAME" ]]; then
|
||||
rm -f "$NGINX_DIR/sites-enabled/$NAME" "$NGINX_DIR/sites-available/$NAME"
|
||||
# Drei Faelle, und sie bedeuten Verschiedenes: kein nginx da (dann gibt es
|
||||
# nichts neu zu laden), nginx da und zufrieden, nginx da und unzufrieden.
|
||||
# Sie in einen Zweig zu werfen hiesse, auf einer Maschine ohne nginx eine
|
||||
# Warnung ueber eine kaputte Konfiguration auszugeben, die es nicht gibt.
|
||||
if ! command -v nginx >/dev/null 2>&1; then
|
||||
info "Site entfernt (nginx ist hier nicht installiert)"
|
||||
elif nginx -t >/dev/null 2>&1; then
|
||||
systemctl reload nginx >/dev/null 2>&1 || true
|
||||
info "Site entfernt, nginx neu geladen"
|
||||
else
|
||||
warn "Site entfernt -- nginx beanstandet die Konfiguration, bitte 'nginx -t' ansehen"
|
||||
fi
|
||||
else
|
||||
info "keine Site vorhanden"
|
||||
fi
|
||||
|
||||
step "Dateien"
|
||||
for pfad in "${DATEIEN[@]}"; do
|
||||
[[ -e "$pfad" ]] && { rm -f "$pfad"; info "entfernt: $pfad"; }
|
||||
done
|
||||
for pfad in "${VERZEICHNISSE[@]}"; do
|
||||
[[ -d "$pfad" ]] && { rm -rf "$pfad"; info "entfernt: $pfad"; }
|
||||
done
|
||||
if [[ -d "$ROOT" ]]; then
|
||||
rm -rf "$ROOT"
|
||||
info "entfernt: $ROOT"
|
||||
fi
|
||||
if [[ $PURGE -eq 1 ]]; then
|
||||
# Die Rueckwege aelterer Aktualisierungen. Sie enthalten dieselben Daten wie
|
||||
# die Installation -- deshalb nur hier, nie im Normalfall.
|
||||
for rest in "$SRV_DIR/$NAME-backup-"* "$SRV_DIR/$NAME-failed-"*; do
|
||||
[[ -d "$rest" ]] && { rm -rf "$rest"; info "entfernt: $rest"; }
|
||||
done
|
||||
fi
|
||||
|
||||
step "Geteiltes"
|
||||
if weitere_dieser_anwendung; then
|
||||
info "sudoers und Dienstbenutzer '$APP' bleiben -- eine weitere Installation braucht sie"
|
||||
else
|
||||
rm -f "$SUDOERS_DIR/$APP"
|
||||
info "entfernt: $SUDOERS_DIR/$APP"
|
||||
if getent passwd "$APP" >/dev/null 2>&1; then
|
||||
# Ein laufender Prozess haelt den Benutzer fest. Das ist kein Grund,
|
||||
# den Rueckbau scheitern zu lassen -- der Dienst ist oben schon gestoppt,
|
||||
# und was sonst noch unter diesem Namen laeuft, gehoert angesehen.
|
||||
if userdel "$APP" >/dev/null 2>&1; then
|
||||
info "entfernt: Dienstbenutzer $APP"
|
||||
else
|
||||
warn "Dienstbenutzer $APP liess sich nicht entfernen -- laeuft noch etwas unter ihm?"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if weitere_ueberhaupt; then
|
||||
info "Helfer unter $LIB_DIR bleibt -- andere Installationen brauchen ihn"
|
||||
else
|
||||
rm -rf "$LIB_DIR"
|
||||
rmdir "$ETC_TESM/certs" "$ETC_TESM" >/dev/null 2>&1 || true
|
||||
info "entfernt: $LIB_DIR"
|
||||
fi
|
||||
|
||||
step "Fertig"
|
||||
info "'$NAME' ist zurueckgebaut."
|
||||
if [[ $PURGE -eq 0 && -n "${ARCHIV:-}" ]]; then
|
||||
info "Die Daten liegen unter $ARCHIV -- dort und sonst nirgends."
|
||||
fi
|
||||
@@ -18,6 +18,23 @@
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
|
||||
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
|
||||
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
|
||||
# nicht erzeugt, warnt **jede neu gestartete Shell**:
|
||||
#
|
||||
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
|
||||
#
|
||||
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
|
||||
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
|
||||
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
|
||||
# klingt, liest sich schlecht.
|
||||
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
|
||||
export LC_ALL=C.UTF-8 LANG=C.UTF-8
|
||||
else
|
||||
export LC_ALL=C LANG=C
|
||||
fi
|
||||
|
||||
APP="${1:-}"
|
||||
TAG="${2:-latest}"
|
||||
|
||||
@@ -33,6 +50,8 @@ EXTRA=("$@")
|
||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||
echo "Beispiel : update.sh tesm latest --instance test" >&2
|
||||
echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2
|
||||
echo "Laeuft die Fassung schon, endet der Aufruf ohne Arbeit; --force setzt" >&2
|
||||
echo "sie trotzdem neu auf." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
@@ -173,6 +192,32 @@ release_holen() {
|
||||
|
||||
release_holen
|
||||
|
||||
# Schon installiert? Dann gar nicht erst fragen.
|
||||
#
|
||||
# Dieselbe Pruefung steht in install.sh und gilt dort endgueltig (sie
|
||||
# vergleicht auch den Kern). Hier steht sie noch einmal, weil ein Update, das
|
||||
# ohne Wirkung bleibt, gar nicht erst nach einer Bestaetigung fragen soll --
|
||||
# und weil "es lief durch" sonst wie eine Aktualisierung aussieht.
|
||||
ERZWINGEN=0
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--force" ]] && ERZWINGEN=1
|
||||
done
|
||||
INSTALLATION="$APP"
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--instance" ]] && INSTALLATION="$APP-${EXTRA[i + 1]:-}"
|
||||
done
|
||||
MODUL="tesm"
|
||||
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
|
||||
if [[ $ERZWINGEN -eq 0 && -x "/srv/$INSTALLATION/venv/bin/python" ]]; then
|
||||
LAEUFT="$("/srv/$INSTALLATION/venv/bin/python" -c \
|
||||
"import $MODUL; print($MODUL.__version__)" 2>/dev/null || true)"
|
||||
if [[ -n "$LAEUFT" && "v$LAEUFT" == "$TAG" ]]; then
|
||||
echo "==> $APP $LAEUFT laeuft hier bereits -- nichts zu tun."
|
||||
echo " Trotzdem neu einrichten: --force"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es
|
||||
# bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht.
|
||||
OHNE_RUECKFRAGE=0
|
||||
|
||||
@@ -120,6 +120,14 @@ sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
sudo bash /srv/tesm-license/deploy/update.sh tesm-license
|
||||
```
|
||||
|
||||
**Läuft die Fassung schon, passiert nichts.** `update.sh` vergleicht den
|
||||
aufgelösten Tag mit dem, was installiert ist, und endet mit `0`, bevor es eine
|
||||
Rückfrage stellt; `install.sh` prüft es noch einmal genauer (Anwendung **und**
|
||||
Kern) und bricht vor dem ersten Eingriff ab. Ein zweiter Lauf auf dieselbe
|
||||
Fassung baut sonst die Python-Umgebung neu, kopiert dieselben Dateien und
|
||||
startet den Dienst neu -- ein paar Sekunden Ausfall ohne jede Wirkung. Wer das
|
||||
trotzdem will (eine beschädigte Installation etwa), hängt `--force` an.
|
||||
|
||||
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
||||
eine **benannte Instanz** zu aktualisieren:
|
||||
|
||||
@@ -375,6 +383,87 @@ sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
|
||||
---
|
||||
|
||||
## 4bb. Release-Upgrade des Betriebssystems
|
||||
|
||||
Ein `do-release-upgrade` tauscht die Minor-Fassung von Python aus (24.04 bringt
|
||||
3.12, 26.04 bringt 3.14). Der venv einer Installation hängt daran: seine Pakete
|
||||
liegen unter `lib/python3.12`, während `venv/bin/python3` auf `/usr/bin/python3`
|
||||
zeigt — also nach dem Upgrade auf den **neuen** Interpreter. Er ist ausführbar
|
||||
und trotzdem tot; jeder Dienst scheitert mit `No module named gunicorn`, nginx
|
||||
meldet 502.
|
||||
|
||||
**Das repariert TESM seit 2.8.33 von selbst.** Vor jedem Dienststart läuft
|
||||
|
||||
```
|
||||
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
|
||||
```
|
||||
|
||||
Ist die Umgebung heil, kostet das Millisekunden. Sonst wird sie neu gebaut und
|
||||
mit denselben Paketen bestückt, die auch die Installation einspielt — es ist
|
||||
dasselbe Programm, `install.sh` ruft es mit `--immer` auf. Nach dem Neustart,
|
||||
der auf jedes Release-Upgrade folgt, kommt der Dienst also von allein zurück;
|
||||
im Live-Protokoll steht dann eine Zeile als Hinweis.
|
||||
|
||||
Von Hand prüfen oder anstoßen:
|
||||
|
||||
```bash
|
||||
/usr/local/lib/tesm/tesm-venv /srv/tesm tesm --pruefen # 0 heil, 1 kaputt
|
||||
/usr/local/lib/tesm/tesm-venv /srv/tesm tesm # reparieren
|
||||
```
|
||||
|
||||
**Grenzen.** Der Neuaufbau lädt Pakete und braucht Zugriff auf das
|
||||
Paketverzeichnis. Ohne Netz kann er nicht gelingen: dann wird **ein** Versuch je
|
||||
Systemstart unternommen (Merker unter `/run`), und es bleibt bei einer klaren
|
||||
Zeile im Journal und im Live-Protokoll. Ohne diese Bremse startete
|
||||
`Restart=always` alle fünf Sekunden einen neuen `pip`-Lauf.
|
||||
|
||||
Die alten Pakete unter `lib/python3.<alt>` werden nicht übernommen —
|
||||
`cryptography`, `argon2-cffi` und `bcrypt` sind kompiliert und für die alte
|
||||
Fassung gebaut.
|
||||
|
||||
Empfohlene Reihenfolge bleibt trotzdem: erst TESM aktualisieren, dann
|
||||
`do-release-upgrade`, dann prüfen.
|
||||
|
||||
---
|
||||
|
||||
## 4c. Installation entfernen
|
||||
|
||||
Das Gegenstück zu `install.sh`:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm --dry-run
|
||||
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm
|
||||
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm-license --instance test --yes
|
||||
```
|
||||
|
||||
Entfernt werden Dienste (`.service`, `-monitor`, `-cleanup` samt Timer), die
|
||||
nginx-Site, der Begrüßungshaken, der Shim unter `/usr/local/bin`, die
|
||||
Umgebungsdatei, das Zertifikatsverzeichnis, das Protokollverzeichnis und die
|
||||
Installation selbst.
|
||||
|
||||
**Die Daten bleiben.** Datenbank, Schlüssel und Lizenz gehen vorher in ein
|
||||
Archiv (`/srv/<name>-daten-<zeit>.tar.gz`, nur root). Sie sind das einzige an
|
||||
einer Installation, was sich nicht wiederherstellen lässt. Wer sie wirklich
|
||||
los sein will, nimmt `--purge` — das löscht zusätzlich die Rückwege älterer
|
||||
Aktualisierungen (`/srv/<name>-backup-*`).
|
||||
|
||||
**Geteiltes bleibt, solange es jemand braucht.** Die sudoers-Datei und der
|
||||
Dienstbenutzer tragen den Namen der *Anwendung*, nicht den der Instanz; der
|
||||
Helfer unter `/usr/local/lib/tesm` gehört allen Installationen beider
|
||||
Anwendungen. Sie verschwinden erst mit der letzten. Sonst legte der Rückbau
|
||||
einer Testinstanz die Produktion daneben lahm.
|
||||
|
||||
`--dry-run` zeigt den Plan, ohne etwas zu ändern. Ein zweiter Lauf auf eine
|
||||
bereits entfernte Installation sagt das und endet mit 0.
|
||||
|
||||
Warum es das gibt: wer eine Installation von Hand löscht (`rm -rf /srv/…`),
|
||||
lässt Unit, Begrüßungshaken, Shim, sudoers-Datei und nginx-Site zurück.
|
||||
systemd versucht die Unit danach in einer Dauerschleife zu starten, und der
|
||||
Haken druckt bei jeder Anmeldung seinen Banner. Auf dem Testserver standen so
|
||||
neun tote Dienste und vierzehn Banner.
|
||||
|
||||
---
|
||||
|
||||
## 5. Dienste
|
||||
|
||||
```bash
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.8.3"
|
||||
version = "2.8.21"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -266,6 +266,35 @@ def _bitte_bestaetigen(permission: str) -> Any:
|
||||
return redirect(url_for("auth.reauth"))
|
||||
|
||||
|
||||
def _heikel(permissions: tuple[str, ...], mode: str) -> str:
|
||||
"""Welches Recht verlangt hier eine frische Bestaetigung? Sonst leer.
|
||||
|
||||
Bei ``mode="all"`` ist die Antwort einfach: jedes genannte Recht wird
|
||||
gebraucht, also zaehlt jedes heikle darunter.
|
||||
|
||||
Bei ``mode="any"`` nicht. Diese Seiten nennen mehrere **unabhaengige**
|
||||
Wege hinein, und welcher genommen wird, entscheidet das Konto. Der
|
||||
Protokollverlauf etwa nennt "logs_history.read" und "logs_history.export":
|
||||
lesen ist harmlos, mitnehmen nicht. Gezaehlt wurde aber ueber alle
|
||||
genannten Rechte -- und so verlangte das blosse **Ansehen** des Verlaufs
|
||||
nach fuenfzehn Minuten eine Passworteingabe, mit dem Hinweis, man greife
|
||||
auf einen Datenexport zu. Das stimmte nicht, und wer es dreimal liest,
|
||||
glaubt die Meldung beim vierten Mal auch dann nicht, wenn sie zutrifft.
|
||||
|
||||
Gefragt wird deshalb: haette dieses Konto die Seite auch ohne das heikle
|
||||
Recht geoeffnet? Dann gibt es nichts zu bestaetigen. Die Ausfuhr selbst
|
||||
haengt an ihrer eigenen Route, und die nennt nur ein Recht.
|
||||
"""
|
||||
heikle = [p for p in permissions if _verlangt_wiederanmeldung(p)]
|
||||
if not heikle:
|
||||
return ""
|
||||
if mode == "any":
|
||||
harmlos = [p for p in permissions if p not in heikle]
|
||||
if any(current_user.has(p) for p in harmlos):
|
||||
return ""
|
||||
return heikle[0]
|
||||
|
||||
|
||||
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
||||
"""Erzwingt Rechte serverseitig.
|
||||
|
||||
@@ -289,9 +318,9 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
|
||||
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
|
||||
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
|
||||
# sie per Mail zu verschicken nicht.
|
||||
heikel = [p for p in permissions if _verlangt_wiederanmeldung(p)]
|
||||
heikel = _heikel(permissions, mode)
|
||||
if heikel and not _wiederanmeldung_frisch():
|
||||
return _bitte_bestaetigen(heikel[0])
|
||||
return _bitte_bestaetigen(heikel)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
||||
|
||||
@@ -42,6 +42,8 @@ def _config_from_settings(extension: Any, conn: Any) -> webserver.WebConfig:
|
||||
],
|
||||
)
|
||||
return webserver.WebConfig(
|
||||
# Die neue Schreibweise, sobald der Webserver sie kennt.
|
||||
http2_directive=webserver.nginx_kann_http2(),
|
||||
app_key=extension.config.site_key,
|
||||
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
|
||||
static_root=str(
|
||||
|
||||
@@ -162,6 +162,67 @@ class LicenseState:
|
||||
return dict(self.status.vendor) if self.status else {}
|
||||
|
||||
|
||||
#: Ausstiegscodes der Abfrage -- danach schlaegt ein RMM Alarm, nicht nach Text.
|
||||
ABFRAGE_OK = 0
|
||||
ABFRAGE_LAEUFT_AB = 1
|
||||
ABFRAGE_UNGUELTIG = 2
|
||||
|
||||
#: Ab hier gilt eine Lizenz als "laeuft bald ab". Dieselbe Zahl wie in der
|
||||
#: Oberflaeche -- zwei Schwellen fuer dieselbe Frage waeren eine zu viel.
|
||||
ABFRAGE_WARNSCHWELLE_TAGE = 30
|
||||
|
||||
|
||||
def zustand_als_auskunft(state: "LicenseState") -> dict[str, Any]:
|
||||
"""Der Lizenzzustand als schlichtes Woerterbuch -- fuer JSON und Skripte.
|
||||
|
||||
Gedacht fuer ein RMM, das auf der Maschine selbst laeuft: kein Netz, kein
|
||||
Token, keine Anmeldung. Was hier steht, beantwortet die eine Frage, die ein
|
||||
Kunde wirklich hat -- wie lange laeuft die Lizenz noch --, und nichts
|
||||
darueber hinaus: keine Schluessel, keine Kennungen anderer Installationen.
|
||||
"""
|
||||
status = state.status
|
||||
return {
|
||||
"valid": bool(state.valid),
|
||||
"activated": bool(state.activated),
|
||||
"type": str(status.type) if status else "",
|
||||
"role": state.role,
|
||||
"lifetime": bool(status.lifetime) if status else False,
|
||||
"expires_at": lic.iso(status.expires_at) if status and status.expires_at else "",
|
||||
# Bei einer unbefristeten Lizenz ist die Zahl bewusst ``null`` und
|
||||
# keine grosse Zahl: "noch 99999 Tage" waere eine Behauptung.
|
||||
"days_left": (status.days_left if status and not status.lifetime else None),
|
||||
"expiring_soon": bool(status.expiring_soon) if status else False,
|
||||
"grace_active": bool(status.grace_active) if status else False,
|
||||
# Drei Angaben, weil zwei davon verschieden sein koennen und die
|
||||
# Verwechslung teuer ist: "modules" ist, was die **Lizenz** freigibt --
|
||||
# dieselbe Liste, die der Lizenzserver zeigt. Was davon vor Ort
|
||||
# abgeschaltet wurde, steht daneben.
|
||||
#
|
||||
# Vorher stand hier allein das Laufende. Beim Kunden fehlte darin
|
||||
# "dhcp", waehrend die Lizenzseite es auswies -- es war lediglich unter
|
||||
# Verwaltung -> System abgeschaltet. Aus der Antwort ging das nicht
|
||||
# hervor, und damit sah es nach einem Lizenzproblem aus.
|
||||
"modules": sorted(state.modules),
|
||||
"modules_active": sorted(state.modules_in_use),
|
||||
"modules_off": sorted(state.modules_off),
|
||||
"customer": state.customer_name,
|
||||
"last_heartbeat_at": state.last_heartbeat_at,
|
||||
"error": state.error,
|
||||
}
|
||||
|
||||
|
||||
def abfrage_ausstiegscode(auskunft: dict[str, Any]) -> int:
|
||||
"""0 heil, 1 laeuft bald ab, 2 ungueltig -- die Sprache eines RMM."""
|
||||
if not auskunft.get("valid"):
|
||||
return ABFRAGE_UNGUELTIG
|
||||
if auskunft.get("lifetime"):
|
||||
return ABFRAGE_OK
|
||||
tage = auskunft.get("days_left")
|
||||
if tage is None or int(tage) <= ABFRAGE_WARNSCHWELLE_TAGE:
|
||||
return ABFRAGE_LAEUFT_AB
|
||||
return ABFRAGE_OK
|
||||
|
||||
|
||||
class LicenseManager:
|
||||
"""Haelt den Lizenzzustand und fuehrt die Handshakes durch."""
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ DIAGNOSTICS = Resource(
|
||||
|
||||
LOGS_LIVE = Resource(
|
||||
key="logs_live",
|
||||
label="Laufendes Protokoll",
|
||||
label="Live-Protokoll",
|
||||
actions=(A.READ,),
|
||||
)
|
||||
|
||||
|
||||
@@ -1131,8 +1131,55 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* ---------------------------------------------------------------------- #
|
||||
Aufklappbares
|
||||
---------------------------------------------------------------------- #
|
||||
|
||||
Der native Marker eines <summary> verschwindet, sobald das Element ein
|
||||
Flex-Container ist -- und genau das sind unsere Kartenkoepfe. Auf der
|
||||
DHCP-Seite stand der Optionskatalog deshalb als Kasten da, dem man nicht
|
||||
ansah, dass er sich oeffnen laesst.
|
||||
|
||||
Der Pfeil wird deshalb selbst gezeichnet: als Flex- bzw. Inline-Element,
|
||||
das in beiden Faellen erscheint, in Akzentfarbe (das ist die Farbe, mit
|
||||
der diese Oberflaeche "hier kannst du etwas tun" sagt) und mit einer
|
||||
Drehung beim Oeffnen. Eine Stelle fuer alle Aufklapper -- Karten,
|
||||
Vorschauen, Abschnitte. */
|
||||
details > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
details > summary::-webkit-details-marker { display: none; }
|
||||
details:not(.nav-tree) > summary::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-right: var(--sp-2);
|
||||
border-left: 6px solid var(--accent);
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
transition: transform var(--dur) var(--ease);
|
||||
}
|
||||
details:not(.nav-tree)[open] > summary::before { transform: rotate(90deg); }
|
||||
/* Der Navigationsbaum bringt seinen eigenen Winkel mit -- der bekommt
|
||||
dieselbe Farbe, damit "aufklappbar" ueberall gleich aussieht. */
|
||||
.nav-tree__chevron { color: var(--accent); }
|
||||
|
||||
.log-view mark { background: var(--warning-soft); color: var(--warning); border-radius: 3px; }
|
||||
|
||||
/* Stufen im laufenden Protokoll. Die Zuordnung der Farbrollen ist Absicht:
|
||||
--warning ist bernsteinfarben und traegt den HINWEIS, --danger ist rot und
|
||||
traegt die WARNUNG. Wer das nach den Namen "aufraeumt", dreht die Bedeutung
|
||||
um: ein Geraet, das nicht antwortet, waere dann orange und eines, das neu
|
||||
startet, rot. */
|
||||
.log-view__zeile--info { color: var(--success); }
|
||||
.log-view__zeile--hinweis { color: var(--warning); }
|
||||
.log-view__zeile--warnung { color: var(--danger); }
|
||||
.log-view__zeile--fehler { color: var(--danger); font-weight: 600; }
|
||||
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-4); font-size: var(--fs-base); }
|
||||
.kv dt { color: var(--text-faint); white-space: nowrap; }
|
||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
@@ -1208,6 +1255,12 @@
|
||||
--tile-accent: var(--text-faint);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* Spalte, damit die Fusszeile unten haengen kann (siehe .tile__actions).
|
||||
Die Kacheln eines Rasters sind gleich hoch, ihr Inhalt aber nicht: mal
|
||||
fehlt der Switch, mal der Standort. Ohne diesen Halt stuenden die Knoepfe
|
||||
in jeder Kachel auf einer anderen Hoehe -- und das Auge sucht sie. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--sp-4);
|
||||
padding-left: calc(var(--sp-4) + 3px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -1269,6 +1322,73 @@
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Mitlesen auf der Wartungsseite: die Ausgabe eines laufenden Auftrags,
|
||||
direkt unter seinem Geraet. Dunkler Grund wie in jeder Konsole -- was
|
||||
dort steht, kommt von einer fremden Maschine und nicht aus dieser
|
||||
Oberflaeche. */
|
||||
.job-output {
|
||||
margin: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: #101216;
|
||||
color: #d7dbe0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- #
|
||||
Bildschirmsitzung (VNC)
|
||||
|
||||
Die Leinwand bekommt einen dunklen Grund -- unabhaengig vom Thema der
|
||||
Oberflaeche: was darauf erscheint, ist der Bildschirm eines fremden
|
||||
Geraets, und ein heller Rahmen darum laesst ihn wie einen Teil dieser
|
||||
Seite aussehen. Er ist keiner.
|
||||
---------------------------------------------------------------------- */
|
||||
.screen {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--r-md);
|
||||
background: #101216;
|
||||
overflow: hidden;
|
||||
}
|
||||
.screen__canvas {
|
||||
width: 100%;
|
||||
/* Genug Hoehe, dass ein Bildschirm erkennbar ist, ohne die Seite zu
|
||||
sprengen; noVNC skaliert das Bild hinein. */
|
||||
height: min(72vh, 760px);
|
||||
}
|
||||
/* Im Fenster ist weniger Platz -- und das Fenster soll nicht ueber den
|
||||
Bildschirmrand hinauswachsen, nur weil das Geraet hochaufloesend ist. */
|
||||
dialog.modal .screen__canvas { height: min(62vh, 620px); }
|
||||
.screen__status {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.screen__status--ok { color: var(--success); }
|
||||
.screen__status--warnung { color: var(--warning); }
|
||||
.screen__status--fehler { color: var(--danger); }
|
||||
|
||||
/* Fusszeile der Kachel: links der Bildschirm, rechts der Neustart.
|
||||
``margin-top: auto`` schiebt sie an den unteren Rand -- unabhaengig
|
||||
davon, wie viel darueber steht. Die beiden Eingriffe stehen damit in
|
||||
jeder Kachel an derselben Stelle, und der eine liegt nicht dort, wo eben
|
||||
noch der andere war. */
|
||||
.tile__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-3);
|
||||
}
|
||||
.tile__actions > :last-child { margin-left: auto; }
|
||||
/* Steht nur ein Knopf da, bleibt er links -- sonst spraenge der Neustart bei
|
||||
einem Geraet ohne Bildschirmzugang an die Stelle, an der nebenan VNC
|
||||
steht. Das Fuellstueck haelt den Platz. */
|
||||
.tile__spacer { flex: 1 1 auto; }
|
||||
|
||||
/* Marke und Karte stehen uebereinander im normalen Fluss. Vorher lag die
|
||||
Marke absolut positioniert hinter der Karte -- auf einem flachen Fenster
|
||||
schnitt die Karte mitten durch den Schriftzug und die Funktionszeile. Ein
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
// Bildschirmsitzung im Browser: noVNC an die WebSocket-Strecke von TESM.
|
||||
//
|
||||
// Der Client handelt das RFB-Protokoll unmittelbar mit dem Geraet aus; TESM
|
||||
// reicht nur Bytes durch. Alles, was diese Datei tut, ist: verbinden, den
|
||||
// Zustand in Worte fassen und nach Zugangsdaten fragen, wenn keine hinterlegt
|
||||
// sind.
|
||||
//
|
||||
// Die Meldungen sind der eigentliche Wert. Eine Sitzung, die schwarz bleibt,
|
||||
// laesst offen, ob es an der Berechtigung, am Netz, am Server oder am
|
||||
// Passwort liegt -- und wer das nicht sieht, sucht den Fehler in TESM.
|
||||
//
|
||||
// **Ein Bauteil, zwei Orte.** Dieselbe Sitzung laeuft auf der Bildschirmseite
|
||||
// und im Fenster der Statusuebersicht. Sie holt alles, was sie braucht, aus
|
||||
// einer JSON-Auskunft des Servers -- damit gibt es keine zweite Fassung, die
|
||||
// man beim naechsten Fund vergisst nachzuziehen.
|
||||
|
||||
import RFB from "../vendor/novnc/core/rfb.js";
|
||||
|
||||
// Drei Eingriffe in private Methoden der mitgelieferten Bibliothek. Bewusst
|
||||
// hier und nicht in der Bibliothek selbst: eine neue Fassung von noVNC laesst
|
||||
// sich dann austauschen, ohne eine Aenderung nachzupflegen -- faellt eine
|
||||
// Methode weg, entfaellt nur die Auskunft, nicht die Sitzung.
|
||||
//
|
||||
// Zwei davon holen nur Auskunft (_fail, _socketClose). Der dritte greift in
|
||||
// die Wahl des Sicherheitsverfahrens ein -- aber erst, **nachdem** eines
|
||||
// nachweislich nicht getragen hat, oder weil eines schon einmal getragen hat.
|
||||
//
|
||||
// Die Vorgeschichte gehoert dazu, damit niemand den Schritt zurueckgeht: hier
|
||||
// stand einmal eine Vorauswahl, die ohne hinterlegte Zugangsdaten ein
|
||||
// Verfahren ohne Anmeldung erzwang, weil RealVNC RA2ne zuerst nennt. Das war
|
||||
// geraten und falsch. Was jetzt hier steht, ist das Gegenteil einer Vermutung:
|
||||
// ein Befund aus einer vorherigen Sitzung.
|
||||
const istUnterstuetzt = RFB.prototype._isSupportedSecurityType;
|
||||
|
||||
//: Den *Grund* eines Fehlschlags kennt nur ``_fail`` -- das Ereignis
|
||||
//: "disconnect" traegt ihn nicht, und der Weg ueber die Protokollausgabe
|
||||
//: funktioniert nicht: die Bibliothek bindet ``console.error`` beim Laden,
|
||||
//: also bevor eine Seite etwas daran aendern koennte.
|
||||
let letzterGrund = "";
|
||||
const originalFail = RFB.prototype._fail;
|
||||
RFB.prototype._fail = function (details) {
|
||||
letzterGrund = String(details);
|
||||
return originalFail.call(this, details);
|
||||
};
|
||||
|
||||
//: Und der Abschluss der Leitung selbst: der Schliesscode sagt, ob TESM
|
||||
//: abgewiesen hat (1008), ob es ein Serverfehler war (1011) -- oder ob die
|
||||
//: Verbindung nie zustande kam (1006).
|
||||
let letzterCode = 0;
|
||||
const originalSocketClose = RFB.prototype._socketClose;
|
||||
RFB.prototype._socketClose = function (ereignis) {
|
||||
letzterCode = (ereignis && ereignis.code) || 0;
|
||||
if (ereignis && ereignis.reason) {
|
||||
letzterGrund = letzterGrund || String(ereignis.reason);
|
||||
}
|
||||
return originalSocketClose.call(this, ereignis);
|
||||
};
|
||||
|
||||
//: Hoechstens ein zweiter Anlauf. Jeder Versuch ist eine Verbindung, und ein
|
||||
//: RealVNC Server zaehlt abgebrochene Versuche mit -- wer dreimal anklopft,
|
||||
//: sperrt sich aus.
|
||||
const HOECHSTENS = 2;
|
||||
|
||||
//: So lange darf "wird aufgebaut" stehen, bevor die Seite selbst nachsieht.
|
||||
//: Kommt die WebSocket-Verbindung gar nicht erst zustande, wartet noVNC ohne
|
||||
//: jedes Ereignis, und die Seite stuende stumm bis zum Seitenwechsel.
|
||||
const AUFBAU_GRENZE = 12000;
|
||||
|
||||
//: Verfahren, die im Browser keinen Schluesseltausch kosten.
|
||||
//:
|
||||
//: Der Grund steht in der Bibliothek, `core/ra2.js`: fuer RA2ne erzeugt der
|
||||
//: **Browser** bei jeder Sitzung ein eigenes 2048-Bit-RSA-Schluesselpaar, in
|
||||
//: JavaScript. Die Primzahlsuche dafuer dauert ein bis drei Minuten -- genau
|
||||
//: die Zeit, die zwischen Klick und Bild lag. "Ohne Authentifizierung" (1)
|
||||
//: kostet davon nichts.
|
||||
//:
|
||||
//: Deshalb kommt es zuerst, **wenn der Server es anbietet** und wir nicht
|
||||
//: ohnehin schon wissen, was hier traegt. Das ist nicht dieselbe Vorauswahl
|
||||
//: wie in 2.8.14: die war geraten, diese steht auf einer Messung. Traegt sie
|
||||
//: nicht, nimmt der zweite Anlauf die Reihenfolge des Servers -- dann dauert
|
||||
//: es eben.
|
||||
const OHNE_SCHLUESSELTAUSCH = new Set([1]);
|
||||
|
||||
//: RA2ne. Wird es ausgehandelt, ist die Wartezeit erklaerbar und kein Fehler.
|
||||
const RA2NE = 6;
|
||||
|
||||
//: Tastenkombinationen, die der Arbeitsplatz selbst abfaengt und die deshalb
|
||||
//: einen Knopf brauchen. X11-Keysyms.
|
||||
const ALT_LINKS = 0xffe9;
|
||||
const F4 = 0xffc1;
|
||||
const F5 = 0xffc2;
|
||||
|
||||
const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
/**
|
||||
* Baut eine Sitzung in diesem Behaelter auf.
|
||||
*
|
||||
* ``quelle`` ist die Adresse der JSON-Auskunft; alles Weitere steht dort.
|
||||
* Rueckgabe: eine Funktion, die die Sitzung wieder abbaut.
|
||||
*/
|
||||
export function starteSitzung(wurzel, quelle, { beiEnde = null } = {}) {
|
||||
const teil = (rolle) => wurzel.querySelector(`[data-rolle="${rolle}"]`);
|
||||
const leinwand = teil("leinwand");
|
||||
const zeile = teil("status");
|
||||
const abfrage = teil("zugangsdaten");
|
||||
const feldBenutzer = teil("benutzer");
|
||||
const feldPasswort = teil("passwort");
|
||||
const knopfSenden = teil("anmelden");
|
||||
const knopfTrennen = teil("trennen");
|
||||
const knoepfeSitzung = [...wurzel.querySelectorAll("[data-rolle^='taste-']"), knopfTrennen];
|
||||
|
||||
const gescheitert = new Set();
|
||||
let rfb = null;
|
||||
let aufbauUhr = 0;
|
||||
let versuche = 0;
|
||||
let stand = false;
|
||||
let daten = null;
|
||||
let letzterZugang = {};
|
||||
let beendet = false;
|
||||
|
||||
function melden(text, art = "") {
|
||||
if (!zeile) return;
|
||||
zeile.textContent = text;
|
||||
zeile.className = `screen__status${art ? " screen__status--" + art : ""}`;
|
||||
}
|
||||
|
||||
function sitzungsknoepfe(sichtbar) {
|
||||
knoepfeSitzung.forEach((knopf) => {
|
||||
if (knopf) knopf.hidden = !sichtbar;
|
||||
});
|
||||
}
|
||||
|
||||
function grund() {
|
||||
const teile = [];
|
||||
if (letzterGrund) {
|
||||
teile.push(letzterGrund.replace(/^Failed (when|while) \w+: /, ""));
|
||||
}
|
||||
if (rfb && rfb._rfbAuthScheme > 0) {
|
||||
teile.push(`gewähltes Verfahren: ${rfb._rfbAuthScheme}`);
|
||||
}
|
||||
if (gescheitert.size) {
|
||||
teile.push(`erfolglos: ${[...gescheitert].join(", ")}`);
|
||||
}
|
||||
return teile.join(" · ");
|
||||
}
|
||||
|
||||
// Steht die WebSocket-Strecke zu TESM überhaupt? Zwei sehr verschiedene
|
||||
// Ursachen sehen von aussen gleich aus: der VNC-Server mag nicht, oder die
|
||||
// Verbindung kommt nie zustande, weil ein Proxy zwischen Browser und TESM
|
||||
// "Upgrade" nicht durchreicht. Diese Probe trennt sie.
|
||||
function streckeTesten() {
|
||||
return new Promise((fertig) => {
|
||||
let beantwortet = false;
|
||||
const pruefling = new WebSocket(`${schema}//${window.location.host}/ws/probe`);
|
||||
const schliessen = (ergebnis) => {
|
||||
if (beantwortet) return;
|
||||
beantwortet = true;
|
||||
try {
|
||||
pruefling.close();
|
||||
} catch (fehler) {
|
||||
/* schon zu */
|
||||
}
|
||||
fertig(ergebnis);
|
||||
};
|
||||
pruefling.onmessage = () => schliessen(true);
|
||||
pruefling.onerror = () => schliessen(false);
|
||||
pruefling.onclose = () => schliessen(false);
|
||||
window.setTimeout(() => schliessen(false), 4000);
|
||||
});
|
||||
}
|
||||
|
||||
// Hat das gewählte Verfahren die Verbindung abreissen lassen? Dann ist das
|
||||
// ein Befund und keine Vermutung -- der naechste Anlauf laesst es aus. Nur
|
||||
// solange nie ein Bild stand: bricht eine **laufende** Sitzung ab, liegt es
|
||||
// nicht am Anmeldeverfahren.
|
||||
function naechsterVersuch() {
|
||||
const verfahren = rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0;
|
||||
if (!verfahren || versuche >= HOECHSTENS || gescheitert.has(verfahren)) {
|
||||
return false;
|
||||
}
|
||||
gescheitert.add(verfahren);
|
||||
melden(
|
||||
`Verfahren ${verfahren} hat die Verbindung nicht getragen – nächster Anlauf ` +
|
||||
"mit dem nächsten, das der Server anbietet …",
|
||||
"warnung",
|
||||
);
|
||||
const zugang = letzterZugang;
|
||||
rfb = null;
|
||||
window.setTimeout(() => verbinden(zugang), 400);
|
||||
return true;
|
||||
}
|
||||
|
||||
function verfahrenMelden(gewaehlt) {
|
||||
if (!gewaehlt || gewaehlt === daten.bewaehrt || !daten.meldeweg) return;
|
||||
fetch(daten.meldeweg, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "fetch",
|
||||
"X-CSRF-Token": document.body?.dataset.csrf || "",
|
||||
},
|
||||
body: JSON.stringify({ verfahren: gewaehlt }),
|
||||
}).catch(() => {
|
||||
/* Eine Merkhilfe, kein Teil der Sitzung. */
|
||||
});
|
||||
}
|
||||
|
||||
function verbinden(zugang) {
|
||||
if (beendet) return;
|
||||
melden(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …");
|
||||
letzterGrund = "";
|
||||
letzterCode = 0;
|
||||
letzterZugang = zugang;
|
||||
versuche += 1;
|
||||
window.clearTimeout(aufbauUhr);
|
||||
aufbauUhr = window.setTimeout(() => {
|
||||
// Ein ausgehandeltes RA2ne ist kein Fehler, sondern Rechenzeit: der
|
||||
// Browser erzeugt dafuer gerade einen 2048-Bit-Schluessel. Das zu
|
||||
// melden ist das Gegenteil einer Warnung -- es nimmt die Sorge.
|
||||
if (rfb && rfb._rfbAuthScheme === RA2NE) {
|
||||
melden(
|
||||
"Der Server verlangt RA2ne (RSA-AES). Dafür erzeugt der Browser ein " +
|
||||
"2048-Bit-Schlüsselpaar – das dauert ein bis drei Minuten. Bitte warten.",
|
||||
"warnung",
|
||||
);
|
||||
return;
|
||||
}
|
||||
melden("Es kommt keine Antwort. Wird geprüft, woran es liegt …", "warnung");
|
||||
streckeTesten().then((steht) => {
|
||||
melden(
|
||||
steht
|
||||
? "Die WebSocket-Strecke zu TESM steht, aber die Sitzung kommt nicht " +
|
||||
"zustande. Antwortet der VNC-Server auf dem Gerät noch? Ein Neustart " +
|
||||
"des VNC-Dienstes räumt eine Sperre ab."
|
||||
: "Die WebSocket-Verbindung zu TESM kommt nicht zustande – auch eine " +
|
||||
"einfache Probe scheitert. Ein Proxy zwischen Browser und TESM muss " +
|
||||
"die Kopfzeilen Upgrade und Connection durchreichen (nginx: " +
|
||||
"location /ws/ mit proxy_set_header Upgrade $http_upgrade).",
|
||||
"fehler",
|
||||
);
|
||||
});
|
||||
}, AUFBAU_GRENZE);
|
||||
|
||||
// Drei Regeln, alle aus Messung und keine aus Vermutung:
|
||||
//
|
||||
// 1. Was schon einmal getragen hat, kommt zuerst.
|
||||
// 2. Sonst das Verfahren ohne Schluesseltausch -- aber nur, wenn der
|
||||
// Server es nachweislich anbietet (sonst faende noVNC gar keines und
|
||||
// scheiterte sofort). RA2ne kostet sonst ein bis drei Minuten
|
||||
// Rechenzeit im Browser, jedes Mal.
|
||||
// 3. Was abgerissen ist, wird nicht noch einmal gewaehlt.
|
||||
//
|
||||
// Beides gilt nur fuer den **ersten** Anlauf; danach zaehlt die
|
||||
// Reihenfolge des Servers, abzueglich dessen, was schon abgerissen ist.
|
||||
const bewaehrt = daten.bewaehrt || 0;
|
||||
const angeboten = daten.verfahren || [];
|
||||
const schnell = angeboten.filter((typ) => OHNE_SCHLUESSELTAUSCH.has(typ));
|
||||
let bevorzugt = 0;
|
||||
if (versuche === 1) {
|
||||
if (bewaehrt > 0 && !gescheitert.has(bewaehrt)) bevorzugt = bewaehrt;
|
||||
else if (schnell.length && !gescheitert.has(schnell[0])) bevorzugt = schnell[0];
|
||||
}
|
||||
RFB.prototype._isSupportedSecurityType = function (typ) {
|
||||
if (gescheitert.has(typ)) return false;
|
||||
if (bevorzugt) return typ === bevorzugt;
|
||||
return istUnterstuetzt.call(this, typ);
|
||||
};
|
||||
|
||||
rfb = new RFB(leinwand, `${schema}//${window.location.host}${daten.socket}`, {
|
||||
credentials: zugang,
|
||||
// Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des
|
||||
// Arbeitsplatzes mit dem Geraet teilen -- das ist eine eigene
|
||||
// Entscheidung, und niemand hat sie hier getroffen.
|
||||
clipboardTarget: null,
|
||||
});
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = false;
|
||||
rfb.focusOnClick = true;
|
||||
|
||||
rfb.addEventListener("connect", () => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
stand = true;
|
||||
verfahrenMelden(rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0);
|
||||
melden(`Verbunden mit ${daten.ziel}`, "ok");
|
||||
if (abfrage) abfrage.hidden = true;
|
||||
leinwand.hidden = false;
|
||||
sitzungsknoepfe(true);
|
||||
});
|
||||
|
||||
rfb.addEventListener("disconnect", (ereignis) => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
leinwand.hidden = true;
|
||||
sitzungsknoepfe(false);
|
||||
if (ereignis.detail && ereignis.detail.clean) {
|
||||
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
|
||||
// dass TESM wegen Untätigkeit getrennt hat.
|
||||
melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet.");
|
||||
// Im Fenster gibt es nach dem Trennen nichts mehr zu sehen: ein leerer
|
||||
// Rahmen, den man wegklicken muss. Bei einem **Abbruch** bleibt es
|
||||
// stehen -- dort steht der Grund, und der ist der ganze Zweck.
|
||||
beiEnde?.();
|
||||
} else if (!stand && naechsterVersuch()) {
|
||||
return;
|
||||
} else {
|
||||
const erklaerung = grund();
|
||||
melden(
|
||||
erklaerung
|
||||
? `Verbindung abgebrochen – ${erklaerung}`
|
||||
: "Verbindung abgebrochen. Wird geprüft, woran es liegt …",
|
||||
"fehler",
|
||||
);
|
||||
if (letzterCode === 1006 || !erklaerung) {
|
||||
streckeTesten().then((steht) => {
|
||||
melden(
|
||||
steht
|
||||
? `${erklaerung || "Verbindung abgebrochen"} – die WebSocket-Strecke zu ` +
|
||||
"TESM selbst steht (Probe erfolgreich), der Abbruch kommt also von " +
|
||||
"der VNC-Seite."
|
||||
: "Die WebSocket-Verbindung zu TESM kommt nicht zustande – auch eine " +
|
||||
"einfache Probe scheitert. Ein Proxy zwischen Browser und TESM muss " +
|
||||
"die Kopfzeilen Upgrade und Connection durchreichen (nginx: " +
|
||||
"location /ws/ mit proxy_set_header Upgrade $http_upgrade). Dasselbe " +
|
||||
"gilt für die Browser-Konsole.",
|
||||
"fehler",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
rfb = null;
|
||||
});
|
||||
|
||||
rfb.addEventListener("credentialsrequired", (ereignis) => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
const gefragt = ereignis.detail.types || ["password"];
|
||||
if (abfrage) abfrage.hidden = false;
|
||||
if (feldBenutzer) feldBenutzer.closest("label").hidden = !gefragt.includes("username");
|
||||
melden("Der VNC-Server verlangt Zugangsdaten.", "warnung");
|
||||
(gefragt.includes("username") ? feldBenutzer : feldPasswort)?.focus();
|
||||
});
|
||||
|
||||
rfb.addEventListener("securityfailure", (ereignis) => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
melden(
|
||||
`Anmeldung abgelehnt: ${ereignis.detail.reason || "kein Grund genannt"}`,
|
||||
"fehler",
|
||||
);
|
||||
if (abfrage) abfrage.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
knopfSenden?.addEventListener("click", () => {
|
||||
const zugang = {};
|
||||
if (feldBenutzer?.value) zugang.username = feldBenutzer.value;
|
||||
if (feldPasswort?.value) zugang.password = feldPasswort.value;
|
||||
if (rfb) rfb.sendCredentials(zugang);
|
||||
else verbinden(zugang);
|
||||
});
|
||||
|
||||
[feldBenutzer, feldPasswort].forEach((feld) =>
|
||||
feld?.addEventListener("keydown", (ereignis) => {
|
||||
if (ereignis.key === "Enter") {
|
||||
ereignis.preventDefault();
|
||||
knopfSenden?.click();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
knopfTrennen?.addEventListener("click", () => rfb && rfb.disconnect());
|
||||
teil("taste-cad")?.addEventListener("click", () => rfb && rfb.sendCtrlAltDel());
|
||||
// Alt+F4 kennt noVNC nicht als fertigen Griff: der Arbeitsplatz faengt die
|
||||
// Kombination selbst ab, bevor sie in der Leinwand ankommt. Also einzeln --
|
||||
// Alt halten, F4 tippen, Alt loslassen -- wie es auf der Tastatur auch
|
||||
// geschieht.
|
||||
teil("taste-altf4")?.addEventListener("click", () => {
|
||||
if (!rfb) return;
|
||||
rfb.sendKey(ALT_LINKS, "AltLeft", true);
|
||||
rfb.sendKey(F4, "F4", true);
|
||||
rfb.sendKey(F4, "F4", false);
|
||||
rfb.sendKey(ALT_LINKS, "AltLeft", false);
|
||||
});
|
||||
// F5 faengt der Browser des Arbeitsplatzes ab -- er wuerde diese Seite neu
|
||||
// laden statt das Bild auf dem Geraet.
|
||||
teil("taste-f5")?.addEventListener("click", () => {
|
||||
if (!rfb) return;
|
||||
rfb.sendKey(F5, "F5", true);
|
||||
rfb.sendKey(F5, "F5", false);
|
||||
});
|
||||
|
||||
sitzungsknoepfe(false);
|
||||
melden("Wird vorbereitet …");
|
||||
|
||||
fetch(quelle, { credentials: "same-origin", headers: { "X-Requested-With": "fetch" } })
|
||||
.then((antwort) => antwort.json().then((inhalt) => ({ status: antwort.status, inhalt })))
|
||||
.then(({ status, inhalt }) => {
|
||||
if (beendet) return;
|
||||
if (status === 409 && inhalt.reauth) {
|
||||
melden(
|
||||
"Für den Bildschirm ist eine frische Passwortbestätigung nötig – bitte über " +
|
||||
"die Geräteseite öffnen.",
|
||||
"warnung",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (status !== 200) {
|
||||
melden(inhalt.fehler || "Diese Sitzung lässt sich nicht öffnen.", "fehler");
|
||||
return;
|
||||
}
|
||||
daten = inhalt;
|
||||
if (daten.hinderungsgrund) {
|
||||
melden(daten.hinderungsgrund, "fehler");
|
||||
return;
|
||||
}
|
||||
const zugang = {};
|
||||
if (daten.vorgaben?.username) zugang.username = daten.vorgaben.username;
|
||||
if (daten.vorgaben?.password) zugang.password = daten.vorgaben.password;
|
||||
verbinden(zugang);
|
||||
})
|
||||
.catch(() => melden("Die Sitzungsdaten sind nicht abrufbar.", "fehler"));
|
||||
|
||||
return function beenden() {
|
||||
beendet = true;
|
||||
window.clearTimeout(aufbauUhr);
|
||||
try {
|
||||
rfb?.disconnect();
|
||||
} catch (fehler) {
|
||||
/* schon zu */
|
||||
}
|
||||
rfb = null;
|
||||
};
|
||||
}
|
||||
|
||||
// -- Die beiden Orte -------------------------------------------------------- #
|
||||
|
||||
// 1. Die Bildschirmseite: ein Behaelter, der seine Quelle selbst mitbringt.
|
||||
const seite = document.getElementById("screen");
|
||||
if (seite?.dataset.sitzung) {
|
||||
starteSitzung(seite, seite.dataset.sitzung);
|
||||
}
|
||||
|
||||
// 2. Die Statusuebersicht: ein Fenster, das jede Kachel oeffnen kann. Ein
|
||||
// Dialog fuer alle -- er bekommt beim Oeffnen, welches Geraet gemeint ist.
|
||||
const fenster = document.getElementById("vnc-fenster");
|
||||
if (fenster) {
|
||||
let beenden = null;
|
||||
const behaelter = fenster.querySelector("[data-rolle='behaelter']");
|
||||
const titel = fenster.querySelector("[data-rolle='titel']");
|
||||
|
||||
document.addEventListener("click", (ereignis) => {
|
||||
const knopf = ereignis.target.closest("[data-vnc-oeffnen]");
|
||||
if (!knopf) return;
|
||||
ereignis.preventDefault();
|
||||
beenden?.();
|
||||
titel.textContent = knopf.dataset.vncName || "Bildschirm";
|
||||
if (typeof fenster.showModal === "function") fenster.showModal();
|
||||
else fenster.setAttribute("open", "");
|
||||
beenden = starteSitzung(behaelter, knopf.dataset.vncOeffnen, {
|
||||
beiEnde: () => fenster.close(),
|
||||
});
|
||||
});
|
||||
|
||||
// Zu heisst zu: eine Sitzung, die im Hintergrund weiterlaeuft, haelt die
|
||||
// Verbindung zum Geraet und bei RealVNC den einen Sitzungsplatz.
|
||||
fenster.addEventListener("close", () => {
|
||||
beenden?.();
|
||||
beenden = null;
|
||||
});
|
||||
}
|
||||
@@ -960,15 +960,22 @@ export function initFilePreview(wurzel = document) {
|
||||
const ersetzenFeld = stueck("ersetzen");
|
||||
const ersetzenKasten = stueck("ersetzenfeld");
|
||||
const pruefzeile = stueck("pruefung");
|
||||
// Die Ordner der geoeffneten Freigabe kommen mit der Seite; die einer
|
||||
// anderen holt das Auswahlfeld bei Bedarf nach.
|
||||
const eigeneOrdner = () => {
|
||||
try {
|
||||
return JSON.parse(rumpf.dataset.viewerOrdnerliste || "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
// Die Ordner der geoeffneten Freigabe stehen bereits in der Auswahl -- der
|
||||
// Server hat sie dort hineingeschrieben. Gemerkt werden sie trotzdem, denn
|
||||
// wer zwischendurch eine andere Freigabe waehlt, ersetzt die Liste; die
|
||||
// einer anderen Freigabe holt das Auswahlfeld dann bei Bedarf nach.
|
||||
//
|
||||
// Vorher stand dieselbe Liste ein zweites Mal daneben, als JSON in einem
|
||||
// Attribut -- und kam dort nie an: ``tojson`` laesst doppelte
|
||||
// Anfuehrungszeichen stehen, und in einem doppelt gequoteten Attribut endet
|
||||
// der Wert damit nach der oeffnenden Klammer. Die Auswahl zeigte danach nur
|
||||
// noch das Wurzelverzeichnis, und in einen Unterordner liess sich nichts
|
||||
// mehr speichern. Zwei Fassungen derselben Liste waren die eigentliche
|
||||
// Ursache; jetzt gibt es nur noch eine.
|
||||
const gelieferteOrdner = ordnerFeld
|
||||
? [...ordnerFeld.options].map((eintrag) => eintrag.value).filter(Boolean)
|
||||
: [];
|
||||
const eigeneOrdner = () => gelieferteOrdner;
|
||||
const jetzt = stueck("jetzt");
|
||||
const abbrechen = stueck("abbrechen");
|
||||
const modusFeld = stueck("modus");
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
maintainers:
|
||||
- Samuel Mannehed for Cendio AB (@samhed)
|
||||
- Pierre Ossman for Cendio AB (@CendioOssman)
|
||||
maintainersEmeritus:
|
||||
- Joel Martin (@kanaka)
|
||||
- Solly Ross (@directxman12)
|
||||
- @astrand
|
||||
contributors:
|
||||
# There are a bunch of people that should be here.
|
||||
# If you want to be on this list, feel free send a PR
|
||||
# to add yourself.
|
||||
- jalf <git@jalf.dk>
|
||||
- NTT corp.
|
||||
@@ -0,0 +1,52 @@
|
||||
# noVNC 1.6.0 -- mitgeliefert, nicht nachgeladen
|
||||
|
||||
Quelle: <https://github.com/novnc/noVNC>, Fassung **1.6.0**, uebernommen am
|
||||
13.09.2026. Enthalten sind `core/` (die Bibliothek) und `vendor/pako` (die
|
||||
Entpackroutine, die `core/inflator.js` braucht) -- ohne `app/`, `tests/` und
|
||||
die mitgelieferte Oberflaeche: die Bedienung stellt TESM selbst
|
||||
(`apps/tesm/src/tesm/templates/tesm/screen.html`).
|
||||
|
||||
**Lizenz:** MPL-2.0 fuer die Kernbibliothek, siehe `LICENSE.txt` und `AUTHORS`
|
||||
(beide unveraendert uebernommen). pako steht unter MIT, siehe
|
||||
`vendor/pako/LICENSE`.
|
||||
|
||||
**Warum mitgeliefert und nicht von einem CDN:** dieselbe Regel wie bei pdf.js
|
||||
und pdf-lib nebenan. Die Anwendung laeuft in Netzen ohne Internetzugang, die
|
||||
Inhaltsrichtlinie (CSP) erlaubt ausschliesslich `'self'`, und eine
|
||||
Bibliothek, die sich zur Laufzeit aendern kann, gehoert nicht in einen Pfad,
|
||||
der einen Bildschirm fernsteuert.
|
||||
|
||||
**Unveraendert.** Wird eine Anpassung noetig, gehoert sie hierher in diese
|
||||
Datei -- mit Grund und Fundstelle, damit ein Wechsel auf eine neuere Fassung
|
||||
nicht stillschweigend darueberlaeuft.
|
||||
|
||||
**Was TESM von aussen umbiegt** (also *nicht* in diesen Dateien steht, sondern
|
||||
in `js/screen.js`):
|
||||
|
||||
* `RFB.prototype._fail` und `_socketClose` -- nur, um den Grund eines
|
||||
Abbruchs zu erfahren. noVNC meldet ihn sonst ausschliesslich in die
|
||||
Entwicklerkonsole, und `console.error` mitzulesen traegt nicht: die
|
||||
Bibliothek bindet es beim Laden.
|
||||
* `RFB.prototype._isSupportedSecurityType` -- **erst nachdem** ein Verfahren
|
||||
die Verbindung nachweislich hat abreissen lassen. Dann wird es fuer den
|
||||
zweiten Anlauf ausgelassen, damit noVNC nicht wieder dasselbe zuerst findet.
|
||||
Hoechstens zwei Anlaeufe, denn jeder ist eine Verbindung, und ein RealVNC
|
||||
Server zaehlt abgebrochene mit.
|
||||
|
||||
Hier stand einmal das Gegenteil: eine **Vorauswahl**, die ohne hinterlegte
|
||||
Zugangsdaten ein Verfahren ohne Anmeldung erzwang. Das war geraten und hat
|
||||
eine laufende Sitzung gebrochen. Der Unterschied ist nicht der Code, sondern
|
||||
der Anlass: eine Vermutung vorher gegen einen Befund hinterher.
|
||||
|
||||
Faellt eine dieser Methoden in einer neueren Fassung weg, entfaellt nur die
|
||||
Auskunft bzw. der zweite Anlauf -- die Sitzung laeuft wie ohne sie.
|
||||
|
||||
**Was noVNC an Sicherheitsverfahren spricht** (`core/rfb.js`,
|
||||
`_isSupportedSecurityType`): `1` None, `2` VNC-Passwort, `6` RA2ne (RSA-AES
|
||||
ohne Streckenverschluesselung), `16` Tight, `19` VeNCrypt, `22` xvp, `30` ARD,
|
||||
`113` MS-Logon II. **Nicht** dabei: `5` (RA2 mit Streckenverschluesselung) und
|
||||
alles aus dem RealVNC-eigenen Bereich `7`-`15`. Ein RealVNC Server, der nur
|
||||
`13` und `5` anbietet, kommt mit dieser Bibliothek also nicht zusammen -- er
|
||||
muss `Encryption=AlwaysOff` (dann `6`) oder `Authentication=VncAuth` (dann
|
||||
`2`) angeboten bekommen. TESM misst das und schreibt es auf die Seite, statt
|
||||
den Benutzer vor eine schwarze Flaeche zu setzen.
|
||||
@@ -0,0 +1,62 @@
|
||||
noVNC is Copyright (C) 2022 The noVNC authors
|
||||
(./AUTHORS)
|
||||
|
||||
The noVNC core library files are licensed under the MPL 2.0 (Mozilla
|
||||
Public License 2.0). The noVNC core library is composed of the
|
||||
Javascript code necessary for full noVNC operation. This includes (but
|
||||
is not limited to):
|
||||
|
||||
core/**/*.js
|
||||
app/*.js
|
||||
test/playback.js
|
||||
|
||||
The HTML, CSS, font and images files that included with the noVNC
|
||||
source distibution (or repository) are not considered part of the
|
||||
noVNC core library and are licensed under more permissive licenses.
|
||||
The intent is to allow easy integration of noVNC into existing web
|
||||
sites and web applications.
|
||||
|
||||
The HTML, CSS, font and image files are licensed as follows:
|
||||
|
||||
*.html : 2-Clause BSD license
|
||||
|
||||
app/styles/*.css : 2-Clause BSD license
|
||||
|
||||
app/styles/Orbitron* : SIL Open Font License 1.1
|
||||
(Copyright 2009 Matt McInerney)
|
||||
|
||||
app/images/ : Creative Commons Attribution-ShareAlike
|
||||
http://creativecommons.org/licenses/by-sa/3.0/
|
||||
|
||||
Some portions of noVNC are copyright to their individual authors.
|
||||
Please refer to the individual source files and/or to the noVNC commit
|
||||
history: https://github.com/novnc/noVNC/commits/master
|
||||
|
||||
The are several files and projects that have been incorporated into
|
||||
the noVNC core library. Here is a list of those files and the original
|
||||
licenses (all MPL 2.0 compatible):
|
||||
|
||||
core/base64.js : MPL 2.0
|
||||
|
||||
core/des.js : Various BSD style licenses
|
||||
|
||||
vendor/pako/ : MIT
|
||||
|
||||
Any other files not mentioned above are typically marked with
|
||||
a copyright/license header at the top of the file. The default noVNC
|
||||
license is MPL-2.0.
|
||||
|
||||
The following license texts are included:
|
||||
|
||||
docs/LICENSE.MPL-2.0
|
||||
docs/LICENSE.OFL-1.1
|
||||
docs/LICENSE.BSD-3-Clause (New BSD)
|
||||
docs/LICENSE.BSD-2-Clause (Simplified BSD / FreeBSD)
|
||||
vendor/pako/LICENSE (MIT)
|
||||
|
||||
Or alternatively the license texts may be found here:
|
||||
|
||||
http://www.mozilla.org/MPL/2.0/
|
||||
http://scripts.sil.org/OFL
|
||||
http://en.wikipedia.org/wiki/BSD_licenses
|
||||
https://opensource.org/licenses/MIT
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
// From: http://hg.mozilla.org/mozilla-central/raw-file/ec10630b1a54/js/src/devtools/jint/sunspider/string-base64.js
|
||||
|
||||
import * as Log from './util/logging.js';
|
||||
|
||||
export default {
|
||||
/* Convert data (an array of integers) to a Base64 string. */
|
||||
toBase64Table: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='.split(''),
|
||||
base64Pad: '=',
|
||||
|
||||
encode(data) {
|
||||
"use strict";
|
||||
let result = '';
|
||||
const length = data.length;
|
||||
const lengthpad = (length % 3);
|
||||
// Convert every three bytes to 4 ascii characters.
|
||||
|
||||
for (let i = 0; i < (length - 2); i += 3) {
|
||||
result += this.toBase64Table[data[i] >> 2];
|
||||
result += this.toBase64Table[((data[i] & 0x03) << 4) + (data[i + 1] >> 4)];
|
||||
result += this.toBase64Table[((data[i + 1] & 0x0f) << 2) + (data[i + 2] >> 6)];
|
||||
result += this.toBase64Table[data[i + 2] & 0x3f];
|
||||
}
|
||||
|
||||
// Convert the remaining 1 or 2 bytes, pad out to 4 characters.
|
||||
const j = length - lengthpad;
|
||||
if (lengthpad === 2) {
|
||||
result += this.toBase64Table[data[j] >> 2];
|
||||
result += this.toBase64Table[((data[j] & 0x03) << 4) + (data[j + 1] >> 4)];
|
||||
result += this.toBase64Table[(data[j + 1] & 0x0f) << 2];
|
||||
result += this.toBase64Table[64];
|
||||
} else if (lengthpad === 1) {
|
||||
result += this.toBase64Table[data[j] >> 2];
|
||||
result += this.toBase64Table[(data[j] & 0x03) << 4];
|
||||
result += this.toBase64Table[64];
|
||||
result += this.toBase64Table[64];
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/* Convert Base64 data to a string */
|
||||
/* eslint-disable comma-spacing */
|
||||
toBinaryTable: [
|
||||
-1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1,
|
||||
-1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1,
|
||||
-1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,62, -1,-1,-1,63,
|
||||
52,53,54,55, 56,57,58,59, 60,61,-1,-1, -1, 0,-1,-1,
|
||||
-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11,12,13,14,
|
||||
15,16,17,18, 19,20,21,22, 23,24,25,-1, -1,-1,-1,-1,
|
||||
-1,26,27,28, 29,30,31,32, 33,34,35,36, 37,38,39,40,
|
||||
41,42,43,44, 45,46,47,48, 49,50,51,-1, -1,-1,-1,-1
|
||||
],
|
||||
/* eslint-enable comma-spacing */
|
||||
|
||||
decode(data, offset = 0) {
|
||||
let dataLength = data.indexOf('=') - offset;
|
||||
if (dataLength < 0) { dataLength = data.length - offset; }
|
||||
|
||||
/* Every four characters is 3 resulting numbers */
|
||||
const resultLength = (dataLength >> 2) * 3 + Math.floor((dataLength % 4) / 1.5);
|
||||
const result = new Array(resultLength);
|
||||
|
||||
// Convert one by one.
|
||||
|
||||
let leftbits = 0; // number of bits decoded, but yet to be appended
|
||||
let leftdata = 0; // bits decoded, but yet to be appended
|
||||
for (let idx = 0, i = offset; i < data.length; i++) {
|
||||
const c = this.toBinaryTable[data.charCodeAt(i) & 0x7f];
|
||||
const padding = (data.charAt(i) === this.base64Pad);
|
||||
// Skip illegal characters and whitespace
|
||||
if (c === -1) {
|
||||
Log.Error("Illegal character code " + data.charCodeAt(i) + " at position " + i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect data into leftdata, update bitcount
|
||||
leftdata = (leftdata << 6) | c;
|
||||
leftbits += 6;
|
||||
|
||||
// If we have 8 or more bits, append 8 bits to the result
|
||||
if (leftbits >= 8) {
|
||||
leftbits -= 8;
|
||||
// Append if not padding.
|
||||
if (!padding) {
|
||||
result[idx++] = (leftdata >> leftbits) & 0xff;
|
||||
}
|
||||
leftdata &= (1 << leftbits) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If there are any bits left, the base64 string was corrupted
|
||||
if (leftbits) {
|
||||
const err = new Error('Corrupted base64 string');
|
||||
err.name = 'Base64-Error';
|
||||
throw err;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}; /* End of Base64 namespace */
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
export class AESECBCipher {
|
||||
constructor() {
|
||||
this._key = null;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "AES-ECB" };
|
||||
}
|
||||
|
||||
static async importKey(key, _algorithm, extractable, keyUsages) {
|
||||
const cipher = new AESECBCipher;
|
||||
await cipher._importKey(key, extractable, keyUsages);
|
||||
return cipher;
|
||||
}
|
||||
|
||||
async _importKey(key, extractable, keyUsages) {
|
||||
this._key = await window.crypto.subtle.importKey(
|
||||
"raw", key, {name: "AES-CBC"}, extractable, keyUsages);
|
||||
}
|
||||
|
||||
async encrypt(_algorithm, plaintext) {
|
||||
const x = new Uint8Array(plaintext);
|
||||
if (x.length % 16 !== 0 || this._key === null) {
|
||||
return null;
|
||||
}
|
||||
const n = x.length / 16;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const y = new Uint8Array(await window.crypto.subtle.encrypt({
|
||||
name: "AES-CBC",
|
||||
iv: new Uint8Array(16),
|
||||
}, this._key, x.slice(i * 16, i * 16 + 16))).slice(0, 16);
|
||||
x.set(y, i * 16);
|
||||
}
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
||||
export class AESEAXCipher {
|
||||
constructor() {
|
||||
this._rawKey = null;
|
||||
this._ctrKey = null;
|
||||
this._cbcKey = null;
|
||||
this._zeroBlock = new Uint8Array(16);
|
||||
this._prefixBlock0 = this._zeroBlock;
|
||||
this._prefixBlock1 = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]);
|
||||
this._prefixBlock2 = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2]);
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "AES-EAX" };
|
||||
}
|
||||
|
||||
async _encryptBlock(block) {
|
||||
const encrypted = await window.crypto.subtle.encrypt({
|
||||
name: "AES-CBC",
|
||||
iv: this._zeroBlock,
|
||||
}, this._cbcKey, block);
|
||||
return new Uint8Array(encrypted).slice(0, 16);
|
||||
}
|
||||
|
||||
async _initCMAC() {
|
||||
const k1 = await this._encryptBlock(this._zeroBlock);
|
||||
const k2 = new Uint8Array(16);
|
||||
const v = k1[0] >>> 6;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
k2[i] = (k1[i + 1] >> 6) | (k1[i] << 2);
|
||||
k1[i] = (k1[i + 1] >> 7) | (k1[i] << 1);
|
||||
}
|
||||
const lut = [0x0, 0x87, 0x0e, 0x89];
|
||||
k2[14] ^= v >>> 1;
|
||||
k2[15] = (k1[15] << 2) ^ lut[v];
|
||||
k1[15] = (k1[15] << 1) ^ lut[v >> 1];
|
||||
this._k1 = k1;
|
||||
this._k2 = k2;
|
||||
}
|
||||
|
||||
async _encryptCTR(data, counter) {
|
||||
const encrypted = await window.crypto.subtle.encrypt({
|
||||
name: "AES-CTR",
|
||||
counter: counter,
|
||||
length: 128
|
||||
}, this._ctrKey, data);
|
||||
return new Uint8Array(encrypted);
|
||||
}
|
||||
|
||||
async _decryptCTR(data, counter) {
|
||||
const decrypted = await window.crypto.subtle.decrypt({
|
||||
name: "AES-CTR",
|
||||
counter: counter,
|
||||
length: 128
|
||||
}, this._ctrKey, data);
|
||||
return new Uint8Array(decrypted);
|
||||
}
|
||||
|
||||
async _computeCMAC(data, prefixBlock) {
|
||||
if (prefixBlock.length !== 16) {
|
||||
return null;
|
||||
}
|
||||
const n = Math.floor(data.length / 16);
|
||||
const m = Math.ceil(data.length / 16);
|
||||
const r = data.length - n * 16;
|
||||
const cbcData = new Uint8Array((m + 1) * 16);
|
||||
cbcData.set(prefixBlock);
|
||||
cbcData.set(data, 16);
|
||||
if (r === 0) {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
cbcData[n * 16 + i] ^= this._k1[i];
|
||||
}
|
||||
} else {
|
||||
cbcData[(n + 1) * 16 + r] = 0x80;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
cbcData[(n + 1) * 16 + i] ^= this._k2[i];
|
||||
}
|
||||
}
|
||||
let cbcEncrypted = await window.crypto.subtle.encrypt({
|
||||
name: "AES-CBC",
|
||||
iv: this._zeroBlock,
|
||||
}, this._cbcKey, cbcData);
|
||||
|
||||
cbcEncrypted = new Uint8Array(cbcEncrypted);
|
||||
const mac = cbcEncrypted.slice(cbcEncrypted.length - 32, cbcEncrypted.length - 16);
|
||||
return mac;
|
||||
}
|
||||
|
||||
static async importKey(key, _algorithm, _extractable, _keyUsages) {
|
||||
const cipher = new AESEAXCipher;
|
||||
await cipher._importKey(key);
|
||||
return cipher;
|
||||
}
|
||||
|
||||
async _importKey(key) {
|
||||
this._rawKey = key;
|
||||
this._ctrKey = await window.crypto.subtle.importKey(
|
||||
"raw", key, {name: "AES-CTR"}, false, ["encrypt", "decrypt"]);
|
||||
this._cbcKey = await window.crypto.subtle.importKey(
|
||||
"raw", key, {name: "AES-CBC"}, false, ["encrypt"]);
|
||||
await this._initCMAC();
|
||||
}
|
||||
|
||||
async encrypt(algorithm, message) {
|
||||
const ad = algorithm.additionalData;
|
||||
const nonce = algorithm.iv;
|
||||
const nCMAC = await this._computeCMAC(nonce, this._prefixBlock0);
|
||||
const encrypted = await this._encryptCTR(message, nCMAC);
|
||||
const adCMAC = await this._computeCMAC(ad, this._prefixBlock1);
|
||||
const mac = await this._computeCMAC(encrypted, this._prefixBlock2);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
mac[i] ^= nCMAC[i] ^ adCMAC[i];
|
||||
}
|
||||
const res = new Uint8Array(16 + encrypted.length);
|
||||
res.set(encrypted);
|
||||
res.set(mac, encrypted.length);
|
||||
return res;
|
||||
}
|
||||
|
||||
async decrypt(algorithm, data) {
|
||||
const encrypted = data.slice(0, data.length - 16);
|
||||
const ad = algorithm.additionalData;
|
||||
const nonce = algorithm.iv;
|
||||
const mac = data.slice(data.length - 16);
|
||||
const nCMAC = await this._computeCMAC(nonce, this._prefixBlock0);
|
||||
const adCMAC = await this._computeCMAC(ad, this._prefixBlock1);
|
||||
const computedMac = await this._computeCMAC(encrypted, this._prefixBlock2);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
computedMac[i] ^= nCMAC[i] ^ adCMAC[i];
|
||||
}
|
||||
if (computedMac.length !== mac.length) {
|
||||
return null;
|
||||
}
|
||||
for (let i = 0; i < mac.length; i++) {
|
||||
if (computedMac[i] !== mac[i]) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const res = await this._decryptCTR(encrypted, nCMAC);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
export function modPow(b, e, m) {
|
||||
let r = 1n;
|
||||
b = b % m;
|
||||
while (e > 0n) {
|
||||
if ((e & 1n) === 1n) {
|
||||
r = (r * b) % m;
|
||||
}
|
||||
e = e >> 1n;
|
||||
b = (b * b) % m;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
export function bigIntToU8Array(bigint, padLength=0) {
|
||||
let hex = bigint.toString(16);
|
||||
if (padLength === 0) {
|
||||
padLength = Math.ceil(hex.length / 2);
|
||||
}
|
||||
hex = hex.padStart(padLength * 2, '0');
|
||||
const length = hex.length / 2;
|
||||
const arr = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
arr[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function u8ArrayToBigInt(arr) {
|
||||
let hex = '0x';
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
hex += arr[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
return BigInt(hex);
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { AESECBCipher, AESEAXCipher } from "./aes.js";
|
||||
import { DESCBCCipher, DESECBCipher } from "./des.js";
|
||||
import { RSACipher } from "./rsa.js";
|
||||
import { DHCipher } from "./dh.js";
|
||||
import { MD5 } from "./md5.js";
|
||||
|
||||
// A single interface for the cryptographic algorithms not supported by SubtleCrypto.
|
||||
// Both synchronous and asynchronous implmentations are allowed.
|
||||
class LegacyCrypto {
|
||||
constructor() {
|
||||
this._algorithms = {
|
||||
"AES-ECB": AESECBCipher,
|
||||
"AES-EAX": AESEAXCipher,
|
||||
"DES-ECB": DESECBCipher,
|
||||
"DES-CBC": DESCBCCipher,
|
||||
"RSA-PKCS1-v1_5": RSACipher,
|
||||
"DH": DHCipher,
|
||||
"MD5": MD5,
|
||||
};
|
||||
}
|
||||
|
||||
encrypt(algorithm, key, data) {
|
||||
if (key.algorithm.name !== algorithm.name) {
|
||||
throw new Error("algorithm does not match");
|
||||
}
|
||||
if (typeof key.encrypt !== "function") {
|
||||
throw new Error("key does not support encryption");
|
||||
}
|
||||
return key.encrypt(algorithm, data);
|
||||
}
|
||||
|
||||
decrypt(algorithm, key, data) {
|
||||
if (key.algorithm.name !== algorithm.name) {
|
||||
throw new Error("algorithm does not match");
|
||||
}
|
||||
if (typeof key.decrypt !== "function") {
|
||||
throw new Error("key does not support encryption");
|
||||
}
|
||||
return key.decrypt(algorithm, data);
|
||||
}
|
||||
|
||||
importKey(format, keyData, algorithm, extractable, keyUsages) {
|
||||
if (format !== "raw") {
|
||||
throw new Error("key format is not supported");
|
||||
}
|
||||
const alg = this._algorithms[algorithm.name];
|
||||
if (typeof alg === "undefined" || typeof alg.importKey !== "function") {
|
||||
throw new Error("algorithm is not supported");
|
||||
}
|
||||
return alg.importKey(keyData, algorithm, extractable, keyUsages);
|
||||
}
|
||||
|
||||
generateKey(algorithm, extractable, keyUsages) {
|
||||
const alg = this._algorithms[algorithm.name];
|
||||
if (typeof alg === "undefined" || typeof alg.generateKey !== "function") {
|
||||
throw new Error("algorithm is not supported");
|
||||
}
|
||||
return alg.generateKey(algorithm, extractable, keyUsages);
|
||||
}
|
||||
|
||||
exportKey(format, key) {
|
||||
if (format !== "raw") {
|
||||
throw new Error("key format is not supported");
|
||||
}
|
||||
if (typeof key.exportKey !== "function") {
|
||||
throw new Error("key does not support exportKey");
|
||||
}
|
||||
return key.exportKey();
|
||||
}
|
||||
|
||||
digest(algorithm, data) {
|
||||
const alg = this._algorithms[algorithm];
|
||||
if (typeof alg !== "function") {
|
||||
throw new Error("algorithm is not supported");
|
||||
}
|
||||
return alg(data);
|
||||
}
|
||||
|
||||
deriveBits(algorithm, key, length) {
|
||||
if (key.algorithm.name !== algorithm.name) {
|
||||
throw new Error("algorithm does not match");
|
||||
}
|
||||
if (typeof key.deriveBits !== "function") {
|
||||
throw new Error("key does not support deriveBits");
|
||||
}
|
||||
return key.deriveBits(algorithm, length);
|
||||
}
|
||||
}
|
||||
|
||||
export default new LegacyCrypto;
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
/*
|
||||
* Ported from Flashlight VNC ActionScript implementation:
|
||||
* http://www.wizhelp.com/flashlight-vnc/
|
||||
*
|
||||
* Full attribution follows:
|
||||
*
|
||||
* -------------------------------------------------------------------------
|
||||
*
|
||||
* This DES class has been extracted from package Acme.Crypto for use in VNC.
|
||||
* The unnecessary odd parity code has been removed.
|
||||
*
|
||||
* These changes are:
|
||||
* Copyright (C) 1999 AT&T Laboratories Cambridge. All Rights Reserved.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
*
|
||||
|
||||
* DesCipher - the DES encryption method
|
||||
*
|
||||
* The meat of this code is by Dave Zimmerman <dzimm@widget.com>, and is:
|
||||
*
|
||||
* Copyright (c) 1996 Widget Workshop, Inc. All Rights Reserved.
|
||||
*
|
||||
* Permission to use, copy, modify, and distribute this software
|
||||
* and its documentation for NON-COMMERCIAL or COMMERCIAL purposes and
|
||||
* without fee is hereby granted, provided that this copyright notice is kept
|
||||
* intact.
|
||||
*
|
||||
* WIDGET WORKSHOP MAKES NO REPRESENTATIONS OR WARRANTIES ABOUT THE SUITABILITY
|
||||
* OF THE SOFTWARE, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
* TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
* PARTICULAR PURPOSE, OR NON-INFRINGEMENT. WIDGET WORKSHOP SHALL NOT BE LIABLE
|
||||
* FOR ANY DAMAGES SUFFERED BY LICENSEE AS A RESULT OF USING, MODIFYING OR
|
||||
* DISTRIBUTING THIS SOFTWARE OR ITS DERIVATIVES.
|
||||
*
|
||||
* THIS SOFTWARE IS NOT DESIGNED OR INTENDED FOR USE OR RESALE AS ON-LINE
|
||||
* CONTROL EQUIPMENT IN HAZARDOUS ENVIRONMENTS REQUIRING FAIL-SAFE
|
||||
* PERFORMANCE, SUCH AS IN THE OPERATION OF NUCLEAR FACILITIES, AIRCRAFT
|
||||
* NAVIGATION OR COMMUNICATION SYSTEMS, AIR TRAFFIC CONTROL, DIRECT LIFE
|
||||
* SUPPORT MACHINES, OR WEAPONS SYSTEMS, IN WHICH THE FAILURE OF THE
|
||||
* SOFTWARE COULD LEAD DIRECTLY TO DEATH, PERSONAL INJURY, OR SEVERE
|
||||
* PHYSICAL OR ENVIRONMENTAL DAMAGE ("HIGH RISK ACTIVITIES"). WIDGET WORKSHOP
|
||||
* SPECIFICALLY DISCLAIMS ANY EXPRESS OR IMPLIED WARRANTY OF FITNESS FOR
|
||||
* HIGH RISK ACTIVITIES.
|
||||
*
|
||||
*
|
||||
* The rest is:
|
||||
*
|
||||
* Copyright (C) 1996 by Jef Poskanzer <jef@acme.com>. All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions
|
||||
* are met:
|
||||
* 1. Redistributions of source code must retain the above copyright
|
||||
* notice, this list of conditions and the following disclaimer.
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
||||
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
* SUCH DAMAGE.
|
||||
*
|
||||
* Visit the ACME Labs Java page for up-to-date versions of this and other
|
||||
* fine Java utilities: http://www.acme.com/java/
|
||||
*/
|
||||
|
||||
/* eslint-disable comma-spacing */
|
||||
|
||||
// Tables, permutations, S-boxes, etc.
|
||||
const PC2 = [13,16,10,23, 0, 4, 2,27,14, 5,20, 9,22,18,11, 3,
|
||||
25, 7,15, 6,26,19,12, 1,40,51,30,36,46,54,29,39,
|
||||
50,44,32,47,43,48,38,55,33,52,45,41,49,35,28,31 ],
|
||||
totrot = [ 1, 2, 4, 6, 8,10,12,14,15,17,19,21,23,25,27,28];
|
||||
|
||||
const z = 0x0;
|
||||
let a,b,c,d,e,f;
|
||||
a=1<<16; b=1<<24; c=a|b; d=1<<2; e=1<<10; f=d|e;
|
||||
const SP1 = [c|e,z|z,a|z,c|f,c|d,a|f,z|d,a|z,z|e,c|e,c|f,z|e,b|f,c|d,b|z,z|d,
|
||||
z|f,b|e,b|e,a|e,a|e,c|z,c|z,b|f,a|d,b|d,b|d,a|d,z|z,z|f,a|f,b|z,
|
||||
a|z,c|f,z|d,c|z,c|e,b|z,b|z,z|e,c|d,a|z,a|e,b|d,z|e,z|d,b|f,a|f,
|
||||
c|f,a|d,c|z,b|f,b|d,z|f,a|f,c|e,z|f,b|e,b|e,z|z,a|d,a|e,z|z,c|d];
|
||||
a=1<<20; b=1<<31; c=a|b; d=1<<5; e=1<<15; f=d|e;
|
||||
const SP2 = [c|f,b|e,z|e,a|f,a|z,z|d,c|d,b|f,b|d,c|f,c|e,b|z,b|e,a|z,z|d,c|d,
|
||||
a|e,a|d,b|f,z|z,b|z,z|e,a|f,c|z,a|d,b|d,z|z,a|e,z|f,c|e,c|z,z|f,
|
||||
z|z,a|f,c|d,a|z,b|f,c|z,c|e,z|e,c|z,b|e,z|d,c|f,a|f,z|d,z|e,b|z,
|
||||
z|f,c|e,a|z,b|d,a|d,b|f,b|d,a|d,a|e,z|z,b|e,z|f,b|z,c|d,c|f,a|e];
|
||||
a=1<<17; b=1<<27; c=a|b; d=1<<3; e=1<<9; f=d|e;
|
||||
const SP3 = [z|f,c|e,z|z,c|d,b|e,z|z,a|f,b|e,a|d,b|d,b|d,a|z,c|f,a|d,c|z,z|f,
|
||||
b|z,z|d,c|e,z|e,a|e,c|z,c|d,a|f,b|f,a|e,a|z,b|f,z|d,c|f,z|e,b|z,
|
||||
c|e,b|z,a|d,z|f,a|z,c|e,b|e,z|z,z|e,a|d,c|f,b|e,b|d,z|e,z|z,c|d,
|
||||
b|f,a|z,b|z,c|f,z|d,a|f,a|e,b|d,c|z,b|f,z|f,c|z,a|f,z|d,c|d,a|e];
|
||||
a=1<<13; b=1<<23; c=a|b; d=1<<0; e=1<<7; f=d|e;
|
||||
const SP4 = [c|d,a|f,a|f,z|e,c|e,b|f,b|d,a|d,z|z,c|z,c|z,c|f,z|f,z|z,b|e,b|d,
|
||||
z|d,a|z,b|z,c|d,z|e,b|z,a|d,a|e,b|f,z|d,a|e,b|e,a|z,c|e,c|f,z|f,
|
||||
b|e,b|d,c|z,c|f,z|f,z|z,z|z,c|z,a|e,b|e,b|f,z|d,c|d,a|f,a|f,z|e,
|
||||
c|f,z|f,z|d,a|z,b|d,a|d,c|e,b|f,a|d,a|e,b|z,c|d,z|e,b|z,a|z,c|e];
|
||||
a=1<<25; b=1<<30; c=a|b; d=1<<8; e=1<<19; f=d|e;
|
||||
const SP5 = [z|d,a|f,a|e,c|d,z|e,z|d,b|z,a|e,b|f,z|e,a|d,b|f,c|d,c|e,z|f,b|z,
|
||||
a|z,b|e,b|e,z|z,b|d,c|f,c|f,a|d,c|e,b|d,z|z,c|z,a|f,a|z,c|z,z|f,
|
||||
z|e,c|d,z|d,a|z,b|z,a|e,c|d,b|f,a|d,b|z,c|e,a|f,b|f,z|d,a|z,c|e,
|
||||
c|f,z|f,c|z,c|f,a|e,z|z,b|e,c|z,z|f,a|d,b|d,z|e,z|z,b|e,a|f,b|d];
|
||||
a=1<<22; b=1<<29; c=a|b; d=1<<4; e=1<<14; f=d|e;
|
||||
const SP6 = [b|d,c|z,z|e,c|f,c|z,z|d,c|f,a|z,b|e,a|f,a|z,b|d,a|d,b|e,b|z,z|f,
|
||||
z|z,a|d,b|f,z|e,a|e,b|f,z|d,c|d,c|d,z|z,a|f,c|e,z|f,a|e,c|e,b|z,
|
||||
b|e,z|d,c|d,a|e,c|f,a|z,z|f,b|d,a|z,b|e,b|z,z|f,b|d,c|f,a|e,c|z,
|
||||
a|f,c|e,z|z,c|d,z|d,z|e,c|z,a|f,z|e,a|d,b|f,z|z,c|e,b|z,a|d,b|f];
|
||||
a=1<<21; b=1<<26; c=a|b; d=1<<1; e=1<<11; f=d|e;
|
||||
const SP7 = [a|z,c|d,b|f,z|z,z|e,b|f,a|f,c|e,c|f,a|z,z|z,b|d,z|d,b|z,c|d,z|f,
|
||||
b|e,a|f,a|d,b|e,b|d,c|z,c|e,a|d,c|z,z|e,z|f,c|f,a|e,z|d,b|z,a|e,
|
||||
b|z,a|e,a|z,b|f,b|f,c|d,c|d,z|d,a|d,b|z,b|e,a|z,c|e,z|f,a|f,c|e,
|
||||
z|f,b|d,c|f,c|z,a|e,z|z,z|d,c|f,z|z,a|f,c|z,z|e,b|d,b|e,z|e,a|d];
|
||||
a=1<<18; b=1<<28; c=a|b; d=1<<6; e=1<<12; f=d|e;
|
||||
const SP8 = [b|f,z|e,a|z,c|f,b|z,b|f,z|d,b|z,a|d,c|z,c|f,a|e,c|e,a|f,z|e,z|d,
|
||||
c|z,b|d,b|e,z|f,a|e,a|d,c|d,c|e,z|f,z|z,z|z,c|d,b|d,b|e,a|f,a|z,
|
||||
a|f,a|z,c|e,z|e,z|d,c|d,z|e,a|f,b|e,z|d,b|d,c|z,c|d,b|z,a|z,b|f,
|
||||
z|z,c|f,a|d,b|d,c|z,b|e,b|f,z|z,c|f,a|e,a|e,z|f,z|f,a|d,b|z,c|e];
|
||||
|
||||
/* eslint-enable comma-spacing */
|
||||
|
||||
class DES {
|
||||
constructor(password) {
|
||||
this.keys = [];
|
||||
|
||||
// Set the key.
|
||||
const pc1m = [], pcr = [], kn = [];
|
||||
|
||||
for (let j = 0, l = 56; j < 56; ++j, l -= 8) {
|
||||
l += l < -5 ? 65 : l < -3 ? 31 : l < -1 ? 63 : l === 27 ? 35 : 0; // PC1
|
||||
const m = l & 0x7;
|
||||
pc1m[j] = ((password[l >>> 3] & (1<<m)) !== 0) ? 1: 0;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 16; ++i) {
|
||||
const m = i << 1;
|
||||
const n = m + 1;
|
||||
kn[m] = kn[n] = 0;
|
||||
for (let o = 28; o < 59; o += 28) {
|
||||
for (let j = o - 28; j < o; ++j) {
|
||||
const l = j + totrot[i];
|
||||
pcr[j] = l < o ? pc1m[l] : pc1m[l - 28];
|
||||
}
|
||||
}
|
||||
for (let j = 0; j < 24; ++j) {
|
||||
if (pcr[PC2[j]] !== 0) {
|
||||
kn[m] |= 1 << (23 - j);
|
||||
}
|
||||
if (pcr[PC2[j + 24]] !== 0) {
|
||||
kn[n] |= 1 << (23 - j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cookey
|
||||
for (let i = 0, rawi = 0, KnLi = 0; i < 16; ++i) {
|
||||
const raw0 = kn[rawi++];
|
||||
const raw1 = kn[rawi++];
|
||||
this.keys[KnLi] = (raw0 & 0x00fc0000) << 6;
|
||||
this.keys[KnLi] |= (raw0 & 0x00000fc0) << 10;
|
||||
this.keys[KnLi] |= (raw1 & 0x00fc0000) >>> 10;
|
||||
this.keys[KnLi] |= (raw1 & 0x00000fc0) >>> 6;
|
||||
++KnLi;
|
||||
this.keys[KnLi] = (raw0 & 0x0003f000) << 12;
|
||||
this.keys[KnLi] |= (raw0 & 0x0000003f) << 16;
|
||||
this.keys[KnLi] |= (raw1 & 0x0003f000) >>> 4;
|
||||
this.keys[KnLi] |= (raw1 & 0x0000003f);
|
||||
++KnLi;
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt 8 bytes of text
|
||||
enc8(text) {
|
||||
const b = text.slice();
|
||||
let i = 0, l, r, x; // left, right, accumulator
|
||||
|
||||
// Squash 8 bytes to 2 ints
|
||||
l = b[i++]<<24 | b[i++]<<16 | b[i++]<<8 | b[i++];
|
||||
r = b[i++]<<24 | b[i++]<<16 | b[i++]<<8 | b[i++];
|
||||
|
||||
x = ((l >>> 4) ^ r) & 0x0f0f0f0f;
|
||||
r ^= x;
|
||||
l ^= (x << 4);
|
||||
x = ((l >>> 16) ^ r) & 0x0000ffff;
|
||||
r ^= x;
|
||||
l ^= (x << 16);
|
||||
x = ((r >>> 2) ^ l) & 0x33333333;
|
||||
l ^= x;
|
||||
r ^= (x << 2);
|
||||
x = ((r >>> 8) ^ l) & 0x00ff00ff;
|
||||
l ^= x;
|
||||
r ^= (x << 8);
|
||||
r = (r << 1) | ((r >>> 31) & 1);
|
||||
x = (l ^ r) & 0xaaaaaaaa;
|
||||
l ^= x;
|
||||
r ^= x;
|
||||
l = (l << 1) | ((l >>> 31) & 1);
|
||||
|
||||
for (let i = 0, keysi = 0; i < 8; ++i) {
|
||||
x = (r << 28) | (r >>> 4);
|
||||
x ^= this.keys[keysi++];
|
||||
let fval = SP7[x & 0x3f];
|
||||
fval |= SP5[(x >>> 8) & 0x3f];
|
||||
fval |= SP3[(x >>> 16) & 0x3f];
|
||||
fval |= SP1[(x >>> 24) & 0x3f];
|
||||
x = r ^ this.keys[keysi++];
|
||||
fval |= SP8[x & 0x3f];
|
||||
fval |= SP6[(x >>> 8) & 0x3f];
|
||||
fval |= SP4[(x >>> 16) & 0x3f];
|
||||
fval |= SP2[(x >>> 24) & 0x3f];
|
||||
l ^= fval;
|
||||
x = (l << 28) | (l >>> 4);
|
||||
x ^= this.keys[keysi++];
|
||||
fval = SP7[x & 0x3f];
|
||||
fval |= SP5[(x >>> 8) & 0x3f];
|
||||
fval |= SP3[(x >>> 16) & 0x3f];
|
||||
fval |= SP1[(x >>> 24) & 0x3f];
|
||||
x = l ^ this.keys[keysi++];
|
||||
fval |= SP8[x & 0x0000003f];
|
||||
fval |= SP6[(x >>> 8) & 0x3f];
|
||||
fval |= SP4[(x >>> 16) & 0x3f];
|
||||
fval |= SP2[(x >>> 24) & 0x3f];
|
||||
r ^= fval;
|
||||
}
|
||||
|
||||
r = (r << 31) | (r >>> 1);
|
||||
x = (l ^ r) & 0xaaaaaaaa;
|
||||
l ^= x;
|
||||
r ^= x;
|
||||
l = (l << 31) | (l >>> 1);
|
||||
x = ((l >>> 8) ^ r) & 0x00ff00ff;
|
||||
r ^= x;
|
||||
l ^= (x << 8);
|
||||
x = ((l >>> 2) ^ r) & 0x33333333;
|
||||
r ^= x;
|
||||
l ^= (x << 2);
|
||||
x = ((r >>> 16) ^ l) & 0x0000ffff;
|
||||
l ^= x;
|
||||
r ^= (x << 16);
|
||||
x = ((r >>> 4) ^ l) & 0x0f0f0f0f;
|
||||
l ^= x;
|
||||
r ^= (x << 4);
|
||||
|
||||
// Spread ints to bytes
|
||||
x = [r, l];
|
||||
for (i = 0; i < 8; i++) {
|
||||
b[i] = (x[i>>>2] >>> (8 * (3 - (i % 4)))) % 256;
|
||||
if (b[i] < 0) { b[i] += 256; } // unsigned
|
||||
}
|
||||
return b;
|
||||
}
|
||||
}
|
||||
|
||||
export class DESECBCipher {
|
||||
constructor() {
|
||||
this._cipher = null;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "DES-ECB" };
|
||||
}
|
||||
|
||||
static importKey(key, _algorithm, _extractable, _keyUsages) {
|
||||
const cipher = new DESECBCipher;
|
||||
cipher._importKey(key);
|
||||
return cipher;
|
||||
}
|
||||
|
||||
_importKey(key, _extractable, _keyUsages) {
|
||||
this._cipher = new DES(key);
|
||||
}
|
||||
|
||||
encrypt(_algorithm, plaintext) {
|
||||
const x = new Uint8Array(plaintext);
|
||||
if (x.length % 8 !== 0 || this._cipher === null) {
|
||||
return null;
|
||||
}
|
||||
const n = x.length / 8;
|
||||
for (let i = 0; i < n; i++) {
|
||||
x.set(this._cipher.enc8(x.slice(i * 8, i * 8 + 8)), i * 8);
|
||||
}
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
||||
export class DESCBCCipher {
|
||||
constructor() {
|
||||
this._cipher = null;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "DES-CBC" };
|
||||
}
|
||||
|
||||
static importKey(key, _algorithm, _extractable, _keyUsages) {
|
||||
const cipher = new DESCBCCipher;
|
||||
cipher._importKey(key);
|
||||
return cipher;
|
||||
}
|
||||
|
||||
_importKey(key) {
|
||||
this._cipher = new DES(key);
|
||||
}
|
||||
|
||||
encrypt(algorithm, plaintext) {
|
||||
const x = new Uint8Array(plaintext);
|
||||
let y = new Uint8Array(algorithm.iv);
|
||||
if (x.length % 8 !== 0 || this._cipher === null) {
|
||||
return null;
|
||||
}
|
||||
const n = x.length / 8;
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = 0; j < 8; j++) {
|
||||
y[j] ^= plaintext[i * 8 + j];
|
||||
}
|
||||
y = this._cipher.enc8(y);
|
||||
x.set(y, i * 8);
|
||||
}
|
||||
return x;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { modPow, bigIntToU8Array, u8ArrayToBigInt } from "./bigint.js";
|
||||
|
||||
class DHPublicKey {
|
||||
constructor(key) {
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "DH" };
|
||||
}
|
||||
|
||||
exportKey() {
|
||||
return this._key;
|
||||
}
|
||||
}
|
||||
|
||||
export class DHCipher {
|
||||
constructor() {
|
||||
this._g = null;
|
||||
this._p = null;
|
||||
this._gBigInt = null;
|
||||
this._pBigInt = null;
|
||||
this._privateKey = null;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "DH" };
|
||||
}
|
||||
|
||||
static generateKey(algorithm, _extractable) {
|
||||
const cipher = new DHCipher;
|
||||
cipher._generateKey(algorithm);
|
||||
return { privateKey: cipher, publicKey: new DHPublicKey(cipher._publicKey) };
|
||||
}
|
||||
|
||||
_generateKey(algorithm) {
|
||||
const g = algorithm.g;
|
||||
const p = algorithm.p;
|
||||
this._keyBytes = p.length;
|
||||
this._gBigInt = u8ArrayToBigInt(g);
|
||||
this._pBigInt = u8ArrayToBigInt(p);
|
||||
this._privateKey = window.crypto.getRandomValues(new Uint8Array(this._keyBytes));
|
||||
this._privateKeyBigInt = u8ArrayToBigInt(this._privateKey);
|
||||
this._publicKey = bigIntToU8Array(modPow(
|
||||
this._gBigInt, this._privateKeyBigInt, this._pBigInt), this._keyBytes);
|
||||
}
|
||||
|
||||
deriveBits(algorithm, length) {
|
||||
const bytes = Math.ceil(length / 8);
|
||||
const pkey = new Uint8Array(algorithm.public);
|
||||
const len = bytes > this._keyBytes ? bytes : this._keyBytes;
|
||||
const secret = modPow(u8ArrayToBigInt(pkey), this._privateKeyBigInt, this._pBigInt);
|
||||
return bigIntToU8Array(secret, len).slice(0, len);
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2021 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Performs MD5 hashing on an array of bytes, returns an array of bytes
|
||||
*/
|
||||
|
||||
export async function MD5(d) {
|
||||
let s = "";
|
||||
for (let i = 0; i < d.length; i++) {
|
||||
s += String.fromCharCode(d[i]);
|
||||
}
|
||||
return M(V(Y(X(s), 8 * s.length)));
|
||||
}
|
||||
|
||||
function M(d) {
|
||||
let f = new Uint8Array(d.length);
|
||||
for (let i=0;i<d.length;i++) {
|
||||
f[i] = d.charCodeAt(i);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
function X(d) {
|
||||
let r = Array(d.length >> 2);
|
||||
for (let m = 0; m < r.length; m++) r[m] = 0;
|
||||
for (let m = 0; m < 8 * d.length; m += 8) r[m >> 5] |= (255 & d.charCodeAt(m / 8)) << m % 32;
|
||||
return r;
|
||||
}
|
||||
|
||||
function V(d) {
|
||||
let r = "";
|
||||
for (let m = 0; m < 32 * d.length; m += 8) r += String.fromCharCode(d[m >> 5] >>> m % 32 & 255);
|
||||
return r;
|
||||
}
|
||||
|
||||
function Y(d, g) {
|
||||
d[g >> 5] |= 128 << g % 32, d[14 + (g + 64 >>> 9 << 4)] = g;
|
||||
let m = 1732584193, f = -271733879, r = -1732584194, i = 271733878;
|
||||
for (let n = 0; n < d.length; n += 16) {
|
||||
let h = m,
|
||||
t = f,
|
||||
g = r,
|
||||
e = i;
|
||||
f = ii(f = ii(f = ii(f = ii(f = hh(f = hh(f = hh(f = hh(f = gg(f = gg(f = gg(f = gg(f = ff(f = ff(f = ff(f = ff(f, r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 0], 7, -680876936), f, r, d[n + 1], 12, -389564586), m, f, d[n + 2], 17, 606105819), i, m, d[n + 3], 22, -1044525330), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 4], 7, -176418897), f, r, d[n + 5], 12, 1200080426), m, f, d[n + 6], 17, -1473231341), i, m, d[n + 7], 22, -45705983), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 8], 7, 1770035416), f, r, d[n + 9], 12, -1958414417), m, f, d[n + 10], 17, -42063), i, m, d[n + 11], 22, -1990404162), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 12], 7, 1804603682), f, r, d[n + 13], 12, -40341101), m, f, d[n + 14], 17, -1502002290), i, m, d[n + 15], 22, 1236535329), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 1], 5, -165796510), f, r, d[n + 6], 9, -1069501632), m, f, d[n + 11], 14, 643717713), i, m, d[n + 0], 20, -373897302), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 5], 5, -701558691), f, r, d[n + 10], 9, 38016083), m, f, d[n + 15], 14, -660478335), i, m, d[n + 4], 20, -405537848), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 9], 5, 568446438), f, r, d[n + 14], 9, -1019803690), m, f, d[n + 3], 14, -187363961), i, m, d[n + 8], 20, 1163531501), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 13], 5, -1444681467), f, r, d[n + 2], 9, -51403784), m, f, d[n + 7], 14, 1735328473), i, m, d[n + 12], 20, -1926607734), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 5], 4, -378558), f, r, d[n + 8], 11, -2022574463), m, f, d[n + 11], 16, 1839030562), i, m, d[n + 14], 23, -35309556), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 1], 4, -1530992060), f, r, d[n + 4], 11, 1272893353), m, f, d[n + 7], 16, -155497632), i, m, d[n + 10], 23, -1094730640), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 13], 4, 681279174), f, r, d[n + 0], 11, -358537222), m, f, d[n + 3], 16, -722521979), i, m, d[n + 6], 23, 76029189), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 9], 4, -640364487), f, r, d[n + 12], 11, -421815835), m, f, d[n + 15], 16, 530742520), i, m, d[n + 2], 23, -995338651), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 0], 6, -198630844), f, r, d[n + 7], 10, 1126891415), m, f, d[n + 14], 15, -1416354905), i, m, d[n + 5], 21, -57434055), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 12], 6, 1700485571), f, r, d[n + 3], 10, -1894986606), m, f, d[n + 10], 15, -1051523), i, m, d[n + 1], 21, -2054922799), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 8], 6, 1873313359), f, r, d[n + 15], 10, -30611744), m, f, d[n + 6], 15, -1560198380), i, m, d[n + 13], 21, 1309151649), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 4], 6, -145523070), f, r, d[n + 11], 10, -1120210379), m, f, d[n + 2], 15, 718787259), i, m, d[n + 9], 21, -343485551), m = add(m, h), f = add(f, t), r = add(r, g), i = add(i, e);
|
||||
}
|
||||
return Array(m, f, r, i);
|
||||
}
|
||||
|
||||
function cmn(d, g, m, f, r, i) {
|
||||
return add(rol(add(add(g, d), add(f, i)), r), m);
|
||||
}
|
||||
|
||||
function ff(d, g, m, f, r, i, n) {
|
||||
return cmn(g & m | ~g & f, d, g, r, i, n);
|
||||
}
|
||||
|
||||
function gg(d, g, m, f, r, i, n) {
|
||||
return cmn(g & f | m & ~f, d, g, r, i, n);
|
||||
}
|
||||
|
||||
function hh(d, g, m, f, r, i, n) {
|
||||
return cmn(g ^ m ^ f, d, g, r, i, n);
|
||||
}
|
||||
|
||||
function ii(d, g, m, f, r, i, n) {
|
||||
return cmn(m ^ (g | ~f), d, g, r, i, n);
|
||||
}
|
||||
|
||||
function add(d, g) {
|
||||
let m = (65535 & d) + (65535 & g);
|
||||
return (d >> 16) + (g >> 16) + (m >> 16) << 16 | 65535 & m;
|
||||
}
|
||||
|
||||
function rol(d, g) {
|
||||
return d << g | d >>> 32 - g;
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import Base64 from "../base64.js";
|
||||
import { modPow, bigIntToU8Array, u8ArrayToBigInt } from "./bigint.js";
|
||||
|
||||
export class RSACipher {
|
||||
constructor() {
|
||||
this._keyLength = 0;
|
||||
this._keyBytes = 0;
|
||||
this._n = null;
|
||||
this._e = null;
|
||||
this._d = null;
|
||||
this._nBigInt = null;
|
||||
this._eBigInt = null;
|
||||
this._dBigInt = null;
|
||||
this._extractable = false;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "RSA-PKCS1-v1_5" };
|
||||
}
|
||||
|
||||
_base64urlDecode(data) {
|
||||
data = data.replace(/-/g, "+").replace(/_/g, "/");
|
||||
data = data.padEnd(Math.ceil(data.length / 4) * 4, "=");
|
||||
return Base64.decode(data);
|
||||
}
|
||||
|
||||
_padArray(arr, length) {
|
||||
const res = new Uint8Array(length);
|
||||
res.set(arr, length - arr.length);
|
||||
return res;
|
||||
}
|
||||
|
||||
static async generateKey(algorithm, extractable, _keyUsages) {
|
||||
const cipher = new RSACipher;
|
||||
await cipher._generateKey(algorithm, extractable);
|
||||
return { privateKey: cipher };
|
||||
}
|
||||
|
||||
async _generateKey(algorithm, extractable) {
|
||||
this._keyLength = algorithm.modulusLength;
|
||||
this._keyBytes = Math.ceil(this._keyLength / 8);
|
||||
const key = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
modulusLength: algorithm.modulusLength,
|
||||
publicExponent: algorithm.publicExponent,
|
||||
hash: {name: "SHA-256"},
|
||||
},
|
||||
true, ["encrypt", "decrypt"]);
|
||||
const privateKey = await window.crypto.subtle.exportKey("jwk", key.privateKey);
|
||||
this._n = this._padArray(this._base64urlDecode(privateKey.n), this._keyBytes);
|
||||
this._nBigInt = u8ArrayToBigInt(this._n);
|
||||
this._e = this._padArray(this._base64urlDecode(privateKey.e), this._keyBytes);
|
||||
this._eBigInt = u8ArrayToBigInt(this._e);
|
||||
this._d = this._padArray(this._base64urlDecode(privateKey.d), this._keyBytes);
|
||||
this._dBigInt = u8ArrayToBigInt(this._d);
|
||||
this._extractable = extractable;
|
||||
}
|
||||
|
||||
static async importKey(key, _algorithm, extractable, keyUsages) {
|
||||
if (keyUsages.length !== 1 || keyUsages[0] !== "encrypt") {
|
||||
throw new Error("only support importing RSA public key");
|
||||
}
|
||||
const cipher = new RSACipher;
|
||||
await cipher._importKey(key, extractable);
|
||||
return cipher;
|
||||
}
|
||||
|
||||
async _importKey(key, extractable) {
|
||||
const n = key.n;
|
||||
const e = key.e;
|
||||
if (n.length !== e.length) {
|
||||
throw new Error("the sizes of modulus and public exponent do not match");
|
||||
}
|
||||
this._keyBytes = n.length;
|
||||
this._keyLength = this._keyBytes * 8;
|
||||
this._n = new Uint8Array(this._keyBytes);
|
||||
this._e = new Uint8Array(this._keyBytes);
|
||||
this._n.set(n);
|
||||
this._e.set(e);
|
||||
this._nBigInt = u8ArrayToBigInt(this._n);
|
||||
this._eBigInt = u8ArrayToBigInt(this._e);
|
||||
this._extractable = extractable;
|
||||
}
|
||||
|
||||
async encrypt(_algorithm, message) {
|
||||
if (message.length > this._keyBytes - 11) {
|
||||
return null;
|
||||
}
|
||||
const ps = new Uint8Array(this._keyBytes - message.length - 3);
|
||||
window.crypto.getRandomValues(ps);
|
||||
for (let i = 0; i < ps.length; i++) {
|
||||
ps[i] = Math.floor(ps[i] * 254 / 255 + 1);
|
||||
}
|
||||
const em = new Uint8Array(this._keyBytes);
|
||||
em[1] = 0x02;
|
||||
em.set(ps, 2);
|
||||
em.set(message, ps.length + 3);
|
||||
const emBigInt = u8ArrayToBigInt(em);
|
||||
const c = modPow(emBigInt, this._eBigInt, this._nBigInt);
|
||||
return bigIntToU8Array(c, this._keyBytes);
|
||||
}
|
||||
|
||||
async decrypt(_algorithm, message) {
|
||||
if (message.length !== this._keyBytes) {
|
||||
return null;
|
||||
}
|
||||
const msgBigInt = u8ArrayToBigInt(message);
|
||||
const emBigInt = modPow(msgBigInt, this._dBigInt, this._nBigInt);
|
||||
const em = bigIntToU8Array(emBigInt, this._keyBytes);
|
||||
if (em[0] !== 0x00 || em[1] !== 0x02) {
|
||||
return null;
|
||||
}
|
||||
let i = 2;
|
||||
for (; i < em.length; i++) {
|
||||
if (em[i] === 0x00) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (i === em.length) {
|
||||
return null;
|
||||
}
|
||||
return em.slice(i + 1, em.length);
|
||||
}
|
||||
|
||||
async exportKey() {
|
||||
if (!this._extractable) {
|
||||
throw new Error("key is not extractable");
|
||||
}
|
||||
return { n: this._n, e: this._e, d: this._d };
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
export default class CopyRectDecoder {
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if (sock.rQwait("COPYRECT", 4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let deltaX = sock.rQshift16();
|
||||
let deltaY = sock.rQshift16();
|
||||
|
||||
if ((width === 0) || (height === 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
display.copyImage(deltaX, deltaY, x, y, width, height);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2024 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
import * as Log from '../util/logging.js';
|
||||
|
||||
export class H264Parser {
|
||||
constructor(data) {
|
||||
this._data = data;
|
||||
this._index = 0;
|
||||
this.profileIdc = null;
|
||||
this.constraintSet = null;
|
||||
this.levelIdc = null;
|
||||
}
|
||||
|
||||
_getStartSequenceLen(index) {
|
||||
let data = this._data;
|
||||
if (data[index + 0] == 0 && data[index + 1] == 0 && data[index + 2] == 0 && data[index + 3] == 1) {
|
||||
return 4;
|
||||
}
|
||||
if (data[index + 0] == 0 && data[index + 1] == 0 && data[index + 2] == 1) {
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
_indexOfNextNalUnit(index) {
|
||||
let data = this._data;
|
||||
for (let i = index; i < data.length; ++i) {
|
||||
if (this._getStartSequenceLen(i) != 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
_parseSps(index) {
|
||||
this.profileIdc = this._data[index];
|
||||
this.constraintSet = this._data[index + 1];
|
||||
this.levelIdc = this._data[index + 2];
|
||||
}
|
||||
|
||||
_parseNalUnit(index) {
|
||||
const firstByte = this._data[index];
|
||||
if (firstByte & 0x80) {
|
||||
throw new Error('H264 parsing sanity check failed, forbidden zero bit is set');
|
||||
}
|
||||
const unitType = firstByte & 0x1f;
|
||||
|
||||
switch (unitType) {
|
||||
case 1: // coded slice, non-idr
|
||||
return { slice: true };
|
||||
case 5: // coded slice, idr
|
||||
return { slice: true, key: true };
|
||||
case 6: // sei
|
||||
return {};
|
||||
case 7: // sps
|
||||
this._parseSps(index + 1);
|
||||
return {};
|
||||
case 8: // pps
|
||||
return {};
|
||||
default:
|
||||
Log.Warn("Unhandled unit type: ", unitType);
|
||||
break;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
parse() {
|
||||
const startIndex = this._index;
|
||||
let isKey = false;
|
||||
|
||||
while (this._index < this._data.length) {
|
||||
const startSequenceLen = this._getStartSequenceLen(this._index);
|
||||
if (startSequenceLen == 0) {
|
||||
throw new Error('Invalid start sequence in bit stream');
|
||||
}
|
||||
|
||||
const { slice, key } = this._parseNalUnit(this._index + startSequenceLen);
|
||||
|
||||
let nextIndex = this._indexOfNextNalUnit(this._index + startSequenceLen);
|
||||
if (nextIndex == -1) {
|
||||
this._index = this._data.length;
|
||||
} else {
|
||||
this._index = nextIndex;
|
||||
}
|
||||
|
||||
if (key) {
|
||||
isKey = true;
|
||||
}
|
||||
if (slice) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startIndex === this._index) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
frame: this._data.subarray(startIndex, this._index),
|
||||
key: isKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class H264Context {
|
||||
constructor(width, height) {
|
||||
this.lastUsed = 0;
|
||||
this._width = width;
|
||||
this._height = height;
|
||||
this._profileIdc = null;
|
||||
this._constraintSet = null;
|
||||
this._levelIdc = null;
|
||||
this._decoder = null;
|
||||
this._pendingFrames = [];
|
||||
}
|
||||
|
||||
_handleFrame(frame) {
|
||||
let pending = this._pendingFrames.shift();
|
||||
if (pending === undefined) {
|
||||
throw new Error("Pending frame queue empty when receiving frame from decoder");
|
||||
}
|
||||
|
||||
if (pending.timestamp != frame.timestamp) {
|
||||
throw new Error("Video frame timestamp mismatch. Expected " +
|
||||
frame.timestamp + " but but got " + pending.timestamp);
|
||||
}
|
||||
|
||||
pending.frame = frame;
|
||||
pending.ready = true;
|
||||
pending.resolve();
|
||||
|
||||
if (!pending.keep) {
|
||||
frame.close();
|
||||
}
|
||||
}
|
||||
|
||||
_handleError(e) {
|
||||
throw new Error("Failed to decode frame: " + e.message);
|
||||
}
|
||||
|
||||
_configureDecoder(profileIdc, constraintSet, levelIdc) {
|
||||
if (this._decoder === null || this._decoder.state === 'closed') {
|
||||
this._decoder = new VideoDecoder({
|
||||
output: frame => this._handleFrame(frame),
|
||||
error: e => this._handleError(e),
|
||||
});
|
||||
}
|
||||
const codec = 'avc1.' +
|
||||
profileIdc.toString(16).padStart(2, '0') +
|
||||
constraintSet.toString(16).padStart(2, '0') +
|
||||
levelIdc.toString(16).padStart(2, '0');
|
||||
this._decoder.configure({
|
||||
codec: codec,
|
||||
codedWidth: this._width,
|
||||
codedHeight: this._height,
|
||||
optimizeForLatency: true,
|
||||
});
|
||||
}
|
||||
|
||||
_preparePendingFrame(timestamp) {
|
||||
let pending = {
|
||||
timestamp: timestamp,
|
||||
promise: null,
|
||||
resolve: null,
|
||||
frame: null,
|
||||
ready: false,
|
||||
keep: false,
|
||||
};
|
||||
pending.promise = new Promise((resolve) => {
|
||||
pending.resolve = resolve;
|
||||
});
|
||||
this._pendingFrames.push(pending);
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
decode(payload) {
|
||||
let parser = new H264Parser(payload);
|
||||
let result = null;
|
||||
|
||||
// Ideally, this timestamp should come from the server, but we'll just
|
||||
// approximate it instead.
|
||||
let timestamp = Math.round(window.performance.now() * 1e3);
|
||||
|
||||
while (true) {
|
||||
let encodedFrame = parser.parse();
|
||||
if (encodedFrame === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (parser.profileIdc !== null) {
|
||||
self._profileIdc = parser.profileIdc;
|
||||
self._constraintSet = parser.constraintSet;
|
||||
self._levelIdc = parser.levelIdc;
|
||||
}
|
||||
|
||||
if (this._decoder === null || this._decoder.state !== 'configured') {
|
||||
if (!encodedFrame.key) {
|
||||
Log.Warn("Missing key frame. Can't decode until one arrives");
|
||||
continue;
|
||||
}
|
||||
if (self._profileIdc === null) {
|
||||
Log.Warn('Cannot config decoder. Have not received SPS and PPS yet.');
|
||||
continue;
|
||||
}
|
||||
this._configureDecoder(self._profileIdc, self._constraintSet,
|
||||
self._levelIdc);
|
||||
}
|
||||
|
||||
result = this._preparePendingFrame(timestamp);
|
||||
|
||||
const chunk = new EncodedVideoChunk({
|
||||
timestamp: timestamp,
|
||||
type: encodedFrame.key ? 'key' : 'delta',
|
||||
data: encodedFrame.frame,
|
||||
});
|
||||
|
||||
try {
|
||||
this._decoder.decode(chunk);
|
||||
} catch (e) {
|
||||
Log.Warn("Failed to decode:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// We only keep last frame of each payload
|
||||
if (result !== null) {
|
||||
result.keep = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default class H264Decoder {
|
||||
constructor() {
|
||||
this._tick = 0;
|
||||
this._contexts = {};
|
||||
}
|
||||
|
||||
_contextId(x, y, width, height) {
|
||||
return [x, y, width, height].join(',');
|
||||
}
|
||||
|
||||
_findOldestContextId() {
|
||||
let oldestTick = Number.MAX_VALUE;
|
||||
let oldestKey = undefined;
|
||||
for (const [key, value] of Object.entries(this._contexts)) {
|
||||
if (value.lastUsed < oldestTick) {
|
||||
oldestTick = value.lastUsed;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
return oldestKey;
|
||||
}
|
||||
|
||||
_createContext(x, y, width, height) {
|
||||
const maxContexts = 64;
|
||||
if (Object.keys(this._contexts).length >= maxContexts) {
|
||||
let oldestContextId = this._findOldestContextId();
|
||||
delete this._contexts[oldestContextId];
|
||||
}
|
||||
let context = new H264Context(width, height);
|
||||
this._contexts[this._contextId(x, y, width, height)] = context;
|
||||
return context;
|
||||
}
|
||||
|
||||
_getContext(x, y, width, height) {
|
||||
let context = this._contexts[this._contextId(x, y, width, height)];
|
||||
return context !== undefined ? context : this._createContext(x, y, width, height);
|
||||
}
|
||||
|
||||
_resetContext(x, y, width, height) {
|
||||
delete this._contexts[this._contextId(x, y, width, height)];
|
||||
}
|
||||
|
||||
_resetAllContexts() {
|
||||
this._contexts = {};
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
const resetContextFlag = 1;
|
||||
const resetAllContextsFlag = 2;
|
||||
|
||||
if (sock.rQwait("h264 header", 8)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const length = sock.rQshift32();
|
||||
const flags = sock.rQshift32();
|
||||
|
||||
if (sock.rQwait("h264 payload", length, 8)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (flags & resetAllContextsFlag) {
|
||||
this._resetAllContexts();
|
||||
} else if (flags & resetContextFlag) {
|
||||
this._resetContext(x, y, width, height);
|
||||
}
|
||||
|
||||
let context = this._getContext(x, y, width, height);
|
||||
context.lastUsed = this._tick++;
|
||||
|
||||
if (length !== 0) {
|
||||
let payload = sock.rQshiftBytes(length, false);
|
||||
let frame = context.decode(payload);
|
||||
if (frame !== null) {
|
||||
display.videoFrame(x, y, width, height, frame);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
import * as Log from '../util/logging.js';
|
||||
|
||||
export default class HextileDecoder {
|
||||
constructor() {
|
||||
this._tiles = 0;
|
||||
this._lastsubencoding = 0;
|
||||
this._tileBuffer = new Uint8Array(16 * 16 * 4);
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if (this._tiles === 0) {
|
||||
this._tilesX = Math.ceil(width / 16);
|
||||
this._tilesY = Math.ceil(height / 16);
|
||||
this._totalTiles = this._tilesX * this._tilesY;
|
||||
this._tiles = this._totalTiles;
|
||||
}
|
||||
|
||||
while (this._tiles > 0) {
|
||||
let bytes = 1;
|
||||
|
||||
if (sock.rQwait("HEXTILE", bytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let subencoding = sock.rQpeek8();
|
||||
if (subencoding > 30) { // Raw
|
||||
throw new Error("Illegal hextile subencoding (subencoding: " +
|
||||
subencoding + ")");
|
||||
}
|
||||
|
||||
const currTile = this._totalTiles - this._tiles;
|
||||
const tileX = currTile % this._tilesX;
|
||||
const tileY = Math.floor(currTile / this._tilesX);
|
||||
const tx = x + tileX * 16;
|
||||
const ty = y + tileY * 16;
|
||||
const tw = Math.min(16, (x + width) - tx);
|
||||
const th = Math.min(16, (y + height) - ty);
|
||||
|
||||
// Figure out how much we are expecting
|
||||
if (subencoding & 0x01) { // Raw
|
||||
bytes += tw * th * 4;
|
||||
} else {
|
||||
if (subencoding & 0x02) { // Background
|
||||
bytes += 4;
|
||||
}
|
||||
if (subencoding & 0x04) { // Foreground
|
||||
bytes += 4;
|
||||
}
|
||||
if (subencoding & 0x08) { // AnySubrects
|
||||
bytes++; // Since we aren't shifting it off
|
||||
|
||||
if (sock.rQwait("HEXTILE", bytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let subrects = sock.rQpeekBytes(bytes).at(-1);
|
||||
if (subencoding & 0x10) { // SubrectsColoured
|
||||
bytes += subrects * (4 + 2);
|
||||
} else {
|
||||
bytes += subrects * 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sock.rQwait("HEXTILE", bytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We know the encoding and have a whole tile
|
||||
sock.rQshift8();
|
||||
if (subencoding === 0) {
|
||||
if (this._lastsubencoding & 0x01) {
|
||||
// Weird: ignore blanks are RAW
|
||||
Log.Debug(" Ignoring blank after RAW");
|
||||
} else {
|
||||
display.fillRect(tx, ty, tw, th, this._background);
|
||||
}
|
||||
} else if (subencoding & 0x01) { // Raw
|
||||
let pixels = tw * th;
|
||||
let data = sock.rQshiftBytes(pixels * 4, false);
|
||||
// Max sure the image is fully opaque
|
||||
for (let i = 0;i < pixels;i++) {
|
||||
data[i * 4 + 3] = 255;
|
||||
}
|
||||
display.blitImage(tx, ty, tw, th, data, 0);
|
||||
} else {
|
||||
if (subencoding & 0x02) { // Background
|
||||
this._background = new Uint8Array(sock.rQshiftBytes(4));
|
||||
}
|
||||
if (subencoding & 0x04) { // Foreground
|
||||
this._foreground = new Uint8Array(sock.rQshiftBytes(4));
|
||||
}
|
||||
|
||||
this._startTile(tx, ty, tw, th, this._background);
|
||||
if (subencoding & 0x08) { // AnySubrects
|
||||
let subrects = sock.rQshift8();
|
||||
|
||||
for (let s = 0; s < subrects; s++) {
|
||||
let color;
|
||||
if (subencoding & 0x10) { // SubrectsColoured
|
||||
color = sock.rQshiftBytes(4);
|
||||
} else {
|
||||
color = this._foreground;
|
||||
}
|
||||
const xy = sock.rQshift8();
|
||||
const sx = (xy >> 4);
|
||||
const sy = (xy & 0x0f);
|
||||
|
||||
const wh = sock.rQshift8();
|
||||
const sw = (wh >> 4) + 1;
|
||||
const sh = (wh & 0x0f) + 1;
|
||||
|
||||
this._subTile(sx, sy, sw, sh, color);
|
||||
}
|
||||
}
|
||||
this._finishTile(display);
|
||||
}
|
||||
this._lastsubencoding = subencoding;
|
||||
this._tiles--;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// start updating a tile
|
||||
_startTile(x, y, width, height, color) {
|
||||
this._tileX = x;
|
||||
this._tileY = y;
|
||||
this._tileW = width;
|
||||
this._tileH = height;
|
||||
|
||||
const red = color[0];
|
||||
const green = color[1];
|
||||
const blue = color[2];
|
||||
|
||||
const data = this._tileBuffer;
|
||||
for (let i = 0; i < width * height * 4; i += 4) {
|
||||
data[i] = red;
|
||||
data[i + 1] = green;
|
||||
data[i + 2] = blue;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// update sub-rectangle of the current tile
|
||||
_subTile(x, y, w, h, color) {
|
||||
const red = color[0];
|
||||
const green = color[1];
|
||||
const blue = color[2];
|
||||
const xend = x + w;
|
||||
const yend = y + h;
|
||||
|
||||
const data = this._tileBuffer;
|
||||
const width = this._tileW;
|
||||
for (let j = y; j < yend; j++) {
|
||||
for (let i = x; i < xend; i++) {
|
||||
const p = (i + (j * width)) * 4;
|
||||
data[p] = red;
|
||||
data[p + 1] = green;
|
||||
data[p + 2] = blue;
|
||||
data[p + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// draw the current tile to the screen
|
||||
_finishTile(display) {
|
||||
display.blitImage(this._tileX, this._tileY,
|
||||
this._tileW, this._tileH,
|
||||
this._tileBuffer, 0);
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
export default class JPEGDecoder {
|
||||
constructor() {
|
||||
// RealVNC will reuse the quantization tables
|
||||
// and Huffman tables, so we need to cache them.
|
||||
this._cachedQuantTables = [];
|
||||
this._cachedHuffmanTables = [];
|
||||
|
||||
this._segments = [];
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
// A rect of JPEG encodings is simply a JPEG file
|
||||
while (true) {
|
||||
let segment = this._readSegment(sock);
|
||||
if (segment === null) {
|
||||
return false;
|
||||
}
|
||||
this._segments.push(segment);
|
||||
// End of image?
|
||||
if (segment[1] === 0xD9) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let huffmanTables = [];
|
||||
let quantTables = [];
|
||||
for (let segment of this._segments) {
|
||||
let type = segment[1];
|
||||
if (type === 0xC4) {
|
||||
// Huffman tables
|
||||
huffmanTables.push(segment);
|
||||
} else if (type === 0xDB) {
|
||||
// Quantization tables
|
||||
quantTables.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
const sofIndex = this._segments.findIndex(
|
||||
x => x[1] == 0xC0 || x[1] == 0xC2
|
||||
);
|
||||
if (sofIndex == -1) {
|
||||
throw new Error("Illegal JPEG image without SOF");
|
||||
}
|
||||
|
||||
if (quantTables.length === 0) {
|
||||
this._segments.splice(sofIndex+1, 0,
|
||||
...this._cachedQuantTables);
|
||||
}
|
||||
if (huffmanTables.length === 0) {
|
||||
this._segments.splice(sofIndex+1, 0,
|
||||
...this._cachedHuffmanTables);
|
||||
}
|
||||
|
||||
let length = 0;
|
||||
for (let segment of this._segments) {
|
||||
length += segment.length;
|
||||
}
|
||||
|
||||
let data = new Uint8Array(length);
|
||||
length = 0;
|
||||
for (let segment of this._segments) {
|
||||
data.set(segment, length);
|
||||
length += segment.length;
|
||||
}
|
||||
|
||||
display.imageRect(x, y, width, height, "image/jpeg", data);
|
||||
|
||||
if (huffmanTables.length !== 0) {
|
||||
this._cachedHuffmanTables = huffmanTables;
|
||||
}
|
||||
if (quantTables.length !== 0) {
|
||||
this._cachedQuantTables = quantTables;
|
||||
}
|
||||
|
||||
this._segments = [];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_readSegment(sock) {
|
||||
if (sock.rQwait("JPEG", 2)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let marker = sock.rQshift8();
|
||||
if (marker != 0xFF) {
|
||||
throw new Error("Illegal JPEG marker received (byte: " +
|
||||
marker + ")");
|
||||
}
|
||||
let type = sock.rQshift8();
|
||||
if (type >= 0xD0 && type <= 0xD9 || type == 0x01) {
|
||||
// No length after marker
|
||||
return new Uint8Array([marker, type]);
|
||||
}
|
||||
|
||||
if (sock.rQwait("JPEG", 2, 2)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let length = sock.rQshift16();
|
||||
if (length < 2) {
|
||||
throw new Error("Illegal JPEG length received (length: " +
|
||||
length + ")");
|
||||
}
|
||||
|
||||
if (sock.rQwait("JPEG", length-2, 4)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let extra = 0;
|
||||
if (type === 0xDA) {
|
||||
// start of scan
|
||||
extra += 2;
|
||||
while (true) {
|
||||
if (sock.rQwait("JPEG", length-2+extra, 4)) {
|
||||
return null;
|
||||
}
|
||||
let data = sock.rQpeekBytes(length-2+extra, false);
|
||||
if (data.at(-2) === 0xFF && data.at(-1) !== 0x00 &&
|
||||
!(data.at(-1) >= 0xD0 && data.at(-1) <= 0xD7)) {
|
||||
extra -= 2;
|
||||
break;
|
||||
}
|
||||
extra++;
|
||||
}
|
||||
}
|
||||
|
||||
let segment = new Uint8Array(2 + length + extra);
|
||||
segment[0] = marker;
|
||||
segment[1] = type;
|
||||
segment[2] = length >> 8;
|
||||
segment[3] = length;
|
||||
segment.set(sock.rQshiftBytes(length-2+extra, false), 4);
|
||||
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
export default class RawDecoder {
|
||||
constructor() {
|
||||
this._lines = 0;
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if ((width === 0) || (height === 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._lines === 0) {
|
||||
this._lines = height;
|
||||
}
|
||||
|
||||
const pixelSize = depth == 8 ? 1 : 4;
|
||||
const bytesPerLine = width * pixelSize;
|
||||
|
||||
while (this._lines > 0) {
|
||||
if (sock.rQwait("RAW", bytesPerLine)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const curY = y + (height - this._lines);
|
||||
|
||||
let data = sock.rQshiftBytes(bytesPerLine, false);
|
||||
|
||||
// Convert data if needed
|
||||
if (depth == 8) {
|
||||
const newdata = new Uint8Array(width * 4);
|
||||
for (let i = 0; i < width; i++) {
|
||||
newdata[i * 4 + 0] = ((data[i] >> 0) & 0x3) * 255 / 3;
|
||||
newdata[i * 4 + 1] = ((data[i] >> 2) & 0x3) * 255 / 3;
|
||||
newdata[i * 4 + 2] = ((data[i] >> 4) & 0x3) * 255 / 3;
|
||||
newdata[i * 4 + 3] = 255;
|
||||
}
|
||||
data = newdata;
|
||||
}
|
||||
|
||||
// Max sure the image is fully opaque
|
||||
for (let i = 0; i < width; i++) {
|
||||
data[i * 4 + 3] = 255;
|
||||
}
|
||||
|
||||
display.blitImage(x, curY, width, 1, data, 0);
|
||||
this._lines--;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
export default class RREDecoder {
|
||||
constructor() {
|
||||
this._subrects = 0;
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if (this._subrects === 0) {
|
||||
if (sock.rQwait("RRE", 4 + 4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._subrects = sock.rQshift32();
|
||||
|
||||
let color = sock.rQshiftBytes(4); // Background
|
||||
display.fillRect(x, y, width, height, color);
|
||||
}
|
||||
|
||||
while (this._subrects > 0) {
|
||||
if (sock.rQwait("RRE", 4 + 8)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let color = sock.rQshiftBytes(4);
|
||||
let sx = sock.rQshift16();
|
||||
let sy = sock.rQshift16();
|
||||
let swidth = sock.rQshift16();
|
||||
let sheight = sock.rQshift16();
|
||||
display.fillRect(x + sx, y + sy, swidth, sheight, color);
|
||||
|
||||
this._subrects--;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* (c) 2012 Michael Tinglof, Joe Balaz, Les Piech (Mercuri.ca)
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
import * as Log from '../util/logging.js';
|
||||
import Inflator from "../inflator.js";
|
||||
|
||||
export default class TightDecoder {
|
||||
constructor() {
|
||||
this._ctl = null;
|
||||
this._filter = null;
|
||||
this._numColors = 0;
|
||||
this._palette = new Uint8Array(1024); // 256 * 4 (max palette size * max bytes-per-pixel)
|
||||
this._len = 0;
|
||||
|
||||
this._zlibs = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
this._zlibs[i] = new Inflator();
|
||||
}
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if (this._ctl === null) {
|
||||
if (sock.rQwait("TIGHT compression-control", 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._ctl = sock.rQshift8();
|
||||
|
||||
// Reset streams if the server requests it
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if ((this._ctl >> i) & 1) {
|
||||
this._zlibs[i].reset();
|
||||
Log.Info("Reset zlib stream " + i);
|
||||
}
|
||||
}
|
||||
|
||||
// Figure out filter
|
||||
this._ctl = this._ctl >> 4;
|
||||
}
|
||||
|
||||
let ret;
|
||||
|
||||
if (this._ctl === 0x08) {
|
||||
ret = this._fillRect(x, y, width, height,
|
||||
sock, display, depth);
|
||||
} else if (this._ctl === 0x09) {
|
||||
ret = this._jpegRect(x, y, width, height,
|
||||
sock, display, depth);
|
||||
} else if (this._ctl === 0x0A) {
|
||||
ret = this._pngRect(x, y, width, height,
|
||||
sock, display, depth);
|
||||
} else if ((this._ctl & 0x08) == 0) {
|
||||
ret = this._basicRect(this._ctl, x, y, width, height,
|
||||
sock, display, depth);
|
||||
} else {
|
||||
throw new Error("Illegal tight compression received (ctl: " +
|
||||
this._ctl + ")");
|
||||
}
|
||||
|
||||
if (ret) {
|
||||
this._ctl = null;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
_fillRect(x, y, width, height, sock, display, depth) {
|
||||
if (sock.rQwait("TIGHT", 3)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let pixel = sock.rQshiftBytes(3);
|
||||
display.fillRect(x, y, width, height, pixel, false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_jpegRect(x, y, width, height, sock, display, depth) {
|
||||
let data = this._readData(sock);
|
||||
if (data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
display.imageRect(x, y, width, height, "image/jpeg", data);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_pngRect(x, y, width, height, sock, display, depth) {
|
||||
throw new Error("PNG received in standard Tight rect");
|
||||
}
|
||||
|
||||
_basicRect(ctl, x, y, width, height, sock, display, depth) {
|
||||
if (this._filter === null) {
|
||||
if (ctl & 0x4) {
|
||||
if (sock.rQwait("TIGHT", 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._filter = sock.rQshift8();
|
||||
} else {
|
||||
// Implicit CopyFilter
|
||||
this._filter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let streamId = ctl & 0x3;
|
||||
|
||||
let ret;
|
||||
|
||||
switch (this._filter) {
|
||||
case 0: // CopyFilter
|
||||
ret = this._copyFilter(streamId, x, y, width, height,
|
||||
sock, display, depth);
|
||||
break;
|
||||
case 1: // PaletteFilter
|
||||
ret = this._paletteFilter(streamId, x, y, width, height,
|
||||
sock, display, depth);
|
||||
break;
|
||||
case 2: // GradientFilter
|
||||
ret = this._gradientFilter(streamId, x, y, width, height,
|
||||
sock, display, depth);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Illegal tight filter received (ctl: " +
|
||||
this._filter + ")");
|
||||
}
|
||||
|
||||
if (ret) {
|
||||
this._filter = null;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
_copyFilter(streamId, x, y, width, height, sock, display, depth) {
|
||||
const uncompressedSize = width * height * 3;
|
||||
let data;
|
||||
|
||||
if (uncompressedSize === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (uncompressedSize < 12) {
|
||||
if (sock.rQwait("TIGHT", uncompressedSize)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
data = sock.rQshiftBytes(uncompressedSize);
|
||||
} else {
|
||||
data = this._readData(sock);
|
||||
if (data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._zlibs[streamId].setInput(data);
|
||||
data = this._zlibs[streamId].inflate(uncompressedSize);
|
||||
this._zlibs[streamId].setInput(null);
|
||||
}
|
||||
|
||||
let rgbx = new Uint8Array(width * height * 4);
|
||||
for (let i = 0, j = 0; i < width * height * 4; i += 4, j += 3) {
|
||||
rgbx[i] = data[j];
|
||||
rgbx[i + 1] = data[j + 1];
|
||||
rgbx[i + 2] = data[j + 2];
|
||||
rgbx[i + 3] = 255; // Alpha
|
||||
}
|
||||
|
||||
display.blitImage(x, y, width, height, rgbx, 0, false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_paletteFilter(streamId, x, y, width, height, sock, display, depth) {
|
||||
if (this._numColors === 0) {
|
||||
if (sock.rQwait("TIGHT palette", 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const numColors = sock.rQpeek8() + 1;
|
||||
const paletteSize = numColors * 3;
|
||||
|
||||
if (sock.rQwait("TIGHT palette", 1 + paletteSize)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._numColors = numColors;
|
||||
sock.rQskipBytes(1);
|
||||
|
||||
sock.rQshiftTo(this._palette, paletteSize);
|
||||
}
|
||||
|
||||
const bpp = (this._numColors <= 2) ? 1 : 8;
|
||||
const rowSize = Math.floor((width * bpp + 7) / 8);
|
||||
const uncompressedSize = rowSize * height;
|
||||
|
||||
let data;
|
||||
|
||||
if (uncompressedSize === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (uncompressedSize < 12) {
|
||||
if (sock.rQwait("TIGHT", uncompressedSize)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
data = sock.rQshiftBytes(uncompressedSize);
|
||||
} else {
|
||||
data = this._readData(sock);
|
||||
if (data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._zlibs[streamId].setInput(data);
|
||||
data = this._zlibs[streamId].inflate(uncompressedSize);
|
||||
this._zlibs[streamId].setInput(null);
|
||||
}
|
||||
|
||||
// Convert indexed (palette based) image data to RGB
|
||||
if (this._numColors == 2) {
|
||||
this._monoRect(x, y, width, height, data, this._palette, display);
|
||||
} else {
|
||||
this._paletteRect(x, y, width, height, data, this._palette, display);
|
||||
}
|
||||
|
||||
this._numColors = 0;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_monoRect(x, y, width, height, data, palette, display) {
|
||||
// Convert indexed (palette based) image data to RGB
|
||||
// TODO: reduce number of calculations inside loop
|
||||
const dest = this._getScratchBuffer(width * height * 4);
|
||||
const w = Math.floor((width + 7) / 8);
|
||||
const w1 = Math.floor(width / 8);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
let dp, sp, x;
|
||||
for (x = 0; x < w1; x++) {
|
||||
for (let b = 7; b >= 0; b--) {
|
||||
dp = (y * width + x * 8 + 7 - b) * 4;
|
||||
sp = (data[y * w + x] >> b & 1) * 3;
|
||||
dest[dp] = palette[sp];
|
||||
dest[dp + 1] = palette[sp + 1];
|
||||
dest[dp + 2] = palette[sp + 2];
|
||||
dest[dp + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
for (let b = 7; b >= 8 - width % 8; b--) {
|
||||
dp = (y * width + x * 8 + 7 - b) * 4;
|
||||
sp = (data[y * w + x] >> b & 1) * 3;
|
||||
dest[dp] = palette[sp];
|
||||
dest[dp + 1] = palette[sp + 1];
|
||||
dest[dp + 2] = palette[sp + 2];
|
||||
dest[dp + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
display.blitImage(x, y, width, height, dest, 0, false);
|
||||
}
|
||||
|
||||
_paletteRect(x, y, width, height, data, palette, display) {
|
||||
// Convert indexed (palette based) image data to RGB
|
||||
const dest = this._getScratchBuffer(width * height * 4);
|
||||
const total = width * height * 4;
|
||||
for (let i = 0, j = 0; i < total; i += 4, j++) {
|
||||
const sp = data[j] * 3;
|
||||
dest[i] = palette[sp];
|
||||
dest[i + 1] = palette[sp + 1];
|
||||
dest[i + 2] = palette[sp + 2];
|
||||
dest[i + 3] = 255;
|
||||
}
|
||||
|
||||
display.blitImage(x, y, width, height, dest, 0, false);
|
||||
}
|
||||
|
||||
_gradientFilter(streamId, x, y, width, height, sock, display, depth) {
|
||||
// assume the TPIXEL is 3 bytes long
|
||||
const uncompressedSize = width * height * 3;
|
||||
let data;
|
||||
|
||||
if (uncompressedSize === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (uncompressedSize < 12) {
|
||||
if (sock.rQwait("TIGHT", uncompressedSize)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
data = sock.rQshiftBytes(uncompressedSize);
|
||||
} else {
|
||||
data = this._readData(sock);
|
||||
if (data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._zlibs[streamId].setInput(data);
|
||||
data = this._zlibs[streamId].inflate(uncompressedSize);
|
||||
this._zlibs[streamId].setInput(null);
|
||||
}
|
||||
|
||||
let rgbx = new Uint8Array(4 * width * height);
|
||||
|
||||
let rgbxIndex = 0, dataIndex = 0;
|
||||
let left = new Uint8Array(3);
|
||||
for (let x = 0; x < width; x++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const prediction = left[c];
|
||||
const value = data[dataIndex++] + prediction;
|
||||
rgbx[rgbxIndex++] = value;
|
||||
left[c] = value;
|
||||
}
|
||||
rgbx[rgbxIndex++] = 255;
|
||||
}
|
||||
|
||||
let upperIndex = 0;
|
||||
let upper = new Uint8Array(3),
|
||||
upperleft = new Uint8Array(3);
|
||||
for (let y = 1; y < height; y++) {
|
||||
left.fill(0);
|
||||
upperleft.fill(0);
|
||||
for (let x = 0; x < width; x++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
upper[c] = rgbx[upperIndex++];
|
||||
let prediction = left[c] + upper[c] - upperleft[c];
|
||||
if (prediction < 0) {
|
||||
prediction = 0;
|
||||
} else if (prediction > 255) {
|
||||
prediction = 255;
|
||||
}
|
||||
const value = data[dataIndex++] + prediction;
|
||||
rgbx[rgbxIndex++] = value;
|
||||
upperleft[c] = upper[c];
|
||||
left[c] = value;
|
||||
}
|
||||
rgbx[rgbxIndex++] = 255;
|
||||
upperIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
display.blitImage(x, y, width, height, rgbx, 0, false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_readData(sock) {
|
||||
if (this._len === 0) {
|
||||
if (sock.rQwait("TIGHT", 3)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let byte;
|
||||
|
||||
byte = sock.rQshift8();
|
||||
this._len = byte & 0x7f;
|
||||
if (byte & 0x80) {
|
||||
byte = sock.rQshift8();
|
||||
this._len |= (byte & 0x7f) << 7;
|
||||
if (byte & 0x80) {
|
||||
byte = sock.rQshift8();
|
||||
this._len |= byte << 14;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sock.rQwait("TIGHT", this._len)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let data = sock.rQshiftBytes(this._len, false);
|
||||
this._len = 0;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
_getScratchBuffer(size) {
|
||||
if (!this._scratchBuffer || (this._scratchBuffer.length < size)) {
|
||||
this._scratchBuffer = new Uint8Array(size);
|
||||
}
|
||||
return this._scratchBuffer;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
import TightDecoder from './tight.js';
|
||||
|
||||
export default class TightPNGDecoder extends TightDecoder {
|
||||
_pngRect(x, y, width, height, sock, display, depth) {
|
||||
let data = this._readData(sock);
|
||||
if (data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
display.imageRect(x, y, width, height, "image/png", data);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_basicRect(ctl, x, y, width, height, sock, display, depth) {
|
||||
throw new Error("BasicCompression received in TightPNG rect");
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2024 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
import Inflator from "../inflator.js";
|
||||
|
||||
export default class ZlibDecoder {
|
||||
constructor() {
|
||||
this._zlib = new Inflator();
|
||||
this._length = 0;
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if ((width === 0) || (height === 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._length === 0) {
|
||||
if (sock.rQwait("ZLIB", 4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._length = sock.rQshift32();
|
||||
}
|
||||
|
||||
if (sock.rQwait("ZLIB", this._length)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let data = new Uint8Array(sock.rQshiftBytes(this._length, false));
|
||||
this._length = 0;
|
||||
|
||||
this._zlib.setInput(data);
|
||||
data = this._zlib.inflate(width * height * 4);
|
||||
this._zlib.setInput(null);
|
||||
|
||||
// Max sure the image is fully opaque
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
data[i * 4 + 3] = 255;
|
||||
}
|
||||
|
||||
display.blitImage(x, y, width, height, data, 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2021 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
import Inflate from "../inflator.js";
|
||||
|
||||
const ZRLE_TILE_WIDTH = 64;
|
||||
const ZRLE_TILE_HEIGHT = 64;
|
||||
|
||||
export default class ZRLEDecoder {
|
||||
constructor() {
|
||||
this._length = 0;
|
||||
this._inflator = new Inflate();
|
||||
|
||||
this._pixelBuffer = new Uint8Array(ZRLE_TILE_WIDTH * ZRLE_TILE_HEIGHT * 4);
|
||||
this._tileBuffer = new Uint8Array(ZRLE_TILE_WIDTH * ZRLE_TILE_HEIGHT * 4);
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if (this._length === 0) {
|
||||
if (sock.rQwait("ZLib data length", 4)) {
|
||||
return false;
|
||||
}
|
||||
this._length = sock.rQshift32();
|
||||
}
|
||||
if (sock.rQwait("Zlib data", this._length)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = sock.rQshiftBytes(this._length, false);
|
||||
|
||||
this._inflator.setInput(data);
|
||||
|
||||
for (let ty = y; ty < y + height; ty += ZRLE_TILE_HEIGHT) {
|
||||
let th = Math.min(ZRLE_TILE_HEIGHT, y + height - ty);
|
||||
|
||||
for (let tx = x; tx < x + width; tx += ZRLE_TILE_WIDTH) {
|
||||
let tw = Math.min(ZRLE_TILE_WIDTH, x + width - tx);
|
||||
|
||||
const tileSize = tw * th;
|
||||
const subencoding = this._inflator.inflate(1)[0];
|
||||
if (subencoding === 0) {
|
||||
// raw data
|
||||
const data = this._readPixels(tileSize);
|
||||
display.blitImage(tx, ty, tw, th, data, 0, false);
|
||||
} else if (subencoding === 1) {
|
||||
// solid
|
||||
const background = this._readPixels(1);
|
||||
display.fillRect(tx, ty, tw, th, [background[0], background[1], background[2]]);
|
||||
} else if (subencoding >= 2 && subencoding <= 16) {
|
||||
const data = this._decodePaletteTile(subencoding, tileSize, tw, th);
|
||||
display.blitImage(tx, ty, tw, th, data, 0, false);
|
||||
} else if (subencoding === 128) {
|
||||
const data = this._decodeRLETile(tileSize);
|
||||
display.blitImage(tx, ty, tw, th, data, 0, false);
|
||||
} else if (subencoding >= 130 && subencoding <= 255) {
|
||||
const data = this._decodeRLEPaletteTile(subencoding - 128, tileSize);
|
||||
display.blitImage(tx, ty, tw, th, data, 0, false);
|
||||
} else {
|
||||
throw new Error('Unknown subencoding: ' + subencoding);
|
||||
}
|
||||
}
|
||||
}
|
||||
this._length = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
_getBitsPerPixelInPalette(paletteSize) {
|
||||
if (paletteSize <= 2) {
|
||||
return 1;
|
||||
} else if (paletteSize <= 4) {
|
||||
return 2;
|
||||
} else if (paletteSize <= 16) {
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
_readPixels(pixels) {
|
||||
let data = this._pixelBuffer;
|
||||
const buffer = this._inflator.inflate(3*pixels);
|
||||
for (let i = 0, j = 0; i < pixels*4; i += 4, j += 3) {
|
||||
data[i] = buffer[j];
|
||||
data[i + 1] = buffer[j + 1];
|
||||
data[i + 2] = buffer[j + 2];
|
||||
data[i + 3] = 255; // Add the Alpha
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
_decodePaletteTile(paletteSize, tileSize, tilew, tileh) {
|
||||
const data = this._tileBuffer;
|
||||
const palette = this._readPixels(paletteSize);
|
||||
const bitsPerPixel = this._getBitsPerPixelInPalette(paletteSize);
|
||||
const mask = (1 << bitsPerPixel) - 1;
|
||||
|
||||
let offset = 0;
|
||||
let encoded = this._inflator.inflate(1)[0];
|
||||
|
||||
for (let y=0; y<tileh; y++) {
|
||||
let shift = 8-bitsPerPixel;
|
||||
for (let x=0; x<tilew; x++) {
|
||||
if (shift<0) {
|
||||
shift=8-bitsPerPixel;
|
||||
encoded = this._inflator.inflate(1)[0];
|
||||
}
|
||||
let indexInPalette = (encoded>>shift) & mask;
|
||||
|
||||
data[offset] = palette[indexInPalette * 4];
|
||||
data[offset + 1] = palette[indexInPalette * 4 + 1];
|
||||
data[offset + 2] = palette[indexInPalette * 4 + 2];
|
||||
data[offset + 3] = palette[indexInPalette * 4 + 3];
|
||||
offset += 4;
|
||||
shift-=bitsPerPixel;
|
||||
}
|
||||
if (shift<8-bitsPerPixel && y<tileh-1) {
|
||||
encoded = this._inflator.inflate(1)[0];
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
_decodeRLETile(tileSize) {
|
||||
const data = this._tileBuffer;
|
||||
let i = 0;
|
||||
while (i < tileSize) {
|
||||
const pixel = this._readPixels(1);
|
||||
const length = this._readRLELength();
|
||||
for (let j = 0; j < length; j++) {
|
||||
data[i * 4] = pixel[0];
|
||||
data[i * 4 + 1] = pixel[1];
|
||||
data[i * 4 + 2] = pixel[2];
|
||||
data[i * 4 + 3] = pixel[3];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
_decodeRLEPaletteTile(paletteSize, tileSize) {
|
||||
const data = this._tileBuffer;
|
||||
|
||||
// palette
|
||||
const palette = this._readPixels(paletteSize);
|
||||
|
||||
let offset = 0;
|
||||
while (offset < tileSize) {
|
||||
let indexInPalette = this._inflator.inflate(1)[0];
|
||||
let length = 1;
|
||||
if (indexInPalette >= 128) {
|
||||
indexInPalette -= 128;
|
||||
length = this._readRLELength();
|
||||
}
|
||||
if (indexInPalette > paletteSize) {
|
||||
throw new Error('Too big index in palette: ' + indexInPalette + ', palette size: ' + paletteSize);
|
||||
}
|
||||
if (offset + length > tileSize) {
|
||||
throw new Error('Too big rle length in palette mode: ' + length + ', allowed length is: ' + (tileSize - offset));
|
||||
}
|
||||
|
||||
for (let j = 0; j < length; j++) {
|
||||
data[offset * 4] = palette[indexInPalette * 4];
|
||||
data[offset * 4 + 1] = palette[indexInPalette * 4 + 1];
|
||||
data[offset * 4 + 2] = palette[indexInPalette * 4 + 2];
|
||||
data[offset * 4 + 3] = palette[indexInPalette * 4 + 3];
|
||||
offset++;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
_readRLELength() {
|
||||
let length = 0;
|
||||
let current = 0;
|
||||
do {
|
||||
current = this._inflator.inflate(1)[0];
|
||||
length += current;
|
||||
} while (current === 255);
|
||||
return length + 1;
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2020 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
import { deflateInit, deflate } from "../vendor/pako/lib/zlib/deflate.js";
|
||||
import { Z_FULL_FLUSH, Z_DEFAULT_COMPRESSION } from "../vendor/pako/lib/zlib/deflate.js";
|
||||
import ZStream from "../vendor/pako/lib/zlib/zstream.js";
|
||||
|
||||
export default class Deflator {
|
||||
constructor() {
|
||||
this.strm = new ZStream();
|
||||
this.chunkSize = 1024 * 10 * 10;
|
||||
this.outputBuffer = new Uint8Array(this.chunkSize);
|
||||
|
||||
deflateInit(this.strm, Z_DEFAULT_COMPRESSION);
|
||||
}
|
||||
|
||||
deflate(inData) {
|
||||
/* eslint-disable camelcase */
|
||||
this.strm.input = inData;
|
||||
this.strm.avail_in = this.strm.input.length;
|
||||
this.strm.next_in = 0;
|
||||
this.strm.output = this.outputBuffer;
|
||||
this.strm.avail_out = this.chunkSize;
|
||||
this.strm.next_out = 0;
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
let lastRet = deflate(this.strm, Z_FULL_FLUSH);
|
||||
let outData = new Uint8Array(this.strm.output.buffer, 0, this.strm.next_out);
|
||||
|
||||
if (lastRet < 0) {
|
||||
throw new Error("zlib deflate failed");
|
||||
}
|
||||
|
||||
if (this.strm.avail_in > 0) {
|
||||
// Read chunks until done
|
||||
|
||||
let chunks = [outData];
|
||||
let totalLen = outData.length;
|
||||
do {
|
||||
/* eslint-disable camelcase */
|
||||
this.strm.output = new Uint8Array(this.chunkSize);
|
||||
this.strm.next_out = 0;
|
||||
this.strm.avail_out = this.chunkSize;
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
lastRet = deflate(this.strm, Z_FULL_FLUSH);
|
||||
|
||||
if (lastRet < 0) {
|
||||
throw new Error("zlib deflate failed");
|
||||
}
|
||||
|
||||
let chunk = new Uint8Array(this.strm.output.buffer, 0, this.strm.next_out);
|
||||
totalLen += chunk.length;
|
||||
chunks.push(chunk);
|
||||
} while (this.strm.avail_in > 0);
|
||||
|
||||
// Combine chunks into a single data
|
||||
|
||||
let newData = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
newData.set(chunks[i], offset);
|
||||
offset += chunks[i].length;
|
||||
}
|
||||
|
||||
outData = newData;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
this.strm.input = null;
|
||||
this.strm.avail_in = 0;
|
||||
this.strm.next_in = 0;
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
return outData;
|
||||
}
|
||||
|
||||
}
|
||||
+575
@@ -0,0 +1,575 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
import * as Log from './util/logging.js';
|
||||
import Base64 from "./base64.js";
|
||||
import { toSigned32bit } from './util/int.js';
|
||||
|
||||
export default class Display {
|
||||
constructor(target) {
|
||||
this._drawCtx = null;
|
||||
|
||||
this._renderQ = []; // queue drawing actions for in-oder rendering
|
||||
this._flushPromise = null;
|
||||
|
||||
// the full frame buffer (logical canvas) size
|
||||
this._fbWidth = 0;
|
||||
this._fbHeight = 0;
|
||||
|
||||
this._prevDrawStyle = "";
|
||||
|
||||
Log.Debug(">> Display.constructor");
|
||||
|
||||
// The visible canvas
|
||||
this._target = target;
|
||||
|
||||
if (!this._target) {
|
||||
throw new Error("Target must be set");
|
||||
}
|
||||
|
||||
if (typeof this._target === 'string') {
|
||||
throw new Error('target must be a DOM element');
|
||||
}
|
||||
|
||||
if (!this._target.getContext) {
|
||||
throw new Error("no getContext method");
|
||||
}
|
||||
|
||||
this._targetCtx = this._target.getContext('2d');
|
||||
|
||||
// the visible canvas viewport (i.e. what actually gets seen)
|
||||
this._viewportLoc = { 'x': 0, 'y': 0, 'w': this._target.width, 'h': this._target.height };
|
||||
|
||||
// The hidden canvas, where we do the actual rendering
|
||||
this._backbuffer = document.createElement('canvas');
|
||||
this._drawCtx = this._backbuffer.getContext('2d');
|
||||
|
||||
this._damageBounds = { left: 0, top: 0,
|
||||
right: this._backbuffer.width,
|
||||
bottom: this._backbuffer.height };
|
||||
|
||||
Log.Debug("User Agent: " + navigator.userAgent);
|
||||
|
||||
Log.Debug("<< Display.constructor");
|
||||
|
||||
// ===== PROPERTIES =====
|
||||
|
||||
this._scale = 1.0;
|
||||
this._clipViewport = false;
|
||||
}
|
||||
|
||||
// ===== PROPERTIES =====
|
||||
|
||||
get scale() { return this._scale; }
|
||||
set scale(scale) {
|
||||
this._rescale(scale);
|
||||
}
|
||||
|
||||
get clipViewport() { return this._clipViewport; }
|
||||
set clipViewport(viewport) {
|
||||
this._clipViewport = viewport;
|
||||
// May need to readjust the viewport dimensions
|
||||
const vp = this._viewportLoc;
|
||||
this.viewportChangeSize(vp.w, vp.h);
|
||||
this.viewportChangePos(0, 0);
|
||||
}
|
||||
|
||||
get width() {
|
||||
return this._fbWidth;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this._fbHeight;
|
||||
}
|
||||
|
||||
// ===== PUBLIC METHODS =====
|
||||
|
||||
viewportChangePos(deltaX, deltaY) {
|
||||
const vp = this._viewportLoc;
|
||||
deltaX = Math.floor(deltaX);
|
||||
deltaY = Math.floor(deltaY);
|
||||
|
||||
if (!this._clipViewport) {
|
||||
deltaX = -vp.w; // clamped later of out of bounds
|
||||
deltaY = -vp.h;
|
||||
}
|
||||
|
||||
const vx2 = vp.x + vp.w - 1;
|
||||
const vy2 = vp.y + vp.h - 1;
|
||||
|
||||
// Position change
|
||||
|
||||
if (deltaX < 0 && vp.x + deltaX < 0) {
|
||||
deltaX = -vp.x;
|
||||
}
|
||||
if (vx2 + deltaX >= this._fbWidth) {
|
||||
deltaX -= vx2 + deltaX - this._fbWidth + 1;
|
||||
}
|
||||
|
||||
if (vp.y + deltaY < 0) {
|
||||
deltaY = -vp.y;
|
||||
}
|
||||
if (vy2 + deltaY >= this._fbHeight) {
|
||||
deltaY -= (vy2 + deltaY - this._fbHeight + 1);
|
||||
}
|
||||
|
||||
if (deltaX === 0 && deltaY === 0) {
|
||||
return;
|
||||
}
|
||||
Log.Debug("viewportChange deltaX: " + deltaX + ", deltaY: " + deltaY);
|
||||
|
||||
vp.x += deltaX;
|
||||
vp.y += deltaY;
|
||||
|
||||
this._damage(vp.x, vp.y, vp.w, vp.h);
|
||||
|
||||
this.flip();
|
||||
}
|
||||
|
||||
viewportChangeSize(width, height) {
|
||||
|
||||
if (!this._clipViewport ||
|
||||
typeof(width) === "undefined" ||
|
||||
typeof(height) === "undefined") {
|
||||
|
||||
Log.Debug("Setting viewport to full display region");
|
||||
width = this._fbWidth;
|
||||
height = this._fbHeight;
|
||||
}
|
||||
|
||||
width = Math.floor(width);
|
||||
height = Math.floor(height);
|
||||
|
||||
if (width > this._fbWidth) {
|
||||
width = this._fbWidth;
|
||||
}
|
||||
if (height > this._fbHeight) {
|
||||
height = this._fbHeight;
|
||||
}
|
||||
|
||||
const vp = this._viewportLoc;
|
||||
if (vp.w !== width || vp.h !== height) {
|
||||
vp.w = width;
|
||||
vp.h = height;
|
||||
|
||||
const canvas = this._target;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// The position might need to be updated if we've grown
|
||||
this.viewportChangePos(0, 0);
|
||||
|
||||
this._damage(vp.x, vp.y, vp.w, vp.h);
|
||||
this.flip();
|
||||
|
||||
// Update the visible size of the target canvas
|
||||
this._rescale(this._scale);
|
||||
}
|
||||
}
|
||||
|
||||
absX(x) {
|
||||
if (this._scale === 0) {
|
||||
return 0;
|
||||
}
|
||||
return toSigned32bit(x / this._scale + this._viewportLoc.x);
|
||||
}
|
||||
|
||||
absY(y) {
|
||||
if (this._scale === 0) {
|
||||
return 0;
|
||||
}
|
||||
return toSigned32bit(y / this._scale + this._viewportLoc.y);
|
||||
}
|
||||
|
||||
resize(width, height) {
|
||||
this._prevDrawStyle = "";
|
||||
|
||||
this._fbWidth = width;
|
||||
this._fbHeight = height;
|
||||
|
||||
const canvas = this._backbuffer;
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
|
||||
// We have to save the canvas data since changing the size will clear it
|
||||
let saveImg = null;
|
||||
if (canvas.width > 0 && canvas.height > 0) {
|
||||
saveImg = this._drawCtx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
if (canvas.width !== width) {
|
||||
canvas.width = width;
|
||||
}
|
||||
if (canvas.height !== height) {
|
||||
canvas.height = height;
|
||||
}
|
||||
|
||||
if (saveImg) {
|
||||
this._drawCtx.putImageData(saveImg, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Readjust the viewport as it may be incorrectly sized
|
||||
// and positioned
|
||||
const vp = this._viewportLoc;
|
||||
this.viewportChangeSize(vp.w, vp.h);
|
||||
this.viewportChangePos(0, 0);
|
||||
}
|
||||
|
||||
getImageData() {
|
||||
return this._drawCtx.getImageData(0, 0, this.width, this.height);
|
||||
}
|
||||
|
||||
toDataURL(type, encoderOptions) {
|
||||
return this._backbuffer.toDataURL(type, encoderOptions);
|
||||
}
|
||||
|
||||
toBlob(callback, type, quality) {
|
||||
return this._backbuffer.toBlob(callback, type, quality);
|
||||
}
|
||||
|
||||
// Track what parts of the visible canvas that need updating
|
||||
_damage(x, y, w, h) {
|
||||
if (x < this._damageBounds.left) {
|
||||
this._damageBounds.left = x;
|
||||
}
|
||||
if (y < this._damageBounds.top) {
|
||||
this._damageBounds.top = y;
|
||||
}
|
||||
if ((x + w) > this._damageBounds.right) {
|
||||
this._damageBounds.right = x + w;
|
||||
}
|
||||
if ((y + h) > this._damageBounds.bottom) {
|
||||
this._damageBounds.bottom = y + h;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the visible canvas with the contents of the
|
||||
// rendering canvas
|
||||
flip(fromQueue) {
|
||||
if (this._renderQ.length !== 0 && !fromQueue) {
|
||||
this._renderQPush({
|
||||
'type': 'flip'
|
||||
});
|
||||
} else {
|
||||
let x = this._damageBounds.left;
|
||||
let y = this._damageBounds.top;
|
||||
let w = this._damageBounds.right - x;
|
||||
let h = this._damageBounds.bottom - y;
|
||||
|
||||
let vx = x - this._viewportLoc.x;
|
||||
let vy = y - this._viewportLoc.y;
|
||||
|
||||
if (vx < 0) {
|
||||
w += vx;
|
||||
x -= vx;
|
||||
vx = 0;
|
||||
}
|
||||
if (vy < 0) {
|
||||
h += vy;
|
||||
y -= vy;
|
||||
vy = 0;
|
||||
}
|
||||
|
||||
if ((vx + w) > this._viewportLoc.w) {
|
||||
w = this._viewportLoc.w - vx;
|
||||
}
|
||||
if ((vy + h) > this._viewportLoc.h) {
|
||||
h = this._viewportLoc.h - vy;
|
||||
}
|
||||
|
||||
if ((w > 0) && (h > 0)) {
|
||||
// FIXME: We may need to disable image smoothing here
|
||||
// as well (see copyImage()), but we haven't
|
||||
// noticed any problem yet.
|
||||
this._targetCtx.drawImage(this._backbuffer,
|
||||
x, y, w, h,
|
||||
vx, vy, w, h);
|
||||
}
|
||||
|
||||
this._damageBounds.left = this._damageBounds.top = 65535;
|
||||
this._damageBounds.right = this._damageBounds.bottom = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pending() {
|
||||
return this._renderQ.length > 0;
|
||||
}
|
||||
|
||||
flush() {
|
||||
if (this._renderQ.length === 0) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
if (this._flushPromise === null) {
|
||||
this._flushPromise = new Promise((resolve) => {
|
||||
this._flushResolve = resolve;
|
||||
});
|
||||
}
|
||||
return this._flushPromise;
|
||||
}
|
||||
}
|
||||
|
||||
fillRect(x, y, width, height, color, fromQueue) {
|
||||
if (this._renderQ.length !== 0 && !fromQueue) {
|
||||
this._renderQPush({
|
||||
'type': 'fill',
|
||||
'x': x,
|
||||
'y': y,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'color': color
|
||||
});
|
||||
} else {
|
||||
this._setFillColor(color);
|
||||
this._drawCtx.fillRect(x, y, width, height);
|
||||
this._damage(x, y, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
copyImage(oldX, oldY, newX, newY, w, h, fromQueue) {
|
||||
if (this._renderQ.length !== 0 && !fromQueue) {
|
||||
this._renderQPush({
|
||||
'type': 'copy',
|
||||
'oldX': oldX,
|
||||
'oldY': oldY,
|
||||
'x': newX,
|
||||
'y': newY,
|
||||
'width': w,
|
||||
'height': h,
|
||||
});
|
||||
} else {
|
||||
// Due to this bug among others [1] we need to disable the image-smoothing to
|
||||
// avoid getting a blur effect when copying data.
|
||||
//
|
||||
// 1. https://bugzilla.mozilla.org/show_bug.cgi?id=1194719
|
||||
//
|
||||
// We need to set these every time since all properties are reset
|
||||
// when the the size is changed
|
||||
this._drawCtx.mozImageSmoothingEnabled = false;
|
||||
this._drawCtx.webkitImageSmoothingEnabled = false;
|
||||
this._drawCtx.msImageSmoothingEnabled = false;
|
||||
this._drawCtx.imageSmoothingEnabled = false;
|
||||
|
||||
this._drawCtx.drawImage(this._backbuffer,
|
||||
oldX, oldY, w, h,
|
||||
newX, newY, w, h);
|
||||
this._damage(newX, newY, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
imageRect(x, y, width, height, mime, arr) {
|
||||
/* The internal logic cannot handle empty images, so bail early */
|
||||
if ((width === 0) || (height === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.src = "data: " + mime + ";base64," + Base64.encode(arr);
|
||||
|
||||
this._renderQPush({
|
||||
'type': 'img',
|
||||
'img': img,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'width': width,
|
||||
'height': height
|
||||
});
|
||||
}
|
||||
|
||||
videoFrame(x, y, width, height, frame) {
|
||||
this._renderQPush({
|
||||
'type': 'frame',
|
||||
'frame': frame,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'width': width,
|
||||
'height': height
|
||||
});
|
||||
}
|
||||
|
||||
blitImage(x, y, width, height, arr, offset, fromQueue) {
|
||||
if (this._renderQ.length !== 0 && !fromQueue) {
|
||||
// NB(directxman12): it's technically more performant here to use preallocated arrays,
|
||||
// but it's a lot of extra work for not a lot of payoff -- if we're using the render queue,
|
||||
// this probably isn't getting called *nearly* as much
|
||||
const newArr = new Uint8Array(width * height * 4);
|
||||
newArr.set(new Uint8Array(arr.buffer, 0, newArr.length));
|
||||
this._renderQPush({
|
||||
'type': 'blit',
|
||||
'data': newArr,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'width': width,
|
||||
'height': height,
|
||||
});
|
||||
} else {
|
||||
// NB(directxman12): arr must be an Type Array view
|
||||
let data = new Uint8ClampedArray(arr.buffer,
|
||||
arr.byteOffset + offset,
|
||||
width * height * 4);
|
||||
let img = new ImageData(data, width, height);
|
||||
this._drawCtx.putImageData(img, x, y);
|
||||
this._damage(x, y, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
drawImage(img, ...args) {
|
||||
this._drawCtx.drawImage(img, ...args);
|
||||
|
||||
if (args.length <= 4) {
|
||||
const [x, y] = args;
|
||||
this._damage(x, y, img.width, img.height);
|
||||
} else {
|
||||
const [,, sw, sh, dx, dy] = args;
|
||||
this._damage(dx, dy, sw, sh);
|
||||
}
|
||||
}
|
||||
|
||||
autoscale(containerWidth, containerHeight) {
|
||||
let scaleRatio;
|
||||
|
||||
if (containerWidth === 0 || containerHeight === 0) {
|
||||
scaleRatio = 0;
|
||||
|
||||
} else {
|
||||
|
||||
const vp = this._viewportLoc;
|
||||
const targetAspectRatio = containerWidth / containerHeight;
|
||||
const fbAspectRatio = vp.w / vp.h;
|
||||
|
||||
if (fbAspectRatio >= targetAspectRatio) {
|
||||
scaleRatio = containerWidth / vp.w;
|
||||
} else {
|
||||
scaleRatio = containerHeight / vp.h;
|
||||
}
|
||||
}
|
||||
|
||||
this._rescale(scaleRatio);
|
||||
}
|
||||
|
||||
// ===== PRIVATE METHODS =====
|
||||
|
||||
_rescale(factor) {
|
||||
this._scale = factor;
|
||||
const vp = this._viewportLoc;
|
||||
|
||||
// NB(directxman12): If you set the width directly, or set the
|
||||
// style width to a number, the canvas is cleared.
|
||||
// However, if you set the style width to a string
|
||||
// ('NNNpx'), the canvas is scaled without clearing.
|
||||
const width = factor * vp.w + 'px';
|
||||
const height = factor * vp.h + 'px';
|
||||
|
||||
if ((this._target.style.width !== width) ||
|
||||
(this._target.style.height !== height)) {
|
||||
this._target.style.width = width;
|
||||
this._target.style.height = height;
|
||||
}
|
||||
}
|
||||
|
||||
_setFillColor(color) {
|
||||
const newStyle = 'rgb(' + color[0] + ',' + color[1] + ',' + color[2] + ')';
|
||||
if (newStyle !== this._prevDrawStyle) {
|
||||
this._drawCtx.fillStyle = newStyle;
|
||||
this._prevDrawStyle = newStyle;
|
||||
}
|
||||
}
|
||||
|
||||
_renderQPush(action) {
|
||||
this._renderQ.push(action);
|
||||
if (this._renderQ.length === 1) {
|
||||
// If this can be rendered immediately it will be, otherwise
|
||||
// the scanner will wait for the relevant event
|
||||
this._scanRenderQ();
|
||||
}
|
||||
}
|
||||
|
||||
_resumeRenderQ() {
|
||||
// "this" is the object that is ready, not the
|
||||
// display object
|
||||
this.removeEventListener('load', this._noVNCDisplay._resumeRenderQ);
|
||||
this._noVNCDisplay._scanRenderQ();
|
||||
}
|
||||
|
||||
_scanRenderQ() {
|
||||
let ready = true;
|
||||
while (ready && this._renderQ.length > 0) {
|
||||
const a = this._renderQ[0];
|
||||
switch (a.type) {
|
||||
case 'flip':
|
||||
this.flip(true);
|
||||
break;
|
||||
case 'copy':
|
||||
this.copyImage(a.oldX, a.oldY, a.x, a.y, a.width, a.height, true);
|
||||
break;
|
||||
case 'fill':
|
||||
this.fillRect(a.x, a.y, a.width, a.height, a.color, true);
|
||||
break;
|
||||
case 'blit':
|
||||
this.blitImage(a.x, a.y, a.width, a.height, a.data, 0, true);
|
||||
break;
|
||||
case 'img':
|
||||
if (a.img.complete) {
|
||||
if (a.img.width !== a.width || a.img.height !== a.height) {
|
||||
Log.Error("Decoded image has incorrect dimensions. Got " +
|
||||
a.img.width + "x" + a.img.height + ". Expected " +
|
||||
a.width + "x" + a.height + ".");
|
||||
return;
|
||||
}
|
||||
this.drawImage(a.img, a.x, a.y);
|
||||
} else {
|
||||
a.img._noVNCDisplay = this;
|
||||
a.img.addEventListener('load', this._resumeRenderQ);
|
||||
// We need to wait for this image to 'load'
|
||||
// to keep things in-order
|
||||
ready = false;
|
||||
}
|
||||
break;
|
||||
case 'frame':
|
||||
if (a.frame.ready) {
|
||||
// The encoded frame may be larger than the rect due to
|
||||
// limitations of the encoder, so we need to crop the
|
||||
// frame.
|
||||
let frame = a.frame.frame;
|
||||
if (frame.codedWidth < a.width || frame.codedHeight < a.height) {
|
||||
Log.Warn("Decoded video frame does not cover its full rectangle area. Expecting at least " +
|
||||
a.width + "x" + a.height + " but got " +
|
||||
frame.codedWidth + "x" + frame.codedHeight);
|
||||
}
|
||||
const sx = 0;
|
||||
const sy = 0;
|
||||
const sw = a.width;
|
||||
const sh = a.height;
|
||||
const dx = a.x;
|
||||
const dy = a.y;
|
||||
const dw = sw;
|
||||
const dh = sh;
|
||||
this.drawImage(frame, sx, sy, sw, sh, dx, dy, dw, dh);
|
||||
frame.close();
|
||||
} else {
|
||||
let display = this;
|
||||
a.frame.promise.then(() => {
|
||||
display._scanRenderQ();
|
||||
});
|
||||
ready = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (ready) {
|
||||
this._renderQ.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if (this._renderQ.length === 0 &&
|
||||
this._flushPromise !== null) {
|
||||
this._flushResolve();
|
||||
this._flushPromise = null;
|
||||
this._flushResolve = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
export const encodings = {
|
||||
encodingRaw: 0,
|
||||
encodingCopyRect: 1,
|
||||
encodingRRE: 2,
|
||||
encodingHextile: 5,
|
||||
encodingZlib: 6,
|
||||
encodingTight: 7,
|
||||
encodingZRLE: 16,
|
||||
encodingTightPNG: -260,
|
||||
encodingJPEG: 21,
|
||||
encodingH264: 50,
|
||||
|
||||
pseudoEncodingQualityLevel9: -23,
|
||||
pseudoEncodingQualityLevel0: -32,
|
||||
pseudoEncodingDesktopSize: -223,
|
||||
pseudoEncodingLastRect: -224,
|
||||
pseudoEncodingCursor: -239,
|
||||
pseudoEncodingQEMUExtendedKeyEvent: -258,
|
||||
pseudoEncodingQEMULedEvent: -261,
|
||||
pseudoEncodingDesktopName: -307,
|
||||
pseudoEncodingExtendedDesktopSize: -308,
|
||||
pseudoEncodingXvp: -309,
|
||||
pseudoEncodingFence: -312,
|
||||
pseudoEncodingContinuousUpdates: -313,
|
||||
pseudoEncodingExtendedMouseButtons: -316,
|
||||
pseudoEncodingCompressLevel9: -247,
|
||||
pseudoEncodingCompressLevel0: -256,
|
||||
pseudoEncodingVMwareCursor: 0x574d5664,
|
||||
pseudoEncodingExtendedClipboard: 0xc0a1e5ce
|
||||
};
|
||||
|
||||
export function encodingName(num) {
|
||||
switch (num) {
|
||||
case encodings.encodingRaw: return "Raw";
|
||||
case encodings.encodingCopyRect: return "CopyRect";
|
||||
case encodings.encodingRRE: return "RRE";
|
||||
case encodings.encodingHextile: return "Hextile";
|
||||
case encodings.encodingZlib: return "Zlib";
|
||||
case encodings.encodingTight: return "Tight";
|
||||
case encodings.encodingZRLE: return "ZRLE";
|
||||
case encodings.encodingTightPNG: return "TightPNG";
|
||||
case encodings.encodingJPEG: return "JPEG";
|
||||
case encodings.encodingH264: return "H.264";
|
||||
default: return "[unknown encoding " + num + "]";
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2020 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
import { inflateInit, inflate, inflateReset } from "../vendor/pako/lib/zlib/inflate.js";
|
||||
import ZStream from "../vendor/pako/lib/zlib/zstream.js";
|
||||
|
||||
export default class Inflate {
|
||||
constructor() {
|
||||
this.strm = new ZStream();
|
||||
this.chunkSize = 1024 * 10 * 10;
|
||||
this.strm.output = new Uint8Array(this.chunkSize);
|
||||
|
||||
inflateInit(this.strm);
|
||||
}
|
||||
|
||||
setInput(data) {
|
||||
if (!data) {
|
||||
//FIXME: flush remaining data.
|
||||
/* eslint-disable camelcase */
|
||||
this.strm.input = null;
|
||||
this.strm.avail_in = 0;
|
||||
this.strm.next_in = 0;
|
||||
} else {
|
||||
this.strm.input = data;
|
||||
this.strm.avail_in = this.strm.input.length;
|
||||
this.strm.next_in = 0;
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
}
|
||||
|
||||
inflate(expected) {
|
||||
// resize our output buffer if it's too small
|
||||
// (we could just use multiple chunks, but that would cause an extra
|
||||
// allocation each time to flatten the chunks)
|
||||
if (expected > this.chunkSize) {
|
||||
this.chunkSize = expected;
|
||||
this.strm.output = new Uint8Array(this.chunkSize);
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
this.strm.next_out = 0;
|
||||
this.strm.avail_out = expected;
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
let ret = inflate(this.strm, 0); // Flush argument not used.
|
||||
if (ret < 0) {
|
||||
throw new Error("zlib inflate failed");
|
||||
}
|
||||
|
||||
if (this.strm.next_out != expected) {
|
||||
throw new Error("Incomplete zlib block");
|
||||
}
|
||||
|
||||
return new Uint8Array(this.strm.output.buffer, 0, this.strm.next_out);
|
||||
}
|
||||
|
||||
reset() {
|
||||
inflateReset(this.strm);
|
||||
}
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2018 The noVNC authors
|
||||
* Licensed under MPL 2.0 or any later version (see LICENSE.txt)
|
||||
*/
|
||||
|
||||
import KeyTable from "./keysym.js";
|
||||
|
||||
/*
|
||||
* Mapping between HTML key values and VNC/X11 keysyms for "special"
|
||||
* keys that cannot be handled via their Unicode codepoint.
|
||||
*
|
||||
* See https://www.w3.org/TR/uievents-key/ for possible values.
|
||||
*/
|
||||
|
||||
const DOMKeyTable = {};
|
||||
|
||||
function addStandard(key, standard) {
|
||||
if (standard === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
|
||||
if (key in DOMKeyTable) throw new Error("Duplicate entry for key \"" + key + "\"");
|
||||
DOMKeyTable[key] = [standard, standard, standard, standard];
|
||||
}
|
||||
|
||||
function addLeftRight(key, left, right) {
|
||||
if (left === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
|
||||
if (right === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
|
||||
if (key in DOMKeyTable) throw new Error("Duplicate entry for key \"" + key + "\"");
|
||||
DOMKeyTable[key] = [left, left, right, left];
|
||||
}
|
||||
|
||||
function addNumpad(key, standard, numpad) {
|
||||
if (standard === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
|
||||
if (numpad === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
|
||||
if (key in DOMKeyTable) throw new Error("Duplicate entry for key \"" + key + "\"");
|
||||
DOMKeyTable[key] = [standard, standard, standard, numpad];
|
||||
}
|
||||
|
||||
// 3.2. Modifier Keys
|
||||
|
||||
addLeftRight("Alt", KeyTable.XK_Alt_L, KeyTable.XK_Alt_R);
|
||||
addStandard("AltGraph", KeyTable.XK_ISO_Level3_Shift);
|
||||
addStandard("CapsLock", KeyTable.XK_Caps_Lock);
|
||||
addLeftRight("Control", KeyTable.XK_Control_L, KeyTable.XK_Control_R);
|
||||
// - Fn
|
||||
// - FnLock
|
||||
addLeftRight("Meta", KeyTable.XK_Super_L, KeyTable.XK_Super_R);
|
||||
addStandard("NumLock", KeyTable.XK_Num_Lock);
|
||||
addStandard("ScrollLock", KeyTable.XK_Scroll_Lock);
|
||||
addLeftRight("Shift", KeyTable.XK_Shift_L, KeyTable.XK_Shift_R);
|
||||
// - Symbol
|
||||
// - SymbolLock
|
||||
// - Hyper
|
||||
// - Super
|
||||
|
||||
// 3.3. Whitespace Keys
|
||||
|
||||
addNumpad("Enter", KeyTable.XK_Return, KeyTable.XK_KP_Enter);
|
||||
addStandard("Tab", KeyTable.XK_Tab);
|
||||
addNumpad(" ", KeyTable.XK_space, KeyTable.XK_KP_Space);
|
||||
|
||||
// 3.4. Navigation Keys
|
||||
|
||||
addNumpad("ArrowDown", KeyTable.XK_Down, KeyTable.XK_KP_Down);
|
||||
addNumpad("ArrowLeft", KeyTable.XK_Left, KeyTable.XK_KP_Left);
|
||||
addNumpad("ArrowRight", KeyTable.XK_Right, KeyTable.XK_KP_Right);
|
||||
addNumpad("ArrowUp", KeyTable.XK_Up, KeyTable.XK_KP_Up);
|
||||
addNumpad("End", KeyTable.XK_End, KeyTable.XK_KP_End);
|
||||
addNumpad("Home", KeyTable.XK_Home, KeyTable.XK_KP_Home);
|
||||
addNumpad("PageDown", KeyTable.XK_Next, KeyTable.XK_KP_Next);
|
||||
addNumpad("PageUp", KeyTable.XK_Prior, KeyTable.XK_KP_Prior);
|
||||
|
||||
// 3.5. Editing Keys
|
||||
|
||||
addStandard("Backspace", KeyTable.XK_BackSpace);
|
||||
// Browsers send "Clear" for the numpad 5 without NumLock because
|
||||
// Windows uses VK_Clear for that key. But Unix expects KP_Begin for
|
||||
// that scenario.
|
||||
addNumpad("Clear", KeyTable.XK_Clear, KeyTable.XK_KP_Begin);
|
||||
addStandard("Copy", KeyTable.XF86XK_Copy);
|
||||
// - CrSel
|
||||
addStandard("Cut", KeyTable.XF86XK_Cut);
|
||||
addNumpad("Delete", KeyTable.XK_Delete, KeyTable.XK_KP_Delete);
|
||||
// - EraseEof
|
||||
// - ExSel
|
||||
addNumpad("Insert", KeyTable.XK_Insert, KeyTable.XK_KP_Insert);
|
||||
addStandard("Paste", KeyTable.XF86XK_Paste);
|
||||
addStandard("Redo", KeyTable.XK_Redo);
|
||||
addStandard("Undo", KeyTable.XK_Undo);
|
||||
|
||||
// 3.6. UI Keys
|
||||
|
||||
// - Accept
|
||||
// - Again (could just be XK_Redo)
|
||||
// - Attn
|
||||
addStandard("Cancel", KeyTable.XK_Cancel);
|
||||
addStandard("ContextMenu", KeyTable.XK_Menu);
|
||||
addStandard("Escape", KeyTable.XK_Escape);
|
||||
addStandard("Execute", KeyTable.XK_Execute);
|
||||
addStandard("Find", KeyTable.XK_Find);
|
||||
addStandard("Help", KeyTable.XK_Help);
|
||||
addStandard("Pause", KeyTable.XK_Pause);
|
||||
// - Play
|
||||
// - Props
|
||||
addStandard("Select", KeyTable.XK_Select);
|
||||
addStandard("ZoomIn", KeyTable.XF86XK_ZoomIn);
|
||||
addStandard("ZoomOut", KeyTable.XF86XK_ZoomOut);
|
||||
|
||||
// 3.7. Device Keys
|
||||
|
||||
addStandard("BrightnessDown", KeyTable.XF86XK_MonBrightnessDown);
|
||||
addStandard("BrightnessUp", KeyTable.XF86XK_MonBrightnessUp);
|
||||
addStandard("Eject", KeyTable.XF86XK_Eject);
|
||||
addStandard("LogOff", KeyTable.XF86XK_LogOff);
|
||||
addStandard("Power", KeyTable.XF86XK_PowerOff);
|
||||
addStandard("PowerOff", KeyTable.XF86XK_PowerDown);
|
||||
addStandard("PrintScreen", KeyTable.XK_Print);
|
||||
addStandard("Hibernate", KeyTable.XF86XK_Hibernate);
|
||||
addStandard("Standby", KeyTable.XF86XK_Standby);
|
||||
addStandard("WakeUp", KeyTable.XF86XK_WakeUp);
|
||||
|
||||
// 3.8. IME and Composition Keys
|
||||
|
||||
addStandard("AllCandidates", KeyTable.XK_MultipleCandidate);
|
||||
addStandard("Alphanumeric", KeyTable.XK_Eisu_toggle);
|
||||
addStandard("CodeInput", KeyTable.XK_Codeinput);
|
||||
addStandard("Compose", KeyTable.XK_Multi_key);
|
||||
addStandard("Convert", KeyTable.XK_Henkan);
|
||||
// - Dead
|
||||
// - FinalMode
|
||||
addStandard("GroupFirst", KeyTable.XK_ISO_First_Group);
|
||||
addStandard("GroupLast", KeyTable.XK_ISO_Last_Group);
|
||||
addStandard("GroupNext", KeyTable.XK_ISO_Next_Group);
|
||||
addStandard("GroupPrevious", KeyTable.XK_ISO_Prev_Group);
|
||||
// - ModeChange (XK_Mode_switch is often used for AltGr)
|
||||
// - NextCandidate
|
||||
addStandard("NonConvert", KeyTable.XK_Muhenkan);
|
||||
addStandard("PreviousCandidate", KeyTable.XK_PreviousCandidate);
|
||||
// - Process
|
||||
addStandard("SingleCandidate", KeyTable.XK_SingleCandidate);
|
||||
addStandard("HangulMode", KeyTable.XK_Hangul);
|
||||
addStandard("HanjaMode", KeyTable.XK_Hangul_Hanja);
|
||||
addStandard("JunjaMode", KeyTable.XK_Hangul_Jeonja);
|
||||
addStandard("Eisu", KeyTable.XK_Eisu_toggle);
|
||||
addStandard("Hankaku", KeyTable.XK_Hankaku);
|
||||
addStandard("Hiragana", KeyTable.XK_Hiragana);
|
||||
addStandard("HiraganaKatakana", KeyTable.XK_Hiragana_Katakana);
|
||||
addStandard("KanaMode", KeyTable.XK_Kana_Shift); // could also be _Kana_Lock
|
||||
addStandard("KanjiMode", KeyTable.XK_Kanji);
|
||||
addStandard("Katakana", KeyTable.XK_Katakana);
|
||||
addStandard("Romaji", KeyTable.XK_Romaji);
|
||||
addStandard("Zenkaku", KeyTable.XK_Zenkaku);
|
||||
addStandard("ZenkakuHankaku", KeyTable.XK_Zenkaku_Hankaku);
|
||||
|
||||
// 3.9. General-Purpose Function Keys
|
||||
|
||||
addStandard("F1", KeyTable.XK_F1);
|
||||
addStandard("F2", KeyTable.XK_F2);
|
||||
addStandard("F3", KeyTable.XK_F3);
|
||||
addStandard("F4", KeyTable.XK_F4);
|
||||
addStandard("F5", KeyTable.XK_F5);
|
||||
addStandard("F6", KeyTable.XK_F6);
|
||||
addStandard("F7", KeyTable.XK_F7);
|
||||
addStandard("F8", KeyTable.XK_F8);
|
||||
addStandard("F9", KeyTable.XK_F9);
|
||||
addStandard("F10", KeyTable.XK_F10);
|
||||
addStandard("F11", KeyTable.XK_F11);
|
||||
addStandard("F12", KeyTable.XK_F12);
|
||||
addStandard("F13", KeyTable.XK_F13);
|
||||
addStandard("F14", KeyTable.XK_F14);
|
||||
addStandard("F15", KeyTable.XK_F15);
|
||||
addStandard("F16", KeyTable.XK_F16);
|
||||
addStandard("F17", KeyTable.XK_F17);
|
||||
addStandard("F18", KeyTable.XK_F18);
|
||||
addStandard("F19", KeyTable.XK_F19);
|
||||
addStandard("F20", KeyTable.XK_F20);
|
||||
addStandard("F21", KeyTable.XK_F21);
|
||||
addStandard("F22", KeyTable.XK_F22);
|
||||
addStandard("F23", KeyTable.XK_F23);
|
||||
addStandard("F24", KeyTable.XK_F24);
|
||||
addStandard("F25", KeyTable.XK_F25);
|
||||
addStandard("F26", KeyTable.XK_F26);
|
||||
addStandard("F27", KeyTable.XK_F27);
|
||||
addStandard("F28", KeyTable.XK_F28);
|
||||
addStandard("F29", KeyTable.XK_F29);
|
||||
addStandard("F30", KeyTable.XK_F30);
|
||||
addStandard("F31", KeyTable.XK_F31);
|
||||
addStandard("F32", KeyTable.XK_F32);
|
||||
addStandard("F33", KeyTable.XK_F33);
|
||||
addStandard("F34", KeyTable.XK_F34);
|
||||
addStandard("F35", KeyTable.XK_F35);
|
||||
// - Soft1...
|
||||
|
||||
// 3.10. Multimedia Keys
|
||||
|
||||
// - ChannelDown
|
||||
// - ChannelUp
|
||||
addStandard("Close", KeyTable.XF86XK_Close);
|
||||
addStandard("MailForward", KeyTable.XF86XK_MailForward);
|
||||
addStandard("MailReply", KeyTable.XF86XK_Reply);
|
||||
addStandard("MailSend", KeyTable.XF86XK_Send);
|
||||
// - MediaClose
|
||||
addStandard("MediaFastForward", KeyTable.XF86XK_AudioForward);
|
||||
addStandard("MediaPause", KeyTable.XF86XK_AudioPause);
|
||||
addStandard("MediaPlay", KeyTable.XF86XK_AudioPlay);
|
||||
// - MediaPlayPause
|
||||
addStandard("MediaRecord", KeyTable.XF86XK_AudioRecord);
|
||||
addStandard("MediaRewind", KeyTable.XF86XK_AudioRewind);
|
||||
addStandard("MediaStop", KeyTable.XF86XK_AudioStop);
|
||||
addStandard("MediaTrackNext", KeyTable.XF86XK_AudioNext);
|
||||
addStandard("MediaTrackPrevious", KeyTable.XF86XK_AudioPrev);
|
||||
addStandard("New", KeyTable.XF86XK_New);
|
||||
addStandard("Open", KeyTable.XF86XK_Open);
|
||||
addStandard("Print", KeyTable.XK_Print);
|
||||
addStandard("Save", KeyTable.XF86XK_Save);
|
||||
addStandard("SpellCheck", KeyTable.XF86XK_Spell);
|
||||
|
||||
// 3.11. Multimedia Numpad Keys
|
||||
|
||||
// - Key11
|
||||
// - Key12
|
||||
|
||||
// 3.12. Audio Keys
|
||||
|
||||
// - AudioBalanceLeft
|
||||
// - AudioBalanceRight
|
||||
// - AudioBassBoostDown
|
||||
// - AudioBassBoostToggle
|
||||
// - AudioBassBoostUp
|
||||
// - AudioFaderFront
|
||||
// - AudioFaderRear
|
||||
// - AudioSurroundModeNext
|
||||
// - AudioTrebleDown
|
||||
// - AudioTrebleUp
|
||||
addStandard("AudioVolumeDown", KeyTable.XF86XK_AudioLowerVolume);
|
||||
addStandard("AudioVolumeUp", KeyTable.XF86XK_AudioRaiseVolume);
|
||||
addStandard("AudioVolumeMute", KeyTable.XF86XK_AudioMute);
|
||||
// - MicrophoneToggle
|
||||
// - MicrophoneVolumeDown
|
||||
// - MicrophoneVolumeUp
|
||||
addStandard("MicrophoneVolumeMute", KeyTable.XF86XK_AudioMicMute);
|
||||
|
||||
// 3.13. Speech Keys
|
||||
|
||||
// - SpeechCorrectionList
|
||||
// - SpeechInputToggle
|
||||
|
||||
// 3.14. Application Keys
|
||||
|
||||
addStandard("LaunchApplication1", KeyTable.XF86XK_MyComputer);
|
||||
addStandard("LaunchApplication2", KeyTable.XF86XK_Calculator);
|
||||
addStandard("LaunchCalendar", KeyTable.XF86XK_Calendar);
|
||||
// - LaunchContacts
|
||||
addStandard("LaunchMail", KeyTable.XF86XK_Mail);
|
||||
addStandard("LaunchMediaPlayer", KeyTable.XF86XK_AudioMedia);
|
||||
addStandard("LaunchMusicPlayer", KeyTable.XF86XK_Music);
|
||||
addStandard("LaunchPhone", KeyTable.XF86XK_Phone);
|
||||
addStandard("LaunchScreenSaver", KeyTable.XF86XK_ScreenSaver);
|
||||
addStandard("LaunchSpreadsheet", KeyTable.XF86XK_Excel);
|
||||
addStandard("LaunchWebBrowser", KeyTable.XF86XK_WWW);
|
||||
addStandard("LaunchWebCam", KeyTable.XF86XK_WebCam);
|
||||
addStandard("LaunchWordProcessor", KeyTable.XF86XK_Word);
|
||||
|
||||
// 3.15. Browser Keys
|
||||
|
||||
addStandard("BrowserBack", KeyTable.XF86XK_Back);
|
||||
addStandard("BrowserFavorites", KeyTable.XF86XK_Favorites);
|
||||
addStandard("BrowserForward", KeyTable.XF86XK_Forward);
|
||||
addStandard("BrowserHome", KeyTable.XF86XK_HomePage);
|
||||
addStandard("BrowserRefresh", KeyTable.XF86XK_Refresh);
|
||||
addStandard("BrowserSearch", KeyTable.XF86XK_Search);
|
||||
addStandard("BrowserStop", KeyTable.XF86XK_Stop);
|
||||
|
||||
// 3.16. Mobile Phone Keys
|
||||
|
||||
// - A whole bunch...
|
||||
|
||||
// 3.17. TV Keys
|
||||
|
||||
// - A whole bunch...
|
||||
|
||||
// 3.18. Media Controller Keys
|
||||
|
||||
// - A whole bunch...
|
||||
addStandard("Dimmer", KeyTable.XF86XK_BrightnessAdjust);
|
||||
addStandard("MediaAudioTrack", KeyTable.XF86XK_AudioCycleTrack);
|
||||
addStandard("RandomToggle", KeyTable.XF86XK_AudioRandomPlay);
|
||||
addStandard("SplitScreenToggle", KeyTable.XF86XK_SplitScreen);
|
||||
addStandard("Subtitle", KeyTable.XF86XK_Subtitle);
|
||||
addStandard("VideoModeNext", KeyTable.XF86XK_Next_VMode);
|
||||
|
||||
// Extra: Numpad
|
||||
|
||||
addNumpad("=", KeyTable.XK_equal, KeyTable.XK_KP_Equal);
|
||||
addNumpad("+", KeyTable.XK_plus, KeyTable.XK_KP_Add);
|
||||
addNumpad("-", KeyTable.XK_minus, KeyTable.XK_KP_Subtract);
|
||||
addNumpad("*", KeyTable.XK_asterisk, KeyTable.XK_KP_Multiply);
|
||||
addNumpad("/", KeyTable.XK_slash, KeyTable.XK_KP_Divide);
|
||||
addNumpad(".", KeyTable.XK_period, KeyTable.XK_KP_Decimal);
|
||||
addNumpad(",", KeyTable.XK_comma, KeyTable.XK_KP_Separator);
|
||||
addNumpad("0", KeyTable.XK_0, KeyTable.XK_KP_0);
|
||||
addNumpad("1", KeyTable.XK_1, KeyTable.XK_KP_1);
|
||||
addNumpad("2", KeyTable.XK_2, KeyTable.XK_KP_2);
|
||||
addNumpad("3", KeyTable.XK_3, KeyTable.XK_KP_3);
|
||||
addNumpad("4", KeyTable.XK_4, KeyTable.XK_KP_4);
|
||||
addNumpad("5", KeyTable.XK_5, KeyTable.XK_KP_5);
|
||||
addNumpad("6", KeyTable.XK_6, KeyTable.XK_KP_6);
|
||||
addNumpad("7", KeyTable.XK_7, KeyTable.XK_KP_7);
|
||||
addNumpad("8", KeyTable.XK_8, KeyTable.XK_KP_8);
|
||||
addNumpad("9", KeyTable.XK_9, KeyTable.XK_KP_9);
|
||||
|
||||
export default DOMKeyTable;
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2018 The noVNC authors
|
||||
* Licensed under MPL 2.0 or any later version (see LICENSE.txt)
|
||||
*/
|
||||
|
||||
/*
|
||||
* Fallback mapping between HTML key codes (physical keys) and
|
||||
* HTML key values. This only works for keys that don't vary
|
||||
* between layouts. We also omit those who manage fine by mapping the
|
||||
* Unicode representation.
|
||||
*
|
||||
* See https://www.w3.org/TR/uievents-code/ for possible codes.
|
||||
* See https://www.w3.org/TR/uievents-key/ for possible values.
|
||||
*/
|
||||
|
||||
/* eslint-disable key-spacing */
|
||||
|
||||
export default {
|
||||
|
||||
// 3.1.1.1. Writing System Keys
|
||||
|
||||
'Backspace': 'Backspace',
|
||||
|
||||
// 3.1.1.2. Functional Keys
|
||||
|
||||
'AltLeft': 'Alt',
|
||||
'AltRight': 'Alt', // This could also be 'AltGraph'
|
||||
'CapsLock': 'CapsLock',
|
||||
'ContextMenu': 'ContextMenu',
|
||||
'ControlLeft': 'Control',
|
||||
'ControlRight': 'Control',
|
||||
'Enter': 'Enter',
|
||||
'MetaLeft': 'Meta',
|
||||
'MetaRight': 'Meta',
|
||||
'ShiftLeft': 'Shift',
|
||||
'ShiftRight': 'Shift',
|
||||
'Tab': 'Tab',
|
||||
// FIXME: Japanese/Korean keys
|
||||
|
||||
// 3.1.2. Control Pad Section
|
||||
|
||||
'Delete': 'Delete',
|
||||
'End': 'End',
|
||||
'Help': 'Help',
|
||||
'Home': 'Home',
|
||||
'Insert': 'Insert',
|
||||
'PageDown': 'PageDown',
|
||||
'PageUp': 'PageUp',
|
||||
|
||||
// 3.1.3. Arrow Pad Section
|
||||
|
||||
'ArrowDown': 'ArrowDown',
|
||||
'ArrowLeft': 'ArrowLeft',
|
||||
'ArrowRight': 'ArrowRight',
|
||||
'ArrowUp': 'ArrowUp',
|
||||
|
||||
// 3.1.4. Numpad Section
|
||||
|
||||
'NumLock': 'NumLock',
|
||||
'NumpadBackspace': 'Backspace',
|
||||
'NumpadClear': 'Clear',
|
||||
|
||||
// 3.1.5. Function Section
|
||||
|
||||
'Escape': 'Escape',
|
||||
'F1': 'F1',
|
||||
'F2': 'F2',
|
||||
'F3': 'F3',
|
||||
'F4': 'F4',
|
||||
'F5': 'F5',
|
||||
'F6': 'F6',
|
||||
'F7': 'F7',
|
||||
'F8': 'F8',
|
||||
'F9': 'F9',
|
||||
'F10': 'F10',
|
||||
'F11': 'F11',
|
||||
'F12': 'F12',
|
||||
'F13': 'F13',
|
||||
'F14': 'F14',
|
||||
'F15': 'F15',
|
||||
'F16': 'F16',
|
||||
'F17': 'F17',
|
||||
'F18': 'F18',
|
||||
'F19': 'F19',
|
||||
'F20': 'F20',
|
||||
'F21': 'F21',
|
||||
'F22': 'F22',
|
||||
'F23': 'F23',
|
||||
'F24': 'F24',
|
||||
'F25': 'F25',
|
||||
'F26': 'F26',
|
||||
'F27': 'F27',
|
||||
'F28': 'F28',
|
||||
'F29': 'F29',
|
||||
'F30': 'F30',
|
||||
'F31': 'F31',
|
||||
'F32': 'F32',
|
||||
'F33': 'F33',
|
||||
'F34': 'F34',
|
||||
'F35': 'F35',
|
||||
'PrintScreen': 'PrintScreen',
|
||||
'ScrollLock': 'ScrollLock',
|
||||
'Pause': 'Pause',
|
||||
|
||||
// 3.1.6. Media Keys
|
||||
|
||||
'BrowserBack': 'BrowserBack',
|
||||
'BrowserFavorites': 'BrowserFavorites',
|
||||
'BrowserForward': 'BrowserForward',
|
||||
'BrowserHome': 'BrowserHome',
|
||||
'BrowserRefresh': 'BrowserRefresh',
|
||||
'BrowserSearch': 'BrowserSearch',
|
||||
'BrowserStop': 'BrowserStop',
|
||||
'Eject': 'Eject',
|
||||
'LaunchApp1': 'LaunchMyComputer',
|
||||
'LaunchApp2': 'LaunchCalendar',
|
||||
'LaunchMail': 'LaunchMail',
|
||||
'MediaPlayPause': 'MediaPlay',
|
||||
'MediaStop': 'MediaStop',
|
||||
'MediaTrackNext': 'MediaTrackNext',
|
||||
'MediaTrackPrevious': 'MediaTrackPrevious',
|
||||
'Power': 'Power',
|
||||
'Sleep': 'Sleep',
|
||||
'AudioVolumeDown': 'AudioVolumeDown',
|
||||
'AudioVolumeMute': 'AudioVolumeMute',
|
||||
'AudioVolumeUp': 'AudioVolumeUp',
|
||||
'WakeUp': 'WakeUp',
|
||||
};
|
||||
Vendored
+567
@@ -0,0 +1,567 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2020 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
const GH_NOGESTURE = 0;
|
||||
const GH_ONETAP = 1;
|
||||
const GH_TWOTAP = 2;
|
||||
const GH_THREETAP = 4;
|
||||
const GH_DRAG = 8;
|
||||
const GH_LONGPRESS = 16;
|
||||
const GH_TWODRAG = 32;
|
||||
const GH_PINCH = 64;
|
||||
|
||||
const GH_INITSTATE = 127;
|
||||
|
||||
const GH_MOVE_THRESHOLD = 50;
|
||||
const GH_ANGLE_THRESHOLD = 90; // Degrees
|
||||
|
||||
// Timeout when waiting for gestures (ms)
|
||||
const GH_MULTITOUCH_TIMEOUT = 250;
|
||||
|
||||
// Maximum time between press and release for a tap (ms)
|
||||
const GH_TAP_TIMEOUT = 1000;
|
||||
|
||||
// Timeout when waiting for longpress (ms)
|
||||
const GH_LONGPRESS_TIMEOUT = 1000;
|
||||
|
||||
// Timeout when waiting to decide between PINCH and TWODRAG (ms)
|
||||
const GH_TWOTOUCH_TIMEOUT = 50;
|
||||
|
||||
export default class GestureHandler {
|
||||
constructor() {
|
||||
this._target = null;
|
||||
|
||||
this._state = GH_INITSTATE;
|
||||
|
||||
this._tracked = [];
|
||||
this._ignored = [];
|
||||
|
||||
this._waitingRelease = false;
|
||||
this._releaseStart = 0.0;
|
||||
|
||||
this._longpressTimeoutId = null;
|
||||
this._twoTouchTimeoutId = null;
|
||||
|
||||
this._boundEventHandler = this._eventHandler.bind(this);
|
||||
}
|
||||
|
||||
attach(target) {
|
||||
this.detach();
|
||||
|
||||
this._target = target;
|
||||
this._target.addEventListener('touchstart',
|
||||
this._boundEventHandler);
|
||||
this._target.addEventListener('touchmove',
|
||||
this._boundEventHandler);
|
||||
this._target.addEventListener('touchend',
|
||||
this._boundEventHandler);
|
||||
this._target.addEventListener('touchcancel',
|
||||
this._boundEventHandler);
|
||||
}
|
||||
|
||||
detach() {
|
||||
if (!this._target) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._stopLongpressTimeout();
|
||||
this._stopTwoTouchTimeout();
|
||||
|
||||
this._target.removeEventListener('touchstart',
|
||||
this._boundEventHandler);
|
||||
this._target.removeEventListener('touchmove',
|
||||
this._boundEventHandler);
|
||||
this._target.removeEventListener('touchend',
|
||||
this._boundEventHandler);
|
||||
this._target.removeEventListener('touchcancel',
|
||||
this._boundEventHandler);
|
||||
this._target = null;
|
||||
}
|
||||
|
||||
_eventHandler(e) {
|
||||
let fn;
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
switch (e.type) {
|
||||
case 'touchstart':
|
||||
fn = this._touchStart;
|
||||
break;
|
||||
case 'touchmove':
|
||||
fn = this._touchMove;
|
||||
break;
|
||||
case 'touchend':
|
||||
case 'touchcancel':
|
||||
fn = this._touchEnd;
|
||||
break;
|
||||
}
|
||||
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
let touch = e.changedTouches[i];
|
||||
fn.call(this, touch.identifier, touch.clientX, touch.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
_touchStart(id, x, y) {
|
||||
// Ignore any new touches if there is already an active gesture,
|
||||
// or we're in a cleanup state
|
||||
if (this._hasDetectedGesture() || (this._state === GH_NOGESTURE)) {
|
||||
this._ignored.push(id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Did it take too long between touches that we should no longer
|
||||
// consider this a single gesture?
|
||||
if ((this._tracked.length > 0) &&
|
||||
((Date.now() - this._tracked[0].started) > GH_MULTITOUCH_TIMEOUT)) {
|
||||
this._state = GH_NOGESTURE;
|
||||
this._ignored.push(id);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're waiting for fingers to release then we should no longer
|
||||
// recognize new touches
|
||||
if (this._waitingRelease) {
|
||||
this._state = GH_NOGESTURE;
|
||||
this._ignored.push(id);
|
||||
return;
|
||||
}
|
||||
|
||||
this._tracked.push({
|
||||
id: id,
|
||||
started: Date.now(),
|
||||
active: true,
|
||||
firstX: x,
|
||||
firstY: y,
|
||||
lastX: x,
|
||||
lastY: y,
|
||||
angle: 0
|
||||
});
|
||||
|
||||
switch (this._tracked.length) {
|
||||
case 1:
|
||||
this._startLongpressTimeout();
|
||||
break;
|
||||
|
||||
case 2:
|
||||
this._state &= ~(GH_ONETAP | GH_DRAG | GH_LONGPRESS);
|
||||
this._stopLongpressTimeout();
|
||||
break;
|
||||
|
||||
case 3:
|
||||
this._state &= ~(GH_TWOTAP | GH_TWODRAG | GH_PINCH);
|
||||
break;
|
||||
|
||||
default:
|
||||
this._state = GH_NOGESTURE;
|
||||
}
|
||||
}
|
||||
|
||||
_touchMove(id, x, y) {
|
||||
let touch = this._tracked.find(t => t.id === id);
|
||||
|
||||
// If this is an update for a touch we're not tracking, ignore it
|
||||
if (touch === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the touches last position with the event coordinates
|
||||
touch.lastX = x;
|
||||
touch.lastY = y;
|
||||
|
||||
let deltaX = x - touch.firstX;
|
||||
let deltaY = y - touch.firstY;
|
||||
|
||||
// Update angle when the touch has moved
|
||||
if ((touch.firstX !== touch.lastX) ||
|
||||
(touch.firstY !== touch.lastY)) {
|
||||
touch.angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI;
|
||||
}
|
||||
|
||||
if (!this._hasDetectedGesture()) {
|
||||
// Ignore moves smaller than the minimum threshold
|
||||
if (Math.hypot(deltaX, deltaY) < GH_MOVE_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't be a tap or long press as we've seen movement
|
||||
this._state &= ~(GH_ONETAP | GH_TWOTAP | GH_THREETAP | GH_LONGPRESS);
|
||||
this._stopLongpressTimeout();
|
||||
|
||||
if (this._tracked.length !== 1) {
|
||||
this._state &= ~(GH_DRAG);
|
||||
}
|
||||
if (this._tracked.length !== 2) {
|
||||
this._state &= ~(GH_TWODRAG | GH_PINCH);
|
||||
}
|
||||
|
||||
// We need to figure out which of our different two touch gestures
|
||||
// this might be
|
||||
if (this._tracked.length === 2) {
|
||||
|
||||
// The other touch is the one where the id doesn't match
|
||||
let prevTouch = this._tracked.find(t => t.id !== id);
|
||||
|
||||
// How far the previous touch point has moved since start
|
||||
let prevDeltaMove = Math.hypot(prevTouch.firstX - prevTouch.lastX,
|
||||
prevTouch.firstY - prevTouch.lastY);
|
||||
|
||||
// We know that the current touch moved far enough,
|
||||
// but unless both touches moved further than their
|
||||
// threshold we don't want to disqualify any gestures
|
||||
if (prevDeltaMove > GH_MOVE_THRESHOLD) {
|
||||
|
||||
// The angle difference between the direction of the touch points
|
||||
let deltaAngle = Math.abs(touch.angle - prevTouch.angle);
|
||||
deltaAngle = Math.abs(((deltaAngle + 180) % 360) - 180);
|
||||
|
||||
// PINCH or TWODRAG can be eliminated depending on the angle
|
||||
if (deltaAngle > GH_ANGLE_THRESHOLD) {
|
||||
this._state &= ~GH_TWODRAG;
|
||||
} else {
|
||||
this._state &= ~GH_PINCH;
|
||||
}
|
||||
|
||||
if (this._isTwoTouchTimeoutRunning()) {
|
||||
this._stopTwoTouchTimeout();
|
||||
}
|
||||
} else if (!this._isTwoTouchTimeoutRunning()) {
|
||||
// We can't determine the gesture right now, let's
|
||||
// wait and see if more events are on their way
|
||||
this._startTwoTouchTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._hasDetectedGesture()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._pushEvent('gesturestart');
|
||||
}
|
||||
|
||||
this._pushEvent('gesturemove');
|
||||
}
|
||||
|
||||
_touchEnd(id, x, y) {
|
||||
// Check if this is an ignored touch
|
||||
if (this._ignored.indexOf(id) !== -1) {
|
||||
// Remove this touch from ignored
|
||||
this._ignored.splice(this._ignored.indexOf(id), 1);
|
||||
|
||||
// And reset the state if there are no more touches
|
||||
if ((this._ignored.length === 0) &&
|
||||
(this._tracked.length === 0)) {
|
||||
this._state = GH_INITSTATE;
|
||||
this._waitingRelease = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// We got a touchend before the timer triggered,
|
||||
// this cannot result in a gesture anymore.
|
||||
if (!this._hasDetectedGesture() &&
|
||||
this._isTwoTouchTimeoutRunning()) {
|
||||
this._stopTwoTouchTimeout();
|
||||
this._state = GH_NOGESTURE;
|
||||
}
|
||||
|
||||
// Some gestures don't trigger until a touch is released
|
||||
if (!this._hasDetectedGesture()) {
|
||||
// Can't be a gesture that relies on movement
|
||||
this._state &= ~(GH_DRAG | GH_TWODRAG | GH_PINCH);
|
||||
// Or something that relies on more time
|
||||
this._state &= ~GH_LONGPRESS;
|
||||
this._stopLongpressTimeout();
|
||||
|
||||
if (!this._waitingRelease) {
|
||||
this._releaseStart = Date.now();
|
||||
this._waitingRelease = true;
|
||||
|
||||
// Can't be a tap that requires more touches than we current have
|
||||
switch (this._tracked.length) {
|
||||
case 1:
|
||||
this._state &= ~(GH_TWOTAP | GH_THREETAP);
|
||||
break;
|
||||
|
||||
case 2:
|
||||
this._state &= ~(GH_ONETAP | GH_THREETAP);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Waiting for all touches to release? (i.e. some tap)
|
||||
if (this._waitingRelease) {
|
||||
// Were all touches released at roughly the same time?
|
||||
if ((Date.now() - this._releaseStart) > GH_MULTITOUCH_TIMEOUT) {
|
||||
this._state = GH_NOGESTURE;
|
||||
}
|
||||
|
||||
// Did too long time pass between press and release?
|
||||
if (this._tracked.some(t => (Date.now() - t.started) > GH_TAP_TIMEOUT)) {
|
||||
this._state = GH_NOGESTURE;
|
||||
}
|
||||
|
||||
let touch = this._tracked.find(t => t.id === id);
|
||||
touch.active = false;
|
||||
|
||||
// Are we still waiting for more releases?
|
||||
if (this._hasDetectedGesture()) {
|
||||
this._pushEvent('gesturestart');
|
||||
} else {
|
||||
// Have we reached a dead end?
|
||||
if (this._state !== GH_NOGESTURE) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this._hasDetectedGesture()) {
|
||||
this._pushEvent('gestureend');
|
||||
}
|
||||
|
||||
// Ignore any remaining touches until they are ended
|
||||
for (let i = 0; i < this._tracked.length; i++) {
|
||||
if (this._tracked[i].active) {
|
||||
this._ignored.push(this._tracked[i].id);
|
||||
}
|
||||
}
|
||||
this._tracked = [];
|
||||
|
||||
this._state = GH_NOGESTURE;
|
||||
|
||||
// Remove this touch from ignored if it's in there
|
||||
if (this._ignored.indexOf(id) !== -1) {
|
||||
this._ignored.splice(this._ignored.indexOf(id), 1);
|
||||
}
|
||||
|
||||
// We reset the state if ignored is empty
|
||||
if ((this._ignored.length === 0)) {
|
||||
this._state = GH_INITSTATE;
|
||||
this._waitingRelease = false;
|
||||
}
|
||||
}
|
||||
|
||||
_hasDetectedGesture() {
|
||||
if (this._state === GH_NOGESTURE) {
|
||||
return false;
|
||||
}
|
||||
// Check to see if the bitmask value is a power of 2
|
||||
// (i.e. only one bit set). If it is, we have a state.
|
||||
if (this._state & (this._state - 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For taps we also need to have all touches released
|
||||
// before we've fully detected the gesture
|
||||
if (this._state & (GH_ONETAP | GH_TWOTAP | GH_THREETAP)) {
|
||||
if (this._tracked.some(t => t.active)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_startLongpressTimeout() {
|
||||
this._stopLongpressTimeout();
|
||||
this._longpressTimeoutId = setTimeout(() => this._longpressTimeout(),
|
||||
GH_LONGPRESS_TIMEOUT);
|
||||
}
|
||||
|
||||
_stopLongpressTimeout() {
|
||||
clearTimeout(this._longpressTimeoutId);
|
||||
this._longpressTimeoutId = null;
|
||||
}
|
||||
|
||||
_longpressTimeout() {
|
||||
if (this._hasDetectedGesture()) {
|
||||
throw new Error("A longpress gesture failed, conflict with a different gesture");
|
||||
}
|
||||
|
||||
this._state = GH_LONGPRESS;
|
||||
this._pushEvent('gesturestart');
|
||||
}
|
||||
|
||||
_startTwoTouchTimeout() {
|
||||
this._stopTwoTouchTimeout();
|
||||
this._twoTouchTimeoutId = setTimeout(() => this._twoTouchTimeout(),
|
||||
GH_TWOTOUCH_TIMEOUT);
|
||||
}
|
||||
|
||||
_stopTwoTouchTimeout() {
|
||||
clearTimeout(this._twoTouchTimeoutId);
|
||||
this._twoTouchTimeoutId = null;
|
||||
}
|
||||
|
||||
_isTwoTouchTimeoutRunning() {
|
||||
return this._twoTouchTimeoutId !== null;
|
||||
}
|
||||
|
||||
_twoTouchTimeout() {
|
||||
if (this._tracked.length === 0) {
|
||||
throw new Error("A pinch or two drag gesture failed, no tracked touches");
|
||||
}
|
||||
|
||||
// How far each touch point has moved since start
|
||||
let avgM = this._getAverageMovement();
|
||||
let avgMoveH = Math.abs(avgM.x);
|
||||
let avgMoveV = Math.abs(avgM.y);
|
||||
|
||||
// The difference in the distance between where
|
||||
// the touch points started and where they are now
|
||||
let avgD = this._getAverageDistance();
|
||||
let deltaTouchDistance = Math.abs(Math.hypot(avgD.first.x, avgD.first.y) -
|
||||
Math.hypot(avgD.last.x, avgD.last.y));
|
||||
|
||||
if ((avgMoveV < deltaTouchDistance) &&
|
||||
(avgMoveH < deltaTouchDistance)) {
|
||||
this._state = GH_PINCH;
|
||||
} else {
|
||||
this._state = GH_TWODRAG;
|
||||
}
|
||||
|
||||
this._pushEvent('gesturestart');
|
||||
this._pushEvent('gesturemove');
|
||||
}
|
||||
|
||||
_pushEvent(type) {
|
||||
let detail = { type: this._stateToGesture(this._state) };
|
||||
|
||||
// For most gesture events the current (average) position is the
|
||||
// most useful
|
||||
let avg = this._getPosition();
|
||||
let pos = avg.last;
|
||||
|
||||
// However we have a slight distance to detect gestures, so for the
|
||||
// first gesture event we want to use the first positions we saw
|
||||
if (type === 'gesturestart') {
|
||||
pos = avg.first;
|
||||
}
|
||||
|
||||
// For these gestures, we always want the event coordinates
|
||||
// to be where the gesture began, not the current touch location.
|
||||
switch (this._state) {
|
||||
case GH_TWODRAG:
|
||||
case GH_PINCH:
|
||||
pos = avg.first;
|
||||
break;
|
||||
}
|
||||
|
||||
detail['clientX'] = pos.x;
|
||||
detail['clientY'] = pos.y;
|
||||
|
||||
// FIXME: other coordinates?
|
||||
|
||||
// Some gestures also have a magnitude
|
||||
if (this._state === GH_PINCH) {
|
||||
let distance = this._getAverageDistance();
|
||||
if (type === 'gesturestart') {
|
||||
detail['magnitudeX'] = distance.first.x;
|
||||
detail['magnitudeY'] = distance.first.y;
|
||||
} else {
|
||||
detail['magnitudeX'] = distance.last.x;
|
||||
detail['magnitudeY'] = distance.last.y;
|
||||
}
|
||||
} else if (this._state === GH_TWODRAG) {
|
||||
if (type === 'gesturestart') {
|
||||
detail['magnitudeX'] = 0.0;
|
||||
detail['magnitudeY'] = 0.0;
|
||||
} else {
|
||||
let movement = this._getAverageMovement();
|
||||
detail['magnitudeX'] = movement.x;
|
||||
detail['magnitudeY'] = movement.y;
|
||||
}
|
||||
}
|
||||
|
||||
let gev = new CustomEvent(type, { detail: detail });
|
||||
this._target.dispatchEvent(gev);
|
||||
}
|
||||
|
||||
_stateToGesture(state) {
|
||||
switch (state) {
|
||||
case GH_ONETAP:
|
||||
return 'onetap';
|
||||
case GH_TWOTAP:
|
||||
return 'twotap';
|
||||
case GH_THREETAP:
|
||||
return 'threetap';
|
||||
case GH_DRAG:
|
||||
return 'drag';
|
||||
case GH_LONGPRESS:
|
||||
return 'longpress';
|
||||
case GH_TWODRAG:
|
||||
return 'twodrag';
|
||||
case GH_PINCH:
|
||||
return 'pinch';
|
||||
}
|
||||
|
||||
throw new Error("Unknown gesture state: " + state);
|
||||
}
|
||||
|
||||
_getPosition() {
|
||||
if (this._tracked.length === 0) {
|
||||
throw new Error("Failed to get gesture position, no tracked touches");
|
||||
}
|
||||
|
||||
let size = this._tracked.length;
|
||||
let fx = 0, fy = 0, lx = 0, ly = 0;
|
||||
|
||||
for (let i = 0; i < this._tracked.length; i++) {
|
||||
fx += this._tracked[i].firstX;
|
||||
fy += this._tracked[i].firstY;
|
||||
lx += this._tracked[i].lastX;
|
||||
ly += this._tracked[i].lastY;
|
||||
}
|
||||
|
||||
return { first: { x: fx / size,
|
||||
y: fy / size },
|
||||
last: { x: lx / size,
|
||||
y: ly / size } };
|
||||
}
|
||||
|
||||
_getAverageMovement() {
|
||||
if (this._tracked.length === 0) {
|
||||
throw new Error("Failed to get gesture movement, no tracked touches");
|
||||
}
|
||||
|
||||
let totalH, totalV;
|
||||
totalH = totalV = 0;
|
||||
let size = this._tracked.length;
|
||||
|
||||
for (let i = 0; i < this._tracked.length; i++) {
|
||||
totalH += this._tracked[i].lastX - this._tracked[i].firstX;
|
||||
totalV += this._tracked[i].lastY - this._tracked[i].firstY;
|
||||
}
|
||||
|
||||
return { x: totalH / size,
|
||||
y: totalV / size };
|
||||
}
|
||||
|
||||
_getAverageDistance() {
|
||||
if (this._tracked.length === 0) {
|
||||
throw new Error("Failed to get gesture distance, no tracked touches");
|
||||
}
|
||||
|
||||
// Distance between the first and last tracked touches
|
||||
|
||||
let first = this._tracked[0];
|
||||
let last = this._tracked[this._tracked.length - 1];
|
||||
|
||||
let fdx = Math.abs(last.firstX - first.firstX);
|
||||
let fdy = Math.abs(last.firstY - first.firstY);
|
||||
|
||||
let ldx = Math.abs(last.lastX - first.lastX);
|
||||
let ldy = Math.abs(last.lastY - first.lastY);
|
||||
|
||||
return { first: { x: fdx, y: fdy },
|
||||
last: { x: ldx, y: ldy } };
|
||||
}
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 or any later version (see LICENSE.txt)
|
||||
*/
|
||||
|
||||
import * as Log from '../util/logging.js';
|
||||
import { stopEvent } from '../util/events.js';
|
||||
import * as KeyboardUtil from "./util.js";
|
||||
import KeyTable from "./keysym.js";
|
||||
import * as browser from "../util/browser.js";
|
||||
|
||||
//
|
||||
// Keyboard event handler
|
||||
//
|
||||
|
||||
export default class Keyboard {
|
||||
constructor(target) {
|
||||
this._target = target || null;
|
||||
|
||||
this._keyDownList = {}; // List of depressed keys
|
||||
// (even if they are happy)
|
||||
this._altGrArmed = false; // Windows AltGr detection
|
||||
|
||||
// keep these here so we can refer to them later
|
||||
this._eventHandlers = {
|
||||
'keyup': this._handleKeyUp.bind(this),
|
||||
'keydown': this._handleKeyDown.bind(this),
|
||||
'blur': this._allKeysUp.bind(this),
|
||||
};
|
||||
|
||||
// ===== EVENT HANDLERS =====
|
||||
|
||||
this.onkeyevent = () => {}; // Handler for key press/release
|
||||
}
|
||||
|
||||
// ===== PRIVATE METHODS =====
|
||||
|
||||
_sendKeyEvent(keysym, code, down, numlock = null, capslock = null) {
|
||||
if (down) {
|
||||
this._keyDownList[code] = keysym;
|
||||
} else {
|
||||
// Do we really think this key is down?
|
||||
if (!(code in this._keyDownList)) {
|
||||
return;
|
||||
}
|
||||
delete this._keyDownList[code];
|
||||
}
|
||||
|
||||
Log.Debug("onkeyevent " + (down ? "down" : "up") +
|
||||
", keysym: " + keysym, ", code: " + code +
|
||||
", numlock: " + numlock + ", capslock: " + capslock);
|
||||
this.onkeyevent(keysym, code, down, numlock, capslock);
|
||||
}
|
||||
|
||||
_getKeyCode(e) {
|
||||
const code = KeyboardUtil.getKeycode(e);
|
||||
if (code !== 'Unidentified') {
|
||||
return code;
|
||||
}
|
||||
|
||||
// Unstable, but we don't have anything else to go on
|
||||
if (e.keyCode) {
|
||||
// 229 is used for composition events
|
||||
if (e.keyCode !== 229) {
|
||||
return 'Platform' + e.keyCode;
|
||||
}
|
||||
}
|
||||
|
||||
// A precursor to the final DOM3 standard. Unfortunately it
|
||||
// is not layout independent, so it is as bad as using keyCode
|
||||
if (e.keyIdentifier) {
|
||||
// Non-character key?
|
||||
if (e.keyIdentifier.substr(0, 2) !== 'U+') {
|
||||
return e.keyIdentifier;
|
||||
}
|
||||
|
||||
const codepoint = parseInt(e.keyIdentifier.substr(2), 16);
|
||||
const char = String.fromCharCode(codepoint).toUpperCase();
|
||||
|
||||
return 'Platform' + char.charCodeAt();
|
||||
}
|
||||
|
||||
return 'Unidentified';
|
||||
}
|
||||
|
||||
_handleKeyDown(e) {
|
||||
const code = this._getKeyCode(e);
|
||||
let keysym = KeyboardUtil.getKeysym(e);
|
||||
let numlock = e.getModifierState('NumLock');
|
||||
let capslock = e.getModifierState('CapsLock');
|
||||
|
||||
// getModifierState for NumLock is not supported on mac and ios and always returns false.
|
||||
// Set to null to indicate unknown/unsupported instead.
|
||||
if (browser.isMac() || browser.isIOS()) {
|
||||
numlock = null;
|
||||
}
|
||||
|
||||
// Windows doesn't have a proper AltGr, but handles it using
|
||||
// fake Ctrl+Alt. However the remote end might not be Windows,
|
||||
// so we need to merge those in to a single AltGr event. We
|
||||
// detect this case by seeing the two key events directly after
|
||||
// each other with a very short time between them (<50ms).
|
||||
if (this._altGrArmed) {
|
||||
this._altGrArmed = false;
|
||||
clearTimeout(this._altGrTimeout);
|
||||
|
||||
if ((code === "AltRight") &&
|
||||
((e.timeStamp - this._altGrCtrlTime) < 50)) {
|
||||
// FIXME: We fail to detect this if either Ctrl key is
|
||||
// first manually pressed as Windows then no
|
||||
// longer sends the fake Ctrl down event. It
|
||||
// does however happily send real Ctrl events
|
||||
// even when AltGr is already down. Some
|
||||
// browsers detect this for us though and set the
|
||||
// key to "AltGraph".
|
||||
keysym = KeyTable.XK_ISO_Level3_Shift;
|
||||
} else {
|
||||
this._sendKeyEvent(KeyTable.XK_Control_L, "ControlLeft", true, numlock, capslock);
|
||||
}
|
||||
}
|
||||
|
||||
// We cannot handle keys we cannot track, but we also need
|
||||
// to deal with virtual keyboards which omit key info
|
||||
if (code === 'Unidentified') {
|
||||
if (keysym) {
|
||||
// If it's a virtual keyboard then it should be
|
||||
// sufficient to just send press and release right
|
||||
// after each other
|
||||
this._sendKeyEvent(keysym, code, true, numlock, capslock);
|
||||
this._sendKeyEvent(keysym, code, false, numlock, capslock);
|
||||
}
|
||||
|
||||
stopEvent(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt behaves more like AltGraph on macOS, so shuffle the
|
||||
// keys around a bit to make things more sane for the remote
|
||||
// server. This method is used by RealVNC and TigerVNC (and
|
||||
// possibly others).
|
||||
if (browser.isMac() || browser.isIOS()) {
|
||||
switch (keysym) {
|
||||
case KeyTable.XK_Super_L:
|
||||
keysym = KeyTable.XK_Alt_L;
|
||||
break;
|
||||
case KeyTable.XK_Super_R:
|
||||
keysym = KeyTable.XK_Super_L;
|
||||
break;
|
||||
case KeyTable.XK_Alt_L:
|
||||
keysym = KeyTable.XK_Mode_switch;
|
||||
break;
|
||||
case KeyTable.XK_Alt_R:
|
||||
keysym = KeyTable.XK_ISO_Level3_Shift;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Is this key already pressed? If so, then we must use the
|
||||
// same keysym or we'll confuse the server
|
||||
if (code in this._keyDownList) {
|
||||
keysym = this._keyDownList[code];
|
||||
}
|
||||
|
||||
// macOS doesn't send proper key releases if a key is pressed
|
||||
// while meta is held down
|
||||
if ((browser.isMac() || browser.isIOS()) &&
|
||||
(e.metaKey && code !== 'MetaLeft' && code !== 'MetaRight')) {
|
||||
this._sendKeyEvent(keysym, code, true, numlock, capslock);
|
||||
this._sendKeyEvent(keysym, code, false, numlock, capslock);
|
||||
stopEvent(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// macOS doesn't send proper key events for modifiers, only
|
||||
// state change events. That gets extra confusing for CapsLock
|
||||
// which toggles on each press, but not on release. So pretend
|
||||
// it was a quick press and release of the button.
|
||||
if ((browser.isMac() || browser.isIOS()) && (code === 'CapsLock')) {
|
||||
this._sendKeyEvent(KeyTable.XK_Caps_Lock, 'CapsLock', true, numlock, capslock);
|
||||
this._sendKeyEvent(KeyTable.XK_Caps_Lock, 'CapsLock', false, numlock, capslock);
|
||||
stopEvent(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Windows doesn't send proper key releases for a bunch of
|
||||
// Japanese IM keys so we have to fake the release right away
|
||||
const jpBadKeys = [ KeyTable.XK_Zenkaku_Hankaku,
|
||||
KeyTable.XK_Eisu_toggle,
|
||||
KeyTable.XK_Katakana,
|
||||
KeyTable.XK_Hiragana,
|
||||
KeyTable.XK_Romaji ];
|
||||
if (browser.isWindows() && jpBadKeys.includes(keysym)) {
|
||||
this._sendKeyEvent(keysym, code, true, numlock, capslock);
|
||||
this._sendKeyEvent(keysym, code, false, numlock, capslock);
|
||||
stopEvent(e);
|
||||
return;
|
||||
}
|
||||
|
||||
stopEvent(e);
|
||||
|
||||
// Possible start of AltGr sequence? (see above)
|
||||
if ((code === "ControlLeft") && browser.isWindows() &&
|
||||
!("ControlLeft" in this._keyDownList)) {
|
||||
this._altGrArmed = true;
|
||||
this._altGrTimeout = setTimeout(this._interruptAltGrSequence.bind(this), 100);
|
||||
this._altGrCtrlTime = e.timeStamp;
|
||||
return;
|
||||
}
|
||||
|
||||
this._sendKeyEvent(keysym, code, true, numlock, capslock);
|
||||
}
|
||||
|
||||
_handleKeyUp(e) {
|
||||
stopEvent(e);
|
||||
|
||||
const code = this._getKeyCode(e);
|
||||
|
||||
// We can't get a release in the middle of an AltGr sequence, so
|
||||
// abort that detection
|
||||
this._interruptAltGrSequence();
|
||||
|
||||
// See comment in _handleKeyDown()
|
||||
if ((browser.isMac() || browser.isIOS()) && (code === 'CapsLock')) {
|
||||
this._sendKeyEvent(KeyTable.XK_Caps_Lock, 'CapsLock', true);
|
||||
this._sendKeyEvent(KeyTable.XK_Caps_Lock, 'CapsLock', false);
|
||||
return;
|
||||
}
|
||||
|
||||
this._sendKeyEvent(this._keyDownList[code], code, false);
|
||||
|
||||
// Windows has a rather nasty bug where it won't send key
|
||||
// release events for a Shift button if the other Shift is still
|
||||
// pressed
|
||||
if (browser.isWindows() && ((code === 'ShiftLeft') ||
|
||||
(code === 'ShiftRight'))) {
|
||||
if ('ShiftRight' in this._keyDownList) {
|
||||
this._sendKeyEvent(this._keyDownList['ShiftRight'],
|
||||
'ShiftRight', false);
|
||||
}
|
||||
if ('ShiftLeft' in this._keyDownList) {
|
||||
this._sendKeyEvent(this._keyDownList['ShiftLeft'],
|
||||
'ShiftLeft', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_interruptAltGrSequence() {
|
||||
if (this._altGrArmed) {
|
||||
this._altGrArmed = false;
|
||||
clearTimeout(this._altGrTimeout);
|
||||
this._sendKeyEvent(KeyTable.XK_Control_L, "ControlLeft", true);
|
||||
}
|
||||
}
|
||||
|
||||
_allKeysUp() {
|
||||
Log.Debug(">> Keyboard.allKeysUp");
|
||||
|
||||
// Prevent control key being processed after losing focus.
|
||||
this._interruptAltGrSequence();
|
||||
|
||||
for (let code in this._keyDownList) {
|
||||
this._sendKeyEvent(this._keyDownList[code], code, false);
|
||||
}
|
||||
Log.Debug("<< Keyboard.allKeysUp");
|
||||
}
|
||||
|
||||
// ===== PUBLIC METHODS =====
|
||||
|
||||
grab() {
|
||||
//Log.Debug(">> Keyboard.grab");
|
||||
|
||||
this._target.addEventListener('keydown', this._eventHandlers.keydown);
|
||||
this._target.addEventListener('keyup', this._eventHandlers.keyup);
|
||||
|
||||
// Release (key up) if window loses focus
|
||||
window.addEventListener('blur', this._eventHandlers.blur);
|
||||
|
||||
//Log.Debug("<< Keyboard.grab");
|
||||
}
|
||||
|
||||
ungrab() {
|
||||
//Log.Debug(">> Keyboard.ungrab");
|
||||
|
||||
this._target.removeEventListener('keydown', this._eventHandlers.keydown);
|
||||
this._target.removeEventListener('keyup', this._eventHandlers.keyup);
|
||||
window.removeEventListener('blur', this._eventHandlers.blur);
|
||||
|
||||
// Release (key up) all keys that are in a down state
|
||||
this._allKeysUp();
|
||||
|
||||
//Log.Debug(">> Keyboard.ungrab");
|
||||
}
|
||||
}
|
||||
+616
@@ -0,0 +1,616 @@
|
||||
/* eslint-disable key-spacing */
|
||||
|
||||
export default {
|
||||
XK_VoidSymbol: 0xffffff, /* Void symbol */
|
||||
|
||||
XK_BackSpace: 0xff08, /* Back space, back char */
|
||||
XK_Tab: 0xff09,
|
||||
XK_Linefeed: 0xff0a, /* Linefeed, LF */
|
||||
XK_Clear: 0xff0b,
|
||||
XK_Return: 0xff0d, /* Return, enter */
|
||||
XK_Pause: 0xff13, /* Pause, hold */
|
||||
XK_Scroll_Lock: 0xff14,
|
||||
XK_Sys_Req: 0xff15,
|
||||
XK_Escape: 0xff1b,
|
||||
XK_Delete: 0xffff, /* Delete, rubout */
|
||||
|
||||
/* International & multi-key character composition */
|
||||
|
||||
XK_Multi_key: 0xff20, /* Multi-key character compose */
|
||||
XK_Codeinput: 0xff37,
|
||||
XK_SingleCandidate: 0xff3c,
|
||||
XK_MultipleCandidate: 0xff3d,
|
||||
XK_PreviousCandidate: 0xff3e,
|
||||
|
||||
/* Japanese keyboard support */
|
||||
|
||||
XK_Kanji: 0xff21, /* Kanji, Kanji convert */
|
||||
XK_Muhenkan: 0xff22, /* Cancel Conversion */
|
||||
XK_Henkan_Mode: 0xff23, /* Start/Stop Conversion */
|
||||
XK_Henkan: 0xff23, /* Alias for Henkan_Mode */
|
||||
XK_Romaji: 0xff24, /* to Romaji */
|
||||
XK_Hiragana: 0xff25, /* to Hiragana */
|
||||
XK_Katakana: 0xff26, /* to Katakana */
|
||||
XK_Hiragana_Katakana: 0xff27, /* Hiragana/Katakana toggle */
|
||||
XK_Zenkaku: 0xff28, /* to Zenkaku */
|
||||
XK_Hankaku: 0xff29, /* to Hankaku */
|
||||
XK_Zenkaku_Hankaku: 0xff2a, /* Zenkaku/Hankaku toggle */
|
||||
XK_Touroku: 0xff2b, /* Add to Dictionary */
|
||||
XK_Massyo: 0xff2c, /* Delete from Dictionary */
|
||||
XK_Kana_Lock: 0xff2d, /* Kana Lock */
|
||||
XK_Kana_Shift: 0xff2e, /* Kana Shift */
|
||||
XK_Eisu_Shift: 0xff2f, /* Alphanumeric Shift */
|
||||
XK_Eisu_toggle: 0xff30, /* Alphanumeric toggle */
|
||||
XK_Kanji_Bangou: 0xff37, /* Codeinput */
|
||||
XK_Zen_Koho: 0xff3d, /* Multiple/All Candidate(s) */
|
||||
XK_Mae_Koho: 0xff3e, /* Previous Candidate */
|
||||
|
||||
/* Cursor control & motion */
|
||||
|
||||
XK_Home: 0xff50,
|
||||
XK_Left: 0xff51, /* Move left, left arrow */
|
||||
XK_Up: 0xff52, /* Move up, up arrow */
|
||||
XK_Right: 0xff53, /* Move right, right arrow */
|
||||
XK_Down: 0xff54, /* Move down, down arrow */
|
||||
XK_Prior: 0xff55, /* Prior, previous */
|
||||
XK_Page_Up: 0xff55,
|
||||
XK_Next: 0xff56, /* Next */
|
||||
XK_Page_Down: 0xff56,
|
||||
XK_End: 0xff57, /* EOL */
|
||||
XK_Begin: 0xff58, /* BOL */
|
||||
|
||||
|
||||
/* Misc functions */
|
||||
|
||||
XK_Select: 0xff60, /* Select, mark */
|
||||
XK_Print: 0xff61,
|
||||
XK_Execute: 0xff62, /* Execute, run, do */
|
||||
XK_Insert: 0xff63, /* Insert, insert here */
|
||||
XK_Undo: 0xff65,
|
||||
XK_Redo: 0xff66, /* Redo, again */
|
||||
XK_Menu: 0xff67,
|
||||
XK_Find: 0xff68, /* Find, search */
|
||||
XK_Cancel: 0xff69, /* Cancel, stop, abort, exit */
|
||||
XK_Help: 0xff6a, /* Help */
|
||||
XK_Break: 0xff6b,
|
||||
XK_Mode_switch: 0xff7e, /* Character set switch */
|
||||
XK_script_switch: 0xff7e, /* Alias for mode_switch */
|
||||
XK_Num_Lock: 0xff7f,
|
||||
|
||||
/* Keypad functions, keypad numbers cleverly chosen to map to ASCII */
|
||||
|
||||
XK_KP_Space: 0xff80, /* Space */
|
||||
XK_KP_Tab: 0xff89,
|
||||
XK_KP_Enter: 0xff8d, /* Enter */
|
||||
XK_KP_F1: 0xff91, /* PF1, KP_A, ... */
|
||||
XK_KP_F2: 0xff92,
|
||||
XK_KP_F3: 0xff93,
|
||||
XK_KP_F4: 0xff94,
|
||||
XK_KP_Home: 0xff95,
|
||||
XK_KP_Left: 0xff96,
|
||||
XK_KP_Up: 0xff97,
|
||||
XK_KP_Right: 0xff98,
|
||||
XK_KP_Down: 0xff99,
|
||||
XK_KP_Prior: 0xff9a,
|
||||
XK_KP_Page_Up: 0xff9a,
|
||||
XK_KP_Next: 0xff9b,
|
||||
XK_KP_Page_Down: 0xff9b,
|
||||
XK_KP_End: 0xff9c,
|
||||
XK_KP_Begin: 0xff9d,
|
||||
XK_KP_Insert: 0xff9e,
|
||||
XK_KP_Delete: 0xff9f,
|
||||
XK_KP_Equal: 0xffbd, /* Equals */
|
||||
XK_KP_Multiply: 0xffaa,
|
||||
XK_KP_Add: 0xffab,
|
||||
XK_KP_Separator: 0xffac, /* Separator, often comma */
|
||||
XK_KP_Subtract: 0xffad,
|
||||
XK_KP_Decimal: 0xffae,
|
||||
XK_KP_Divide: 0xffaf,
|
||||
|
||||
XK_KP_0: 0xffb0,
|
||||
XK_KP_1: 0xffb1,
|
||||
XK_KP_2: 0xffb2,
|
||||
XK_KP_3: 0xffb3,
|
||||
XK_KP_4: 0xffb4,
|
||||
XK_KP_5: 0xffb5,
|
||||
XK_KP_6: 0xffb6,
|
||||
XK_KP_7: 0xffb7,
|
||||
XK_KP_8: 0xffb8,
|
||||
XK_KP_9: 0xffb9,
|
||||
|
||||
/*
|
||||
* Auxiliary functions; note the duplicate definitions for left and right
|
||||
* function keys; Sun keyboards and a few other manufacturers have such
|
||||
* function key groups on the left and/or right sides of the keyboard.
|
||||
* We've not found a keyboard with more than 35 function keys total.
|
||||
*/
|
||||
|
||||
XK_F1: 0xffbe,
|
||||
XK_F2: 0xffbf,
|
||||
XK_F3: 0xffc0,
|
||||
XK_F4: 0xffc1,
|
||||
XK_F5: 0xffc2,
|
||||
XK_F6: 0xffc3,
|
||||
XK_F7: 0xffc4,
|
||||
XK_F8: 0xffc5,
|
||||
XK_F9: 0xffc6,
|
||||
XK_F10: 0xffc7,
|
||||
XK_F11: 0xffc8,
|
||||
XK_L1: 0xffc8,
|
||||
XK_F12: 0xffc9,
|
||||
XK_L2: 0xffc9,
|
||||
XK_F13: 0xffca,
|
||||
XK_L3: 0xffca,
|
||||
XK_F14: 0xffcb,
|
||||
XK_L4: 0xffcb,
|
||||
XK_F15: 0xffcc,
|
||||
XK_L5: 0xffcc,
|
||||
XK_F16: 0xffcd,
|
||||
XK_L6: 0xffcd,
|
||||
XK_F17: 0xffce,
|
||||
XK_L7: 0xffce,
|
||||
XK_F18: 0xffcf,
|
||||
XK_L8: 0xffcf,
|
||||
XK_F19: 0xffd0,
|
||||
XK_L9: 0xffd0,
|
||||
XK_F20: 0xffd1,
|
||||
XK_L10: 0xffd1,
|
||||
XK_F21: 0xffd2,
|
||||
XK_R1: 0xffd2,
|
||||
XK_F22: 0xffd3,
|
||||
XK_R2: 0xffd3,
|
||||
XK_F23: 0xffd4,
|
||||
XK_R3: 0xffd4,
|
||||
XK_F24: 0xffd5,
|
||||
XK_R4: 0xffd5,
|
||||
XK_F25: 0xffd6,
|
||||
XK_R5: 0xffd6,
|
||||
XK_F26: 0xffd7,
|
||||
XK_R6: 0xffd7,
|
||||
XK_F27: 0xffd8,
|
||||
XK_R7: 0xffd8,
|
||||
XK_F28: 0xffd9,
|
||||
XK_R8: 0xffd9,
|
||||
XK_F29: 0xffda,
|
||||
XK_R9: 0xffda,
|
||||
XK_F30: 0xffdb,
|
||||
XK_R10: 0xffdb,
|
||||
XK_F31: 0xffdc,
|
||||
XK_R11: 0xffdc,
|
||||
XK_F32: 0xffdd,
|
||||
XK_R12: 0xffdd,
|
||||
XK_F33: 0xffde,
|
||||
XK_R13: 0xffde,
|
||||
XK_F34: 0xffdf,
|
||||
XK_R14: 0xffdf,
|
||||
XK_F35: 0xffe0,
|
||||
XK_R15: 0xffe0,
|
||||
|
||||
/* Modifiers */
|
||||
|
||||
XK_Shift_L: 0xffe1, /* Left shift */
|
||||
XK_Shift_R: 0xffe2, /* Right shift */
|
||||
XK_Control_L: 0xffe3, /* Left control */
|
||||
XK_Control_R: 0xffe4, /* Right control */
|
||||
XK_Caps_Lock: 0xffe5, /* Caps lock */
|
||||
XK_Shift_Lock: 0xffe6, /* Shift lock */
|
||||
|
||||
XK_Meta_L: 0xffe7, /* Left meta */
|
||||
XK_Meta_R: 0xffe8, /* Right meta */
|
||||
XK_Alt_L: 0xffe9, /* Left alt */
|
||||
XK_Alt_R: 0xffea, /* Right alt */
|
||||
XK_Super_L: 0xffeb, /* Left super */
|
||||
XK_Super_R: 0xffec, /* Right super */
|
||||
XK_Hyper_L: 0xffed, /* Left hyper */
|
||||
XK_Hyper_R: 0xffee, /* Right hyper */
|
||||
|
||||
/*
|
||||
* Keyboard (XKB) Extension function and modifier keys
|
||||
* (from Appendix C of "The X Keyboard Extension: Protocol Specification")
|
||||
* Byte 3 = 0xfe
|
||||
*/
|
||||
|
||||
XK_ISO_Level3_Shift: 0xfe03, /* AltGr */
|
||||
XK_ISO_Next_Group: 0xfe08,
|
||||
XK_ISO_Prev_Group: 0xfe0a,
|
||||
XK_ISO_First_Group: 0xfe0c,
|
||||
XK_ISO_Last_Group: 0xfe0e,
|
||||
|
||||
/*
|
||||
* Latin 1
|
||||
* (ISO/IEC 8859-1: Unicode U+0020..U+00FF)
|
||||
* Byte 3: 0
|
||||
*/
|
||||
|
||||
XK_space: 0x0020, /* U+0020 SPACE */
|
||||
XK_exclam: 0x0021, /* U+0021 EXCLAMATION MARK */
|
||||
XK_quotedbl: 0x0022, /* U+0022 QUOTATION MARK */
|
||||
XK_numbersign: 0x0023, /* U+0023 NUMBER SIGN */
|
||||
XK_dollar: 0x0024, /* U+0024 DOLLAR SIGN */
|
||||
XK_percent: 0x0025, /* U+0025 PERCENT SIGN */
|
||||
XK_ampersand: 0x0026, /* U+0026 AMPERSAND */
|
||||
XK_apostrophe: 0x0027, /* U+0027 APOSTROPHE */
|
||||
XK_quoteright: 0x0027, /* deprecated */
|
||||
XK_parenleft: 0x0028, /* U+0028 LEFT PARENTHESIS */
|
||||
XK_parenright: 0x0029, /* U+0029 RIGHT PARENTHESIS */
|
||||
XK_asterisk: 0x002a, /* U+002A ASTERISK */
|
||||
XK_plus: 0x002b, /* U+002B PLUS SIGN */
|
||||
XK_comma: 0x002c, /* U+002C COMMA */
|
||||
XK_minus: 0x002d, /* U+002D HYPHEN-MINUS */
|
||||
XK_period: 0x002e, /* U+002E FULL STOP */
|
||||
XK_slash: 0x002f, /* U+002F SOLIDUS */
|
||||
XK_0: 0x0030, /* U+0030 DIGIT ZERO */
|
||||
XK_1: 0x0031, /* U+0031 DIGIT ONE */
|
||||
XK_2: 0x0032, /* U+0032 DIGIT TWO */
|
||||
XK_3: 0x0033, /* U+0033 DIGIT THREE */
|
||||
XK_4: 0x0034, /* U+0034 DIGIT FOUR */
|
||||
XK_5: 0x0035, /* U+0035 DIGIT FIVE */
|
||||
XK_6: 0x0036, /* U+0036 DIGIT SIX */
|
||||
XK_7: 0x0037, /* U+0037 DIGIT SEVEN */
|
||||
XK_8: 0x0038, /* U+0038 DIGIT EIGHT */
|
||||
XK_9: 0x0039, /* U+0039 DIGIT NINE */
|
||||
XK_colon: 0x003a, /* U+003A COLON */
|
||||
XK_semicolon: 0x003b, /* U+003B SEMICOLON */
|
||||
XK_less: 0x003c, /* U+003C LESS-THAN SIGN */
|
||||
XK_equal: 0x003d, /* U+003D EQUALS SIGN */
|
||||
XK_greater: 0x003e, /* U+003E GREATER-THAN SIGN */
|
||||
XK_question: 0x003f, /* U+003F QUESTION MARK */
|
||||
XK_at: 0x0040, /* U+0040 COMMERCIAL AT */
|
||||
XK_A: 0x0041, /* U+0041 LATIN CAPITAL LETTER A */
|
||||
XK_B: 0x0042, /* U+0042 LATIN CAPITAL LETTER B */
|
||||
XK_C: 0x0043, /* U+0043 LATIN CAPITAL LETTER C */
|
||||
XK_D: 0x0044, /* U+0044 LATIN CAPITAL LETTER D */
|
||||
XK_E: 0x0045, /* U+0045 LATIN CAPITAL LETTER E */
|
||||
XK_F: 0x0046, /* U+0046 LATIN CAPITAL LETTER F */
|
||||
XK_G: 0x0047, /* U+0047 LATIN CAPITAL LETTER G */
|
||||
XK_H: 0x0048, /* U+0048 LATIN CAPITAL LETTER H */
|
||||
XK_I: 0x0049, /* U+0049 LATIN CAPITAL LETTER I */
|
||||
XK_J: 0x004a, /* U+004A LATIN CAPITAL LETTER J */
|
||||
XK_K: 0x004b, /* U+004B LATIN CAPITAL LETTER K */
|
||||
XK_L: 0x004c, /* U+004C LATIN CAPITAL LETTER L */
|
||||
XK_M: 0x004d, /* U+004D LATIN CAPITAL LETTER M */
|
||||
XK_N: 0x004e, /* U+004E LATIN CAPITAL LETTER N */
|
||||
XK_O: 0x004f, /* U+004F LATIN CAPITAL LETTER O */
|
||||
XK_P: 0x0050, /* U+0050 LATIN CAPITAL LETTER P */
|
||||
XK_Q: 0x0051, /* U+0051 LATIN CAPITAL LETTER Q */
|
||||
XK_R: 0x0052, /* U+0052 LATIN CAPITAL LETTER R */
|
||||
XK_S: 0x0053, /* U+0053 LATIN CAPITAL LETTER S */
|
||||
XK_T: 0x0054, /* U+0054 LATIN CAPITAL LETTER T */
|
||||
XK_U: 0x0055, /* U+0055 LATIN CAPITAL LETTER U */
|
||||
XK_V: 0x0056, /* U+0056 LATIN CAPITAL LETTER V */
|
||||
XK_W: 0x0057, /* U+0057 LATIN CAPITAL LETTER W */
|
||||
XK_X: 0x0058, /* U+0058 LATIN CAPITAL LETTER X */
|
||||
XK_Y: 0x0059, /* U+0059 LATIN CAPITAL LETTER Y */
|
||||
XK_Z: 0x005a, /* U+005A LATIN CAPITAL LETTER Z */
|
||||
XK_bracketleft: 0x005b, /* U+005B LEFT SQUARE BRACKET */
|
||||
XK_backslash: 0x005c, /* U+005C REVERSE SOLIDUS */
|
||||
XK_bracketright: 0x005d, /* U+005D RIGHT SQUARE BRACKET */
|
||||
XK_asciicircum: 0x005e, /* U+005E CIRCUMFLEX ACCENT */
|
||||
XK_underscore: 0x005f, /* U+005F LOW LINE */
|
||||
XK_grave: 0x0060, /* U+0060 GRAVE ACCENT */
|
||||
XK_quoteleft: 0x0060, /* deprecated */
|
||||
XK_a: 0x0061, /* U+0061 LATIN SMALL LETTER A */
|
||||
XK_b: 0x0062, /* U+0062 LATIN SMALL LETTER B */
|
||||
XK_c: 0x0063, /* U+0063 LATIN SMALL LETTER C */
|
||||
XK_d: 0x0064, /* U+0064 LATIN SMALL LETTER D */
|
||||
XK_e: 0x0065, /* U+0065 LATIN SMALL LETTER E */
|
||||
XK_f: 0x0066, /* U+0066 LATIN SMALL LETTER F */
|
||||
XK_g: 0x0067, /* U+0067 LATIN SMALL LETTER G */
|
||||
XK_h: 0x0068, /* U+0068 LATIN SMALL LETTER H */
|
||||
XK_i: 0x0069, /* U+0069 LATIN SMALL LETTER I */
|
||||
XK_j: 0x006a, /* U+006A LATIN SMALL LETTER J */
|
||||
XK_k: 0x006b, /* U+006B LATIN SMALL LETTER K */
|
||||
XK_l: 0x006c, /* U+006C LATIN SMALL LETTER L */
|
||||
XK_m: 0x006d, /* U+006D LATIN SMALL LETTER M */
|
||||
XK_n: 0x006e, /* U+006E LATIN SMALL LETTER N */
|
||||
XK_o: 0x006f, /* U+006F LATIN SMALL LETTER O */
|
||||
XK_p: 0x0070, /* U+0070 LATIN SMALL LETTER P */
|
||||
XK_q: 0x0071, /* U+0071 LATIN SMALL LETTER Q */
|
||||
XK_r: 0x0072, /* U+0072 LATIN SMALL LETTER R */
|
||||
XK_s: 0x0073, /* U+0073 LATIN SMALL LETTER S */
|
||||
XK_t: 0x0074, /* U+0074 LATIN SMALL LETTER T */
|
||||
XK_u: 0x0075, /* U+0075 LATIN SMALL LETTER U */
|
||||
XK_v: 0x0076, /* U+0076 LATIN SMALL LETTER V */
|
||||
XK_w: 0x0077, /* U+0077 LATIN SMALL LETTER W */
|
||||
XK_x: 0x0078, /* U+0078 LATIN SMALL LETTER X */
|
||||
XK_y: 0x0079, /* U+0079 LATIN SMALL LETTER Y */
|
||||
XK_z: 0x007a, /* U+007A LATIN SMALL LETTER Z */
|
||||
XK_braceleft: 0x007b, /* U+007B LEFT CURLY BRACKET */
|
||||
XK_bar: 0x007c, /* U+007C VERTICAL LINE */
|
||||
XK_braceright: 0x007d, /* U+007D RIGHT CURLY BRACKET */
|
||||
XK_asciitilde: 0x007e, /* U+007E TILDE */
|
||||
|
||||
XK_nobreakspace: 0x00a0, /* U+00A0 NO-BREAK SPACE */
|
||||
XK_exclamdown: 0x00a1, /* U+00A1 INVERTED EXCLAMATION MARK */
|
||||
XK_cent: 0x00a2, /* U+00A2 CENT SIGN */
|
||||
XK_sterling: 0x00a3, /* U+00A3 POUND SIGN */
|
||||
XK_currency: 0x00a4, /* U+00A4 CURRENCY SIGN */
|
||||
XK_yen: 0x00a5, /* U+00A5 YEN SIGN */
|
||||
XK_brokenbar: 0x00a6, /* U+00A6 BROKEN BAR */
|
||||
XK_section: 0x00a7, /* U+00A7 SECTION SIGN */
|
||||
XK_diaeresis: 0x00a8, /* U+00A8 DIAERESIS */
|
||||
XK_copyright: 0x00a9, /* U+00A9 COPYRIGHT SIGN */
|
||||
XK_ordfeminine: 0x00aa, /* U+00AA FEMININE ORDINAL INDICATOR */
|
||||
XK_guillemotleft: 0x00ab, /* U+00AB LEFT-POINTING DOUBLE ANGLE QUOTATION MARK */
|
||||
XK_notsign: 0x00ac, /* U+00AC NOT SIGN */
|
||||
XK_hyphen: 0x00ad, /* U+00AD SOFT HYPHEN */
|
||||
XK_registered: 0x00ae, /* U+00AE REGISTERED SIGN */
|
||||
XK_macron: 0x00af, /* U+00AF MACRON */
|
||||
XK_degree: 0x00b0, /* U+00B0 DEGREE SIGN */
|
||||
XK_plusminus: 0x00b1, /* U+00B1 PLUS-MINUS SIGN */
|
||||
XK_twosuperior: 0x00b2, /* U+00B2 SUPERSCRIPT TWO */
|
||||
XK_threesuperior: 0x00b3, /* U+00B3 SUPERSCRIPT THREE */
|
||||
XK_acute: 0x00b4, /* U+00B4 ACUTE ACCENT */
|
||||
XK_mu: 0x00b5, /* U+00B5 MICRO SIGN */
|
||||
XK_paragraph: 0x00b6, /* U+00B6 PILCROW SIGN */
|
||||
XK_periodcentered: 0x00b7, /* U+00B7 MIDDLE DOT */
|
||||
XK_cedilla: 0x00b8, /* U+00B8 CEDILLA */
|
||||
XK_onesuperior: 0x00b9, /* U+00B9 SUPERSCRIPT ONE */
|
||||
XK_masculine: 0x00ba, /* U+00BA MASCULINE ORDINAL INDICATOR */
|
||||
XK_guillemotright: 0x00bb, /* U+00BB RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK */
|
||||
XK_onequarter: 0x00bc, /* U+00BC VULGAR FRACTION ONE QUARTER */
|
||||
XK_onehalf: 0x00bd, /* U+00BD VULGAR FRACTION ONE HALF */
|
||||
XK_threequarters: 0x00be, /* U+00BE VULGAR FRACTION THREE QUARTERS */
|
||||
XK_questiondown: 0x00bf, /* U+00BF INVERTED QUESTION MARK */
|
||||
XK_Agrave: 0x00c0, /* U+00C0 LATIN CAPITAL LETTER A WITH GRAVE */
|
||||
XK_Aacute: 0x00c1, /* U+00C1 LATIN CAPITAL LETTER A WITH ACUTE */
|
||||
XK_Acircumflex: 0x00c2, /* U+00C2 LATIN CAPITAL LETTER A WITH CIRCUMFLEX */
|
||||
XK_Atilde: 0x00c3, /* U+00C3 LATIN CAPITAL LETTER A WITH TILDE */
|
||||
XK_Adiaeresis: 0x00c4, /* U+00C4 LATIN CAPITAL LETTER A WITH DIAERESIS */
|
||||
XK_Aring: 0x00c5, /* U+00C5 LATIN CAPITAL LETTER A WITH RING ABOVE */
|
||||
XK_AE: 0x00c6, /* U+00C6 LATIN CAPITAL LETTER AE */
|
||||
XK_Ccedilla: 0x00c7, /* U+00C7 LATIN CAPITAL LETTER C WITH CEDILLA */
|
||||
XK_Egrave: 0x00c8, /* U+00C8 LATIN CAPITAL LETTER E WITH GRAVE */
|
||||
XK_Eacute: 0x00c9, /* U+00C9 LATIN CAPITAL LETTER E WITH ACUTE */
|
||||
XK_Ecircumflex: 0x00ca, /* U+00CA LATIN CAPITAL LETTER E WITH CIRCUMFLEX */
|
||||
XK_Ediaeresis: 0x00cb, /* U+00CB LATIN CAPITAL LETTER E WITH DIAERESIS */
|
||||
XK_Igrave: 0x00cc, /* U+00CC LATIN CAPITAL LETTER I WITH GRAVE */
|
||||
XK_Iacute: 0x00cd, /* U+00CD LATIN CAPITAL LETTER I WITH ACUTE */
|
||||
XK_Icircumflex: 0x00ce, /* U+00CE LATIN CAPITAL LETTER I WITH CIRCUMFLEX */
|
||||
XK_Idiaeresis: 0x00cf, /* U+00CF LATIN CAPITAL LETTER I WITH DIAERESIS */
|
||||
XK_ETH: 0x00d0, /* U+00D0 LATIN CAPITAL LETTER ETH */
|
||||
XK_Eth: 0x00d0, /* deprecated */
|
||||
XK_Ntilde: 0x00d1, /* U+00D1 LATIN CAPITAL LETTER N WITH TILDE */
|
||||
XK_Ograve: 0x00d2, /* U+00D2 LATIN CAPITAL LETTER O WITH GRAVE */
|
||||
XK_Oacute: 0x00d3, /* U+00D3 LATIN CAPITAL LETTER O WITH ACUTE */
|
||||
XK_Ocircumflex: 0x00d4, /* U+00D4 LATIN CAPITAL LETTER O WITH CIRCUMFLEX */
|
||||
XK_Otilde: 0x00d5, /* U+00D5 LATIN CAPITAL LETTER O WITH TILDE */
|
||||
XK_Odiaeresis: 0x00d6, /* U+00D6 LATIN CAPITAL LETTER O WITH DIAERESIS */
|
||||
XK_multiply: 0x00d7, /* U+00D7 MULTIPLICATION SIGN */
|
||||
XK_Oslash: 0x00d8, /* U+00D8 LATIN CAPITAL LETTER O WITH STROKE */
|
||||
XK_Ooblique: 0x00d8, /* U+00D8 LATIN CAPITAL LETTER O WITH STROKE */
|
||||
XK_Ugrave: 0x00d9, /* U+00D9 LATIN CAPITAL LETTER U WITH GRAVE */
|
||||
XK_Uacute: 0x00da, /* U+00DA LATIN CAPITAL LETTER U WITH ACUTE */
|
||||
XK_Ucircumflex: 0x00db, /* U+00DB LATIN CAPITAL LETTER U WITH CIRCUMFLEX */
|
||||
XK_Udiaeresis: 0x00dc, /* U+00DC LATIN CAPITAL LETTER U WITH DIAERESIS */
|
||||
XK_Yacute: 0x00dd, /* U+00DD LATIN CAPITAL LETTER Y WITH ACUTE */
|
||||
XK_THORN: 0x00de, /* U+00DE LATIN CAPITAL LETTER THORN */
|
||||
XK_Thorn: 0x00de, /* deprecated */
|
||||
XK_ssharp: 0x00df, /* U+00DF LATIN SMALL LETTER SHARP S */
|
||||
XK_agrave: 0x00e0, /* U+00E0 LATIN SMALL LETTER A WITH GRAVE */
|
||||
XK_aacute: 0x00e1, /* U+00E1 LATIN SMALL LETTER A WITH ACUTE */
|
||||
XK_acircumflex: 0x00e2, /* U+00E2 LATIN SMALL LETTER A WITH CIRCUMFLEX */
|
||||
XK_atilde: 0x00e3, /* U+00E3 LATIN SMALL LETTER A WITH TILDE */
|
||||
XK_adiaeresis: 0x00e4, /* U+00E4 LATIN SMALL LETTER A WITH DIAERESIS */
|
||||
XK_aring: 0x00e5, /* U+00E5 LATIN SMALL LETTER A WITH RING ABOVE */
|
||||
XK_ae: 0x00e6, /* U+00E6 LATIN SMALL LETTER AE */
|
||||
XK_ccedilla: 0x00e7, /* U+00E7 LATIN SMALL LETTER C WITH CEDILLA */
|
||||
XK_egrave: 0x00e8, /* U+00E8 LATIN SMALL LETTER E WITH GRAVE */
|
||||
XK_eacute: 0x00e9, /* U+00E9 LATIN SMALL LETTER E WITH ACUTE */
|
||||
XK_ecircumflex: 0x00ea, /* U+00EA LATIN SMALL LETTER E WITH CIRCUMFLEX */
|
||||
XK_ediaeresis: 0x00eb, /* U+00EB LATIN SMALL LETTER E WITH DIAERESIS */
|
||||
XK_igrave: 0x00ec, /* U+00EC LATIN SMALL LETTER I WITH GRAVE */
|
||||
XK_iacute: 0x00ed, /* U+00ED LATIN SMALL LETTER I WITH ACUTE */
|
||||
XK_icircumflex: 0x00ee, /* U+00EE LATIN SMALL LETTER I WITH CIRCUMFLEX */
|
||||
XK_idiaeresis: 0x00ef, /* U+00EF LATIN SMALL LETTER I WITH DIAERESIS */
|
||||
XK_eth: 0x00f0, /* U+00F0 LATIN SMALL LETTER ETH */
|
||||
XK_ntilde: 0x00f1, /* U+00F1 LATIN SMALL LETTER N WITH TILDE */
|
||||
XK_ograve: 0x00f2, /* U+00F2 LATIN SMALL LETTER O WITH GRAVE */
|
||||
XK_oacute: 0x00f3, /* U+00F3 LATIN SMALL LETTER O WITH ACUTE */
|
||||
XK_ocircumflex: 0x00f4, /* U+00F4 LATIN SMALL LETTER O WITH CIRCUMFLEX */
|
||||
XK_otilde: 0x00f5, /* U+00F5 LATIN SMALL LETTER O WITH TILDE */
|
||||
XK_odiaeresis: 0x00f6, /* U+00F6 LATIN SMALL LETTER O WITH DIAERESIS */
|
||||
XK_division: 0x00f7, /* U+00F7 DIVISION SIGN */
|
||||
XK_oslash: 0x00f8, /* U+00F8 LATIN SMALL LETTER O WITH STROKE */
|
||||
XK_ooblique: 0x00f8, /* U+00F8 LATIN SMALL LETTER O WITH STROKE */
|
||||
XK_ugrave: 0x00f9, /* U+00F9 LATIN SMALL LETTER U WITH GRAVE */
|
||||
XK_uacute: 0x00fa, /* U+00FA LATIN SMALL LETTER U WITH ACUTE */
|
||||
XK_ucircumflex: 0x00fb, /* U+00FB LATIN SMALL LETTER U WITH CIRCUMFLEX */
|
||||
XK_udiaeresis: 0x00fc, /* U+00FC LATIN SMALL LETTER U WITH DIAERESIS */
|
||||
XK_yacute: 0x00fd, /* U+00FD LATIN SMALL LETTER Y WITH ACUTE */
|
||||
XK_thorn: 0x00fe, /* U+00FE LATIN SMALL LETTER THORN */
|
||||
XK_ydiaeresis: 0x00ff, /* U+00FF LATIN SMALL LETTER Y WITH DIAERESIS */
|
||||
|
||||
/*
|
||||
* Korean
|
||||
* Byte 3 = 0x0e
|
||||
*/
|
||||
|
||||
XK_Hangul: 0xff31, /* Hangul start/stop(toggle) */
|
||||
XK_Hangul_Hanja: 0xff34, /* Start Hangul->Hanja Conversion */
|
||||
XK_Hangul_Jeonja: 0xff38, /* Jeonja mode */
|
||||
|
||||
/*
|
||||
* XFree86 vendor specific keysyms.
|
||||
*
|
||||
* The XFree86 keysym range is 0x10080001 - 0x1008FFFF.
|
||||
*/
|
||||
|
||||
XF86XK_ModeLock: 0x1008FF01,
|
||||
XF86XK_MonBrightnessUp: 0x1008FF02,
|
||||
XF86XK_MonBrightnessDown: 0x1008FF03,
|
||||
XF86XK_KbdLightOnOff: 0x1008FF04,
|
||||
XF86XK_KbdBrightnessUp: 0x1008FF05,
|
||||
XF86XK_KbdBrightnessDown: 0x1008FF06,
|
||||
XF86XK_Standby: 0x1008FF10,
|
||||
XF86XK_AudioLowerVolume: 0x1008FF11,
|
||||
XF86XK_AudioMute: 0x1008FF12,
|
||||
XF86XK_AudioRaiseVolume: 0x1008FF13,
|
||||
XF86XK_AudioPlay: 0x1008FF14,
|
||||
XF86XK_AudioStop: 0x1008FF15,
|
||||
XF86XK_AudioPrev: 0x1008FF16,
|
||||
XF86XK_AudioNext: 0x1008FF17,
|
||||
XF86XK_HomePage: 0x1008FF18,
|
||||
XF86XK_Mail: 0x1008FF19,
|
||||
XF86XK_Start: 0x1008FF1A,
|
||||
XF86XK_Search: 0x1008FF1B,
|
||||
XF86XK_AudioRecord: 0x1008FF1C,
|
||||
XF86XK_Calculator: 0x1008FF1D,
|
||||
XF86XK_Memo: 0x1008FF1E,
|
||||
XF86XK_ToDoList: 0x1008FF1F,
|
||||
XF86XK_Calendar: 0x1008FF20,
|
||||
XF86XK_PowerDown: 0x1008FF21,
|
||||
XF86XK_ContrastAdjust: 0x1008FF22,
|
||||
XF86XK_RockerUp: 0x1008FF23,
|
||||
XF86XK_RockerDown: 0x1008FF24,
|
||||
XF86XK_RockerEnter: 0x1008FF25,
|
||||
XF86XK_Back: 0x1008FF26,
|
||||
XF86XK_Forward: 0x1008FF27,
|
||||
XF86XK_Stop: 0x1008FF28,
|
||||
XF86XK_Refresh: 0x1008FF29,
|
||||
XF86XK_PowerOff: 0x1008FF2A,
|
||||
XF86XK_WakeUp: 0x1008FF2B,
|
||||
XF86XK_Eject: 0x1008FF2C,
|
||||
XF86XK_ScreenSaver: 0x1008FF2D,
|
||||
XF86XK_WWW: 0x1008FF2E,
|
||||
XF86XK_Sleep: 0x1008FF2F,
|
||||
XF86XK_Favorites: 0x1008FF30,
|
||||
XF86XK_AudioPause: 0x1008FF31,
|
||||
XF86XK_AudioMedia: 0x1008FF32,
|
||||
XF86XK_MyComputer: 0x1008FF33,
|
||||
XF86XK_VendorHome: 0x1008FF34,
|
||||
XF86XK_LightBulb: 0x1008FF35,
|
||||
XF86XK_Shop: 0x1008FF36,
|
||||
XF86XK_History: 0x1008FF37,
|
||||
XF86XK_OpenURL: 0x1008FF38,
|
||||
XF86XK_AddFavorite: 0x1008FF39,
|
||||
XF86XK_HotLinks: 0x1008FF3A,
|
||||
XF86XK_BrightnessAdjust: 0x1008FF3B,
|
||||
XF86XK_Finance: 0x1008FF3C,
|
||||
XF86XK_Community: 0x1008FF3D,
|
||||
XF86XK_AudioRewind: 0x1008FF3E,
|
||||
XF86XK_BackForward: 0x1008FF3F,
|
||||
XF86XK_Launch0: 0x1008FF40,
|
||||
XF86XK_Launch1: 0x1008FF41,
|
||||
XF86XK_Launch2: 0x1008FF42,
|
||||
XF86XK_Launch3: 0x1008FF43,
|
||||
XF86XK_Launch4: 0x1008FF44,
|
||||
XF86XK_Launch5: 0x1008FF45,
|
||||
XF86XK_Launch6: 0x1008FF46,
|
||||
XF86XK_Launch7: 0x1008FF47,
|
||||
XF86XK_Launch8: 0x1008FF48,
|
||||
XF86XK_Launch9: 0x1008FF49,
|
||||
XF86XK_LaunchA: 0x1008FF4A,
|
||||
XF86XK_LaunchB: 0x1008FF4B,
|
||||
XF86XK_LaunchC: 0x1008FF4C,
|
||||
XF86XK_LaunchD: 0x1008FF4D,
|
||||
XF86XK_LaunchE: 0x1008FF4E,
|
||||
XF86XK_LaunchF: 0x1008FF4F,
|
||||
XF86XK_ApplicationLeft: 0x1008FF50,
|
||||
XF86XK_ApplicationRight: 0x1008FF51,
|
||||
XF86XK_Book: 0x1008FF52,
|
||||
XF86XK_CD: 0x1008FF53,
|
||||
XF86XK_Calculater: 0x1008FF54,
|
||||
XF86XK_Clear: 0x1008FF55,
|
||||
XF86XK_Close: 0x1008FF56,
|
||||
XF86XK_Copy: 0x1008FF57,
|
||||
XF86XK_Cut: 0x1008FF58,
|
||||
XF86XK_Display: 0x1008FF59,
|
||||
XF86XK_DOS: 0x1008FF5A,
|
||||
XF86XK_Documents: 0x1008FF5B,
|
||||
XF86XK_Excel: 0x1008FF5C,
|
||||
XF86XK_Explorer: 0x1008FF5D,
|
||||
XF86XK_Game: 0x1008FF5E,
|
||||
XF86XK_Go: 0x1008FF5F,
|
||||
XF86XK_iTouch: 0x1008FF60,
|
||||
XF86XK_LogOff: 0x1008FF61,
|
||||
XF86XK_Market: 0x1008FF62,
|
||||
XF86XK_Meeting: 0x1008FF63,
|
||||
XF86XK_MenuKB: 0x1008FF65,
|
||||
XF86XK_MenuPB: 0x1008FF66,
|
||||
XF86XK_MySites: 0x1008FF67,
|
||||
XF86XK_New: 0x1008FF68,
|
||||
XF86XK_News: 0x1008FF69,
|
||||
XF86XK_OfficeHome: 0x1008FF6A,
|
||||
XF86XK_Open: 0x1008FF6B,
|
||||
XF86XK_Option: 0x1008FF6C,
|
||||
XF86XK_Paste: 0x1008FF6D,
|
||||
XF86XK_Phone: 0x1008FF6E,
|
||||
XF86XK_Q: 0x1008FF70,
|
||||
XF86XK_Reply: 0x1008FF72,
|
||||
XF86XK_Reload: 0x1008FF73,
|
||||
XF86XK_RotateWindows: 0x1008FF74,
|
||||
XF86XK_RotationPB: 0x1008FF75,
|
||||
XF86XK_RotationKB: 0x1008FF76,
|
||||
XF86XK_Save: 0x1008FF77,
|
||||
XF86XK_ScrollUp: 0x1008FF78,
|
||||
XF86XK_ScrollDown: 0x1008FF79,
|
||||
XF86XK_ScrollClick: 0x1008FF7A,
|
||||
XF86XK_Send: 0x1008FF7B,
|
||||
XF86XK_Spell: 0x1008FF7C,
|
||||
XF86XK_SplitScreen: 0x1008FF7D,
|
||||
XF86XK_Support: 0x1008FF7E,
|
||||
XF86XK_TaskPane: 0x1008FF7F,
|
||||
XF86XK_Terminal: 0x1008FF80,
|
||||
XF86XK_Tools: 0x1008FF81,
|
||||
XF86XK_Travel: 0x1008FF82,
|
||||
XF86XK_UserPB: 0x1008FF84,
|
||||
XF86XK_User1KB: 0x1008FF85,
|
||||
XF86XK_User2KB: 0x1008FF86,
|
||||
XF86XK_Video: 0x1008FF87,
|
||||
XF86XK_WheelButton: 0x1008FF88,
|
||||
XF86XK_Word: 0x1008FF89,
|
||||
XF86XK_Xfer: 0x1008FF8A,
|
||||
XF86XK_ZoomIn: 0x1008FF8B,
|
||||
XF86XK_ZoomOut: 0x1008FF8C,
|
||||
XF86XK_Away: 0x1008FF8D,
|
||||
XF86XK_Messenger: 0x1008FF8E,
|
||||
XF86XK_WebCam: 0x1008FF8F,
|
||||
XF86XK_MailForward: 0x1008FF90,
|
||||
XF86XK_Pictures: 0x1008FF91,
|
||||
XF86XK_Music: 0x1008FF92,
|
||||
XF86XK_Battery: 0x1008FF93,
|
||||
XF86XK_Bluetooth: 0x1008FF94,
|
||||
XF86XK_WLAN: 0x1008FF95,
|
||||
XF86XK_UWB: 0x1008FF96,
|
||||
XF86XK_AudioForward: 0x1008FF97,
|
||||
XF86XK_AudioRepeat: 0x1008FF98,
|
||||
XF86XK_AudioRandomPlay: 0x1008FF99,
|
||||
XF86XK_Subtitle: 0x1008FF9A,
|
||||
XF86XK_AudioCycleTrack: 0x1008FF9B,
|
||||
XF86XK_CycleAngle: 0x1008FF9C,
|
||||
XF86XK_FrameBack: 0x1008FF9D,
|
||||
XF86XK_FrameForward: 0x1008FF9E,
|
||||
XF86XK_Time: 0x1008FF9F,
|
||||
XF86XK_Select: 0x1008FFA0,
|
||||
XF86XK_View: 0x1008FFA1,
|
||||
XF86XK_TopMenu: 0x1008FFA2,
|
||||
XF86XK_Red: 0x1008FFA3,
|
||||
XF86XK_Green: 0x1008FFA4,
|
||||
XF86XK_Yellow: 0x1008FFA5,
|
||||
XF86XK_Blue: 0x1008FFA6,
|
||||
XF86XK_Suspend: 0x1008FFA7,
|
||||
XF86XK_Hibernate: 0x1008FFA8,
|
||||
XF86XK_TouchpadToggle: 0x1008FFA9,
|
||||
XF86XK_TouchpadOn: 0x1008FFB0,
|
||||
XF86XK_TouchpadOff: 0x1008FFB1,
|
||||
XF86XK_AudioMicMute: 0x1008FFB2,
|
||||
XF86XK_Switch_VT_1: 0x1008FE01,
|
||||
XF86XK_Switch_VT_2: 0x1008FE02,
|
||||
XF86XK_Switch_VT_3: 0x1008FE03,
|
||||
XF86XK_Switch_VT_4: 0x1008FE04,
|
||||
XF86XK_Switch_VT_5: 0x1008FE05,
|
||||
XF86XK_Switch_VT_6: 0x1008FE06,
|
||||
XF86XK_Switch_VT_7: 0x1008FE07,
|
||||
XF86XK_Switch_VT_8: 0x1008FE08,
|
||||
XF86XK_Switch_VT_9: 0x1008FE09,
|
||||
XF86XK_Switch_VT_10: 0x1008FE0A,
|
||||
XF86XK_Switch_VT_11: 0x1008FE0B,
|
||||
XF86XK_Switch_VT_12: 0x1008FE0C,
|
||||
XF86XK_Ungrab: 0x1008FE20,
|
||||
XF86XK_ClearGrab: 0x1008FE21,
|
||||
XF86XK_Next_VMode: 0x1008FE22,
|
||||
XF86XK_Prev_VMode: 0x1008FE23,
|
||||
XF86XK_LogWindowTree: 0x1008FE24,
|
||||
XF86XK_LogGrabInfo: 0x1008FE25,
|
||||
};
|
||||
+688
@@ -0,0 +1,688 @@
|
||||
/*
|
||||
* Mapping from Unicode codepoints to X11/RFB keysyms
|
||||
*
|
||||
* This file was automatically generated from keysymdef.h
|
||||
* DO NOT EDIT!
|
||||
*/
|
||||
|
||||
/* Functions at the bottom */
|
||||
|
||||
const codepoints = {
|
||||
0x0100: 0x03c0, // XK_Amacron
|
||||
0x0101: 0x03e0, // XK_amacron
|
||||
0x0102: 0x01c3, // XK_Abreve
|
||||
0x0103: 0x01e3, // XK_abreve
|
||||
0x0104: 0x01a1, // XK_Aogonek
|
||||
0x0105: 0x01b1, // XK_aogonek
|
||||
0x0106: 0x01c6, // XK_Cacute
|
||||
0x0107: 0x01e6, // XK_cacute
|
||||
0x0108: 0x02c6, // XK_Ccircumflex
|
||||
0x0109: 0x02e6, // XK_ccircumflex
|
||||
0x010a: 0x02c5, // XK_Cabovedot
|
||||
0x010b: 0x02e5, // XK_cabovedot
|
||||
0x010c: 0x01c8, // XK_Ccaron
|
||||
0x010d: 0x01e8, // XK_ccaron
|
||||
0x010e: 0x01cf, // XK_Dcaron
|
||||
0x010f: 0x01ef, // XK_dcaron
|
||||
0x0110: 0x01d0, // XK_Dstroke
|
||||
0x0111: 0x01f0, // XK_dstroke
|
||||
0x0112: 0x03aa, // XK_Emacron
|
||||
0x0113: 0x03ba, // XK_emacron
|
||||
0x0116: 0x03cc, // XK_Eabovedot
|
||||
0x0117: 0x03ec, // XK_eabovedot
|
||||
0x0118: 0x01ca, // XK_Eogonek
|
||||
0x0119: 0x01ea, // XK_eogonek
|
||||
0x011a: 0x01cc, // XK_Ecaron
|
||||
0x011b: 0x01ec, // XK_ecaron
|
||||
0x011c: 0x02d8, // XK_Gcircumflex
|
||||
0x011d: 0x02f8, // XK_gcircumflex
|
||||
0x011e: 0x02ab, // XK_Gbreve
|
||||
0x011f: 0x02bb, // XK_gbreve
|
||||
0x0120: 0x02d5, // XK_Gabovedot
|
||||
0x0121: 0x02f5, // XK_gabovedot
|
||||
0x0122: 0x03ab, // XK_Gcedilla
|
||||
0x0123: 0x03bb, // XK_gcedilla
|
||||
0x0124: 0x02a6, // XK_Hcircumflex
|
||||
0x0125: 0x02b6, // XK_hcircumflex
|
||||
0x0126: 0x02a1, // XK_Hstroke
|
||||
0x0127: 0x02b1, // XK_hstroke
|
||||
0x0128: 0x03a5, // XK_Itilde
|
||||
0x0129: 0x03b5, // XK_itilde
|
||||
0x012a: 0x03cf, // XK_Imacron
|
||||
0x012b: 0x03ef, // XK_imacron
|
||||
0x012e: 0x03c7, // XK_Iogonek
|
||||
0x012f: 0x03e7, // XK_iogonek
|
||||
0x0130: 0x02a9, // XK_Iabovedot
|
||||
0x0131: 0x02b9, // XK_idotless
|
||||
0x0134: 0x02ac, // XK_Jcircumflex
|
||||
0x0135: 0x02bc, // XK_jcircumflex
|
||||
0x0136: 0x03d3, // XK_Kcedilla
|
||||
0x0137: 0x03f3, // XK_kcedilla
|
||||
0x0138: 0x03a2, // XK_kra
|
||||
0x0139: 0x01c5, // XK_Lacute
|
||||
0x013a: 0x01e5, // XK_lacute
|
||||
0x013b: 0x03a6, // XK_Lcedilla
|
||||
0x013c: 0x03b6, // XK_lcedilla
|
||||
0x013d: 0x01a5, // XK_Lcaron
|
||||
0x013e: 0x01b5, // XK_lcaron
|
||||
0x0141: 0x01a3, // XK_Lstroke
|
||||
0x0142: 0x01b3, // XK_lstroke
|
||||
0x0143: 0x01d1, // XK_Nacute
|
||||
0x0144: 0x01f1, // XK_nacute
|
||||
0x0145: 0x03d1, // XK_Ncedilla
|
||||
0x0146: 0x03f1, // XK_ncedilla
|
||||
0x0147: 0x01d2, // XK_Ncaron
|
||||
0x0148: 0x01f2, // XK_ncaron
|
||||
0x014a: 0x03bd, // XK_ENG
|
||||
0x014b: 0x03bf, // XK_eng
|
||||
0x014c: 0x03d2, // XK_Omacron
|
||||
0x014d: 0x03f2, // XK_omacron
|
||||
0x0150: 0x01d5, // XK_Odoubleacute
|
||||
0x0151: 0x01f5, // XK_odoubleacute
|
||||
0x0152: 0x13bc, // XK_OE
|
||||
0x0153: 0x13bd, // XK_oe
|
||||
0x0154: 0x01c0, // XK_Racute
|
||||
0x0155: 0x01e0, // XK_racute
|
||||
0x0156: 0x03a3, // XK_Rcedilla
|
||||
0x0157: 0x03b3, // XK_rcedilla
|
||||
0x0158: 0x01d8, // XK_Rcaron
|
||||
0x0159: 0x01f8, // XK_rcaron
|
||||
0x015a: 0x01a6, // XK_Sacute
|
||||
0x015b: 0x01b6, // XK_sacute
|
||||
0x015c: 0x02de, // XK_Scircumflex
|
||||
0x015d: 0x02fe, // XK_scircumflex
|
||||
0x015e: 0x01aa, // XK_Scedilla
|
||||
0x015f: 0x01ba, // XK_scedilla
|
||||
0x0160: 0x01a9, // XK_Scaron
|
||||
0x0161: 0x01b9, // XK_scaron
|
||||
0x0162: 0x01de, // XK_Tcedilla
|
||||
0x0163: 0x01fe, // XK_tcedilla
|
||||
0x0164: 0x01ab, // XK_Tcaron
|
||||
0x0165: 0x01bb, // XK_tcaron
|
||||
0x0166: 0x03ac, // XK_Tslash
|
||||
0x0167: 0x03bc, // XK_tslash
|
||||
0x0168: 0x03dd, // XK_Utilde
|
||||
0x0169: 0x03fd, // XK_utilde
|
||||
0x016a: 0x03de, // XK_Umacron
|
||||
0x016b: 0x03fe, // XK_umacron
|
||||
0x016c: 0x02dd, // XK_Ubreve
|
||||
0x016d: 0x02fd, // XK_ubreve
|
||||
0x016e: 0x01d9, // XK_Uring
|
||||
0x016f: 0x01f9, // XK_uring
|
||||
0x0170: 0x01db, // XK_Udoubleacute
|
||||
0x0171: 0x01fb, // XK_udoubleacute
|
||||
0x0172: 0x03d9, // XK_Uogonek
|
||||
0x0173: 0x03f9, // XK_uogonek
|
||||
0x0178: 0x13be, // XK_Ydiaeresis
|
||||
0x0179: 0x01ac, // XK_Zacute
|
||||
0x017a: 0x01bc, // XK_zacute
|
||||
0x017b: 0x01af, // XK_Zabovedot
|
||||
0x017c: 0x01bf, // XK_zabovedot
|
||||
0x017d: 0x01ae, // XK_Zcaron
|
||||
0x017e: 0x01be, // XK_zcaron
|
||||
0x0192: 0x08f6, // XK_function
|
||||
0x01d2: 0x10001d1, // XK_Ocaron
|
||||
0x02c7: 0x01b7, // XK_caron
|
||||
0x02d8: 0x01a2, // XK_breve
|
||||
0x02d9: 0x01ff, // XK_abovedot
|
||||
0x02db: 0x01b2, // XK_ogonek
|
||||
0x02dd: 0x01bd, // XK_doubleacute
|
||||
0x0385: 0x07ae, // XK_Greek_accentdieresis
|
||||
0x0386: 0x07a1, // XK_Greek_ALPHAaccent
|
||||
0x0388: 0x07a2, // XK_Greek_EPSILONaccent
|
||||
0x0389: 0x07a3, // XK_Greek_ETAaccent
|
||||
0x038a: 0x07a4, // XK_Greek_IOTAaccent
|
||||
0x038c: 0x07a7, // XK_Greek_OMICRONaccent
|
||||
0x038e: 0x07a8, // XK_Greek_UPSILONaccent
|
||||
0x038f: 0x07ab, // XK_Greek_OMEGAaccent
|
||||
0x0390: 0x07b6, // XK_Greek_iotaaccentdieresis
|
||||
0x0391: 0x07c1, // XK_Greek_ALPHA
|
||||
0x0392: 0x07c2, // XK_Greek_BETA
|
||||
0x0393: 0x07c3, // XK_Greek_GAMMA
|
||||
0x0394: 0x07c4, // XK_Greek_DELTA
|
||||
0x0395: 0x07c5, // XK_Greek_EPSILON
|
||||
0x0396: 0x07c6, // XK_Greek_ZETA
|
||||
0x0397: 0x07c7, // XK_Greek_ETA
|
||||
0x0398: 0x07c8, // XK_Greek_THETA
|
||||
0x0399: 0x07c9, // XK_Greek_IOTA
|
||||
0x039a: 0x07ca, // XK_Greek_KAPPA
|
||||
0x039b: 0x07cb, // XK_Greek_LAMDA
|
||||
0x039c: 0x07cc, // XK_Greek_MU
|
||||
0x039d: 0x07cd, // XK_Greek_NU
|
||||
0x039e: 0x07ce, // XK_Greek_XI
|
||||
0x039f: 0x07cf, // XK_Greek_OMICRON
|
||||
0x03a0: 0x07d0, // XK_Greek_PI
|
||||
0x03a1: 0x07d1, // XK_Greek_RHO
|
||||
0x03a3: 0x07d2, // XK_Greek_SIGMA
|
||||
0x03a4: 0x07d4, // XK_Greek_TAU
|
||||
0x03a5: 0x07d5, // XK_Greek_UPSILON
|
||||
0x03a6: 0x07d6, // XK_Greek_PHI
|
||||
0x03a7: 0x07d7, // XK_Greek_CHI
|
||||
0x03a8: 0x07d8, // XK_Greek_PSI
|
||||
0x03a9: 0x07d9, // XK_Greek_OMEGA
|
||||
0x03aa: 0x07a5, // XK_Greek_IOTAdieresis
|
||||
0x03ab: 0x07a9, // XK_Greek_UPSILONdieresis
|
||||
0x03ac: 0x07b1, // XK_Greek_alphaaccent
|
||||
0x03ad: 0x07b2, // XK_Greek_epsilonaccent
|
||||
0x03ae: 0x07b3, // XK_Greek_etaaccent
|
||||
0x03af: 0x07b4, // XK_Greek_iotaaccent
|
||||
0x03b0: 0x07ba, // XK_Greek_upsilonaccentdieresis
|
||||
0x03b1: 0x07e1, // XK_Greek_alpha
|
||||
0x03b2: 0x07e2, // XK_Greek_beta
|
||||
0x03b3: 0x07e3, // XK_Greek_gamma
|
||||
0x03b4: 0x07e4, // XK_Greek_delta
|
||||
0x03b5: 0x07e5, // XK_Greek_epsilon
|
||||
0x03b6: 0x07e6, // XK_Greek_zeta
|
||||
0x03b7: 0x07e7, // XK_Greek_eta
|
||||
0x03b8: 0x07e8, // XK_Greek_theta
|
||||
0x03b9: 0x07e9, // XK_Greek_iota
|
||||
0x03ba: 0x07ea, // XK_Greek_kappa
|
||||
0x03bb: 0x07eb, // XK_Greek_lamda
|
||||
0x03bc: 0x07ec, // XK_Greek_mu
|
||||
0x03bd: 0x07ed, // XK_Greek_nu
|
||||
0x03be: 0x07ee, // XK_Greek_xi
|
||||
0x03bf: 0x07ef, // XK_Greek_omicron
|
||||
0x03c0: 0x07f0, // XK_Greek_pi
|
||||
0x03c1: 0x07f1, // XK_Greek_rho
|
||||
0x03c2: 0x07f3, // XK_Greek_finalsmallsigma
|
||||
0x03c3: 0x07f2, // XK_Greek_sigma
|
||||
0x03c4: 0x07f4, // XK_Greek_tau
|
||||
0x03c5: 0x07f5, // XK_Greek_upsilon
|
||||
0x03c6: 0x07f6, // XK_Greek_phi
|
||||
0x03c7: 0x07f7, // XK_Greek_chi
|
||||
0x03c8: 0x07f8, // XK_Greek_psi
|
||||
0x03c9: 0x07f9, // XK_Greek_omega
|
||||
0x03ca: 0x07b5, // XK_Greek_iotadieresis
|
||||
0x03cb: 0x07b9, // XK_Greek_upsilondieresis
|
||||
0x03cc: 0x07b7, // XK_Greek_omicronaccent
|
||||
0x03cd: 0x07b8, // XK_Greek_upsilonaccent
|
||||
0x03ce: 0x07bb, // XK_Greek_omegaaccent
|
||||
0x0401: 0x06b3, // XK_Cyrillic_IO
|
||||
0x0402: 0x06b1, // XK_Serbian_DJE
|
||||
0x0403: 0x06b2, // XK_Macedonia_GJE
|
||||
0x0404: 0x06b4, // XK_Ukrainian_IE
|
||||
0x0405: 0x06b5, // XK_Macedonia_DSE
|
||||
0x0406: 0x06b6, // XK_Ukrainian_I
|
||||
0x0407: 0x06b7, // XK_Ukrainian_YI
|
||||
0x0408: 0x06b8, // XK_Cyrillic_JE
|
||||
0x0409: 0x06b9, // XK_Cyrillic_LJE
|
||||
0x040a: 0x06ba, // XK_Cyrillic_NJE
|
||||
0x040b: 0x06bb, // XK_Serbian_TSHE
|
||||
0x040c: 0x06bc, // XK_Macedonia_KJE
|
||||
0x040e: 0x06be, // XK_Byelorussian_SHORTU
|
||||
0x040f: 0x06bf, // XK_Cyrillic_DZHE
|
||||
0x0410: 0x06e1, // XK_Cyrillic_A
|
||||
0x0411: 0x06e2, // XK_Cyrillic_BE
|
||||
0x0412: 0x06f7, // XK_Cyrillic_VE
|
||||
0x0413: 0x06e7, // XK_Cyrillic_GHE
|
||||
0x0414: 0x06e4, // XK_Cyrillic_DE
|
||||
0x0415: 0x06e5, // XK_Cyrillic_IE
|
||||
0x0416: 0x06f6, // XK_Cyrillic_ZHE
|
||||
0x0417: 0x06fa, // XK_Cyrillic_ZE
|
||||
0x0418: 0x06e9, // XK_Cyrillic_I
|
||||
0x0419: 0x06ea, // XK_Cyrillic_SHORTI
|
||||
0x041a: 0x06eb, // XK_Cyrillic_KA
|
||||
0x041b: 0x06ec, // XK_Cyrillic_EL
|
||||
0x041c: 0x06ed, // XK_Cyrillic_EM
|
||||
0x041d: 0x06ee, // XK_Cyrillic_EN
|
||||
0x041e: 0x06ef, // XK_Cyrillic_O
|
||||
0x041f: 0x06f0, // XK_Cyrillic_PE
|
||||
0x0420: 0x06f2, // XK_Cyrillic_ER
|
||||
0x0421: 0x06f3, // XK_Cyrillic_ES
|
||||
0x0422: 0x06f4, // XK_Cyrillic_TE
|
||||
0x0423: 0x06f5, // XK_Cyrillic_U
|
||||
0x0424: 0x06e6, // XK_Cyrillic_EF
|
||||
0x0425: 0x06e8, // XK_Cyrillic_HA
|
||||
0x0426: 0x06e3, // XK_Cyrillic_TSE
|
||||
0x0427: 0x06fe, // XK_Cyrillic_CHE
|
||||
0x0428: 0x06fb, // XK_Cyrillic_SHA
|
||||
0x0429: 0x06fd, // XK_Cyrillic_SHCHA
|
||||
0x042a: 0x06ff, // XK_Cyrillic_HARDSIGN
|
||||
0x042b: 0x06f9, // XK_Cyrillic_YERU
|
||||
0x042c: 0x06f8, // XK_Cyrillic_SOFTSIGN
|
||||
0x042d: 0x06fc, // XK_Cyrillic_E
|
||||
0x042e: 0x06e0, // XK_Cyrillic_YU
|
||||
0x042f: 0x06f1, // XK_Cyrillic_YA
|
||||
0x0430: 0x06c1, // XK_Cyrillic_a
|
||||
0x0431: 0x06c2, // XK_Cyrillic_be
|
||||
0x0432: 0x06d7, // XK_Cyrillic_ve
|
||||
0x0433: 0x06c7, // XK_Cyrillic_ghe
|
||||
0x0434: 0x06c4, // XK_Cyrillic_de
|
||||
0x0435: 0x06c5, // XK_Cyrillic_ie
|
||||
0x0436: 0x06d6, // XK_Cyrillic_zhe
|
||||
0x0437: 0x06da, // XK_Cyrillic_ze
|
||||
0x0438: 0x06c9, // XK_Cyrillic_i
|
||||
0x0439: 0x06ca, // XK_Cyrillic_shorti
|
||||
0x043a: 0x06cb, // XK_Cyrillic_ka
|
||||
0x043b: 0x06cc, // XK_Cyrillic_el
|
||||
0x043c: 0x06cd, // XK_Cyrillic_em
|
||||
0x043d: 0x06ce, // XK_Cyrillic_en
|
||||
0x043e: 0x06cf, // XK_Cyrillic_o
|
||||
0x043f: 0x06d0, // XK_Cyrillic_pe
|
||||
0x0440: 0x06d2, // XK_Cyrillic_er
|
||||
0x0441: 0x06d3, // XK_Cyrillic_es
|
||||
0x0442: 0x06d4, // XK_Cyrillic_te
|
||||
0x0443: 0x06d5, // XK_Cyrillic_u
|
||||
0x0444: 0x06c6, // XK_Cyrillic_ef
|
||||
0x0445: 0x06c8, // XK_Cyrillic_ha
|
||||
0x0446: 0x06c3, // XK_Cyrillic_tse
|
||||
0x0447: 0x06de, // XK_Cyrillic_che
|
||||
0x0448: 0x06db, // XK_Cyrillic_sha
|
||||
0x0449: 0x06dd, // XK_Cyrillic_shcha
|
||||
0x044a: 0x06df, // XK_Cyrillic_hardsign
|
||||
0x044b: 0x06d9, // XK_Cyrillic_yeru
|
||||
0x044c: 0x06d8, // XK_Cyrillic_softsign
|
||||
0x044d: 0x06dc, // XK_Cyrillic_e
|
||||
0x044e: 0x06c0, // XK_Cyrillic_yu
|
||||
0x044f: 0x06d1, // XK_Cyrillic_ya
|
||||
0x0451: 0x06a3, // XK_Cyrillic_io
|
||||
0x0452: 0x06a1, // XK_Serbian_dje
|
||||
0x0453: 0x06a2, // XK_Macedonia_gje
|
||||
0x0454: 0x06a4, // XK_Ukrainian_ie
|
||||
0x0455: 0x06a5, // XK_Macedonia_dse
|
||||
0x0456: 0x06a6, // XK_Ukrainian_i
|
||||
0x0457: 0x06a7, // XK_Ukrainian_yi
|
||||
0x0458: 0x06a8, // XK_Cyrillic_je
|
||||
0x0459: 0x06a9, // XK_Cyrillic_lje
|
||||
0x045a: 0x06aa, // XK_Cyrillic_nje
|
||||
0x045b: 0x06ab, // XK_Serbian_tshe
|
||||
0x045c: 0x06ac, // XK_Macedonia_kje
|
||||
0x045e: 0x06ae, // XK_Byelorussian_shortu
|
||||
0x045f: 0x06af, // XK_Cyrillic_dzhe
|
||||
0x0490: 0x06bd, // XK_Ukrainian_GHE_WITH_UPTURN
|
||||
0x0491: 0x06ad, // XK_Ukrainian_ghe_with_upturn
|
||||
0x05d0: 0x0ce0, // XK_hebrew_aleph
|
||||
0x05d1: 0x0ce1, // XK_hebrew_bet
|
||||
0x05d2: 0x0ce2, // XK_hebrew_gimel
|
||||
0x05d3: 0x0ce3, // XK_hebrew_dalet
|
||||
0x05d4: 0x0ce4, // XK_hebrew_he
|
||||
0x05d5: 0x0ce5, // XK_hebrew_waw
|
||||
0x05d6: 0x0ce6, // XK_hebrew_zain
|
||||
0x05d7: 0x0ce7, // XK_hebrew_chet
|
||||
0x05d8: 0x0ce8, // XK_hebrew_tet
|
||||
0x05d9: 0x0ce9, // XK_hebrew_yod
|
||||
0x05da: 0x0cea, // XK_hebrew_finalkaph
|
||||
0x05db: 0x0ceb, // XK_hebrew_kaph
|
||||
0x05dc: 0x0cec, // XK_hebrew_lamed
|
||||
0x05dd: 0x0ced, // XK_hebrew_finalmem
|
||||
0x05de: 0x0cee, // XK_hebrew_mem
|
||||
0x05df: 0x0cef, // XK_hebrew_finalnun
|
||||
0x05e0: 0x0cf0, // XK_hebrew_nun
|
||||
0x05e1: 0x0cf1, // XK_hebrew_samech
|
||||
0x05e2: 0x0cf2, // XK_hebrew_ayin
|
||||
0x05e3: 0x0cf3, // XK_hebrew_finalpe
|
||||
0x05e4: 0x0cf4, // XK_hebrew_pe
|
||||
0x05e5: 0x0cf5, // XK_hebrew_finalzade
|
||||
0x05e6: 0x0cf6, // XK_hebrew_zade
|
||||
0x05e7: 0x0cf7, // XK_hebrew_qoph
|
||||
0x05e8: 0x0cf8, // XK_hebrew_resh
|
||||
0x05e9: 0x0cf9, // XK_hebrew_shin
|
||||
0x05ea: 0x0cfa, // XK_hebrew_taw
|
||||
0x060c: 0x05ac, // XK_Arabic_comma
|
||||
0x061b: 0x05bb, // XK_Arabic_semicolon
|
||||
0x061f: 0x05bf, // XK_Arabic_question_mark
|
||||
0x0621: 0x05c1, // XK_Arabic_hamza
|
||||
0x0622: 0x05c2, // XK_Arabic_maddaonalef
|
||||
0x0623: 0x05c3, // XK_Arabic_hamzaonalef
|
||||
0x0624: 0x05c4, // XK_Arabic_hamzaonwaw
|
||||
0x0625: 0x05c5, // XK_Arabic_hamzaunderalef
|
||||
0x0626: 0x05c6, // XK_Arabic_hamzaonyeh
|
||||
0x0627: 0x05c7, // XK_Arabic_alef
|
||||
0x0628: 0x05c8, // XK_Arabic_beh
|
||||
0x0629: 0x05c9, // XK_Arabic_tehmarbuta
|
||||
0x062a: 0x05ca, // XK_Arabic_teh
|
||||
0x062b: 0x05cb, // XK_Arabic_theh
|
||||
0x062c: 0x05cc, // XK_Arabic_jeem
|
||||
0x062d: 0x05cd, // XK_Arabic_hah
|
||||
0x062e: 0x05ce, // XK_Arabic_khah
|
||||
0x062f: 0x05cf, // XK_Arabic_dal
|
||||
0x0630: 0x05d0, // XK_Arabic_thal
|
||||
0x0631: 0x05d1, // XK_Arabic_ra
|
||||
0x0632: 0x05d2, // XK_Arabic_zain
|
||||
0x0633: 0x05d3, // XK_Arabic_seen
|
||||
0x0634: 0x05d4, // XK_Arabic_sheen
|
||||
0x0635: 0x05d5, // XK_Arabic_sad
|
||||
0x0636: 0x05d6, // XK_Arabic_dad
|
||||
0x0637: 0x05d7, // XK_Arabic_tah
|
||||
0x0638: 0x05d8, // XK_Arabic_zah
|
||||
0x0639: 0x05d9, // XK_Arabic_ain
|
||||
0x063a: 0x05da, // XK_Arabic_ghain
|
||||
0x0640: 0x05e0, // XK_Arabic_tatweel
|
||||
0x0641: 0x05e1, // XK_Arabic_feh
|
||||
0x0642: 0x05e2, // XK_Arabic_qaf
|
||||
0x0643: 0x05e3, // XK_Arabic_kaf
|
||||
0x0644: 0x05e4, // XK_Arabic_lam
|
||||
0x0645: 0x05e5, // XK_Arabic_meem
|
||||
0x0646: 0x05e6, // XK_Arabic_noon
|
||||
0x0647: 0x05e7, // XK_Arabic_ha
|
||||
0x0648: 0x05e8, // XK_Arabic_waw
|
||||
0x0649: 0x05e9, // XK_Arabic_alefmaksura
|
||||
0x064a: 0x05ea, // XK_Arabic_yeh
|
||||
0x064b: 0x05eb, // XK_Arabic_fathatan
|
||||
0x064c: 0x05ec, // XK_Arabic_dammatan
|
||||
0x064d: 0x05ed, // XK_Arabic_kasratan
|
||||
0x064e: 0x05ee, // XK_Arabic_fatha
|
||||
0x064f: 0x05ef, // XK_Arabic_damma
|
||||
0x0650: 0x05f0, // XK_Arabic_kasra
|
||||
0x0651: 0x05f1, // XK_Arabic_shadda
|
||||
0x0652: 0x05f2, // XK_Arabic_sukun
|
||||
0x0e01: 0x0da1, // XK_Thai_kokai
|
||||
0x0e02: 0x0da2, // XK_Thai_khokhai
|
||||
0x0e03: 0x0da3, // XK_Thai_khokhuat
|
||||
0x0e04: 0x0da4, // XK_Thai_khokhwai
|
||||
0x0e05: 0x0da5, // XK_Thai_khokhon
|
||||
0x0e06: 0x0da6, // XK_Thai_khorakhang
|
||||
0x0e07: 0x0da7, // XK_Thai_ngongu
|
||||
0x0e08: 0x0da8, // XK_Thai_chochan
|
||||
0x0e09: 0x0da9, // XK_Thai_choching
|
||||
0x0e0a: 0x0daa, // XK_Thai_chochang
|
||||
0x0e0b: 0x0dab, // XK_Thai_soso
|
||||
0x0e0c: 0x0dac, // XK_Thai_chochoe
|
||||
0x0e0d: 0x0dad, // XK_Thai_yoying
|
||||
0x0e0e: 0x0dae, // XK_Thai_dochada
|
||||
0x0e0f: 0x0daf, // XK_Thai_topatak
|
||||
0x0e10: 0x0db0, // XK_Thai_thothan
|
||||
0x0e11: 0x0db1, // XK_Thai_thonangmontho
|
||||
0x0e12: 0x0db2, // XK_Thai_thophuthao
|
||||
0x0e13: 0x0db3, // XK_Thai_nonen
|
||||
0x0e14: 0x0db4, // XK_Thai_dodek
|
||||
0x0e15: 0x0db5, // XK_Thai_totao
|
||||
0x0e16: 0x0db6, // XK_Thai_thothung
|
||||
0x0e17: 0x0db7, // XK_Thai_thothahan
|
||||
0x0e18: 0x0db8, // XK_Thai_thothong
|
||||
0x0e19: 0x0db9, // XK_Thai_nonu
|
||||
0x0e1a: 0x0dba, // XK_Thai_bobaimai
|
||||
0x0e1b: 0x0dbb, // XK_Thai_popla
|
||||
0x0e1c: 0x0dbc, // XK_Thai_phophung
|
||||
0x0e1d: 0x0dbd, // XK_Thai_fofa
|
||||
0x0e1e: 0x0dbe, // XK_Thai_phophan
|
||||
0x0e1f: 0x0dbf, // XK_Thai_fofan
|
||||
0x0e20: 0x0dc0, // XK_Thai_phosamphao
|
||||
0x0e21: 0x0dc1, // XK_Thai_moma
|
||||
0x0e22: 0x0dc2, // XK_Thai_yoyak
|
||||
0x0e23: 0x0dc3, // XK_Thai_rorua
|
||||
0x0e24: 0x0dc4, // XK_Thai_ru
|
||||
0x0e25: 0x0dc5, // XK_Thai_loling
|
||||
0x0e26: 0x0dc6, // XK_Thai_lu
|
||||
0x0e27: 0x0dc7, // XK_Thai_wowaen
|
||||
0x0e28: 0x0dc8, // XK_Thai_sosala
|
||||
0x0e29: 0x0dc9, // XK_Thai_sorusi
|
||||
0x0e2a: 0x0dca, // XK_Thai_sosua
|
||||
0x0e2b: 0x0dcb, // XK_Thai_hohip
|
||||
0x0e2c: 0x0dcc, // XK_Thai_lochula
|
||||
0x0e2d: 0x0dcd, // XK_Thai_oang
|
||||
0x0e2e: 0x0dce, // XK_Thai_honokhuk
|
||||
0x0e2f: 0x0dcf, // XK_Thai_paiyannoi
|
||||
0x0e30: 0x0dd0, // XK_Thai_saraa
|
||||
0x0e31: 0x0dd1, // XK_Thai_maihanakat
|
||||
0x0e32: 0x0dd2, // XK_Thai_saraaa
|
||||
0x0e33: 0x0dd3, // XK_Thai_saraam
|
||||
0x0e34: 0x0dd4, // XK_Thai_sarai
|
||||
0x0e35: 0x0dd5, // XK_Thai_saraii
|
||||
0x0e36: 0x0dd6, // XK_Thai_saraue
|
||||
0x0e37: 0x0dd7, // XK_Thai_sarauee
|
||||
0x0e38: 0x0dd8, // XK_Thai_sarau
|
||||
0x0e39: 0x0dd9, // XK_Thai_sarauu
|
||||
0x0e3a: 0x0dda, // XK_Thai_phinthu
|
||||
0x0e3f: 0x0ddf, // XK_Thai_baht
|
||||
0x0e40: 0x0de0, // XK_Thai_sarae
|
||||
0x0e41: 0x0de1, // XK_Thai_saraae
|
||||
0x0e42: 0x0de2, // XK_Thai_sarao
|
||||
0x0e43: 0x0de3, // XK_Thai_saraaimaimuan
|
||||
0x0e44: 0x0de4, // XK_Thai_saraaimaimalai
|
||||
0x0e45: 0x0de5, // XK_Thai_lakkhangyao
|
||||
0x0e46: 0x0de6, // XK_Thai_maiyamok
|
||||
0x0e47: 0x0de7, // XK_Thai_maitaikhu
|
||||
0x0e48: 0x0de8, // XK_Thai_maiek
|
||||
0x0e49: 0x0de9, // XK_Thai_maitho
|
||||
0x0e4a: 0x0dea, // XK_Thai_maitri
|
||||
0x0e4b: 0x0deb, // XK_Thai_maichattawa
|
||||
0x0e4c: 0x0dec, // XK_Thai_thanthakhat
|
||||
0x0e4d: 0x0ded, // XK_Thai_nikhahit
|
||||
0x0e50: 0x0df0, // XK_Thai_leksun
|
||||
0x0e51: 0x0df1, // XK_Thai_leknung
|
||||
0x0e52: 0x0df2, // XK_Thai_leksong
|
||||
0x0e53: 0x0df3, // XK_Thai_leksam
|
||||
0x0e54: 0x0df4, // XK_Thai_leksi
|
||||
0x0e55: 0x0df5, // XK_Thai_lekha
|
||||
0x0e56: 0x0df6, // XK_Thai_lekhok
|
||||
0x0e57: 0x0df7, // XK_Thai_lekchet
|
||||
0x0e58: 0x0df8, // XK_Thai_lekpaet
|
||||
0x0e59: 0x0df9, // XK_Thai_lekkao
|
||||
0x2002: 0x0aa2, // XK_enspace
|
||||
0x2003: 0x0aa1, // XK_emspace
|
||||
0x2004: 0x0aa3, // XK_em3space
|
||||
0x2005: 0x0aa4, // XK_em4space
|
||||
0x2007: 0x0aa5, // XK_digitspace
|
||||
0x2008: 0x0aa6, // XK_punctspace
|
||||
0x2009: 0x0aa7, // XK_thinspace
|
||||
0x200a: 0x0aa8, // XK_hairspace
|
||||
0x2012: 0x0abb, // XK_figdash
|
||||
0x2013: 0x0aaa, // XK_endash
|
||||
0x2014: 0x0aa9, // XK_emdash
|
||||
0x2015: 0x07af, // XK_Greek_horizbar
|
||||
0x2017: 0x0cdf, // XK_hebrew_doublelowline
|
||||
0x2018: 0x0ad0, // XK_leftsinglequotemark
|
||||
0x2019: 0x0ad1, // XK_rightsinglequotemark
|
||||
0x201a: 0x0afd, // XK_singlelowquotemark
|
||||
0x201c: 0x0ad2, // XK_leftdoublequotemark
|
||||
0x201d: 0x0ad3, // XK_rightdoublequotemark
|
||||
0x201e: 0x0afe, // XK_doublelowquotemark
|
||||
0x2020: 0x0af1, // XK_dagger
|
||||
0x2021: 0x0af2, // XK_doubledagger
|
||||
0x2022: 0x0ae6, // XK_enfilledcircbullet
|
||||
0x2025: 0x0aaf, // XK_doubbaselinedot
|
||||
0x2026: 0x0aae, // XK_ellipsis
|
||||
0x2030: 0x0ad5, // XK_permille
|
||||
0x2032: 0x0ad6, // XK_minutes
|
||||
0x2033: 0x0ad7, // XK_seconds
|
||||
0x2038: 0x0afc, // XK_caret
|
||||
0x203e: 0x047e, // XK_overline
|
||||
0x20a9: 0x0eff, // XK_Korean_Won
|
||||
0x20ac: 0x20ac, // XK_EuroSign
|
||||
0x2105: 0x0ab8, // XK_careof
|
||||
0x2116: 0x06b0, // XK_numerosign
|
||||
0x2117: 0x0afb, // XK_phonographcopyright
|
||||
0x211e: 0x0ad4, // XK_prescription
|
||||
0x2122: 0x0ac9, // XK_trademark
|
||||
0x2153: 0x0ab0, // XK_onethird
|
||||
0x2154: 0x0ab1, // XK_twothirds
|
||||
0x2155: 0x0ab2, // XK_onefifth
|
||||
0x2156: 0x0ab3, // XK_twofifths
|
||||
0x2157: 0x0ab4, // XK_threefifths
|
||||
0x2158: 0x0ab5, // XK_fourfifths
|
||||
0x2159: 0x0ab6, // XK_onesixth
|
||||
0x215a: 0x0ab7, // XK_fivesixths
|
||||
0x215b: 0x0ac3, // XK_oneeighth
|
||||
0x215c: 0x0ac4, // XK_threeeighths
|
||||
0x215d: 0x0ac5, // XK_fiveeighths
|
||||
0x215e: 0x0ac6, // XK_seveneighths
|
||||
0x2190: 0x08fb, // XK_leftarrow
|
||||
0x2191: 0x08fc, // XK_uparrow
|
||||
0x2192: 0x08fd, // XK_rightarrow
|
||||
0x2193: 0x08fe, // XK_downarrow
|
||||
0x21d2: 0x08ce, // XK_implies
|
||||
0x21d4: 0x08cd, // XK_ifonlyif
|
||||
0x2202: 0x08ef, // XK_partialderivative
|
||||
0x2207: 0x08c5, // XK_nabla
|
||||
0x2218: 0x0bca, // XK_jot
|
||||
0x221a: 0x08d6, // XK_radical
|
||||
0x221d: 0x08c1, // XK_variation
|
||||
0x221e: 0x08c2, // XK_infinity
|
||||
0x2227: 0x08de, // XK_logicaland
|
||||
0x2228: 0x08df, // XK_logicalor
|
||||
0x2229: 0x08dc, // XK_intersection
|
||||
0x222a: 0x08dd, // XK_union
|
||||
0x222b: 0x08bf, // XK_integral
|
||||
0x2234: 0x08c0, // XK_therefore
|
||||
0x223c: 0x08c8, // XK_approximate
|
||||
0x2243: 0x08c9, // XK_similarequal
|
||||
0x2245: 0x1002248, // XK_approxeq
|
||||
0x2260: 0x08bd, // XK_notequal
|
||||
0x2261: 0x08cf, // XK_identical
|
||||
0x2264: 0x08bc, // XK_lessthanequal
|
||||
0x2265: 0x08be, // XK_greaterthanequal
|
||||
0x2282: 0x08da, // XK_includedin
|
||||
0x2283: 0x08db, // XK_includes
|
||||
0x22a2: 0x0bfc, // XK_righttack
|
||||
0x22a3: 0x0bdc, // XK_lefttack
|
||||
0x22a4: 0x0bc2, // XK_downtack
|
||||
0x22a5: 0x0bce, // XK_uptack
|
||||
0x2308: 0x0bd3, // XK_upstile
|
||||
0x230a: 0x0bc4, // XK_downstile
|
||||
0x2315: 0x0afa, // XK_telephonerecorder
|
||||
0x2320: 0x08a4, // XK_topintegral
|
||||
0x2321: 0x08a5, // XK_botintegral
|
||||
0x2395: 0x0bcc, // XK_quad
|
||||
0x239b: 0x08ab, // XK_topleftparens
|
||||
0x239d: 0x08ac, // XK_botleftparens
|
||||
0x239e: 0x08ad, // XK_toprightparens
|
||||
0x23a0: 0x08ae, // XK_botrightparens
|
||||
0x23a1: 0x08a7, // XK_topleftsqbracket
|
||||
0x23a3: 0x08a8, // XK_botleftsqbracket
|
||||
0x23a4: 0x08a9, // XK_toprightsqbracket
|
||||
0x23a6: 0x08aa, // XK_botrightsqbracket
|
||||
0x23a8: 0x08af, // XK_leftmiddlecurlybrace
|
||||
0x23ac: 0x08b0, // XK_rightmiddlecurlybrace
|
||||
0x23b7: 0x08a1, // XK_leftradical
|
||||
0x23ba: 0x09ef, // XK_horizlinescan1
|
||||
0x23bb: 0x09f0, // XK_horizlinescan3
|
||||
0x23bc: 0x09f2, // XK_horizlinescan7
|
||||
0x23bd: 0x09f3, // XK_horizlinescan9
|
||||
0x2409: 0x09e2, // XK_ht
|
||||
0x240a: 0x09e5, // XK_lf
|
||||
0x240b: 0x09e9, // XK_vt
|
||||
0x240c: 0x09e3, // XK_ff
|
||||
0x240d: 0x09e4, // XK_cr
|
||||
0x2423: 0x0aac, // XK_signifblank
|
||||
0x2424: 0x09e8, // XK_nl
|
||||
0x2500: 0x08a3, // XK_horizconnector
|
||||
0x2502: 0x08a6, // XK_vertconnector
|
||||
0x250c: 0x08a2, // XK_topleftradical
|
||||
0x2510: 0x09eb, // XK_uprightcorner
|
||||
0x2514: 0x09ed, // XK_lowleftcorner
|
||||
0x2518: 0x09ea, // XK_lowrightcorner
|
||||
0x251c: 0x09f4, // XK_leftt
|
||||
0x2524: 0x09f5, // XK_rightt
|
||||
0x252c: 0x09f7, // XK_topt
|
||||
0x2534: 0x09f6, // XK_bott
|
||||
0x253c: 0x09ee, // XK_crossinglines
|
||||
0x2592: 0x09e1, // XK_checkerboard
|
||||
0x25aa: 0x0ae7, // XK_enfilledsqbullet
|
||||
0x25ab: 0x0ae1, // XK_enopensquarebullet
|
||||
0x25ac: 0x0adb, // XK_filledrectbullet
|
||||
0x25ad: 0x0ae2, // XK_openrectbullet
|
||||
0x25ae: 0x0adf, // XK_emfilledrect
|
||||
0x25af: 0x0acf, // XK_emopenrectangle
|
||||
0x25b2: 0x0ae8, // XK_filledtribulletup
|
||||
0x25b3: 0x0ae3, // XK_opentribulletup
|
||||
0x25b6: 0x0add, // XK_filledrighttribullet
|
||||
0x25b7: 0x0acd, // XK_rightopentriangle
|
||||
0x25bc: 0x0ae9, // XK_filledtribulletdown
|
||||
0x25bd: 0x0ae4, // XK_opentribulletdown
|
||||
0x25c0: 0x0adc, // XK_filledlefttribullet
|
||||
0x25c1: 0x0acc, // XK_leftopentriangle
|
||||
0x25c6: 0x09e0, // XK_soliddiamond
|
||||
0x25cb: 0x0ace, // XK_emopencircle
|
||||
0x25cf: 0x0ade, // XK_emfilledcircle
|
||||
0x25e6: 0x0ae0, // XK_enopencircbullet
|
||||
0x2606: 0x0ae5, // XK_openstar
|
||||
0x260e: 0x0af9, // XK_telephone
|
||||
0x2613: 0x0aca, // XK_signaturemark
|
||||
0x261c: 0x0aea, // XK_leftpointer
|
||||
0x261e: 0x0aeb, // XK_rightpointer
|
||||
0x2640: 0x0af8, // XK_femalesymbol
|
||||
0x2642: 0x0af7, // XK_malesymbol
|
||||
0x2663: 0x0aec, // XK_club
|
||||
0x2665: 0x0aee, // XK_heart
|
||||
0x2666: 0x0aed, // XK_diamond
|
||||
0x266d: 0x0af6, // XK_musicalflat
|
||||
0x266f: 0x0af5, // XK_musicalsharp
|
||||
0x2713: 0x0af3, // XK_checkmark
|
||||
0x2717: 0x0af4, // XK_ballotcross
|
||||
0x271d: 0x0ad9, // XK_latincross
|
||||
0x2720: 0x0af0, // XK_maltesecross
|
||||
0x27e8: 0x0abc, // XK_leftanglebracket
|
||||
0x27e9: 0x0abe, // XK_rightanglebracket
|
||||
0x3001: 0x04a4, // XK_kana_comma
|
||||
0x3002: 0x04a1, // XK_kana_fullstop
|
||||
0x300c: 0x04a2, // XK_kana_openingbracket
|
||||
0x300d: 0x04a3, // XK_kana_closingbracket
|
||||
0x309b: 0x04de, // XK_voicedsound
|
||||
0x309c: 0x04df, // XK_semivoicedsound
|
||||
0x30a1: 0x04a7, // XK_kana_a
|
||||
0x30a2: 0x04b1, // XK_kana_A
|
||||
0x30a3: 0x04a8, // XK_kana_i
|
||||
0x30a4: 0x04b2, // XK_kana_I
|
||||
0x30a5: 0x04a9, // XK_kana_u
|
||||
0x30a6: 0x04b3, // XK_kana_U
|
||||
0x30a7: 0x04aa, // XK_kana_e
|
||||
0x30a8: 0x04b4, // XK_kana_E
|
||||
0x30a9: 0x04ab, // XK_kana_o
|
||||
0x30aa: 0x04b5, // XK_kana_O
|
||||
0x30ab: 0x04b6, // XK_kana_KA
|
||||
0x30ad: 0x04b7, // XK_kana_KI
|
||||
0x30af: 0x04b8, // XK_kana_KU
|
||||
0x30b1: 0x04b9, // XK_kana_KE
|
||||
0x30b3: 0x04ba, // XK_kana_KO
|
||||
0x30b5: 0x04bb, // XK_kana_SA
|
||||
0x30b7: 0x04bc, // XK_kana_SHI
|
||||
0x30b9: 0x04bd, // XK_kana_SU
|
||||
0x30bb: 0x04be, // XK_kana_SE
|
||||
0x30bd: 0x04bf, // XK_kana_SO
|
||||
0x30bf: 0x04c0, // XK_kana_TA
|
||||
0x30c1: 0x04c1, // XK_kana_CHI
|
||||
0x30c3: 0x04af, // XK_kana_tsu
|
||||
0x30c4: 0x04c2, // XK_kana_TSU
|
||||
0x30c6: 0x04c3, // XK_kana_TE
|
||||
0x30c8: 0x04c4, // XK_kana_TO
|
||||
0x30ca: 0x04c5, // XK_kana_NA
|
||||
0x30cb: 0x04c6, // XK_kana_NI
|
||||
0x30cc: 0x04c7, // XK_kana_NU
|
||||
0x30cd: 0x04c8, // XK_kana_NE
|
||||
0x30ce: 0x04c9, // XK_kana_NO
|
||||
0x30cf: 0x04ca, // XK_kana_HA
|
||||
0x30d2: 0x04cb, // XK_kana_HI
|
||||
0x30d5: 0x04cc, // XK_kana_FU
|
||||
0x30d8: 0x04cd, // XK_kana_HE
|
||||
0x30db: 0x04ce, // XK_kana_HO
|
||||
0x30de: 0x04cf, // XK_kana_MA
|
||||
0x30df: 0x04d0, // XK_kana_MI
|
||||
0x30e0: 0x04d1, // XK_kana_MU
|
||||
0x30e1: 0x04d2, // XK_kana_ME
|
||||
0x30e2: 0x04d3, // XK_kana_MO
|
||||
0x30e3: 0x04ac, // XK_kana_ya
|
||||
0x30e4: 0x04d4, // XK_kana_YA
|
||||
0x30e5: 0x04ad, // XK_kana_yu
|
||||
0x30e6: 0x04d5, // XK_kana_YU
|
||||
0x30e7: 0x04ae, // XK_kana_yo
|
||||
0x30e8: 0x04d6, // XK_kana_YO
|
||||
0x30e9: 0x04d7, // XK_kana_RA
|
||||
0x30ea: 0x04d8, // XK_kana_RI
|
||||
0x30eb: 0x04d9, // XK_kana_RU
|
||||
0x30ec: 0x04da, // XK_kana_RE
|
||||
0x30ed: 0x04db, // XK_kana_RO
|
||||
0x30ef: 0x04dc, // XK_kana_WA
|
||||
0x30f2: 0x04a6, // XK_kana_WO
|
||||
0x30f3: 0x04dd, // XK_kana_N
|
||||
0x30fb: 0x04a5, // XK_kana_conjunctive
|
||||
0x30fc: 0x04b0, // XK_prolongedsound
|
||||
};
|
||||
|
||||
export default {
|
||||
lookup(u) {
|
||||
// Latin-1 is one-to-one mapping
|
||||
if ((u >= 0x20) && (u <= 0xff)) {
|
||||
return u;
|
||||
}
|
||||
|
||||
// Lookup table (fairly random)
|
||||
const keysym = codepoints[u];
|
||||
if (keysym !== undefined) {
|
||||
return keysym;
|
||||
}
|
||||
|
||||
// General mapping as final fallback
|
||||
return 0x01000000 | u;
|
||||
},
|
||||
};
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import KeyTable from "./keysym.js";
|
||||
import keysyms from "./keysymdef.js";
|
||||
import vkeys from "./vkeys.js";
|
||||
import fixedkeys from "./fixedkeys.js";
|
||||
import DOMKeyTable from "./domkeytable.js";
|
||||
import * as browser from "../util/browser.js";
|
||||
|
||||
// Get 'KeyboardEvent.code', handling legacy browsers
|
||||
export function getKeycode(evt) {
|
||||
// Are we getting proper key identifiers?
|
||||
// (unfortunately Firefox and Chrome are crappy here and gives
|
||||
// us an empty string on some platforms, rather than leaving it
|
||||
// undefined)
|
||||
if (evt.code) {
|
||||
// Mozilla isn't fully in sync with the spec yet
|
||||
switch (evt.code) {
|
||||
case 'OSLeft': return 'MetaLeft';
|
||||
case 'OSRight': return 'MetaRight';
|
||||
}
|
||||
|
||||
return evt.code;
|
||||
}
|
||||
|
||||
// The de-facto standard is to use Windows Virtual-Key codes
|
||||
// in the 'keyCode' field for non-printable characters
|
||||
if (evt.keyCode in vkeys) {
|
||||
let code = vkeys[evt.keyCode];
|
||||
|
||||
// macOS has messed up this code for some reason
|
||||
if (browser.isMac() && (code === 'ContextMenu')) {
|
||||
code = 'MetaRight';
|
||||
}
|
||||
|
||||
// The keyCode doesn't distinguish between left and right
|
||||
// for the standard modifiers
|
||||
if (evt.location === 2) {
|
||||
switch (code) {
|
||||
case 'ShiftLeft': return 'ShiftRight';
|
||||
case 'ControlLeft': return 'ControlRight';
|
||||
case 'AltLeft': return 'AltRight';
|
||||
}
|
||||
}
|
||||
|
||||
// Nor a bunch of the numpad keys
|
||||
if (evt.location === 3) {
|
||||
switch (code) {
|
||||
case 'Delete': return 'NumpadDecimal';
|
||||
case 'Insert': return 'Numpad0';
|
||||
case 'End': return 'Numpad1';
|
||||
case 'ArrowDown': return 'Numpad2';
|
||||
case 'PageDown': return 'Numpad3';
|
||||
case 'ArrowLeft': return 'Numpad4';
|
||||
case 'ArrowRight': return 'Numpad6';
|
||||
case 'Home': return 'Numpad7';
|
||||
case 'ArrowUp': return 'Numpad8';
|
||||
case 'PageUp': return 'Numpad9';
|
||||
case 'Enter': return 'NumpadEnter';
|
||||
}
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
return 'Unidentified';
|
||||
}
|
||||
|
||||
// Get 'KeyboardEvent.key', handling legacy browsers
|
||||
export function getKey(evt) {
|
||||
// Are we getting a proper key value?
|
||||
if ((evt.key !== undefined) && (evt.key !== 'Unidentified')) {
|
||||
// Mozilla isn't fully in sync with the spec yet
|
||||
switch (evt.key) {
|
||||
case 'OS': return 'Meta';
|
||||
case 'LaunchMyComputer': return 'LaunchApplication1';
|
||||
case 'LaunchCalculator': return 'LaunchApplication2';
|
||||
}
|
||||
|
||||
// iOS leaks some OS names
|
||||
switch (evt.key) {
|
||||
case 'UIKeyInputUpArrow': return 'ArrowUp';
|
||||
case 'UIKeyInputDownArrow': return 'ArrowDown';
|
||||
case 'UIKeyInputLeftArrow': return 'ArrowLeft';
|
||||
case 'UIKeyInputRightArrow': return 'ArrowRight';
|
||||
case 'UIKeyInputEscape': return 'Escape';
|
||||
}
|
||||
|
||||
// Broken behaviour in Chrome
|
||||
if ((evt.key === '\x00') && (evt.code === 'NumpadDecimal')) {
|
||||
return 'Delete';
|
||||
}
|
||||
|
||||
return evt.key;
|
||||
}
|
||||
|
||||
// Try to deduce it based on the physical key
|
||||
const code = getKeycode(evt);
|
||||
if (code in fixedkeys) {
|
||||
return fixedkeys[code];
|
||||
}
|
||||
|
||||
// If that failed, then see if we have a printable character
|
||||
if (evt.charCode) {
|
||||
return String.fromCharCode(evt.charCode);
|
||||
}
|
||||
|
||||
// At this point we have nothing left to go on
|
||||
return 'Unidentified';
|
||||
}
|
||||
|
||||
// Get the most reliable keysym value we can get from a key event
|
||||
export function getKeysym(evt) {
|
||||
const key = getKey(evt);
|
||||
|
||||
if (key === 'Unidentified') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// First look up special keys
|
||||
if (key in DOMKeyTable) {
|
||||
let location = evt.location;
|
||||
|
||||
// Safari screws up location for the right cmd key
|
||||
if ((key === 'Meta') && (location === 0)) {
|
||||
location = 2;
|
||||
}
|
||||
|
||||
// And for Clear
|
||||
if ((key === 'Clear') && (location === 3)) {
|
||||
let code = getKeycode(evt);
|
||||
if (code === 'NumLock') {
|
||||
location = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ((location === undefined) || (location > 3)) {
|
||||
location = 0;
|
||||
}
|
||||
|
||||
// The original Meta key now gets confused with the Windows key
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1020141
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1232918
|
||||
if (key === 'Meta') {
|
||||
let code = getKeycode(evt);
|
||||
if (code === 'AltLeft') {
|
||||
return KeyTable.XK_Meta_L;
|
||||
} else if (code === 'AltRight') {
|
||||
return KeyTable.XK_Meta_R;
|
||||
}
|
||||
}
|
||||
|
||||
// macOS has Clear instead of NumLock, but the remote system is
|
||||
// probably not macOS, so lying here is probably best...
|
||||
if (key === 'Clear') {
|
||||
let code = getKeycode(evt);
|
||||
if (code === 'NumLock') {
|
||||
return KeyTable.XK_Num_Lock;
|
||||
}
|
||||
}
|
||||
|
||||
// Windows sends alternating symbols for some keys when using a
|
||||
// Japanese layout. We have no way of synchronising with the IM
|
||||
// running on the remote system, so we send some combined keysym
|
||||
// instead and hope for the best.
|
||||
if (browser.isWindows()) {
|
||||
switch (key) {
|
||||
case 'Zenkaku':
|
||||
case 'Hankaku':
|
||||
return KeyTable.XK_Zenkaku_Hankaku;
|
||||
case 'Romaji':
|
||||
case 'KanaMode':
|
||||
return KeyTable.XK_Romaji;
|
||||
}
|
||||
}
|
||||
|
||||
return DOMKeyTable[key][location];
|
||||
}
|
||||
|
||||
// Now we need to look at the Unicode symbol instead
|
||||
|
||||
// Special key? (FIXME: Should have been caught earlier)
|
||||
if (key.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codepoint = key.charCodeAt();
|
||||
if (codepoint) {
|
||||
return keysyms.lookup(codepoint);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2018 The noVNC authors
|
||||
* Licensed under MPL 2.0 or any later version (see LICENSE.txt)
|
||||
*/
|
||||
|
||||
/*
|
||||
* Mapping between Microsoft® Windows® Virtual-Key codes and
|
||||
* HTML key codes.
|
||||
*/
|
||||
|
||||
export default {
|
||||
0x08: 'Backspace',
|
||||
0x09: 'Tab',
|
||||
0x0a: 'NumpadClear',
|
||||
0x0d: 'Enter',
|
||||
0x10: 'ShiftLeft',
|
||||
0x11: 'ControlLeft',
|
||||
0x12: 'AltLeft',
|
||||
0x13: 'Pause',
|
||||
0x14: 'CapsLock',
|
||||
0x15: 'Lang1',
|
||||
0x19: 'Lang2',
|
||||
0x1b: 'Escape',
|
||||
0x1c: 'Convert',
|
||||
0x1d: 'NonConvert',
|
||||
0x20: 'Space',
|
||||
0x21: 'PageUp',
|
||||
0x22: 'PageDown',
|
||||
0x23: 'End',
|
||||
0x24: 'Home',
|
||||
0x25: 'ArrowLeft',
|
||||
0x26: 'ArrowUp',
|
||||
0x27: 'ArrowRight',
|
||||
0x28: 'ArrowDown',
|
||||
0x29: 'Select',
|
||||
0x2c: 'PrintScreen',
|
||||
0x2d: 'Insert',
|
||||
0x2e: 'Delete',
|
||||
0x2f: 'Help',
|
||||
0x30: 'Digit0',
|
||||
0x31: 'Digit1',
|
||||
0x32: 'Digit2',
|
||||
0x33: 'Digit3',
|
||||
0x34: 'Digit4',
|
||||
0x35: 'Digit5',
|
||||
0x36: 'Digit6',
|
||||
0x37: 'Digit7',
|
||||
0x38: 'Digit8',
|
||||
0x39: 'Digit9',
|
||||
0x5b: 'MetaLeft',
|
||||
0x5c: 'MetaRight',
|
||||
0x5d: 'ContextMenu',
|
||||
0x5f: 'Sleep',
|
||||
0x60: 'Numpad0',
|
||||
0x61: 'Numpad1',
|
||||
0x62: 'Numpad2',
|
||||
0x63: 'Numpad3',
|
||||
0x64: 'Numpad4',
|
||||
0x65: 'Numpad5',
|
||||
0x66: 'Numpad6',
|
||||
0x67: 'Numpad7',
|
||||
0x68: 'Numpad8',
|
||||
0x69: 'Numpad9',
|
||||
0x6a: 'NumpadMultiply',
|
||||
0x6b: 'NumpadAdd',
|
||||
0x6c: 'NumpadDecimal',
|
||||
0x6d: 'NumpadSubtract',
|
||||
0x6e: 'NumpadDecimal', // Duplicate, because buggy on Windows
|
||||
0x6f: 'NumpadDivide',
|
||||
0x70: 'F1',
|
||||
0x71: 'F2',
|
||||
0x72: 'F3',
|
||||
0x73: 'F4',
|
||||
0x74: 'F5',
|
||||
0x75: 'F6',
|
||||
0x76: 'F7',
|
||||
0x77: 'F8',
|
||||
0x78: 'F9',
|
||||
0x79: 'F10',
|
||||
0x7a: 'F11',
|
||||
0x7b: 'F12',
|
||||
0x7c: 'F13',
|
||||
0x7d: 'F14',
|
||||
0x7e: 'F15',
|
||||
0x7f: 'F16',
|
||||
0x80: 'F17',
|
||||
0x81: 'F18',
|
||||
0x82: 'F19',
|
||||
0x83: 'F20',
|
||||
0x84: 'F21',
|
||||
0x85: 'F22',
|
||||
0x86: 'F23',
|
||||
0x87: 'F24',
|
||||
0x90: 'NumLock',
|
||||
0x91: 'ScrollLock',
|
||||
0xa6: 'BrowserBack',
|
||||
0xa7: 'BrowserForward',
|
||||
0xa8: 'BrowserRefresh',
|
||||
0xa9: 'BrowserStop',
|
||||
0xaa: 'BrowserSearch',
|
||||
0xab: 'BrowserFavorites',
|
||||
0xac: 'BrowserHome',
|
||||
0xad: 'AudioVolumeMute',
|
||||
0xae: 'AudioVolumeDown',
|
||||
0xaf: 'AudioVolumeUp',
|
||||
0xb0: 'MediaTrackNext',
|
||||
0xb1: 'MediaTrackPrevious',
|
||||
0xb2: 'MediaStop',
|
||||
0xb3: 'MediaPlayPause',
|
||||
0xb4: 'LaunchMail',
|
||||
0xb5: 'MediaSelect',
|
||||
0xb6: 'LaunchApp1',
|
||||
0xb7: 'LaunchApp2',
|
||||
0xe1: 'AltRight', // Only when it is AltGraph
|
||||
};
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* This file is auto-generated from keymaps.csv
|
||||
* Database checksum sha256(76d68c10e97d37fe2ea459e210125ae41796253fb217e900bf2983ade13a7920)
|
||||
* To re-generate, run:
|
||||
* keymap-gen code-map --lang=js keymaps.csv html atset1
|
||||
*/
|
||||
export default {
|
||||
"Again": 0xe005, /* html:Again (Again) -> linux:129 (KEY_AGAIN) -> atset1:57349 */
|
||||
"AltLeft": 0x38, /* html:AltLeft (AltLeft) -> linux:56 (KEY_LEFTALT) -> atset1:56 */
|
||||
"AltRight": 0xe038, /* html:AltRight (AltRight) -> linux:100 (KEY_RIGHTALT) -> atset1:57400 */
|
||||
"ArrowDown": 0xe050, /* html:ArrowDown (ArrowDown) -> linux:108 (KEY_DOWN) -> atset1:57424 */
|
||||
"ArrowLeft": 0xe04b, /* html:ArrowLeft (ArrowLeft) -> linux:105 (KEY_LEFT) -> atset1:57419 */
|
||||
"ArrowRight": 0xe04d, /* html:ArrowRight (ArrowRight) -> linux:106 (KEY_RIGHT) -> atset1:57421 */
|
||||
"ArrowUp": 0xe048, /* html:ArrowUp (ArrowUp) -> linux:103 (KEY_UP) -> atset1:57416 */
|
||||
"AudioVolumeDown": 0xe02e, /* html:AudioVolumeDown (AudioVolumeDown) -> linux:114 (KEY_VOLUMEDOWN) -> atset1:57390 */
|
||||
"AudioVolumeMute": 0xe020, /* html:AudioVolumeMute (AudioVolumeMute) -> linux:113 (KEY_MUTE) -> atset1:57376 */
|
||||
"AudioVolumeUp": 0xe030, /* html:AudioVolumeUp (AudioVolumeUp) -> linux:115 (KEY_VOLUMEUP) -> atset1:57392 */
|
||||
"Backquote": 0x29, /* html:Backquote (Backquote) -> linux:41 (KEY_GRAVE) -> atset1:41 */
|
||||
"Backslash": 0x2b, /* html:Backslash (Backslash) -> linux:43 (KEY_BACKSLASH) -> atset1:43 */
|
||||
"Backspace": 0xe, /* html:Backspace (Backspace) -> linux:14 (KEY_BACKSPACE) -> atset1:14 */
|
||||
"BracketLeft": 0x1a, /* html:BracketLeft (BracketLeft) -> linux:26 (KEY_LEFTBRACE) -> atset1:26 */
|
||||
"BracketRight": 0x1b, /* html:BracketRight (BracketRight) -> linux:27 (KEY_RIGHTBRACE) -> atset1:27 */
|
||||
"BrowserBack": 0xe06a, /* html:BrowserBack (BrowserBack) -> linux:158 (KEY_BACK) -> atset1:57450 */
|
||||
"BrowserFavorites": 0xe066, /* html:BrowserFavorites (BrowserFavorites) -> linux:156 (KEY_BOOKMARKS) -> atset1:57446 */
|
||||
"BrowserForward": 0xe069, /* html:BrowserForward (BrowserForward) -> linux:159 (KEY_FORWARD) -> atset1:57449 */
|
||||
"BrowserHome": 0xe032, /* html:BrowserHome (BrowserHome) -> linux:172 (KEY_HOMEPAGE) -> atset1:57394 */
|
||||
"BrowserRefresh": 0xe067, /* html:BrowserRefresh (BrowserRefresh) -> linux:173 (KEY_REFRESH) -> atset1:57447 */
|
||||
"BrowserSearch": 0xe065, /* html:BrowserSearch (BrowserSearch) -> linux:217 (KEY_SEARCH) -> atset1:57445 */
|
||||
"BrowserStop": 0xe068, /* html:BrowserStop (BrowserStop) -> linux:128 (KEY_STOP) -> atset1:57448 */
|
||||
"CapsLock": 0x3a, /* html:CapsLock (CapsLock) -> linux:58 (KEY_CAPSLOCK) -> atset1:58 */
|
||||
"Comma": 0x33, /* html:Comma (Comma) -> linux:51 (KEY_COMMA) -> atset1:51 */
|
||||
"ContextMenu": 0xe05d, /* html:ContextMenu (ContextMenu) -> linux:127 (KEY_COMPOSE) -> atset1:57437 */
|
||||
"ControlLeft": 0x1d, /* html:ControlLeft (ControlLeft) -> linux:29 (KEY_LEFTCTRL) -> atset1:29 */
|
||||
"ControlRight": 0xe01d, /* html:ControlRight (ControlRight) -> linux:97 (KEY_RIGHTCTRL) -> atset1:57373 */
|
||||
"Convert": 0x79, /* html:Convert (Convert) -> linux:92 (KEY_HENKAN) -> atset1:121 */
|
||||
"Copy": 0xe078, /* html:Copy (Copy) -> linux:133 (KEY_COPY) -> atset1:57464 */
|
||||
"Cut": 0xe03c, /* html:Cut (Cut) -> linux:137 (KEY_CUT) -> atset1:57404 */
|
||||
"Delete": 0xe053, /* html:Delete (Delete) -> linux:111 (KEY_DELETE) -> atset1:57427 */
|
||||
"Digit0": 0xb, /* html:Digit0 (Digit0) -> linux:11 (KEY_0) -> atset1:11 */
|
||||
"Digit1": 0x2, /* html:Digit1 (Digit1) -> linux:2 (KEY_1) -> atset1:2 */
|
||||
"Digit2": 0x3, /* html:Digit2 (Digit2) -> linux:3 (KEY_2) -> atset1:3 */
|
||||
"Digit3": 0x4, /* html:Digit3 (Digit3) -> linux:4 (KEY_3) -> atset1:4 */
|
||||
"Digit4": 0x5, /* html:Digit4 (Digit4) -> linux:5 (KEY_4) -> atset1:5 */
|
||||
"Digit5": 0x6, /* html:Digit5 (Digit5) -> linux:6 (KEY_5) -> atset1:6 */
|
||||
"Digit6": 0x7, /* html:Digit6 (Digit6) -> linux:7 (KEY_6) -> atset1:7 */
|
||||
"Digit7": 0x8, /* html:Digit7 (Digit7) -> linux:8 (KEY_7) -> atset1:8 */
|
||||
"Digit8": 0x9, /* html:Digit8 (Digit8) -> linux:9 (KEY_8) -> atset1:9 */
|
||||
"Digit9": 0xa, /* html:Digit9 (Digit9) -> linux:10 (KEY_9) -> atset1:10 */
|
||||
"Eject": 0xe07d, /* html:Eject (Eject) -> linux:162 (KEY_EJECTCLOSECD) -> atset1:57469 */
|
||||
"End": 0xe04f, /* html:End (End) -> linux:107 (KEY_END) -> atset1:57423 */
|
||||
"Enter": 0x1c, /* html:Enter (Enter) -> linux:28 (KEY_ENTER) -> atset1:28 */
|
||||
"Equal": 0xd, /* html:Equal (Equal) -> linux:13 (KEY_EQUAL) -> atset1:13 */
|
||||
"Escape": 0x1, /* html:Escape (Escape) -> linux:1 (KEY_ESC) -> atset1:1 */
|
||||
"F1": 0x3b, /* html:F1 (F1) -> linux:59 (KEY_F1) -> atset1:59 */
|
||||
"F10": 0x44, /* html:F10 (F10) -> linux:68 (KEY_F10) -> atset1:68 */
|
||||
"F11": 0x57, /* html:F11 (F11) -> linux:87 (KEY_F11) -> atset1:87 */
|
||||
"F12": 0x58, /* html:F12 (F12) -> linux:88 (KEY_F12) -> atset1:88 */
|
||||
"F13": 0x5d, /* html:F13 (F13) -> linux:183 (KEY_F13) -> atset1:93 */
|
||||
"F14": 0x5e, /* html:F14 (F14) -> linux:184 (KEY_F14) -> atset1:94 */
|
||||
"F15": 0x5f, /* html:F15 (F15) -> linux:185 (KEY_F15) -> atset1:95 */
|
||||
"F16": 0x55, /* html:F16 (F16) -> linux:186 (KEY_F16) -> atset1:85 */
|
||||
"F17": 0xe003, /* html:F17 (F17) -> linux:187 (KEY_F17) -> atset1:57347 */
|
||||
"F18": 0xe077, /* html:F18 (F18) -> linux:188 (KEY_F18) -> atset1:57463 */
|
||||
"F19": 0xe004, /* html:F19 (F19) -> linux:189 (KEY_F19) -> atset1:57348 */
|
||||
"F2": 0x3c, /* html:F2 (F2) -> linux:60 (KEY_F2) -> atset1:60 */
|
||||
"F20": 0x5a, /* html:F20 (F20) -> linux:190 (KEY_F20) -> atset1:90 */
|
||||
"F21": 0x74, /* html:F21 (F21) -> linux:191 (KEY_F21) -> atset1:116 */
|
||||
"F22": 0xe079, /* html:F22 (F22) -> linux:192 (KEY_F22) -> atset1:57465 */
|
||||
"F23": 0x6d, /* html:F23 (F23) -> linux:193 (KEY_F23) -> atset1:109 */
|
||||
"F24": 0x6f, /* html:F24 (F24) -> linux:194 (KEY_F24) -> atset1:111 */
|
||||
"F3": 0x3d, /* html:F3 (F3) -> linux:61 (KEY_F3) -> atset1:61 */
|
||||
"F4": 0x3e, /* html:F4 (F4) -> linux:62 (KEY_F4) -> atset1:62 */
|
||||
"F5": 0x3f, /* html:F5 (F5) -> linux:63 (KEY_F5) -> atset1:63 */
|
||||
"F6": 0x40, /* html:F6 (F6) -> linux:64 (KEY_F6) -> atset1:64 */
|
||||
"F7": 0x41, /* html:F7 (F7) -> linux:65 (KEY_F7) -> atset1:65 */
|
||||
"F8": 0x42, /* html:F8 (F8) -> linux:66 (KEY_F8) -> atset1:66 */
|
||||
"F9": 0x43, /* html:F9 (F9) -> linux:67 (KEY_F9) -> atset1:67 */
|
||||
"Find": 0xe041, /* html:Find (Find) -> linux:136 (KEY_FIND) -> atset1:57409 */
|
||||
"Help": 0xe075, /* html:Help (Help) -> linux:138 (KEY_HELP) -> atset1:57461 */
|
||||
"Hiragana": 0x77, /* html:Hiragana (Lang4) -> linux:91 (KEY_HIRAGANA) -> atset1:119 */
|
||||
"Home": 0xe047, /* html:Home (Home) -> linux:102 (KEY_HOME) -> atset1:57415 */
|
||||
"Insert": 0xe052, /* html:Insert (Insert) -> linux:110 (KEY_INSERT) -> atset1:57426 */
|
||||
"IntlBackslash": 0x56, /* html:IntlBackslash (IntlBackslash) -> linux:86 (KEY_102ND) -> atset1:86 */
|
||||
"IntlRo": 0x73, /* html:IntlRo (IntlRo) -> linux:89 (KEY_RO) -> atset1:115 */
|
||||
"IntlYen": 0x7d, /* html:IntlYen (IntlYen) -> linux:124 (KEY_YEN) -> atset1:125 */
|
||||
"KanaMode": 0x70, /* html:KanaMode (KanaMode) -> linux:93 (KEY_KATAKANAHIRAGANA) -> atset1:112 */
|
||||
"Katakana": 0x78, /* html:Katakana (Lang3) -> linux:90 (KEY_KATAKANA) -> atset1:120 */
|
||||
"KeyA": 0x1e, /* html:KeyA (KeyA) -> linux:30 (KEY_A) -> atset1:30 */
|
||||
"KeyB": 0x30, /* html:KeyB (KeyB) -> linux:48 (KEY_B) -> atset1:48 */
|
||||
"KeyC": 0x2e, /* html:KeyC (KeyC) -> linux:46 (KEY_C) -> atset1:46 */
|
||||
"KeyD": 0x20, /* html:KeyD (KeyD) -> linux:32 (KEY_D) -> atset1:32 */
|
||||
"KeyE": 0x12, /* html:KeyE (KeyE) -> linux:18 (KEY_E) -> atset1:18 */
|
||||
"KeyF": 0x21, /* html:KeyF (KeyF) -> linux:33 (KEY_F) -> atset1:33 */
|
||||
"KeyG": 0x22, /* html:KeyG (KeyG) -> linux:34 (KEY_G) -> atset1:34 */
|
||||
"KeyH": 0x23, /* html:KeyH (KeyH) -> linux:35 (KEY_H) -> atset1:35 */
|
||||
"KeyI": 0x17, /* html:KeyI (KeyI) -> linux:23 (KEY_I) -> atset1:23 */
|
||||
"KeyJ": 0x24, /* html:KeyJ (KeyJ) -> linux:36 (KEY_J) -> atset1:36 */
|
||||
"KeyK": 0x25, /* html:KeyK (KeyK) -> linux:37 (KEY_K) -> atset1:37 */
|
||||
"KeyL": 0x26, /* html:KeyL (KeyL) -> linux:38 (KEY_L) -> atset1:38 */
|
||||
"KeyM": 0x32, /* html:KeyM (KeyM) -> linux:50 (KEY_M) -> atset1:50 */
|
||||
"KeyN": 0x31, /* html:KeyN (KeyN) -> linux:49 (KEY_N) -> atset1:49 */
|
||||
"KeyO": 0x18, /* html:KeyO (KeyO) -> linux:24 (KEY_O) -> atset1:24 */
|
||||
"KeyP": 0x19, /* html:KeyP (KeyP) -> linux:25 (KEY_P) -> atset1:25 */
|
||||
"KeyQ": 0x10, /* html:KeyQ (KeyQ) -> linux:16 (KEY_Q) -> atset1:16 */
|
||||
"KeyR": 0x13, /* html:KeyR (KeyR) -> linux:19 (KEY_R) -> atset1:19 */
|
||||
"KeyS": 0x1f, /* html:KeyS (KeyS) -> linux:31 (KEY_S) -> atset1:31 */
|
||||
"KeyT": 0x14, /* html:KeyT (KeyT) -> linux:20 (KEY_T) -> atset1:20 */
|
||||
"KeyU": 0x16, /* html:KeyU (KeyU) -> linux:22 (KEY_U) -> atset1:22 */
|
||||
"KeyV": 0x2f, /* html:KeyV (KeyV) -> linux:47 (KEY_V) -> atset1:47 */
|
||||
"KeyW": 0x11, /* html:KeyW (KeyW) -> linux:17 (KEY_W) -> atset1:17 */
|
||||
"KeyX": 0x2d, /* html:KeyX (KeyX) -> linux:45 (KEY_X) -> atset1:45 */
|
||||
"KeyY": 0x15, /* html:KeyY (KeyY) -> linux:21 (KEY_Y) -> atset1:21 */
|
||||
"KeyZ": 0x2c, /* html:KeyZ (KeyZ) -> linux:44 (KEY_Z) -> atset1:44 */
|
||||
"Lang1": 0x72, /* html:Lang1 (Lang1) -> linux:122 (KEY_HANGEUL) -> atset1:114 */
|
||||
"Lang2": 0x71, /* html:Lang2 (Lang2) -> linux:123 (KEY_HANJA) -> atset1:113 */
|
||||
"Lang3": 0x78, /* html:Lang3 (Lang3) -> linux:90 (KEY_KATAKANA) -> atset1:120 */
|
||||
"Lang4": 0x77, /* html:Lang4 (Lang4) -> linux:91 (KEY_HIRAGANA) -> atset1:119 */
|
||||
"Lang5": 0x76, /* html:Lang5 (Lang5) -> linux:85 (KEY_ZENKAKUHANKAKU) -> atset1:118 */
|
||||
"LaunchApp1": 0xe06b, /* html:LaunchApp1 (LaunchApp1) -> linux:157 (KEY_COMPUTER) -> atset1:57451 */
|
||||
"LaunchApp2": 0xe021, /* html:LaunchApp2 (LaunchApp2) -> linux:140 (KEY_CALC) -> atset1:57377 */
|
||||
"LaunchMail": 0xe06c, /* html:LaunchMail (LaunchMail) -> linux:155 (KEY_MAIL) -> atset1:57452 */
|
||||
"MediaPlayPause": 0xe022, /* html:MediaPlayPause (MediaPlayPause) -> linux:164 (KEY_PLAYPAUSE) -> atset1:57378 */
|
||||
"MediaSelect": 0xe06d, /* html:MediaSelect (MediaSelect) -> linux:226 (KEY_MEDIA) -> atset1:57453 */
|
||||
"MediaStop": 0xe024, /* html:MediaStop (MediaStop) -> linux:166 (KEY_STOPCD) -> atset1:57380 */
|
||||
"MediaTrackNext": 0xe019, /* html:MediaTrackNext (MediaTrackNext) -> linux:163 (KEY_NEXTSONG) -> atset1:57369 */
|
||||
"MediaTrackPrevious": 0xe010, /* html:MediaTrackPrevious (MediaTrackPrevious) -> linux:165 (KEY_PREVIOUSSONG) -> atset1:57360 */
|
||||
"MetaLeft": 0xe05b, /* html:MetaLeft (MetaLeft) -> linux:125 (KEY_LEFTMETA) -> atset1:57435 */
|
||||
"MetaRight": 0xe05c, /* html:MetaRight (MetaRight) -> linux:126 (KEY_RIGHTMETA) -> atset1:57436 */
|
||||
"Minus": 0xc, /* html:Minus (Minus) -> linux:12 (KEY_MINUS) -> atset1:12 */
|
||||
"NonConvert": 0x7b, /* html:NonConvert (NonConvert) -> linux:94 (KEY_MUHENKAN) -> atset1:123 */
|
||||
"NumLock": 0x45, /* html:NumLock (NumLock) -> linux:69 (KEY_NUMLOCK) -> atset1:69 */
|
||||
"Numpad0": 0x52, /* html:Numpad0 (Numpad0) -> linux:82 (KEY_KP0) -> atset1:82 */
|
||||
"Numpad1": 0x4f, /* html:Numpad1 (Numpad1) -> linux:79 (KEY_KP1) -> atset1:79 */
|
||||
"Numpad2": 0x50, /* html:Numpad2 (Numpad2) -> linux:80 (KEY_KP2) -> atset1:80 */
|
||||
"Numpad3": 0x51, /* html:Numpad3 (Numpad3) -> linux:81 (KEY_KP3) -> atset1:81 */
|
||||
"Numpad4": 0x4b, /* html:Numpad4 (Numpad4) -> linux:75 (KEY_KP4) -> atset1:75 */
|
||||
"Numpad5": 0x4c, /* html:Numpad5 (Numpad5) -> linux:76 (KEY_KP5) -> atset1:76 */
|
||||
"Numpad6": 0x4d, /* html:Numpad6 (Numpad6) -> linux:77 (KEY_KP6) -> atset1:77 */
|
||||
"Numpad7": 0x47, /* html:Numpad7 (Numpad7) -> linux:71 (KEY_KP7) -> atset1:71 */
|
||||
"Numpad8": 0x48, /* html:Numpad8 (Numpad8) -> linux:72 (KEY_KP8) -> atset1:72 */
|
||||
"Numpad9": 0x49, /* html:Numpad9 (Numpad9) -> linux:73 (KEY_KP9) -> atset1:73 */
|
||||
"NumpadAdd": 0x4e, /* html:NumpadAdd (NumpadAdd) -> linux:78 (KEY_KPPLUS) -> atset1:78 */
|
||||
"NumpadComma": 0x7e, /* html:NumpadComma (NumpadComma) -> linux:121 (KEY_KPCOMMA) -> atset1:126 */
|
||||
"NumpadDecimal": 0x53, /* html:NumpadDecimal (NumpadDecimal) -> linux:83 (KEY_KPDOT) -> atset1:83 */
|
||||
"NumpadDivide": 0xe035, /* html:NumpadDivide (NumpadDivide) -> linux:98 (KEY_KPSLASH) -> atset1:57397 */
|
||||
"NumpadEnter": 0xe01c, /* html:NumpadEnter (NumpadEnter) -> linux:96 (KEY_KPENTER) -> atset1:57372 */
|
||||
"NumpadEqual": 0x59, /* html:NumpadEqual (NumpadEqual) -> linux:117 (KEY_KPEQUAL) -> atset1:89 */
|
||||
"NumpadMultiply": 0x37, /* html:NumpadMultiply (NumpadMultiply) -> linux:55 (KEY_KPASTERISK) -> atset1:55 */
|
||||
"NumpadParenLeft": 0xe076, /* html:NumpadParenLeft (NumpadParenLeft) -> linux:179 (KEY_KPLEFTPAREN) -> atset1:57462 */
|
||||
"NumpadParenRight": 0xe07b, /* html:NumpadParenRight (NumpadParenRight) -> linux:180 (KEY_KPRIGHTPAREN) -> atset1:57467 */
|
||||
"NumpadSubtract": 0x4a, /* html:NumpadSubtract (NumpadSubtract) -> linux:74 (KEY_KPMINUS) -> atset1:74 */
|
||||
"Open": 0x64, /* html:Open (Open) -> linux:134 (KEY_OPEN) -> atset1:100 */
|
||||
"PageDown": 0xe051, /* html:PageDown (PageDown) -> linux:109 (KEY_PAGEDOWN) -> atset1:57425 */
|
||||
"PageUp": 0xe049, /* html:PageUp (PageUp) -> linux:104 (KEY_PAGEUP) -> atset1:57417 */
|
||||
"Paste": 0x65, /* html:Paste (Paste) -> linux:135 (KEY_PASTE) -> atset1:101 */
|
||||
"Pause": 0xe046, /* html:Pause (Pause) -> linux:119 (KEY_PAUSE) -> atset1:57414 */
|
||||
"Period": 0x34, /* html:Period (Period) -> linux:52 (KEY_DOT) -> atset1:52 */
|
||||
"Power": 0xe05e, /* html:Power (Power) -> linux:116 (KEY_POWER) -> atset1:57438 */
|
||||
"PrintScreen": 0x54, /* html:PrintScreen (PrintScreen) -> linux:99 (KEY_SYSRQ) -> atset1:84 */
|
||||
"Props": 0xe006, /* html:Props (Props) -> linux:130 (KEY_PROPS) -> atset1:57350 */
|
||||
"Quote": 0x28, /* html:Quote (Quote) -> linux:40 (KEY_APOSTROPHE) -> atset1:40 */
|
||||
"ScrollLock": 0x46, /* html:ScrollLock (ScrollLock) -> linux:70 (KEY_SCROLLLOCK) -> atset1:70 */
|
||||
"Semicolon": 0x27, /* html:Semicolon (Semicolon) -> linux:39 (KEY_SEMICOLON) -> atset1:39 */
|
||||
"ShiftLeft": 0x2a, /* html:ShiftLeft (ShiftLeft) -> linux:42 (KEY_LEFTSHIFT) -> atset1:42 */
|
||||
"ShiftRight": 0x36, /* html:ShiftRight (ShiftRight) -> linux:54 (KEY_RIGHTSHIFT) -> atset1:54 */
|
||||
"Slash": 0x35, /* html:Slash (Slash) -> linux:53 (KEY_SLASH) -> atset1:53 */
|
||||
"Sleep": 0xe05f, /* html:Sleep (Sleep) -> linux:142 (KEY_SLEEP) -> atset1:57439 */
|
||||
"Space": 0x39, /* html:Space (Space) -> linux:57 (KEY_SPACE) -> atset1:57 */
|
||||
"Suspend": 0xe025, /* html:Suspend (Suspend) -> linux:205 (KEY_SUSPEND) -> atset1:57381 */
|
||||
"Tab": 0xf, /* html:Tab (Tab) -> linux:15 (KEY_TAB) -> atset1:15 */
|
||||
"Undo": 0xe007, /* html:Undo (Undo) -> linux:131 (KEY_UNDO) -> atset1:57351 */
|
||||
"WakeUp": 0xe063, /* html:WakeUp (WakeUp) -> linux:143 (KEY_WAKEUP) -> atset1:57443 */
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
import { encodeUTF8 } from './util/strings.js';
|
||||
import EventTargetMixin from './util/eventtarget.js';
|
||||
import legacyCrypto from './crypto/crypto.js';
|
||||
|
||||
class RA2Cipher {
|
||||
constructor() {
|
||||
this._cipher = null;
|
||||
this._counter = new Uint8Array(16);
|
||||
}
|
||||
|
||||
async setKey(key) {
|
||||
this._cipher = await legacyCrypto.importKey(
|
||||
"raw", key, { name: "AES-EAX" }, false, ["encrypt, decrypt"]);
|
||||
}
|
||||
|
||||
async makeMessage(message) {
|
||||
const ad = new Uint8Array([(message.length & 0xff00) >>> 8, message.length & 0xff]);
|
||||
const encrypted = await legacyCrypto.encrypt({
|
||||
name: "AES-EAX",
|
||||
iv: this._counter,
|
||||
additionalData: ad,
|
||||
}, this._cipher, message);
|
||||
for (let i = 0; i < 16 && this._counter[i]++ === 255; i++);
|
||||
const res = new Uint8Array(message.length + 2 + 16);
|
||||
res.set(ad);
|
||||
res.set(encrypted, 2);
|
||||
return res;
|
||||
}
|
||||
|
||||
async receiveMessage(length, encrypted) {
|
||||
const ad = new Uint8Array([(length & 0xff00) >>> 8, length & 0xff]);
|
||||
const res = await legacyCrypto.decrypt({
|
||||
name: "AES-EAX",
|
||||
iv: this._counter,
|
||||
additionalData: ad,
|
||||
}, this._cipher, encrypted);
|
||||
for (let i = 0; i < 16 && this._counter[i]++ === 255; i++);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export default class RSAAESAuthenticationState extends EventTargetMixin {
|
||||
constructor(sock, getCredentials) {
|
||||
super();
|
||||
this._hasStarted = false;
|
||||
this._checkSock = null;
|
||||
this._checkCredentials = null;
|
||||
this._approveServerResolve = null;
|
||||
this._sockReject = null;
|
||||
this._credentialsReject = null;
|
||||
this._approveServerReject = null;
|
||||
this._sock = sock;
|
||||
this._getCredentials = getCredentials;
|
||||
}
|
||||
|
||||
_waitSockAsync(len) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hasData = () => !this._sock.rQwait('RA2', len);
|
||||
if (hasData()) {
|
||||
resolve();
|
||||
} else {
|
||||
this._checkSock = () => {
|
||||
if (hasData()) {
|
||||
resolve();
|
||||
this._checkSock = null;
|
||||
this._sockReject = null;
|
||||
}
|
||||
};
|
||||
this._sockReject = reject;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_waitApproveKeyAsync() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._approveServerResolve = resolve;
|
||||
this._approveServerReject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
_waitCredentialsAsync(subtype) {
|
||||
const hasCredentials = () => {
|
||||
if (subtype === 1 && this._getCredentials().username !== undefined &&
|
||||
this._getCredentials().password !== undefined) {
|
||||
return true;
|
||||
} else if (subtype === 2 && this._getCredentials().password !== undefined) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
if (hasCredentials()) {
|
||||
resolve();
|
||||
} else {
|
||||
this._checkCredentials = () => {
|
||||
if (hasCredentials()) {
|
||||
resolve();
|
||||
this._checkCredentials = null;
|
||||
this._credentialsReject = null;
|
||||
}
|
||||
};
|
||||
this._credentialsReject = reject;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkInternalEvents() {
|
||||
if (this._checkSock !== null) {
|
||||
this._checkSock();
|
||||
}
|
||||
if (this._checkCredentials !== null) {
|
||||
this._checkCredentials();
|
||||
}
|
||||
}
|
||||
|
||||
approveServer() {
|
||||
if (this._approveServerResolve !== null) {
|
||||
this._approveServerResolve();
|
||||
this._approveServerResolve = null;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this._sockReject !== null) {
|
||||
this._sockReject(new Error("disconnect normally"));
|
||||
this._sockReject = null;
|
||||
}
|
||||
if (this._credentialsReject !== null) {
|
||||
this._credentialsReject(new Error("disconnect normally"));
|
||||
this._credentialsReject = null;
|
||||
}
|
||||
if (this._approveServerReject !== null) {
|
||||
this._approveServerReject(new Error("disconnect normally"));
|
||||
this._approveServerReject = null;
|
||||
}
|
||||
}
|
||||
|
||||
async negotiateRA2neAuthAsync() {
|
||||
this._hasStarted = true;
|
||||
// 1: Receive server public key
|
||||
await this._waitSockAsync(4);
|
||||
const serverKeyLengthBuffer = this._sock.rQpeekBytes(4);
|
||||
const serverKeyLength = this._sock.rQshift32();
|
||||
if (serverKeyLength < 1024) {
|
||||
throw new Error("RA2: server public key is too short: " + serverKeyLength);
|
||||
} else if (serverKeyLength > 8192) {
|
||||
throw new Error("RA2: server public key is too long: " + serverKeyLength);
|
||||
}
|
||||
const serverKeyBytes = Math.ceil(serverKeyLength / 8);
|
||||
await this._waitSockAsync(serverKeyBytes * 2);
|
||||
const serverN = this._sock.rQshiftBytes(serverKeyBytes);
|
||||
const serverE = this._sock.rQshiftBytes(serverKeyBytes);
|
||||
const serverRSACipher = await legacyCrypto.importKey(
|
||||
"raw", { n: serverN, e: serverE }, { name: "RSA-PKCS1-v1_5" }, false, ["encrypt"]);
|
||||
const serverPublickey = new Uint8Array(4 + serverKeyBytes * 2);
|
||||
serverPublickey.set(serverKeyLengthBuffer);
|
||||
serverPublickey.set(serverN, 4);
|
||||
serverPublickey.set(serverE, 4 + serverKeyBytes);
|
||||
|
||||
// verify server public key
|
||||
let approveKey = this._waitApproveKeyAsync();
|
||||
this.dispatchEvent(new CustomEvent("serververification", {
|
||||
detail: { type: "RSA", publickey: serverPublickey }
|
||||
}));
|
||||
await approveKey;
|
||||
|
||||
// 2: Send client public key
|
||||
const clientKeyLength = 2048;
|
||||
const clientKeyBytes = Math.ceil(clientKeyLength / 8);
|
||||
const clientRSACipher = (await legacyCrypto.generateKey({
|
||||
name: "RSA-PKCS1-v1_5",
|
||||
modulusLength: clientKeyLength,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
}, true, ["encrypt"])).privateKey;
|
||||
const clientExportedRSAKey = await legacyCrypto.exportKey("raw", clientRSACipher);
|
||||
const clientN = clientExportedRSAKey.n;
|
||||
const clientE = clientExportedRSAKey.e;
|
||||
const clientPublicKey = new Uint8Array(4 + clientKeyBytes * 2);
|
||||
clientPublicKey[0] = (clientKeyLength & 0xff000000) >>> 24;
|
||||
clientPublicKey[1] = (clientKeyLength & 0xff0000) >>> 16;
|
||||
clientPublicKey[2] = (clientKeyLength & 0xff00) >>> 8;
|
||||
clientPublicKey[3] = clientKeyLength & 0xff;
|
||||
clientPublicKey.set(clientN, 4);
|
||||
clientPublicKey.set(clientE, 4 + clientKeyBytes);
|
||||
this._sock.sQpushBytes(clientPublicKey);
|
||||
this._sock.flush();
|
||||
|
||||
// 3: Send client random
|
||||
const clientRandom = new Uint8Array(16);
|
||||
window.crypto.getRandomValues(clientRandom);
|
||||
const clientEncryptedRandom = await legacyCrypto.encrypt(
|
||||
{ name: "RSA-PKCS1-v1_5" }, serverRSACipher, clientRandom);
|
||||
const clientRandomMessage = new Uint8Array(2 + serverKeyBytes);
|
||||
clientRandomMessage[0] = (serverKeyBytes & 0xff00) >>> 8;
|
||||
clientRandomMessage[1] = serverKeyBytes & 0xff;
|
||||
clientRandomMessage.set(clientEncryptedRandom, 2);
|
||||
this._sock.sQpushBytes(clientRandomMessage);
|
||||
this._sock.flush();
|
||||
|
||||
// 4: Receive server random
|
||||
await this._waitSockAsync(2);
|
||||
if (this._sock.rQshift16() !== clientKeyBytes) {
|
||||
throw new Error("RA2: wrong encrypted message length");
|
||||
}
|
||||
const serverEncryptedRandom = this._sock.rQshiftBytes(clientKeyBytes);
|
||||
const serverRandom = await legacyCrypto.decrypt(
|
||||
{ name: "RSA-PKCS1-v1_5" }, clientRSACipher, serverEncryptedRandom);
|
||||
if (serverRandom === null || serverRandom.length !== 16) {
|
||||
throw new Error("RA2: corrupted server encrypted random");
|
||||
}
|
||||
|
||||
// 5: Compute session keys and set ciphers
|
||||
let clientSessionKey = new Uint8Array(32);
|
||||
let serverSessionKey = new Uint8Array(32);
|
||||
clientSessionKey.set(serverRandom);
|
||||
clientSessionKey.set(clientRandom, 16);
|
||||
serverSessionKey.set(clientRandom);
|
||||
serverSessionKey.set(serverRandom, 16);
|
||||
clientSessionKey = await window.crypto.subtle.digest("SHA-1", clientSessionKey);
|
||||
clientSessionKey = new Uint8Array(clientSessionKey).slice(0, 16);
|
||||
serverSessionKey = await window.crypto.subtle.digest("SHA-1", serverSessionKey);
|
||||
serverSessionKey = new Uint8Array(serverSessionKey).slice(0, 16);
|
||||
const clientCipher = new RA2Cipher();
|
||||
await clientCipher.setKey(clientSessionKey);
|
||||
const serverCipher = new RA2Cipher();
|
||||
await serverCipher.setKey(serverSessionKey);
|
||||
|
||||
// 6: Compute and exchange hashes
|
||||
let serverHash = new Uint8Array(8 + serverKeyBytes * 2 + clientKeyBytes * 2);
|
||||
let clientHash = new Uint8Array(8 + serverKeyBytes * 2 + clientKeyBytes * 2);
|
||||
serverHash.set(serverPublickey);
|
||||
serverHash.set(clientPublicKey, 4 + serverKeyBytes * 2);
|
||||
clientHash.set(clientPublicKey);
|
||||
clientHash.set(serverPublickey, 4 + clientKeyBytes * 2);
|
||||
serverHash = await window.crypto.subtle.digest("SHA-1", serverHash);
|
||||
clientHash = await window.crypto.subtle.digest("SHA-1", clientHash);
|
||||
serverHash = new Uint8Array(serverHash);
|
||||
clientHash = new Uint8Array(clientHash);
|
||||
this._sock.sQpushBytes(await clientCipher.makeMessage(clientHash));
|
||||
this._sock.flush();
|
||||
await this._waitSockAsync(2 + 20 + 16);
|
||||
if (this._sock.rQshift16() !== 20) {
|
||||
throw new Error("RA2: wrong server hash");
|
||||
}
|
||||
const serverHashReceived = await serverCipher.receiveMessage(
|
||||
20, this._sock.rQshiftBytes(20 + 16));
|
||||
if (serverHashReceived === null) {
|
||||
throw new Error("RA2: failed to authenticate the message");
|
||||
}
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (serverHashReceived[i] !== serverHash[i]) {
|
||||
throw new Error("RA2: wrong server hash");
|
||||
}
|
||||
}
|
||||
|
||||
// 7: Receive subtype
|
||||
await this._waitSockAsync(2 + 1 + 16);
|
||||
if (this._sock.rQshift16() !== 1) {
|
||||
throw new Error("RA2: wrong subtype");
|
||||
}
|
||||
let subtype = (await serverCipher.receiveMessage(
|
||||
1, this._sock.rQshiftBytes(1 + 16)));
|
||||
if (subtype === null) {
|
||||
throw new Error("RA2: failed to authenticate the message");
|
||||
}
|
||||
subtype = subtype[0];
|
||||
let waitCredentials = this._waitCredentialsAsync(subtype);
|
||||
if (subtype === 1) {
|
||||
if (this._getCredentials().username === undefined ||
|
||||
this._getCredentials().password === undefined) {
|
||||
this.dispatchEvent(new CustomEvent(
|
||||
"credentialsrequired",
|
||||
{ detail: { types: ["username", "password"] } }));
|
||||
}
|
||||
} else if (subtype === 2) {
|
||||
if (this._getCredentials().password === undefined) {
|
||||
this.dispatchEvent(new CustomEvent(
|
||||
"credentialsrequired",
|
||||
{ detail: { types: ["password"] } }));
|
||||
}
|
||||
} else {
|
||||
throw new Error("RA2: wrong subtype");
|
||||
}
|
||||
await waitCredentials;
|
||||
let username;
|
||||
if (subtype === 1) {
|
||||
username = encodeUTF8(this._getCredentials().username).slice(0, 255);
|
||||
} else {
|
||||
username = "";
|
||||
}
|
||||
const password = encodeUTF8(this._getCredentials().password).slice(0, 255);
|
||||
const credentials = new Uint8Array(username.length + password.length + 2);
|
||||
credentials[0] = username.length;
|
||||
credentials[username.length + 1] = password.length;
|
||||
for (let i = 0; i < username.length; i++) {
|
||||
credentials[i + 1] = username.charCodeAt(i);
|
||||
}
|
||||
for (let i = 0; i < password.length; i++) {
|
||||
credentials[username.length + 2 + i] = password.charCodeAt(i);
|
||||
}
|
||||
this._sock.sQpushBytes(await clientCipher.makeMessage(credentials));
|
||||
this._sock.flush();
|
||||
}
|
||||
|
||||
get hasStarted() {
|
||||
return this._hasStarted;
|
||||
}
|
||||
|
||||
set hasStarted(s) {
|
||||
this._hasStarted = s;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+233
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
* Browser feature support detection
|
||||
*/
|
||||
|
||||
import * as Log from './logging.js';
|
||||
import Base64 from '../base64.js';
|
||||
|
||||
// Touch detection
|
||||
export let isTouchDevice = ('ontouchstart' in document.documentElement) ||
|
||||
// required for Chrome debugger
|
||||
(document.ontouchstart !== undefined) ||
|
||||
// required for MS Surface
|
||||
(navigator.maxTouchPoints > 0) ||
|
||||
(navigator.msMaxTouchPoints > 0);
|
||||
window.addEventListener('touchstart', function onFirstTouch() {
|
||||
isTouchDevice = true;
|
||||
window.removeEventListener('touchstart', onFirstTouch, false);
|
||||
}, false);
|
||||
|
||||
|
||||
// The goal is to find a certain physical width, the devicePixelRatio
|
||||
// brings us a bit closer but is not optimal.
|
||||
export let dragThreshold = 10 * (window.devicePixelRatio || 1);
|
||||
|
||||
let _supportsCursorURIs = false;
|
||||
|
||||
try {
|
||||
const target = document.createElement('canvas');
|
||||
target.style.cursor = 'url("data:image/x-icon;base64,AAACAAEACAgAAAIAAgA4AQAAFgAAACgAAAAIAAAAEAAAAAEAIAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////AAAAAAAAAAAAAAAAAAAAAA==") 2 2, default';
|
||||
|
||||
if (target.style.cursor.indexOf("url") === 0) {
|
||||
Log.Info("Data URI scheme cursor supported");
|
||||
_supportsCursorURIs = true;
|
||||
} else {
|
||||
Log.Warn("Data URI scheme cursor not supported");
|
||||
}
|
||||
} catch (exc) {
|
||||
Log.Error("Data URI scheme cursor test exception: " + exc);
|
||||
}
|
||||
|
||||
export const supportsCursorURIs = _supportsCursorURIs;
|
||||
|
||||
let _hasScrollbarGutter = true;
|
||||
try {
|
||||
// Create invisible container
|
||||
const container = document.createElement('div');
|
||||
container.style.visibility = 'hidden';
|
||||
container.style.overflow = 'scroll'; // forcing scrollbars
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Create a div and place it in the container
|
||||
const child = document.createElement('div');
|
||||
container.appendChild(child);
|
||||
|
||||
// Calculate the difference between the container's full width
|
||||
// and the child's width - the difference is the scrollbars
|
||||
const scrollbarWidth = (container.offsetWidth - child.offsetWidth);
|
||||
|
||||
// Clean up
|
||||
container.parentNode.removeChild(container);
|
||||
|
||||
_hasScrollbarGutter = scrollbarWidth != 0;
|
||||
} catch (exc) {
|
||||
Log.Error("Scrollbar test exception: " + exc);
|
||||
}
|
||||
export const hasScrollbarGutter = _hasScrollbarGutter;
|
||||
|
||||
export let supportsWebCodecsH264Decode = false;
|
||||
|
||||
async function _checkWebCodecsH264DecodeSupport() {
|
||||
if (!('VideoDecoder' in window)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We'll need to make do with some placeholders here
|
||||
const config = {
|
||||
codec: 'avc1.42401f',
|
||||
codedWidth: 1920,
|
||||
codedHeight: 1080,
|
||||
optimizeForLatency: true,
|
||||
};
|
||||
|
||||
let support = await VideoDecoder.isConfigSupported(config);
|
||||
if (!support.supported) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Firefox incorrectly reports supports for H.264 under some
|
||||
// circumstances, so we need to actually test a real frame
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1932392
|
||||
|
||||
const data = new Uint8Array(Base64.decode(
|
||||
'AAAAAWdCwBTZnpuAgICgAAADACAAAAZB4oVNAAAAAWjJYyyAAAABBgX//4Hc' +
|
||||
'Rem95tlIt5Ys2CDZI+7veDI2NCAtIGNvcmUgMTY0IHIzMTA4IDMxZTE5Zjkg' +
|
||||
'LSBILjI2NC9NUEVHLTQgQVZDIGNvZGVjIC0gQ29weWxlZnQgMjAwMy0yMDIz' +
|
||||
'IC0gaHR0cDovL3d3dy52aWRlb2xhbi5vcmcveDI2NC5odG1sIC0gb3B0aW9u' +
|
||||
'czogY2FiYWM9MCByZWY9NSBkZWJsb2NrPTE6MDowIGFuYWx5c2U9MHgxOjB4' +
|
||||
'MTExIG1lPWhleCBzdWJtZT04IHBzeT0xIHBzeV9yZD0xLjAwOjAuMDAgbWl4' +
|
||||
'ZWRfcmVmPTEgbWVfcmFuZ2U9MTYgY2hyb21hX21lPTEgdHJlbGxpcz0yIDh4' +
|
||||
'OGRjdD0wIGNxbT0wIGRlYWR6b25lPTIxLDExIGZhc3RfcHNraXA9MSBjaHJv' +
|
||||
'bWFfcXBfb2Zmc2V0PS0yIHRocmVhZHM9MSBsb29rYWhlYWRfdGhyZWFkcz0x' +
|
||||
'IHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9' +
|
||||
'MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVz' +
|
||||
'PTAgd2VpZ2h0cD0wIGtleWludD1pbmZpbml0ZSBrZXlpbnRfbWluPTI1IHNj' +
|
||||
'ZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NTAgcmM9' +
|
||||
'YWJyIG1idHJlZT0xIGJpdHJhdGU9NDAwIHJhdGV0b2w9MS4wIHFjb21wPTAu' +
|
||||
'NjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFx' +
|
||||
'PTE6MS4wMACAAAABZYiEBrxmKAAPVccAAS044AA5DRJMnkycJk4TPw=='));
|
||||
|
||||
let gotframe = false;
|
||||
let error = null;
|
||||
|
||||
let decoder = new VideoDecoder({
|
||||
output: (frame) => { gotframe = true; },
|
||||
error: (e) => { error = e; },
|
||||
});
|
||||
let chunk = new EncodedVideoChunk({
|
||||
timestamp: 0,
|
||||
type: 'key',
|
||||
data: data,
|
||||
});
|
||||
|
||||
decoder.configure(config);
|
||||
decoder.decode(chunk);
|
||||
try {
|
||||
await decoder.flush();
|
||||
} catch (e) {
|
||||
// Firefox incorrectly throws an exception here
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1932566
|
||||
error = e;
|
||||
}
|
||||
|
||||
// Firefox fails to deliver the error on Windows, so we need to
|
||||
// check if we got a frame instead
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1932579
|
||||
if (!gotframe) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
supportsWebCodecsH264Decode = await _checkWebCodecsH264DecodeSupport();
|
||||
|
||||
/*
|
||||
* The functions for detection of platforms and browsers below are exported
|
||||
* but the use of these should be minimized as much as possible.
|
||||
*
|
||||
* It's better to use feature detection than platform detection.
|
||||
*/
|
||||
|
||||
/* OS */
|
||||
|
||||
export function isMac() {
|
||||
return !!(/mac/i).exec(navigator.platform);
|
||||
}
|
||||
|
||||
export function isWindows() {
|
||||
return !!(/win/i).exec(navigator.platform);
|
||||
}
|
||||
|
||||
export function isIOS() {
|
||||
return (!!(/ipad/i).exec(navigator.platform) ||
|
||||
!!(/iphone/i).exec(navigator.platform) ||
|
||||
!!(/ipod/i).exec(navigator.platform));
|
||||
}
|
||||
|
||||
export function isAndroid() {
|
||||
/* Android sets navigator.platform to Linux :/ */
|
||||
return !!navigator.userAgent.match('Android ');
|
||||
}
|
||||
|
||||
export function isChromeOS() {
|
||||
/* ChromeOS sets navigator.platform to Linux :/ */
|
||||
return !!navigator.userAgent.match(' CrOS ');
|
||||
}
|
||||
|
||||
/* Browser */
|
||||
|
||||
export function isSafari() {
|
||||
return !!navigator.userAgent.match('Safari/...') &&
|
||||
!navigator.userAgent.match('Chrome/...') &&
|
||||
!navigator.userAgent.match('Chromium/...') &&
|
||||
!navigator.userAgent.match('Epiphany/...');
|
||||
}
|
||||
|
||||
export function isFirefox() {
|
||||
return !!navigator.userAgent.match('Firefox/...') &&
|
||||
!navigator.userAgent.match('Seamonkey/...');
|
||||
}
|
||||
|
||||
export function isChrome() {
|
||||
return !!navigator.userAgent.match('Chrome/...') &&
|
||||
!navigator.userAgent.match('Chromium/...') &&
|
||||
!navigator.userAgent.match('Edg/...') &&
|
||||
!navigator.userAgent.match('OPR/...');
|
||||
}
|
||||
|
||||
export function isChromium() {
|
||||
return !!navigator.userAgent.match('Chromium/...');
|
||||
}
|
||||
|
||||
export function isOpera() {
|
||||
return !!navigator.userAgent.match('OPR/...');
|
||||
}
|
||||
|
||||
export function isEdge() {
|
||||
return !!navigator.userAgent.match('Edg/...');
|
||||
}
|
||||
|
||||
/* Engine */
|
||||
|
||||
export function isGecko() {
|
||||
return !!navigator.userAgent.match('Gecko/...');
|
||||
}
|
||||
|
||||
export function isWebKit() {
|
||||
return !!navigator.userAgent.match('AppleWebKit/...') &&
|
||||
!navigator.userAgent.match('Chrome/...');
|
||||
}
|
||||
|
||||
export function isBlink() {
|
||||
return !!navigator.userAgent.match('Chrome/...');
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 or any later version (see LICENSE.txt)
|
||||
*/
|
||||
|
||||
import { supportsCursorURIs, isTouchDevice } from './browser.js';
|
||||
|
||||
const useFallback = !supportsCursorURIs || isTouchDevice;
|
||||
|
||||
export default class Cursor {
|
||||
constructor() {
|
||||
this._target = null;
|
||||
|
||||
this._canvas = document.createElement('canvas');
|
||||
|
||||
if (useFallback) {
|
||||
this._canvas.style.position = 'fixed';
|
||||
this._canvas.style.zIndex = '65535';
|
||||
this._canvas.style.pointerEvents = 'none';
|
||||
// Safari on iOS can select the cursor image
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=249223
|
||||
this._canvas.style.userSelect = 'none';
|
||||
this._canvas.style.WebkitUserSelect = 'none';
|
||||
// Can't use "display" because of Firefox bug #1445997
|
||||
this._canvas.style.visibility = 'hidden';
|
||||
}
|
||||
|
||||
this._position = { x: 0, y: 0 };
|
||||
this._hotSpot = { x: 0, y: 0 };
|
||||
|
||||
this._eventHandlers = {
|
||||
'mouseover': this._handleMouseOver.bind(this),
|
||||
'mouseleave': this._handleMouseLeave.bind(this),
|
||||
'mousemove': this._handleMouseMove.bind(this),
|
||||
'mouseup': this._handleMouseUp.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
attach(target) {
|
||||
if (this._target) {
|
||||
this.detach();
|
||||
}
|
||||
|
||||
this._target = target;
|
||||
|
||||
if (useFallback) {
|
||||
document.body.appendChild(this._canvas);
|
||||
|
||||
const options = { capture: true, passive: true };
|
||||
this._target.addEventListener('mouseover', this._eventHandlers.mouseover, options);
|
||||
this._target.addEventListener('mouseleave', this._eventHandlers.mouseleave, options);
|
||||
this._target.addEventListener('mousemove', this._eventHandlers.mousemove, options);
|
||||
this._target.addEventListener('mouseup', this._eventHandlers.mouseup, options);
|
||||
}
|
||||
|
||||
this.clear();
|
||||
}
|
||||
|
||||
detach() {
|
||||
if (!this._target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (useFallback) {
|
||||
const options = { capture: true, passive: true };
|
||||
this._target.removeEventListener('mouseover', this._eventHandlers.mouseover, options);
|
||||
this._target.removeEventListener('mouseleave', this._eventHandlers.mouseleave, options);
|
||||
this._target.removeEventListener('mousemove', this._eventHandlers.mousemove, options);
|
||||
this._target.removeEventListener('mouseup', this._eventHandlers.mouseup, options);
|
||||
|
||||
if (document.contains(this._canvas)) {
|
||||
document.body.removeChild(this._canvas);
|
||||
}
|
||||
}
|
||||
|
||||
this._target = null;
|
||||
}
|
||||
|
||||
change(rgba, hotx, hoty, w, h) {
|
||||
if ((w === 0) || (h === 0)) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this._position.x = this._position.x + this._hotSpot.x - hotx;
|
||||
this._position.y = this._position.y + this._hotSpot.y - hoty;
|
||||
this._hotSpot.x = hotx;
|
||||
this._hotSpot.y = hoty;
|
||||
|
||||
let ctx = this._canvas.getContext('2d');
|
||||
|
||||
this._canvas.width = w;
|
||||
this._canvas.height = h;
|
||||
|
||||
let img = new ImageData(new Uint8ClampedArray(rgba), w, h);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.putImageData(img, 0, 0);
|
||||
|
||||
if (useFallback) {
|
||||
this._updatePosition();
|
||||
} else {
|
||||
let url = this._canvas.toDataURL();
|
||||
this._target.style.cursor = 'url(' + url + ')' + hotx + ' ' + hoty + ', default';
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._target.style.cursor = 'none';
|
||||
this._canvas.width = 0;
|
||||
this._canvas.height = 0;
|
||||
this._position.x = this._position.x + this._hotSpot.x;
|
||||
this._position.y = this._position.y + this._hotSpot.y;
|
||||
this._hotSpot.x = 0;
|
||||
this._hotSpot.y = 0;
|
||||
}
|
||||
|
||||
// Mouse events might be emulated, this allows
|
||||
// moving the cursor in such cases
|
||||
move(clientX, clientY) {
|
||||
if (!useFallback) {
|
||||
return;
|
||||
}
|
||||
// clientX/clientY are relative the _visual viewport_,
|
||||
// but our position is relative the _layout viewport_,
|
||||
// so try to compensate when we can
|
||||
if (window.visualViewport) {
|
||||
this._position.x = clientX + window.visualViewport.offsetLeft;
|
||||
this._position.y = clientY + window.visualViewport.offsetTop;
|
||||
} else {
|
||||
this._position.x = clientX;
|
||||
this._position.y = clientY;
|
||||
}
|
||||
this._updatePosition();
|
||||
let target = document.elementFromPoint(clientX, clientY);
|
||||
this._updateVisibility(target);
|
||||
}
|
||||
|
||||
_handleMouseOver(event) {
|
||||
// This event could be because we're entering the target, or
|
||||
// moving around amongst its sub elements. Let the move handler
|
||||
// sort things out.
|
||||
this._handleMouseMove(event);
|
||||
}
|
||||
|
||||
_handleMouseLeave(event) {
|
||||
// Check if we should show the cursor on the element we are leaving to
|
||||
this._updateVisibility(event.relatedTarget);
|
||||
}
|
||||
|
||||
_handleMouseMove(event) {
|
||||
this._updateVisibility(event.target);
|
||||
|
||||
this._position.x = event.clientX - this._hotSpot.x;
|
||||
this._position.y = event.clientY - this._hotSpot.y;
|
||||
|
||||
this._updatePosition();
|
||||
}
|
||||
|
||||
_handleMouseUp(event) {
|
||||
// We might get this event because of a drag operation that
|
||||
// moved outside of the target. Check what's under the cursor
|
||||
// now and adjust visibility based on that.
|
||||
let target = document.elementFromPoint(event.clientX, event.clientY);
|
||||
this._updateVisibility(target);
|
||||
|
||||
// Captures end with a mouseup but we can't know the event order of
|
||||
// mouseup vs releaseCapture.
|
||||
//
|
||||
// In the cases when releaseCapture comes first, the code above is
|
||||
// enough.
|
||||
//
|
||||
// In the cases when the mouseup comes first, we need wait for the
|
||||
// browser to flush all events and then check again if the cursor
|
||||
// should be visible.
|
||||
if (this._captureIsActive()) {
|
||||
window.setTimeout(() => {
|
||||
// We might have detached at this point
|
||||
if (!this._target) {
|
||||
return;
|
||||
}
|
||||
// Refresh the target from elementFromPoint since queued events
|
||||
// might have altered the DOM
|
||||
target = document.elementFromPoint(event.clientX,
|
||||
event.clientY);
|
||||
this._updateVisibility(target);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
_showCursor() {
|
||||
if (this._canvas.style.visibility === 'hidden') {
|
||||
this._canvas.style.visibility = '';
|
||||
}
|
||||
}
|
||||
|
||||
_hideCursor() {
|
||||
if (this._canvas.style.visibility !== 'hidden') {
|
||||
this._canvas.style.visibility = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
// Should we currently display the cursor?
|
||||
// (i.e. are we over the target, or a child of the target without a
|
||||
// different cursor set)
|
||||
_shouldShowCursor(target) {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
// Easy case
|
||||
if (target === this._target) {
|
||||
return true;
|
||||
}
|
||||
// Other part of the DOM?
|
||||
if (!this._target.contains(target)) {
|
||||
return false;
|
||||
}
|
||||
// Has the child its own cursor?
|
||||
// FIXME: How can we tell that a sub element has an
|
||||
// explicit "cursor: none;"?
|
||||
if (window.getComputedStyle(target).cursor !== 'none') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_updateVisibility(target) {
|
||||
// When the cursor target has capture we want to show the cursor.
|
||||
// So, if a capture is active - look at the captured element instead.
|
||||
if (this._captureIsActive()) {
|
||||
target = document.captureElement;
|
||||
}
|
||||
if (this._shouldShowCursor(target)) {
|
||||
this._showCursor();
|
||||
} else {
|
||||
this._hideCursor();
|
||||
}
|
||||
}
|
||||
|
||||
_updatePosition() {
|
||||
this._canvas.style.left = this._position.x + "px";
|
||||
this._canvas.style.top = this._position.y + "px";
|
||||
}
|
||||
|
||||
_captureIsActive() {
|
||||
return document.captureElement &&
|
||||
document.documentElement.contains(document.captureElement);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2020 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
/*
|
||||
* HTML element utility functions
|
||||
*/
|
||||
|
||||
export function clientToElement(x, y, elem) {
|
||||
const bounds = elem.getBoundingClientRect();
|
||||
let pos = { x: 0, y: 0 };
|
||||
// Clip to target bounds
|
||||
if (x < bounds.left) {
|
||||
pos.x = 0;
|
||||
} else if (x >= bounds.right) {
|
||||
pos.x = bounds.width - 1;
|
||||
} else {
|
||||
pos.x = x - bounds.left;
|
||||
}
|
||||
if (y < bounds.top) {
|
||||
pos.y = 0;
|
||||
} else if (y >= bounds.bottom) {
|
||||
pos.y = bounds.height - 1;
|
||||
} else {
|
||||
pos.y = y - bounds.top;
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2018 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Cross-browser event and position routines
|
||||
*/
|
||||
|
||||
export function getPointerEvent(e) {
|
||||
return e.changedTouches ? e.changedTouches[0] : e.touches ? e.touches[0] : e;
|
||||
}
|
||||
|
||||
export function stopEvent(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Emulate Element.setCapture() when not supported
|
||||
let _captureRecursion = false;
|
||||
let _elementForUnflushedEvents = null;
|
||||
document.captureElement = null;
|
||||
function _captureProxy(e) {
|
||||
// Recursion protection as we'll see our own event
|
||||
if (_captureRecursion) return;
|
||||
|
||||
// Clone the event as we cannot dispatch an already dispatched event
|
||||
const newEv = new e.constructor(e.type, e);
|
||||
|
||||
_captureRecursion = true;
|
||||
if (document.captureElement) {
|
||||
document.captureElement.dispatchEvent(newEv);
|
||||
} else {
|
||||
_elementForUnflushedEvents.dispatchEvent(newEv);
|
||||
}
|
||||
_captureRecursion = false;
|
||||
|
||||
// Avoid double events
|
||||
e.stopPropagation();
|
||||
|
||||
// Respect the wishes of the redirected event handlers
|
||||
if (newEv.defaultPrevented) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Implicitly release the capture on button release
|
||||
if (e.type === "mouseup") {
|
||||
releaseCapture();
|
||||
}
|
||||
}
|
||||
|
||||
// Follow cursor style of target element
|
||||
function _capturedElemChanged() {
|
||||
const proxyElem = document.getElementById("noVNC_mouse_capture_elem");
|
||||
proxyElem.style.cursor = window.getComputedStyle(document.captureElement).cursor;
|
||||
}
|
||||
|
||||
const _captureObserver = new MutationObserver(_capturedElemChanged);
|
||||
|
||||
export function setCapture(target) {
|
||||
if (target.setCapture) {
|
||||
|
||||
target.setCapture();
|
||||
document.captureElement = target;
|
||||
} else {
|
||||
// Release any existing capture in case this method is
|
||||
// called multiple times without coordination
|
||||
releaseCapture();
|
||||
|
||||
let proxyElem = document.getElementById("noVNC_mouse_capture_elem");
|
||||
|
||||
if (proxyElem === null) {
|
||||
proxyElem = document.createElement("div");
|
||||
proxyElem.id = "noVNC_mouse_capture_elem";
|
||||
proxyElem.style.position = "fixed";
|
||||
proxyElem.style.top = "0px";
|
||||
proxyElem.style.left = "0px";
|
||||
proxyElem.style.width = "100%";
|
||||
proxyElem.style.height = "100%";
|
||||
proxyElem.style.zIndex = 10000;
|
||||
proxyElem.style.display = "none";
|
||||
document.body.appendChild(proxyElem);
|
||||
|
||||
// This is to make sure callers don't get confused by having
|
||||
// our blocking element as the target
|
||||
proxyElem.addEventListener('contextmenu', _captureProxy);
|
||||
|
||||
proxyElem.addEventListener('mousemove', _captureProxy);
|
||||
proxyElem.addEventListener('mouseup', _captureProxy);
|
||||
}
|
||||
|
||||
document.captureElement = target;
|
||||
|
||||
// Track cursor and get initial cursor
|
||||
_captureObserver.observe(target, {attributes: true});
|
||||
_capturedElemChanged();
|
||||
|
||||
proxyElem.style.display = "";
|
||||
|
||||
// We listen to events on window in order to keep tracking if it
|
||||
// happens to leave the viewport
|
||||
window.addEventListener('mousemove', _captureProxy);
|
||||
window.addEventListener('mouseup', _captureProxy);
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseCapture() {
|
||||
if (document.releaseCapture) {
|
||||
|
||||
document.releaseCapture();
|
||||
document.captureElement = null;
|
||||
|
||||
} else {
|
||||
if (!document.captureElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// There might be events already queued. The event proxy needs
|
||||
// access to the captured element for these queued events.
|
||||
// E.g. contextmenu (right-click) in Microsoft Edge
|
||||
//
|
||||
// Before removing the capturedElem pointer we save it to a
|
||||
// temporary variable that the unflushed events can use.
|
||||
_elementForUnflushedEvents = document.captureElement;
|
||||
document.captureElement = null;
|
||||
|
||||
_captureObserver.disconnect();
|
||||
|
||||
const proxyElem = document.getElementById("noVNC_mouse_capture_elem");
|
||||
proxyElem.style.display = "none";
|
||||
|
||||
window.removeEventListener('mousemove', _captureProxy);
|
||||
window.removeEventListener('mouseup', _captureProxy);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
export default class EventTargetMixin {
|
||||
constructor() {
|
||||
this._listeners = new Map();
|
||||
}
|
||||
|
||||
addEventListener(type, callback) {
|
||||
if (!this._listeners.has(type)) {
|
||||
this._listeners.set(type, new Set());
|
||||
}
|
||||
this._listeners.get(type).add(callback);
|
||||
}
|
||||
|
||||
removeEventListener(type, callback) {
|
||||
if (this._listeners.has(type)) {
|
||||
this._listeners.get(type).delete(callback);
|
||||
}
|
||||
}
|
||||
|
||||
dispatchEvent(event) {
|
||||
if (!this._listeners.has(event.type)) {
|
||||
return true;
|
||||
}
|
||||
this._listeners.get(event.type)
|
||||
.forEach(callback => callback.call(this, event));
|
||||
return !event.defaultPrevented;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2020 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
export function toUnsigned32bit(toConvert) {
|
||||
return toConvert >>> 0;
|
||||
}
|
||||
|
||||
export function toSigned32bit(toConvert) {
|
||||
return toConvert | 0;
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Logging/debug routines
|
||||
*/
|
||||
|
||||
let _logLevel = 'warn';
|
||||
|
||||
let Debug = () => {};
|
||||
let Info = () => {};
|
||||
let Warn = () => {};
|
||||
let Error = () => {};
|
||||
|
||||
export function initLogging(level) {
|
||||
if (typeof level === 'undefined') {
|
||||
level = _logLevel;
|
||||
} else {
|
||||
_logLevel = level;
|
||||
}
|
||||
|
||||
Debug = Info = Warn = Error = () => {};
|
||||
|
||||
if (typeof window.console !== "undefined") {
|
||||
/* eslint-disable no-console, no-fallthrough */
|
||||
switch (level) {
|
||||
case 'debug':
|
||||
Debug = console.debug.bind(window.console);
|
||||
case 'info':
|
||||
Info = console.info.bind(window.console);
|
||||
case 'warn':
|
||||
Warn = console.warn.bind(window.console);
|
||||
case 'error':
|
||||
Error = console.error.bind(window.console);
|
||||
case 'none':
|
||||
break;
|
||||
default:
|
||||
throw new window.Error("invalid logging type '" + level + "'");
|
||||
}
|
||||
/* eslint-enable no-console, no-fallthrough */
|
||||
}
|
||||
}
|
||||
|
||||
export function getLogging() {
|
||||
return _logLevel;
|
||||
}
|
||||
|
||||
export { Debug, Info, Warn, Error };
|
||||
|
||||
// Initialize logging level
|
||||
initLogging();
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
// Decode from UTF-8
|
||||
export function decodeUTF8(utf8string, allowLatin1=false) {
|
||||
try {
|
||||
return decodeURIComponent(escape(utf8string));
|
||||
} catch (e) {
|
||||
if (e instanceof URIError) {
|
||||
if (allowLatin1) {
|
||||
// If we allow Latin1 we can ignore any decoding fails
|
||||
// and in these cases return the original string
|
||||
return utf8string;
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Encode to UTF-8
|
||||
export function encodeUTF8(DOMString) {
|
||||
return unescape(encodeURIComponent(DOMString));
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
/*
|
||||
* Websock: high-performance buffering wrapper
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* Websock is similar to the standard WebSocket / RTCDataChannel object
|
||||
* but with extra buffer handling.
|
||||
*
|
||||
* Websock has built-in receive queue buffering; the message event
|
||||
* does not contain actual data but is simply a notification that
|
||||
* there is new data available. Several rQ* methods are available to
|
||||
* read binary data off of the receive queue.
|
||||
*/
|
||||
|
||||
import * as Log from './util/logging.js';
|
||||
|
||||
// this has performance issues in some versions Chromium, and
|
||||
// doesn't gain a tremendous amount of performance increase in Firefox
|
||||
// at the moment. It may be valuable to turn it on in the future.
|
||||
const MAX_RQ_GROW_SIZE = 40 * 1024 * 1024; // 40 MiB
|
||||
|
||||
// Constants pulled from RTCDataChannelState enum
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/readyState#RTCDataChannelState_enum
|
||||
const DataChannel = {
|
||||
CONNECTING: "connecting",
|
||||
OPEN: "open",
|
||||
CLOSING: "closing",
|
||||
CLOSED: "closed"
|
||||
};
|
||||
|
||||
const ReadyStates = {
|
||||
CONNECTING: [WebSocket.CONNECTING, DataChannel.CONNECTING],
|
||||
OPEN: [WebSocket.OPEN, DataChannel.OPEN],
|
||||
CLOSING: [WebSocket.CLOSING, DataChannel.CLOSING],
|
||||
CLOSED: [WebSocket.CLOSED, DataChannel.CLOSED],
|
||||
};
|
||||
|
||||
// Properties a raw channel must have, WebSocket and RTCDataChannel are two examples
|
||||
const rawChannelProps = [
|
||||
"send",
|
||||
"close",
|
||||
"binaryType",
|
||||
"onerror",
|
||||
"onmessage",
|
||||
"onopen",
|
||||
"protocol",
|
||||
"readyState",
|
||||
];
|
||||
|
||||
export default class Websock {
|
||||
constructor() {
|
||||
this._websocket = null; // WebSocket or RTCDataChannel object
|
||||
|
||||
this._rQi = 0; // Receive queue index
|
||||
this._rQlen = 0; // Next write position in the receive queue
|
||||
this._rQbufferSize = 1024 * 1024 * 4; // Receive queue buffer size (4 MiB)
|
||||
// called in init: this._rQ = new Uint8Array(this._rQbufferSize);
|
||||
this._rQ = null; // Receive queue
|
||||
|
||||
this._sQbufferSize = 1024 * 10; // 10 KiB
|
||||
// called in init: this._sQ = new Uint8Array(this._sQbufferSize);
|
||||
this._sQlen = 0;
|
||||
this._sQ = null; // Send queue
|
||||
|
||||
this._eventHandlers = {
|
||||
message: () => {},
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
error: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
get readyState() {
|
||||
let subState;
|
||||
|
||||
if (this._websocket === null) {
|
||||
return "unused";
|
||||
}
|
||||
|
||||
subState = this._websocket.readyState;
|
||||
|
||||
if (ReadyStates.CONNECTING.includes(subState)) {
|
||||
return "connecting";
|
||||
} else if (ReadyStates.OPEN.includes(subState)) {
|
||||
return "open";
|
||||
} else if (ReadyStates.CLOSING.includes(subState)) {
|
||||
return "closing";
|
||||
} else if (ReadyStates.CLOSED.includes(subState)) {
|
||||
return "closed";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// Receive queue
|
||||
rQpeek8() {
|
||||
return this._rQ[this._rQi];
|
||||
}
|
||||
|
||||
rQskipBytes(bytes) {
|
||||
this._rQi += bytes;
|
||||
}
|
||||
|
||||
rQshift8() {
|
||||
return this._rQshift(1);
|
||||
}
|
||||
|
||||
rQshift16() {
|
||||
return this._rQshift(2);
|
||||
}
|
||||
|
||||
rQshift32() {
|
||||
return this._rQshift(4);
|
||||
}
|
||||
|
||||
// TODO(directxman12): test performance with these vs a DataView
|
||||
_rQshift(bytes) {
|
||||
let res = 0;
|
||||
for (let byte = bytes - 1; byte >= 0; byte--) {
|
||||
res += this._rQ[this._rQi++] << (byte * 8);
|
||||
}
|
||||
return res >>> 0;
|
||||
}
|
||||
|
||||
rQshiftStr(len) {
|
||||
let str = "";
|
||||
// Handle large arrays in steps to avoid long strings on the stack
|
||||
for (let i = 0; i < len; i += 4096) {
|
||||
let part = this.rQshiftBytes(Math.min(4096, len - i), false);
|
||||
str += String.fromCharCode.apply(null, part);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
rQshiftBytes(len, copy=true) {
|
||||
this._rQi += len;
|
||||
if (copy) {
|
||||
return this._rQ.slice(this._rQi - len, this._rQi);
|
||||
} else {
|
||||
return this._rQ.subarray(this._rQi - len, this._rQi);
|
||||
}
|
||||
}
|
||||
|
||||
rQshiftTo(target, len) {
|
||||
// TODO: make this just use set with views when using a ArrayBuffer to store the rQ
|
||||
target.set(new Uint8Array(this._rQ.buffer, this._rQi, len));
|
||||
this._rQi += len;
|
||||
}
|
||||
|
||||
rQpeekBytes(len, copy=true) {
|
||||
if (copy) {
|
||||
return this._rQ.slice(this._rQi, this._rQi + len);
|
||||
} else {
|
||||
return this._rQ.subarray(this._rQi, this._rQi + len);
|
||||
}
|
||||
}
|
||||
|
||||
// Check to see if we must wait for 'num' bytes (default to FBU.bytes)
|
||||
// to be available in the receive queue. Return true if we need to
|
||||
// wait (and possibly print a debug message), otherwise false.
|
||||
rQwait(msg, num, goback) {
|
||||
if (this._rQlen - this._rQi < num) {
|
||||
if (goback) {
|
||||
if (this._rQi < goback) {
|
||||
throw new Error("rQwait cannot backup " + goback + " bytes");
|
||||
}
|
||||
this._rQi -= goback;
|
||||
}
|
||||
return true; // true means need more data
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Send queue
|
||||
|
||||
sQpush8(num) {
|
||||
this._sQensureSpace(1);
|
||||
this._sQ[this._sQlen++] = num;
|
||||
}
|
||||
|
||||
sQpush16(num) {
|
||||
this._sQensureSpace(2);
|
||||
this._sQ[this._sQlen++] = (num >> 8) & 0xff;
|
||||
this._sQ[this._sQlen++] = (num >> 0) & 0xff;
|
||||
}
|
||||
|
||||
sQpush32(num) {
|
||||
this._sQensureSpace(4);
|
||||
this._sQ[this._sQlen++] = (num >> 24) & 0xff;
|
||||
this._sQ[this._sQlen++] = (num >> 16) & 0xff;
|
||||
this._sQ[this._sQlen++] = (num >> 8) & 0xff;
|
||||
this._sQ[this._sQlen++] = (num >> 0) & 0xff;
|
||||
}
|
||||
|
||||
sQpushString(str) {
|
||||
let bytes = str.split('').map(chr => chr.charCodeAt(0));
|
||||
this.sQpushBytes(new Uint8Array(bytes));
|
||||
}
|
||||
|
||||
sQpushBytes(bytes) {
|
||||
for (let offset = 0;offset < bytes.length;) {
|
||||
this._sQensureSpace(1);
|
||||
|
||||
let chunkSize = this._sQbufferSize - this._sQlen;
|
||||
if (chunkSize > bytes.length - offset) {
|
||||
chunkSize = bytes.length - offset;
|
||||
}
|
||||
|
||||
this._sQ.set(bytes.subarray(offset, offset + chunkSize), this._sQlen);
|
||||
this._sQlen += chunkSize;
|
||||
offset += chunkSize;
|
||||
}
|
||||
}
|
||||
|
||||
flush() {
|
||||
if (this._sQlen > 0 && this.readyState === 'open') {
|
||||
this._websocket.send(new Uint8Array(this._sQ.buffer, 0, this._sQlen));
|
||||
this._sQlen = 0;
|
||||
}
|
||||
}
|
||||
|
||||
_sQensureSpace(bytes) {
|
||||
if (this._sQbufferSize - this._sQlen < bytes) {
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
off(evt) {
|
||||
this._eventHandlers[evt] = () => {};
|
||||
}
|
||||
|
||||
on(evt, handler) {
|
||||
this._eventHandlers[evt] = handler;
|
||||
}
|
||||
|
||||
_allocateBuffers() {
|
||||
this._rQ = new Uint8Array(this._rQbufferSize);
|
||||
this._sQ = new Uint8Array(this._sQbufferSize);
|
||||
}
|
||||
|
||||
init() {
|
||||
this._allocateBuffers();
|
||||
this._rQi = 0;
|
||||
this._websocket = null;
|
||||
}
|
||||
|
||||
open(uri, protocols) {
|
||||
this.attach(new WebSocket(uri, protocols));
|
||||
}
|
||||
|
||||
attach(rawChannel) {
|
||||
this.init();
|
||||
|
||||
// Must get object and class methods to be compatible with the tests.
|
||||
const channelProps = [...Object.keys(rawChannel), ...Object.getOwnPropertyNames(Object.getPrototypeOf(rawChannel))];
|
||||
for (let i = 0; i < rawChannelProps.length; i++) {
|
||||
const prop = rawChannelProps[i];
|
||||
if (channelProps.indexOf(prop) < 0) {
|
||||
throw new Error('Raw channel missing property: ' + prop);
|
||||
}
|
||||
}
|
||||
|
||||
this._websocket = rawChannel;
|
||||
this._websocket.binaryType = "arraybuffer";
|
||||
this._websocket.onmessage = this._recvMessage.bind(this);
|
||||
|
||||
this._websocket.onopen = () => {
|
||||
Log.Debug('>> WebSock.onopen');
|
||||
if (this._websocket.protocol) {
|
||||
Log.Info("Server choose sub-protocol: " + this._websocket.protocol);
|
||||
}
|
||||
|
||||
this._eventHandlers.open();
|
||||
Log.Debug("<< WebSock.onopen");
|
||||
};
|
||||
|
||||
this._websocket.onclose = (e) => {
|
||||
Log.Debug(">> WebSock.onclose");
|
||||
this._eventHandlers.close(e);
|
||||
Log.Debug("<< WebSock.onclose");
|
||||
};
|
||||
|
||||
this._websocket.onerror = (e) => {
|
||||
Log.Debug(">> WebSock.onerror: " + e);
|
||||
this._eventHandlers.error(e);
|
||||
Log.Debug("<< WebSock.onerror: " + e);
|
||||
};
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this._websocket) {
|
||||
if (this.readyState === 'connecting' ||
|
||||
this.readyState === 'open') {
|
||||
Log.Info("Closing WebSocket connection");
|
||||
this._websocket.close();
|
||||
}
|
||||
|
||||
this._websocket.onmessage = () => {};
|
||||
}
|
||||
}
|
||||
|
||||
// private methods
|
||||
|
||||
// We want to move all the unread data to the start of the queue,
|
||||
// e.g. compacting.
|
||||
// The function also expands the receive que if needed, and for
|
||||
// performance reasons we combine these two actions to avoid
|
||||
// unnecessary copying.
|
||||
_expandCompactRQ(minFit) {
|
||||
// if we're using less than 1/8th of the buffer even with the incoming bytes, compact in place
|
||||
// instead of resizing
|
||||
const requiredBufferSize = (this._rQlen - this._rQi + minFit) * 8;
|
||||
const resizeNeeded = this._rQbufferSize < requiredBufferSize;
|
||||
|
||||
if (resizeNeeded) {
|
||||
// Make sure we always *at least* double the buffer size, and have at least space for 8x
|
||||
// the current amount of data
|
||||
this._rQbufferSize = Math.max(this._rQbufferSize * 2, requiredBufferSize);
|
||||
}
|
||||
|
||||
// we don't want to grow unboundedly
|
||||
if (this._rQbufferSize > MAX_RQ_GROW_SIZE) {
|
||||
this._rQbufferSize = MAX_RQ_GROW_SIZE;
|
||||
if (this._rQbufferSize - (this._rQlen - this._rQi) < minFit) {
|
||||
throw new Error("Receive queue buffer exceeded " + MAX_RQ_GROW_SIZE + " bytes, and the new message could not fit");
|
||||
}
|
||||
}
|
||||
|
||||
if (resizeNeeded) {
|
||||
const oldRQbuffer = this._rQ.buffer;
|
||||
this._rQ = new Uint8Array(this._rQbufferSize);
|
||||
this._rQ.set(new Uint8Array(oldRQbuffer, this._rQi, this._rQlen - this._rQi));
|
||||
} else {
|
||||
this._rQ.copyWithin(0, this._rQi, this._rQlen);
|
||||
}
|
||||
|
||||
this._rQlen = this._rQlen - this._rQi;
|
||||
this._rQi = 0;
|
||||
}
|
||||
|
||||
// push arraybuffer values onto the end of the receive que
|
||||
_recvMessage(e) {
|
||||
if (this._rQlen == this._rQi) {
|
||||
// All data has now been processed, this means we
|
||||
// can reset the receive queue.
|
||||
this._rQlen = 0;
|
||||
this._rQi = 0;
|
||||
}
|
||||
const u8 = new Uint8Array(e.data);
|
||||
if (u8.length > this._rQbufferSize - this._rQlen) {
|
||||
this._expandCompactRQ(u8.length);
|
||||
}
|
||||
this._rQ.set(u8, this._rQlen);
|
||||
this._rQlen += u8.length;
|
||||
|
||||
if (this._rQlen - this._rQi > 0) {
|
||||
this._eventHandlers.message();
|
||||
} else {
|
||||
Log.Debug("Ignoring empty message");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
(The MIT License)
|
||||
|
||||
Copyright (C) 2014-2016 by Vitaly Puzrin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
This is an ES6-modules-compatible version of
|
||||
https://github.com/nodeca/pako, based on pako version 1.0.3.
|
||||
|
||||
It's more-or-less a direct translation of the original, with unused parts
|
||||
removed, and the dynamic support for non-typed arrays removed (since ES6
|
||||
modules don't work well with dynamic exports).
|
||||
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
// reduce buffer size, avoiding mem copy
|
||||
export function shrinkBuf (buf, size) {
|
||||
if (buf.length === size) { return buf; }
|
||||
if (buf.subarray) { return buf.subarray(0, size); }
|
||||
buf.length = size;
|
||||
return buf;
|
||||
};
|
||||
|
||||
|
||||
export function arraySet (dest, src, src_offs, len, dest_offs) {
|
||||
if (src.subarray && dest.subarray) {
|
||||
dest.set(src.subarray(src_offs, src_offs + len), dest_offs);
|
||||
return;
|
||||
}
|
||||
// Fallback to ordinary array
|
||||
for (var i = 0; i < len; i++) {
|
||||
dest[dest_offs + i] = src[src_offs + i];
|
||||
}
|
||||
}
|
||||
|
||||
// Join array of chunks to single array.
|
||||
export function flattenChunks (chunks) {
|
||||
var i, l, len, pos, chunk, result;
|
||||
|
||||
// calculate data length
|
||||
len = 0;
|
||||
for (i = 0, l = chunks.length; i < l; i++) {
|
||||
len += chunks[i].length;
|
||||
}
|
||||
|
||||
// join chunks
|
||||
result = new Uint8Array(len);
|
||||
pos = 0;
|
||||
for (i = 0, l = chunks.length; i < l; i++) {
|
||||
chunk = chunks[i];
|
||||
result.set(chunk, pos);
|
||||
pos += chunk.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export var Buf8 = Uint8Array;
|
||||
export var Buf16 = Uint16Array;
|
||||
export var Buf32 = Int32Array;
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
// Note: adler32 takes 12% for level 0 and 2% for level 6.
|
||||
// It doesn't worth to make additional optimizationa as in original.
|
||||
// Small size is preferable.
|
||||
|
||||
export default function adler32(adler, buf, len, pos) {
|
||||
var s1 = (adler & 0xffff) |0,
|
||||
s2 = ((adler >>> 16) & 0xffff) |0,
|
||||
n = 0;
|
||||
|
||||
while (len !== 0) {
|
||||
// Set limit ~ twice less than 5552, to keep
|
||||
// s2 in 31-bits, because we force signed ints.
|
||||
// in other case %= will fail.
|
||||
n = len > 2000 ? 2000 : len;
|
||||
len -= n;
|
||||
|
||||
do {
|
||||
s1 = (s1 + buf[pos++]) |0;
|
||||
s2 = (s2 + s1) |0;
|
||||
} while (--n);
|
||||
|
||||
s1 %= 65521;
|
||||
s2 %= 65521;
|
||||
}
|
||||
|
||||
return (s1 | (s2 << 16)) |0;
|
||||
}
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user