Compare commits

...
18 Commits
Author SHA1 Message Date
alientim 491724beb5 Version 2.8.17: keine Vorauswahl, keine Messung im Seitenaufbau 2026-09-13 13:07:05 +02:00
alientim b54b87ca6f Version 2.8.16: die Ueberwachung sperrt sich nicht mehr selbst aus 2026-09-13 12:22:31 +02:00
alientim 270ba455a8 Version 2.8.15: Probe der Strecke, Grund aus _fail, RFB 3.3 2026-09-13 12:04:50 +02:00
alientim f3b312046f Version 2.8.14: Diagnose in der Bildschirmsitzung, Vorauswahl ohne Passwort 2026-09-13 11:31:41 +02:00
alientim 436b080deb Version 2.8.13: Bildschirmsitzung im Browser (noVNC) 2026-09-13 10:35:12 +02:00
alientim 85c40ccfbd Version 2.8.12 / tesm-core 2.8.4 2026-09-13 10:10:30 +02:00
alientim 0ac651a1b9 Version 2.8.11: die Kachel oeffnet den Bildschirm 2026-09-13 10:09:54 +02:00
alientim 4275c84a6c Version 2.8.10: der Name gilt nur, wenn ihn jemand aufloesen kann 2026-09-13 09:55:36 +02:00
alientim 4f4310bfe4 Version 2.8.9: VNC-Zugang, wo ein VNC-Server antwortet 2026-09-13 09:49:27 +02:00
alientim 1ad2f3f9a5 Version 2.8.8: der Notausgang holt die Gruppe aus dem Papierkorb 2026-09-13 00:27:02 +02:00
alientim 9d5eac1db2 Version 2.8.7: drei Standardgruppen, Module abschaltbar 2026-09-13 00:19:54 +02:00
alientim 2fd78ac26b Verzeichnisanmeldung: Wiederholversuch, weniger Runden, Zeiten 2026-09-12 23:46:51 +02:00
alientim 86c09d8a0f Version 2.8.5: Prompt hinter Steuerzeichen wird erkannt 2026-09-12 23:25:49 +02:00
alientim 70f6b005a9 Version 2.8.4: Konsole meldet jede Stufe, Befehl switch-test 2026-09-12 21:10:39 +02:00
alientim 57eb7764cc Version 2.8.3: Switch-Sitzung wird abgemeldet, Erfolg wird belegt 2026-09-12 20:26:30 +02:00
alientim 30c2c56656 Verzeichniskonfiguration aus 1.2.8 uebernehmen; Sicherungen setzten Schalter falsch 2026-09-12 19:26:41 +02:00
alientim 292ea622a7 Version 2.8.1: das Upgrade von 1.2.8 nimmt die Freigaben mit 2026-09-12 19:06:52 +02:00
alientim 0d99760d89 Version 2.8.0: Rechtekonzept -- eine Zeile je Funktion, Administrator heisst Verwaltung 2026-09-12 13:27:45 +02:00
132 changed files with 21015 additions and 485 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.7.8"
version = "2.8.17"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+5 -3
View File
@@ -19,7 +19,7 @@ from tesm_core.extension import core
from tesm_core.views import trash as trash_views
from .navigation import NAVIGATION
from .permissions import PERMISSIONS, ROLE_PRESETS
from .permissions import DHCP_PAGE, PERMISSIONS, ROLE_PRESETS
from .schema import TESM_MIGRATIONS
from .settings_spec import TESM_SETTINGS
@@ -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.
@@ -205,7 +207,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
# Timer bis zum naechsten Durchlauf, mit Knopf fuer "sofort pruefen". Der
# Knopf gehoert in die Pille, weil er genau die Angabe daneben zuruecksetzt.
if user.has_any(("devices.view", "monitoring.view")):
if user.has_any(("devices.read", "monitoring.read")):
intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300)
faellig = monitor.next_check_at(verbindung, intervall)
if faellig is not None:
@@ -237,7 +239,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
}
)
if user.has_any(("dhcp.view", "dhcp.edit")) and extension.license is not None:
if user.has_any(DHCP_PAGE) and extension.license is not None:
if extension.license.module_active("dhcp") and extension.sysops is not None:
state = extension.sysops.service_state(kea.SERVICE_UNIT)
if state["installed"] == "ja":
+8 -8
View File
@@ -37,14 +37,14 @@ def _allowed_categories() -> list[transfer.Category]:
for category in transfer.CATEGORIES:
if category.admin_only and not current_user.is_admin:
continue
if category.sensitive and not current_user.has("backup.secrets"):
if category.sensitive and not current_user.has("backup_export.secrets"):
continue
allowed.append(category)
return allowed
@bp.get("/")
@permission_required("backup.view", "backup.export", "backup.execute", mode="any")
@permission_required("backup_export.read", "backup_import.read", mode="any")
def overview():
extension = core()
conn = db()
@@ -72,12 +72,12 @@ def overview():
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
license_hint=license_hint,
can_secrets=current_user.has("backup.secrets"),
can_secrets=current_user.has("backup_export.secrets"),
)
@bp.post("/export")
@permission_required("backup.export")
@permission_required("backup_export.export")
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def export():
extension = core()
@@ -96,7 +96,7 @@ def export():
return redirect(url_for("backup.overview"))
include_secrets = (
request.form.get("include_secrets") == "1" and current_user.has("backup.secrets")
request.form.get("include_secrets") == "1" and current_user.has("backup_export.secrets")
)
passphrase = request.form.get("passphrase") or ""
repeat = request.form.get("passphrase_repeat") or ""
@@ -130,7 +130,7 @@ def export():
@bp.post("/import/preview")
@permission_required("backup.execute")
@permission_required("backup_import.execute")
@reauth_required
def import_preview():
conn = db()
@@ -162,7 +162,7 @@ def import_preview():
@bp.post("/import/apply")
@permission_required("backup.execute")
@permission_required("backup_import.execute")
@reauth_required
def import_apply():
extension = core()
@@ -226,7 +226,7 @@ def import_apply():
@bp.post("/import/discard")
@permission_required("backup.execute")
@permission_required("backup_import.execute")
def import_discard():
token = session.pop(PREVIEW_SESSION_KEY, "")
if token:
+2 -2
View File
@@ -29,7 +29,7 @@ def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str
@bp.get("/")
@permission_required("credentials.view")
@permission_required("credentials.read")
def overview():
conn = db()
return render_template(
@@ -62,7 +62,7 @@ def create():
@bp.post("/<int:credential_id>")
@permission_required("credentials.edit")
@permission_required("credentials.write")
def update(credential_id: int):
try:
with transaction() as conn:
+27 -2
View File
@@ -39,11 +39,21 @@ 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.view")
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")
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] = {
@@ -67,9 +77,24 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
"credential_category": str(row["credential_category"] or ""),
# Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC
# neu startbar sein. Die Kachel zeigt den bevorzugten Weg.
# Mit Rechtefilter: die Kachel zeigt nur Wege, die
# dieser Benutzer auch ausloesen darf.
"restart_methods": restart.available(
row, maintenance_active=_maintenance_active()
row,
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"]),
}
)
tiles.append(entry)
+90 -11
View File
@@ -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")
@@ -60,7 +69,7 @@ def _form_values() -> dict[str, Any]:
@bp.get("/")
@permission_required("devices.view")
@permission_required("devices.read")
def overview():
conn = db()
devices = inventory.list_devices(conn)
@@ -138,7 +147,7 @@ def create():
@bp.post("/<int:device_id>")
@permission_required("devices.edit")
@permission_required("devices.write")
def update(device_id: int):
try:
with transaction() as conn:
@@ -162,7 +171,7 @@ def update(device_id: int):
@bp.post("/<int:device_id>/active")
@permission_required("devices.edit")
@permission_required("devices.write")
def toggle_active(device_id: int):
active = request.form.get("active") == "1"
with transaction() as conn:
@@ -194,7 +203,12 @@ def delete(device_id: int):
@bp.post("/<int:device_id>/restart")
@permission_required("devices.execute")
# Die Seite darf sehen, wer Clients sehen darf; welcher Weg offensteht,
# entscheidet das Recht des Weges -- geprueft in restart.resolve() ueber
# "darf". Ein Weg, den der Benutzer nicht ausloesen darf, existiert fuer ihn
# nicht, und die Antwort ist dieselbe wie bei einem technisch unmoeglichen:
# "dieser Weg steht nicht zur Verfuegung".
@permission_required("devices.read")
def restart_device(device_id: int):
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
@@ -214,9 +228,12 @@ def restart_device(device_id: int):
dict(device),
(request.form.get("method") or "").strip(),
maintenance_active=maintenance_active,
darf=current_user.has,
)
if method is None:
options = restart.available(dict(device), maintenance_active=maintenance_active)
options = restart.available(
dict(device), maintenance_active=maintenance_active, darf=current_user.has
)
if options:
flash(
"Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. "
@@ -326,7 +343,7 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
@bp.get("/<int:device_id>")
@permission_required("devices.view")
@permission_required("devices.read")
def detail(device_id: int):
conn = db()
device = inventory.get_device(conn, device_id)
@@ -352,6 +369,68 @@ 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)
with transaction():
_audit(
conn,
"device.vnc_opened",
str(device["name"]),
f"Verbindungsdatei für {ziel}:{port}",
)
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",
},
)
@@ -361,7 +440,7 @@ def detail(device_id: int):
@bp.post("/<int:device_id>/host-key/probe")
@permission_required("devices.edit")
@permission_required("devices.write")
def probe_host_key(device_id: int):
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden.
@@ -391,7 +470,7 @@ def probe_host_key(device_id: int):
@bp.post("/<int:device_id>/host-key/trust")
@permission_required("devices.edit")
@permission_required("devices.write")
@reauth_required
def trust_host_key(device_id: int):
conn = db()
@@ -425,7 +504,7 @@ def trust_host_key(device_id: int):
@bp.post("/<int:device_id>/host-key/forget")
@permission_required("devices.edit")
@permission_required("devices.write")
def forget_host_key(device_id: int):
conn = db()
device = inventory.get_device(conn, device_id)
+14 -13
View File
@@ -21,6 +21,7 @@ from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from ..services import kea
from ..permissions import DHCP_PAGE
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
@@ -53,7 +54,7 @@ def _settings_values() -> dict[str, Any]:
@bp.get("/")
@permission_required("dhcp.view", "dhcp.edit", "dhcp.execute", mode="any")
@permission_required(*DHCP_PAGE, mode="any")
def overview():
extension = core()
conn = db()
@@ -106,7 +107,7 @@ def overview():
@bp.post("/settings")
@permission_required("dhcp.edit")
@permission_required("dhcp_subnets.write")
def save_settings():
extension = core()
conn = db()
@@ -123,7 +124,7 @@ def save_settings():
@bp.post("/subnet")
@permission_required("dhcp.edit")
@permission_required("dhcp_subnets.create")
def add_subnet():
extension = core()
conn = db()
@@ -159,7 +160,7 @@ def add_subnet():
@bp.post("/subnet/<int:subnet_id>")
@permission_required("dhcp.edit")
@permission_required("dhcp_subnets.write")
def update_subnet(subnet_id: int):
extension = core()
conn = db()
@@ -217,7 +218,7 @@ def update_subnet(subnet_id: int):
@bp.post("/subnet/<int:subnet_id>/delete")
@permission_required("dhcp.edit")
@permission_required("dhcp_subnets.delete")
def delete_subnet(subnet_id: int):
with transaction() as conn:
conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
@@ -232,7 +233,7 @@ def delete_subnet(subnet_id: int):
@bp.post("/option/value")
@permission_required("dhcp.edit")
@permission_required("dhcp_options.write")
def set_option_value():
conn = db()
option_id = int(request.form.get("option_id") or 0)
@@ -271,7 +272,7 @@ def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ign
@bp.post("/option/device")
@permission_required("dhcp.edit")
@permission_required("dhcp_options.write")
def set_device_option_value():
"""Setzt eine Option fuer ein einzelnes Geraet.
@@ -319,7 +320,7 @@ def set_device_option_value():
@bp.post("/option/new")
@permission_required("dhcp.edit")
@permission_required("dhcp_options.write")
def add_custom_option():
conn = db()
try:
@@ -350,7 +351,7 @@ def add_custom_option():
@bp.post("/option/<int:option_id>/delete")
@permission_required("dhcp.edit")
@permission_required("dhcp_options.write")
def delete_custom_option(option_id: int):
conn = db()
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
@@ -375,7 +376,7 @@ def delete_custom_option(option_id: int):
@bp.post("/reservation")
@permission_required("dhcp.edit")
@permission_required("dhcp_reservations.create")
def add_reservation():
conn = db()
mac = kea.normalize_mac(request.form.get("mac", ""))
@@ -447,7 +448,7 @@ def add_reservation():
@bp.post("/reservation/<int:reservation_id>/delete")
@permission_required("dhcp.edit")
@permission_required("dhcp_reservations.delete")
def delete_reservation(reservation_id: int):
with transaction() as conn:
conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,))
@@ -515,13 +516,13 @@ def _write_and_redirect():
@bp.post("/write")
@permission_required("dhcp.edit")
@permission_required("dhcp_config.execute")
def write_config():
return _write_and_redirect()
@bp.post("/service/<action>")
@permission_required("dhcp.execute")
@permission_required("dhcp_service.execute")
def service_action(action: str):
allowed = {"install", "start", "stop"}
if action not in allowed:
+16 -11
View File
@@ -84,7 +84,7 @@ def _mount_or_404(token: str) -> service.Mount:
@bp.get("/")
@permission_required("fileshare.view")
@permission_required("fileshare.read")
def browse():
conn = db()
mounts = service.doppelte_zusammenfassen(
@@ -148,7 +148,7 @@ def download(token: str):
@bp.get("/<token>/preview")
@permission_required("fileshare.view")
@permission_required("fileshare.read")
def preview(token: str):
mount = _mount_or_404(token)
relative = request.args.get("path") or ""
@@ -221,7 +221,7 @@ def upload(token: str):
@bp.get("/<token>/signature")
@permission_required("fileshare.view")
@permission_required("fileshare.read")
def signature_state(token: str):
"""Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll?
@@ -237,7 +237,7 @@ def signature_state(token: str):
@bp.get("/<token>/folders")
@permission_required("fileshare.view")
@permission_required("fileshare.read")
def folders(token: str):
"""Die Ordner **dieser** Freigabe als Vorschlagsliste.
@@ -299,7 +299,7 @@ def save_form(token: str):
# Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es
# "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit
# niemandem weiter.
if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.edit"):
if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.write"):
return {
"error": "Sie dürfen ausgefüllte Formulare nur abgeschlossen speichern "
"(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' wählen."
@@ -351,6 +351,11 @@ def save_form(token: str):
wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix()
pruefsumme = hashlib.sha256(daten).hexdigest()
unterschriften = _unterschriften(request.form.get("unterschriften") or "")
# Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand
# mehr bearbeiten kann, mit Namen im Dokument und Pruefsumme im Protokoll.
# Wer Dateien ablegen darf, darf das nicht automatisch.
if unterschriften and not current_user.has("fileshare_signature.execute"):
return {"error": "Zum Unterschreiben fehlt die Berechtigung."}, 403
# Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide
# da: der Name **in** der Unterschrift ist eine Angabe des
@@ -412,7 +417,7 @@ def make_directory(token: str):
@bp.post("/<token>/rename")
@permission_required("fileshare.edit")
@permission_required("fileshare.write")
def rename(token: str):
mount = _mount_or_404(token)
relative = request.form.get("path") or ""
@@ -446,7 +451,7 @@ def delete(token: str):
@bp.post("/<token>/release")
@permission_required("fileshare.view")
@permission_required("fileshare.read")
def release(token: str):
mount = _mount_or_404(token)
with transaction() as conn:
@@ -462,7 +467,7 @@ def release(token: str):
@bp.get("/mappings")
@permission_required("fileshare.edit")
@permission_required("fileshare_mappings.read")
def mappings():
from tesm_core.auth import directory
@@ -476,7 +481,7 @@ def mappings():
@bp.post("/mappings")
@permission_required("fileshare.edit")
@permission_required("fileshare_mappings.create")
def mapping_add():
try:
with transaction() as conn:
@@ -506,7 +511,7 @@ def mapping_add():
@bp.post("/mappings/<int:mapping_id>")
@permission_required("fileshare.edit")
@permission_required("fileshare_mappings.write")
def mapping_update(mapping_id: int):
try:
with transaction() as conn:
@@ -536,7 +541,7 @@ def mapping_update(mapping_id: int):
@bp.post("/mappings/<int:mapping_id>/delete")
@permission_required("fileshare.edit")
@permission_required("fileshare_mappings.delete")
def mapping_delete(mapping_id: int):
with transaction() as conn:
entfernt = service.get_mapping(conn, mapping_id)
+11 -7
View File
@@ -36,8 +36,12 @@ bp = Blueprint("hostkeys", __name__, url_prefix="/monitoring/host-keys")
KIND_DEVICE = "client"
KIND_SWITCH = "switch"
#: Welches Recht welche Art von Ziel freigibt.
EDIT_RIGHT = {KIND_DEVICE: "devices.edit", KIND_SWITCH: "switches.edit"}
#: Freigeben ist eine eigene Zeile: es war das Recht fuer Stammdaten
#: ("Gerät ändern"), obwohl es der Vorgang ist, mit dem man bestaetigt, dass
#: ein Geraet wirklich das ist, fuer das es sich ausgibt. Die Art des Ziels
#: entscheidet nicht mehr mit -- Schluessel ist Schluessel.
FREIGABE_RECHT = "hostkeys.execute"
EDIT_RIGHT = {KIND_DEVICE: FREIGABE_RECHT, KIND_SWITCH: FREIGABE_RECHT}
def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None:
@@ -112,7 +116,7 @@ def _orphans(conn: Any, targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
@bp.get("/")
@permission_required("devices.view", "switches.view", mode="any")
@permission_required("hostkeys.read")
def overview():
conn = db()
targets = _targets(conn)
@@ -143,7 +147,7 @@ def _require(kind: str) -> None:
@bp.post("/<kind>/<int:target_id>/probe")
@permission_required("devices.view", "switches.view", mode="any")
@permission_required("hostkeys.read")
def probe(kind: str, target_id: int):
conn = db()
_require(kind)
@@ -170,7 +174,7 @@ def probe(kind: str, target_id: int):
@bp.post("/<kind>/<int:target_id>/trust")
@permission_required("devices.view", "switches.view", mode="any")
@permission_required("hostkeys.read")
@reauth_required
def trust(kind: str, target_id: int):
conn = db()
@@ -204,7 +208,7 @@ def trust(kind: str, target_id: int):
@bp.post("/<kind>/<int:target_id>/forget")
@permission_required("devices.view", "switches.view", mode="any")
@permission_required("hostkeys.read")
def forget(kind: str, target_id: int):
conn = db()
_require(kind)
@@ -223,7 +227,7 @@ def forget(kind: str, target_id: int):
@bp.post("/orphaned/forget")
@permission_required("devices.edit", "switches.edit", mode="any")
@permission_required("hostkeys.execute")
def forget_orphan():
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
host = (request.form.get("host") or "").strip()
+7 -7
View File
@@ -43,7 +43,7 @@ def _audit(action: str, target: str = "", detail: str = "", severity: str = "inf
@bp.get("/")
@permission_required("settings_license.view")
@permission_required("settings_license.read")
def overview():
extension = core()
conn = db()
@@ -70,7 +70,7 @@ def overview():
@bp.post("/install")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def upload():
extension = core()
manager = extension.license
@@ -111,13 +111,13 @@ def upload():
@bp.post("/activate")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def activate():
return _handshake("activate")
@bp.post("/deactivate")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
@reauth_required
def deactivate():
"""Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann.
@@ -129,7 +129,7 @@ def deactivate():
@bp.post("/heartbeat")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def heartbeat():
return _handshake("heartbeat")
@@ -159,7 +159,7 @@ def _handshake(action: str):
@bp.post("/offline-response")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def offline_response():
extension = core()
manager = extension.license
@@ -178,7 +178,7 @@ def offline_response():
@bp.post("/server-certificate")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def server_certificate():
"""Hinterlegt das Zertifikat des Lizenzservers.
+2 -2
View File
@@ -19,7 +19,7 @@ MAX_TAIL_LINES = 2000
@bp.get("/dhcp")
@permission_required("logs_dhcp.view")
@permission_required("logs_dhcp.read")
def dhcp():
extension = core()
if extension.license is not None and not extension.license.module_active("dhcp"):
@@ -39,7 +39,7 @@ def dhcp():
@bp.get("/restarts")
@permission_required("devices.view")
@permission_required("devices.read")
def restarts():
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
+8 -7
View File
@@ -23,6 +23,7 @@ from tesm_core.auth.service import client_ip
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from ..permissions import SERVICING_PAGE
from ..services import inventory, maintenance as service, ssh, windowsops, monitor
bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")
@@ -75,7 +76,7 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
@bp.get("/")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
@permission_required(*SERVICING_PAGE, mode="any")
def overview():
conn = db()
return render_template(
@@ -106,7 +107,7 @@ def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def]
@bp.post("/install-package")
@permission_required("maintenance.execute")
@permission_required("servicing_tool.execute")
def install_windows_tool():
"""Installiert das Paket, das der Fehlermeldung nach fehlt.
@@ -139,7 +140,7 @@ def install_windows_tool():
@bp.get("/status")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
@permission_required(*SERVICING_PAGE, mode="any")
def status():
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
conn = db()
@@ -166,7 +167,7 @@ def status():
@bp.get("/job/<job_id>")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
@permission_required(*SERVICING_PAGE, mode="any")
def job_detail(job_id: str):
conn = db()
job = service.get_job(conn, job_id)
@@ -176,7 +177,7 @@ def job_detail(job_id: str):
@bp.get("/job/<job_id>/output")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
@permission_required(*SERVICING_PAGE, mode="any")
@fragment
def job_output(job_id: str):
conn = db()
@@ -277,14 +278,14 @@ def _start(kind: str) -> None:
@bp.post("/update")
@permission_required("maintenance.execute")
@permission_required("servicing_update.execute")
def start_update():
_start(service.JOB_KIND_UPDATE)
return redirect(url_for("maintenance.overview"))
@bp.post("/restart")
@permission_required("maintenance.execute")
@permission_required("servicing_reboot.execute")
def start_reboot():
_start(service.JOB_KIND_REBOOT)
return redirect(url_for("maintenance.overview"))
+302
View File
@@ -0,0 +1,302 @@
"""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, 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
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 _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)
benutzer, passwort = "", ""
darf_geheimnis = bool(current_user.has("credentials.secrets"))
if device.get("credential_id") and darf_geheimnis:
try:
benutzer, passwort = inventory.credential_secret(
conn, core().keystore, int(device["credential_id"])
)
except inventory.ValidationError:
benutzer, passwort = "", ""
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 render_template(
"tesm/screen.html",
device=device,
zugang=zugang,
websocket_path=f"/ws/screen/{device_id}",
vorgaben={"username": benutzer, "password": passwort},
darf_geheimnis=darf_geheimnis,
verfahren_name=vnc.verfahren_name,
)
@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))
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
try:
gegenstelle = socket.create_connection((host, port), timeout=VERBINDUNGSLIMIT)
except OSError as exc:
ws.close(1011, f"{host}:{port} antwortet nicht ({exc}).")
return
begonnen = time.monotonic()
with extension.database.transaction(conn):
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="device.screen_opened",
target=str(device["name"]),
detail=f"Bildschirmsitzung zu {host}:{port}",
ip=client_ip(),
severity="warning",
)
laeuft = threading.Event()
laeuft.set()
def vom_geraet() -> None:
"""Alles, was der VNC-Server schickt, geht als Binaerrahmen weiter."""
try:
while laeuft.is_set():
daten = gegenstelle.recv(BLOCK)
if not daten:
break
ws.send(daten)
except Exception: # noqa: BLE001 - Abbruch ist der Normalfall, nicht die Stoerung
pass
finally:
laeuft.clear()
try:
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
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)
with extension.database.session() as frisch, extension.database.transaction(frisch):
audit.log(
frisch,
actor=current_user.username,
actor_id=current_user.id,
action="device.screen_closed",
target=str(device["name"]),
detail=f"Dauer {dauer}s",
ip=client_ip(),
severity="info",
)
+90 -4
View File
@@ -18,6 +18,8 @@ from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
from tesm_core.licensing_client import LicenseError
import tesm_licensing as lic
bp = Blueprint("settings", __name__, url_prefix="/administration")
@@ -46,6 +48,28 @@ def _license_hint(feature: str) -> str:
return ""
def _module_rows(conn: Any) -> list[dict[str, Any]]:
"""Die drei Module mit ihrem Zustand -- lizenziert, benutzt.
Zwei verschiedene Fragen, die die Seite auseinanderhalten muss: was die
Lizenz erlaubt (dagegen hilft kein Schalter) und was dieses Haus benutzen
will (dafuer ist der Schalter da).
"""
manager = core().license
if manager is None:
return []
state = manager.reload(conn)
return [
{
"key": name,
"label": lic.MODULE_LABELS.get(name, name),
"licensed": name in state.modules,
"enabled": name not in state.modules_off,
}
for name in lic.ALL_MODULES
]
def _current_timezone() -> str:
sysops = core().sysops
if sysops is None:
@@ -65,7 +89,7 @@ def _timezone_choices() -> list[str]:
@bp.get("/system")
@permission_required("settings_system.view")
@permission_required("settings_system.read")
def system():
extension = core()
conn = db()
@@ -79,6 +103,9 @@ def system():
"monitor_failures_before_restart",
"monitor_auto_restart",
"dashboard_public",
"vnc_enabled",
"vnc_port",
"vnc_warn_unencrypted_off",
"terminal_switches_enabled",
"terminal_clients_enabled",
"terminal_idle_timeout",
@@ -94,16 +121,21 @@ def system():
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
timezone=_current_timezone(),
timezones=_timezone_choices(),
nav_items=extension.nav.items,
# Nicht ``nav_items``: dieser Name gehoert dem Seitenrahmen. Wer ihn hier
# belegt, ueberschreibt die gefilterte Navigation aus dem Kontext --
# und die Seitenleiste zeigte dann jeden Punkt, auch den, den dieser
# Benutzer nicht darf und den die Lizenz nicht freischaltet.
nav_baum=extension.nav.items,
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
license_hint=_license_hint("monitor_interval"),
sysops_available=bool(sysops and sysops.available),
module_rows=_module_rows(conn),
)
@bp.post("/system")
@permission_required("settings_system.edit")
@permission_required("settings_system.write")
def system_save():
extension = core()
conn = db()
@@ -114,6 +146,9 @@ def system_save():
"monitor_failures_before_restart",
"monitor_auto_restart",
"dashboard_public",
"vnc_enabled",
"vnc_port",
"vnc_warn_unencrypted_off",
"terminal_switches_enabled",
"terminal_clients_enabled",
"terminal_idle_timeout",
@@ -167,6 +202,56 @@ def system_save():
return redirect(url_for("settings.system"))
@bp.post("/system/modules")
@permission_required("settings_system.write")
def modules_save():
"""Module ein- und ausschalten.
Abgeschaltet heisst wirklich abgeschaltet: der Punkt verschwindet aus der
Navigation, und die Routen antworten mit 404 -- dieselbe Sperre, die auch
eine fehlende Lizenz zieht. Daten bleiben unangetastet; wer das Modul
wieder einschaltet, findet alles vor wie zuvor.
"""
extension = core()
manager = extension.license
if manager is None:
flash("Diese Installation nutzt keine Lizenzierung.", "info")
return redirect(url_for("settings.system"))
aus = [name for name in lic.ALL_MODULES if request.form.get(f"module_{name}") != "1"]
with transaction() as conn:
eingeschaltet, abgeschaltet = manager.module_abschalten(
conn, aus, actor=current_user.username
)
if eingeschaltet or abgeschaltet:
teile = []
if abgeschaltet:
teile.append(
"abgeschaltet: "
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(abgeschaltet))
)
if eingeschaltet:
teile.append(
"eingeschaltet: "
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(eingeschaltet))
)
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="settings.modules_changed",
detail="; ".join(teile),
ip=client_ip(),
severity="notice",
)
if eingeschaltet or abgeschaltet:
flash("Module gespeichert.", "success")
else:
flash("Keine Änderungen.", "info")
return redirect(url_for("settings.system"))
@bp.post("/system/hostname")
@permission_required("settings_system.execute")
def set_hostname():
@@ -226,7 +311,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"
@@ -236,6 +321,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()
+7 -7
View File
@@ -47,7 +47,7 @@ def _form_values() -> dict[str, Any]:
@bp.get("/")
@permission_required("switches.view")
@permission_required("switches.read")
def overview():
conn = db()
return render_template(
@@ -79,7 +79,7 @@ def create():
@bp.post("/<int:switch_id>")
@permission_required("switches.edit")
@permission_required("switches.write")
def update(switch_id: int):
try:
with transaction() as conn:
@@ -115,7 +115,7 @@ def delete(switch_id: int):
@bp.get("/<int:switch_id>")
@permission_required("switches.view")
@permission_required("switches.read")
def detail(switch_id: int):
conn = db()
switch = inventory.get_switch(conn, switch_id)
@@ -152,7 +152,7 @@ def detail(switch_id: int):
@bp.post("/<int:switch_id>/host-key/probe")
@permission_required("switches.edit")
@permission_required("switches.write")
def probe_host_key(switch_id: int):
conn = db()
switch = inventory.get_switch(conn, switch_id)
@@ -177,7 +177,7 @@ def probe_host_key(switch_id: int):
@bp.post("/<int:switch_id>/host-key/trust")
@permission_required("switches.edit")
@permission_required("switches.write")
@reauth_required
def trust_host_key(switch_id: int):
"""Gibt einen Host-Schluessel ausdruecklich frei.
@@ -216,7 +216,7 @@ def trust_host_key(switch_id: int):
@bp.post("/<int:switch_id>/host-key/forget")
@permission_required("switches.edit")
@permission_required("switches.write")
def forget_host_key(switch_id: int):
conn = db()
switch = inventory.get_switch(conn, switch_id)
@@ -235,7 +235,7 @@ def forget_host_key(switch_id: int):
@bp.post("/<int:switch_id>/command")
@permission_required("switches.execute")
@permission_required("switch_console.execute")
@reauth_required
def run_command(switch_id: int):
"""Fuehrt ein Kommando aus der Positivliste aus.
+30 -5
View File
@@ -12,7 +12,7 @@ Zwei Ziele, ein Ablauf:
* **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber
:class:`~tesm.services.switchcli.SwitchCli`.
* **Client** -- ``maintenance.execute`` und das lizenzpflichtige Modul
* **Client** -- ``device_console.execute`` und das lizenzpflichtige Modul
``maintenance``. Ein Kommando je ``exec``-Kanal ueber
:class:`~tesm.services.shellcli.ShellSession`.
@@ -167,7 +167,7 @@ def _render_console(target: Target): # type: ignore[no-untyped-def]
@bp.get("/monitoring/switches/<int:switch_id>/console")
@permission_required("switches.execute")
@permission_required("switch_console.execute")
@reauth_required
def switch_console(switch_id: int):
target = _load_target(db(), KIND_SWITCH, switch_id)
@@ -177,7 +177,7 @@ def switch_console(switch_id: int):
@bp.get("/monitoring/clients/<int:device_id>/console")
@permission_required("maintenance.execute")
@permission_required("device_console.execute")
@reauth_required
def device_console(device_id: int):
if not _client_module_active():
@@ -211,7 +211,17 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def]
ws.send(json.dumps({"type": kind, "data": text}))
"""Schickt eine Nachricht. Ein geschlossener Socket ist kein Fehler.
Bricht der Browser die Sitzung ab, schlaegt das Senden fehl -- und die
Ausnahme daraus verdeckte bisher den eigentlichen Grund, warum die
Sitzung endete: im Protokoll stand ein Stapelverlauf aus ``ws.send``,
nicht die Meldung des Switches.
"""
try:
ws.send(json.dumps({"type": kind, "data": text}))
except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung
logger.debug("Konsolennachricht nicht mehr zustellbar (%s)", kind)
def _transcript_name(label: str) -> str:
@@ -220,12 +230,23 @@ def _transcript_name(label: str) -> str:
def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-untyped-def]
"""Eine Konsolensitzung, von der Berechtigung bis zum Abmelden.
Jede Stufe schickt eine Zeile in die Oberflaeche. Das ist keine Zierde:
eine Konsole, die nichts sagt, laesst offen, ob die Berechtigung fehlt,
die Zugangsdaten, die Verbindung oder die Antwort des Geraets -- und genau
diese Frage stand im Betrieb im Raum. Sichtbar sind die Zeilen erst
nuetzlich, wenn sie **vor** dem Warten kommen, nicht danach.
"""
extension = core()
conn = db()
_send(ws, "info", "Sitzung wird geöffnet …")
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute"
required = (
"switch_console.execute" if kind == KIND_SWITCH else "device_console.execute"
)
if not current_user.is_authenticated or not current_user.has(required):
_send(ws, "error", "Keine Berechtigung für diese Konsole.")
return
@@ -253,6 +274,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
/ f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log"
)
_send(ws, "info", f"Ziel: {target.label} ({target.host}:{target.port})")
try:
username, password = inventory.credential_secret(
conn, extension.keystore, target.credential_id
@@ -260,6 +282,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
except inventory.ValidationError as exc:
_send(ws, "error", str(exc))
return
_send(ws, "info", f"Anmeldung als {username} …")
with extension.database.transaction(conn):
audit.log(
@@ -341,6 +364,7 @@ def _run_switch_session( # type: ignore[no-untyped-def]
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
) -> int:
commands = 0
_send(ws, "info", "Warte auf die Eingabeaufforderung des Switches …")
with switchcli.SwitchCli(
conn, host=target.host, port=target.port, username=username, password=password
) as cli:
@@ -373,6 +397,7 @@ def _run_client_session( # type: ignore[no-untyped-def]
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
) -> int:
commands = 0
_send(ws, "info", "Verbindung wird aufgebaut …")
with shellcli.ShellSession(
conn, host=target.host, port=target.port, username=username, password=password
) as session:
+164 -2
View File
@@ -69,13 +69,41 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
)
user_id = int(cursor.lastrowid or 0)
action = "angelegt"
# Der Vollzugriff gehoert dazu. Seit 2.8.0 gibt das
# Administratorkennzeichen den Verwaltungsbereich und sonst
# nichts; Funktionsrechte kommen ueber Gruppen. Ein Konto, das
# jemand an der Kommandozeile anlegt, ist aber der Notausgang --
# es entsteht, wenn sonst niemand mehr hineinkommt, und dann
# nuetzt eine Verwaltung ohne Geraete, Freigaben und Protokolle
# wenig. Wer das nicht will, nimmt die Gruppe in einem Klick
# wieder weg.
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
gruppe = rbac.funktionsgruppe_anlegen(
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
)
# Liegt sie im Papierkorb, kommt sie zurueck. Sonst haette dieser
# Aufruf nichts bewirkt: das Konto waere in einer geloeschten
# Gruppe -- und das faellt genau dann auf, wenn man es am
# wenigsten gebrauchen kann.
conn.execute(
"UPDATE groups SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL",
(gruppe,),
)
conn.execute(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
(user_id, gruppe),
)
audit.log(
conn,
actor="cli",
actor_id=user_id,
action="user.admin_created",
target=username,
detail=f"über die Kommandozeile {action}",
detail=(
f"über die Kommandozeile {action}, Gruppe "
f"{FUNKTIONEN_VOLLZUGRIFF.name} zugewiesen"
),
severity="warning",
)
print(f"Administrator {username!r} {action}.")
@@ -191,7 +219,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(
@@ -202,6 +230,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, "
@@ -216,6 +245,123 @@ def cmd_web_setup(args: argparse.Namespace) -> int:
return run_web_setup(app, extension, args)
def cmd_switch_test(args: argparse.Namespace) -> int:
"""Prueft Stufe fuer Stufe, wo eine Switch-Sitzung haengt.
Warum ein eigener Befehl und nicht die Konsole im Browser: dort liegen
zwischen dem Switch und dem Auge ein WebSocket, ein Reverse Proxy und
JavaScript. Wenn nichts ankommt, ist die erste Frage, *welche* dieser
Schichten schweigt. Hier gibt es keine davon -- und jede Stufe sagt, was
sie bekommen hat, notfalls als rohe Bytes.
Liest nur, ausser mit ``--zyklus``: dann wird der Port wirklich
geschaltet.
"""
from .services import inventory, switchcli
from .services.ssh import SshError
from tesm_core.db import Database
from tesm_core.extension import core
app = create_app()
erweiterung = core(app)
def sagen(stufe: str, text: str = "") -> None:
print(f" {stufe:<28} {text}".rstrip())
with app.app_context():
with erweiterung.database.session() as conn:
zeile = Database.one(
conn,
"SELECT id, hostname, ip, ssh_port, credential_id FROM switches "
"WHERE (hostname=? COLLATE NOCASE OR ip=?) AND deleted_at IS NULL",
(args.switch, args.switch),
)
if zeile is None:
print(f"Kein Switch mit Name oder Adresse {args.switch!r}.")
print("Vorhanden:")
for andere in Database.all(
conn, "SELECT hostname, ip FROM switches WHERE deleted_at IS NULL"
):
print(f" {andere['hostname']} ({andere['ip']})")
return 2
host = str(zeile["ip"])
ssh_port = int(zeile["ssh_port"] or 22)
print(f"Switch {zeile['hostname']} ({host}:{ssh_port})")
print()
if not zeile["credential_id"]:
sagen("Zugangsdaten", "FEHLT -- dem Switch sind keine zugeordnet")
return 1
try:
benutzer, passwort = inventory.credential_secret(
conn, erweiterung.keystore, int(zeile["credential_id"])
)
except Exception as fehler: # noqa: BLE001
sagen("Zugangsdaten", f"nicht lesbar: {fehler}")
return 1
sagen("Zugangsdaten", f"{benutzer} (Passwort {len(passwort)} Zeichen)")
from .services import ssh as ssh_modul
for record in ssh_modul.all_known_keys(conn, host, ssh_port):
sagen("Freigegebener Schluessel", f"{record.key_type} {record.fingerprint}")
try:
sitzung = switchcli.SwitchCli(
conn,
host=host,
port=ssh_port,
username=benutzer,
password=passwort,
)
with sitzung as cli:
sagen("Anmeldung", "erfolgreich")
erstes = cli.full_transcript
sagen("Erste Antwort", f"{len(erstes)} Zeichen")
print()
print("--- was der Switch nach der Anmeldung geschickt hat ---")
print(erstes if erstes.strip() else "(nichts)")
print("--- Ende ---")
print()
if args.port:
ausgabe = cli.send(
f"show power-over-ethernet {args.port}", tolerate_error=True
)
sagen("Zustandsabfrage", f"{len(ausgabe)} Zeichen")
print("--- show power-over-ethernet ---")
print(ausgabe)
print("--- Ende ---")
sagen("Erkannter Zustand", cli.poe_state(args.port))
if args.zyklus:
if not args.port:
print("Fuer --zyklus braucht es --port.")
return 2
ergebnis = cli.cycle_poe(args.port)
sagen("Portzyklus", "erfolgreich" if ergebnis.ok else ergebnis.error)
for schritt in ergebnis.steps:
sagen("", schritt)
if not ergebnis.ok:
return 1
except SshError as fehler:
sagen("FEHLGESCHLAGEN", str(fehler))
# Gerade dann sind die Bytes interessant: "kein Prompt" kann
# heissen, dass nichts kam -- oder dass etwas kam, das nicht
# erkannt wurde. Das sieht man nur im Rohtext, und
# Steuerzeichen sieht man nur als repr().
roh = "".join(sitzung.transcript)
print()
print("--- rohe Antwort des Switches (mit Steuerzeichen) ---")
print(repr(roh) if roh else "(nichts)")
print("--- Ende ---")
return 1
print()
print("Fertig.")
return 0
def cmd_convert_legacy(args: argparse.Namespace) -> int:
"""Uebersetzt einen Export des alten TESM in das neue Format.
@@ -418,6 +564,22 @@ def build_parser() -> argparse.ArgumentParser:
check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen")
check.set_defaults(func=cmd_check_now)
pruefung = sub.add_parser(
"switch-test",
help="Eine Switch-Sitzung Stufe fuer Stufe pruefen",
description=(
"Meldet sich am Switch an und zeigt, was er schickt -- ohne Browser, "
"ohne WebSocket, ohne Reverse Proxy. Liest nur; mit --zyklus wird der "
"Port wirklich geschaltet."
),
)
pruefung.add_argument("switch", help="Name oder IP des Switches, wie in TESM hinterlegt")
pruefung.add_argument("--port", help="Portbezeichnung, z. B. 1/8 -- fragt den Zustand ab")
pruefung.add_argument(
"--zyklus", action="store_true", help="PoE wirklich aus- und wieder einschalten"
)
pruefung.set_defaults(func=cmd_switch_test)
legacy = sub.add_parser(
"convert-legacy",
help="Export des alten TESM in das neue Format übersetzen",
+26 -24
View File
@@ -13,6 +13,8 @@ from typing import Any
from tesm_core.extension import core
from tesm_core.nav import NavItem, NavTree
from .permissions import DHCP_PAGE, SERVICING_PAGE
def _module(name: str):
"""Bedingung: Vollmodul lizenziert?"""
@@ -36,7 +38,7 @@ def _has_fileshare_mounts(user: Any) -> bool:
return False
if not _module("fileshare")(user):
return False
if user.has("fileshare.edit"):
if user.has("fileshare.write"):
return True
from .services import fileshare
@@ -56,28 +58,28 @@ NAVIGATION = NavTree(
label="Clients",
icon="cpu",
endpoint="devices.overview",
permissions=("devices.view",),
permissions=("devices.read",),
),
NavItem(
key="switches",
label="Switche",
icon="switch",
endpoint="switches.overview",
permissions=("switches.view",),
permissions=("switches.read",),
),
NavItem(
key="credentials",
label="Zugangsdaten",
icon="key",
endpoint="credentials.overview",
permissions=("credentials.view",),
permissions=("credentials.read",),
),
NavItem(
key="hostkeys",
label="Host-Schlüssel",
icon="shield",
endpoint="hostkeys.overview",
permissions=("devices.view", "switches.view"),
permissions=("devices.read", "switches.read"),
),
),
),
@@ -86,7 +88,7 @@ NAVIGATION = NavTree(
label="Wartung",
icon="wrench",
endpoint="maintenance.overview",
permissions=("maintenance.view", "maintenance.execute"),
permissions=SERVICING_PAGE,
condition=_module("maintenance"),
),
NavItem(
@@ -94,7 +96,7 @@ NAVIGATION = NavTree(
label="DHCP-Server",
icon="network",
endpoint="dhcp.overview",
permissions=("dhcp.view", "dhcp.edit", "dhcp.execute"),
permissions=DHCP_PAGE,
condition=_module("dhcp"),
),
NavItem(
@@ -108,14 +110,14 @@ NAVIGATION = NavTree(
label="Meine Freigaben",
icon="folder",
endpoint="fileshare.browse",
permissions=("fileshare.view",),
permissions=("fileshare.read",),
),
NavItem(
key="fileshare_mappings",
label="Zuordnungen",
icon="link",
endpoint="fileshare.mappings",
permissions=("fileshare.edit",),
permissions=("fileshare.write",),
),
),
),
@@ -129,28 +131,28 @@ NAVIGATION = NavTree(
label="Laufend",
icon="terminal",
endpoint="logs.live",
permissions=("logs_live.view",),
permissions=("logs_live.read",),
),
NavItem(
key="logs_history",
label="Verlauf",
icon="clock",
endpoint="logs.history",
permissions=("logs_history.view", "logs_history.export"),
permissions=("logs_history.read", "logs_history.export"),
),
NavItem(
key="logs_audit",
label="Audit",
icon="history",
endpoint="logs.audit_view",
permissions=("logs_audit.view", "logs_audit.export"),
permissions=("logs_audit.read", "logs_audit.export"),
),
NavItem(
key="logs_dhcp",
label="DHCP",
icon="network",
endpoint="tesmlogs.dhcp",
permissions=("logs_dhcp.view",),
permissions=("logs_dhcp.read",),
condition=_module("dhcp"),
),
NavItem(
@@ -158,7 +160,7 @@ NAVIGATION = NavTree(
label="Neustarts",
icon="power",
endpoint="tesmlogs.restarts",
permissions=("devices.view",),
permissions=("devices.read",),
),
),
),
@@ -172,42 +174,42 @@ NAVIGATION = NavTree(
label="Benutzer",
icon="users",
endpoint="admin.users",
permissions=("users.view",),
permissions=("users.read",),
),
NavItem(
key="groups",
label="Gruppen und Rechte",
icon="shield-users",
endpoint="admin.groups",
permissions=("groups.view",),
permissions=("groups.read",),
),
NavItem(
key="settings_system",
label="System",
icon="sliders",
endpoint="settings.system",
permissions=("settings_system.view",),
permissions=("settings_system.read",),
),
NavItem(
key="settings_network",
label="Netzwerk",
icon="network",
endpoint="hostsettings.network",
permissions=("settings_network.view",),
permissions=("settings_network.read",),
),
NavItem(
key="settings_web",
label="Webserver und TLS",
icon="server",
endpoint="hostsettings.web",
permissions=("settings_web.view",),
permissions=("settings_web.read",),
),
NavItem(
key="settings_directory",
label="Verzeichnisdienst",
icon="users",
endpoint="hostsettings.directory_settings",
permissions=("settings_directory.view",),
permissions=("settings_directory.read",),
),
NavItem(
key="settings_license",
@@ -216,28 +218,28 @@ NAVIGATION = NavTree(
endpoint="license.overview",
# Bewusst immer erreichbar, auch ohne gueltige Lizenz --
# sonst liesse sich eine neue Lizenz nie einspielen.
permissions=("settings_license.view",),
permissions=("settings_license.read",),
),
NavItem(
key="backup",
label="Sicherung",
icon="transfer",
endpoint="backup.overview",
permissions=("backup.view", "backup.export", "backup.execute"),
permissions=("backup_export.read", "backup_import.read"),
),
NavItem(
key="trash",
label="Papierkorb",
icon="trash",
endpoint="trash.overview",
permissions=("trash.view",),
permissions=("trash.read",),
),
NavItem(
key="diagnostics",
label="Diagnose",
icon="info",
endpoint="diagnostics.overview",
permissions=("diagnostics.view",),
permissions=("diagnostics.read",),
),
),
),
+297 -97
View File
@@ -30,67 +30,239 @@ A = Action
DEVICES = Resource(
key="devices",
label="Clients",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
hint="Überwachte PoE-Endgeräte",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Stammdaten überwachter PoE-Endgeräte: Name, MAC, IP, Switch und Port",
action_hints={
A.EXECUTE: "PoE-Neustart eines Geräts manuell auslösen. Der automatische "
"Neustart bei Ausfall ist davon unabhängig und läuft immer.",
A.DELETE: "Gerät in den Papierkorb legen.",
},
)
#: Drei Wege, drei Rechte -- bisher hingen alle an ``devices.execute``.
#:
#: Wer jemandem den PoE-Neustart erlauben wollte (der Weg, der auch bei einem
#: haengenden Geraet noch wirkt und nichts Laufendes unterbricht), erlaubte ihm
#: damit zugleich, ein arbeitendes System ueber SSH herunterzufahren. Das sind
#: drei verschiedene Eingriffe mit drei verschiedenen Folgen -- also drei
#: Zeilen. Die Reihenfolge ist die der Rangfolge in ``services.restart``.
DEVICE_RESTART_POE = Resource(
key="device_restart_poe",
parent="devices",
label="Clients: Neustart über PoE",
actions=(A.READ, A.EXECUTE),
hint="Portzyklus am Switch – wirkt in Sekunden, auch wenn das Gerät hängt",
action_hints={
A.READ: "Den Knopf sehen.",
A.EXECUTE: "Neustart auslösen. Bewusst nicht lizenzpflichtig: ein hängendes "
"Gerät soll man auch ohne gültige Lizenz von Hand neu starten können.",
},
)
DEVICE_RESTART_SSH = Resource(
key="device_restart_ssh",
parent="devices",
label="Clients: Neustart über SSH",
actions=(A.READ, A.EXECUTE),
hint="Sauberes Herunterfahren eines Linux-Geräts (Modul Wartung)",
action_hints={
A.EXECUTE: "Neustartauftrag anlegen. Dauert Minuten und setzt ein "
"antwortendes Gerät samt freigegebenem Host-Schlüssel voraus.",
},
)
DEVICE_RESTART_RPC = Resource(
key="device_restart_rpc",
parent="devices",
label="Clients: Neustart über RPC",
actions=(A.READ, A.EXECUTE),
hint="Windows-Gerät ohne SSH und ohne Host-Schlüssel (Modul Wartung)",
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.
DEVICE_CONSOLE = Resource(
key="device_console",
parent="devices",
label="Clients: Konsole",
actions=(A.READ, A.EXECUTE),
hint="Browser-Terminal auf einem Client (Modul Wartung)",
action_hints={
A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
"jede Sitzung wird vollständig mitgeschrieben.",
},
)
SWITCHES = Resource(
key="switches",
label="Switche",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
hint="Aruba-Switche und ihre Erreichbarkeit",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Aruba-Switche, ihre Erreichbarkeit und Ports",
)
SWITCH_CONSOLE = Resource(
key="switch_console",
parent="switches",
label="Switche: Konsole",
actions=(A.READ, A.EXECUTE),
hint="Kommandos auf dem Switch ausführen",
action_hints={
A.EXECUTE: "Browser-Terminal öffnen und Kommandos auf dem Switch ausführen. "
"Verlangt zusätzlich eine frische Passwortbestätigung; jede Sitzung wird "
"vollständig protokolliert.",
A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
"jede Sitzung wird vollständig mitgeschrieben.",
},
)
#: Einen geaenderten Host-Schluessel freizugeben hing an "Gerät ändern" bzw.
#: "Switch ändern" -- also an einem Recht fuer Stammdaten. Es ist aber der
#: Vorgang, mit dem man sagt: "dieses Geraet ist wirklich das, fuer das es sich
#: ausgibt". Wer Namen und Standort pflegt, muss das nicht entscheiden duerfen.
HOSTKEYS = Resource(
key="hostkeys",
label="Host-Schlüssel",
actions=(A.READ, A.EXECUTE),
hint="Ein Gerät meldet sich mit einem anderen SSH-Schlüssel als beim letzten Mal",
action_hints={
A.READ: "Offene und freigegebene Schlüssel sehen.",
A.EXECUTE: "Einen geänderten Schlüssel freigeben.",
},
)
CREDENTIALS = Resource(
key="credentials",
label="Zugangsdaten",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS),
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.SECRETS),
hint="Wiederverwendbare SSH-Logins",
action_hints={
A.VIEW: "Namen und Benutzer sehen – nicht das Passwort.",
A.READ: "Namen und Benutzer sehen – nicht das Passwort.",
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
"Passwortbestätigung und wird protokolliert.",
},
)
MAINTENANCE = Resource(
key="maintenance",
# Nicht "Wartung": so heisst der Bereich. Die Zeile sagt, was sie erlaubt.
label="Aktualisierung und Neustart",
actions=(A.VIEW, A.EXECUTE),
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewählten Geräten starten."},
#: Aus einer Zeile ("Aktualisierung und Neustart") wurden drei. Sie deckte
#: vier Funktionen ab: Pakete aktualisieren, reihenweise neu starten, das
#: Windows-Werkzeug installieren -- und die Client-Konsole, die jetzt bei den
#: Clients steht, wo sie hingehoert.
SERVICING_UPDATE = Resource(
key="servicing_update",
label="Paketaktualisierung",
actions=(A.READ, A.EXECUTE),
hint="Pakete auf ausgewählten Geräten aktualisieren (lizenzpflichtiges Modul)",
action_hints={A.EXECUTE: "Auftrag starten."},
)
DHCP = Resource(
key="dhcp",
label="DHCP-Server",
actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
SERVICING_REBOOT = Resource(
key="servicing_reboot",
label="Neustart als Auftrag",
actions=(A.READ, A.EXECUTE),
hint="Reihenweise Neustarts mit Wartezeit und Rückmeldung",
action_hints={A.EXECUTE: "Auftrag starten."},
)
SERVICING_TOOL = Resource(
key="servicing_tool",
label="Windows-Werkzeug installieren",
actions=(A.READ, A.EXECUTE),
hint="Voraussetzung für den RPC-Weg auf einem Windows-Gerät",
action_hints={A.EXECUTE: "Installation auslösen."},
)
#: 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.
SERVICING_PAGE: tuple[str, ...] = (
"servicing_update.read",
"servicing_reboot.read",
"servicing_tool.read",
)
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
#: Optionen, Geraeteoptionen, eigene Optionen, Reservierungen und das
#: Schreiben der Dienstkonfiguration. Wer eine Reservierung eintragen sollte,
#: durfte damit auch Subnetze loeschen und die Konfiguration ueberschreiben.
DHCP_SUBNETS = Resource(
key="dhcp_subnets",
label="DHCP: Subnetze",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Bereiche, Gateway, DNS, Reihenfolge (lizenzpflichtiges Modul)",
)
DHCP_OPTIONS = Resource(
key="dhcp_options",
parent="dhcp_subnets",
label="DHCP: Optionen",
actions=(A.READ, A.WRITE),
hint="Globale Optionen, Optionen je Gerät und eigene Optionen",
action_hints={A.WRITE: "Werte setzen, eigene Optionen anlegen und entfernen."},
)
DHCP_RESERVATIONS = Resource(
key="dhcp_reservations",
parent="dhcp_subnets",
label="DHCP: Reservierungen",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Feste Adressen zu einer MAC",
)
DHCP_CONFIG = Resource(
key="dhcp_config",
parent="dhcp_subnets",
label="DHCP: Konfiguration schreiben",
actions=(A.READ, A.EXECUTE),
hint="Den bearbeiteten Stand in die Dienstkonfiguration übernehmen",
action_hints={
A.EDIT: "Konfiguration ändern und schreiben.",
A.EXECUTE: "Dienst installieren, starten oder stoppen.",
A.EXECUTE: "Schreiben und neu laden. Bis dahin ändert sich am laufenden "
"Dienst nichts – Bearbeiten und Wirksamwerden sind zwei Schritte.",
},
)
DHCP_SERVICE = Resource(
key="dhcp_service",
parent="dhcp_subnets",
label="DHCP: Dienst",
actions=(A.READ, A.EXECUTE),
hint="Kea installieren, starten, stoppen",
action_hints={A.EXECUTE: "Dienstzustand ändern."},
)
#: 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, ...] = (
"dhcp_subnets.read",
"dhcp_options.read",
"dhcp_reservations.read",
"dhcp_config.read",
"dhcp_service.read",
)
FILESHARE = Resource(
key="fileshare",
label="Dateifreigaben",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXPORT),
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.EXPORT),
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
action_hints={
A.CREATE: "Ordner anlegen und Dateien hochladen.",
A.EDIT: "Umbenennen und verschieben.",
A.WRITE: "Umbenennen und verschieben.",
# Genau die Bedeutung aus der Legende: Daten aus dem System
# heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau
# bleibt im Haus, eine heruntergeladene Datei nicht.
@@ -109,20 +281,47 @@ FILESHARE = Resource(
#: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen.
FILESHARE_PDF = Resource(
key="fileshare_pdf",
parent="fileshare",
label="PDF-Formulare",
actions=(A.VIEW, A.CREATE, A.EDIT),
actions=(A.READ, A.CREATE, A.WRITE),
hint="Formulare in der Vorschau ausfüllen und in der Freigabe ablegen",
action_hints={
A.VIEW: "In der Vorschau ausfüllen (ohne Speichern).",
A.READ: "In der Vorschau ausfüllen (ohne Speichern).",
A.CREATE: "Ausgefüllt speichern – abgeschlossen, Felder gesperrt.",
A.EDIT: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.",
A.WRITE: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.",
},
)
#: Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand
#: mehr bearbeiten kann, mit Namen und Zeitpunkt im Dokument und einer
#: Pruefsumme im Aenderungsprotokoll. Wer Dateien umbenennen darf, soll das
#: nicht automatisch duerfen.
FILESHARE_SIGNATURE = Resource(
key="fileshare_signature",
parent="fileshare",
label="Unterschriften",
actions=(A.READ, A.EXECUTE),
hint="Mit dem Finger unterschreiben; das Ergebnis ist unveränderbar",
action_hints={
A.READ: "Unterschriebene Dateien öffnen und die Prüfung sehen.",
A.EXECUTE: "Unterschreiben und abgeschlossen ablegen.",
},
)
#: Welche Verzeichnisfreigabe unter welchem Namen erscheint, ist Administration
#: und kein Dateibetrieb -- es lief unter demselben Recht wie "umbenennen".
FILESHARE_MAPPINGS = Resource(
key="fileshare_mappings",
parent="fileshare",
label="Freigabe-Zuordnungen",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Verzeichnisgruppe, UNC-Pfad und Anzeigename einer Freigabe",
)
LOGS_DHCP = Resource(
key="logs_dhcp",
label="DHCP-Protokoll",
actions=(A.VIEW,),
actions=(A.READ,),
)
MONITORING_AREA = Area(
@@ -131,7 +330,18 @@ MONITORING_AREA = Area(
icon="cpu",
hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
"gesamte Bereich verborgen.",
resources=(DEVICES, SWITCHES, CREDENTIALS),
resources=(
DEVICES,
DEVICE_RESTART_POE,
DEVICE_RESTART_SSH,
DEVICE_RESTART_RPC,
DEVICE_CONSOLE,
DEVICE_VNC,
SWITCHES,
SWITCH_CONSOLE,
HOSTKEYS,
CREDENTIALS,
),
)
#: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als
@@ -144,21 +354,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=(MAINTENANCE,),
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_TOOL),
)
NETWORK_AREA = Area(
key="network",
label="Netzwerkdienste",
icon="network",
resources=(DHCP,),
resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE),
)
FILES_AREA = Area(
key="files",
label="Dateifreigaben",
icon="folder",
resources=(FILESHARE, FILESHARE_PDF),
resources=(FILESHARE, FILESHARE_PDF, FILESHARE_SIGNATURE, FILESHARE_MAPPINGS),
)
PERMISSIONS = PermissionTree(
@@ -173,72 +383,53 @@ PERMISSIONS = PermissionTree(
)
#: Alle Funktionsrechte ausserhalb der Verwaltung, in einer Gruppe.
#:
#: Seit 2.8.0 heisst das Administratorkennzeichen "Verwaltung" und nicht
#: "alles": Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen. Diese
#: Vorlage ist die Gruppe, die man sich dafuer zuweist -- und die Migration
#: gibt sie bestehenden Administratoren, damit nach dem Update nichts fehlt,
#: was vorher ging. Entfernen kann man sie mit einem Klick.
#:
#: Die Liste entsteht aus dem Baum und nicht aus Tippen: eine neue Zeile in der
#: Matrix ist sonst genau die, die hier vergessen wird.
FUNKTIONEN_VOLLZUGRIFF = RolePreset(
key="functions",
name="Funktionen (Vollzugriff)",
description=(
"Alle Rechte ausserhalb der Verwaltung. Gedacht fuer Administratoren, "
"die nicht nur einstellen, sondern auch bedienen."
),
grants=tuple(
(resource.key, "*")
for area in PERMISSIONS.areas
if area.key != PermissionTree.VERWALTUNGSBEREICH
for resource in area.resources
),
)
#: Die Gruppen, die eine frische Installation mitbringt -- drei, nicht sieben.
#:
#: Vorher standen hier zusaetzlich Betrieb, Support, Netzwerk und Revision.
#: Gut gemeint, aber sie beantworten eine Frage, die jeder Betrieb anders
#: beantwortet: welche Rolle es gibt, weiss der Kunde, nicht die Anwendung.
#: Was bleibt, sind die drei, ohne die man nicht anfangen kann -- eine
#: Standardgruppe fuer jeden neuen Nutzer, eine fuer die Verwaltung und eine,
#: die alle Funktionen buendelt. Weitere legt man in der Matrix an, und die
#: Matrix zeigt seit 2.8.0 je Funktion eine Zeile.
ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset(
key="viewer",
name="Benutzer",
description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.",
grants=(
("devices", (A.VIEW,)),
("switches", (A.VIEW,)),
("logs_live", (A.VIEW,)),
("devices", (A.READ,)),
("switches", (A.READ,)),
("logs_live", (A.READ,)),
),
is_default=True,
),
RolePreset(
key="operations",
name="Betrieb",
description="Geräte und Switche pflegen, Neustarts auslösen, Wartung fahren.",
grants=(
("devices", "*"),
("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)),
("credentials", (A.VIEW,)),
("maintenance", "*"),
("logs_live", (A.VIEW,)),
("logs_history", (A.VIEW,)),
("trash", (A.VIEW, A.EDIT)),
),
),
RolePreset(
key="support",
name="Support",
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart auslösen.",
grants=(
("devices", (A.VIEW, A.EXECUTE)),
("switches", (A.VIEW,)),
("logs_live", (A.VIEW,)),
),
),
RolePreset(
key="network",
name="Netzwerk",
description="DHCP und Host-Netzwerkeinstellungen.",
grants=(
("dhcp", "*"),
("settings_network", "*"),
("logs_dhcp", (A.VIEW,)),
("devices", (A.VIEW,)),
("switches", (A.VIEW,)),
),
),
RolePreset(
key="auditor",
name="Revision",
description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.",
grants=(
("devices", (A.VIEW,)),
("switches", (A.VIEW,)),
("credentials", (A.VIEW,)),
("dhcp", (A.VIEW,)),
("users", (A.VIEW,)),
("groups", (A.VIEW,)),
("logs_live", (A.VIEW,)),
("logs_history", (A.VIEW, A.EXPORT)),
("logs_audit", (A.VIEW, A.EXPORT)),
("diagnostics", (A.VIEW,)),
("trash", (A.VIEW,)),
),
),
RolePreset(
key="administration",
name="Administration",
@@ -247,16 +438,25 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
("users", "*"),
("groups", "*"),
("settings_system", "*"),
("settings_nav", "*"),
("settings_license", "*"),
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
("settings_web", "*"),
("settings_network", "*"),
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
("backup_export", (A.READ, A.EXPORT)),
("backup_import", "*"),
("trash", "*"),
("diagnostics", (A.VIEW,)),
("logs_audit", (A.VIEW,)),
("diagnostics", (A.READ,)),
("logs_audit", (A.READ,)),
),
),
FUNKTIONEN_VOLLZUGRIFF,
)
__all__ = ["PERMISSIONS", "ROLE_PRESETS", "LOGS_DHCP"]
__all__ = [
"PERMISSIONS",
"ROLE_PRESETS",
"LOGS_DHCP",
"SERVICING_PAGE",
"DHCP_PAGE",
]
+216 -10
View File
@@ -23,6 +23,8 @@ Frische Zeitlinie ab Version 1 -- kein Nachbau der 35 gewachsenen
from __future__ import annotations
from typing import Any
from tesm_core.migrations import Migration
V1 = """
@@ -257,19 +259,19 @@ CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, t
V4 = """
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare.export' FROM group_permissions
WHERE permission = 'fileshare.view';
WHERE permission = 'fileshare.read';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.view' FROM group_permissions
WHERE permission = 'fileshare.view';
SELECT group_id, 'fileshare_pdf.read' FROM group_permissions
WHERE permission = 'fileshare.read';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.create' FROM group_permissions
WHERE permission = 'fileshare.create';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.edit' FROM group_permissions
WHERE permission = 'fileshare.edit';
SELECT group_id, 'fileshare_pdf.write' FROM group_permissions
WHERE permission = 'fileshare.write';
"""
@@ -297,13 +299,21 @@ DELETE FROM settings WHERE key = 'terminal_enabled';
# Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich
# Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update
# darf Zugang erhalten, aber keinen neuen schaffen.
#
# Die Bedingung nennt absichtlich keine festen Schluessel, sondern Praefixe:
# Wer von einer Fassung vor diesem Schritt direkt auf 2.8 geht, hat vorher die
# Migrationen des Kerns durchlaufen (core laeuft vor der Anwendung) -- die
# Rechte heissen dann schon "read"/"write" und die Wartungszeilen "servicing_".
# Mit ausgeschriebenen Schluesseln fand dieser Schritt in genau diesem Fall
# nichts und der Bereich waere doch weg. Ein Ueberrest ist die Zeile nicht:
# eine angewendete Migration laeuft nie wieder.
V6 = """
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT DISTINCT group_id, 'servicing.view'
SELECT DISTINCT group_id, 'servicing.read'
FROM group_permissions
WHERE permission IN ('maintenance.view', 'maintenance.execute')
WHERE (permission LIKE 'maintenance.%' OR permission LIKE 'servicing%')
AND group_id IN (
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.view'
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.read'
);
"""
@@ -371,12 +381,208 @@ def uebernehme_signaturen(conn) -> None: # type: ignore[no-untyped-def]
)
def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
"""Bestehende Administratoren behalten ihre Funktionen -- ueber eine Gruppe.
Steht hier und nicht im Kern, weil erst die Anwendung weiss, welche Zeilen
ausserhalb der Verwaltung es gibt. Laeuft nach den Migrationen des Kerns
(die Rechte heissen dann schon read/write und sind aufgeteilt).
"""
from tesm_core.rbac import service as rbac
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
#: Die vier Vorlagen, die es bis 2.8.6 gab und ab 2.8.7 nicht mehr.
#:
#: Stehen hier als eingefrorene Kopie, weil eine Migration ihre eigenen Daten
#: mitbringen muss: in ``permissions.py`` sind sie weg, und trotzdem muss
#: dieser Schritt noch erkennen koennen, ob eine vorgefundene Gruppe wirklich
#: unveraendert die ausgelieferte ist.
ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = (
(
"operations",
"Betrieb",
(
("devices", "*"),
("device_restart_poe", "*"),
("device_restart_ssh", "*"),
("device_restart_rpc", "*"),
("device_console", "*"),
("switches", ("read", "create", "write")),
("switch_console", "*"),
("hostkeys", "*"),
("credentials", ("read",)),
("servicing_update", "*"),
("servicing_reboot", "*"),
("servicing_tool", "*"),
("logs_live", ("read",)),
("logs_history", ("read",)),
("trash", ("read", "write")),
),
),
(
"support",
"Support",
(
("devices", ("read",)),
("device_restart_poe", "*"),
("switches", ("read",)),
("logs_live", ("read",)),
),
),
(
"network",
"Netzwerk",
(
("dhcp_subnets", "*"),
("dhcp_options", "*"),
("dhcp_reservations", "*"),
("dhcp_config", "*"),
("dhcp_service", "*"),
("settings_network", "*"),
("logs_dhcp", ("read",)),
("devices", ("read",)),
("switches", ("read",)),
),
),
(
"auditor",
"Revision",
(
("devices", ("read",)),
("switches", ("read",)),
("credentials", ("read",)),
("dhcp_subnets", ("read",)),
("dhcp_options", ("read",)),
("dhcp_reservations", ("read",)),
("users", ("read",)),
("groups", ("read",)),
("logs_live", ("read",)),
("logs_history", ("read", "export")),
("logs_audit", ("read", "export")),
("diagnostics", ("read",)),
("trash", ("read",)),
),
),
)
def eingefrorene_vorlagen(
beschreibung: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...],
) -> list[Any]:
"""Baut aus der Kopie oben wieder ``RolePreset``-Objekte.
Die Aktionen stehen dort als Text und nicht als ``Action``, damit diese
Migration auch dann noch lesbar bleibt, wenn das Enum sich weiterentwickelt.
"""
from tesm_core.rbac.model import Action, RolePreset
vorlagen = []
for key, name, grants in beschreibung:
gewandelt = tuple(
(ressource, aktionen if aktionen == "*" else tuple(Action(a) for a in aktionen))
for ressource, aktionen in grants
)
vorlagen.append(RolePreset(key=key, name=name, description="", grants=gewandelt))
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;
"""
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
Was eine Anwendung an Rollen mitbringt, ist eine Vermutung darueber, wie
ein Betrieb organisiert ist. Sieben davon sind sechs zu viele: wer sie
nicht braucht, muss sie trotzdem jedes Mal ueberlesen, und in der
Rechtematrix stand fuer jede eine Spalte. Es bleiben "Benutzer",
"Administration" und "Funktionen (Vollzugriff)".
Weggeraeumt wird nur, was unberuehrt ist -- keine Mitglieder, Rechte
unveraendert. Alles andere bleibt stehen, wo es steht. Und auch das
Weggeraeumte liegt nur im Papierkorb.
"""
from tesm_core.rbac import service as rbac
from .permissions import PERMISSIONS
rbac.unberuehrte_vorlagen_entfernen(
conn, PERMISSIONS, eingefrorene_vorlagen(ABGELEGTE_VORLAGEN)
)
TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
Migration(version=3, name="restart-events", sql=V3),
Migration(version=4, name="fileshare-pdf-rechte", sql=V4),
Migration(
version=4,
name="fileshare-pdf-rechte",
sql=V4,
geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.",
),
Migration(version=5, name="terminal-getrennt", sql=V5),
Migration(version=6, name="wartung-eigener-bereich", sql=V6),
Migration(
version=6,
name="wartung-eigener-bereich",
sql=V6,
geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.",
),
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),
)
+4 -1
View File
@@ -542,7 +542,10 @@ 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
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
+274 -13
View File
@@ -16,10 +16,20 @@ eingelesen. Ein eigener Schreibpfad fuer Altdaten waere eine zweite Stelle, an
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
die niemand pflegt.
Uebersetzt werden Clients, Switche, Zugangsdaten und die DHCP-Konfiguration.
Was in der Datei sonst noch steht, wird nicht stillschweigend verworfen,
sondern im Bericht benannt -- damit sichtbar bleibt, was von Hand nachzuziehen
ist.
Uebersetzt werden Clients, Switche, Zugangsdaten, die DHCP-Konfiguration, die
Freigabe-Zuordnungen und die Anbindung an das Verzeichnis (Server, Transport,
Basis-DN, Anmeldeattribut, Bind-Konto samt Passwort, und die Zuordnung
"diese AD-Gruppe wird Administrator"). Was in der Datei sonst noch steht, wird nicht
stillschweigend verworfen, sondern im Bericht benannt -- damit sichtbar bleibt,
was von Hand nachzuziehen ist.
Die Freigaben standen bis 2.8.0 auf der Liste "von Hand nachtragen", obwohl es
dafuer keinen Grund gab: der Vorgaenger fuehrt sie in
``ldap_fileshare_mappings`` mit denselben vier Angaben, die diese Anwendung in
``fileshare_mappings`` braucht (Verzeichnisgruppe, deren Name, Beschriftung,
UNC-Pfad), und einen Import dafuer gibt es hier ohnehin. Was sich uebernehmen
laesst, soll man nicht abtippen muessen -- ein UNC-Pfad von Hand ist ein
Tippfehler, der erst auffaellt, wenn jemand die Freigabe braucht.
"""
from __future__ import annotations
@@ -36,7 +46,7 @@ LEGACY_KDF_ITERATIONS = 390000
LEGACY_APP_TAG = "tesm"
#: Diese Abschnitte werden uebersetzt.
SUPPORTED = ("credentials", "switches", "devices", "dhcp")
SUPPORTED = ("credentials", "switches", "devices", "dhcp", "ldap")
#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
KNOWN_UNSUPPORTED: dict[str, str] = {
@@ -46,10 +56,9 @@ KNOWN_UNSUPPORTED: dict[str, str] = {
"Zweck. Konten neu anlegen."
),
"groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
"ldap": (
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von "
"Hand übertragen."
),
# Der Abschnitt "ldap" wird uebersetzt (siehe _verzeichnis) -- was dabei
# nicht mitkommt, benennt die Uebersetzung einzeln und genauer, als eine
# pauschale Zeile hier es koennte.
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
"logs": "Auditlog: bleibt bewusst beim alten System.",
"known_hosts": (
@@ -272,6 +281,168 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
return ergebnis
def _verzeichnis(data: Any, report: Report) -> dict[str, Any]:
"""Die Anbindung an das Verzeichnis -- so weit sie sich abbilden laesst.
Zwei Stellen sind **nicht** 1:1, und beide werden benannt statt geraten:
* **Transport.** Der Vorgaenger kannte ``ldap_use_ssl`` (an/aus). Aus "an"
wird LDAPS. Aus "aus" wird **StartTLS auf demselben Port** und nicht
"unverschluesselt": diese Anwendung verlangt fuer eine unverschluesselte
Verbindung einen ausdruecklichen Schalter, weil das Passwort dabei im
Klartext ueber das Netz geht. Diesen Schalter setzt eine Uebernahme
nicht fuer jemanden -- ein Active Directory beantwortet StartTLS auf 389
ohnehin, und wenn nicht, sagt der Verbindungstest es sofort.
* **Standard- und Anmeldegruppe.** Es gibt sie hier nicht als
Einstellung: die Standardgruppe ist ein Kennzeichen an der Gruppe
selbst, und eine erzwungene Anmeldegruppe kennt diese Anwendung nicht.
Waren sie gesetzt, steht es im Bericht.
"""
if not isinstance(data, dict):
return {}
alt = {str(k): v for k, v in (data.get("settings") or {}).items()}
ergebnis: dict[str, Any] = {}
einstellungen: dict[str, Any] = {}
def ja(schluessel: str) -> bool:
return str(alt.get(schluessel) or "").strip().lower() in {"1", "true", "yes", "on"}
server = str(alt.get("ldap_server") or "").strip()
if server:
einstellungen["ldap_host"] = server
if str(alt.get("ldap_port") or "").strip().isdigit():
einstellungen["ldap_port"] = int(str(alt["ldap_port"]).strip())
if "ldap_enabled" in alt:
einstellungen["ldap_enabled"] = ja("ldap_enabled")
if str(alt.get("ldap_base_dn") or "").strip():
einstellungen["ldap_base_dn"] = str(alt["ldap_base_dn"]).strip()
if str(alt.get("ldap_user_filter_attr") or "").strip():
einstellungen["ldap_user_attribute"] = str(alt["ldap_user_filter_attr"]).strip()
if "ldap_use_ssl" in alt:
if ja("ldap_use_ssl"):
einstellungen["ldap_security"] = "ldaps"
else:
einstellungen["ldap_security"] = "starttls"
report.note(
"Der Vorgänger hat unverschlüsselt verbunden. Übernommen als StartTLS auf "
"demselben Port – eine unverschlüsselte Verbindung verlangt hier einen "
"ausdrücklichen Schalter, den eine Übernahme nicht für Sie setzt. Bitte den "
"Verbindungstest ausführen."
)
if ja("ldap_tls_skip_verify"):
einstellungen["ldap_verify_cert"] = False
report.note(
"Die Prüfung des Serverzertifikats war abgeschaltet und bleibt es – sonst "
"scheitert die Anmeldung nach dem Upgrade. Damit schützt die Verschlüsselung "
"nur gegen Mitlesen, nicht gegen einen Man-in-the-Middle: bitte ein "
"CA-Zertifikat hinterlegen und die Prüfung einschalten."
)
if einstellungen:
ergebnis["settings"] = einstellungen
for schluessel, text in (
(
"ldap_default_group",
"Standardgruppe {wert!r}: hier ist das ein Kennzeichen an der Gruppe selbst "
"(Verwaltung -> Gruppen). Bitte dort setzen.",
),
(
"ldap_required_login_group",
"Erzwungene Anmeldegruppe {wert!r}: diese Anwendung kennt sie nicht. Wer sich "
"anmelden darf, entscheidet die Gruppen-Zuordnung.",
),
):
wert = str(alt.get(schluessel) or "").strip()
if wert:
report.note(text.format(wert=wert))
benutzer = str(data.get("bind_username") or alt.get("ldap_bind_dn") or "").strip()
passwort = str(data.get("bind_password") or "")
if benutzer:
ergebnis["bind_username"] = benutzer
ergebnis["bind_password"] = passwort
if not passwort:
report.note(
f"Bind-Konto {benutzer!r} übernommen, aber ohne Passwort – es stand nicht in "
"der Datei. Unter Verwaltung -> Verzeichnisdienst nachtragen."
)
zuordnungen: list[dict[str, Any]] = []
offen: list[str] = []
for eintrag in _liste(data.get("group_mappings")):
dn = str(eintrag.get("ad_group_dn") or "").strip()
if not dn:
continue
name = str(eintrag.get("ad_group_name") or "").strip() or dn
ziel = str(
eintrag.get("app_group_name") or eintrag.get("app_group_id") or ""
).strip()
# Der Vorgaenger kannte den Sonderwert "admin" (bzw. "Admin" im
# Export): diese AD-Gruppe macht Administratoren. Genau dafuer gibt es
# hier seit 2.8.0 ein Ziel -- das Administratorkennzeichen.
if ziel.lower() == "admin":
zuordnungen.append(
{"ad_group_dn": dn, "ad_group_name": name, "grants_admin": True}
)
continue
if ziel:
offen.append(f"{name} -> {ziel}")
if zuordnungen:
ergebnis["group_mappings"] = zuordnungen
if offen:
report.note(
"Diese Gruppen-Zuordnungen zeigen auf lokale Gruppen, die nicht mit übernommen "
"werden (die Rechteschlüssel sind neu geschnitten): "
+ ", ".join(sorted(offen))
+ ". Nach dem Anlegen der Gruppen unter Verwaltung -> Verzeichnisdienst "
"nachtragen."
)
return ergebnis
def _freigaben(data: Any, report: Report) -> list[dict[str, Any]]:
"""Freigabe-Zuordnungen aus dem Abschnitt "ldap" des alten Exports.
Der Vorgaenger legt sie unter ``ldap.fileshare_mappings`` ab; beim Lesen
einer Installation stehen sie unmittelbar in der Tabelle. Beides landet
hier, damit es nur eine Stelle gibt, die die Felder umbenennt.
"""
if isinstance(data, dict):
eintraege = _liste(data.get("fileshare_mappings"))
else:
eintraege = _liste(data)
ergebnis: list[dict[str, Any]] = []
ohne_pfad = 0
for eintrag in eintraege:
# "share_label"/"share_unc" heissen hier "label"/"unc" -- dieselbe
# Angabe, kuerzerer Name, weil die Tabelle schon "fileshare" heisst.
unc = str(eintrag.get("share_unc") or eintrag.get("unc") or "").strip()
gruppe = str(eintrag.get("ad_group_dn") or "").strip()
if not unc or not gruppe:
ohne_pfad += 1
continue
beschriftung = str(eintrag.get("share_label") or eintrag.get("label") or "").strip()
ergebnis.append(
{
"ad_group_dn": gruppe,
"ad_group_name": str(eintrag.get("ad_group_name") or "").strip() or gruppe,
# Ohne Beschriftung der letzte Teil des Pfades -- besser als
# eine Freigabe ohne Namen. Beide Trenner: der Vorgaenger
# speichert "//server/share", getippt wird "\server\share".
"label": beschriftung
or unc.replace(chr(92), "/").rstrip("/").rsplit("/", 1)[-1],
"unc": unc,
}
)
if ohne_pfad:
report.note(
f"{ohne_pfad} Freigabe-Zuordnung(en) ohne Verzeichnisgruppe oder UNC-Pfad "
"übersprungen."
)
return ergebnis
def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
"""DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
if not isinstance(data, dict):
@@ -382,6 +553,14 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
if devices:
payload["devices"] = devices
freigaben = _freigaben(legacy.get("ldap") or legacy.get("fileshare"), report)
if freigaben:
payload["fileshare"] = freigaben
verzeichnis = _verzeichnis(legacy.get("ldap"), report)
if verzeichnis:
payload["directory"] = verzeichnis
dhcp, settings = _dhcp(legacy.get("dhcp"), report)
if dhcp:
payload["dhcp"] = dhcp
@@ -417,8 +596,8 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
if not payload:
raise LegacyTransferError(
"Die Datei enthält keine Clients, Switche, Zugangsdaten oder DHCP-Daten. "
"Beim Export im alten TESM diese Kategorien mitwählen."
"Die Datei enthält keine Clients, Switche, Zugangsdaten, DHCP-Daten oder "
"Freigaben. Beim Export im alten TESM diese Kategorien mitwählen."
)
return payload, report
@@ -617,8 +796,9 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
continue
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
entschluesseln_bind = _entschluessler(root, report)
if "credentials" in vorhanden:
entschluesseln = _entschluessler(root, report)
entschluesseln = entschluesseln_bind
payload["credentials"] = [
{
"name": row["name"],
@@ -634,6 +814,80 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
else:
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
# Der Abschnitt "ldap" -- genau so aufgebaut wie im alten Export, damit
# die Uebersetzung beide Wege gleich liest.
ldap: dict[str, Any] = {}
if "ldap_fileshare_mappings" in vorhanden:
freigaben = [
dict(row)
for row in conn.execute(
"SELECT ad_group_dn, ad_group_name, share_label, share_unc "
"FROM ldap_fileshare_mappings"
).fetchall()
]
if freigaben:
ldap["fileshare_mappings"] = freigaben
else:
report.note("Tabelle 'ldap_fileshare_mappings' fehlt in der alten Datenbank.")
if "settings" in vorhanden:
# Nur anlegen, wenn etwas drinsteht: ein leerer Abschnitt sieht im
# Bericht wie eine Uebernahme aus und ist keine.
ldap_einstellungen = {
str(row["key"]): str(row["value"])
for row in conn.execute(
"SELECT key, value FROM settings WHERE key LIKE 'ldap%'"
).fetchall()
}
if ldap_einstellungen:
ldap["settings"] = ldap_einstellungen
if "ldap_group_mappings" in vorhanden:
# Der Sonderwert "admin" steht in derselben Spalte wie eine
# Gruppen-Id; der Name dahinter ist das, was ueber die
# Systemgrenze traegt.
gruppen_da = "groups" in vorhanden
zuordnungen = [
{
"ad_group_dn": str(row["ad_group_dn"]),
"ad_group_name": str(row["ad_group_name"]),
"app_group_name": str(row["ziel"] or ""),
}
for row in conn.execute(
"SELECT m.ad_group_dn, m.ad_group_name, CASE "
" WHEN m.app_group_id = 'admin' THEN 'admin' "
+ (" ELSE g.name " if gruppen_da else " ELSE NULL ")
+ "END AS ziel FROM ldap_group_mappings m"
+ (
" LEFT JOIN groups g ON g.id = m.app_group_id"
if gruppen_da
else ""
)
).fetchall()
]
if zuordnungen:
ldap["group_mappings"] = zuordnungen
# Das Bind-Konto: erst das Dienstkonto (so liest es auch der alte
# Export), sonst die beiden Einstellungen aus aelteren Fassungen.
if "service_accounts" in vorhanden:
konto = _eine(
conn,
"SELECT username, password FROM service_accounts WHERE purpose='ldap_bind'",
)
if konto is not None:
ldap["bind_username"] = str(konto["username"] or "")
ldap["bind_password"] = entschluesseln_bind(str(konto["password"] or ""))
if not ldap.get("bind_username"):
aus_einstellungen = (ldap.get("settings") or {}).get("ldap_bind_dn") or ""
if aus_einstellungen:
ldap["bind_username"] = str(aus_einstellungen)
ldap["bind_password"] = entschluesseln_bind(
str((ldap.get("settings") or {}).get("ldap_bind_password") or "")
)
if ldap:
payload["ldap"] = ldap
dhcp = _alt_dhcp(conn, vorhanden)
if dhcp:
payload["dhcp"] = dhcp
@@ -644,9 +898,16 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
if not payload:
raise LegacyTransferError(
f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, "
"Zugangsdaten, DHCP)."
"Zugangsdaten, DHCP, Freigaben)."
)
for abschnitt, inhalt in payload.items():
if abschnitt == "ldap" and isinstance(inhalt, dict):
report.took("Freigaben", len(_liste(inhalt.get("fileshare_mappings"))))
report.took("Verzeichniseinstellungen", len(inhalt.get("settings") or {}))
report.took("Gruppen-Zuordnungen", len(_liste(inhalt.get("group_mappings"))))
if inhalt.get("bind_username"):
report.took("Bind-Konto", 1)
continue
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
return payload, report
+60 -2
View File
@@ -507,6 +507,39 @@ 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 run_sweep(
*,
database: Any,
@@ -514,13 +547,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 +576,28 @@ 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
return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port)
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:
+46 -7
View File
@@ -22,7 +22,7 @@ etwa bei einem Geraet, das schreibend auf eine Platte zugreift.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from typing import Any, Callable
METHOD_POE = "poe"
METHOD_SSH = "ssh"
@@ -40,6 +40,16 @@ class Method:
#: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"?
licensed: bool = False
@property
def recht(self) -> str:
"""Das Recht, das genau diesen Weg freigibt.
Am Weg und nicht in der Route: die Liste der Wege wird an vier Stellen
gebraucht (Dashboard, Geraeteliste, Detailseite, Route), und ein Recht,
das man an vier Stellen von Hand dazuschreibt, fehlt an der fuenften.
"""
return f"device_restart_{self.key}.execute"
@property
def is_poe(self) -> bool:
return self.key == METHOD_POE
@@ -78,12 +88,22 @@ DEFINITIONS: dict[str, Method] = {
}
def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
def available(
device: Any,
*,
maintenance_active: bool = True,
darf: Callable[[str], bool] | None = None,
) -> list[Method]:
"""Alle Wege, die bei diesem Geraet tatsaechlich zu etwas fuehren.
Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt
als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist
schlimmer als kein Knopf.
``darf`` filtert zusaetzlich nach dem Recht des jeweiligen Weges (in der
Anwendung ``current_user.has``). Damit verschwindet der Knopf fuer den, der
ihn nicht druecken darf -- und zwar an allen Stellen zugleich, weil alle
dieselbe Liste benutzen. Ohne ``darf`` ist die Antwort rein technisch.
"""
def field(name: str) -> Any:
@@ -102,7 +122,10 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
# der PoE-Weg, und der heisst dort "Einschalten": er schaltet den Port
# wieder ein, statt ein laufendes System neu zu starten.
if str(field("state") or "") == "offline":
return [DEFINITIONS[POWER_ON]] if hat_poe else []
einschalten = [DEFINITIONS[POWER_ON]] if hat_poe else []
if darf is not None:
einschalten = [entry for entry in einschalten if darf(entry.recht)]
return einschalten
category = str(field("credential_category") or "")
has_login = bool(field("credential_id"))
@@ -112,23 +135,39 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
elif category == "linux":
methods.append(DEFINITIONS[METHOD_SSH])
if darf is not None:
methods = [entry for entry in methods if darf(entry.recht)]
return sorted(methods, key=lambda entry: PREFERENCE.index(entry.key))
def preferred(device: Any, *, maintenance_active: bool = True) -> Method | None:
def preferred(
device: Any,
*,
maintenance_active: bool = True,
darf: Callable[[str], bool] | None = None,
) -> Method | None:
"""Der Weg, der vorausgewaehlt wird -- oder ``None``, wenn es keinen gibt."""
options = available(device, maintenance_active=maintenance_active)
options = available(device, maintenance_active=maintenance_active, darf=darf)
return options[0] if options else None
def resolve(device: Any, requested: str, *, maintenance_active: bool = True) -> Method | None:
def resolve(
device: Any,
requested: str,
*,
maintenance_active: bool = True,
darf: Callable[[str], bool] | None = None,
) -> Method | None:
"""Prueft eine Anforderung gegen das, was tatsaechlich moeglich ist.
Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der
fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt --
sonst startet jemand ein Geraet anders neu, als er dachte.
"""
options = {entry.key: entry for entry in available(device, maintenance_active=maintenance_active)}
options = {
entry.key: entry
for entry in available(device, maintenance_active=maintenance_active, darf=darf)
}
if not options:
return None
if not requested:
+123 -9
View File
@@ -30,14 +30,37 @@ from .ssh import SshError, open_transport
logger = logging.getLogger(__name__)
#: Steuerzeichen eines Terminals -- ANSI-Sequenzen, Glocke, Einzelbefehle.
#:
#: Ein ArubaOS-Switch an einem Pseudoterminal zeichnet seine Zeile neu und
#: haengt dabei Sequenzen wie ``ESC [ K`` ("bis Zeilenende loeschen") an. Im
#: Terminal sieht man davon **nichts** -- und genau das war der Fehler, der
#: Konsole und PoE-Neustart lahmgelegt hat: der Prompt stand da, aber hinter
#: ihm standen drei unsichtbare Bytes, und das verankerte Muster traf nicht
#: mehr. Im Protokoll las man "kein Prompt", waehrend der Prompt im Text
#: stand. Deshalb kommen die Steuerzeichen weg, bevor irgendetwas gesucht
#: wird -- das macht zugleich die Ausgabe in der Konsole lesbar.
ANSI_RE = re.compile(rb"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[@-Z\\-_]|[\x07\x00]")
#: Erkennt Operator- (``>``) und privilegierten Modus (``#``) sowie den
#: Konfigurationsmodus (``switch(config)#``).
PROMPT_RE = re.compile(rb"[\r\n][^\r\n]{0,80}?[>#]\s?$")
#:
#: Zwei Lockerungen gegenueber der ersten Fassung, beide aus dem Betrieb:
#: der Prompt darf am Anfang des Puffers stehen (nicht nur nach einem
#: Zeilenumbruch -- so kommt er nach einem geleerten Puffer), und hinter ihm
#: darf beliebiger Leerraum stehen (nicht genau eines).
PROMPT_RE = re.compile(rb"(?:^|[\r\n])[^\r\n]{0,120}?[>#]\s*$")
PAGER_RE = re.compile(rb"(--\s?MORE\s?--|Press any key to continue)", re.IGNORECASE)
ERROR_RE = re.compile(
r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE
)
#: Der Weg nach draussen -- zweimal "exit" fuehrt aus jedem Modus in den
#: privilegierten zurueck, das dritte beendet die Sitzung. Genau so macht es
#: das bewaehrte Skript der Vorgaengerfassung.
ABMELDUNG = ("exit", "exit", "exit")
ABMELDE_PAUSE = 0.15
READ_CHUNK = 65536
DEFAULT_COMMAND_TIMEOUT = 20
POE_SETTLE_SECONDS = 2
@@ -104,7 +127,42 @@ class SwitchCli:
self.close()
def close(self) -> None:
for closer in (self._channel, self._transport):
"""Meldet sich ab und legt dann erst auf.
Der entscheidende Teil, und der einzige, den das ``expect``-Skript der
Fassung 1.2.8 hatte und diese Fassung nicht: ``exit`` bis heraus aus
dem Konfigurationsmodus, dann ``exit``, und auf die Frage
"Do you want to log out (y/n)?" ein ``y``.
Warum das mehr ist als Hoeflichkeit: ArubaOS erlaubt nur eine Handvoll
gleichzeitiger SSH-Sitzungen (``show ip ssh``, Vorgabe fuenf) und
raeumt eine weggebrochene Verbindung **nicht sofort** ab. Wer die
Verbindung nur fallen laesst, belegt den Platz weiter. Nach ein paar
Vorgaengen -- ein Ueberwachungsdurchlauf mit mehreren Geraeten am
selben Switch reicht -- sind alle Plaetze belegt. Danach kommt eine
neue Sitzung noch durch Anmeldung und Authentisierung, bekommt aber
**keine Shell**: kein Prompt, keine Antwort, nichts. Im Betrieb sieht
das aus wie ein stummer Switch, und der Portzyklus passiert nicht.
Fehler werden hier geschluckt: eine Abmeldung, die nicht klappt, darf
das Ergebnis des Vorgangs nicht umwerfen -- aufgelegt wird ohnehin.
"""
kanal = self._channel
if kanal is not None:
try:
for zeile in ABMELDUNG:
kanal.send(zeile + "\r\n")
# Kurz warten, damit der Switch die Zeile verarbeitet --
# auf die Antwort zu warten wuerde beim Abmelden nur
# Zeitlimits sammeln.
time.sleep(ABMELDE_PAUSE)
# "Do you want to log out (y/n)?"
kanal.send("y\r\n")
time.sleep(ABMELDE_PAUSE)
except Exception: # noqa: BLE001 - die Verbindung ist ohnehin am Ende
logger.debug("Abmeldung von %s nicht mehr moeglich", self.host)
for closer in (kanal, self._transport):
try:
if closer is not None:
closer.close()
@@ -116,28 +174,62 @@ class SwitchCli:
# ------------------------------------------------------------------ #
def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str:
"""Liest bis zum Prompt. Ohne Prompt ist es ein Fehler, keine Antwort.
Bis 2.8.2 lief diese Schleife nach dem Zeitlimit einfach aus und gab
zurueck, was bis dahin gekommen war -- oft nichts. Der Aufrufer sah
eine leere Antwort und machte weiter: ``configure terminal`` ins
Nichts, ``no power-over-ethernet`` ins Nichts, und weil die
Zustandsabfrage dann "unknown" ergab und "unknown" als
"nicht widersprochen" durchging, meldete TESM am Ende **Erfolg** --
waehrend am Switch nichts geschaltet wurde. Genau das war im Betrieb
zu sehen: der Client blieb durchgehend erreichbar, das Protokoll sagte
"durchgefuehrt".
Ein Kommando ohne Prompt ist ein Kommando ohne Antwort. Das ist ein
Fehlschlag und wird als solcher gemeldet -- mit dem, was zuletzt
hereinkam, denn daran erkennt man, wo die Sitzung steht.
"""
assert self._channel is not None
deadline = time.monotonic() + timeout
collected = b""
# Zwei Puffer: das Rohe kommt herein, gesucht wird im Sichtbaren.
# Ohne diese Trennung entscheiden unsichtbare Steuerzeichen darueber,
# ob ein Prompt erkannt wird.
sichtbar = b""
prompt_gesehen = False
while time.monotonic() < deadline:
if self._channel.recv_ready():
chunk = self._channel.recv(READ_CHUNK)
if not chunk:
break
collected += chunk
if PAGER_RE.search(collected[-200:]):
sichtbar = ANSI_RE.sub(b"", sichtbar + chunk)
if PAGER_RE.search(sichtbar[-300:]):
self._channel.send(" ")
collected = PAGER_RE.sub(b"", collected)
sichtbar = PAGER_RE.sub(b"", sichtbar)
continue
if PROMPT_RE.search(collected[-200:]):
if PROMPT_RE.search(sichtbar[-300:]):
prompt_gesehen = True
break
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
if initial and collected.rstrip().endswith(b"continue"):
if initial and sichtbar.rstrip().endswith(b"continue"):
self._channel.send("\r\n")
else:
time.sleep(0.05)
text = collected.decode("utf-8", errors="replace")
text = sichtbar.decode("utf-8", errors="replace")
self.transcript.append(text)
if not prompt_gesehen:
letzte = " ".join(text.split())[-200:]
if initial:
raise SshError(
f"{self.host} hat sich nach der Anmeldung nicht gemeldet "
f"(kein Prompt innerhalb von {timeout}s)."
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
)
raise SshError(
f"{self.host} hat auf das Kommando nicht geantwortet "
f"(kein Prompt innerhalb von {timeout}s)."
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
)
return text
def send(
@@ -212,6 +304,17 @@ class SwitchCli:
)
result.transcript = self.full_transcript
return result
if before == "enabled" and off_state == "unknown":
# Vorher lesbar, nachher nicht: dann ist nicht die Firmware
# unbekannt, sondern die Sitzung aus dem Tritt. Das als Erfolg
# zu zaehlen war der Fehler, der einen Neustart meldete, den
# es nicht gab.
result.error = (
"Der Zustand des Ports ließ sich nach dem Abschalten nicht mehr "
"lesen – vorher ging es. Der Portzyklus ist damit nicht belegt."
)
result.transcript = self.full_transcript
return result
result.add(f"Zustand im ausgeschalteten Zustand: {off_state}")
self.set_poe(port, enabled=True)
@@ -224,6 +327,17 @@ class SwitchCli:
result.transcript = self.full_transcript
return result
if before == "unknown" and off_state == "unknown" and after == "unknown":
# Kein einziger Zustand lesbar: die Kommandos hat der Switch
# zwar mit einem Prompt bestaetigt (sonst waere die Sitzung
# oben abgebrochen), aber belegt ist der Zyklus nicht. Das
# gehoert in die Meldung -- "durchgefuehrt" waere zu viel
# gesagt.
result.add(
"Der Zustand war nicht lesbar – die Kommandos hat der Switch "
"angenommen, bestätigt ist der Zyklus damit nicht."
)
result.ok = True
except SshError as exc:
result.error = str(exc)
+155 -4
View File
@@ -14,6 +14,7 @@ from typing import Any, Callable, Iterable
from tesm_core.db import Database
from tesm_core.keystore import Keystore
from tesm_core.rbac.legacy_permissions import umgeschrieben
from tesm_core.transfer import (
MIN_PASSPHRASE_LENGTH,
PREVIEW_TTL_MINUTES,
@@ -61,6 +62,14 @@ CATEGORIES: tuple[Category, ...] = (
),
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
Category(
"directory",
"Verzeichnisdienst",
"LDAP-Anbindung, Bind-Konto und Gruppen-Zuordnungen. Das Passwort des "
"Bind-Kontos nur mit \"Geheimnisse einschliessen\".",
admin_only=True,
sensitive=True,
),
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
@@ -185,6 +194,49 @@ def build_export(
}
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
]
if "directory" in wanted:
# Die Anbindung an das Verzeichnis war das einzige Stueck Konfiguration,
# das man nach einer Wiederherstellung von Hand nachbauen musste --
# inklusive Bind-Passwort, das dabei jemand aus einem Kennwortspeicher
# holen muss. Es gehoert zur Anlage wie die Netzwerkeinstellung.
from tesm_core.auth import directory as verzeichnis
bind_benutzer, bind_passwort = verzeichnis.bind_credentials(conn)
gesetzt = {
str(row["key"]): str(row["value"])
for row in Database.all(conn, "SELECT key, value FROM settings")
}
payload["directory"] = {
# Nur die Schluessel der Anbindung, und nur die, die wirklich
# gesetzt sind: ein Vorgabewert in der Datei sieht aus wie eine
# Entscheidung und ueberschreibt beim Einlesen eine echte.
"settings": {
spec.key: gesetzt[spec.key] for spec in verzeichnis.SETTINGS
if spec.key in gesetzt
},
"bind_username": bind_benutzer,
# Ohne "Geheimnisse einschliessen" reist das Passwort nicht mit.
# Der Benutzername schon: er ist keine Geheimnis, und ohne ihn
# muesste man die halbe Anbindung doch wieder tippen.
"bind_password": bind_passwort if include_secrets else "",
# Die Zuordnung zeigt auf eine Gruppe **oder** auf das
# Administratorkennzeichen. Ueber eine Systemgrenze hinweg ist eine
# Gruppen-Id nichts wert -- der Name ist es.
"group_mappings": [
{
"ad_group_dn": str(row["ad_group_dn"]),
"ad_group_name": str(row["ad_group_name"]),
"group_name": str(row["group_name"] or ""),
"grants_admin": bool(row["grants_admin"]),
}
for row in Database.all(
conn,
"SELECT m.ad_group_dn, m.ad_group_name, m.grants_admin, g.name AS group_name "
"FROM ldap_group_mappings m "
"LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL",
)
],
}
if "settings" in wanted:
payload["settings"] = {
str(row["key"]): str(row["value"])
@@ -254,6 +306,9 @@ def apply_import(
"""
from .inventory import ValidationError, aad_for_credential, normalize_mac
#: Merkt, ob das Paket alte Rechteschluessel mitbringt (siehe unten).
alte_fassung: set[bool] = set()
def mac_or_none(value: Any) -> str | None:
"""MAC normalisieren, ohne den ganzen Import zu sprengen.
@@ -530,19 +585,33 @@ def apply_import(
if "fileshare" in wanted:
for entry in payload.get("fileshare", []):
gruppe = str(entry.get("ad_group_dn") or "")
pfad = str(entry.get("unc") or "")
if not gruppe or not pfad:
bump("fileshare", "skipped")
continue
# Vorher nachsehen, damit der Bericht die Wahrheit sagt: bisher
# stand dort auch bei einer frischen Installation "2 aktualisiert",
# obwohl zwei Zuordnungen **angelegt** wurden. Wer einen Import
# prueft, liest genau diese Zahlen.
vorhanden = Database.value(
conn,
"SELECT 1 FROM fileshare_mappings WHERE ad_group_dn=? AND unc=? LIMIT 1",
(gruppe, pfad),
)
conn.execute(
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET "
"label=excluded.label, ad_group_name=excluded.ad_group_name",
(
str(entry.get("ad_group_dn") or ""),
gruppe,
str(entry.get("ad_group_name") or ""),
str(entry.get("label") or ""),
str(entry.get("unc") or ""),
pfad,
stamp,
),
)
bump("fileshare", "updated")
bump("fileshare", "updated" if vorhanden else "created")
if "groups" in wanted:
for entry in payload.get("groups", []):
@@ -574,9 +643,21 @@ def apply_import(
group_id = int(existing["id"])
bump("groups", "updated")
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
# Eine Sicherung aus 2.7.x nennt devices.view und devices.edit.
# Ohne die Umschreibung landen lauter unbekannte Schluessel in der
# Gruppe, und sanitize() wirft Unbekanntes still weg: die
# wiederhergestellte Gruppe waere leer, ohne eine Zeile Meldung.
mitgebracht = [str(recht) for recht in entry.get("permissions", []) or []]
neue = umgeschrieben(mitgebracht)
if sorted(set(mitgebracht)) != neue:
# Das Paket kennt die alten Schluessel -- es stammt also von
# vor 2.8.0. Ein Versionsfeld gibt es im Umschlag nicht, aber
# die Schluessel selbst sind das verlaesslichere Kennzeichen:
# sie stehen genau dann drin, wenn die Sicherung aelter ist.
alte_fassung.add(True)
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(group_id, str(permission)) for permission in entry.get("permissions", [])],
[(group_id, permission) for permission in neue],
)
if "users" in wanted:
@@ -674,6 +755,63 @@ def apply_import(
)
bump("signatures", "created")
if "directory" in wanted:
from tesm_core.auth import directory as verzeichnis
abschnitt = payload.get("directory") or {}
bekannt = {spec.key for spec in verzeichnis.SETTINGS}
for key, value in (abschnitt.get("settings") or {}).items():
if str(key) not in bekannt:
# Ein fremder Schluessel in diesem Abschnitt gehoert nicht hier
# hin; die Kategorie "Einstellungen" nimmt alles andere.
bump("directory", "skipped")
continue
set_setting(str(key), value)
bump("directory", "updated")
benutzer = str(abschnitt.get("bind_username") or "").strip()
passwort = str(abschnitt.get("bind_password") or "")
if benutzer:
# Ohne Passwort bleibt ein vorhandenes stehen -- genau dafuer nimmt
# store_bind_credentials None. Eine Sicherung ohne Geheimnisse darf
# das Passwort nicht loeschen.
verzeichnis.store_bind_credentials(conn, benutzer, passwort or None)
bump("directory", "updated")
for eintrag in abschnitt.get("group_mappings") or []:
dn = str(eintrag.get("ad_group_dn") or "").strip()
if not dn:
bump("directory", "skipped")
continue
kennzeichen = bool(eintrag.get("grants_admin"))
gruppe = None
if not kennzeichen:
name = str(eintrag.get("group_name") or "").strip()
gruppe = Database.value(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (name,)
)
if gruppe is None:
# Eine Zuordnung auf eine Gruppe, die es hier nicht gibt,
# waere eine Zuordnung ins Leere: beim naechsten Login
# bekaeme das Konto nichts, und niemand wuesste warum.
bump("directory", "skipped")
continue
conn.execute(
"INSERT INTO ldap_group_mappings "
"(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn) DO UPDATE SET "
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id, "
"grants_admin=excluded.grants_admin",
(
dn,
str(eintrag.get("ad_group_name") or dn),
int(gruppe) if gruppe is not None else None,
int(kennzeichen),
stamp,
),
)
bump("directory", "created")
if "settings" in wanted:
for key, value in (payload.get("settings") or {}).items():
if str(key).startswith("license_"):
@@ -683,4 +821,17 @@ def apply_import(
set_setting(str(key), value)
bump("settings", "updated")
if alte_fassung and "users" in wanted:
# Dieselbe Ueberlegung wie in der Migration: in der Welt, aus der
# diese Sicherung kommt, konnte ein Administrator alles. Ohne die
# Gruppe fehlt ihm nach dem Einlesen die halbe Anwendung -- und er
# haelt das fuer einen Fehler der Wiederherstellung.
from tesm_core.rbac import service as rbac
from ..permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
rbac.funktionsgruppe_fuer_administratoren(
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
)
return report
+408
View File
@@ -0,0 +1,408 @@
"""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 = ""
@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 OSError:
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 ""),
)
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)
+31
View File
@@ -45,6 +45,37 @@ 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_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(
@@ -84,22 +84,7 @@
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
Details
</a>
{% if current_user.has('devices.execute') and 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.edit') and tile.state == 'disabled' %}
{% 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>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -108,6 +93,40 @@
</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. #}
<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>
{% 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 %}
@@ -79,7 +79,7 @@ davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
data-confirm-style="primary">{{ icon('eye', size=15) }}</button>
</form>
{% endif %}
{% if current_user.has('credentials.edit') %}
{% if current_user.has('credentials.write') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="credential-edit-{{ entry.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }}
@@ -121,7 +121,7 @@ davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
</form>
{% endcall %}
{% endif %}
{% if current_user.has('credentials.edit') %}
{% if current_user.has('credentials.write') %}
{% for entry in credentials %}
{% call modal('credential-edit-' ~ entry.id, entry.name) %}
<form method="post" action="{{ url_for('credentials.update', credential_id=entry.id) }}">
@@ -11,12 +11,18 @@
{% endblock %}
{% block topbar_extra %}
{% if terminal_enabled and current_user.has('maintenance.execute') and device.credential_id %}
{% if terminal_enabled and current_user.has('device_console.execute') and device.credential_id %}
<a class="btn btn--sm" href="{{ url_for('terminal.device_console', device_id=device.id) }}">
{{ icon('terminal', size=15) }} Konsole
</a>
{% endif %}
{% if current_user.has('devices.execute') and restart_methods %}
{% 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"
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
@@ -138,7 +144,7 @@
Ohne freigegebenen Schlüssel verweigern Wartung und Konsole die Verbindung.
</div>
</div>
{% if current_user.has('devices.edit') %}
{% if current_user.has('devices.write') %}
<form method="post" action="{{ url_for('devices.probe_host_key', device_id=device.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -151,7 +157,7 @@
<div class="card__body stack">
{% set probed_type = request.args.get('probed_type') %}
{% set probed_fingerprint = request.args.get('probed_fingerprint') %}
{% if probed_type and probed_fingerprint and current_user.has('devices.edit') %}
{% if probed_type and probed_fingerprint and current_user.has('devices.write') %}
<div class="alert alert--warning">
{{ icon('alert', size=18) }}
<div class="alert__body">
@@ -182,7 +188,7 @@
<dd class="mono fs-xs break-all">{{ key.fingerprint }}</dd>
{% endfor %}
</dl>
{% if current_user.has('devices.edit') %}
{% if current_user.has('devices.write') %}
<form method="post"
action="{{ url_for('devices.forget_host_key', device_id=device.id) }}"
data-no-guard>
@@ -206,7 +212,7 @@
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div>
{% if current_user.has('devices.edit') %}
{% if current_user.has('devices.write') %}
<a class="btn btn--sm" href="{{ url_for('devices.overview') }}">Bearbeiten</a>
{% endif %}
</div>
@@ -225,6 +231,29 @@
<dt>Kategorie</dt><dd>{{ device.category or '&ndash;' | safe }}</dd>
<dt>Standort</dt><dd>{{ device.location or '&ndash;' | 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>
@@ -232,7 +261,7 @@
</dl>
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
</div>
{% if current_user.has('devices.edit') %}
{% if current_user.has('devices.write') %}
<div class="card__footer">
<form method="post" action="{{ url_for('devices.toggle_active', device_id=device.id) }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -213,10 +213,10 @@
{% endif %}
</td>
<td class="actions">
{% if current_user.has('devices.execute') %}
{% if restart_methods.get(device.id) %}
{{ restart_control(device, restart_methods.get(device.id, []), compact=True) }}
{% endif %}
{% if current_user.has('devices.edit') %}
{% if current_user.has('devices.write') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="device-edit-{{ device.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }}
@@ -263,7 +263,7 @@ nicht -- damit kein Neustart vorgetäuscht wird, der nichts bewirkt.', 'info') }
{% endcall %}
{% endif %}
{% if current_user.has('devices.edit') %}
{% if current_user.has('devices.write') %}
{% for device in devices %}
{% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %}
<form method="post" action="{{ url_for('devices.update', device_id=device.id) }}">
+11 -11
View File
@@ -27,7 +27,7 @@
<div class="stat">
<span class="stat__label">Reservierungen</span>
<span class="stat__value">{{ reservations | length }}</span>
<span class="stat__hint">automatisch + manüll</span>
<span class="stat__hint">automatisch + manuell</span>
</div>
<div class="stat">
<span class="stat__label">Leases</span>
@@ -39,7 +39,7 @@
</div>
</div>
{% if current_user.has('dhcp.execute') %}
{% if current_user.has('dhcp_service.execute') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -89,7 +89,7 @@
<td class="mono fs-sm">{{ subnet.dns or '&ndash;' | safe }}</td>
<td>{% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %}</td>
<td class="actions">
{% if current_user.has('dhcp.edit') %}
{% if current_user.has('dhcp_subnets.write') %}
<form method="post" action="{{ url_for('dhcp.delete_subnet', subnet_id=subnet.id) }}"
style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -109,7 +109,7 @@
{{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }}
{% endif %}
{% if current_user.has('dhcp.edit') %}
{% if current_user.has('dhcp_subnets.create') %}
<form method="post" action="{{ url_for('dhcp.add_subnet') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
@@ -135,7 +135,7 @@
</section>
{# -- Globale Einstellungen --------------------------------------------- #}
{% if current_user.has('dhcp.edit') %}
{% 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>
@@ -183,7 +183,7 @@
</td>
<td class="fs-xs mono">{{ option.type }}</td>
<td>
{% if current_user.has('dhcp.edit') %}
{% 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() }}">
@@ -197,7 +197,7 @@
{% endif %}
</td>
<td class="actions">
{% if current_user.has('dhcp.edit') and not option.is_standard %}
{% 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() }}">
@@ -213,7 +213,7 @@
</tbody>
</table>
</div>
{% if current_user.has('dhcp.edit') %}
{% if current_user.has('dhcp_options.write') %}
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
@@ -339,7 +339,7 @@
{% endif %}
</td>
<td class="actions">
{% if reservation.source == 'manüll' and current_user.has('dhcp.edit') %}
{% if reservation.source == 'manuell' and current_user.has('dhcp_reservations.delete') %}
<form method="post" data-no-guard
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -355,7 +355,7 @@
</tbody>
</table>
</div>
{% if current_user.has('dhcp.edit') %}
{% if current_user.has('dhcp_reservations.create') %}
<form method="post" action="{{ url_for('dhcp.add_reservation') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
@@ -434,7 +434,7 @@
{{ icon('chevron-down', size=16) }}
</summary>
<pre class="log-view">{{ preview }}</pre>
{% if current_user.has('dhcp.edit') %}
{% if current_user.has('dhcp_config.execute') %}
<div class="card__footer">
<form method="post" action="{{ url_for('dhcp.write_config') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -5,7 +5,7 @@
{% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %}
{% block topbar_extra %}
{% if current_user.has('fileshare.edit') %}
{% if current_user.has('fileshare.write') %}
<a class="btn btn--sm" href="{{ url_for('fileshare.mappings') }}">
{{ icon('settings', size=15) }} Zuordnungen
</a>
@@ -152,7 +152,7 @@
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
{% endif %}
{% if current_user.has('fileshare.edit') %}
{% if current_user.has('fileshare.write') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-rename"
data-fill-Pfad="{{ entry.relative }}"
@@ -237,7 +237,7 @@
<div class="modal__body modal__body--flush" data-viewer
{# Ohne das Recht zum Ausfuellen zeigt die Vorschau das Dokument so,
wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #}
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.view') else '' }}"
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.read') else '' }}"
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
data-viewer-konto="{{ current_user.username }}"
@@ -330,7 +330,7 @@
PDF-Formulare/Bearbeiten, "abgeschlossen" an Anlegen. Wer nur
anlegen darf, sieht eine Zeile statt einer Auswahl. #}
<select class="select" data-viewer-modus>
{% if current_user.has('fileshare_pdf.edit') %}
{% if current_user.has('fileshare_pdf.write') %}
<option value="formular">Formular – weiter ausfüllbar</option>
{% endif %}
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
@@ -374,10 +374,16 @@
{{ icon('download', size=15) }} Herunterladen
</a>
{% endif %}
{% if current_user.has('fileshare_pdf.create') %}
{# Unterschreiben ist eine eigene Zeile der Matrix: wer ausgefuellte
Formulare ablegen darf, darf damit nicht unterschreiben. Der Server
weist es ohnehin ab -- aber ein Knopf, der immer in eine Meldung
laeuft, ist keiner. #}
{% if current_user.has('fileshare_signature.execute') %}
<button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden>
{{ icon('pencil', size=15) }} Unterschreiben
</button>
{% endif %}
{% if current_user.has('fileshare_pdf.create') %}
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
data-url="{{ url_for('fileshare.save_form', token=token) }}">
{{ icon('save', size=15) }} Ausgefüllt speichern
@@ -388,7 +394,7 @@
{% endcall %}
{% endif %}
{% if listing and current_user.has('fileshare.edit') %}
{% if listing and current_user.has('fileshare.write') %}
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -33,7 +33,7 @@ Einbindungen.', 'info') }}
<td class="fs-sm">{{ entry.ad_group_name }}
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
<td class="actions">
{% if current_user.has('fileshare.edit') %}
{% if current_user.has('fileshare.write') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="mapping-edit-{{ entry.id }}"
aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button>
@@ -80,7 +80,7 @@ Einbindungen.', 'info') }}
{# Bearbeiten statt neu anlegen: bei einem Tippfehler im UNC-Pfad oder im
Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen
zweiten Eintrag an und vergisst, den ersten zu löschen. #}
{% if current_user.has('fileshare.edit') %}
{% if current_user.has('fileshare.write') %}
{% for entry in mappings %}
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
<form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}">
@@ -160,7 +160,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
<td class="mono fs-xs break-all">{{ row.fingerprint }}</td>
<td class="fs-sm">{{ row.added_at | dt }} <span class="faint">von {{ row.added_by }}</span></td>
<td>
{% if current_user.has('devices.edit') or current_user.has('switches.edit') %}
{% if current_user.has('devices.write') or current_user.has('switches.write') %}
<form method="post" action="{{ url_for('hostkeys.forget_orphan') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="host" value="{{ row.host }}">
+13 -4
View File
@@ -60,9 +60,18 @@
<dd>
{% if state.modules %}
{% for module in modules %}
{% if module in state.modules %}{{ pill(module_labels[module], 'online') }}
{% if module in state.modules and module in state.modules_off %}
{{ pill(module_labels[module] ~ ' (abgeschaltet)', 'warning') }}
{% elif module in state.modules %}{{ pill(module_labels[module], 'online') }}
{% else %}{{ pill(module_labels[module], '') }}{% endif %}
{% endfor %}
{% if state.modules_off %}
<div class="fs-xs faint">
Abgeschaltet wurde in
<a href="{{ url_for('settings.system') }}">Verwaltung → System</a>;
die Lizenz ist davon unberührt.
</div>
{% endif %}
{% else %}<span class="faint">keine Zusatzmodule</span>{% endif %}
</dd>
<dt>Lizenzserver</dt>
@@ -76,7 +85,7 @@
</dd>
</dl>
</div>
{% if current_user.has('settings_license.edit') %}
{% if current_user.has('settings_license.write') %}
<div class="card__footer">
{% if not state.activated %}
<form method="post" action="{{ url_for('license.activate') }}" data-no-guard>
@@ -155,7 +164,7 @@
</section>
{% endif %}
{% if current_user.has('settings_license.edit') %}
{% if current_user.has('settings_license.write') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -190,7 +199,7 @@
</section>
{% endif %}
{% if current_user.has('settings_license.edit') %}
{% if current_user.has('settings_license.write') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -5,7 +5,7 @@
{% block topbar_extra %}{{ search_box('restart-table', 'Gerät, Ergebnis, Methode ...') }}{% endblock %}
{% block content %}
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
wie manüll, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
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') }}
<section class="card">
@@ -54,7 +54,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<span>Alle auswählen</span>
</label>
<span class="table-toolbar__spacer"></span>
{% if current_user.has('maintenance.execute') %}
{% if current_user.has('servicing_update.execute') %}
<button class="btn btn--sm btn--primary" type="submit"
formaction="{{ url_for('maintenance.start_update') }}"
title="Nur für Linux-Clients -- Windows-Geräte werden dabei übersprungen."
@@ -91,7 +91,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<label class="check">
<input type="checkbox" name="device_ids" value="{{ device.id }}"
class="device-select" data-category="{{ device.category }}"
{% if not current_user.has('maintenance.execute') %}disabled{% endif %}>
{% if not current_user.has('servicing_update.execute') %}disabled{% endif %}>
<span class="sr-only">{{ device.name }} auswählen</span>
</label>
</td>
@@ -121,7 +121,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
</td>
<td class="nowrap">
{% if terminal_enabled and device.ssh_managed
and current_user.has('maintenance.execute') %}
and current_user.has('servicing_update.execute') %}
<a class="btn btn--sm btn--ghost"
href="{{ url_for('terminal.device_console', device_id=device.id) }}">
{{ icon('terminal', size=14) }} Konsole
@@ -0,0 +1,113 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import alert, pill %}
{% 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 %}
{# Die Zugangsdaten stehen in einem eigenen JSON-Block statt in einem
data-Attribut: ein Attribut steht im Quelltext der Seite zwischen der
Beschriftung und wird beim Kopieren einer Zeile mitgenommen. Beides ist
derselbe Inhalt -- der Unterschied ist, wie leicht er einem versehentlich
unter die Augen kommt. #}
<script type="application/json" id="screen-credentials-data">{{ vorgaben | tojson }}</script>
<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">
<button class="btn btn--sm" type="button" id="screen-cad" hidden
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
<button class="btn btn--sm" type="button" id="screen-disconnect" hidden>Trennen</button>
{# 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">
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">
<div id="screen-status" class="screen__status">Wird vorbereitet …</div>
<form id="screen-credentials" class="row row--tight" hidden onsubmit="return false">
<label class="field">
<span class="field__label">Benutzer</span>
<input class="input" type="text" id="screen-username" autocomplete="off"
value="{{ vorgaben.username }}">
</label>
<label class="field">
<span class="field__label">Passwort</span>
<input class="input" type="password" id="screen-password" autocomplete="off">
</label>
<button class="btn btn--primary" type="button" id="screen-send">Anmelden</button>
</form>
<div id="screen" class="screen"
data-socket="{{ websocket_path }}"
data-ziel="{{ zugang.ziel }}:{{ zugang.port }}"
data-verfahren="{{ zugang.verfahren | join(',') }}"
data-blockiert="{{ '1' if zugang.hinderungsgrund else '0' }}"
data-grund="{{ zugang.hinderungsgrund }}">
<div id="screen-canvas" class="screen__canvas" hidden></div>
</div>
{% 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 %}
@@ -41,16 +41,55 @@
</div>
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
checked=values.monitor_auto_restart,
hint_text='Zusätzlich lizenzpflichtig. Der manülle Knopf im Dashboard
hint_text='Zusätzlich lizenzpflichtig. Der manuelle Knopf im Dashboard
funktioniert unabhängig davon.') }}
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
checked=values.dashboard_public,
hint_text=specs['dashboard_public'].hint) }}
{{ 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) }}
</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>
{% if module_rows %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('puzzle', size=17) }} Module</div>
<div class="card__sub">
Die Lizenz sagt, was erlaubt ist – hier steht, was dieses Haus benutzt.
Ein abgeschaltetes Modul verschwindet aus der Navigation, und seine Seiten
antworten wie ohne Lizenz. Bestehende Daten bleiben unverändert stehen und
sind nach dem Wiedereinschalten wieder da.
</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.modules_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{% for modul in module_rows %}
{{ checkbox('module_' ~ modul.key, modul.label, checked=modul.enabled,
hint_text=('In der Lizenz enthalten.' if modul.licensed
else 'Nicht lizenziert – bleibt aus, bis die Lizenz es
freischaltet. Der Haken gilt dann sofort.')) }}
{% endfor %}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
{% endif %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -189,7 +228,7 @@
</section>
{% if current_user.is_admin %}
{{ nav_order_card(nav_items, nav_order, nav_child_order,
{{ nav_order_card(nav_baum, nav_order, nav_child_order,
url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
{% endif %}
</div>
@@ -11,7 +11,7 @@
{% endblock %}
{% block topbar_extra %}
{% if terminal_enabled and current_user.has('switches.execute') and host_keys %}
{% if terminal_enabled and current_user.has('switch_console.execute') and host_keys %}
<a class="btn btn--sm btn--primary" href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
{{ icon('terminal', size=15) }} Konsole
</a>
@@ -32,7 +32,7 @@
Ohne freigegebenen Schlüssel verweigert die Anwendung jeden Zugriff auf diesen Switch.
</div>
</div>
{% if current_user.has('switches.edit') %}
{% if current_user.has('switches.write') %}
<form method="post" action="{{ url_for('switches.probe_host_key', switch_id=switch.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -53,7 +53,7 @@
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsolenzugang am
Switch, Inventardokumentation), bevor Sie ihn freigeben.
</p>
{% if current_user.has('switches.edit') %}
{% if current_user.has('switches.write') %}
<form method="post" class="mt-2"
action="{{ url_for('switches.trust_host_key', switch_id=switch.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -78,7 +78,7 @@
</dd>
{% endfor %}
</dl>
{% if current_user.has('switches.edit') %}
{% if current_user.has('switches.write') %}
<form method="post" action="{{ url_for('switches.forget_host_key', switch_id=switch.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -95,7 +95,7 @@
</div>
</section>
{% if current_user.has('switches.execute') %}
{% if current_user.has('switch_console.execute') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -83,13 +83,13 @@ stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
{% endif %}
</td>
<td class="actions">
{% if terminal_enabled and current_user.has('switches.execute') and switch.host_key_known %}
{% if terminal_enabled and current_user.has('switch_console.execute') and switch.host_key_known %}
<a class="btn btn--sm btn--ghost" title="Konsole"
href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
{{ icon('terminal', size=15) }}
</a>
{% endif %}
{% if current_user.has('switches.edit') %}
{% if current_user.has('switches.write') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="switch-edit-{{ switch.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }}
@@ -135,7 +135,7 @@ stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
</form>
{% endcall %}
{% endif %}
{% if current_user.has('switches.edit') %}
{% if current_user.has('switches.write') %}
{% for switch in switches %}
{% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %}
<form method="post" action="{{ url_for('switches.update', switch_id=switch.id) }}">
+2 -1
View File
@@ -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), "
+5
View File
@@ -11,6 +11,7 @@ Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
| Speicherung | Argon2id (argon2-cffi) |
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
| Rechte | Eine Zeile je Funktion; das Administratorkennzeichen gibt den Verwaltungsbereich, nicht alles |
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
@@ -171,6 +172,10 @@ braucht der Dienst kein `--workers 1` mehr.
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
Ein echter Administrator darf sich Funktionsrechte über eine Gruppe geben --
das ist gewollt und hinterlässt eine Zeile im Änderungsprotokoll. Was es
nicht gibt, ist der stille Weg: ein Kennzeichen, das ohne Eintrag alles
aufschließt.
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
nicht brechen.
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.7.8"
version = "2.8.8"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -102,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
),
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
SettingSpec(
"modules_disabled",
[],
"json",
"Vor Ort abgeschaltete Module",
hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.",
section="system",
),
SettingSpec(
"license_server_ca",
"",
@@ -26,6 +26,7 @@ import re
import socket
import sqlite3
import ssl
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal
@@ -162,19 +163,82 @@ def _tls_object(config: dict[str, Any]):
def _server(config: dict[str, Any]):
"""Das Server-Objekt -- so sparsam wie moeglich.
``get_info`` entscheidet, was ldap3 beim Verbinden **zusaetzlich** holt.
Mit ``ALL`` ist das die Beschreibung des Verzeichnisses *und sein ganzes
Schema*: bei einem Active Directory sind das Tausende Attribut- und
Klassendefinitionen, mehrere Megabyte, bei jeder Verbindung neu. Fuer eine
Anmeldung braucht davon nichts -- gelesen wird hoechstens der
``defaultNamingContext``, wenn kein Basis-DN eingetragen ist, und dafuer
genuegt ``DSA`` (nur die Serverinformation, ein Datensatz).
Liegt der Domain-Controller in einem anderen Netz, ist genau das der
Unterschied zwischen "geht sofort" und "dauert manchmal".
"""
from ldap3 import Server
security = str(config["ldap_security"])
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
return Server(
str(config["ldap_host"]),
port=int(config["ldap_port"]),
use_ssl=security == "ldaps",
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
get_info="ALL",
get_info="DSA" if braucht_basis else "NO_INFO",
connect_timeout=int(config["ldap_timeout"]),
)
#: Fehler, die nur "gerade jetzt nicht" bedeuten -- nicht "falsches Passwort".
#:
#: Die Liste stammt aus der Fassung 1.2.8, wo dieses Problem schon einmal
#: geloest war. Der Kommentar dort beschreibt es genauer, als ich es koennte:
#: ein Domain-Controller in einem anderen, langsameren Netzsegment -- die
#: allererste Verbindung dorthin ist oft spuerbar langsamer als jede folgende,
#: und der erste Login schlug mit "Benutzer/Passwort ungueltig" fehl, waehrend
#: ein sofortiger zweiter Versuch klappte.
TRANSIENTE_FEHLER: tuple[type[Exception], ...] = ()
def _transiente_fehler() -> tuple[type[Exception], ...]:
"""Die Ausnahmeklassen von ldap3, die einen Wiederholversuch verdienen."""
global TRANSIENTE_FEHLER
if TRANSIENTE_FEHLER:
return TRANSIENTE_FEHLER
from ldap3.core.exceptions import (
LDAPResponseTimeoutError,
LDAPSocketOpenError,
LDAPSocketReceiveError,
LDAPSocketSendError,
)
TRANSIENTE_FEHLER = (
LDAPSocketOpenError,
LDAPSocketReceiveError,
LDAPSocketSendError,
LDAPResponseTimeoutError,
)
return TRANSIENTE_FEHLER
def mit_wiederholung(aufbau: Any) -> Any:
"""Genau ein zweiter Versuch -- und nur bei einem Netzfehler.
Nicht bei einer abgelehnten Anmeldung: ein Tippfehler im Passwort wuerde
sonst den Sperrzaehler des Kontos zweimal statt einmal hochzaehlen. Das
ist die Regel aus 1.2.8, und sie ist richtig.
"""
try:
return aufbau()
except _transiente_fehler() as fehler:
logger.info(
"Verzeichnisdienst antwortete nicht (%s) -- ein zweiter Versuch",
type(fehler).__name__,
)
return aufbau()
def _connect(config: dict[str, Any], user: str, password: str):
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
from ldap3 import Connection
@@ -192,6 +256,12 @@ def _connect(config: dict[str, Any], user: str, password: str):
auto_bind=False,
raise_exceptions=False,
receive_timeout=int(config["ldap_timeout"]),
# Ein Active Directory antwortet auf eine Suche ab der Domaenenwurzel
# mit Verweisen auf andere Controller. ldap3 folgt ihnen von sich aus
# -- und baut dafuer neue Verbindungen auf, womoeglich in ein anderes
# Netz. Fuer eine Anmeldung ist das nicht nur unnoetig, sondern die
# Quelle genau der Wartezeiten, die niemand erklaeren kann.
auto_referrals=False,
)
if security == "starttls":
if not connection.open():
@@ -253,8 +323,19 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
if not config["ldap_verify_cert"]:
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
begonnen = time.monotonic()
zeiten: list[str] = []
def gemessen(name: str, seit: float) -> float:
"""Haelt eine Stufe mit ihrer Dauer fest und gibt die neue Marke."""
jetzt = time.monotonic()
zeiten.append(f"{name} {int((jetzt - seit) * 1000)}ms")
return jetzt
try:
service_conn, bound = _connect(config, bind_user, bind_password)
service_conn, bound = mit_wiederholung(
lambda: _connect(config, bind_user, bind_password)
)
except (PermissionError, ConnectionError) as exc:
logger.error("Verzeichnisdienst: %s", exc)
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
@@ -271,8 +352,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
warnings=tuple(warnings),
)
marke = gemessen("Dienstbindung", begonnen)
try:
base = _base_dn(service_conn, config)
marke = gemessen("Basis-DN", marke)
if not base:
return DirectoryResult(
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
@@ -288,8 +371,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
attributes=list(dict.fromkeys(wanted)),
size_limit=2,
)
marke = gemessen("Benutzersuche", marke)
entries = list(service_conn.entries)
if len(entries) != 1:
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
return DirectoryResult("invalid", warnings=tuple(warnings))
entry = entries[0]
@@ -300,9 +385,18 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
user_conn, user_bound = _connect(config, dn, password)
#
# Mit Wiederholversuch, und genau hier zeigte sich das Symptom: bei
# einem Domain-Controller in einem anderen Netz schlug der erste
# Versuch mit "Benutzer/Passwort ungueltig" fehl, der zweite gleich
# darauf klappte. Wiederholt wird nur bei einem Netzfehler -- eine
# abgelehnte Anmeldung bleibt abgelehnt, sonst zaehlt ein Tippfehler
# den Sperrzaehler des Kontos zweimal hoch.
user_conn, user_bound = mit_wiederholung(lambda: _connect(config, dn, password))
marke = gemessen("Passwortpruefung", marke)
try:
if not user_bound:
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
return DirectoryResult("invalid", warnings=tuple(warnings))
finally:
try:
@@ -313,6 +407,7 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
group_dns: tuple[str, ...] = ()
if config["ldap_group_sync"]:
group_dns = _resolve_groups(service_conn, base, dn)
marke = gemessen("Gruppen", marke)
profile = DirectoryProfile(
username=_attr(entry, attribute) or username,
@@ -322,6 +417,15 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
email=_attr(entry, email_attribute),
group_dns=group_dns,
)
# Eine Zeile je Anmeldung mit den Dauern der Stufen. Aus "dauert
# manchmal laenger" wird damit eine Zahl -- und man sieht, ob es die
# Verbindung ist, die Suche oder die Gruppen.
logger.info(
"Verzeichnisanmeldung %s: %s, gesamt %dms",
username,
", ".join(zeiten),
int((time.monotonic() - begonnen) * 1000),
)
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
finally:
try:
@@ -423,18 +527,24 @@ def _apply_group_mappings(
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
"""
if not group_dns:
return
mappings = Database.all(
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings"
conn, "SELECT ad_group_dn, app_group_id, grants_admin FROM ldap_group_mappings"
)
if not mappings:
return
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
lowered = {dn.lower() for dn in group_dns}
# Das Kennzeichen zuerst -- es haengt an keiner Gruppe und muss auch dann
# ausgewertet werden, wenn das Konto in keiner zugeordneten Gruppe steht.
_kennzeichen_setzen(conn, user_id, mappings, lowered)
if not group_dns:
return
gruppenzuordnungen = [row for row in mappings if row["app_group_id"] is not None]
if not gruppenzuordnungen:
return
mapped_group_ids = {int(row["app_group_id"]) for row in gruppenzuordnungen}
wanted = {
int(row["app_group_id"])
for row in mappings
for row in gruppenzuordnungen
if str(row["ad_group_dn"]).lower() in lowered
}
@@ -452,6 +562,71 @@ def _apply_group_mappings(
)
def _kennzeichen_setzen(
conn: sqlite3.Connection,
user_id: int,
mappings: list[dict[str, Any]],
gruppen: set[str],
) -> None:
"""Setzt oder entzieht das Administratorkennzeichen nach dem Verzeichnis.
Wirkung wie bei Gruppen: **ersetzend**. Wer aus der zugeordneten
AD-Gruppe herausfaellt, verliert das Kennzeichen bei der naechsten
Anmeldung -- sonst waere ein Austritt im Verzeichnis eine Aenderung, die
hier nie ankommt.
Zwei Grenzen, beide absichtlich:
* Nur **Verzeichniskonten**. Ein lokales Konto (etwa das, das
``tesm-admin create-admin`` anlegt) bleibt unberuehrt -- es ist der
Notausgang, wenn das Verzeichnis nicht erreichbar ist.
* Nie den letzten entsperrten Administrator herunterstufen. Ein
Tippfehler im DN wuerde sonst beim naechsten Login die Anlage
verschliessen, und niemand koennte ihn korrigieren.
"""
zuordnungen = [row for row in mappings if int(row["grants_admin"] or 0) == 1]
if not zuordnungen:
return
soll = any(str(row["ad_group_dn"]).lower() in gruppen for row in zuordnungen)
zeile = Database.one(
conn, "SELECT is_admin, auth_source FROM users WHERE id=?", (user_id,)
)
if zeile is None or str(zeile["auth_source"]) != "ldap":
return
ist = bool(zeile["is_admin"])
if ist == soll:
return
if not soll:
uebrig = Database.value(
conn,
"SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 "
"AND deleted_at IS NULL AND id<>?",
(user_id,),
default=0,
)
if not uebrig:
logger.warning(
"Administratorkennzeichen bleibt bei Benutzer %s: es waere das letzte.",
user_id,
)
return
conn.execute(
"UPDATE users SET is_admin=?, updated_at=? WHERE id=?", (int(soll), _now(), user_id)
)
audit.log(
conn,
actor="system",
action="auth.admin_flag_changed",
target=str(user_id),
detail=(
"Administrator: "
+ ("nein -> ja" if soll else "ja -> nein")
+ " (aus der Verzeichniszuordnung)"
),
severity="warning",
)
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
@@ -77,25 +77,40 @@ class User:
@property
def effective_permissions(self) -> frozenset[str]:
"""Rechte nach Anwendung der Bereichs-Kill-Switches."""
"""Rechte nach Anwendung der Bereichs-Kill-Switches.
Ein echter Administrator hat den Verwaltungsbereich immer dabei -- auch
ohne Gruppe und ohne dass jemand ihn angehakt haette. Sonst koennte
sich das letzte Administratorkonto aus der eigenen Anlage aussperren,
indem es seine Gruppe verliert.
"""
if self._effective is None:
tree = self._tree
self._effective = (
frozenset(tree.effective(self.granted)) if tree is not None else frozenset()
)
if tree is None:
self._effective = frozenset()
else:
eigene = set(tree.effective(self.granted))
if self.is_admin:
eigene |= set(tree.verwaltungsrechte())
self._effective = frozenset(eigene)
return self._effective
def has(self, *permissions: str) -> bool:
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles."""
if self.is_admin:
return True
"""True, wenn **alle** angegebenen Rechte greifen.
Das Administratorkennzeichen gibt seit 2.8.0 **den Verwaltungsbereich**
frei und nicht mehr jede Pruefung: Benutzer, Gruppen, Einstellungen,
Sicherung, Papierkorb, Diagnose. Funktionsrechte -- Geraete, Neustarts,
DHCP, Freigaben -- kommen ueber Gruppen, und die darf sich ein
Administrator selbst zuweisen. Der Unterschied ist nicht, *ob* er sie
bekommen kann, sondern dass es dann eine Zeile im
Aenderungsprotokoll gibt und in der Matrix zu sehen ist.
"""
if not permissions:
return True
return all(permission in self.effective_permissions for permission in permissions)
def has_any(self, permissions: Iterable[str]) -> bool:
if self.is_admin:
return True
effective = self.effective_permissions
return any(permission in effective for permission in permissions)
+144 -2
View File
@@ -141,11 +141,16 @@ CREATE TABLE IF NOT EXISTS service_accounts (
updated_at TEXT NOT NULL
);
-- Eine Verzeichnisgruppe zeigt entweder auf eine lokale Gruppe **oder** auf
-- das Administratorkennzeichen (grants_admin). Darum ist app_group_id
-- nullbar: "Domänen-Admins" soll echte Administratoren erzeugen koennen und
-- nicht nur Mitglieder der Gruppe "Administration".
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL,
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
grants_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
"""
@@ -186,8 +191,145 @@ CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state);
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
"""
def benenne_rechte_um(conn) -> None: # type: ignore[no-untyped-def]
"""Schreibt bestehende Gruppenrechte auf das neue Vokabular um.
``devices.view`` wird ``devices.read``, ``devices.edit`` wird
``devices.write``. Ohne diesen Schritt stehen in den Gruppen nur noch
Schluessel, die der Rechtebaum nicht kennt -- und
:meth:`PermissionTree.sanitize` wirft Unbekanntes **still** weg. Nach dem
Update waeren alle Gruppen leer und niemand kaeme mehr an seine Seiten:
kein Fehler, den man sieht, sondern einer, den man erst merkt, wenn jemand
anruft.
Im Kern und nicht in den Anwendungen, weil die Tabelle beiden gehoert.
Idempotent: was schon neu heisst, wird nicht angefasst.
"""
from .rbac.legacy_permissions import UMBENANNTE_AKTIONEN
for alt, neu in UMBENANNTE_AKTIONEN.items():
# Erst die Dubletten weg, die beim Umschreiben entstehen wuerden: haette
# eine Gruppe (aus welchem Grund auch immer) beide Schluessel, brach der
# eindeutige Index. Lieber vorher aufraeumen als in der Migration
# scheitern.
conn.execute(
"DELETE FROM group_permissions WHERE permission LIKE ? AND EXISTS ("
" SELECT 1 FROM group_permissions AS andere"
" WHERE andere.group_id = group_permissions.group_id"
" AND andere.permission = substr(group_permissions.permission, 1,"
" length(group_permissions.permission) - ?) || ?)",
(f"%.{alt}", len(alt), neu),
)
conn.execute(
"UPDATE group_permissions"
" SET permission = substr(permission, 1, length(permission) - ?) || ?"
" WHERE permission LIKE ?",
(len(alt), neu, f"%.{alt}"),
)
def zuordnung_auf_kennzeichen(conn) -> None: # type: ignore[no-untyped-def]
"""Erlaubt einer Verzeichnisgruppe, das Administratorkennzeichen zu setzen.
Bisher konnte eine Zuordnung nur auf eine lokale Gruppe zeigen. "Domänen-
Admins" landete deshalb in der Gruppe *Administration* -- und wurde damit
gerade **kein** echter Administrator: die Gruppe pflegt Benutzer und
Rechte, hat aber kein Kennzeichen und kann sich keines geben.
Zwei Aenderungen an der Tabelle, und SQLite kann Spalten nicht
umdefinieren: also neu anlegen und umkopieren. ``app_group_id`` wird
nullbar (eine Zuordnung auf das Kennzeichen zeigt auf keine Gruppe), und
``grants_admin`` sagt, dass genau das gemeint ist.
"""
spalten = {
str(zeile[1]) for zeile in conn.execute("PRAGMA table_info(ldap_group_mappings)")
}
if "grants_admin" in spalten:
return
conn.execute(
"""
CREATE TABLE ldap_group_mappings_neu (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL,
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
grants_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"INSERT INTO ldap_group_mappings_neu "
"(id, ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
"SELECT id, ad_group_dn, ad_group_name, app_group_id, 0, created_at "
" FROM ldap_group_mappings"
)
conn.execute("DROP TABLE ldap_group_mappings")
conn.execute("ALTER TABLE ldap_group_mappings_neu RENAME TO ldap_group_mappings")
def teile_rechte_auf(conn) -> None: # type: ignore[no-untyped-def]
"""Verteilt ein altes Sammelrecht auf die Zeilen, die daraus wurden.
Additiv: wer ``dhcp.edit`` hatte, bekommt alle fuenf DHCP-Zeilen, und das
alte Recht verschwindet. Ohne diesen Schritt stuende in der Gruppe ein
Schluessel, den der Baum nicht kennt -- und weg waere er still, denn genau
das tut :meth:`PermissionTree.sanitize` mit Unbekanntem.
Laeuft nach ``benenne_rechte_um``: die Tabelle nennt beide Schreibweisen,
wo es darauf ankommt, und ein zweiter Lauf findet nichts mehr zu tun.
"""
from .rbac.legacy_permissions import (
ERWEITERTE_RECHTE,
GETEILTE_RECHTE,
neue_schluessel,
)
def gruppen_mit(recht: str) -> list[int]:
return [
int(zeile[0])
for zeile in conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?",
(recht,),
).fetchall()
]
# Geteilt: das alte Recht gibt es nicht mehr, also ersetzen.
for altes, neue in GETEILTE_RECHTE.items():
gruppen = gruppen_mit(altes)
if not gruppen:
continue
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(gid, recht) for gid in gruppen for recht in neue],
)
conn.execute("DELETE FROM group_permissions WHERE permission=?", (altes,))
# Erweitert: das Recht bleibt und bekommt dazu, was bisher an ihm hing.
# Loeschen waere hier falsch -- es ist keines von gestern.
for recht, dazu in ERWEITERTE_RECHTE.items():
gruppen = gruppen_mit(recht)
if not gruppen:
continue
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(gid, weiteres) for gid in gruppen for weiteres in dazu],
)
assert neue_schluessel # in derselben Tabelle nachgeschlagen wie beim Import
CORE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1_BASELINE),
Migration(
version=1,
name="baseline",
sql=V1_BASELINE,
geaendert_weil=(
"Das Grundschema legt ldap_group_mappings jetzt mit grants_admin und nullbarer app_group_id an -- eine neue Installation soll nicht erst core/6 brauchen, um die Tabelle wieder umzubauen."
),
),
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
Migration(version=3, name="jobs", sql=V3_JOBS),
Migration(version=4, name="rechte-read-write", fn=benenne_rechte_um),
Migration(version=5, name="rechte-je-funktion", fn=teile_rechte_auf),
Migration(version=6, name="ldap-kennzeichen", fn=zuordnung_auf_kennzeichen),
)
@@ -34,7 +34,7 @@ import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from typing import Any, Callable, Iterable
import tesm_licensing as lic
@@ -54,6 +54,21 @@ SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
EXPIRY_HINT_DAYS = 90
SETTING_EXPORT_GRACE = "license_export_grace"
#: Module, die der Betreiber vor Ort abgeschaltet hat (Liste von Namen).
#:
#: Die Lizenz sagt, *was erlaubt ist*; sie sagt nicht, was ein Haus auch
#: benutzen will. Wer kein DHCP ueber TESM fahren will, weil ein anderer Server
#: das tut, soll den Punkt nicht wegdruecken muessen -- und vor allem soll
#: niemand versehentlich eine fremde Kea-Konfiguration ueberschreiben koennen.
#: Abschalten ist deshalb mehr als Aufraeumen der Navigation: die Sperre sitzt
#: an derselben Stelle wie die Lizenzpruefung, also vor jeder Route.
#: Bewusst **ohne** das Praefix ``license_``: Sicherungen lassen alles mit
#: diesem Praefix draussen, weil Lizenz- und Aktivierungszustand zur Maschine
#: gehoeren und nicht in ein Backup. Der Schalter ist aber keine Lizenzangabe,
#: sondern eine Entscheidung des Hauses -- die soll eine Wiederherstellung
#: mitbringen.
SETTING_MODULES_OFF = "modules_disabled"
STATUS_INACTIVE = "inactive"
STATUS_PENDING = "pending"
STATUS_ACTIVE = "active"
@@ -121,6 +136,8 @@ class LicenseState:
last_heartbeat_at: str = ""
heartbeat_error: str = ""
pending_action: str = ""
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
modules_off: frozenset[str] = frozenset()
@property
def role(self) -> str:
@@ -128,8 +145,14 @@ class LicenseState:
@property
def modules(self) -> frozenset[str]:
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
return self.status.modules if self.status else frozenset()
@property
def modules_in_use(self) -> frozenset[str]:
"""Was tatsaechlich laeuft: lizenziert **und** nicht abgeschaltet."""
return self.modules - self.modules_off
@property
def customer_name(self) -> str:
return str((self.status.customer if self.status else {}).get("name") or "")
@@ -179,6 +202,7 @@ class LicenseManager:
"""Liest Lizenzdatei und Status neu ein."""
with self._lock:
state = LicenseState()
state.modules_off = self.abgeschaltete_module(conn)
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
pending = self._pending(conn)
@@ -239,9 +263,51 @@ class LicenseManager:
)
def module_active(self, name: str) -> bool:
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) in Betrieb?
Zwei Bedingungen, und beide muessen gelten: die Lizenz muss es
freischalten, und der Betreiber darf es nicht abgeschaltet haben. Diese
eine Funktion fragen die Navigation, jede Route und die Hintergrund-
aufgaben -- darum sitzt der Schalter hier und nicht in einer Vorlage.
"""
return (
self.active()
and name in self._state.modules
and name not in self._state.modules_off
)
def module_licensed(self, name: str) -> bool:
"""Schaltet die Lizenz das Modul frei? (Ohne den Schalter vor Ort.)"""
return self.active() and name in self._state.modules
def abgeschaltete_module(self, conn: sqlite3.Connection) -> frozenset[str]:
"""Liest die Liste aus den Einstellungen."""
gespeichert = self.settings.get(conn, SETTING_MODULES_OFF) or []
if isinstance(gespeichert, str): # Altbestand: ein einzelner Name
gespeichert = [gespeichert]
return frozenset(str(name) for name in gespeichert)
def module_abschalten(
self, conn: sqlite3.Connection, namen: Iterable[str], *, actor: str = ""
) -> tuple[frozenset[str], frozenset[str]]:
"""Setzt die Liste der abgeschalteten Module neu.
Gibt ``(eingeschaltet, abgeschaltet)`` zurueck -- also nur das, was sich
wirklich geaendert hat; der Aufrufer schreibt daraus seinen
Protokolleintrag.
Der Zustand wird hier nicht neu eingelesen, sondern nur an dieser einen
Stelle nachgezogen: ein voller Neuaufbau wuerde die Lizenzdatei erneut
pruefen, obwohl sich an ihr nichts geaendert hat -- und der Schalter ist
das Einzige, was jetzt anders ist.
"""
vorher = self.abgeschaltete_module(conn)
nachher = frozenset(str(name) for name in namen)
self.settings.set(conn, SETTING_MODULES_OFF, sorted(nachher), actor=actor)
with self._lock:
self._state.modules_off = nachher
return vorher - nachher, nachher - vorher
def feature_allowed(self, feature: str) -> bool:
"""Lizenzpflichtige Einzelfunktion.
+33 -11
View File
@@ -50,6 +50,12 @@ class Migration:
sql: str = ""
fn: MigrationFn | None = None
allow_integrity_error: bool = False
#: Begruendung, falls der Text einer bereits angewendeten Migration
#: absichtlich geaendert wurde -- etwa weil eine frische Installation
#: mit der neuen Schreibweise starten muss. Ohne Begruendung warnt der
#: Lauf, und das soll er auch: ein stiller Umbau einer angewendeten
#: Migration ist fast immer ein Versehen.
geaendert_weil: str = ""
tags: tuple[str, ...] = field(default=())
@property
@@ -175,17 +181,33 @@ class MigrationRunner:
record = known.get(migration.version)
if record is not None:
if record["checksum"] != migration.checksum:
logger.warning(
"Migration %s/%s (%s) wurde nachträglich geändert "
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
"nicht erneut ausgeführt – neue Änderungen brauchen eine neue "
"Versionsnummer.",
component,
migration.version,
migration.name,
record["checksum"],
migration.checksum,
)
if migration.geaendert_weil:
# Mit Begruendung ist es eine Notiz, keine Warnung:
# manchmal muss der Text einer laengst angewendeten
# Migration mit, damit eine **frische** Installation
# denselben Stand erreicht. Wer das Protokoll liest,
# soll den Unterschied zu einem Versehen sehen.
logger.info(
"Migration %s/%s (%s) wurde absichtlich geändert: %s "
"Auf dieser Installation ist sie längst angewendet und "
"wird nicht erneut ausgeführt.",
component,
migration.version,
migration.name,
migration.geaendert_weil,
)
else:
logger.warning(
"Migration %s/%s (%s) wurde nachträglich geändert "
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
"nicht erneut ausgeführt – neue Änderungen brauchen eine neue "
"Versionsnummer.",
component,
migration.version,
migration.name,
record["checksum"],
migration.checksum,
)
continue
try:
+47 -23
View File
@@ -16,10 +16,10 @@ A = Action
USERS = Resource(
key="users",
label="Benutzer",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Konten anlegen, sperren, Gruppen zuordnen",
action_hints={
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
A.WRITE: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
},
)
@@ -27,32 +27,43 @@ USERS = Resource(
GROUPS = Resource(
key="groups",
label="Gruppen und Rechte",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Rechtematrix pflegen",
action_hints={
A.EDIT: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
A.WRITE: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
},
)
SETTINGS_SYSTEM = Resource(
key="settings_system",
label="Systemeinstellungen",
actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="Hostname, Zeitzone, Protokollrotation, Navigation",
actions=(A.READ, A.WRITE, A.EXECUTE),
hint="Hostname, Zeitzone, Protokollrotation, Sitzungsdauer",
action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."},
)
#: Die Reihenfolge des Menues lief unter "Systemaktionen auslösen" mit -- also
#: unter demselben Recht wie das Aendern des Hostnamens. Es ist aber das
#: harmloseste, was man in der Verwaltung tun kann, und das haeufigste.
SETTINGS_NAV = Resource(
key="settings_nav",
parent="settings_system",
label="Navigation",
actions=(A.READ, A.WRITE),
hint="Reihenfolge der Menüpunkte und ihrer Unterpunkte",
)
SETTINGS_LICENSE = Resource(
key="settings_license",
label="Lizenz",
actions=(A.VIEW, A.EDIT),
actions=(A.READ, A.WRITE),
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
)
SETTINGS_DIRECTORY = Resource(
key="settings_directory",
label="Verzeichnisdienst (LDAP/AD)",
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
actions=(A.READ, A.WRITE, A.EXECUTE, A.SECRETS),
hint="Anbindung und Gruppen-Zuordnung",
action_hints={
A.EXECUTE: "Verbindungstest ausführen.",
@@ -63,7 +74,7 @@ SETTINGS_DIRECTORY = Resource(
SETTINGS_WEB = Resource(
key="settings_web",
label="Webserver und TLS",
actions=(A.VIEW, A.EDIT, A.EXECUTE),
actions=(A.READ, A.WRITE, A.EXECUTE),
hint="Domain, Zertifikat, HSTS",
action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
)
@@ -71,29 +82,40 @@ SETTINGS_WEB = Resource(
SETTINGS_NETWORK = Resource(
key="settings_network",
label="Netzwerkeinstellungen",
actions=(A.VIEW, A.EDIT),
actions=(A.READ, A.WRITE),
hint="IP-Adresse, Gateway, DNS des Hosts",
)
BACKUP = Resource(
key="backup",
label="Sicherung und Übertragung",
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
hint="Verschlüsselter Export und Import",
#: Sichern und Einspielen sind zwei Zeilen, weil es zwei Vorgaenge mit sehr
#: verschiedenen Folgen sind: eine Sicherung zu erzeugen aendert nichts, eine
#: einzuspielen aendert alles. Sie liefen unter einem Recht, und wer eine
#: Sicherung ziehen durfte, durfte damit auch eine fremde einspielen.
BACKUP_EXPORT = Resource(
key="backup_export",
label="Sicherung erstellen",
actions=(A.READ, A.EXPORT, A.SECRETS),
hint="Verschlüsselter Export des Datenstands",
action_hints={
A.EXECUTE: "Eine Sicherung einspielen.",
A.EXPORT: "Sicherung erzeugen und herunterladen.",
A.SECRETS: "Zugangsdaten in die Sicherung einschließen.",
},
)
BACKUP_IMPORT = Resource(
key="backup_import",
label="Sicherung einspielen",
actions=(A.READ, A.EXECUTE),
hint="Prüfen, übernehmen, verwerfen",
action_hints={A.EXECUTE: "Eine geprüfte Sicherung anwenden."},
)
TRASH = Resource(
key="trash",
label="Papierkorb",
actions=(A.VIEW, A.EDIT, A.DELETE),
actions=(A.READ, A.WRITE, A.DELETE),
hint="Gelöschtes wiederherstellen oder endgültig entfernen",
action_hints={
A.EDIT: "Einträge wiederherstellen.",
A.WRITE: "Einträge wiederherstellen.",
A.DELETE: "Einträge endgültig entfernen.",
},
)
@@ -101,20 +123,20 @@ TRASH = Resource(
DIAGNOSTICS = Resource(
key="diagnostics",
label="Diagnose",
actions=(A.VIEW,),
actions=(A.READ,),
hint="Schemastand, Protokollintegrität, Schlüsselrechte",
)
LOGS_LIVE = Resource(
key="logs_live",
label="Laufendes Protokoll",
actions=(A.VIEW,),
actions=(A.READ,),
)
LOGS_HISTORY = Resource(
key="logs_history",
label="Protokollverlauf",
actions=(A.VIEW, A.DELETE, A.EXPORT),
actions=(A.READ, A.DELETE, A.EXPORT),
hint="Rotierte und archivierte Protokolle",
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
)
@@ -122,7 +144,7 @@ LOGS_HISTORY = Resource(
LOGS_AUDIT = Resource(
key="logs_audit",
label="Änderungsprotokoll",
actions=(A.VIEW, A.EXPORT),
actions=(A.READ, A.EXPORT),
hint="Wer hat wann was geändert",
)
@@ -139,11 +161,13 @@ def administration_area(*extra: Resource) -> Area:
USERS,
GROUPS,
SETTINGS_SYSTEM,
SETTINGS_NAV,
SETTINGS_LICENSE,
SETTINGS_DIRECTORY,
SETTINGS_WEB,
SETTINGS_NETWORK,
BACKUP,
BACKUP_EXPORT,
BACKUP_IMPORT,
TRASH,
DIAGNOSTICS,
*extra,
@@ -0,0 +1,171 @@
"""Alte Rechteschluessel auf neue -- an einer Stelle, nicht an dreien.
Mit 2.8.0 heisst das Aktionsvokabular durchgehend wie das, was es tut:
``view`` wurde ``read``, ``edit`` wurde ``write``. Danach steht in der Matrix
"Write" und im Aenderungsprotokoll ``write``, nicht zweierlei.
Drei Stellen brauchen die Zuordnung, und darum steht sie hier und nicht dort:
* die **Migration**, die bestehende Gruppenzeilen umschreibt,
* der **Import**, dem eine Sicherung aus 2.7.x untergeschoben wird -- ohne die
Zuordnung landen dort lauter unbekannte Schluessel, und
:meth:`PermissionTree.sanitize` wirft Unbekanntes absichtlich **still** weg.
Aus einer Sicherung wuerden Gruppen ohne Rechte.
* der **Test**, der prueft, dass kein alter Schluessel mehr im Code steht.
Warum nicht dreimal dieselbe Ersetzung tippen: weil die dritte Kopie beim
naechsten Umbau vergessen wird. Genau so entstand das Muster bei den Adressen
(:mod:`tesm_core.legacy_urls`).
"""
from __future__ import annotations
#: Aktion alt -> neu. Die Ressource bleibt, wie sie heisst.
UMBENANNTE_AKTIONEN: dict[str, str] = {
"view": "read",
"edit": "write",
}
#: Ein altes Recht, das in mehrere neue zerfaellt -- ein Recht je Funktion.
#:
#: Die Aufteilung ist **additiv**: wer das alte Recht hatte, bekommt alle
#: neuen. Niemand verliert etwas; ab jetzt laesst sich jedes Stueck einzeln
#: vergeben. Der umgekehrte Weg waere auch denkbar gewesen -- nur das
#: Harmloseste uebernehmen --, waere aber ein Update, das Leuten Rechte
#: wegnimmt, ohne dass jemand es entschieden hat.
#:
#: Die Schluessel stehen fuer beide Anwendungen in einer Tabelle. Auf einer
#: TESM-Datenbank tut die Zeile fuer ``licenses.write`` nichts, weil sie dort
#: in keiner Gruppe steht -- und umgekehrt. Zwei getrennte Tabellen waeren
#: zwei Stellen, die man pflegen muss.
GETEILTE_RECHTE: dict[str, tuple[str, ...]] = {
# Drei Neustartwege mit drei verschiedenen Folgen.
"devices.execute": (
"device_restart_poe.read",
"device_restart_poe.execute",
"device_restart_ssh.read",
"device_restart_ssh.execute",
"device_restart_rpc.read",
"device_restart_rpc.execute",
),
# Die Switch-Konsole war "Switch ausfuehren".
"switches.execute": ("switch_console.read", "switch_console.execute"),
# Vier Funktionen: aktualisieren, neu starten, Werkzeug installieren --
# und die Client-Konsole, die jetzt bei den Clients steht.
"maintenance.read": (
"servicing_update.read",
"servicing_reboot.read",
"servicing_tool.read",
"device_console.read",
),
"maintenance.execute": (
"servicing_update.read",
"servicing_update.execute",
"servicing_reboot.read",
"servicing_reboot.execute",
"servicing_tool.read",
"servicing_tool.execute",
"device_console.read",
"device_console.execute",
),
# Elf Funktionen an einem Recht.
"dhcp.read": (
"dhcp_subnets.read",
"dhcp_options.read",
"dhcp_reservations.read",
"dhcp_config.read",
"dhcp_service.read",
),
"dhcp.write": (
"dhcp_subnets.read",
"dhcp_subnets.create",
"dhcp_subnets.write",
"dhcp_subnets.delete",
"dhcp_options.read",
"dhcp_options.write",
"dhcp_reservations.read",
"dhcp_reservations.create",
"dhcp_reservations.write",
"dhcp_reservations.delete",
"dhcp_config.read",
"dhcp_config.execute",
),
"dhcp.execute": ("dhcp_service.read", "dhcp_service.execute"),
# Sichern und Einspielen.
"backup.read": ("backup_export.read", "backup_import.read"),
"backup.export": ("backup_export.read", "backup_export.export"),
"backup.secrets": ("backup_export.read", "backup_export.secrets"),
"backup.execute": ("backup_import.read", "backup_import.execute"),
# Lizenzserver: "aendern" hiess widerrufen **oder** erneuern -- zwei
# Eingriffe mit entgegengesetzter Wirkung unter einem Haken.
"licenses.create": ("license_issue.read", "license_issue.execute"),
"licenses.write": (
"license_renew.read",
"license_renew.execute",
"license_revoke.read",
"license_revoke.execute",
),
"licenses.execute": ("license_activation.read", "license_activation.execute"),
}
#: Ein Recht, das **bleibt** und bisher noch etwas anderes mitgab.
#:
#: Der Unterschied zu :data:`GETEILTE_RECHTE` ist keine Formsache: dort ist der
#: alte Schluessel verschwunden und muss ersetzt werden, hier gilt er weiter
#: und bekommt nur Gesellschaft. Beides in eine Tabelle zu werfen hiesse, in
#: der Migration ein Recht zu loeschen, das es noch gibt -- und im Test nicht
#: mehr pruefen zu koennen, welcher Fall gemeint war.
ERWEITERTE_RECHTE: dict[str, tuple[str, ...]] = {
# Die Navigationsreihenfolge lief unter "Systemaktionen ausloesen" mit.
"settings_system.execute": ("settings_nav.read", "settings_nav.write"),
# Einen Host-Schluessel freizugeben hing an den Stammdaten.
"devices.write": ("hostkeys.read", "hostkeys.execute"),
"switches.write": ("hostkeys.read", "hostkeys.execute"),
# Umbenennen war auch "Freigaben zuordnen".
"fileshare.write": (
"fileshare_mappings.read",
"fileshare_mappings.create",
"fileshare_mappings.write",
"fileshare_mappings.delete",
),
# Ausgefuellt ablegen war auch "unterschreiben".
"fileshare_pdf.create": ("fileshare_signature.read", "fileshare_signature.execute"),
}
def neue_schluessel(alt: str) -> tuple[str, ...]:
"""Was aus einem alten Rechteschluessel wird -- eins oder mehrere.
Unbekanntes kommt unveraendert zurueck: ob ein Schluessel gilt, entscheidet
der Rechtebaum, nicht diese Tabelle. Sie benennt nur um.
"""
if alt in GETEILTE_RECHTE:
return GETEILTE_RECHTE[alt]
ressource, punkt, aktion = alt.rpartition(".")
if not punkt:
return (alt,)
neu = UMBENANNTE_AKTIONEN.get(aktion)
selbst = f"{ressource}.{neu}" if neu else alt
# Ein Recht, das bleibt, nimmt mit, was es bisher mitgab -- sonst verliert
# jemand beim Update genau die Funktion, die vorher an diesem Haken hing.
return (selbst, *ERWEITERTE_RECHTE.get(selbst, ()))
def umgeschrieben(rechte: object) -> list[str]:
"""Eine ganze Liste umschreiben, ohne Dubletten und in fester Reihenfolge."""
if not isinstance(rechte, (list, tuple, set, frozenset)):
return []
ergebnis: set[str] = set()
for recht in rechte:
ergebnis.update(neue_schluessel(str(recht)))
return sorted(ergebnis)
__all__ = [
"UMBENANNTE_AKTIONEN",
"GETEILTE_RECHTE",
"ERWEITERTE_RECHTE",
"neue_schluessel",
"umgeschrieben",
]
+119 -28
View File
@@ -1,6 +1,14 @@
"""Rechtemodell: Bereiche, Ressourcen, Aktionen.
Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
Das Vokabular heisst seit 2.8.0 durchgehend wie das, was es tut: **Read,
Create, Write, Delete, Execute, Export, Secrets**. Bis dahin hiessen zwei
Aktionen anders (``view``, ``edit``), waehrend die Matrix "Bearbeiten" zeigte
und die Kurzfassung "U" -- dieselbe Aktion in drei Schreibweisen. Der
Kill-Switch eines Bereichs heisst entsprechend ``<bereich>.read``.
:mod:`tesm_core.rbac.legacy_permissions` schreibt alte Schluessel um; die
Migration ``core/4`` tut das fuer bestehende Gruppen.
Das Modell vor 2.0 kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
@@ -15,7 +23,7 @@ Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
``<bereich>.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
``<bereich>.read``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
eingestellten Einzelrechte zu verlieren.
@@ -29,28 +37,55 @@ from typing import Iterable, Mapping, Sequence
class Action(str, Enum):
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen."""
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen.
VIEW = "view"
Die Namen sind die englischen, weil sie in jedem Rechtesystem dieselben
sind und keiner Uebersetzung bedarf: wer ``write`` liest, weiss es. Zwei
hiessen bis 2.7.x anders (``view``, ``edit``) -- in der Matrix stand
"Bearbeiten" und im Protokoll ``edit``, also zweierlei fuer dasselbe.
:mod:`tesm_core.rbac.legacy_permissions` schreibt die alten Schluessel um.
"""
READ = "read"
CREATE = "create"
EDIT = "edit"
WRITE = "write"
DELETE = "delete"
EXECUTE = "execute"
EXPORT = "export"
SECRETS = "secrets"
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
ACTION_META: dict[Action, tuple[str, str, str]] = {
Action.VIEW: ("R", "Ansehen", "Datensätze und Seiten dieses Bereichs lesen."),
Action.CREATE: ("C", "Anlegen", "Neue Datensätze erstellen."),
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensätze ändern – ohne Löschen."),
Action.DELETE: ("D", "Loeschen", "Datensätze löschen bzw. endgültig entfernen."),
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen auslösen (Neustart, Dienste, Jobs, Import)."),
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
#: Kuerzel, englischer Name, deutsche Beschriftung, Erklaerung.
#:
#: Beides hat seinen Ort: die Matrix fuehrt die Spalte unter ihrem englischen
#: Namen (das ist das Vokabular, und es steht so auch im Schluessel), die
#: Kleinschrift darunter und jeder Satz in der Oberflaeche bleiben deutsch.
ACTION_META: dict[Action, tuple[str, str, str, str]] = {
Action.READ: ("R", "Read", "ansehen", "Datensätze und Seiten dieses Bereichs ansehen."),
Action.CREATE: ("C", "Create", "anlegen", "Neue Datensätze anlegen."),
Action.WRITE: (
"W",
"Write",
"ändern",
"Bestehende Datensätze ändern – ohne Anlegen, ohne Löschen.",
),
Action.DELETE: ("D", "Delete", "löschen", "Datensätze löschen bzw. endgültig entfernen."),
Action.EXECUTE: (
"X",
"Execute",
"auslösen",
"Aktionen auslösen (Neustart, Dienste, Aufträge, Import).",
),
Action.EXPORT: (
"E",
"Export",
"mitnehmen",
"Daten aus dem System heraustragen (Datei-Download).",
),
Action.SECRETS: (
"S",
"Geheimnisse",
"Secrets",
"Klartext",
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
),
}
@@ -58,9 +93,12 @@ ACTION_META: dict[Action, tuple[str, str, str]] = {
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile.
#: ``read`` ist Voraussetzung fuer alles andere in derselben Zeile. Umgekehrt
#: gilt nichts: wer anlegen darf, darf damit nicht aendern, und wer aendern
#: darf, nicht anlegen. Das ist die Trennung, die man in einer
#: Verwaltungsanwendung am haeufigsten braucht.
IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
action: (Action.VIEW,) for action in Action if action is not Action.VIEW
action: (Action.READ,) for action in Action if action is not Action.READ
}
@@ -74,12 +112,33 @@ class Resource:
hint: str = ""
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
action_hints: Mapping[Action, str] = field(default_factory=dict)
#: Zu welcher Zeile diese gehoert -- rein fuer die Darstellung.
#:
#: Aus einer Zeile "Clients" sind sechs geworden (Stammdaten, drei
#: Neustartwege, Konsole, Host-Schluessel). Untereinander und alle gleich
#: eingerueckt liest man sie als sechs unabhaengige Dinge; eingerueckt
#: unter ihrer Hauptzeile sieht man, dass sie zusammengehoeren. Auf die
#: Rechte hat das keine Wirkung: jede Zeile bleibt einzeln vergebbar, und
#: die Hauptzeile ist **nicht** Voraussetzung fuer ihre Unterzeilen.
parent: str = ""
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
#: stilles Weglassen in der Route: eine Ausnahme von einer
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
ohne_wiederanmeldung: tuple[Action, ...] = ()
@property
def short_label(self) -> str:
"""Beschriftung ohne den Namen der Hauptzeile.
Unter "Clients" steht "Neustart über PoE" und nicht "Clients: Neustart
über PoE" -- das Wort stand sonst siebenmal untereinander. Ausserhalb
der Matrix (Protokoll, Meldungen) bleibt der vollstaendige Name.
"""
if self.parent and ": " in self.label:
return self.label.split(": ", 1)[1]
return self.label
def permission(self, action: Action) -> str:
return f"{self.key}.{action.value}"
@@ -101,8 +160,9 @@ class Area:
hint: str = ""
@property
def view_key(self) -> str:
return f"{self.key}.view"
def read_key(self) -> str:
"""Der Kill-Switch des Bereichs. Ohne ihn greift keine Zeile darunter."""
return f"{self.key}.read"
def columns(self) -> tuple[Action, ...]:
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
@@ -128,6 +188,7 @@ class PermissionTree:
self._parent: dict[str, str] = {}
self._all: list[str] = []
self._resources: dict[str, tuple[Area, Resource]] = {}
self._verwaltung: frozenset[str] | None = None
seen_keys: set[str] = set()
for area in self.areas:
@@ -135,30 +196,57 @@ class PermissionTree:
raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}")
seen_keys.add(area.key)
self._by_key[area.key] = area
self._all.append(area.view_key)
self._all.append(area.read_key)
for resource in area.resources:
if resource.key in seen_keys:
raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}")
seen_keys.add(resource.key)
if Action.VIEW not in resource.actions:
if Action.READ not in resource.actions:
raise ValueError(
f"Ressource {resource.key} muss die Aktion 'view' anbieten – "
f"Ressource {resource.key} muss die Aktion 'read' anbieten – "
"sie ist Voraussetzung für jede andere Aktion."
)
self._resources[resource.key] = (area, resource)
for permission in resource.permissions():
self._all.append(permission)
self._parent[permission] = area.view_key
self._parent[permission] = area.read_key
# ------------------------------------------------------------------ #
def all_keys(self) -> tuple[str, ...]:
return tuple(self._all)
#: Der Bereich, den ein echter Administrator immer ganz hat.
#:
#: Nicht "alles": das Kennzeichen ``is_admin`` gab bisher jede Pruefung
#: frei, auch Geraete, DHCP und Dateifreigaben. Ein Administrator ist aber
#: jemand, der die **Anlage** verwaltet -- Benutzer, Rechte,
#: Einstellungen --, und nicht automatisch jemand, der Maschinen neu
#: startet oder in Freigaben liest. Diese Rechte holt er sich ueber eine
#: Gruppe, und dann steht es im Aenderungsprotokoll.
VERWALTUNGSBEREICH = "administration"
def verwaltungsrechte(self) -> frozenset[str]:
"""Alle Rechte des Verwaltungsbereichs, samt seinem Kill-Switch.
Einmal gerechnet und gemerkt: die Antwort haengt nur am Baum, und der
entsteht beim Import.
"""
if self._verwaltung is None:
bereich = self._by_key.get(self.VERWALTUNGSBEREICH)
if bereich is None:
self._verwaltung = frozenset()
else:
rechte = {bereich.read_key}
for resource in bereich.resources:
rechte.update(resource.permissions())
self._verwaltung = frozenset(rechte)
return self._verwaltung
def area(self, key: str) -> Area | None:
return self._by_key.get(key)
def parent_view_key(self, permission: str) -> str | None:
def parent_read_key(self, permission: str) -> str | None:
return self._parent.get(permission)
def resource(self, key: str) -> tuple[Area, Resource] | None:
@@ -185,7 +273,7 @@ class PermissionTree:
def label_for(self, permission: str) -> str:
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
if permission.endswith(".view") and permission[:-5] in self._by_key:
if permission.endswith(".read") and permission[:-5] in self._by_key:
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
resource_key, _, action_value = permission.rpartition(".")
entry = self._resources.get(resource_key)
@@ -196,7 +284,10 @@ class PermissionTree:
action = Action(action_value)
except ValueError:
return permission
return f"{resource.label}: {ACTION_META[action][1]}"
# Die deutsche Beschriftung, nicht der englische Name: dieser Text
# landet in Saetzen ("Sie koennen kein Recht vergeben ...") und im
# Aenderungsprotokoll, und die sind deutsch.
return f"{resource.label}: {ACTION_META[action][2]}"
def sanitize(self, permissions: Iterable[str]) -> list[str]:
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
@@ -204,7 +295,7 @@ class PermissionTree:
Regeln:
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
Weiterschleppen von Altrechten nach einem Refactoring).
* Jede Aktion impliziert ``view`` derselben Zeile.
* Jede Aktion impliziert ``read`` derselben Zeile -- aber nur diese.
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
Bearbeiter wuerde sich wundern.
@@ -224,13 +315,13 @@ class PermissionTree:
result.add(candidate)
for area in self.areas:
if any(p in result for resource in area.resources for p in resource.permissions()):
result.add(area.view_key)
result.add(area.read_key)
return sorted(result)
def effective(self, permissions: Iterable[str]) -> set[str]:
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
granted = set(permissions)
active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key}
active = {key for key in granted if key.endswith(".read") and key[:-5] in self._by_key}
return {
key
for key in granted
+128 -11
View File
@@ -29,7 +29,7 @@ from typing import Any, Iterable, Sequence
from ..db import Database
from ..security import passwords
from .model import Action, PermissionTree, RolePreset
from .model import ACTION_META, Action, PermissionTree, RolePreset
class PermissionDenied(Exception):
@@ -124,7 +124,20 @@ def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
def guard_no_amplification(
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
) -> None:
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt."""
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt.
Ein echter Administrator darf es -- und das ist seit 2.8.0 die bewusst
offene Tuer: sein Kennzeichen gibt ihm den Verwaltungsbereich, die
Funktionen holt er sich ueber eine Gruppe, auch fuer sich selbst. Der
Unterschied zu vorher ist nicht, *ob* er an die Funktionen kommt, sondern
dass es dann eine Zeile im Aenderungsprotokoll gibt und in der Matrix zu
sehen ist, statt unsichtbar im Kennzeichen zu stecken.
Fuer alle anderen bleibt die Regel dicht -- auch fuer die Gruppe
"Administration", die Benutzer und Gruppen pflegt: sie kann keiner Gruppe
ein Recht geben, das sie nicht selbst hat, und sich damit nicht ueber eine
neue Gruppe hochstufen.
"""
if getattr(actor, "is_admin", False):
return
missing = sorted(p for p in added if not actor.has(p))
@@ -315,6 +328,113 @@ def ensure_presets(
return created
def unberuehrte_vorlagen_entfernen(
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
) -> list[str]:
"""Legt Vorlagengruppen in den Papierkorb -- aber nur unberuehrte.
Wird gebraucht, wenn eine Vorlage aus der Auslieferung verschwindet. Auf
einer frischen Installation entsteht sie dann gar nicht erst; auf einer
bestehenden steht sie in der Datenbank, und eine Migration, die sie einfach
loescht, nimmt einem Kunden womoeglich eine Gruppe weg, mit der er
arbeitet.
Darum diese fuenf Bedingungen, und alle fuenf muessen zutreffen:
* Die Gruppe stammt **aus der Auslieferung**: ihr ``preset_key`` ist der der
Vorlage. Diese Kennung setzt nur :func:`ensure_presets`; eine selbst
angelegte Gruppe hat sie nicht, auch wenn sie zufaellig denselben Namen
traegt. Ueber den Namen wird bewusst *nicht* gesucht -- sonst raeumte
dieser Schritt jemandem seine eigene Gruppe "Betrieb" weg.
* Sie heisst noch so, wie sie ausgeliefert wurde. Ein anderer Name heisst:
jemand hat sie uebernommen.
* Die Gruppe hat **keine Mitglieder**. Wer Mitglieder hat, wird benutzt.
* Ihre Rechte sind **unveraendert** die der Vorlage. Jede Abweichung heisst:
jemand hat sie angepasst, also ist sie seine, nicht unsere.
* Sie ist keine Systemgruppe.
Geloescht wird weich: die Gruppe liegt im Papierkorb und kommt mit einem
Klick zurueck. Gibt die Namen der einsortierten Gruppen zurueck.
"""
entfernt: list[str] = []
gruppen = list_groups(conn)
for preset in presets:
gruppe = next(
(
g
for g in gruppen
if g.preset_key
and g.preset_key == preset.key
and g.name.lower() == preset.name.lower()
),
None,
)
if gruppe is None or gruppe.is_system or gruppe.member_count:
continue
if set(gruppe.permissions) != set(tree.expand_preset(preset)):
continue
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), gruppe.id))
entfernt.append(gruppe.name)
return entfernt
def funktionsgruppe_anlegen(
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
) -> int:
"""Legt die Vollzugriffsgruppe an, falls sie fehlt, und gibt ihre Nummer.
Getrennt von :func:`funktionsgruppe_fuer_administratoren`, weil das zwei
verschiedene Dinge sind: hier entsteht nur die Gruppe, dort bekommt sie
**jeder** Administrator. Wer ein einzelnes Konto aufnehmen will -- etwa
das, das an der Kommandozeile entsteht --, braucht das erste ohne das
zweite; sonst faengt sich ein Administrator, der die Gruppe bewusst
abgelegt hat, sie beim naechsten Aufruf wieder ein.
"""
zeile = Database.one(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
)
if zeile is not None:
return int(zeile["id"])
cursor = conn.execute(
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
(preset.name, preset.description, preset.key, _now(), _now()),
)
gruppe = int(cursor.lastrowid or 0)
_write_permissions(conn, gruppe, tree.expand_preset(preset))
return gruppe
def funktionsgruppe_fuer_administratoren(
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
) -> int:
"""Legt die Vollzugriffsgruppe an und gibt sie allen Administratoren.
Der Uebergang zu 2.8.0: bis dahin gab das Kennzeichen ``is_admin`` jede
Pruefung frei. Danach gibt es den Verwaltungsbereich, und alles andere
kommt ueber Gruppen. Ohne diesen Schritt fehlt einem bestehenden
Administrator nach dem Update die halbe Anwendung -- und er haelt das fuer
einen Fehler, nicht fuer eine Absicht.
Auf einer frischen Installation gibt es beim Migrieren noch kein Konto;
dann entsteht nur die Gruppe, und wer sie will, weist sie sich zu. Genau so
ist die neue Trennung gedacht.
Gibt die Nummer der Gruppe zurueck.
"""
gruppe = funktionsgruppe_anlegen(conn, tree, preset)
conn.executemany(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
[
(int(row["id"]), gruppe)
for row in Database.all(
conn, "SELECT id FROM users WHERE is_admin=1 AND deleted_at IS NULL"
)
],
)
return gruppe
def default_group_id(conn: sqlite3.Connection) -> int | None:
value = Database.value(
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
@@ -683,7 +803,7 @@ def matrix_state(
granted = set(permissions)
state: dict[str, dict[str, bool]] = {}
for area in tree.areas:
state[area.key] = {"view": area.view_key in granted}
state[area.key] = {"read": area.read_key in granted}
for resource in area.resources:
state[resource.key] = {
action.value: resource.permission(action) in granted for action in resource.actions
@@ -696,7 +816,7 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
granted = set(permissions)
lines: list[str] = []
for area in tree.areas:
if area.view_key not in granted:
if area.read_key not in granted:
continue
parts: list[str] = []
for resource in area.resources:
@@ -712,12 +832,9 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
return lines
#: Abgeleitet und nicht abgeschrieben: die Buchstaben stehen in ACTION_META.
#: Die zweite Liste hier hatte "U" fuer Bearbeiten, waehrend die Matrix "W"
#: zeigte -- dieselbe Aktion, zwei Kuerzel, je nachdem wo man hinsah.
ACTION_LETTERS: dict[Action, str] = {
Action.VIEW: "R",
Action.CREATE: "C",
Action.EDIT: "U",
Action.DELETE: "D",
Action.EXECUTE: "X",
Action.EXPORT: "E",
Action.SECRETS: "S",
action: meta[0] for action, meta in ACTION_META.items()
}
@@ -58,6 +58,30 @@ class SettingSpec:
return str(raw)
def serialize(self, value: Any) -> str:
"""Ein Wert als Text fuer die Datenbank.
Der Wert kann als Python-Wert kommen (aus einem Formular, aus dem
Code) -- oder als die Zeichenkette, die schon in der Datenbank stand.
Genau so liest eine **Sicherung** ihn wieder ein, und genau dort lag
ein stiller Fehler: ``serialize("0")`` ergab ``"1"``, weil jede nicht
leere Zeichenkette wahr ist. Aus jedem abgeschalteten Schalter wurde
beim Einspielen ein eingeschalteter -- auch aus
``ldap_allow_insecure``. Ein JSON-Feld wurde ein zweites Mal kodiert,
und aus der Navigationsreihenfolge damit eine Zeichenkette.
Deshalb: eine Zeichenkette erst lesen. Was hier herauskommt, muss
durch ``serialize`` unveraendert hindurchgehen -- sonst ueberlebt kein
Wert eine Sicherung.
"""
if isinstance(value, str):
if self.kind == "bool":
value = self.parse(value)
elif self.kind == "json":
try:
value = json.loads(value)
except ValueError:
# Kein JSON-Text, also ein Wert: dann wird er kodiert.
pass
if self.kind == "bool":
return "1" if value else "0"
if self.kind == "json":
@@ -1010,7 +1010,25 @@
text-align: center;
}
.matrix thead th:first-child { text-align: left; }
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 62px; text-align: center; }
/* Kuerzel, englischer Name, deutsche Beschriftung -- untereinander. Nur das
Kuerzel allein war eine Spalte, die man nachschlagen musste; die Legende
steht unter einer Tabelle, die man dafuer erst zu Ende scrollt. */
.matrix__spalte { display: flex; flex-direction: column; align-items: center; line-height: 1.15; }
.matrix__spalte b {
font-family: var(--font-mono);
font-size: var(--fs-sm);
color: var(--text);
font-weight: 650;
}
.matrix__spalte span { font-size: var(--fs-xs); color: var(--text-muted); text-transform: none; }
.matrix__spalte small {
font-size: 10px;
color: var(--text-faint);
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 72px; text-align: center; }
.matrix tr.matrix__area > td {
background: var(--surface-2);
font-weight: 640;
@@ -1036,6 +1054,46 @@
}
.matrix__resource-label { display: flex; flex-direction: column; gap: 1px; }
.matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); }
.matrix__zeile { display: flex; align-items: baseline; gap: var(--sp-2); }
.matrix__zeile b { font-weight: 600; }
/* Unterzeile: gehoert zur Zeile darueber. Ohne Einrueckung liest man sechs
unabhaengige Dinge, wo sechs Teile eines Ganzen stehen. */
.matrix tr.matrix__unterzeile > td:first-child { padding-left: var(--sp-6); }
.matrix tr.matrix__unterzeile > td:first-child .matrix__zeile b { font-weight: 500; }
/* Was eine Aktion in dieser Zeile bedeutet -- in der Zeile, nicht im
Titelattribut: mit dem Finger gibt es kein "hover". */
.matrix__bedeutung { color: var(--text-faint); font-size: var(--fs-xs); }
.matrix__bedeutung b {
font-family: var(--font-mono);
color: var(--text-muted);
font-weight: 500;
}
.matrix__alle {
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
border-radius: var(--r-sm);
font-size: 10px;
line-height: 1;
padding: 2px 4px;
cursor: pointer;
}
.matrix__alle:hover { color: var(--accent); border-color: var(--accent); }
.matrix__filter {
display: flex;
align-items: baseline;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
flex-wrap: wrap;
}
.matrix__filter-hint { color: var(--text-faint); font-size: var(--fs-xs); }
/* Eine ausgefilterte Zeile ist versteckt, nicht entfernt: ihre Kaestchen
bleiben im Formular, sonst loescht ein Filter beim Speichern Rechte. */
.matrix tr[hidden] { display: none; }
.matrix__legend {
display: flex;
flex-wrap: wrap;
@@ -1150,6 +1208,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);
@@ -1211,6 +1275,52 @@
color: var(--text-faint);
}
/* ---------------------------------------------------------------------- #
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);
}
.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
@@ -440,6 +440,56 @@ function initPermissionMatrix() {
});
};
/* -- Ganze Zeile, ganze Spalte, Filter -----------------------------
Bei 37 Zeilen und bis zu sieben Spalten je Bereich sind das ueber
zweihundert Kaestchen. Ohne diese drei Hilfen setzt man oben den
Bereichsschalter und laesst es dabei -- und die feine Aufteilung
haette nichts gebracht. */
// Nur was man sieht: eine vom Filter ausgeblendete Zeile darf ein
// Sammelknopf nicht mitnehmen. Sonst setzt man "alle Write" waehrend nach
// "Neustart" gefiltert ist und aendert dabei Zeilen, die gar nicht auf dem
// Schirm stehen -- und merkt es erst nach dem Speichern.
const sichtbar = (box) => !box.disabled && !box.closest("tr").hidden;
const felder = (bereich) =>
$$('input[type="checkbox"][name][data-action]', bereich).filter(sichtbar);
table.addEventListener("click", (event) => {
const ziel = event.target;
if (!(ziel instanceof HTMLElement)) return;
const zeilenknopf = ziel.closest("[data-matrix-row]");
if (zeilenknopf) {
event.preventDefault();
const row = zeilenknopf.closest("tr");
if (!row) return;
const kaestchen = felder(row);
// Erst wenn alles gesetzt ist, nimmt der Knopf weg -- sonst waere
// ein zweiter Klick auf eine halb gesetzte Zeile ein Ruecksprung.
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
kaestchen.forEach((box) => {
box.checked = !alleGesetzt;
box.dispatchEvent(new Event("change", { bubbles: true }));
});
return;
}
const spaltenknopf = ziel.closest("[data-matrix-column]");
if (spaltenknopf) {
event.preventDefault();
const aktion = spaltenknopf.dataset.matrixColumn;
const bereich = spaltenknopf.dataset.area;
const kaestchen = $$(
`tr[data-area="${bereich}"] input[data-action="${aktion}"][name]`,
table
).filter(sichtbar);
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
kaestchen.forEach((box) => {
box.checked = !alleGesetzt;
box.dispatchEvent(new Event("change", { bubbles: true }));
});
}
});
$$("[data-area-switch]", table).forEach((master) => {
const areaKey = master.dataset.areaSwitch;
syncArea(areaKey);
@@ -451,8 +501,12 @@ function initPermissionMatrix() {
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
const row = box.closest("tr");
if (!row) return;
const viewBox = $('input[data-action="view"]', row);
if (box.dataset.action === "view") {
// "read", nicht "view": die Aktion heisst seit 2.8.0 wie das, was sie
// tut. Mit dem alten Namen fand diese Zeile das Kaestchen nicht mehr --
// die Matrix haette sich weiter bedienen lassen und dabei die Regel
// "jede Aktion setzt Ansehen voraus" stillschweigend fallen gelassen.
const viewBox = $('input[data-action="read"]', row);
if (box.dataset.action === "read") {
// "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
if (!box.checked) {
$$('input[type="checkbox"][name]', row).forEach((other) => {
@@ -469,6 +523,30 @@ function initPermissionMatrix() {
master.dispatchEvent(new Event("change"));
}
});
const suche = $("[data-matrix-filter]");
const zaehler = $("[data-matrix-count]");
if (suche) {
const zeilen = $$("tr[data-suchtext]", table);
const filtern = () => {
const begriff = suche.value.trim().toLowerCase();
let sichtbar = 0;
zeilen.forEach((row) => {
const passt = !begriff || (row.dataset.suchtext || "").includes(begriff);
// "hidden" und nicht aus dem Formular nehmen: ein Filter, der
// Kaestchen entfernt, loescht beim Speichern Rechte.
row.hidden = !passt;
if (passt) sichtbar += 1;
});
if (zaehler) {
zaehler.textContent = begriff
? `${sichtbar} von ${zeilen.length} Zeilen`
: `${zeilen.length} Zeilen`;
}
};
suche.addEventListener("input", filtern);
filtern();
}
});
}
@@ -0,0 +1,231 @@
// 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.
import RFB from "../vendor/novnc/core/rfb.js";
// Zwei Eingriffe in private Methoden der mitgelieferten Bibliothek -- beide
// nur, um zu *erfahren*, was schiefging; keiner greift in die Verhandlung ein.
// 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.
//
// Es stand hier einmal ein dritter, der die Wahl des Sicherheitsverfahrens
// umbog: ohne hinterlegte Zugangsdaten sollte ein Verfahren ohne Anmeldung
// gewinnen, weil der Server RA2ne zuerst nennt. Gut gemeint und falsch --
// genau dieser Griff hat eine Sitzung, die lief, zum Abbruch gebracht: der
// RealVNC Server bietet "ohne Authentifizierung" zwar an, legt bei dieser Wahl
// aber auf. Die Reihenfolge des Servers ist seine Entscheidung, nicht unsere.
// 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. (Genau daran ist der erste
// Versuch gescheitert -- die Meldung blieb leer.)
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);
};
const wurzel = document.getElementById("screen");
if (wurzel) {
starten(wurzel);
}
function starten(wurzel) {
const leinwand = document.getElementById("screen-canvas");
const zeile = document.getElementById("screen-status");
const abfrage = document.getElementById("screen-credentials");
const feldBenutzer = document.getElementById("screen-username");
const feldPasswort = document.getElementById("screen-password");
const knopfSenden = document.getElementById("screen-send");
const knopfTrennen = document.getElementById("screen-disconnect");
const knopfStrg = document.getElementById("screen-cad");
const pfad = wurzel.dataset.socket;
const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
const adresse = `${schema}//${window.location.host}${pfad}`;
const vorgabe = JSON.parse(
document.getElementById("screen-credentials-data")?.textContent || "{}",
);
let rfb = null;
function melden(text, art = "") {
zeile.textContent = text;
zeile.className = `screen__status${art ? " screen__status--" + art : ""}`;
}
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}`);
}
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);
});
}
function verbinden(zugang) {
melden("Verbindung wird aufgebaut …");
letzterGrund = "";
letzterCode = 0;
rfb = new RFB(leinwand, adresse, {
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", () => {
melden(`Verbunden mit ${wurzel.dataset.ziel}`, "ok");
abfrage.hidden = true;
leinwand.hidden = false;
knopfTrennen.hidden = false;
knopfStrg.hidden = false;
});
rfb.addEventListener("disconnect", (ereignis) => {
leinwand.hidden = true;
knopfTrennen.hidden = true;
knopfStrg.hidden = true;
if (ereignis.detail && ereignis.detail.clean) {
melden("Sitzung beendet.");
} else {
const erklaerung = grund();
melden(
erklaerung
? `Verbindung abgebrochen – ${erklaerung}`
: "Verbindung abgebrochen. Wird geprüft, woran es liegt …",
"fehler",
);
// 1006 heisst: die Leitung ging zu, ohne dass die Gegenseite einen
// Grund geschickt hat -- typisch, wenn die WebSocket-Verbindung gar
// nicht erst zustande kam. Dann sagt die Probe, wer schweigt.
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) => {
const gefragt = ereignis.detail.types || ["password"];
abfrage.hidden = false;
feldBenutzer.closest("label").hidden = !gefragt.includes("username");
melden("Der VNC-Server verlangt Zugangsdaten.", "warnung");
(gefragt.includes("username") ? feldBenutzer : feldPasswort).focus();
});
rfb.addEventListener("securityfailure", (ereignis) => {
melden(
`Anmeldung abgelehnt: ${ereignis.detail.reason || "kein Grund genannt"}`,
"fehler",
);
abfrage.hidden = false;
});
rfb.addEventListener("desktopname", (ereignis) => {
wurzel.dataset.desktop = ereignis.detail.name;
});
}
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());
knopfStrg?.addEventListener("click", () => rfb && rfb.sendCtrlAltDel());
if (wurzel.dataset.blockiert === "1") {
melden(wurzel.dataset.grund || "Diese Sitzung kann nicht aufgebaut werden.", "fehler");
return;
}
const zugang = {};
if (vorgabe.username) zugang.username = vorgabe.username;
if (vorgabe.password) zugang.password = vorgabe.password;
verbinden(zugang);
}
@@ -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,41 @@
# 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._isSupportedSecurityType` wird ueberschrieben,
solange keine Zugangsdaten vorliegen und der Server ein Verfahren ohne
Anmeldung anbietet. Grund: noVNC nimmt "das erste, das ich kann, in der
Reihenfolge des Servers"; ein RealVNC Server nennt RA2ne vor "ohne
Authentifizierung", und der Browser liefe in eine Passwortabfrage, obwohl
daneben der Weg offensteht, den auch der RealVNC Viewer nimmt. Faellt die
Methode in einer neueren Fassung weg, greift die Vorauswahl nicht mehr -- die
Sitzung laeuft dann wie ohne sie, es bricht nichts.
**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
@@ -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 */
@@ -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;
}
}
@@ -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);
}
@@ -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;
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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 };
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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");
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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 + "]";
}
}
@@ -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);
}
}
@@ -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;
@@ -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',
};
@@ -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 } };
}
}
@@ -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");
}
}
@@ -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,
};
@@ -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;
},
};
@@ -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;
}
@@ -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
};
@@ -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
@@ -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/...');
}
@@ -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);
}
}
@@ -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;
}
@@ -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);
}
}
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more