Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a528e9f3b2 | ||
|
|
3b3ce96678 | ||
|
|
42316ba137 | ||
|
|
d34baa67be | ||
|
|
491724beb5 | ||
|
|
b54b87ca6f | ||
|
|
270ba455a8 | ||
|
|
f3b312046f | ||
|
|
436b080deb | ||
|
|
85c40ccfbd | ||
|
|
0ac651a1b9 | ||
|
|
4275c84a6c | ||
|
|
4f4310bfe4 | ||
|
|
1ad2f3f9a5 | ||
|
|
9d5eac1db2 | ||
|
|
2fd78ac26b | ||
|
|
86c09d8a0f | ||
|
|
70f6b005a9 | ||
|
|
57eb7764cc | ||
|
|
30c2c56656 | ||
|
|
292ea622a7 | ||
|
|
0d99760d89 | ||
|
|
713ff12db2 | ||
|
|
2db7e03154 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.7.6"
|
||||
version = "2.8.21"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,70 @@ def detail(device_id: int):
|
||||
host_keys=host_keys,
|
||||
method_labels=monitor.METHOD_LABELS,
|
||||
terminal_enabled=bool(extension.settings.get(conn, "terminal_clients_enabled")),
|
||||
vnc=_vnc_zugang(conn, device),
|
||||
)
|
||||
|
||||
|
||||
def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None:
|
||||
"""Der Bildschirmzugriff dieses Clients -- aus dem, was bekannt ist.
|
||||
|
||||
Diese Seite fragt das Geraet **nicht**. Erreichbarkeit kommt aus dem
|
||||
Ueberwachungsdurchlauf, die Anmeldeverfahren aus der letzten
|
||||
ausdruecklichen Pruefung. Der Grund steht in :func:`vnc.zugang_gemerkt`:
|
||||
jede Messung ist eine angefangene Protokollverhandlung, und ein RealVNC
|
||||
Server zaehlt so etwas mit.
|
||||
"""
|
||||
extension = core()
|
||||
if not current_user.has("device_vnc.execute"):
|
||||
return None
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
return None
|
||||
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||
return vnc.zugang_gemerkt(device, port=port)
|
||||
|
||||
|
||||
@bp.get("/<int:device_id>/vnc")
|
||||
@permission_required("device_vnc.execute")
|
||||
def vnc_connection(device_id: int):
|
||||
"""Die Verbindungsdatei -- drei Zeilen, sonst nichts.
|
||||
|
||||
Kein Passwort, kein Benutzername, keine Verschluesselungsvorgabe: was beim
|
||||
Verbinden verlangt wird, entscheidet der VNC-Server auf dem Geraet. Die
|
||||
Datei traegt nur, wohin es geht und wie die Verbindung im Viewer heisst.
|
||||
"""
|
||||
conn = db()
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
extension = core()
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
abort(404)
|
||||
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||
ziel = vnc.ziel(device)
|
||||
if not ziel:
|
||||
abort(404)
|
||||
# Kein Eintrag in der Aenderungskette: eine Verbindungsdatei ist derselbe
|
||||
# Vorgang wie die Sitzung im Browser, nur fuer einen Viewer auf dem
|
||||
# Arbeitsplatz -- und sie aendert nichts. Wer zusieht, steht im laufenden
|
||||
# Protokoll; das Aenderungsprotokoll bleibt die Liste der Eingriffe.
|
||||
live = core().registry.get("live_log")
|
||||
if live is not None:
|
||||
live.info(
|
||||
"vnc", f"{current_user.username} lädt die Verbindungsdatei für {device['name']}"
|
||||
)
|
||||
return Response(
|
||||
vnc.verbindungsdatei(
|
||||
device,
|
||||
port=port,
|
||||
warnung_aus=bool(extension.settings.get(conn, "vnc_warn_unencrypted_off")),
|
||||
),
|
||||
mimetype="application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{vnc.dateiname(device)}"',
|
||||
# Eine Verbindungsdatei ist kein Dokument, das ein Proxy aufheben
|
||||
# soll -- und sie ist billig genug, sie jedes Mal neu zu erzeugen.
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -361,7 +442,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 +472,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 +506,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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,356 @@
|
||||
"""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
|
||||
|
||||
#: Wie oft nachgesehen wird, ob die Sitzung noch etwas tut. Kurz genug, dass
|
||||
#: das Trennen puenktlich ist, lang genug, dass das Nachsehen nichts kostet.
|
||||
TAKT = 1.0
|
||||
|
||||
|
||||
def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None:
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None or device.get("deleted_at"):
|
||||
return None
|
||||
return device
|
||||
|
||||
|
||||
def _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
|
||||
# Das Zeitlimit galt dem *Aufbau* -- als Abbruchgrund taugt es nicht.
|
||||
#
|
||||
# ``create_connection`` setzt es an den Socket, und dort gilt es fuer jede
|
||||
# weitere Leseoperation. Ein Bildschirm, auf dem sich gerade nichts tut,
|
||||
# schickt sekundenlang nichts; nach sechs Sekunden Stille brach das Lesen
|
||||
# mit einer Zeitueberschreitung ab, und die Sitzung war weg. Genau so
|
||||
# beobachtet: Bild steht, nach fuenf bis zehn Sekunden Schluss.
|
||||
#
|
||||
# Stattdessen ein kurzer Takt: das Lesen laeuft immer wieder in ein
|
||||
# Zeitlimit, und *das* ist kein Abbruch, sondern der Anlass nachzusehen,
|
||||
# ob die Sitzung ueberhaupt noch etwas tut.
|
||||
gegenstelle.settimeout(TAKT)
|
||||
leerlauf = int(extension.settings.get(conn, "vnc_idle_timeout") or 0)
|
||||
|
||||
# Ins **laufende** Protokoll, nicht in die Aenderungskette: eine
|
||||
# Bildschirmsitzung aendert nichts an der Anlage. Das Aenderungsprotokoll
|
||||
# soll beantworten, wer was umgestellt hat -- wer wann zugesehen hat,
|
||||
# gehoert nicht in dieselbe Liste und macht sie in einem Betrieb mit
|
||||
# ein paar Dutzend Geraeten unlesbar. Sichtbar ist die Sitzung trotzdem,
|
||||
# solange sie laeuft.
|
||||
begonnen = time.monotonic()
|
||||
live = extension.registry.get("live_log")
|
||||
if live is not None:
|
||||
live.info("vnc", f"{current_user.username} öffnet den Bildschirm von {device['name']}")
|
||||
|
||||
laeuft = threading.Event()
|
||||
laeuft.set()
|
||||
#: Wann zuletzt etwas floss -- in *irgendeine* Richtung. Eine Liste, weil
|
||||
#: beide Richtungen daran schreiben und eine Zuweisung in Python nicht in
|
||||
#: die andere hineinlaufen kann.
|
||||
zuletzt = [time.monotonic()]
|
||||
#: Grund des Endes, falls wir selbst getrennt haben.
|
||||
schluss = [""]
|
||||
#: Kam ueberhaupt schon ein Byte vom Geraet?
|
||||
gehoert = [False]
|
||||
|
||||
def vom_geraet() -> None:
|
||||
"""Alles, was der VNC-Server schickt, geht als Binaerrahmen weiter."""
|
||||
try:
|
||||
while laeuft.is_set():
|
||||
try:
|
||||
daten = gegenstelle.recv(BLOCK)
|
||||
except socket.timeout:
|
||||
# Kein Fehler, nur Stille -- und die bedeutet zweierlei,
|
||||
# je nachdem, ob die Sitzung schon stand.
|
||||
#
|
||||
# **Vor dem ersten Byte** ist Stille ein Befund: der Port
|
||||
# nimmt an, aber es kommt nichts. Genau so verhaelt sich
|
||||
# ein RealVNC Server, der die Adresse gerade aussperrt. Wer
|
||||
# das nicht erfaehrt, sieht ewig "Verbindung wird
|
||||
# aufgebaut" -- und sucht den Fehler bei uns.
|
||||
if not gehoert[0] and time.monotonic() - begonnen > VERBINDUNGSLIMIT:
|
||||
schluss[0] = (
|
||||
f"{host}:{port} nimmt die Verbindung an, schickt aber nichts. "
|
||||
"So verhält sich ein VNC-Server, der die Adresse gerade "
|
||||
"aussperrt (RealVNC: BlacklistTimeout) – ein Neustart des "
|
||||
"VNC-Dienstes auf dem Gerät räumt das ab."
|
||||
)
|
||||
break
|
||||
# **Danach** ist Stille der Normalfall: ein Bildschirm, auf
|
||||
# dem sich nichts tut, schickt nichts.
|
||||
if gehoert[0] and leerlauf and time.monotonic() - zuletzt[0] > leerlauf:
|
||||
schluss[0] = (
|
||||
f"Sitzung nach {leerlauf} Sekunden ohne Bewegung getrennt"
|
||||
)
|
||||
break
|
||||
continue
|
||||
if not daten:
|
||||
if not gehoert[0]:
|
||||
schluss[0] = (
|
||||
f"{host}:{port} hat die Verbindung sofort wieder geschlossen."
|
||||
)
|
||||
break
|
||||
gehoert[0] = True
|
||||
zuletzt[0] = time.monotonic()
|
||||
ws.send(daten)
|
||||
except Exception: # noqa: BLE001 - Abbruch ist der Normalfall, nicht die Stoerung
|
||||
pass
|
||||
finally:
|
||||
laeuft.clear()
|
||||
try:
|
||||
if schluss[0]:
|
||||
ws.close(1000 if gehoert[0] else 1011, schluss[0])
|
||||
else:
|
||||
ws.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
leser = threading.Thread(target=vom_geraet, name=f"vnc-{device_id}", daemon=True)
|
||||
leser.start()
|
||||
|
||||
try:
|
||||
while laeuft.is_set():
|
||||
nachricht = ws.receive()
|
||||
if nachricht is None:
|
||||
break
|
||||
if isinstance(nachricht, str):
|
||||
# Der Browser-Client schickt ausschliesslich Binaerrahmen. Text
|
||||
# waere entweder ein Irrtum oder ein Versuch -- beides endet
|
||||
# hier und nicht auf dem Geraet.
|
||||
continue
|
||||
zuletzt[0] = time.monotonic()
|
||||
gegenstelle.sendall(nachricht)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Bildschirmsitzung beendet (%s)", device["name"], exc_info=True)
|
||||
finally:
|
||||
laeuft.clear()
|
||||
try:
|
||||
gegenstelle.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
gegenstelle.close()
|
||||
dauer = int(time.monotonic() - begonnen)
|
||||
if live is not None:
|
||||
live.info(
|
||||
"vnc",
|
||||
f"Bildschirmsitzung {device['name']} beendet ({dauer}s)"
|
||||
+ (f" – {schluss[0]}" if schluss[0] else ""),
|
||||
)
|
||||
@@ -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,10 @@ def system():
|
||||
"monitor_failures_before_restart",
|
||||
"monitor_auto_restart",
|
||||
"dashboard_public",
|
||||
"vnc_enabled",
|
||||
"vnc_port",
|
||||
"vnc_idle_timeout",
|
||||
"vnc_warn_unencrypted_off",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
@@ -94,16 +122,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 +147,10 @@ def system_save():
|
||||
"monitor_failures_before_restart",
|
||||
"monitor_auto_restart",
|
||||
"dashboard_public",
|
||||
"vnc_enabled",
|
||||
"vnc_port",
|
||||
"vnc_idle_timeout",
|
||||
"vnc_warn_unencrypted_off",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
@@ -167,6 +204,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 +313,7 @@ def run_check_now():
|
||||
extension = core()
|
||||
|
||||
def worker() -> None:
|
||||
from ..services import inventory, monitor
|
||||
from ..services import inventory, monitor, vnc
|
||||
|
||||
allowed = extension.license is None or extension.license.feature_allowed(
|
||||
"auto_poe_restart"
|
||||
@@ -236,6 +323,7 @@ def run_check_now():
|
||||
live=extension.registry["live_log"],
|
||||
keystore=extension.keystore,
|
||||
license_allows_auto_restart=allowed,
|
||||
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||
)
|
||||
|
||||
threading.Thread(target=worker, name="manual-sweep", daemon=True).start()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -45,6 +45,49 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
||||
section="monitoring",
|
||||
),
|
||||
# -- Bildschirmzugriff (VNC) -------------------------------------------- #
|
||||
SettingSpec(
|
||||
"vnc_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Bildschirmzugriff (VNC) anbieten",
|
||||
hint="Der Überwachungsdurchlauf fragt bei jedem erreichbaren Client kurz, ob "
|
||||
"auf diesem Port ein VNC-Server antwortet; nur dort erscheint der Knopf. "
|
||||
"Sehen darf ihn trotzdem nur, wer das Recht dafür hat.",
|
||||
section="monitoring",
|
||||
),
|
||||
SettingSpec(
|
||||
"vnc_port",
|
||||
5900,
|
||||
"int",
|
||||
"VNC-Port",
|
||||
hint="5900 ist Anzeige :0 – die Vorgabe eines Raspberry Pi.",
|
||||
section="monitoring",
|
||||
minimum=1,
|
||||
maximum=65535,
|
||||
),
|
||||
SettingSpec(
|
||||
"vnc_idle_timeout",
|
||||
300,
|
||||
"int",
|
||||
"Bildschirmsitzung bei Untätigkeit trennen nach (Sekunden)",
|
||||
hint="Gezählt wird, wenn in **keine** Richtung mehr etwas fließt: der Bildschirm "
|
||||
"ändert sich nicht und es kommt keine Eingabe. Eine Sitzung, die jemand offen "
|
||||
"vergisst, hält sonst die Verbindung zum Gerät. 0 schaltet die Trennung ab.",
|
||||
section="monitoring",
|
||||
minimum=0,
|
||||
maximum=86400,
|
||||
),
|
||||
SettingSpec(
|
||||
"vnc_warn_unencrypted_off",
|
||||
False,
|
||||
"bool",
|
||||
"Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken",
|
||||
hint="Setzt WarnUnencrypted=False in die Verbindungsdatei. Betrifft nur den "
|
||||
"RealVNC Viewer auf dem Arbeitsplatz, nicht die Verbindung selbst: die bleibt "
|
||||
"unverschlüsselt, wenn das Gerät es so anbietet.",
|
||||
section="monitoring",
|
||||
),
|
||||
# -- DHCP --------------------------------------------------------------- #
|
||||
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
|
||||
SettingSpec(
|
||||
|
||||
@@ -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 '–' | safe }}</dd>
|
||||
<dt>Standort</dt><dd>{{ device.location or '–' | safe }}</dd>
|
||||
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
|
||||
{% if vnc %}
|
||||
<dt>Bildschirm (VNC)</dt>
|
||||
<dd>
|
||||
{% if vnc.erreichbar %}
|
||||
<span class="mono">{{ vnc.ziel }}:{{ vnc.port }}</span>
|
||||
<div class="fs-xs faint">
|
||||
<a href="{{ url_for('screen.screen', device_id=device.id) }}">
|
||||
Im Browser öffnen</a> – oder die
|
||||
<a href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||
Verbindungsdatei</a> für einen Viewer auf dem Arbeitsplatz; sie
|
||||
enthält nur Ziel und Namen.
|
||||
{% if vnc.hinderungsgrund %}
|
||||
<div class="mt-1">{{ vnc.hinderungsgrund }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="faint">
|
||||
kein VNC-Server auf {{ vnc.ziel }}:{{ vnc.port }}
|
||||
{%- if device.state != 'online' %} (Client antwortet gerade nicht){% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
|
||||
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
|
||||
<dt>Geändert</dt>
|
||||
@@ -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) }}">
|
||||
|
||||
@@ -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 '–' | 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 }}">
|
||||
|
||||
@@ -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,7 +41,7 @@
|
||||
</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,
|
||||
@@ -51,6 +51,34 @@
|
||||
</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">
|
||||
@@ -78,6 +106,39 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('monitor', size=17) }} Bildschirm (VNC)</div>
|
||||
<div class="card__sub">
|
||||
Dieselben Regeln wie bei der Konsole: eigenes Recht, frische
|
||||
Passwortbestätigung, getrennte Zwischenablage. Der Knopf erscheint nur, wo
|
||||
ein VNC-Server antwortet – das prüft der Überwachungsdurchlauf mit.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ checkbox('vnc_enabled', 'Bildschirmzugriff (VNC) anbieten',
|
||||
checked=values.vnc_enabled,
|
||||
hint_text=specs['vnc_enabled'].hint) }}
|
||||
<div class="form-grid">
|
||||
{{ field('vnc_port', 'VNC-Port', value=values.vnc_port, type='number',
|
||||
hint_text=specs['vnc_port'].hint) }}
|
||||
{{ field('vnc_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
|
||||
value=values.vnc_idle_timeout, type='number',
|
||||
hint_text=specs['vnc_idle_timeout'].hint) }}
|
||||
</div>
|
||||
{{ checkbox('vnc_warn_unencrypted_off',
|
||||
'Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken',
|
||||
checked=values.vnc_warn_unencrypted_off,
|
||||
hint_text=specs['vnc_warn_unencrypted_off'].hint) }}
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
@@ -189,7 +250,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) }}">
|
||||
|
||||
@@ -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), "
|
||||
|
||||
@@ -39,6 +39,7 @@ HTTPS=0
|
||||
HTTPS_PORT=""
|
||||
DOMAIN=""
|
||||
ACME_EMAIL=""
|
||||
ERZWINGEN=0
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -49,6 +50,39 @@ warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
|
||||
# -- Welche Fassung liegt hier, und welche laeuft schon? ---------------------
|
||||
#
|
||||
# Ein Installationslauf auf dieselbe Fassung macht nichts besser: er baut die
|
||||
# Python-Umgebung neu, kopiert dieselben Dateien, laedt nginx neu und startet
|
||||
# den Dienst -- mit der einzigen Wirkung, dass die Anwendung ein paar Sekunden
|
||||
# weg ist. Wer "update.sh" in einen Zeitplan haengt, bekaeme das taeglich.
|
||||
#
|
||||
# Verglichen wird die Anwendung **und** der Kern: die beiden Nummern laufen
|
||||
# absichtlich auseinander (siehe pyproject.toml), und eine Auslieferung kann
|
||||
# denselben Anwendungsstand mit neuem Kern enthalten.
|
||||
|
||||
#: Die Nummer aus einer pyproject.toml. Die erste Zeile "version = ..." gilt --
|
||||
#: weitere Vorkommen stehen in Abhaengigkeiten und gehen uns nichts an.
|
||||
paket_version() {
|
||||
sed -n 's/^version = "\([^"]*\)"/\1/p' "$1" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
#: Was in dieser Installation laeuft: "<anwendung> <kern>", sonst leer.
|
||||
laufende_version() {
|
||||
local wurzel="$1" modul="$2"
|
||||
[[ -x "$wurzel/venv/bin/python" ]] || return 0
|
||||
"$wurzel/venv/bin/python" -c \
|
||||
"import $modul, tesm_core; print($modul.__version__, tesm_core.__version__)" \
|
||||
2>/dev/null || true
|
||||
}
|
||||
|
||||
#: Ist das, was hier liegt, schon installiert? Eine eigene Funktion, weil genau
|
||||
#: diese Frage sich pruefen lassen muss -- ohne einen Server aufzusetzen.
|
||||
gleiche_version() {
|
||||
local laeuft="$1" liegt="$2"
|
||||
[[ -n "$laeuft" && "$laeuft" == "$liegt" ]]
|
||||
}
|
||||
|
||||
#: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist.
|
||||
SICHERUNG_STEHT=0
|
||||
INSTALL_FERTIG=0
|
||||
@@ -129,6 +163,7 @@ Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
||||
--acme-email <a> Kontaktadresse fuer Let's Encrypt.
|
||||
--skip-packages Keine Systempakete installieren
|
||||
--yes Rueckfragen ueberspringen
|
||||
--force Auch dann einrichten, wenn dieselbe Fassung schon laeuft
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -144,6 +179,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
|
||||
--skip-packages) SKIP_PACKAGES=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
--force) ERZWINGEN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) fail "unbekannte Option: $1" ;;
|
||||
esac
|
||||
@@ -261,6 +297,23 @@ FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
|
||||
IS_UPDATE=0
|
||||
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
|
||||
|
||||
# Laeuft hier schon genau das, was hier liegt? Dann ist Schluss, bevor
|
||||
# irgendetwas angefasst wird -- und zwar mit Ausstiegscode 0: es ist nichts
|
||||
# schiefgegangen, es war nur nichts zu tun.
|
||||
MODUL="tesm"
|
||||
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
|
||||
LIEGT="$(paket_version "$REPO_DIR/apps/$APP/pyproject.toml") $(
|
||||
paket_version "$REPO_DIR/packages/tesm-core/pyproject.toml")"
|
||||
LAEUFT="$(laufende_version "$ROOT" "$MODUL")"
|
||||
if [[ $IS_UPDATE -eq 1 && $ERZWINGEN -eq 0 ]] && gleiche_version "$LAEUFT" "$LIEGT"; then
|
||||
step "Nichts zu tun"
|
||||
info "$APP ${LIEGT%% *} (tesm-core ${LIEGT##* }) laeuft hier bereits."
|
||||
info "Ein zweiter Durchlauf wuerde nur die Umgebung neu bauen und den"
|
||||
info "Dienst neu starten -- ohne dass sich etwas aendert."
|
||||
info "Trotzdem neu einrichten: --force"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
step "TESM-Installation"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Ziel : $ROOT"
|
||||
@@ -278,6 +331,7 @@ else
|
||||
info "nginx : Port $HTTP_PORT (nur HTTP)"
|
||||
fi
|
||||
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
|
||||
info "Fassung : ${LIEGT%% *} (tesm-core ${LIEGT##* })${LAEUFT:+, bisher ${LAEUFT%% *}}"
|
||||
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Fortfahren? [j/N] " answer
|
||||
|
||||
@@ -33,6 +33,8 @@ EXTRA=("$@")
|
||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||
echo "Beispiel : update.sh tesm latest --instance test" >&2
|
||||
echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2
|
||||
echo "Laeuft die Fassung schon, endet der Aufruf ohne Arbeit; --force setzt" >&2
|
||||
echo "sie trotzdem neu auf." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
@@ -173,6 +175,32 @@ release_holen() {
|
||||
|
||||
release_holen
|
||||
|
||||
# Schon installiert? Dann gar nicht erst fragen.
|
||||
#
|
||||
# Dieselbe Pruefung steht in install.sh und gilt dort endgueltig (sie
|
||||
# vergleicht auch den Kern). Hier steht sie noch einmal, weil ein Update, das
|
||||
# ohne Wirkung bleibt, gar nicht erst nach einer Bestaetigung fragen soll --
|
||||
# und weil "es lief durch" sonst wie eine Aktualisierung aussieht.
|
||||
ERZWINGEN=0
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--force" ]] && ERZWINGEN=1
|
||||
done
|
||||
INSTALLATION="$APP"
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--instance" ]] && INSTALLATION="$APP-${EXTRA[i + 1]:-}"
|
||||
done
|
||||
MODUL="tesm"
|
||||
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
|
||||
if [[ $ERZWINGEN -eq 0 && -x "/srv/$INSTALLATION/venv/bin/python" ]]; then
|
||||
LAEUFT="$("/srv/$INSTALLATION/venv/bin/python" -c \
|
||||
"import $MODUL; print($MODUL.__version__)" 2>/dev/null || true)"
|
||||
if [[ -n "$LAEUFT" && "v$LAEUFT" == "$TAG" ]]; then
|
||||
echo "==> $APP $LAEUFT laeuft hier bereits -- nichts zu tun."
|
||||
echo " Trotzdem neu einrichten: --force"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es
|
||||
# bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht.
|
||||
OHNE_RUECKFRAGE=0
|
||||
|
||||
@@ -120,6 +120,14 @@ sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
sudo bash /srv/tesm-license/deploy/update.sh tesm-license
|
||||
```
|
||||
|
||||
**Läuft die Fassung schon, passiert nichts.** `update.sh` vergleicht den
|
||||
aufgelösten Tag mit dem, was installiert ist, und endet mit `0`, bevor es eine
|
||||
Rückfrage stellt; `install.sh` prüft es noch einmal genauer (Anwendung **und**
|
||||
Kern) und bricht vor dem ersten Eingriff ab. Ein zweiter Lauf auf dieselbe
|
||||
Fassung baut sonst die Python-Umgebung neu, kopiert dieselben Dateien und
|
||||
startet den Dienst neu -- ein paar Sekunden Ausfall ohne jede Wirkung. Wer das
|
||||
trotzdem will (eine beschädigte Installation etwa), hängt `--force` an.
|
||||
|
||||
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
||||
eine **benannte Instanz** zu aktualisieren:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.7.6"
|
||||
version = "2.8.11"
|
||||
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",
|
||||
"",
|
||||
@@ -148,7 +156,11 @@ def create_base_app(
|
||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||
#: Anwendungen.
|
||||
auth_tagline: str = "",
|
||||
app_version: str = "2.7.6",
|
||||
#: Beide Anwendungen geben ihre eigene Paketversion mit -- und die laeuft
|
||||
#: absichtlich auseinander: was sich nicht geaendert hat, bekommt keine
|
||||
#: neue Nummer. Eine feste Zahl an dieser Stelle wuerde irgendwann eine
|
||||
#: falsche Version in die Fussnote schreiben.
|
||||
app_version: str = "unbekannt",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1456,18 +1566,23 @@
|
||||
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
|
||||
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
|
||||
zieht statt die Seite zu scrollen. */
|
||||
/* Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
|
||||
Ankerpunkt -- dieselbe Kante, an der pdf-lib das Bild setzt. Der Text
|
||||
haengt darunter und darf die Hoehe nicht mitbestimmen. */
|
||||
.viewer__unterschrift {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
container-type: inline-size;
|
||||
border: 1px dashed transparent;
|
||||
/* "outline" statt "border": ein Rahmen haette die Marke um zwei Pixel
|
||||
groesser gemacht und die Bildkante vom Ankerpunkt weggeschoben. */
|
||||
outline: 1px dashed transparent;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.viewer__unterschrift:hover,
|
||||
.viewer__unterschrift.is-fassend {
|
||||
border-color: var(--accent);
|
||||
outline-color: var(--accent);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@@ -1483,15 +1598,23 @@
|
||||
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
|
||||
anderes als die Datei. */
|
||||
.viewer__unterschrifttext {
|
||||
/* Unter der Zeichnung, nicht in ihr: "top: 100%" setzt die Oberkante des
|
||||
Strichs auf die Bildkante -- genau dort zieht ihn auch pdf-lib. Abstand,
|
||||
Strichstaerke und Innenabstand rechnet das Skript in Punkten. */
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
/* Helvetica: dieselbe Schrift, die das PDF benutzt (StandardFonts.Helvetica).
|
||||
Mit der Hausschrift sah die Vorschau anders aus als das Ergebnis. Die
|
||||
Groesse setzt das Skript je Unterschrift -- hier steht nur ein Rueckfall. */
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 5.6cqw;
|
||||
/* Die tatsaechliche Groesse setzt das Skript in Pixeln (Punkte mal
|
||||
Pixel-je-Punkt der Seite). Dieser Wert gilt nur, solange sie noch nicht
|
||||
gerechnet wurde -- geerbt wuerde sonst die Schriftgroesse der Seite. */
|
||||
font-size: 8px;
|
||||
line-height: 1.15;
|
||||
color: #111;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.85);
|
||||
padding-top: 1px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
width: max-content;
|
||||
|
||||
@@ -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,324 @@
|
||||
// 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";
|
||||
|
||||
// Drei Eingriffe in private Methoden der mitgelieferten Bibliothek. Bewusst
|
||||
// hier und nicht in der Bibliothek selbst: eine neue Fassung von noVNC laesst
|
||||
// sich dann austauschen, ohne eine Aenderung nachzupflegen -- faellt eine
|
||||
// Methode weg, entfaellt nur die Auskunft, nicht die Sitzung.
|
||||
//
|
||||
// Zwei davon holen nur Auskunft (_fail, _socketClose). Der dritte greift in
|
||||
// die Wahl des Sicherheitsverfahrens ein -- aber erst, **nachdem** eines
|
||||
// nachweislich nicht getragen hat.
|
||||
//
|
||||
// Die Vorgeschichte gehoert dazu, damit niemand den Schritt zurueckgeht: hier
|
||||
// stand einmal eine Vorauswahl, die ohne hinterlegte Zugangsdaten ein
|
||||
// Verfahren ohne Anmeldung erzwang, weil RealVNC RA2ne zuerst nennt. Das war
|
||||
// geraten und falsch. Was jetzt hier steht, ist das Gegenteil einer
|
||||
// Vermutung: der Server nennt seine Reihenfolge, die gilt -- und wenn seine
|
||||
// erste Wahl die Verbindung abreissen laesst, ist das ein Befund, auf den man
|
||||
// antworten darf. Gemessen am Geraet: RealVNC bietet 6 (RA2ne) an, waehlt man
|
||||
// es, legt es auf; daneben liegt 1 (ohne Authentifizierung), und darueber
|
||||
// laeuft auch der Viewer.
|
||||
const istUnterstuetzt = RFB.prototype._isSupportedSecurityType;
|
||||
|
||||
//: Verfahren, die in dieser Sitzung schon abgerissen sind.
|
||||
const gescheitert = new Set();
|
||||
|
||||
//: Hoechstens ein zweiter Anlauf. Jeder Versuch ist eine Verbindung, und ein
|
||||
//: RealVNC Server zaehlt abgebrochene Versuche mit -- wer dreimal anklopft,
|
||||
//: sperrt sich aus.
|
||||
const HOECHSTENS = 2;
|
||||
|
||||
//: So lange darf "wird aufgebaut" stehen, bevor die Seite selbst nachsieht.
|
||||
//:
|
||||
//: Der Server hat eigene Grenzen und meldet sich, wenn das Geraet schweigt --
|
||||
//: aber nur, wenn die Leitung dorthin ueberhaupt steht. Kommt die
|
||||
//: WebSocket-Verbindung gar nicht erst zustande, wartet noVNC ohne jedes
|
||||
//: Ereignis, und die Seite stuende stumm bis zum Seitenwechsel. Genau so
|
||||
//: gemeldet: "Verbindung wird aufgebaut ..." und sonst nichts.
|
||||
const AUFBAU_GRENZE = 12000;
|
||||
// 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;
|
||||
let aufbauUhr = 0;
|
||||
let versuche = 0;
|
||||
let stand = false;
|
||||
let letzterZugang = {};
|
||||
|
||||
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}`);
|
||||
}
|
||||
if (gescheitert.size) {
|
||||
teile.push(`erfolglos: ${[...gescheitert].join(", ")}`);
|
||||
}
|
||||
return teile.join(" · ");
|
||||
}
|
||||
|
||||
// Hat das gewählte Verfahren die Verbindung abreissen lassen? Dann ist das
|
||||
// ein Befund und keine Vermutung -- der naechste Anlauf laesst es aus.
|
||||
//
|
||||
// Nur solange nie ein Bild stand: bricht eine **laufende** Sitzung ab, liegt
|
||||
// es nicht am Anmeldeverfahren, und ein stiller Neuaufbau wuerde die Ursache
|
||||
// verdecken.
|
||||
function naechsterVersuch() {
|
||||
const verfahren = rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0;
|
||||
if (!verfahren || versuche >= HOECHSTENS || gescheitert.has(verfahren)) {
|
||||
return false;
|
||||
}
|
||||
gescheitert.add(verfahren);
|
||||
melden(
|
||||
`Verfahren ${verfahren} hat die Verbindung nicht getragen – nächster Anlauf ` +
|
||||
"mit dem nächsten, das der Server anbietet …",
|
||||
"warnung",
|
||||
);
|
||||
const zugang = letzterZugang;
|
||||
rfb = null;
|
||||
window.setTimeout(() => verbinden(zugang), 400);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …");
|
||||
letzterGrund = "";
|
||||
letzterCode = 0;
|
||||
letzterZugang = zugang;
|
||||
versuche += 1;
|
||||
window.clearTimeout(aufbauUhr);
|
||||
aufbauUhr = window.setTimeout(() => {
|
||||
// Weder verbunden noch abgebrochen noch nach Zugangsdaten gefragt --
|
||||
// dann haengt es an der Leitung, nicht am VNC-Server.
|
||||
melden("Es kommt keine Antwort. Wird geprüft, woran es liegt …", "warnung");
|
||||
streckeTesten().then((steht) => {
|
||||
melden(
|
||||
steht
|
||||
? "Die WebSocket-Strecke zu TESM steht, aber die Sitzung kommt nicht " +
|
||||
"zustande. Antwortet der VNC-Server auf dem Gerät noch? Ein Neustart " +
|
||||
"des VNC-Dienstes räumt eine Sperre ab."
|
||||
: "Die WebSocket-Verbindung zu TESM kommt nicht zustande – auch eine " +
|
||||
"einfache Probe scheitert. Ein Proxy zwischen Browser und TESM muss " +
|
||||
"die Kopfzeilen Upgrade und Connection durchreichen (nginx: " +
|
||||
"location /ws/ mit proxy_set_header Upgrade $http_upgrade).",
|
||||
"fehler",
|
||||
);
|
||||
});
|
||||
}, AUFBAU_GRENZE);
|
||||
|
||||
// Was abgerissen ist, wird nicht noch einmal gewaehlt. Sonst faende noVNC
|
||||
// dasselbe Verfahren wieder zuerst -- und der zweite Anlauf waere eine
|
||||
// Wiederholung des ersten.
|
||||
RFB.prototype._isSupportedSecurityType = function (typ) {
|
||||
if (gescheitert.has(typ)) return false;
|
||||
return istUnterstuetzt.call(this, typ);
|
||||
};
|
||||
|
||||
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", () => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
stand = true;
|
||||
melden(`Verbunden mit ${wurzel.dataset.ziel}`, "ok");
|
||||
abfrage.hidden = true;
|
||||
leinwand.hidden = false;
|
||||
knopfTrennen.hidden = false;
|
||||
knopfStrg.hidden = false;
|
||||
});
|
||||
|
||||
rfb.addEventListener("disconnect", (ereignis) => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
leinwand.hidden = true;
|
||||
knopfTrennen.hidden = true;
|
||||
knopfStrg.hidden = true;
|
||||
if (ereignis.detail && ereignis.detail.clean) {
|
||||
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
|
||||
// dass TESM wegen Untätigkeit getrennt hat. Ohne ihn stuende da
|
||||
// "beendet", und niemand wuesste, warum das Bild weg ist.
|
||||
melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet.");
|
||||
} else if (!stand && naechsterVersuch()) {
|
||||
return;
|
||||
} 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) => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
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) => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
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);
|
||||
}
|
||||
@@ -297,9 +297,9 @@ async function nachbearbeiten(
|
||||
const textbreite = Math.max(
|
||||
...zeilen.map((zeile) => schrift.widthOfTextAtSize(zeile, groesse))
|
||||
);
|
||||
let unten = eintrag.y - 2;
|
||||
let unten = eintrag.y - UNTERSCHRIFT.textY;
|
||||
for (const zeile of zeilen) {
|
||||
unten -= groesse * 1.15;
|
||||
unten -= groesse * UNTERSCHRIFT.zeile;
|
||||
seite.drawText(zeile, {
|
||||
x: eintrag.x,
|
||||
y: unten,
|
||||
@@ -312,9 +312,9 @@ async function nachbearbeiten(
|
||||
// erkennbar zu bleiben: ein halber Punkt in hellem Grau ging zwischen
|
||||
// den Tabellenlinien schlicht unter.
|
||||
seite.drawLine({
|
||||
start: { x: eintrag.x, y: eintrag.y - 1.5 },
|
||||
end: { x: eintrag.x + textbreite, y: eintrag.y - 1.5 },
|
||||
thickness: 0.9,
|
||||
start: { x: eintrag.x, y: eintrag.y - UNTERSCHRIFT.strichY },
|
||||
end: { x: eintrag.x + textbreite, y: eintrag.y - UNTERSCHRIFT.strichY },
|
||||
thickness: UNTERSCHRIFT.strich,
|
||||
color: PDFLib.rgb(0.15, 0.15, 0.15),
|
||||
});
|
||||
}
|
||||
@@ -342,12 +342,77 @@ async function nachbearbeiten(
|
||||
return dokument.save();
|
||||
}
|
||||
|
||||
/** Masse des Textblocks unter einer Unterschrift -- in PDF-Punkten.
|
||||
*
|
||||
* Die Vorschau rechnet dieselben Zahlen in Pixel um. Standen sie zweimal im
|
||||
* Code, liefen sie auseinander: die Marke haengt mit ihrer **Unterkante** am
|
||||
* Ankerpunkt, das PDF setzt dort die **Unterkante des Bildes** und schreibt
|
||||
* den Text darunter. Beim Setzen sass der Block deshalb um die Texthoehe
|
||||
* hoeher als in der gespeicherten Datei.
|
||||
*/
|
||||
const UNTERSCHRIFT = {
|
||||
//: Mitte des Strichs unter der Bildkante.
|
||||
strichY: 1.5,
|
||||
//: Staerke des Strichs.
|
||||
strich: 0.9,
|
||||
//: Bildkante bis zur ersten Grundlinie, vor dem Zeilenabstand.
|
||||
textY: 2,
|
||||
//: Zeilenabstand als Vielfaches der Schriftgroesse.
|
||||
zeile: 1.15,
|
||||
//: Oberkante der Zeilenbox bis zur Grundlinie, in "em" -- nur ein
|
||||
//: Rueckfall: gewoehnlich misst "grundlinie()" das am echten Text nach.
|
||||
//: (Helvetica/Arial: Ascender 0,905 plus halber Durchschuss.)
|
||||
grundlinie: 0.9215,
|
||||
};
|
||||
|
||||
//: Gemessene Grundlinien, je dargestellter Schriftgroesse in Pixeln.
|
||||
const grundlinien = new Map();
|
||||
|
||||
/** Abstand von der Oberkante der Zeilenbox zur Grundlinie, in "em".
|
||||
*
|
||||
* Gemessen statt geraten, und bei genau der Pixelgroesse, die auch auf dem
|
||||
* Schirm steht. Zwei Gruende: welche Datei hinter "Helvetica" steckt,
|
||||
* entscheidet das Geraet -- auf einem iPad eine andere als auf einem
|
||||
* Windows-Rechner, mit anderen Oberlaengen. Und der Browser rundet die
|
||||
* Schriftmasse auf ganze Pixel: bei acht Punkt Schrift verschiebt das die
|
||||
* Zeile um einen halben Pixel gegenueber der Rechnung. pdf-lib rundet nicht.
|
||||
*
|
||||
* Eine leere Inline-Box sitzt auf der Grundlinie -- ihre Lage verraet also,
|
||||
* wo der Text steht.
|
||||
*/
|
||||
function grundlinie(px) {
|
||||
const schluessel = px.toFixed(1);
|
||||
const gemerkt = grundlinien.get(schluessel);
|
||||
if (gemerkt !== undefined) return gemerkt;
|
||||
let anteil = UNTERSCHRIFT.grundlinie;
|
||||
try {
|
||||
const probe = document.createElement("div");
|
||||
probe.className = "viewer__unterschrifttext";
|
||||
probe.style.cssText =
|
||||
"position:absolute;left:-9999px;top:0;visibility:hidden;border:0;padding:0;";
|
||||
probe.style.fontSize = `${px}px`;
|
||||
probe.style.lineHeight = String(UNTERSCHRIFT.zeile);
|
||||
const lot = document.createElement("span");
|
||||
lot.style.cssText = "display:inline-block;width:0;height:0;";
|
||||
probe.append(document.createTextNode("Hg"), lot);
|
||||
document.body.append(probe);
|
||||
const oben = probe.getBoundingClientRect().top;
|
||||
const auf = lot.getBoundingClientRect().top;
|
||||
probe.remove();
|
||||
if (auf > oben) anteil = (auf - oben) / px;
|
||||
} catch (fehler) {
|
||||
console.warn("Grundlinie nicht messbar -- Rueckfall auf Helvetica", fehler);
|
||||
}
|
||||
grundlinien.set(schluessel, anteil);
|
||||
return anteil;
|
||||
}
|
||||
|
||||
/** Schriftgroesse des Namenszugs unter einer Unterschrift dieser Breite.
|
||||
*
|
||||
* Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht
|
||||
* Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe
|
||||
* Rechnung benutzt die Vorschau (als ``cqw``), damit auf dem Schirm steht,
|
||||
* was spaeter im PDF steht.
|
||||
* Rechnung benutzt die Vorschau -- in Pixeln umgerechnet --, damit auf dem
|
||||
* Schirm steht, was spaeter im PDF steht.
|
||||
*/
|
||||
function schriftgroesse(breite) {
|
||||
return Math.max(5, Math.min(10, breite / 18));
|
||||
@@ -637,6 +702,8 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
||||
const MAX_BREITE = 150;
|
||||
const MAX_HOEHE = 50;
|
||||
const unterschriften = [];
|
||||
//: Je gesetzte Unterschrift eine Funktion, die ihre Marke neu vermisst.
|
||||
const neuzeichnen = new Set();
|
||||
let wartendeUnterschrift = null;
|
||||
let zeugenzeile = () => "";
|
||||
|
||||
@@ -714,19 +781,52 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
||||
seitenEl.append(marke);
|
||||
|
||||
const zeichnen = () => {
|
||||
// Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
|
||||
// Ankerpunkt, so wie im PDF. Der Text haengt darunter (siehe
|
||||
// Gestaltung: absolut, "top: 100%") und zaehlt nicht zur Hoehe --
|
||||
// sonst saesse der Block in der Vorschau um die Texthoehe zu hoch.
|
||||
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
|
||||
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
|
||||
marke.style.width = `${(eintrag.breite / blick.width) * 100}%`;
|
||||
// Dieselbe Rechnung wie beim Stempeln, samt ihrer Ober- und Untergrenze
|
||||
// -- in "cqw" (Prozent der Blockbreite), damit sie beim Zoomen mitgeht.
|
||||
text.style.fontSize = `${(schriftgroesse(eintrag.breite) / eintrag.breite) * 100}cqw`;
|
||||
// Alles Weitere wird gerechnet, nicht vom Stylesheet abgeleitet:
|
||||
// Punkte mal Pixel-je-Punkt der gerade dargestellten Seite. Ueber
|
||||
// Container-Einheiten ("cqw") ging das auch -- aber nur dort, wo der
|
||||
// Browser sie kennt; wo nicht, erbte der Block die Schriftgroesse der
|
||||
// Seite, und der Text war um die Haelfte zu breit.
|
||||
const proPunkt = seitenEl.getBoundingClientRect().width / blick.width;
|
||||
const groesse = schriftgroesse(eintrag.breite);
|
||||
const inPixeln = groesse * proPunkt;
|
||||
const oben = UNTERSCHRIFT.strichY - UNTERSCHRIFT.strich / 2;
|
||||
text.style.fontSize = `${inPixeln}px`;
|
||||
text.style.lineHeight = String(UNTERSCHRIFT.zeile);
|
||||
text.style.marginTop = `${oben * proPunkt}px`;
|
||||
text.style.borderTopWidth = `${UNTERSCHRIFT.strich * proPunkt}px`;
|
||||
// Rahmenbreiten rundet der Browser auf ganze Geraetepixel: aus 1,21
|
||||
// wird 1. Mit dem gerechneten Wert sass die erste Zeile deshalb einen
|
||||
// Fuenftel Punkt zu hoch -- also mit dem tatsaechlich benutzten rechnen.
|
||||
const strich =
|
||||
(parseFloat(getComputedStyle(text).borderTopWidth) ||
|
||||
UNTERSCHRIFT.strich * proPunkt) / proPunkt;
|
||||
// Oberkante des Strichs, die Strichstaerke, dann so viel Luft, dass die
|
||||
// erste Grundlinie dort sitzt, wo pdf-lib sie setzt: textY plus einen
|
||||
// Zeilenabstand unter der Bildkante.
|
||||
const innen =
|
||||
UNTERSCHRIFT.textY +
|
||||
UNTERSCHRIFT.zeile * groesse -
|
||||
oben -
|
||||
strich -
|
||||
grundlinie(inPixeln) * groesse;
|
||||
text.style.paddingTop = `${Math.max(0, innen) * proPunkt}px`;
|
||||
};
|
||||
// Beim Zoomen aendert sich Pixel-je-Punkt -- dann neu rechnen.
|
||||
neuzeichnen.add(zeichnen);
|
||||
zeichnen();
|
||||
|
||||
weg.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const stelle = unterschriften.indexOf(eintrag);
|
||||
if (stelle >= 0) unterschriften.splice(stelle, 1);
|
||||
neuzeichnen.delete(zeichnen);
|
||||
marke.remove();
|
||||
});
|
||||
|
||||
@@ -754,8 +854,8 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
||||
} else {
|
||||
eintrag.x = Math.max(2, start.x + (zug.clientX - startX) / proPunkt);
|
||||
eintrag.y = Math.max(
|
||||
// Platz fuer die Textzeilen unter der Zeichnung.
|
||||
schriftgroesse(eintrag.breite) * 2.5,
|
||||
// Platz fuer die beiden Textzeilen unter der Zeichnung.
|
||||
UNTERSCHRIFT.textY + 2 * UNTERSCHRIFT.zeile * schriftgroesse(eintrag.breite),
|
||||
start.y - (zug.clientY - startY) / proPunkt
|
||||
);
|
||||
}
|
||||
@@ -778,6 +878,18 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
||||
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
|
||||
});
|
||||
|
||||
// Nach dem Zoomen stehen die Marken anteilig richtig, aber die Schrift
|
||||
// haengt an der Pixelgroesse der Seite -- die ist jetzt eine andere.
|
||||
eventBus.on("scalechanging", () => {
|
||||
for (const zeichnen of neuzeichnen) {
|
||||
try {
|
||||
zeichnen();
|
||||
} catch (fehler) {
|
||||
console.error("Unterschrift neu vermessen", fehler);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
hinein: () => {
|
||||
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
maintainers:
|
||||
- Samuel Mannehed for Cendio AB (@samhed)
|
||||
- Pierre Ossman for Cendio AB (@CendioOssman)
|
||||
maintainersEmeritus:
|
||||
- Joel Martin (@kanaka)
|
||||
- Solly Ross (@directxman12)
|
||||
- @astrand
|
||||
contributors:
|
||||
# There are a bunch of people that should be here.
|
||||
# If you want to be on this list, feel free send a PR
|
||||
# to add yourself.
|
||||
- jalf <git@jalf.dk>
|
||||
- NTT corp.
|
||||
@@ -0,0 +1,52 @@
|
||||
# noVNC 1.6.0 -- mitgeliefert, nicht nachgeladen
|
||||
|
||||
Quelle: <https://github.com/novnc/noVNC>, Fassung **1.6.0**, uebernommen am
|
||||
13.09.2026. Enthalten sind `core/` (die Bibliothek) und `vendor/pako` (die
|
||||
Entpackroutine, die `core/inflator.js` braucht) -- ohne `app/`, `tests/` und
|
||||
die mitgelieferte Oberflaeche: die Bedienung stellt TESM selbst
|
||||
(`apps/tesm/src/tesm/templates/tesm/screen.html`).
|
||||
|
||||
**Lizenz:** MPL-2.0 fuer die Kernbibliothek, siehe `LICENSE.txt` und `AUTHORS`
|
||||
(beide unveraendert uebernommen). pako steht unter MIT, siehe
|
||||
`vendor/pako/LICENSE`.
|
||||
|
||||
**Warum mitgeliefert und nicht von einem CDN:** dieselbe Regel wie bei pdf.js
|
||||
und pdf-lib nebenan. Die Anwendung laeuft in Netzen ohne Internetzugang, die
|
||||
Inhaltsrichtlinie (CSP) erlaubt ausschliesslich `'self'`, und eine
|
||||
Bibliothek, die sich zur Laufzeit aendern kann, gehoert nicht in einen Pfad,
|
||||
der einen Bildschirm fernsteuert.
|
||||
|
||||
**Unveraendert.** Wird eine Anpassung noetig, gehoert sie hierher in diese
|
||||
Datei -- mit Grund und Fundstelle, damit ein Wechsel auf eine neuere Fassung
|
||||
nicht stillschweigend darueberlaeuft.
|
||||
|
||||
**Was TESM von aussen umbiegt** (also *nicht* in diesen Dateien steht, sondern
|
||||
in `js/screen.js`):
|
||||
|
||||
* `RFB.prototype._fail` und `_socketClose` -- nur, um den Grund eines
|
||||
Abbruchs zu erfahren. noVNC meldet ihn sonst ausschliesslich in die
|
||||
Entwicklerkonsole, und `console.error` mitzulesen traegt nicht: die
|
||||
Bibliothek bindet es beim Laden.
|
||||
* `RFB.prototype._isSupportedSecurityType` -- **erst nachdem** ein Verfahren
|
||||
die Verbindung nachweislich hat abreissen lassen. Dann wird es fuer den
|
||||
zweiten Anlauf ausgelassen, damit noVNC nicht wieder dasselbe zuerst findet.
|
||||
Hoechstens zwei Anlaeufe, denn jeder ist eine Verbindung, und ein RealVNC
|
||||
Server zaehlt abgebrochene mit.
|
||||
|
||||
Hier stand einmal das Gegenteil: eine **Vorauswahl**, die ohne hinterlegte
|
||||
Zugangsdaten ein Verfahren ohne Anmeldung erzwang. Das war geraten und hat
|
||||
eine laufende Sitzung gebrochen. Der Unterschied ist nicht der Code, sondern
|
||||
der Anlass: eine Vermutung vorher gegen einen Befund hinterher.
|
||||
|
||||
Faellt eine dieser Methoden in einer neueren Fassung weg, entfaellt nur die
|
||||
Auskunft bzw. der zweite Anlauf -- die Sitzung laeuft wie ohne sie.
|
||||
|
||||
**Was noVNC an Sicherheitsverfahren spricht** (`core/rfb.js`,
|
||||
`_isSupportedSecurityType`): `1` None, `2` VNC-Passwort, `6` RA2ne (RSA-AES
|
||||
ohne Streckenverschluesselung), `16` Tight, `19` VeNCrypt, `22` xvp, `30` ARD,
|
||||
`113` MS-Logon II. **Nicht** dabei: `5` (RA2 mit Streckenverschluesselung) und
|
||||
alles aus dem RealVNC-eigenen Bereich `7`-`15`. Ein RealVNC Server, der nur
|
||||
`13` und `5` anbietet, kommt mit dieser Bibliothek also nicht zusammen -- er
|
||||
muss `Encryption=AlwaysOff` (dann `6`) oder `Authentication=VncAuth` (dann
|
||||
`2`) angeboten bekommen. TESM misst das und schreibt es auf die Seite, statt
|
||||
den Benutzer vor eine schwarze Flaeche zu setzen.
|
||||
@@ -0,0 +1,62 @@
|
||||
noVNC is Copyright (C) 2022 The noVNC authors
|
||||
(./AUTHORS)
|
||||
|
||||
The noVNC core library files are licensed under the MPL 2.0 (Mozilla
|
||||
Public License 2.0). The noVNC core library is composed of the
|
||||
Javascript code necessary for full noVNC operation. This includes (but
|
||||
is not limited to):
|
||||
|
||||
core/**/*.js
|
||||
app/*.js
|
||||
test/playback.js
|
||||
|
||||
The HTML, CSS, font and images files that included with the noVNC
|
||||
source distibution (or repository) are not considered part of the
|
||||
noVNC core library and are licensed under more permissive licenses.
|
||||
The intent is to allow easy integration of noVNC into existing web
|
||||
sites and web applications.
|
||||
|
||||
The HTML, CSS, font and image files are licensed as follows:
|
||||
|
||||
*.html : 2-Clause BSD license
|
||||
|
||||
app/styles/*.css : 2-Clause BSD license
|
||||
|
||||
app/styles/Orbitron* : SIL Open Font License 1.1
|
||||
(Copyright 2009 Matt McInerney)
|
||||
|
||||
app/images/ : Creative Commons Attribution-ShareAlike
|
||||
http://creativecommons.org/licenses/by-sa/3.0/
|
||||
|
||||
Some portions of noVNC are copyright to their individual authors.
|
||||
Please refer to the individual source files and/or to the noVNC commit
|
||||
history: https://github.com/novnc/noVNC/commits/master
|
||||
|
||||
The are several files and projects that have been incorporated into
|
||||
the noVNC core library. Here is a list of those files and the original
|
||||
licenses (all MPL 2.0 compatible):
|
||||
|
||||
core/base64.js : MPL 2.0
|
||||
|
||||
core/des.js : Various BSD style licenses
|
||||
|
||||
vendor/pako/ : MIT
|
||||
|
||||
Any other files not mentioned above are typically marked with
|
||||
a copyright/license header at the top of the file. The default noVNC
|
||||
license is MPL-2.0.
|
||||
|
||||
The following license texts are included:
|
||||
|
||||
docs/LICENSE.MPL-2.0
|
||||
docs/LICENSE.OFL-1.1
|
||||
docs/LICENSE.BSD-3-Clause (New BSD)
|
||||
docs/LICENSE.BSD-2-Clause (Simplified BSD / FreeBSD)
|
||||
vendor/pako/LICENSE (MIT)
|
||||
|
||||
Or alternatively the license texts may be found here:
|
||||
|
||||
http://www.mozilla.org/MPL/2.0/
|
||||
http://scripts.sil.org/OFL
|
||||
http://en.wikipedia.org/wiki/BSD_licenses
|
||||
https://opensource.org/licenses/MIT
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
// From: http://hg.mozilla.org/mozilla-central/raw-file/ec10630b1a54/js/src/devtools/jint/sunspider/string-base64.js
|
||||
|
||||
import * as Log from './util/logging.js';
|
||||
|
||||
export default {
|
||||
/* Convert data (an array of integers) to a Base64 string. */
|
||||
toBase64Table: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='.split(''),
|
||||
base64Pad: '=',
|
||||
|
||||
encode(data) {
|
||||
"use strict";
|
||||
let result = '';
|
||||
const length = data.length;
|
||||
const lengthpad = (length % 3);
|
||||
// Convert every three bytes to 4 ascii characters.
|
||||
|
||||
for (let i = 0; i < (length - 2); i += 3) {
|
||||
result += this.toBase64Table[data[i] >> 2];
|
||||
result += this.toBase64Table[((data[i] & 0x03) << 4) + (data[i + 1] >> 4)];
|
||||
result += this.toBase64Table[((data[i + 1] & 0x0f) << 2) + (data[i + 2] >> 6)];
|
||||
result += this.toBase64Table[data[i + 2] & 0x3f];
|
||||
}
|
||||
|
||||
// Convert the remaining 1 or 2 bytes, pad out to 4 characters.
|
||||
const j = length - lengthpad;
|
||||
if (lengthpad === 2) {
|
||||
result += this.toBase64Table[data[j] >> 2];
|
||||
result += this.toBase64Table[((data[j] & 0x03) << 4) + (data[j + 1] >> 4)];
|
||||
result += this.toBase64Table[(data[j + 1] & 0x0f) << 2];
|
||||
result += this.toBase64Table[64];
|
||||
} else if (lengthpad === 1) {
|
||||
result += this.toBase64Table[data[j] >> 2];
|
||||
result += this.toBase64Table[(data[j] & 0x03) << 4];
|
||||
result += this.toBase64Table[64];
|
||||
result += this.toBase64Table[64];
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/* Convert Base64 data to a string */
|
||||
/* eslint-disable comma-spacing */
|
||||
toBinaryTable: [
|
||||
-1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1,
|
||||
-1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1,
|
||||
-1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,62, -1,-1,-1,63,
|
||||
52,53,54,55, 56,57,58,59, 60,61,-1,-1, -1, 0,-1,-1,
|
||||
-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11,12,13,14,
|
||||
15,16,17,18, 19,20,21,22, 23,24,25,-1, -1,-1,-1,-1,
|
||||
-1,26,27,28, 29,30,31,32, 33,34,35,36, 37,38,39,40,
|
||||
41,42,43,44, 45,46,47,48, 49,50,51,-1, -1,-1,-1,-1
|
||||
],
|
||||
/* eslint-enable comma-spacing */
|
||||
|
||||
decode(data, offset = 0) {
|
||||
let dataLength = data.indexOf('=') - offset;
|
||||
if (dataLength < 0) { dataLength = data.length - offset; }
|
||||
|
||||
/* Every four characters is 3 resulting numbers */
|
||||
const resultLength = (dataLength >> 2) * 3 + Math.floor((dataLength % 4) / 1.5);
|
||||
const result = new Array(resultLength);
|
||||
|
||||
// Convert one by one.
|
||||
|
||||
let leftbits = 0; // number of bits decoded, but yet to be appended
|
||||
let leftdata = 0; // bits decoded, but yet to be appended
|
||||
for (let idx = 0, i = offset; i < data.length; i++) {
|
||||
const c = this.toBinaryTable[data.charCodeAt(i) & 0x7f];
|
||||
const padding = (data.charAt(i) === this.base64Pad);
|
||||
// Skip illegal characters and whitespace
|
||||
if (c === -1) {
|
||||
Log.Error("Illegal character code " + data.charCodeAt(i) + " at position " + i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect data into leftdata, update bitcount
|
||||
leftdata = (leftdata << 6) | c;
|
||||
leftbits += 6;
|
||||
|
||||
// If we have 8 or more bits, append 8 bits to the result
|
||||
if (leftbits >= 8) {
|
||||
leftbits -= 8;
|
||||
// Append if not padding.
|
||||
if (!padding) {
|
||||
result[idx++] = (leftdata >> leftbits) & 0xff;
|
||||
}
|
||||
leftdata &= (1 << leftbits) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If there are any bits left, the base64 string was corrupted
|
||||
if (leftbits) {
|
||||
const err = new Error('Corrupted base64 string');
|
||||
err.name = 'Base64-Error';
|
||||
throw err;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}; /* End of Base64 namespace */
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
export class AESECBCipher {
|
||||
constructor() {
|
||||
this._key = null;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "AES-ECB" };
|
||||
}
|
||||
|
||||
static async importKey(key, _algorithm, extractable, keyUsages) {
|
||||
const cipher = new AESECBCipher;
|
||||
await cipher._importKey(key, extractable, keyUsages);
|
||||
return cipher;
|
||||
}
|
||||
|
||||
async _importKey(key, extractable, keyUsages) {
|
||||
this._key = await window.crypto.subtle.importKey(
|
||||
"raw", key, {name: "AES-CBC"}, extractable, keyUsages);
|
||||
}
|
||||
|
||||
async encrypt(_algorithm, plaintext) {
|
||||
const x = new Uint8Array(plaintext);
|
||||
if (x.length % 16 !== 0 || this._key === null) {
|
||||
return null;
|
||||
}
|
||||
const n = x.length / 16;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const y = new Uint8Array(await window.crypto.subtle.encrypt({
|
||||
name: "AES-CBC",
|
||||
iv: new Uint8Array(16),
|
||||
}, this._key, x.slice(i * 16, i * 16 + 16))).slice(0, 16);
|
||||
x.set(y, i * 16);
|
||||
}
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
||||
export class AESEAXCipher {
|
||||
constructor() {
|
||||
this._rawKey = null;
|
||||
this._ctrKey = null;
|
||||
this._cbcKey = null;
|
||||
this._zeroBlock = new Uint8Array(16);
|
||||
this._prefixBlock0 = this._zeroBlock;
|
||||
this._prefixBlock1 = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]);
|
||||
this._prefixBlock2 = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2]);
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "AES-EAX" };
|
||||
}
|
||||
|
||||
async _encryptBlock(block) {
|
||||
const encrypted = await window.crypto.subtle.encrypt({
|
||||
name: "AES-CBC",
|
||||
iv: this._zeroBlock,
|
||||
}, this._cbcKey, block);
|
||||
return new Uint8Array(encrypted).slice(0, 16);
|
||||
}
|
||||
|
||||
async _initCMAC() {
|
||||
const k1 = await this._encryptBlock(this._zeroBlock);
|
||||
const k2 = new Uint8Array(16);
|
||||
const v = k1[0] >>> 6;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
k2[i] = (k1[i + 1] >> 6) | (k1[i] << 2);
|
||||
k1[i] = (k1[i + 1] >> 7) | (k1[i] << 1);
|
||||
}
|
||||
const lut = [0x0, 0x87, 0x0e, 0x89];
|
||||
k2[14] ^= v >>> 1;
|
||||
k2[15] = (k1[15] << 2) ^ lut[v];
|
||||
k1[15] = (k1[15] << 1) ^ lut[v >> 1];
|
||||
this._k1 = k1;
|
||||
this._k2 = k2;
|
||||
}
|
||||
|
||||
async _encryptCTR(data, counter) {
|
||||
const encrypted = await window.crypto.subtle.encrypt({
|
||||
name: "AES-CTR",
|
||||
counter: counter,
|
||||
length: 128
|
||||
}, this._ctrKey, data);
|
||||
return new Uint8Array(encrypted);
|
||||
}
|
||||
|
||||
async _decryptCTR(data, counter) {
|
||||
const decrypted = await window.crypto.subtle.decrypt({
|
||||
name: "AES-CTR",
|
||||
counter: counter,
|
||||
length: 128
|
||||
}, this._ctrKey, data);
|
||||
return new Uint8Array(decrypted);
|
||||
}
|
||||
|
||||
async _computeCMAC(data, prefixBlock) {
|
||||
if (prefixBlock.length !== 16) {
|
||||
return null;
|
||||
}
|
||||
const n = Math.floor(data.length / 16);
|
||||
const m = Math.ceil(data.length / 16);
|
||||
const r = data.length - n * 16;
|
||||
const cbcData = new Uint8Array((m + 1) * 16);
|
||||
cbcData.set(prefixBlock);
|
||||
cbcData.set(data, 16);
|
||||
if (r === 0) {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
cbcData[n * 16 + i] ^= this._k1[i];
|
||||
}
|
||||
} else {
|
||||
cbcData[(n + 1) * 16 + r] = 0x80;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
cbcData[(n + 1) * 16 + i] ^= this._k2[i];
|
||||
}
|
||||
}
|
||||
let cbcEncrypted = await window.crypto.subtle.encrypt({
|
||||
name: "AES-CBC",
|
||||
iv: this._zeroBlock,
|
||||
}, this._cbcKey, cbcData);
|
||||
|
||||
cbcEncrypted = new Uint8Array(cbcEncrypted);
|
||||
const mac = cbcEncrypted.slice(cbcEncrypted.length - 32, cbcEncrypted.length - 16);
|
||||
return mac;
|
||||
}
|
||||
|
||||
static async importKey(key, _algorithm, _extractable, _keyUsages) {
|
||||
const cipher = new AESEAXCipher;
|
||||
await cipher._importKey(key);
|
||||
return cipher;
|
||||
}
|
||||
|
||||
async _importKey(key) {
|
||||
this._rawKey = key;
|
||||
this._ctrKey = await window.crypto.subtle.importKey(
|
||||
"raw", key, {name: "AES-CTR"}, false, ["encrypt", "decrypt"]);
|
||||
this._cbcKey = await window.crypto.subtle.importKey(
|
||||
"raw", key, {name: "AES-CBC"}, false, ["encrypt"]);
|
||||
await this._initCMAC();
|
||||
}
|
||||
|
||||
async encrypt(algorithm, message) {
|
||||
const ad = algorithm.additionalData;
|
||||
const nonce = algorithm.iv;
|
||||
const nCMAC = await this._computeCMAC(nonce, this._prefixBlock0);
|
||||
const encrypted = await this._encryptCTR(message, nCMAC);
|
||||
const adCMAC = await this._computeCMAC(ad, this._prefixBlock1);
|
||||
const mac = await this._computeCMAC(encrypted, this._prefixBlock2);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
mac[i] ^= nCMAC[i] ^ adCMAC[i];
|
||||
}
|
||||
const res = new Uint8Array(16 + encrypted.length);
|
||||
res.set(encrypted);
|
||||
res.set(mac, encrypted.length);
|
||||
return res;
|
||||
}
|
||||
|
||||
async decrypt(algorithm, data) {
|
||||
const encrypted = data.slice(0, data.length - 16);
|
||||
const ad = algorithm.additionalData;
|
||||
const nonce = algorithm.iv;
|
||||
const mac = data.slice(data.length - 16);
|
||||
const nCMAC = await this._computeCMAC(nonce, this._prefixBlock0);
|
||||
const adCMAC = await this._computeCMAC(ad, this._prefixBlock1);
|
||||
const computedMac = await this._computeCMAC(encrypted, this._prefixBlock2);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
computedMac[i] ^= nCMAC[i] ^ adCMAC[i];
|
||||
}
|
||||
if (computedMac.length !== mac.length) {
|
||||
return null;
|
||||
}
|
||||
for (let i = 0; i < mac.length; i++) {
|
||||
if (computedMac[i] !== mac[i]) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const res = await this._decryptCTR(encrypted, nCMAC);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
export function modPow(b, e, m) {
|
||||
let r = 1n;
|
||||
b = b % m;
|
||||
while (e > 0n) {
|
||||
if ((e & 1n) === 1n) {
|
||||
r = (r * b) % m;
|
||||
}
|
||||
e = e >> 1n;
|
||||
b = (b * b) % m;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
export function bigIntToU8Array(bigint, padLength=0) {
|
||||
let hex = bigint.toString(16);
|
||||
if (padLength === 0) {
|
||||
padLength = Math.ceil(hex.length / 2);
|
||||
}
|
||||
hex = hex.padStart(padLength * 2, '0');
|
||||
const length = hex.length / 2;
|
||||
const arr = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
arr[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function u8ArrayToBigInt(arr) {
|
||||
let hex = '0x';
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
hex += arr[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
return BigInt(hex);
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { AESECBCipher, AESEAXCipher } from "./aes.js";
|
||||
import { DESCBCCipher, DESECBCipher } from "./des.js";
|
||||
import { RSACipher } from "./rsa.js";
|
||||
import { DHCipher } from "./dh.js";
|
||||
import { MD5 } from "./md5.js";
|
||||
|
||||
// A single interface for the cryptographic algorithms not supported by SubtleCrypto.
|
||||
// Both synchronous and asynchronous implmentations are allowed.
|
||||
class LegacyCrypto {
|
||||
constructor() {
|
||||
this._algorithms = {
|
||||
"AES-ECB": AESECBCipher,
|
||||
"AES-EAX": AESEAXCipher,
|
||||
"DES-ECB": DESECBCipher,
|
||||
"DES-CBC": DESCBCCipher,
|
||||
"RSA-PKCS1-v1_5": RSACipher,
|
||||
"DH": DHCipher,
|
||||
"MD5": MD5,
|
||||
};
|
||||
}
|
||||
|
||||
encrypt(algorithm, key, data) {
|
||||
if (key.algorithm.name !== algorithm.name) {
|
||||
throw new Error("algorithm does not match");
|
||||
}
|
||||
if (typeof key.encrypt !== "function") {
|
||||
throw new Error("key does not support encryption");
|
||||
}
|
||||
return key.encrypt(algorithm, data);
|
||||
}
|
||||
|
||||
decrypt(algorithm, key, data) {
|
||||
if (key.algorithm.name !== algorithm.name) {
|
||||
throw new Error("algorithm does not match");
|
||||
}
|
||||
if (typeof key.decrypt !== "function") {
|
||||
throw new Error("key does not support encryption");
|
||||
}
|
||||
return key.decrypt(algorithm, data);
|
||||
}
|
||||
|
||||
importKey(format, keyData, algorithm, extractable, keyUsages) {
|
||||
if (format !== "raw") {
|
||||
throw new Error("key format is not supported");
|
||||
}
|
||||
const alg = this._algorithms[algorithm.name];
|
||||
if (typeof alg === "undefined" || typeof alg.importKey !== "function") {
|
||||
throw new Error("algorithm is not supported");
|
||||
}
|
||||
return alg.importKey(keyData, algorithm, extractable, keyUsages);
|
||||
}
|
||||
|
||||
generateKey(algorithm, extractable, keyUsages) {
|
||||
const alg = this._algorithms[algorithm.name];
|
||||
if (typeof alg === "undefined" || typeof alg.generateKey !== "function") {
|
||||
throw new Error("algorithm is not supported");
|
||||
}
|
||||
return alg.generateKey(algorithm, extractable, keyUsages);
|
||||
}
|
||||
|
||||
exportKey(format, key) {
|
||||
if (format !== "raw") {
|
||||
throw new Error("key format is not supported");
|
||||
}
|
||||
if (typeof key.exportKey !== "function") {
|
||||
throw new Error("key does not support exportKey");
|
||||
}
|
||||
return key.exportKey();
|
||||
}
|
||||
|
||||
digest(algorithm, data) {
|
||||
const alg = this._algorithms[algorithm];
|
||||
if (typeof alg !== "function") {
|
||||
throw new Error("algorithm is not supported");
|
||||
}
|
||||
return alg(data);
|
||||
}
|
||||
|
||||
deriveBits(algorithm, key, length) {
|
||||
if (key.algorithm.name !== algorithm.name) {
|
||||
throw new Error("algorithm does not match");
|
||||
}
|
||||
if (typeof key.deriveBits !== "function") {
|
||||
throw new Error("key does not support deriveBits");
|
||||
}
|
||||
return key.deriveBits(algorithm, length);
|
||||
}
|
||||
}
|
||||
|
||||
export default new LegacyCrypto;
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
/*
|
||||
* Ported from Flashlight VNC ActionScript implementation:
|
||||
* http://www.wizhelp.com/flashlight-vnc/
|
||||
*
|
||||
* Full attribution follows:
|
||||
*
|
||||
* -------------------------------------------------------------------------
|
||||
*
|
||||
* This DES class has been extracted from package Acme.Crypto for use in VNC.
|
||||
* The unnecessary odd parity code has been removed.
|
||||
*
|
||||
* These changes are:
|
||||
* Copyright (C) 1999 AT&T Laboratories Cambridge. All Rights Reserved.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
*
|
||||
|
||||
* DesCipher - the DES encryption method
|
||||
*
|
||||
* The meat of this code is by Dave Zimmerman <dzimm@widget.com>, and is:
|
||||
*
|
||||
* Copyright (c) 1996 Widget Workshop, Inc. All Rights Reserved.
|
||||
*
|
||||
* Permission to use, copy, modify, and distribute this software
|
||||
* and its documentation for NON-COMMERCIAL or COMMERCIAL purposes and
|
||||
* without fee is hereby granted, provided that this copyright notice is kept
|
||||
* intact.
|
||||
*
|
||||
* WIDGET WORKSHOP MAKES NO REPRESENTATIONS OR WARRANTIES ABOUT THE SUITABILITY
|
||||
* OF THE SOFTWARE, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
* TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
* PARTICULAR PURPOSE, OR NON-INFRINGEMENT. WIDGET WORKSHOP SHALL NOT BE LIABLE
|
||||
* FOR ANY DAMAGES SUFFERED BY LICENSEE AS A RESULT OF USING, MODIFYING OR
|
||||
* DISTRIBUTING THIS SOFTWARE OR ITS DERIVATIVES.
|
||||
*
|
||||
* THIS SOFTWARE IS NOT DESIGNED OR INTENDED FOR USE OR RESALE AS ON-LINE
|
||||
* CONTROL EQUIPMENT IN HAZARDOUS ENVIRONMENTS REQUIRING FAIL-SAFE
|
||||
* PERFORMANCE, SUCH AS IN THE OPERATION OF NUCLEAR FACILITIES, AIRCRAFT
|
||||
* NAVIGATION OR COMMUNICATION SYSTEMS, AIR TRAFFIC CONTROL, DIRECT LIFE
|
||||
* SUPPORT MACHINES, OR WEAPONS SYSTEMS, IN WHICH THE FAILURE OF THE
|
||||
* SOFTWARE COULD LEAD DIRECTLY TO DEATH, PERSONAL INJURY, OR SEVERE
|
||||
* PHYSICAL OR ENVIRONMENTAL DAMAGE ("HIGH RISK ACTIVITIES"). WIDGET WORKSHOP
|
||||
* SPECIFICALLY DISCLAIMS ANY EXPRESS OR IMPLIED WARRANTY OF FITNESS FOR
|
||||
* HIGH RISK ACTIVITIES.
|
||||
*
|
||||
*
|
||||
* The rest is:
|
||||
*
|
||||
* Copyright (C) 1996 by Jef Poskanzer <jef@acme.com>. All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions
|
||||
* are met:
|
||||
* 1. Redistributions of source code must retain the above copyright
|
||||
* notice, this list of conditions and the following disclaimer.
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
||||
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
* SUCH DAMAGE.
|
||||
*
|
||||
* Visit the ACME Labs Java page for up-to-date versions of this and other
|
||||
* fine Java utilities: http://www.acme.com/java/
|
||||
*/
|
||||
|
||||
/* eslint-disable comma-spacing */
|
||||
|
||||
// Tables, permutations, S-boxes, etc.
|
||||
const PC2 = [13,16,10,23, 0, 4, 2,27,14, 5,20, 9,22,18,11, 3,
|
||||
25, 7,15, 6,26,19,12, 1,40,51,30,36,46,54,29,39,
|
||||
50,44,32,47,43,48,38,55,33,52,45,41,49,35,28,31 ],
|
||||
totrot = [ 1, 2, 4, 6, 8,10,12,14,15,17,19,21,23,25,27,28];
|
||||
|
||||
const z = 0x0;
|
||||
let a,b,c,d,e,f;
|
||||
a=1<<16; b=1<<24; c=a|b; d=1<<2; e=1<<10; f=d|e;
|
||||
const SP1 = [c|e,z|z,a|z,c|f,c|d,a|f,z|d,a|z,z|e,c|e,c|f,z|e,b|f,c|d,b|z,z|d,
|
||||
z|f,b|e,b|e,a|e,a|e,c|z,c|z,b|f,a|d,b|d,b|d,a|d,z|z,z|f,a|f,b|z,
|
||||
a|z,c|f,z|d,c|z,c|e,b|z,b|z,z|e,c|d,a|z,a|e,b|d,z|e,z|d,b|f,a|f,
|
||||
c|f,a|d,c|z,b|f,b|d,z|f,a|f,c|e,z|f,b|e,b|e,z|z,a|d,a|e,z|z,c|d];
|
||||
a=1<<20; b=1<<31; c=a|b; d=1<<5; e=1<<15; f=d|e;
|
||||
const SP2 = [c|f,b|e,z|e,a|f,a|z,z|d,c|d,b|f,b|d,c|f,c|e,b|z,b|e,a|z,z|d,c|d,
|
||||
a|e,a|d,b|f,z|z,b|z,z|e,a|f,c|z,a|d,b|d,z|z,a|e,z|f,c|e,c|z,z|f,
|
||||
z|z,a|f,c|d,a|z,b|f,c|z,c|e,z|e,c|z,b|e,z|d,c|f,a|f,z|d,z|e,b|z,
|
||||
z|f,c|e,a|z,b|d,a|d,b|f,b|d,a|d,a|e,z|z,b|e,z|f,b|z,c|d,c|f,a|e];
|
||||
a=1<<17; b=1<<27; c=a|b; d=1<<3; e=1<<9; f=d|e;
|
||||
const SP3 = [z|f,c|e,z|z,c|d,b|e,z|z,a|f,b|e,a|d,b|d,b|d,a|z,c|f,a|d,c|z,z|f,
|
||||
b|z,z|d,c|e,z|e,a|e,c|z,c|d,a|f,b|f,a|e,a|z,b|f,z|d,c|f,z|e,b|z,
|
||||
c|e,b|z,a|d,z|f,a|z,c|e,b|e,z|z,z|e,a|d,c|f,b|e,b|d,z|e,z|z,c|d,
|
||||
b|f,a|z,b|z,c|f,z|d,a|f,a|e,b|d,c|z,b|f,z|f,c|z,a|f,z|d,c|d,a|e];
|
||||
a=1<<13; b=1<<23; c=a|b; d=1<<0; e=1<<7; f=d|e;
|
||||
const SP4 = [c|d,a|f,a|f,z|e,c|e,b|f,b|d,a|d,z|z,c|z,c|z,c|f,z|f,z|z,b|e,b|d,
|
||||
z|d,a|z,b|z,c|d,z|e,b|z,a|d,a|e,b|f,z|d,a|e,b|e,a|z,c|e,c|f,z|f,
|
||||
b|e,b|d,c|z,c|f,z|f,z|z,z|z,c|z,a|e,b|e,b|f,z|d,c|d,a|f,a|f,z|e,
|
||||
c|f,z|f,z|d,a|z,b|d,a|d,c|e,b|f,a|d,a|e,b|z,c|d,z|e,b|z,a|z,c|e];
|
||||
a=1<<25; b=1<<30; c=a|b; d=1<<8; e=1<<19; f=d|e;
|
||||
const SP5 = [z|d,a|f,a|e,c|d,z|e,z|d,b|z,a|e,b|f,z|e,a|d,b|f,c|d,c|e,z|f,b|z,
|
||||
a|z,b|e,b|e,z|z,b|d,c|f,c|f,a|d,c|e,b|d,z|z,c|z,a|f,a|z,c|z,z|f,
|
||||
z|e,c|d,z|d,a|z,b|z,a|e,c|d,b|f,a|d,b|z,c|e,a|f,b|f,z|d,a|z,c|e,
|
||||
c|f,z|f,c|z,c|f,a|e,z|z,b|e,c|z,z|f,a|d,b|d,z|e,z|z,b|e,a|f,b|d];
|
||||
a=1<<22; b=1<<29; c=a|b; d=1<<4; e=1<<14; f=d|e;
|
||||
const SP6 = [b|d,c|z,z|e,c|f,c|z,z|d,c|f,a|z,b|e,a|f,a|z,b|d,a|d,b|e,b|z,z|f,
|
||||
z|z,a|d,b|f,z|e,a|e,b|f,z|d,c|d,c|d,z|z,a|f,c|e,z|f,a|e,c|e,b|z,
|
||||
b|e,z|d,c|d,a|e,c|f,a|z,z|f,b|d,a|z,b|e,b|z,z|f,b|d,c|f,a|e,c|z,
|
||||
a|f,c|e,z|z,c|d,z|d,z|e,c|z,a|f,z|e,a|d,b|f,z|z,c|e,b|z,a|d,b|f];
|
||||
a=1<<21; b=1<<26; c=a|b; d=1<<1; e=1<<11; f=d|e;
|
||||
const SP7 = [a|z,c|d,b|f,z|z,z|e,b|f,a|f,c|e,c|f,a|z,z|z,b|d,z|d,b|z,c|d,z|f,
|
||||
b|e,a|f,a|d,b|e,b|d,c|z,c|e,a|d,c|z,z|e,z|f,c|f,a|e,z|d,b|z,a|e,
|
||||
b|z,a|e,a|z,b|f,b|f,c|d,c|d,z|d,a|d,b|z,b|e,a|z,c|e,z|f,a|f,c|e,
|
||||
z|f,b|d,c|f,c|z,a|e,z|z,z|d,c|f,z|z,a|f,c|z,z|e,b|d,b|e,z|e,a|d];
|
||||
a=1<<18; b=1<<28; c=a|b; d=1<<6; e=1<<12; f=d|e;
|
||||
const SP8 = [b|f,z|e,a|z,c|f,b|z,b|f,z|d,b|z,a|d,c|z,c|f,a|e,c|e,a|f,z|e,z|d,
|
||||
c|z,b|d,b|e,z|f,a|e,a|d,c|d,c|e,z|f,z|z,z|z,c|d,b|d,b|e,a|f,a|z,
|
||||
a|f,a|z,c|e,z|e,z|d,c|d,z|e,a|f,b|e,z|d,b|d,c|z,c|d,b|z,a|z,b|f,
|
||||
z|z,c|f,a|d,b|d,c|z,b|e,b|f,z|z,c|f,a|e,a|e,z|f,z|f,a|d,b|z,c|e];
|
||||
|
||||
/* eslint-enable comma-spacing */
|
||||
|
||||
class DES {
|
||||
constructor(password) {
|
||||
this.keys = [];
|
||||
|
||||
// Set the key.
|
||||
const pc1m = [], pcr = [], kn = [];
|
||||
|
||||
for (let j = 0, l = 56; j < 56; ++j, l -= 8) {
|
||||
l += l < -5 ? 65 : l < -3 ? 31 : l < -1 ? 63 : l === 27 ? 35 : 0; // PC1
|
||||
const m = l & 0x7;
|
||||
pc1m[j] = ((password[l >>> 3] & (1<<m)) !== 0) ? 1: 0;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 16; ++i) {
|
||||
const m = i << 1;
|
||||
const n = m + 1;
|
||||
kn[m] = kn[n] = 0;
|
||||
for (let o = 28; o < 59; o += 28) {
|
||||
for (let j = o - 28; j < o; ++j) {
|
||||
const l = j + totrot[i];
|
||||
pcr[j] = l < o ? pc1m[l] : pc1m[l - 28];
|
||||
}
|
||||
}
|
||||
for (let j = 0; j < 24; ++j) {
|
||||
if (pcr[PC2[j]] !== 0) {
|
||||
kn[m] |= 1 << (23 - j);
|
||||
}
|
||||
if (pcr[PC2[j + 24]] !== 0) {
|
||||
kn[n] |= 1 << (23 - j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cookey
|
||||
for (let i = 0, rawi = 0, KnLi = 0; i < 16; ++i) {
|
||||
const raw0 = kn[rawi++];
|
||||
const raw1 = kn[rawi++];
|
||||
this.keys[KnLi] = (raw0 & 0x00fc0000) << 6;
|
||||
this.keys[KnLi] |= (raw0 & 0x00000fc0) << 10;
|
||||
this.keys[KnLi] |= (raw1 & 0x00fc0000) >>> 10;
|
||||
this.keys[KnLi] |= (raw1 & 0x00000fc0) >>> 6;
|
||||
++KnLi;
|
||||
this.keys[KnLi] = (raw0 & 0x0003f000) << 12;
|
||||
this.keys[KnLi] |= (raw0 & 0x0000003f) << 16;
|
||||
this.keys[KnLi] |= (raw1 & 0x0003f000) >>> 4;
|
||||
this.keys[KnLi] |= (raw1 & 0x0000003f);
|
||||
++KnLi;
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt 8 bytes of text
|
||||
enc8(text) {
|
||||
const b = text.slice();
|
||||
let i = 0, l, r, x; // left, right, accumulator
|
||||
|
||||
// Squash 8 bytes to 2 ints
|
||||
l = b[i++]<<24 | b[i++]<<16 | b[i++]<<8 | b[i++];
|
||||
r = b[i++]<<24 | b[i++]<<16 | b[i++]<<8 | b[i++];
|
||||
|
||||
x = ((l >>> 4) ^ r) & 0x0f0f0f0f;
|
||||
r ^= x;
|
||||
l ^= (x << 4);
|
||||
x = ((l >>> 16) ^ r) & 0x0000ffff;
|
||||
r ^= x;
|
||||
l ^= (x << 16);
|
||||
x = ((r >>> 2) ^ l) & 0x33333333;
|
||||
l ^= x;
|
||||
r ^= (x << 2);
|
||||
x = ((r >>> 8) ^ l) & 0x00ff00ff;
|
||||
l ^= x;
|
||||
r ^= (x << 8);
|
||||
r = (r << 1) | ((r >>> 31) & 1);
|
||||
x = (l ^ r) & 0xaaaaaaaa;
|
||||
l ^= x;
|
||||
r ^= x;
|
||||
l = (l << 1) | ((l >>> 31) & 1);
|
||||
|
||||
for (let i = 0, keysi = 0; i < 8; ++i) {
|
||||
x = (r << 28) | (r >>> 4);
|
||||
x ^= this.keys[keysi++];
|
||||
let fval = SP7[x & 0x3f];
|
||||
fval |= SP5[(x >>> 8) & 0x3f];
|
||||
fval |= SP3[(x >>> 16) & 0x3f];
|
||||
fval |= SP1[(x >>> 24) & 0x3f];
|
||||
x = r ^ this.keys[keysi++];
|
||||
fval |= SP8[x & 0x3f];
|
||||
fval |= SP6[(x >>> 8) & 0x3f];
|
||||
fval |= SP4[(x >>> 16) & 0x3f];
|
||||
fval |= SP2[(x >>> 24) & 0x3f];
|
||||
l ^= fval;
|
||||
x = (l << 28) | (l >>> 4);
|
||||
x ^= this.keys[keysi++];
|
||||
fval = SP7[x & 0x3f];
|
||||
fval |= SP5[(x >>> 8) & 0x3f];
|
||||
fval |= SP3[(x >>> 16) & 0x3f];
|
||||
fval |= SP1[(x >>> 24) & 0x3f];
|
||||
x = l ^ this.keys[keysi++];
|
||||
fval |= SP8[x & 0x0000003f];
|
||||
fval |= SP6[(x >>> 8) & 0x3f];
|
||||
fval |= SP4[(x >>> 16) & 0x3f];
|
||||
fval |= SP2[(x >>> 24) & 0x3f];
|
||||
r ^= fval;
|
||||
}
|
||||
|
||||
r = (r << 31) | (r >>> 1);
|
||||
x = (l ^ r) & 0xaaaaaaaa;
|
||||
l ^= x;
|
||||
r ^= x;
|
||||
l = (l << 31) | (l >>> 1);
|
||||
x = ((l >>> 8) ^ r) & 0x00ff00ff;
|
||||
r ^= x;
|
||||
l ^= (x << 8);
|
||||
x = ((l >>> 2) ^ r) & 0x33333333;
|
||||
r ^= x;
|
||||
l ^= (x << 2);
|
||||
x = ((r >>> 16) ^ l) & 0x0000ffff;
|
||||
l ^= x;
|
||||
r ^= (x << 16);
|
||||
x = ((r >>> 4) ^ l) & 0x0f0f0f0f;
|
||||
l ^= x;
|
||||
r ^= (x << 4);
|
||||
|
||||
// Spread ints to bytes
|
||||
x = [r, l];
|
||||
for (i = 0; i < 8; i++) {
|
||||
b[i] = (x[i>>>2] >>> (8 * (3 - (i % 4)))) % 256;
|
||||
if (b[i] < 0) { b[i] += 256; } // unsigned
|
||||
}
|
||||
return b;
|
||||
}
|
||||
}
|
||||
|
||||
export class DESECBCipher {
|
||||
constructor() {
|
||||
this._cipher = null;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "DES-ECB" };
|
||||
}
|
||||
|
||||
static importKey(key, _algorithm, _extractable, _keyUsages) {
|
||||
const cipher = new DESECBCipher;
|
||||
cipher._importKey(key);
|
||||
return cipher;
|
||||
}
|
||||
|
||||
_importKey(key, _extractable, _keyUsages) {
|
||||
this._cipher = new DES(key);
|
||||
}
|
||||
|
||||
encrypt(_algorithm, plaintext) {
|
||||
const x = new Uint8Array(plaintext);
|
||||
if (x.length % 8 !== 0 || this._cipher === null) {
|
||||
return null;
|
||||
}
|
||||
const n = x.length / 8;
|
||||
for (let i = 0; i < n; i++) {
|
||||
x.set(this._cipher.enc8(x.slice(i * 8, i * 8 + 8)), i * 8);
|
||||
}
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
||||
export class DESCBCCipher {
|
||||
constructor() {
|
||||
this._cipher = null;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "DES-CBC" };
|
||||
}
|
||||
|
||||
static importKey(key, _algorithm, _extractable, _keyUsages) {
|
||||
const cipher = new DESCBCCipher;
|
||||
cipher._importKey(key);
|
||||
return cipher;
|
||||
}
|
||||
|
||||
_importKey(key) {
|
||||
this._cipher = new DES(key);
|
||||
}
|
||||
|
||||
encrypt(algorithm, plaintext) {
|
||||
const x = new Uint8Array(plaintext);
|
||||
let y = new Uint8Array(algorithm.iv);
|
||||
if (x.length % 8 !== 0 || this._cipher === null) {
|
||||
return null;
|
||||
}
|
||||
const n = x.length / 8;
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = 0; j < 8; j++) {
|
||||
y[j] ^= plaintext[i * 8 + j];
|
||||
}
|
||||
y = this._cipher.enc8(y);
|
||||
x.set(y, i * 8);
|
||||
}
|
||||
return x;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { modPow, bigIntToU8Array, u8ArrayToBigInt } from "./bigint.js";
|
||||
|
||||
class DHPublicKey {
|
||||
constructor(key) {
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "DH" };
|
||||
}
|
||||
|
||||
exportKey() {
|
||||
return this._key;
|
||||
}
|
||||
}
|
||||
|
||||
export class DHCipher {
|
||||
constructor() {
|
||||
this._g = null;
|
||||
this._p = null;
|
||||
this._gBigInt = null;
|
||||
this._pBigInt = null;
|
||||
this._privateKey = null;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "DH" };
|
||||
}
|
||||
|
||||
static generateKey(algorithm, _extractable) {
|
||||
const cipher = new DHCipher;
|
||||
cipher._generateKey(algorithm);
|
||||
return { privateKey: cipher, publicKey: new DHPublicKey(cipher._publicKey) };
|
||||
}
|
||||
|
||||
_generateKey(algorithm) {
|
||||
const g = algorithm.g;
|
||||
const p = algorithm.p;
|
||||
this._keyBytes = p.length;
|
||||
this._gBigInt = u8ArrayToBigInt(g);
|
||||
this._pBigInt = u8ArrayToBigInt(p);
|
||||
this._privateKey = window.crypto.getRandomValues(new Uint8Array(this._keyBytes));
|
||||
this._privateKeyBigInt = u8ArrayToBigInt(this._privateKey);
|
||||
this._publicKey = bigIntToU8Array(modPow(
|
||||
this._gBigInt, this._privateKeyBigInt, this._pBigInt), this._keyBytes);
|
||||
}
|
||||
|
||||
deriveBits(algorithm, length) {
|
||||
const bytes = Math.ceil(length / 8);
|
||||
const pkey = new Uint8Array(algorithm.public);
|
||||
const len = bytes > this._keyBytes ? bytes : this._keyBytes;
|
||||
const secret = modPow(u8ArrayToBigInt(pkey), this._privateKeyBigInt, this._pBigInt);
|
||||
return bigIntToU8Array(secret, len).slice(0, len);
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2021 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Performs MD5 hashing on an array of bytes, returns an array of bytes
|
||||
*/
|
||||
|
||||
export async function MD5(d) {
|
||||
let s = "";
|
||||
for (let i = 0; i < d.length; i++) {
|
||||
s += String.fromCharCode(d[i]);
|
||||
}
|
||||
return M(V(Y(X(s), 8 * s.length)));
|
||||
}
|
||||
|
||||
function M(d) {
|
||||
let f = new Uint8Array(d.length);
|
||||
for (let i=0;i<d.length;i++) {
|
||||
f[i] = d.charCodeAt(i);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
function X(d) {
|
||||
let r = Array(d.length >> 2);
|
||||
for (let m = 0; m < r.length; m++) r[m] = 0;
|
||||
for (let m = 0; m < 8 * d.length; m += 8) r[m >> 5] |= (255 & d.charCodeAt(m / 8)) << m % 32;
|
||||
return r;
|
||||
}
|
||||
|
||||
function V(d) {
|
||||
let r = "";
|
||||
for (let m = 0; m < 32 * d.length; m += 8) r += String.fromCharCode(d[m >> 5] >>> m % 32 & 255);
|
||||
return r;
|
||||
}
|
||||
|
||||
function Y(d, g) {
|
||||
d[g >> 5] |= 128 << g % 32, d[14 + (g + 64 >>> 9 << 4)] = g;
|
||||
let m = 1732584193, f = -271733879, r = -1732584194, i = 271733878;
|
||||
for (let n = 0; n < d.length; n += 16) {
|
||||
let h = m,
|
||||
t = f,
|
||||
g = r,
|
||||
e = i;
|
||||
f = ii(f = ii(f = ii(f = ii(f = hh(f = hh(f = hh(f = hh(f = gg(f = gg(f = gg(f = gg(f = ff(f = ff(f = ff(f = ff(f, r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 0], 7, -680876936), f, r, d[n + 1], 12, -389564586), m, f, d[n + 2], 17, 606105819), i, m, d[n + 3], 22, -1044525330), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 4], 7, -176418897), f, r, d[n + 5], 12, 1200080426), m, f, d[n + 6], 17, -1473231341), i, m, d[n + 7], 22, -45705983), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 8], 7, 1770035416), f, r, d[n + 9], 12, -1958414417), m, f, d[n + 10], 17, -42063), i, m, d[n + 11], 22, -1990404162), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 12], 7, 1804603682), f, r, d[n + 13], 12, -40341101), m, f, d[n + 14], 17, -1502002290), i, m, d[n + 15], 22, 1236535329), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 1], 5, -165796510), f, r, d[n + 6], 9, -1069501632), m, f, d[n + 11], 14, 643717713), i, m, d[n + 0], 20, -373897302), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 5], 5, -701558691), f, r, d[n + 10], 9, 38016083), m, f, d[n + 15], 14, -660478335), i, m, d[n + 4], 20, -405537848), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 9], 5, 568446438), f, r, d[n + 14], 9, -1019803690), m, f, d[n + 3], 14, -187363961), i, m, d[n + 8], 20, 1163531501), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 13], 5, -1444681467), f, r, d[n + 2], 9, -51403784), m, f, d[n + 7], 14, 1735328473), i, m, d[n + 12], 20, -1926607734), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 5], 4, -378558), f, r, d[n + 8], 11, -2022574463), m, f, d[n + 11], 16, 1839030562), i, m, d[n + 14], 23, -35309556), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 1], 4, -1530992060), f, r, d[n + 4], 11, 1272893353), m, f, d[n + 7], 16, -155497632), i, m, d[n + 10], 23, -1094730640), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 13], 4, 681279174), f, r, d[n + 0], 11, -358537222), m, f, d[n + 3], 16, -722521979), i, m, d[n + 6], 23, 76029189), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 9], 4, -640364487), f, r, d[n + 12], 11, -421815835), m, f, d[n + 15], 16, 530742520), i, m, d[n + 2], 23, -995338651), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 0], 6, -198630844), f, r, d[n + 7], 10, 1126891415), m, f, d[n + 14], 15, -1416354905), i, m, d[n + 5], 21, -57434055), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 12], 6, 1700485571), f, r, d[n + 3], 10, -1894986606), m, f, d[n + 10], 15, -1051523), i, m, d[n + 1], 21, -2054922799), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 8], 6, 1873313359), f, r, d[n + 15], 10, -30611744), m, f, d[n + 6], 15, -1560198380), i, m, d[n + 13], 21, 1309151649), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 4], 6, -145523070), f, r, d[n + 11], 10, -1120210379), m, f, d[n + 2], 15, 718787259), i, m, d[n + 9], 21, -343485551), m = add(m, h), f = add(f, t), r = add(r, g), i = add(i, e);
|
||||
}
|
||||
return Array(m, f, r, i);
|
||||
}
|
||||
|
||||
function cmn(d, g, m, f, r, i) {
|
||||
return add(rol(add(add(g, d), add(f, i)), r), m);
|
||||
}
|
||||
|
||||
function ff(d, g, m, f, r, i, n) {
|
||||
return cmn(g & m | ~g & f, d, g, r, i, n);
|
||||
}
|
||||
|
||||
function gg(d, g, m, f, r, i, n) {
|
||||
return cmn(g & f | m & ~f, d, g, r, i, n);
|
||||
}
|
||||
|
||||
function hh(d, g, m, f, r, i, n) {
|
||||
return cmn(g ^ m ^ f, d, g, r, i, n);
|
||||
}
|
||||
|
||||
function ii(d, g, m, f, r, i, n) {
|
||||
return cmn(m ^ (g | ~f), d, g, r, i, n);
|
||||
}
|
||||
|
||||
function add(d, g) {
|
||||
let m = (65535 & d) + (65535 & g);
|
||||
return (d >> 16) + (g >> 16) + (m >> 16) << 16 | 65535 & m;
|
||||
}
|
||||
|
||||
function rol(d, g) {
|
||||
return d << g | d >>> 32 - g;
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import Base64 from "../base64.js";
|
||||
import { modPow, bigIntToU8Array, u8ArrayToBigInt } from "./bigint.js";
|
||||
|
||||
export class RSACipher {
|
||||
constructor() {
|
||||
this._keyLength = 0;
|
||||
this._keyBytes = 0;
|
||||
this._n = null;
|
||||
this._e = null;
|
||||
this._d = null;
|
||||
this._nBigInt = null;
|
||||
this._eBigInt = null;
|
||||
this._dBigInt = null;
|
||||
this._extractable = false;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return { name: "RSA-PKCS1-v1_5" };
|
||||
}
|
||||
|
||||
_base64urlDecode(data) {
|
||||
data = data.replace(/-/g, "+").replace(/_/g, "/");
|
||||
data = data.padEnd(Math.ceil(data.length / 4) * 4, "=");
|
||||
return Base64.decode(data);
|
||||
}
|
||||
|
||||
_padArray(arr, length) {
|
||||
const res = new Uint8Array(length);
|
||||
res.set(arr, length - arr.length);
|
||||
return res;
|
||||
}
|
||||
|
||||
static async generateKey(algorithm, extractable, _keyUsages) {
|
||||
const cipher = new RSACipher;
|
||||
await cipher._generateKey(algorithm, extractable);
|
||||
return { privateKey: cipher };
|
||||
}
|
||||
|
||||
async _generateKey(algorithm, extractable) {
|
||||
this._keyLength = algorithm.modulusLength;
|
||||
this._keyBytes = Math.ceil(this._keyLength / 8);
|
||||
const key = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
modulusLength: algorithm.modulusLength,
|
||||
publicExponent: algorithm.publicExponent,
|
||||
hash: {name: "SHA-256"},
|
||||
},
|
||||
true, ["encrypt", "decrypt"]);
|
||||
const privateKey = await window.crypto.subtle.exportKey("jwk", key.privateKey);
|
||||
this._n = this._padArray(this._base64urlDecode(privateKey.n), this._keyBytes);
|
||||
this._nBigInt = u8ArrayToBigInt(this._n);
|
||||
this._e = this._padArray(this._base64urlDecode(privateKey.e), this._keyBytes);
|
||||
this._eBigInt = u8ArrayToBigInt(this._e);
|
||||
this._d = this._padArray(this._base64urlDecode(privateKey.d), this._keyBytes);
|
||||
this._dBigInt = u8ArrayToBigInt(this._d);
|
||||
this._extractable = extractable;
|
||||
}
|
||||
|
||||
static async importKey(key, _algorithm, extractable, keyUsages) {
|
||||
if (keyUsages.length !== 1 || keyUsages[0] !== "encrypt") {
|
||||
throw new Error("only support importing RSA public key");
|
||||
}
|
||||
const cipher = new RSACipher;
|
||||
await cipher._importKey(key, extractable);
|
||||
return cipher;
|
||||
}
|
||||
|
||||
async _importKey(key, extractable) {
|
||||
const n = key.n;
|
||||
const e = key.e;
|
||||
if (n.length !== e.length) {
|
||||
throw new Error("the sizes of modulus and public exponent do not match");
|
||||
}
|
||||
this._keyBytes = n.length;
|
||||
this._keyLength = this._keyBytes * 8;
|
||||
this._n = new Uint8Array(this._keyBytes);
|
||||
this._e = new Uint8Array(this._keyBytes);
|
||||
this._n.set(n);
|
||||
this._e.set(e);
|
||||
this._nBigInt = u8ArrayToBigInt(this._n);
|
||||
this._eBigInt = u8ArrayToBigInt(this._e);
|
||||
this._extractable = extractable;
|
||||
}
|
||||
|
||||
async encrypt(_algorithm, message) {
|
||||
if (message.length > this._keyBytes - 11) {
|
||||
return null;
|
||||
}
|
||||
const ps = new Uint8Array(this._keyBytes - message.length - 3);
|
||||
window.crypto.getRandomValues(ps);
|
||||
for (let i = 0; i < ps.length; i++) {
|
||||
ps[i] = Math.floor(ps[i] * 254 / 255 + 1);
|
||||
}
|
||||
const em = new Uint8Array(this._keyBytes);
|
||||
em[1] = 0x02;
|
||||
em.set(ps, 2);
|
||||
em.set(message, ps.length + 3);
|
||||
const emBigInt = u8ArrayToBigInt(em);
|
||||
const c = modPow(emBigInt, this._eBigInt, this._nBigInt);
|
||||
return bigIntToU8Array(c, this._keyBytes);
|
||||
}
|
||||
|
||||
async decrypt(_algorithm, message) {
|
||||
if (message.length !== this._keyBytes) {
|
||||
return null;
|
||||
}
|
||||
const msgBigInt = u8ArrayToBigInt(message);
|
||||
const emBigInt = modPow(msgBigInt, this._dBigInt, this._nBigInt);
|
||||
const em = bigIntToU8Array(emBigInt, this._keyBytes);
|
||||
if (em[0] !== 0x00 || em[1] !== 0x02) {
|
||||
return null;
|
||||
}
|
||||
let i = 2;
|
||||
for (; i < em.length; i++) {
|
||||
if (em[i] === 0x00) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (i === em.length) {
|
||||
return null;
|
||||
}
|
||||
return em.slice(i + 1, em.length);
|
||||
}
|
||||
|
||||
async exportKey() {
|
||||
if (!this._extractable) {
|
||||
throw new Error("key is not extractable");
|
||||
}
|
||||
return { n: this._n, e: this._e, d: this._d };
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
export default class CopyRectDecoder {
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if (sock.rQwait("COPYRECT", 4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let deltaX = sock.rQshift16();
|
||||
let deltaY = sock.rQshift16();
|
||||
|
||||
if ((width === 0) || (height === 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
display.copyImage(deltaX, deltaY, x, y, width, height);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2024 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
import * as Log from '../util/logging.js';
|
||||
|
||||
export class H264Parser {
|
||||
constructor(data) {
|
||||
this._data = data;
|
||||
this._index = 0;
|
||||
this.profileIdc = null;
|
||||
this.constraintSet = null;
|
||||
this.levelIdc = null;
|
||||
}
|
||||
|
||||
_getStartSequenceLen(index) {
|
||||
let data = this._data;
|
||||
if (data[index + 0] == 0 && data[index + 1] == 0 && data[index + 2] == 0 && data[index + 3] == 1) {
|
||||
return 4;
|
||||
}
|
||||
if (data[index + 0] == 0 && data[index + 1] == 0 && data[index + 2] == 1) {
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
_indexOfNextNalUnit(index) {
|
||||
let data = this._data;
|
||||
for (let i = index; i < data.length; ++i) {
|
||||
if (this._getStartSequenceLen(i) != 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
_parseSps(index) {
|
||||
this.profileIdc = this._data[index];
|
||||
this.constraintSet = this._data[index + 1];
|
||||
this.levelIdc = this._data[index + 2];
|
||||
}
|
||||
|
||||
_parseNalUnit(index) {
|
||||
const firstByte = this._data[index];
|
||||
if (firstByte & 0x80) {
|
||||
throw new Error('H264 parsing sanity check failed, forbidden zero bit is set');
|
||||
}
|
||||
const unitType = firstByte & 0x1f;
|
||||
|
||||
switch (unitType) {
|
||||
case 1: // coded slice, non-idr
|
||||
return { slice: true };
|
||||
case 5: // coded slice, idr
|
||||
return { slice: true, key: true };
|
||||
case 6: // sei
|
||||
return {};
|
||||
case 7: // sps
|
||||
this._parseSps(index + 1);
|
||||
return {};
|
||||
case 8: // pps
|
||||
return {};
|
||||
default:
|
||||
Log.Warn("Unhandled unit type: ", unitType);
|
||||
break;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
parse() {
|
||||
const startIndex = this._index;
|
||||
let isKey = false;
|
||||
|
||||
while (this._index < this._data.length) {
|
||||
const startSequenceLen = this._getStartSequenceLen(this._index);
|
||||
if (startSequenceLen == 0) {
|
||||
throw new Error('Invalid start sequence in bit stream');
|
||||
}
|
||||
|
||||
const { slice, key } = this._parseNalUnit(this._index + startSequenceLen);
|
||||
|
||||
let nextIndex = this._indexOfNextNalUnit(this._index + startSequenceLen);
|
||||
if (nextIndex == -1) {
|
||||
this._index = this._data.length;
|
||||
} else {
|
||||
this._index = nextIndex;
|
||||
}
|
||||
|
||||
if (key) {
|
||||
isKey = true;
|
||||
}
|
||||
if (slice) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startIndex === this._index) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
frame: this._data.subarray(startIndex, this._index),
|
||||
key: isKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class H264Context {
|
||||
constructor(width, height) {
|
||||
this.lastUsed = 0;
|
||||
this._width = width;
|
||||
this._height = height;
|
||||
this._profileIdc = null;
|
||||
this._constraintSet = null;
|
||||
this._levelIdc = null;
|
||||
this._decoder = null;
|
||||
this._pendingFrames = [];
|
||||
}
|
||||
|
||||
_handleFrame(frame) {
|
||||
let pending = this._pendingFrames.shift();
|
||||
if (pending === undefined) {
|
||||
throw new Error("Pending frame queue empty when receiving frame from decoder");
|
||||
}
|
||||
|
||||
if (pending.timestamp != frame.timestamp) {
|
||||
throw new Error("Video frame timestamp mismatch. Expected " +
|
||||
frame.timestamp + " but but got " + pending.timestamp);
|
||||
}
|
||||
|
||||
pending.frame = frame;
|
||||
pending.ready = true;
|
||||
pending.resolve();
|
||||
|
||||
if (!pending.keep) {
|
||||
frame.close();
|
||||
}
|
||||
}
|
||||
|
||||
_handleError(e) {
|
||||
throw new Error("Failed to decode frame: " + e.message);
|
||||
}
|
||||
|
||||
_configureDecoder(profileIdc, constraintSet, levelIdc) {
|
||||
if (this._decoder === null || this._decoder.state === 'closed') {
|
||||
this._decoder = new VideoDecoder({
|
||||
output: frame => this._handleFrame(frame),
|
||||
error: e => this._handleError(e),
|
||||
});
|
||||
}
|
||||
const codec = 'avc1.' +
|
||||
profileIdc.toString(16).padStart(2, '0') +
|
||||
constraintSet.toString(16).padStart(2, '0') +
|
||||
levelIdc.toString(16).padStart(2, '0');
|
||||
this._decoder.configure({
|
||||
codec: codec,
|
||||
codedWidth: this._width,
|
||||
codedHeight: this._height,
|
||||
optimizeForLatency: true,
|
||||
});
|
||||
}
|
||||
|
||||
_preparePendingFrame(timestamp) {
|
||||
let pending = {
|
||||
timestamp: timestamp,
|
||||
promise: null,
|
||||
resolve: null,
|
||||
frame: null,
|
||||
ready: false,
|
||||
keep: false,
|
||||
};
|
||||
pending.promise = new Promise((resolve) => {
|
||||
pending.resolve = resolve;
|
||||
});
|
||||
this._pendingFrames.push(pending);
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
decode(payload) {
|
||||
let parser = new H264Parser(payload);
|
||||
let result = null;
|
||||
|
||||
// Ideally, this timestamp should come from the server, but we'll just
|
||||
// approximate it instead.
|
||||
let timestamp = Math.round(window.performance.now() * 1e3);
|
||||
|
||||
while (true) {
|
||||
let encodedFrame = parser.parse();
|
||||
if (encodedFrame === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (parser.profileIdc !== null) {
|
||||
self._profileIdc = parser.profileIdc;
|
||||
self._constraintSet = parser.constraintSet;
|
||||
self._levelIdc = parser.levelIdc;
|
||||
}
|
||||
|
||||
if (this._decoder === null || this._decoder.state !== 'configured') {
|
||||
if (!encodedFrame.key) {
|
||||
Log.Warn("Missing key frame. Can't decode until one arrives");
|
||||
continue;
|
||||
}
|
||||
if (self._profileIdc === null) {
|
||||
Log.Warn('Cannot config decoder. Have not received SPS and PPS yet.');
|
||||
continue;
|
||||
}
|
||||
this._configureDecoder(self._profileIdc, self._constraintSet,
|
||||
self._levelIdc);
|
||||
}
|
||||
|
||||
result = this._preparePendingFrame(timestamp);
|
||||
|
||||
const chunk = new EncodedVideoChunk({
|
||||
timestamp: timestamp,
|
||||
type: encodedFrame.key ? 'key' : 'delta',
|
||||
data: encodedFrame.frame,
|
||||
});
|
||||
|
||||
try {
|
||||
this._decoder.decode(chunk);
|
||||
} catch (e) {
|
||||
Log.Warn("Failed to decode:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// We only keep last frame of each payload
|
||||
if (result !== null) {
|
||||
result.keep = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default class H264Decoder {
|
||||
constructor() {
|
||||
this._tick = 0;
|
||||
this._contexts = {};
|
||||
}
|
||||
|
||||
_contextId(x, y, width, height) {
|
||||
return [x, y, width, height].join(',');
|
||||
}
|
||||
|
||||
_findOldestContextId() {
|
||||
let oldestTick = Number.MAX_VALUE;
|
||||
let oldestKey = undefined;
|
||||
for (const [key, value] of Object.entries(this._contexts)) {
|
||||
if (value.lastUsed < oldestTick) {
|
||||
oldestTick = value.lastUsed;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
return oldestKey;
|
||||
}
|
||||
|
||||
_createContext(x, y, width, height) {
|
||||
const maxContexts = 64;
|
||||
if (Object.keys(this._contexts).length >= maxContexts) {
|
||||
let oldestContextId = this._findOldestContextId();
|
||||
delete this._contexts[oldestContextId];
|
||||
}
|
||||
let context = new H264Context(width, height);
|
||||
this._contexts[this._contextId(x, y, width, height)] = context;
|
||||
return context;
|
||||
}
|
||||
|
||||
_getContext(x, y, width, height) {
|
||||
let context = this._contexts[this._contextId(x, y, width, height)];
|
||||
return context !== undefined ? context : this._createContext(x, y, width, height);
|
||||
}
|
||||
|
||||
_resetContext(x, y, width, height) {
|
||||
delete this._contexts[this._contextId(x, y, width, height)];
|
||||
}
|
||||
|
||||
_resetAllContexts() {
|
||||
this._contexts = {};
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
const resetContextFlag = 1;
|
||||
const resetAllContextsFlag = 2;
|
||||
|
||||
if (sock.rQwait("h264 header", 8)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const length = sock.rQshift32();
|
||||
const flags = sock.rQshift32();
|
||||
|
||||
if (sock.rQwait("h264 payload", length, 8)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (flags & resetAllContextsFlag) {
|
||||
this._resetAllContexts();
|
||||
} else if (flags & resetContextFlag) {
|
||||
this._resetContext(x, y, width, height);
|
||||
}
|
||||
|
||||
let context = this._getContext(x, y, width, height);
|
||||
context.lastUsed = this._tick++;
|
||||
|
||||
if (length !== 0) {
|
||||
let payload = sock.rQshiftBytes(length, false);
|
||||
let frame = context.decode(payload);
|
||||
if (frame !== null) {
|
||||
display.videoFrame(x, y, width, height, frame);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
import * as Log from '../util/logging.js';
|
||||
|
||||
export default class HextileDecoder {
|
||||
constructor() {
|
||||
this._tiles = 0;
|
||||
this._lastsubencoding = 0;
|
||||
this._tileBuffer = new Uint8Array(16 * 16 * 4);
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if (this._tiles === 0) {
|
||||
this._tilesX = Math.ceil(width / 16);
|
||||
this._tilesY = Math.ceil(height / 16);
|
||||
this._totalTiles = this._tilesX * this._tilesY;
|
||||
this._tiles = this._totalTiles;
|
||||
}
|
||||
|
||||
while (this._tiles > 0) {
|
||||
let bytes = 1;
|
||||
|
||||
if (sock.rQwait("HEXTILE", bytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let subencoding = sock.rQpeek8();
|
||||
if (subencoding > 30) { // Raw
|
||||
throw new Error("Illegal hextile subencoding (subencoding: " +
|
||||
subencoding + ")");
|
||||
}
|
||||
|
||||
const currTile = this._totalTiles - this._tiles;
|
||||
const tileX = currTile % this._tilesX;
|
||||
const tileY = Math.floor(currTile / this._tilesX);
|
||||
const tx = x + tileX * 16;
|
||||
const ty = y + tileY * 16;
|
||||
const tw = Math.min(16, (x + width) - tx);
|
||||
const th = Math.min(16, (y + height) - ty);
|
||||
|
||||
// Figure out how much we are expecting
|
||||
if (subencoding & 0x01) { // Raw
|
||||
bytes += tw * th * 4;
|
||||
} else {
|
||||
if (subencoding & 0x02) { // Background
|
||||
bytes += 4;
|
||||
}
|
||||
if (subencoding & 0x04) { // Foreground
|
||||
bytes += 4;
|
||||
}
|
||||
if (subencoding & 0x08) { // AnySubrects
|
||||
bytes++; // Since we aren't shifting it off
|
||||
|
||||
if (sock.rQwait("HEXTILE", bytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let subrects = sock.rQpeekBytes(bytes).at(-1);
|
||||
if (subencoding & 0x10) { // SubrectsColoured
|
||||
bytes += subrects * (4 + 2);
|
||||
} else {
|
||||
bytes += subrects * 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sock.rQwait("HEXTILE", bytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We know the encoding and have a whole tile
|
||||
sock.rQshift8();
|
||||
if (subencoding === 0) {
|
||||
if (this._lastsubencoding & 0x01) {
|
||||
// Weird: ignore blanks are RAW
|
||||
Log.Debug(" Ignoring blank after RAW");
|
||||
} else {
|
||||
display.fillRect(tx, ty, tw, th, this._background);
|
||||
}
|
||||
} else if (subencoding & 0x01) { // Raw
|
||||
let pixels = tw * th;
|
||||
let data = sock.rQshiftBytes(pixels * 4, false);
|
||||
// Max sure the image is fully opaque
|
||||
for (let i = 0;i < pixels;i++) {
|
||||
data[i * 4 + 3] = 255;
|
||||
}
|
||||
display.blitImage(tx, ty, tw, th, data, 0);
|
||||
} else {
|
||||
if (subencoding & 0x02) { // Background
|
||||
this._background = new Uint8Array(sock.rQshiftBytes(4));
|
||||
}
|
||||
if (subencoding & 0x04) { // Foreground
|
||||
this._foreground = new Uint8Array(sock.rQshiftBytes(4));
|
||||
}
|
||||
|
||||
this._startTile(tx, ty, tw, th, this._background);
|
||||
if (subencoding & 0x08) { // AnySubrects
|
||||
let subrects = sock.rQshift8();
|
||||
|
||||
for (let s = 0; s < subrects; s++) {
|
||||
let color;
|
||||
if (subencoding & 0x10) { // SubrectsColoured
|
||||
color = sock.rQshiftBytes(4);
|
||||
} else {
|
||||
color = this._foreground;
|
||||
}
|
||||
const xy = sock.rQshift8();
|
||||
const sx = (xy >> 4);
|
||||
const sy = (xy & 0x0f);
|
||||
|
||||
const wh = sock.rQshift8();
|
||||
const sw = (wh >> 4) + 1;
|
||||
const sh = (wh & 0x0f) + 1;
|
||||
|
||||
this._subTile(sx, sy, sw, sh, color);
|
||||
}
|
||||
}
|
||||
this._finishTile(display);
|
||||
}
|
||||
this._lastsubencoding = subencoding;
|
||||
this._tiles--;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// start updating a tile
|
||||
_startTile(x, y, width, height, color) {
|
||||
this._tileX = x;
|
||||
this._tileY = y;
|
||||
this._tileW = width;
|
||||
this._tileH = height;
|
||||
|
||||
const red = color[0];
|
||||
const green = color[1];
|
||||
const blue = color[2];
|
||||
|
||||
const data = this._tileBuffer;
|
||||
for (let i = 0; i < width * height * 4; i += 4) {
|
||||
data[i] = red;
|
||||
data[i + 1] = green;
|
||||
data[i + 2] = blue;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// update sub-rectangle of the current tile
|
||||
_subTile(x, y, w, h, color) {
|
||||
const red = color[0];
|
||||
const green = color[1];
|
||||
const blue = color[2];
|
||||
const xend = x + w;
|
||||
const yend = y + h;
|
||||
|
||||
const data = this._tileBuffer;
|
||||
const width = this._tileW;
|
||||
for (let j = y; j < yend; j++) {
|
||||
for (let i = x; i < xend; i++) {
|
||||
const p = (i + (j * width)) * 4;
|
||||
data[p] = red;
|
||||
data[p + 1] = green;
|
||||
data[p + 2] = blue;
|
||||
data[p + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// draw the current tile to the screen
|
||||
_finishTile(display) {
|
||||
display.blitImage(this._tileX, this._tileY,
|
||||
this._tileW, this._tileH,
|
||||
this._tileBuffer, 0);
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
export default class JPEGDecoder {
|
||||
constructor() {
|
||||
// RealVNC will reuse the quantization tables
|
||||
// and Huffman tables, so we need to cache them.
|
||||
this._cachedQuantTables = [];
|
||||
this._cachedHuffmanTables = [];
|
||||
|
||||
this._segments = [];
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
// A rect of JPEG encodings is simply a JPEG file
|
||||
while (true) {
|
||||
let segment = this._readSegment(sock);
|
||||
if (segment === null) {
|
||||
return false;
|
||||
}
|
||||
this._segments.push(segment);
|
||||
// End of image?
|
||||
if (segment[1] === 0xD9) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let huffmanTables = [];
|
||||
let quantTables = [];
|
||||
for (let segment of this._segments) {
|
||||
let type = segment[1];
|
||||
if (type === 0xC4) {
|
||||
// Huffman tables
|
||||
huffmanTables.push(segment);
|
||||
} else if (type === 0xDB) {
|
||||
// Quantization tables
|
||||
quantTables.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
const sofIndex = this._segments.findIndex(
|
||||
x => x[1] == 0xC0 || x[1] == 0xC2
|
||||
);
|
||||
if (sofIndex == -1) {
|
||||
throw new Error("Illegal JPEG image without SOF");
|
||||
}
|
||||
|
||||
if (quantTables.length === 0) {
|
||||
this._segments.splice(sofIndex+1, 0,
|
||||
...this._cachedQuantTables);
|
||||
}
|
||||
if (huffmanTables.length === 0) {
|
||||
this._segments.splice(sofIndex+1, 0,
|
||||
...this._cachedHuffmanTables);
|
||||
}
|
||||
|
||||
let length = 0;
|
||||
for (let segment of this._segments) {
|
||||
length += segment.length;
|
||||
}
|
||||
|
||||
let data = new Uint8Array(length);
|
||||
length = 0;
|
||||
for (let segment of this._segments) {
|
||||
data.set(segment, length);
|
||||
length += segment.length;
|
||||
}
|
||||
|
||||
display.imageRect(x, y, width, height, "image/jpeg", data);
|
||||
|
||||
if (huffmanTables.length !== 0) {
|
||||
this._cachedHuffmanTables = huffmanTables;
|
||||
}
|
||||
if (quantTables.length !== 0) {
|
||||
this._cachedQuantTables = quantTables;
|
||||
}
|
||||
|
||||
this._segments = [];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_readSegment(sock) {
|
||||
if (sock.rQwait("JPEG", 2)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let marker = sock.rQshift8();
|
||||
if (marker != 0xFF) {
|
||||
throw new Error("Illegal JPEG marker received (byte: " +
|
||||
marker + ")");
|
||||
}
|
||||
let type = sock.rQshift8();
|
||||
if (type >= 0xD0 && type <= 0xD9 || type == 0x01) {
|
||||
// No length after marker
|
||||
return new Uint8Array([marker, type]);
|
||||
}
|
||||
|
||||
if (sock.rQwait("JPEG", 2, 2)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let length = sock.rQshift16();
|
||||
if (length < 2) {
|
||||
throw new Error("Illegal JPEG length received (length: " +
|
||||
length + ")");
|
||||
}
|
||||
|
||||
if (sock.rQwait("JPEG", length-2, 4)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let extra = 0;
|
||||
if (type === 0xDA) {
|
||||
// start of scan
|
||||
extra += 2;
|
||||
while (true) {
|
||||
if (sock.rQwait("JPEG", length-2+extra, 4)) {
|
||||
return null;
|
||||
}
|
||||
let data = sock.rQpeekBytes(length-2+extra, false);
|
||||
if (data.at(-2) === 0xFF && data.at(-1) !== 0x00 &&
|
||||
!(data.at(-1) >= 0xD0 && data.at(-1) <= 0xD7)) {
|
||||
extra -= 2;
|
||||
break;
|
||||
}
|
||||
extra++;
|
||||
}
|
||||
}
|
||||
|
||||
let segment = new Uint8Array(2 + length + extra);
|
||||
segment[0] = marker;
|
||||
segment[1] = type;
|
||||
segment[2] = length >> 8;
|
||||
segment[3] = length;
|
||||
segment.set(sock.rQshiftBytes(length-2+extra, false), 4);
|
||||
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
export default class RawDecoder {
|
||||
constructor() {
|
||||
this._lines = 0;
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if ((width === 0) || (height === 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._lines === 0) {
|
||||
this._lines = height;
|
||||
}
|
||||
|
||||
const pixelSize = depth == 8 ? 1 : 4;
|
||||
const bytesPerLine = width * pixelSize;
|
||||
|
||||
while (this._lines > 0) {
|
||||
if (sock.rQwait("RAW", bytesPerLine)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const curY = y + (height - this._lines);
|
||||
|
||||
let data = sock.rQshiftBytes(bytesPerLine, false);
|
||||
|
||||
// Convert data if needed
|
||||
if (depth == 8) {
|
||||
const newdata = new Uint8Array(width * 4);
|
||||
for (let i = 0; i < width; i++) {
|
||||
newdata[i * 4 + 0] = ((data[i] >> 0) & 0x3) * 255 / 3;
|
||||
newdata[i * 4 + 1] = ((data[i] >> 2) & 0x3) * 255 / 3;
|
||||
newdata[i * 4 + 2] = ((data[i] >> 4) & 0x3) * 255 / 3;
|
||||
newdata[i * 4 + 3] = 255;
|
||||
}
|
||||
data = newdata;
|
||||
}
|
||||
|
||||
// Max sure the image is fully opaque
|
||||
for (let i = 0; i < width; i++) {
|
||||
data[i * 4 + 3] = 255;
|
||||
}
|
||||
|
||||
display.blitImage(x, curY, width, 1, data, 0);
|
||||
this._lines--;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
export default class RREDecoder {
|
||||
constructor() {
|
||||
this._subrects = 0;
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if (this._subrects === 0) {
|
||||
if (sock.rQwait("RRE", 4 + 4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._subrects = sock.rQshift32();
|
||||
|
||||
let color = sock.rQshiftBytes(4); // Background
|
||||
display.fillRect(x, y, width, height, color);
|
||||
}
|
||||
|
||||
while (this._subrects > 0) {
|
||||
if (sock.rQwait("RRE", 4 + 8)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let color = sock.rQshiftBytes(4);
|
||||
let sx = sock.rQshift16();
|
||||
let sy = sock.rQshift16();
|
||||
let swidth = sock.rQshift16();
|
||||
let sheight = sock.rQshift16();
|
||||
display.fillRect(x + sx, y + sy, swidth, sheight, color);
|
||||
|
||||
this._subrects--;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* (c) 2012 Michael Tinglof, Joe Balaz, Les Piech (Mercuri.ca)
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
import * as Log from '../util/logging.js';
|
||||
import Inflator from "../inflator.js";
|
||||
|
||||
export default class TightDecoder {
|
||||
constructor() {
|
||||
this._ctl = null;
|
||||
this._filter = null;
|
||||
this._numColors = 0;
|
||||
this._palette = new Uint8Array(1024); // 256 * 4 (max palette size * max bytes-per-pixel)
|
||||
this._len = 0;
|
||||
|
||||
this._zlibs = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
this._zlibs[i] = new Inflator();
|
||||
}
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if (this._ctl === null) {
|
||||
if (sock.rQwait("TIGHT compression-control", 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._ctl = sock.rQshift8();
|
||||
|
||||
// Reset streams if the server requests it
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if ((this._ctl >> i) & 1) {
|
||||
this._zlibs[i].reset();
|
||||
Log.Info("Reset zlib stream " + i);
|
||||
}
|
||||
}
|
||||
|
||||
// Figure out filter
|
||||
this._ctl = this._ctl >> 4;
|
||||
}
|
||||
|
||||
let ret;
|
||||
|
||||
if (this._ctl === 0x08) {
|
||||
ret = this._fillRect(x, y, width, height,
|
||||
sock, display, depth);
|
||||
} else if (this._ctl === 0x09) {
|
||||
ret = this._jpegRect(x, y, width, height,
|
||||
sock, display, depth);
|
||||
} else if (this._ctl === 0x0A) {
|
||||
ret = this._pngRect(x, y, width, height,
|
||||
sock, display, depth);
|
||||
} else if ((this._ctl & 0x08) == 0) {
|
||||
ret = this._basicRect(this._ctl, x, y, width, height,
|
||||
sock, display, depth);
|
||||
} else {
|
||||
throw new Error("Illegal tight compression received (ctl: " +
|
||||
this._ctl + ")");
|
||||
}
|
||||
|
||||
if (ret) {
|
||||
this._ctl = null;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
_fillRect(x, y, width, height, sock, display, depth) {
|
||||
if (sock.rQwait("TIGHT", 3)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let pixel = sock.rQshiftBytes(3);
|
||||
display.fillRect(x, y, width, height, pixel, false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_jpegRect(x, y, width, height, sock, display, depth) {
|
||||
let data = this._readData(sock);
|
||||
if (data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
display.imageRect(x, y, width, height, "image/jpeg", data);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_pngRect(x, y, width, height, sock, display, depth) {
|
||||
throw new Error("PNG received in standard Tight rect");
|
||||
}
|
||||
|
||||
_basicRect(ctl, x, y, width, height, sock, display, depth) {
|
||||
if (this._filter === null) {
|
||||
if (ctl & 0x4) {
|
||||
if (sock.rQwait("TIGHT", 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._filter = sock.rQshift8();
|
||||
} else {
|
||||
// Implicit CopyFilter
|
||||
this._filter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let streamId = ctl & 0x3;
|
||||
|
||||
let ret;
|
||||
|
||||
switch (this._filter) {
|
||||
case 0: // CopyFilter
|
||||
ret = this._copyFilter(streamId, x, y, width, height,
|
||||
sock, display, depth);
|
||||
break;
|
||||
case 1: // PaletteFilter
|
||||
ret = this._paletteFilter(streamId, x, y, width, height,
|
||||
sock, display, depth);
|
||||
break;
|
||||
case 2: // GradientFilter
|
||||
ret = this._gradientFilter(streamId, x, y, width, height,
|
||||
sock, display, depth);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Illegal tight filter received (ctl: " +
|
||||
this._filter + ")");
|
||||
}
|
||||
|
||||
if (ret) {
|
||||
this._filter = null;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
_copyFilter(streamId, x, y, width, height, sock, display, depth) {
|
||||
const uncompressedSize = width * height * 3;
|
||||
let data;
|
||||
|
||||
if (uncompressedSize === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (uncompressedSize < 12) {
|
||||
if (sock.rQwait("TIGHT", uncompressedSize)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
data = sock.rQshiftBytes(uncompressedSize);
|
||||
} else {
|
||||
data = this._readData(sock);
|
||||
if (data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._zlibs[streamId].setInput(data);
|
||||
data = this._zlibs[streamId].inflate(uncompressedSize);
|
||||
this._zlibs[streamId].setInput(null);
|
||||
}
|
||||
|
||||
let rgbx = new Uint8Array(width * height * 4);
|
||||
for (let i = 0, j = 0; i < width * height * 4; i += 4, j += 3) {
|
||||
rgbx[i] = data[j];
|
||||
rgbx[i + 1] = data[j + 1];
|
||||
rgbx[i + 2] = data[j + 2];
|
||||
rgbx[i + 3] = 255; // Alpha
|
||||
}
|
||||
|
||||
display.blitImage(x, y, width, height, rgbx, 0, false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_paletteFilter(streamId, x, y, width, height, sock, display, depth) {
|
||||
if (this._numColors === 0) {
|
||||
if (sock.rQwait("TIGHT palette", 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const numColors = sock.rQpeek8() + 1;
|
||||
const paletteSize = numColors * 3;
|
||||
|
||||
if (sock.rQwait("TIGHT palette", 1 + paletteSize)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._numColors = numColors;
|
||||
sock.rQskipBytes(1);
|
||||
|
||||
sock.rQshiftTo(this._palette, paletteSize);
|
||||
}
|
||||
|
||||
const bpp = (this._numColors <= 2) ? 1 : 8;
|
||||
const rowSize = Math.floor((width * bpp + 7) / 8);
|
||||
const uncompressedSize = rowSize * height;
|
||||
|
||||
let data;
|
||||
|
||||
if (uncompressedSize === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (uncompressedSize < 12) {
|
||||
if (sock.rQwait("TIGHT", uncompressedSize)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
data = sock.rQshiftBytes(uncompressedSize);
|
||||
} else {
|
||||
data = this._readData(sock);
|
||||
if (data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._zlibs[streamId].setInput(data);
|
||||
data = this._zlibs[streamId].inflate(uncompressedSize);
|
||||
this._zlibs[streamId].setInput(null);
|
||||
}
|
||||
|
||||
// Convert indexed (palette based) image data to RGB
|
||||
if (this._numColors == 2) {
|
||||
this._monoRect(x, y, width, height, data, this._palette, display);
|
||||
} else {
|
||||
this._paletteRect(x, y, width, height, data, this._palette, display);
|
||||
}
|
||||
|
||||
this._numColors = 0;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_monoRect(x, y, width, height, data, palette, display) {
|
||||
// Convert indexed (palette based) image data to RGB
|
||||
// TODO: reduce number of calculations inside loop
|
||||
const dest = this._getScratchBuffer(width * height * 4);
|
||||
const w = Math.floor((width + 7) / 8);
|
||||
const w1 = Math.floor(width / 8);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
let dp, sp, x;
|
||||
for (x = 0; x < w1; x++) {
|
||||
for (let b = 7; b >= 0; b--) {
|
||||
dp = (y * width + x * 8 + 7 - b) * 4;
|
||||
sp = (data[y * w + x] >> b & 1) * 3;
|
||||
dest[dp] = palette[sp];
|
||||
dest[dp + 1] = palette[sp + 1];
|
||||
dest[dp + 2] = palette[sp + 2];
|
||||
dest[dp + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
for (let b = 7; b >= 8 - width % 8; b--) {
|
||||
dp = (y * width + x * 8 + 7 - b) * 4;
|
||||
sp = (data[y * w + x] >> b & 1) * 3;
|
||||
dest[dp] = palette[sp];
|
||||
dest[dp + 1] = palette[sp + 1];
|
||||
dest[dp + 2] = palette[sp + 2];
|
||||
dest[dp + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
display.blitImage(x, y, width, height, dest, 0, false);
|
||||
}
|
||||
|
||||
_paletteRect(x, y, width, height, data, palette, display) {
|
||||
// Convert indexed (palette based) image data to RGB
|
||||
const dest = this._getScratchBuffer(width * height * 4);
|
||||
const total = width * height * 4;
|
||||
for (let i = 0, j = 0; i < total; i += 4, j++) {
|
||||
const sp = data[j] * 3;
|
||||
dest[i] = palette[sp];
|
||||
dest[i + 1] = palette[sp + 1];
|
||||
dest[i + 2] = palette[sp + 2];
|
||||
dest[i + 3] = 255;
|
||||
}
|
||||
|
||||
display.blitImage(x, y, width, height, dest, 0, false);
|
||||
}
|
||||
|
||||
_gradientFilter(streamId, x, y, width, height, sock, display, depth) {
|
||||
// assume the TPIXEL is 3 bytes long
|
||||
const uncompressedSize = width * height * 3;
|
||||
let data;
|
||||
|
||||
if (uncompressedSize === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (uncompressedSize < 12) {
|
||||
if (sock.rQwait("TIGHT", uncompressedSize)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
data = sock.rQshiftBytes(uncompressedSize);
|
||||
} else {
|
||||
data = this._readData(sock);
|
||||
if (data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._zlibs[streamId].setInput(data);
|
||||
data = this._zlibs[streamId].inflate(uncompressedSize);
|
||||
this._zlibs[streamId].setInput(null);
|
||||
}
|
||||
|
||||
let rgbx = new Uint8Array(4 * width * height);
|
||||
|
||||
let rgbxIndex = 0, dataIndex = 0;
|
||||
let left = new Uint8Array(3);
|
||||
for (let x = 0; x < width; x++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const prediction = left[c];
|
||||
const value = data[dataIndex++] + prediction;
|
||||
rgbx[rgbxIndex++] = value;
|
||||
left[c] = value;
|
||||
}
|
||||
rgbx[rgbxIndex++] = 255;
|
||||
}
|
||||
|
||||
let upperIndex = 0;
|
||||
let upper = new Uint8Array(3),
|
||||
upperleft = new Uint8Array(3);
|
||||
for (let y = 1; y < height; y++) {
|
||||
left.fill(0);
|
||||
upperleft.fill(0);
|
||||
for (let x = 0; x < width; x++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
upper[c] = rgbx[upperIndex++];
|
||||
let prediction = left[c] + upper[c] - upperleft[c];
|
||||
if (prediction < 0) {
|
||||
prediction = 0;
|
||||
} else if (prediction > 255) {
|
||||
prediction = 255;
|
||||
}
|
||||
const value = data[dataIndex++] + prediction;
|
||||
rgbx[rgbxIndex++] = value;
|
||||
upperleft[c] = upper[c];
|
||||
left[c] = value;
|
||||
}
|
||||
rgbx[rgbxIndex++] = 255;
|
||||
upperIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
display.blitImage(x, y, width, height, rgbx, 0, false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_readData(sock) {
|
||||
if (this._len === 0) {
|
||||
if (sock.rQwait("TIGHT", 3)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let byte;
|
||||
|
||||
byte = sock.rQshift8();
|
||||
this._len = byte & 0x7f;
|
||||
if (byte & 0x80) {
|
||||
byte = sock.rQshift8();
|
||||
this._len |= (byte & 0x7f) << 7;
|
||||
if (byte & 0x80) {
|
||||
byte = sock.rQshift8();
|
||||
this._len |= byte << 14;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sock.rQwait("TIGHT", this._len)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let data = sock.rQshiftBytes(this._len, false);
|
||||
this._len = 0;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
_getScratchBuffer(size) {
|
||||
if (!this._scratchBuffer || (this._scratchBuffer.length < size)) {
|
||||
this._scratchBuffer = new Uint8Array(size);
|
||||
}
|
||||
return this._scratchBuffer;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
import TightDecoder from './tight.js';
|
||||
|
||||
export default class TightPNGDecoder extends TightDecoder {
|
||||
_pngRect(x, y, width, height, sock, display, depth) {
|
||||
let data = this._readData(sock);
|
||||
if (data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
display.imageRect(x, y, width, height, "image/png", data);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_basicRect(ctl, x, y, width, height, sock, display, depth) {
|
||||
throw new Error("BasicCompression received in TightPNG rect");
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2024 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
import Inflator from "../inflator.js";
|
||||
|
||||
export default class ZlibDecoder {
|
||||
constructor() {
|
||||
this._zlib = new Inflator();
|
||||
this._length = 0;
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if ((width === 0) || (height === 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._length === 0) {
|
||||
if (sock.rQwait("ZLIB", 4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._length = sock.rQshift32();
|
||||
}
|
||||
|
||||
if (sock.rQwait("ZLIB", this._length)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let data = new Uint8Array(sock.rQshiftBytes(this._length, false));
|
||||
this._length = 0;
|
||||
|
||||
this._zlib.setInput(data);
|
||||
data = this._zlib.inflate(width * height * 4);
|
||||
this._zlib.setInput(null);
|
||||
|
||||
// Max sure the image is fully opaque
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
data[i * 4 + 3] = 255;
|
||||
}
|
||||
|
||||
display.blitImage(x, y, width, height, data, 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2021 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
import Inflate from "../inflator.js";
|
||||
|
||||
const ZRLE_TILE_WIDTH = 64;
|
||||
const ZRLE_TILE_HEIGHT = 64;
|
||||
|
||||
export default class ZRLEDecoder {
|
||||
constructor() {
|
||||
this._length = 0;
|
||||
this._inflator = new Inflate();
|
||||
|
||||
this._pixelBuffer = new Uint8Array(ZRLE_TILE_WIDTH * ZRLE_TILE_HEIGHT * 4);
|
||||
this._tileBuffer = new Uint8Array(ZRLE_TILE_WIDTH * ZRLE_TILE_HEIGHT * 4);
|
||||
}
|
||||
|
||||
decodeRect(x, y, width, height, sock, display, depth) {
|
||||
if (this._length === 0) {
|
||||
if (sock.rQwait("ZLib data length", 4)) {
|
||||
return false;
|
||||
}
|
||||
this._length = sock.rQshift32();
|
||||
}
|
||||
if (sock.rQwait("Zlib data", this._length)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = sock.rQshiftBytes(this._length, false);
|
||||
|
||||
this._inflator.setInput(data);
|
||||
|
||||
for (let ty = y; ty < y + height; ty += ZRLE_TILE_HEIGHT) {
|
||||
let th = Math.min(ZRLE_TILE_HEIGHT, y + height - ty);
|
||||
|
||||
for (let tx = x; tx < x + width; tx += ZRLE_TILE_WIDTH) {
|
||||
let tw = Math.min(ZRLE_TILE_WIDTH, x + width - tx);
|
||||
|
||||
const tileSize = tw * th;
|
||||
const subencoding = this._inflator.inflate(1)[0];
|
||||
if (subencoding === 0) {
|
||||
// raw data
|
||||
const data = this._readPixels(tileSize);
|
||||
display.blitImage(tx, ty, tw, th, data, 0, false);
|
||||
} else if (subencoding === 1) {
|
||||
// solid
|
||||
const background = this._readPixels(1);
|
||||
display.fillRect(tx, ty, tw, th, [background[0], background[1], background[2]]);
|
||||
} else if (subencoding >= 2 && subencoding <= 16) {
|
||||
const data = this._decodePaletteTile(subencoding, tileSize, tw, th);
|
||||
display.blitImage(tx, ty, tw, th, data, 0, false);
|
||||
} else if (subencoding === 128) {
|
||||
const data = this._decodeRLETile(tileSize);
|
||||
display.blitImage(tx, ty, tw, th, data, 0, false);
|
||||
} else if (subencoding >= 130 && subencoding <= 255) {
|
||||
const data = this._decodeRLEPaletteTile(subencoding - 128, tileSize);
|
||||
display.blitImage(tx, ty, tw, th, data, 0, false);
|
||||
} else {
|
||||
throw new Error('Unknown subencoding: ' + subencoding);
|
||||
}
|
||||
}
|
||||
}
|
||||
this._length = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
_getBitsPerPixelInPalette(paletteSize) {
|
||||
if (paletteSize <= 2) {
|
||||
return 1;
|
||||
} else if (paletteSize <= 4) {
|
||||
return 2;
|
||||
} else if (paletteSize <= 16) {
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
_readPixels(pixels) {
|
||||
let data = this._pixelBuffer;
|
||||
const buffer = this._inflator.inflate(3*pixels);
|
||||
for (let i = 0, j = 0; i < pixels*4; i += 4, j += 3) {
|
||||
data[i] = buffer[j];
|
||||
data[i + 1] = buffer[j + 1];
|
||||
data[i + 2] = buffer[j + 2];
|
||||
data[i + 3] = 255; // Add the Alpha
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
_decodePaletteTile(paletteSize, tileSize, tilew, tileh) {
|
||||
const data = this._tileBuffer;
|
||||
const palette = this._readPixels(paletteSize);
|
||||
const bitsPerPixel = this._getBitsPerPixelInPalette(paletteSize);
|
||||
const mask = (1 << bitsPerPixel) - 1;
|
||||
|
||||
let offset = 0;
|
||||
let encoded = this._inflator.inflate(1)[0];
|
||||
|
||||
for (let y=0; y<tileh; y++) {
|
||||
let shift = 8-bitsPerPixel;
|
||||
for (let x=0; x<tilew; x++) {
|
||||
if (shift<0) {
|
||||
shift=8-bitsPerPixel;
|
||||
encoded = this._inflator.inflate(1)[0];
|
||||
}
|
||||
let indexInPalette = (encoded>>shift) & mask;
|
||||
|
||||
data[offset] = palette[indexInPalette * 4];
|
||||
data[offset + 1] = palette[indexInPalette * 4 + 1];
|
||||
data[offset + 2] = palette[indexInPalette * 4 + 2];
|
||||
data[offset + 3] = palette[indexInPalette * 4 + 3];
|
||||
offset += 4;
|
||||
shift-=bitsPerPixel;
|
||||
}
|
||||
if (shift<8-bitsPerPixel && y<tileh-1) {
|
||||
encoded = this._inflator.inflate(1)[0];
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
_decodeRLETile(tileSize) {
|
||||
const data = this._tileBuffer;
|
||||
let i = 0;
|
||||
while (i < tileSize) {
|
||||
const pixel = this._readPixels(1);
|
||||
const length = this._readRLELength();
|
||||
for (let j = 0; j < length; j++) {
|
||||
data[i * 4] = pixel[0];
|
||||
data[i * 4 + 1] = pixel[1];
|
||||
data[i * 4 + 2] = pixel[2];
|
||||
data[i * 4 + 3] = pixel[3];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
_decodeRLEPaletteTile(paletteSize, tileSize) {
|
||||
const data = this._tileBuffer;
|
||||
|
||||
// palette
|
||||
const palette = this._readPixels(paletteSize);
|
||||
|
||||
let offset = 0;
|
||||
while (offset < tileSize) {
|
||||
let indexInPalette = this._inflator.inflate(1)[0];
|
||||
let length = 1;
|
||||
if (indexInPalette >= 128) {
|
||||
indexInPalette -= 128;
|
||||
length = this._readRLELength();
|
||||
}
|
||||
if (indexInPalette > paletteSize) {
|
||||
throw new Error('Too big index in palette: ' + indexInPalette + ', palette size: ' + paletteSize);
|
||||
}
|
||||
if (offset + length > tileSize) {
|
||||
throw new Error('Too big rle length in palette mode: ' + length + ', allowed length is: ' + (tileSize - offset));
|
||||
}
|
||||
|
||||
for (let j = 0; j < length; j++) {
|
||||
data[offset * 4] = palette[indexInPalette * 4];
|
||||
data[offset * 4 + 1] = palette[indexInPalette * 4 + 1];
|
||||
data[offset * 4 + 2] = palette[indexInPalette * 4 + 2];
|
||||
data[offset * 4 + 3] = palette[indexInPalette * 4 + 3];
|
||||
offset++;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
_readRLELength() {
|
||||
let length = 0;
|
||||
let current = 0;
|
||||
do {
|
||||
current = this._inflator.inflate(1)[0];
|
||||
length += current;
|
||||
} while (current === 255);
|
||||
return length + 1;
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2020 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
import { deflateInit, deflate } from "../vendor/pako/lib/zlib/deflate.js";
|
||||
import { Z_FULL_FLUSH, Z_DEFAULT_COMPRESSION } from "../vendor/pako/lib/zlib/deflate.js";
|
||||
import ZStream from "../vendor/pako/lib/zlib/zstream.js";
|
||||
|
||||
export default class Deflator {
|
||||
constructor() {
|
||||
this.strm = new ZStream();
|
||||
this.chunkSize = 1024 * 10 * 10;
|
||||
this.outputBuffer = new Uint8Array(this.chunkSize);
|
||||
|
||||
deflateInit(this.strm, Z_DEFAULT_COMPRESSION);
|
||||
}
|
||||
|
||||
deflate(inData) {
|
||||
/* eslint-disable camelcase */
|
||||
this.strm.input = inData;
|
||||
this.strm.avail_in = this.strm.input.length;
|
||||
this.strm.next_in = 0;
|
||||
this.strm.output = this.outputBuffer;
|
||||
this.strm.avail_out = this.chunkSize;
|
||||
this.strm.next_out = 0;
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
let lastRet = deflate(this.strm, Z_FULL_FLUSH);
|
||||
let outData = new Uint8Array(this.strm.output.buffer, 0, this.strm.next_out);
|
||||
|
||||
if (lastRet < 0) {
|
||||
throw new Error("zlib deflate failed");
|
||||
}
|
||||
|
||||
if (this.strm.avail_in > 0) {
|
||||
// Read chunks until done
|
||||
|
||||
let chunks = [outData];
|
||||
let totalLen = outData.length;
|
||||
do {
|
||||
/* eslint-disable camelcase */
|
||||
this.strm.output = new Uint8Array(this.chunkSize);
|
||||
this.strm.next_out = 0;
|
||||
this.strm.avail_out = this.chunkSize;
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
lastRet = deflate(this.strm, Z_FULL_FLUSH);
|
||||
|
||||
if (lastRet < 0) {
|
||||
throw new Error("zlib deflate failed");
|
||||
}
|
||||
|
||||
let chunk = new Uint8Array(this.strm.output.buffer, 0, this.strm.next_out);
|
||||
totalLen += chunk.length;
|
||||
chunks.push(chunk);
|
||||
} while (this.strm.avail_in > 0);
|
||||
|
||||
// Combine chunks into a single data
|
||||
|
||||
let newData = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
newData.set(chunks[i], offset);
|
||||
offset += chunks[i].length;
|
||||
}
|
||||
|
||||
outData = newData;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
this.strm.input = null;
|
||||
this.strm.avail_in = 0;
|
||||
this.strm.next_in = 0;
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
return outData;
|
||||
}
|
||||
|
||||
}
|
||||
+575
@@ -0,0 +1,575 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
import * as Log from './util/logging.js';
|
||||
import Base64 from "./base64.js";
|
||||
import { toSigned32bit } from './util/int.js';
|
||||
|
||||
export default class Display {
|
||||
constructor(target) {
|
||||
this._drawCtx = null;
|
||||
|
||||
this._renderQ = []; // queue drawing actions for in-oder rendering
|
||||
this._flushPromise = null;
|
||||
|
||||
// the full frame buffer (logical canvas) size
|
||||
this._fbWidth = 0;
|
||||
this._fbHeight = 0;
|
||||
|
||||
this._prevDrawStyle = "";
|
||||
|
||||
Log.Debug(">> Display.constructor");
|
||||
|
||||
// The visible canvas
|
||||
this._target = target;
|
||||
|
||||
if (!this._target) {
|
||||
throw new Error("Target must be set");
|
||||
}
|
||||
|
||||
if (typeof this._target === 'string') {
|
||||
throw new Error('target must be a DOM element');
|
||||
}
|
||||
|
||||
if (!this._target.getContext) {
|
||||
throw new Error("no getContext method");
|
||||
}
|
||||
|
||||
this._targetCtx = this._target.getContext('2d');
|
||||
|
||||
// the visible canvas viewport (i.e. what actually gets seen)
|
||||
this._viewportLoc = { 'x': 0, 'y': 0, 'w': this._target.width, 'h': this._target.height };
|
||||
|
||||
// The hidden canvas, where we do the actual rendering
|
||||
this._backbuffer = document.createElement('canvas');
|
||||
this._drawCtx = this._backbuffer.getContext('2d');
|
||||
|
||||
this._damageBounds = { left: 0, top: 0,
|
||||
right: this._backbuffer.width,
|
||||
bottom: this._backbuffer.height };
|
||||
|
||||
Log.Debug("User Agent: " + navigator.userAgent);
|
||||
|
||||
Log.Debug("<< Display.constructor");
|
||||
|
||||
// ===== PROPERTIES =====
|
||||
|
||||
this._scale = 1.0;
|
||||
this._clipViewport = false;
|
||||
}
|
||||
|
||||
// ===== PROPERTIES =====
|
||||
|
||||
get scale() { return this._scale; }
|
||||
set scale(scale) {
|
||||
this._rescale(scale);
|
||||
}
|
||||
|
||||
get clipViewport() { return this._clipViewport; }
|
||||
set clipViewport(viewport) {
|
||||
this._clipViewport = viewport;
|
||||
// May need to readjust the viewport dimensions
|
||||
const vp = this._viewportLoc;
|
||||
this.viewportChangeSize(vp.w, vp.h);
|
||||
this.viewportChangePos(0, 0);
|
||||
}
|
||||
|
||||
get width() {
|
||||
return this._fbWidth;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this._fbHeight;
|
||||
}
|
||||
|
||||
// ===== PUBLIC METHODS =====
|
||||
|
||||
viewportChangePos(deltaX, deltaY) {
|
||||
const vp = this._viewportLoc;
|
||||
deltaX = Math.floor(deltaX);
|
||||
deltaY = Math.floor(deltaY);
|
||||
|
||||
if (!this._clipViewport) {
|
||||
deltaX = -vp.w; // clamped later of out of bounds
|
||||
deltaY = -vp.h;
|
||||
}
|
||||
|
||||
const vx2 = vp.x + vp.w - 1;
|
||||
const vy2 = vp.y + vp.h - 1;
|
||||
|
||||
// Position change
|
||||
|
||||
if (deltaX < 0 && vp.x + deltaX < 0) {
|
||||
deltaX = -vp.x;
|
||||
}
|
||||
if (vx2 + deltaX >= this._fbWidth) {
|
||||
deltaX -= vx2 + deltaX - this._fbWidth + 1;
|
||||
}
|
||||
|
||||
if (vp.y + deltaY < 0) {
|
||||
deltaY = -vp.y;
|
||||
}
|
||||
if (vy2 + deltaY >= this._fbHeight) {
|
||||
deltaY -= (vy2 + deltaY - this._fbHeight + 1);
|
||||
}
|
||||
|
||||
if (deltaX === 0 && deltaY === 0) {
|
||||
return;
|
||||
}
|
||||
Log.Debug("viewportChange deltaX: " + deltaX + ", deltaY: " + deltaY);
|
||||
|
||||
vp.x += deltaX;
|
||||
vp.y += deltaY;
|
||||
|
||||
this._damage(vp.x, vp.y, vp.w, vp.h);
|
||||
|
||||
this.flip();
|
||||
}
|
||||
|
||||
viewportChangeSize(width, height) {
|
||||
|
||||
if (!this._clipViewport ||
|
||||
typeof(width) === "undefined" ||
|
||||
typeof(height) === "undefined") {
|
||||
|
||||
Log.Debug("Setting viewport to full display region");
|
||||
width = this._fbWidth;
|
||||
height = this._fbHeight;
|
||||
}
|
||||
|
||||
width = Math.floor(width);
|
||||
height = Math.floor(height);
|
||||
|
||||
if (width > this._fbWidth) {
|
||||
width = this._fbWidth;
|
||||
}
|
||||
if (height > this._fbHeight) {
|
||||
height = this._fbHeight;
|
||||
}
|
||||
|
||||
const vp = this._viewportLoc;
|
||||
if (vp.w !== width || vp.h !== height) {
|
||||
vp.w = width;
|
||||
vp.h = height;
|
||||
|
||||
const canvas = this._target;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// The position might need to be updated if we've grown
|
||||
this.viewportChangePos(0, 0);
|
||||
|
||||
this._damage(vp.x, vp.y, vp.w, vp.h);
|
||||
this.flip();
|
||||
|
||||
// Update the visible size of the target canvas
|
||||
this._rescale(this._scale);
|
||||
}
|
||||
}
|
||||
|
||||
absX(x) {
|
||||
if (this._scale === 0) {
|
||||
return 0;
|
||||
}
|
||||
return toSigned32bit(x / this._scale + this._viewportLoc.x);
|
||||
}
|
||||
|
||||
absY(y) {
|
||||
if (this._scale === 0) {
|
||||
return 0;
|
||||
}
|
||||
return toSigned32bit(y / this._scale + this._viewportLoc.y);
|
||||
}
|
||||
|
||||
resize(width, height) {
|
||||
this._prevDrawStyle = "";
|
||||
|
||||
this._fbWidth = width;
|
||||
this._fbHeight = height;
|
||||
|
||||
const canvas = this._backbuffer;
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
|
||||
// We have to save the canvas data since changing the size will clear it
|
||||
let saveImg = null;
|
||||
if (canvas.width > 0 && canvas.height > 0) {
|
||||
saveImg = this._drawCtx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
if (canvas.width !== width) {
|
||||
canvas.width = width;
|
||||
}
|
||||
if (canvas.height !== height) {
|
||||
canvas.height = height;
|
||||
}
|
||||
|
||||
if (saveImg) {
|
||||
this._drawCtx.putImageData(saveImg, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Readjust the viewport as it may be incorrectly sized
|
||||
// and positioned
|
||||
const vp = this._viewportLoc;
|
||||
this.viewportChangeSize(vp.w, vp.h);
|
||||
this.viewportChangePos(0, 0);
|
||||
}
|
||||
|
||||
getImageData() {
|
||||
return this._drawCtx.getImageData(0, 0, this.width, this.height);
|
||||
}
|
||||
|
||||
toDataURL(type, encoderOptions) {
|
||||
return this._backbuffer.toDataURL(type, encoderOptions);
|
||||
}
|
||||
|
||||
toBlob(callback, type, quality) {
|
||||
return this._backbuffer.toBlob(callback, type, quality);
|
||||
}
|
||||
|
||||
// Track what parts of the visible canvas that need updating
|
||||
_damage(x, y, w, h) {
|
||||
if (x < this._damageBounds.left) {
|
||||
this._damageBounds.left = x;
|
||||
}
|
||||
if (y < this._damageBounds.top) {
|
||||
this._damageBounds.top = y;
|
||||
}
|
||||
if ((x + w) > this._damageBounds.right) {
|
||||
this._damageBounds.right = x + w;
|
||||
}
|
||||
if ((y + h) > this._damageBounds.bottom) {
|
||||
this._damageBounds.bottom = y + h;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the visible canvas with the contents of the
|
||||
// rendering canvas
|
||||
flip(fromQueue) {
|
||||
if (this._renderQ.length !== 0 && !fromQueue) {
|
||||
this._renderQPush({
|
||||
'type': 'flip'
|
||||
});
|
||||
} else {
|
||||
let x = this._damageBounds.left;
|
||||
let y = this._damageBounds.top;
|
||||
let w = this._damageBounds.right - x;
|
||||
let h = this._damageBounds.bottom - y;
|
||||
|
||||
let vx = x - this._viewportLoc.x;
|
||||
let vy = y - this._viewportLoc.y;
|
||||
|
||||
if (vx < 0) {
|
||||
w += vx;
|
||||
x -= vx;
|
||||
vx = 0;
|
||||
}
|
||||
if (vy < 0) {
|
||||
h += vy;
|
||||
y -= vy;
|
||||
vy = 0;
|
||||
}
|
||||
|
||||
if ((vx + w) > this._viewportLoc.w) {
|
||||
w = this._viewportLoc.w - vx;
|
||||
}
|
||||
if ((vy + h) > this._viewportLoc.h) {
|
||||
h = this._viewportLoc.h - vy;
|
||||
}
|
||||
|
||||
if ((w > 0) && (h > 0)) {
|
||||
// FIXME: We may need to disable image smoothing here
|
||||
// as well (see copyImage()), but we haven't
|
||||
// noticed any problem yet.
|
||||
this._targetCtx.drawImage(this._backbuffer,
|
||||
x, y, w, h,
|
||||
vx, vy, w, h);
|
||||
}
|
||||
|
||||
this._damageBounds.left = this._damageBounds.top = 65535;
|
||||
this._damageBounds.right = this._damageBounds.bottom = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pending() {
|
||||
return this._renderQ.length > 0;
|
||||
}
|
||||
|
||||
flush() {
|
||||
if (this._renderQ.length === 0) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
if (this._flushPromise === null) {
|
||||
this._flushPromise = new Promise((resolve) => {
|
||||
this._flushResolve = resolve;
|
||||
});
|
||||
}
|
||||
return this._flushPromise;
|
||||
}
|
||||
}
|
||||
|
||||
fillRect(x, y, width, height, color, fromQueue) {
|
||||
if (this._renderQ.length !== 0 && !fromQueue) {
|
||||
this._renderQPush({
|
||||
'type': 'fill',
|
||||
'x': x,
|
||||
'y': y,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'color': color
|
||||
});
|
||||
} else {
|
||||
this._setFillColor(color);
|
||||
this._drawCtx.fillRect(x, y, width, height);
|
||||
this._damage(x, y, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
copyImage(oldX, oldY, newX, newY, w, h, fromQueue) {
|
||||
if (this._renderQ.length !== 0 && !fromQueue) {
|
||||
this._renderQPush({
|
||||
'type': 'copy',
|
||||
'oldX': oldX,
|
||||
'oldY': oldY,
|
||||
'x': newX,
|
||||
'y': newY,
|
||||
'width': w,
|
||||
'height': h,
|
||||
});
|
||||
} else {
|
||||
// Due to this bug among others [1] we need to disable the image-smoothing to
|
||||
// avoid getting a blur effect when copying data.
|
||||
//
|
||||
// 1. https://bugzilla.mozilla.org/show_bug.cgi?id=1194719
|
||||
//
|
||||
// We need to set these every time since all properties are reset
|
||||
// when the the size is changed
|
||||
this._drawCtx.mozImageSmoothingEnabled = false;
|
||||
this._drawCtx.webkitImageSmoothingEnabled = false;
|
||||
this._drawCtx.msImageSmoothingEnabled = false;
|
||||
this._drawCtx.imageSmoothingEnabled = false;
|
||||
|
||||
this._drawCtx.drawImage(this._backbuffer,
|
||||
oldX, oldY, w, h,
|
||||
newX, newY, w, h);
|
||||
this._damage(newX, newY, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
imageRect(x, y, width, height, mime, arr) {
|
||||
/* The internal logic cannot handle empty images, so bail early */
|
||||
if ((width === 0) || (height === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.src = "data: " + mime + ";base64," + Base64.encode(arr);
|
||||
|
||||
this._renderQPush({
|
||||
'type': 'img',
|
||||
'img': img,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'width': width,
|
||||
'height': height
|
||||
});
|
||||
}
|
||||
|
||||
videoFrame(x, y, width, height, frame) {
|
||||
this._renderQPush({
|
||||
'type': 'frame',
|
||||
'frame': frame,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'width': width,
|
||||
'height': height
|
||||
});
|
||||
}
|
||||
|
||||
blitImage(x, y, width, height, arr, offset, fromQueue) {
|
||||
if (this._renderQ.length !== 0 && !fromQueue) {
|
||||
// NB(directxman12): it's technically more performant here to use preallocated arrays,
|
||||
// but it's a lot of extra work for not a lot of payoff -- if we're using the render queue,
|
||||
// this probably isn't getting called *nearly* as much
|
||||
const newArr = new Uint8Array(width * height * 4);
|
||||
newArr.set(new Uint8Array(arr.buffer, 0, newArr.length));
|
||||
this._renderQPush({
|
||||
'type': 'blit',
|
||||
'data': newArr,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'width': width,
|
||||
'height': height,
|
||||
});
|
||||
} else {
|
||||
// NB(directxman12): arr must be an Type Array view
|
||||
let data = new Uint8ClampedArray(arr.buffer,
|
||||
arr.byteOffset + offset,
|
||||
width * height * 4);
|
||||
let img = new ImageData(data, width, height);
|
||||
this._drawCtx.putImageData(img, x, y);
|
||||
this._damage(x, y, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
drawImage(img, ...args) {
|
||||
this._drawCtx.drawImage(img, ...args);
|
||||
|
||||
if (args.length <= 4) {
|
||||
const [x, y] = args;
|
||||
this._damage(x, y, img.width, img.height);
|
||||
} else {
|
||||
const [,, sw, sh, dx, dy] = args;
|
||||
this._damage(dx, dy, sw, sh);
|
||||
}
|
||||
}
|
||||
|
||||
autoscale(containerWidth, containerHeight) {
|
||||
let scaleRatio;
|
||||
|
||||
if (containerWidth === 0 || containerHeight === 0) {
|
||||
scaleRatio = 0;
|
||||
|
||||
} else {
|
||||
|
||||
const vp = this._viewportLoc;
|
||||
const targetAspectRatio = containerWidth / containerHeight;
|
||||
const fbAspectRatio = vp.w / vp.h;
|
||||
|
||||
if (fbAspectRatio >= targetAspectRatio) {
|
||||
scaleRatio = containerWidth / vp.w;
|
||||
} else {
|
||||
scaleRatio = containerHeight / vp.h;
|
||||
}
|
||||
}
|
||||
|
||||
this._rescale(scaleRatio);
|
||||
}
|
||||
|
||||
// ===== PRIVATE METHODS =====
|
||||
|
||||
_rescale(factor) {
|
||||
this._scale = factor;
|
||||
const vp = this._viewportLoc;
|
||||
|
||||
// NB(directxman12): If you set the width directly, or set the
|
||||
// style width to a number, the canvas is cleared.
|
||||
// However, if you set the style width to a string
|
||||
// ('NNNpx'), the canvas is scaled without clearing.
|
||||
const width = factor * vp.w + 'px';
|
||||
const height = factor * vp.h + 'px';
|
||||
|
||||
if ((this._target.style.width !== width) ||
|
||||
(this._target.style.height !== height)) {
|
||||
this._target.style.width = width;
|
||||
this._target.style.height = height;
|
||||
}
|
||||
}
|
||||
|
||||
_setFillColor(color) {
|
||||
const newStyle = 'rgb(' + color[0] + ',' + color[1] + ',' + color[2] + ')';
|
||||
if (newStyle !== this._prevDrawStyle) {
|
||||
this._drawCtx.fillStyle = newStyle;
|
||||
this._prevDrawStyle = newStyle;
|
||||
}
|
||||
}
|
||||
|
||||
_renderQPush(action) {
|
||||
this._renderQ.push(action);
|
||||
if (this._renderQ.length === 1) {
|
||||
// If this can be rendered immediately it will be, otherwise
|
||||
// the scanner will wait for the relevant event
|
||||
this._scanRenderQ();
|
||||
}
|
||||
}
|
||||
|
||||
_resumeRenderQ() {
|
||||
// "this" is the object that is ready, not the
|
||||
// display object
|
||||
this.removeEventListener('load', this._noVNCDisplay._resumeRenderQ);
|
||||
this._noVNCDisplay._scanRenderQ();
|
||||
}
|
||||
|
||||
_scanRenderQ() {
|
||||
let ready = true;
|
||||
while (ready && this._renderQ.length > 0) {
|
||||
const a = this._renderQ[0];
|
||||
switch (a.type) {
|
||||
case 'flip':
|
||||
this.flip(true);
|
||||
break;
|
||||
case 'copy':
|
||||
this.copyImage(a.oldX, a.oldY, a.x, a.y, a.width, a.height, true);
|
||||
break;
|
||||
case 'fill':
|
||||
this.fillRect(a.x, a.y, a.width, a.height, a.color, true);
|
||||
break;
|
||||
case 'blit':
|
||||
this.blitImage(a.x, a.y, a.width, a.height, a.data, 0, true);
|
||||
break;
|
||||
case 'img':
|
||||
if (a.img.complete) {
|
||||
if (a.img.width !== a.width || a.img.height !== a.height) {
|
||||
Log.Error("Decoded image has incorrect dimensions. Got " +
|
||||
a.img.width + "x" + a.img.height + ". Expected " +
|
||||
a.width + "x" + a.height + ".");
|
||||
return;
|
||||
}
|
||||
this.drawImage(a.img, a.x, a.y);
|
||||
} else {
|
||||
a.img._noVNCDisplay = this;
|
||||
a.img.addEventListener('load', this._resumeRenderQ);
|
||||
// We need to wait for this image to 'load'
|
||||
// to keep things in-order
|
||||
ready = false;
|
||||
}
|
||||
break;
|
||||
case 'frame':
|
||||
if (a.frame.ready) {
|
||||
// The encoded frame may be larger than the rect due to
|
||||
// limitations of the encoder, so we need to crop the
|
||||
// frame.
|
||||
let frame = a.frame.frame;
|
||||
if (frame.codedWidth < a.width || frame.codedHeight < a.height) {
|
||||
Log.Warn("Decoded video frame does not cover its full rectangle area. Expecting at least " +
|
||||
a.width + "x" + a.height + " but got " +
|
||||
frame.codedWidth + "x" + frame.codedHeight);
|
||||
}
|
||||
const sx = 0;
|
||||
const sy = 0;
|
||||
const sw = a.width;
|
||||
const sh = a.height;
|
||||
const dx = a.x;
|
||||
const dy = a.y;
|
||||
const dw = sw;
|
||||
const dh = sh;
|
||||
this.drawImage(frame, sx, sy, sw, sh, dx, dy, dw, dh);
|
||||
frame.close();
|
||||
} else {
|
||||
let display = this;
|
||||
a.frame.promise.then(() => {
|
||||
display._scanRenderQ();
|
||||
});
|
||||
ready = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (ready) {
|
||||
this._renderQ.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if (this._renderQ.length === 0 &&
|
||||
this._flushPromise !== null) {
|
||||
this._flushResolve();
|
||||
this._flushPromise = null;
|
||||
this._flushResolve = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
export const encodings = {
|
||||
encodingRaw: 0,
|
||||
encodingCopyRect: 1,
|
||||
encodingRRE: 2,
|
||||
encodingHextile: 5,
|
||||
encodingZlib: 6,
|
||||
encodingTight: 7,
|
||||
encodingZRLE: 16,
|
||||
encodingTightPNG: -260,
|
||||
encodingJPEG: 21,
|
||||
encodingH264: 50,
|
||||
|
||||
pseudoEncodingQualityLevel9: -23,
|
||||
pseudoEncodingQualityLevel0: -32,
|
||||
pseudoEncodingDesktopSize: -223,
|
||||
pseudoEncodingLastRect: -224,
|
||||
pseudoEncodingCursor: -239,
|
||||
pseudoEncodingQEMUExtendedKeyEvent: -258,
|
||||
pseudoEncodingQEMULedEvent: -261,
|
||||
pseudoEncodingDesktopName: -307,
|
||||
pseudoEncodingExtendedDesktopSize: -308,
|
||||
pseudoEncodingXvp: -309,
|
||||
pseudoEncodingFence: -312,
|
||||
pseudoEncodingContinuousUpdates: -313,
|
||||
pseudoEncodingExtendedMouseButtons: -316,
|
||||
pseudoEncodingCompressLevel9: -247,
|
||||
pseudoEncodingCompressLevel0: -256,
|
||||
pseudoEncodingVMwareCursor: 0x574d5664,
|
||||
pseudoEncodingExtendedClipboard: 0xc0a1e5ce
|
||||
};
|
||||
|
||||
export function encodingName(num) {
|
||||
switch (num) {
|
||||
case encodings.encodingRaw: return "Raw";
|
||||
case encodings.encodingCopyRect: return "CopyRect";
|
||||
case encodings.encodingRRE: return "RRE";
|
||||
case encodings.encodingHextile: return "Hextile";
|
||||
case encodings.encodingZlib: return "Zlib";
|
||||
case encodings.encodingTight: return "Tight";
|
||||
case encodings.encodingZRLE: return "ZRLE";
|
||||
case encodings.encodingTightPNG: return "TightPNG";
|
||||
case encodings.encodingJPEG: return "JPEG";
|
||||
case encodings.encodingH264: return "H.264";
|
||||
default: return "[unknown encoding " + num + "]";
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2020 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*/
|
||||
|
||||
import { inflateInit, inflate, inflateReset } from "../vendor/pako/lib/zlib/inflate.js";
|
||||
import ZStream from "../vendor/pako/lib/zlib/zstream.js";
|
||||
|
||||
export default class Inflate {
|
||||
constructor() {
|
||||
this.strm = new ZStream();
|
||||
this.chunkSize = 1024 * 10 * 10;
|
||||
this.strm.output = new Uint8Array(this.chunkSize);
|
||||
|
||||
inflateInit(this.strm);
|
||||
}
|
||||
|
||||
setInput(data) {
|
||||
if (!data) {
|
||||
//FIXME: flush remaining data.
|
||||
/* eslint-disable camelcase */
|
||||
this.strm.input = null;
|
||||
this.strm.avail_in = 0;
|
||||
this.strm.next_in = 0;
|
||||
} else {
|
||||
this.strm.input = data;
|
||||
this.strm.avail_in = this.strm.input.length;
|
||||
this.strm.next_in = 0;
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
}
|
||||
|
||||
inflate(expected) {
|
||||
// resize our output buffer if it's too small
|
||||
// (we could just use multiple chunks, but that would cause an extra
|
||||
// allocation each time to flatten the chunks)
|
||||
if (expected > this.chunkSize) {
|
||||
this.chunkSize = expected;
|
||||
this.strm.output = new Uint8Array(this.chunkSize);
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
this.strm.next_out = 0;
|
||||
this.strm.avail_out = expected;
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
let ret = inflate(this.strm, 0); // Flush argument not used.
|
||||
if (ret < 0) {
|
||||
throw new Error("zlib inflate failed");
|
||||
}
|
||||
|
||||
if (this.strm.next_out != expected) {
|
||||
throw new Error("Incomplete zlib block");
|
||||
}
|
||||
|
||||
return new Uint8Array(this.strm.output.buffer, 0, this.strm.next_out);
|
||||
}
|
||||
|
||||
reset() {
|
||||
inflateReset(this.strm);
|
||||
}
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2018 The noVNC authors
|
||||
* Licensed under MPL 2.0 or any later version (see LICENSE.txt)
|
||||
*/
|
||||
|
||||
import KeyTable from "./keysym.js";
|
||||
|
||||
/*
|
||||
* Mapping between HTML key values and VNC/X11 keysyms for "special"
|
||||
* keys that cannot be handled via their Unicode codepoint.
|
||||
*
|
||||
* See https://www.w3.org/TR/uievents-key/ for possible values.
|
||||
*/
|
||||
|
||||
const DOMKeyTable = {};
|
||||
|
||||
function addStandard(key, standard) {
|
||||
if (standard === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
|
||||
if (key in DOMKeyTable) throw new Error("Duplicate entry for key \"" + key + "\"");
|
||||
DOMKeyTable[key] = [standard, standard, standard, standard];
|
||||
}
|
||||
|
||||
function addLeftRight(key, left, right) {
|
||||
if (left === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
|
||||
if (right === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
|
||||
if (key in DOMKeyTable) throw new Error("Duplicate entry for key \"" + key + "\"");
|
||||
DOMKeyTable[key] = [left, left, right, left];
|
||||
}
|
||||
|
||||
function addNumpad(key, standard, numpad) {
|
||||
if (standard === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
|
||||
if (numpad === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
|
||||
if (key in DOMKeyTable) throw new Error("Duplicate entry for key \"" + key + "\"");
|
||||
DOMKeyTable[key] = [standard, standard, standard, numpad];
|
||||
}
|
||||
|
||||
// 3.2. Modifier Keys
|
||||
|
||||
addLeftRight("Alt", KeyTable.XK_Alt_L, KeyTable.XK_Alt_R);
|
||||
addStandard("AltGraph", KeyTable.XK_ISO_Level3_Shift);
|
||||
addStandard("CapsLock", KeyTable.XK_Caps_Lock);
|
||||
addLeftRight("Control", KeyTable.XK_Control_L, KeyTable.XK_Control_R);
|
||||
// - Fn
|
||||
// - FnLock
|
||||
addLeftRight("Meta", KeyTable.XK_Super_L, KeyTable.XK_Super_R);
|
||||
addStandard("NumLock", KeyTable.XK_Num_Lock);
|
||||
addStandard("ScrollLock", KeyTable.XK_Scroll_Lock);
|
||||
addLeftRight("Shift", KeyTable.XK_Shift_L, KeyTable.XK_Shift_R);
|
||||
// - Symbol
|
||||
// - SymbolLock
|
||||
// - Hyper
|
||||
// - Super
|
||||
|
||||
// 3.3. Whitespace Keys
|
||||
|
||||
addNumpad("Enter", KeyTable.XK_Return, KeyTable.XK_KP_Enter);
|
||||
addStandard("Tab", KeyTable.XK_Tab);
|
||||
addNumpad(" ", KeyTable.XK_space, KeyTable.XK_KP_Space);
|
||||
|
||||
// 3.4. Navigation Keys
|
||||
|
||||
addNumpad("ArrowDown", KeyTable.XK_Down, KeyTable.XK_KP_Down);
|
||||
addNumpad("ArrowLeft", KeyTable.XK_Left, KeyTable.XK_KP_Left);
|
||||
addNumpad("ArrowRight", KeyTable.XK_Right, KeyTable.XK_KP_Right);
|
||||
addNumpad("ArrowUp", KeyTable.XK_Up, KeyTable.XK_KP_Up);
|
||||
addNumpad("End", KeyTable.XK_End, KeyTable.XK_KP_End);
|
||||
addNumpad("Home", KeyTable.XK_Home, KeyTable.XK_KP_Home);
|
||||
addNumpad("PageDown", KeyTable.XK_Next, KeyTable.XK_KP_Next);
|
||||
addNumpad("PageUp", KeyTable.XK_Prior, KeyTable.XK_KP_Prior);
|
||||
|
||||
// 3.5. Editing Keys
|
||||
|
||||
addStandard("Backspace", KeyTable.XK_BackSpace);
|
||||
// Browsers send "Clear" for the numpad 5 without NumLock because
|
||||
// Windows uses VK_Clear for that key. But Unix expects KP_Begin for
|
||||
// that scenario.
|
||||
addNumpad("Clear", KeyTable.XK_Clear, KeyTable.XK_KP_Begin);
|
||||
addStandard("Copy", KeyTable.XF86XK_Copy);
|
||||
// - CrSel
|
||||
addStandard("Cut", KeyTable.XF86XK_Cut);
|
||||
addNumpad("Delete", KeyTable.XK_Delete, KeyTable.XK_KP_Delete);
|
||||
// - EraseEof
|
||||
// - ExSel
|
||||
addNumpad("Insert", KeyTable.XK_Insert, KeyTable.XK_KP_Insert);
|
||||
addStandard("Paste", KeyTable.XF86XK_Paste);
|
||||
addStandard("Redo", KeyTable.XK_Redo);
|
||||
addStandard("Undo", KeyTable.XK_Undo);
|
||||
|
||||
// 3.6. UI Keys
|
||||
|
||||
// - Accept
|
||||
// - Again (could just be XK_Redo)
|
||||
// - Attn
|
||||
addStandard("Cancel", KeyTable.XK_Cancel);
|
||||
addStandard("ContextMenu", KeyTable.XK_Menu);
|
||||
addStandard("Escape", KeyTable.XK_Escape);
|
||||
addStandard("Execute", KeyTable.XK_Execute);
|
||||
addStandard("Find", KeyTable.XK_Find);
|
||||
addStandard("Help", KeyTable.XK_Help);
|
||||
addStandard("Pause", KeyTable.XK_Pause);
|
||||
// - Play
|
||||
// - Props
|
||||
addStandard("Select", KeyTable.XK_Select);
|
||||
addStandard("ZoomIn", KeyTable.XF86XK_ZoomIn);
|
||||
addStandard("ZoomOut", KeyTable.XF86XK_ZoomOut);
|
||||
|
||||
// 3.7. Device Keys
|
||||
|
||||
addStandard("BrightnessDown", KeyTable.XF86XK_MonBrightnessDown);
|
||||
addStandard("BrightnessUp", KeyTable.XF86XK_MonBrightnessUp);
|
||||
addStandard("Eject", KeyTable.XF86XK_Eject);
|
||||
addStandard("LogOff", KeyTable.XF86XK_LogOff);
|
||||
addStandard("Power", KeyTable.XF86XK_PowerOff);
|
||||
addStandard("PowerOff", KeyTable.XF86XK_PowerDown);
|
||||
addStandard("PrintScreen", KeyTable.XK_Print);
|
||||
addStandard("Hibernate", KeyTable.XF86XK_Hibernate);
|
||||
addStandard("Standby", KeyTable.XF86XK_Standby);
|
||||
addStandard("WakeUp", KeyTable.XF86XK_WakeUp);
|
||||
|
||||
// 3.8. IME and Composition Keys
|
||||
|
||||
addStandard("AllCandidates", KeyTable.XK_MultipleCandidate);
|
||||
addStandard("Alphanumeric", KeyTable.XK_Eisu_toggle);
|
||||
addStandard("CodeInput", KeyTable.XK_Codeinput);
|
||||
addStandard("Compose", KeyTable.XK_Multi_key);
|
||||
addStandard("Convert", KeyTable.XK_Henkan);
|
||||
// - Dead
|
||||
// - FinalMode
|
||||
addStandard("GroupFirst", KeyTable.XK_ISO_First_Group);
|
||||
addStandard("GroupLast", KeyTable.XK_ISO_Last_Group);
|
||||
addStandard("GroupNext", KeyTable.XK_ISO_Next_Group);
|
||||
addStandard("GroupPrevious", KeyTable.XK_ISO_Prev_Group);
|
||||
// - ModeChange (XK_Mode_switch is often used for AltGr)
|
||||
// - NextCandidate
|
||||
addStandard("NonConvert", KeyTable.XK_Muhenkan);
|
||||
addStandard("PreviousCandidate", KeyTable.XK_PreviousCandidate);
|
||||
// - Process
|
||||
addStandard("SingleCandidate", KeyTable.XK_SingleCandidate);
|
||||
addStandard("HangulMode", KeyTable.XK_Hangul);
|
||||
addStandard("HanjaMode", KeyTable.XK_Hangul_Hanja);
|
||||
addStandard("JunjaMode", KeyTable.XK_Hangul_Jeonja);
|
||||
addStandard("Eisu", KeyTable.XK_Eisu_toggle);
|
||||
addStandard("Hankaku", KeyTable.XK_Hankaku);
|
||||
addStandard("Hiragana", KeyTable.XK_Hiragana);
|
||||
addStandard("HiraganaKatakana", KeyTable.XK_Hiragana_Katakana);
|
||||
addStandard("KanaMode", KeyTable.XK_Kana_Shift); // could also be _Kana_Lock
|
||||
addStandard("KanjiMode", KeyTable.XK_Kanji);
|
||||
addStandard("Katakana", KeyTable.XK_Katakana);
|
||||
addStandard("Romaji", KeyTable.XK_Romaji);
|
||||
addStandard("Zenkaku", KeyTable.XK_Zenkaku);
|
||||
addStandard("ZenkakuHankaku", KeyTable.XK_Zenkaku_Hankaku);
|
||||
|
||||
// 3.9. General-Purpose Function Keys
|
||||
|
||||
addStandard("F1", KeyTable.XK_F1);
|
||||
addStandard("F2", KeyTable.XK_F2);
|
||||
addStandard("F3", KeyTable.XK_F3);
|
||||
addStandard("F4", KeyTable.XK_F4);
|
||||
addStandard("F5", KeyTable.XK_F5);
|
||||
addStandard("F6", KeyTable.XK_F6);
|
||||
addStandard("F7", KeyTable.XK_F7);
|
||||
addStandard("F8", KeyTable.XK_F8);
|
||||
addStandard("F9", KeyTable.XK_F9);
|
||||
addStandard("F10", KeyTable.XK_F10);
|
||||
addStandard("F11", KeyTable.XK_F11);
|
||||
addStandard("F12", KeyTable.XK_F12);
|
||||
addStandard("F13", KeyTable.XK_F13);
|
||||
addStandard("F14", KeyTable.XK_F14);
|
||||
addStandard("F15", KeyTable.XK_F15);
|
||||
addStandard("F16", KeyTable.XK_F16);
|
||||
addStandard("F17", KeyTable.XK_F17);
|
||||
addStandard("F18", KeyTable.XK_F18);
|
||||
addStandard("F19", KeyTable.XK_F19);
|
||||
addStandard("F20", KeyTable.XK_F20);
|
||||
addStandard("F21", KeyTable.XK_F21);
|
||||
addStandard("F22", KeyTable.XK_F22);
|
||||
addStandard("F23", KeyTable.XK_F23);
|
||||
addStandard("F24", KeyTable.XK_F24);
|
||||
addStandard("F25", KeyTable.XK_F25);
|
||||
addStandard("F26", KeyTable.XK_F26);
|
||||
addStandard("F27", KeyTable.XK_F27);
|
||||
addStandard("F28", KeyTable.XK_F28);
|
||||
addStandard("F29", KeyTable.XK_F29);
|
||||
addStandard("F30", KeyTable.XK_F30);
|
||||
addStandard("F31", KeyTable.XK_F31);
|
||||
addStandard("F32", KeyTable.XK_F32);
|
||||
addStandard("F33", KeyTable.XK_F33);
|
||||
addStandard("F34", KeyTable.XK_F34);
|
||||
addStandard("F35", KeyTable.XK_F35);
|
||||
// - Soft1...
|
||||
|
||||
// 3.10. Multimedia Keys
|
||||
|
||||
// - ChannelDown
|
||||
// - ChannelUp
|
||||
addStandard("Close", KeyTable.XF86XK_Close);
|
||||
addStandard("MailForward", KeyTable.XF86XK_MailForward);
|
||||
addStandard("MailReply", KeyTable.XF86XK_Reply);
|
||||
addStandard("MailSend", KeyTable.XF86XK_Send);
|
||||
// - MediaClose
|
||||
addStandard("MediaFastForward", KeyTable.XF86XK_AudioForward);
|
||||
addStandard("MediaPause", KeyTable.XF86XK_AudioPause);
|
||||
addStandard("MediaPlay", KeyTable.XF86XK_AudioPlay);
|
||||
// - MediaPlayPause
|
||||
addStandard("MediaRecord", KeyTable.XF86XK_AudioRecord);
|
||||
addStandard("MediaRewind", KeyTable.XF86XK_AudioRewind);
|
||||
addStandard("MediaStop", KeyTable.XF86XK_AudioStop);
|
||||
addStandard("MediaTrackNext", KeyTable.XF86XK_AudioNext);
|
||||
addStandard("MediaTrackPrevious", KeyTable.XF86XK_AudioPrev);
|
||||
addStandard("New", KeyTable.XF86XK_New);
|
||||
addStandard("Open", KeyTable.XF86XK_Open);
|
||||
addStandard("Print", KeyTable.XK_Print);
|
||||
addStandard("Save", KeyTable.XF86XK_Save);
|
||||
addStandard("SpellCheck", KeyTable.XF86XK_Spell);
|
||||
|
||||
// 3.11. Multimedia Numpad Keys
|
||||
|
||||
// - Key11
|
||||
// - Key12
|
||||
|
||||
// 3.12. Audio Keys
|
||||
|
||||
// - AudioBalanceLeft
|
||||
// - AudioBalanceRight
|
||||
// - AudioBassBoostDown
|
||||
// - AudioBassBoostToggle
|
||||
// - AudioBassBoostUp
|
||||
// - AudioFaderFront
|
||||
// - AudioFaderRear
|
||||
// - AudioSurroundModeNext
|
||||
// - AudioTrebleDown
|
||||
// - AudioTrebleUp
|
||||
addStandard("AudioVolumeDown", KeyTable.XF86XK_AudioLowerVolume);
|
||||
addStandard("AudioVolumeUp", KeyTable.XF86XK_AudioRaiseVolume);
|
||||
addStandard("AudioVolumeMute", KeyTable.XF86XK_AudioMute);
|
||||
// - MicrophoneToggle
|
||||
// - MicrophoneVolumeDown
|
||||
// - MicrophoneVolumeUp
|
||||
addStandard("MicrophoneVolumeMute", KeyTable.XF86XK_AudioMicMute);
|
||||
|
||||
// 3.13. Speech Keys
|
||||
|
||||
// - SpeechCorrectionList
|
||||
// - SpeechInputToggle
|
||||
|
||||
// 3.14. Application Keys
|
||||
|
||||
addStandard("LaunchApplication1", KeyTable.XF86XK_MyComputer);
|
||||
addStandard("LaunchApplication2", KeyTable.XF86XK_Calculator);
|
||||
addStandard("LaunchCalendar", KeyTable.XF86XK_Calendar);
|
||||
// - LaunchContacts
|
||||
addStandard("LaunchMail", KeyTable.XF86XK_Mail);
|
||||
addStandard("LaunchMediaPlayer", KeyTable.XF86XK_AudioMedia);
|
||||
addStandard("LaunchMusicPlayer", KeyTable.XF86XK_Music);
|
||||
addStandard("LaunchPhone", KeyTable.XF86XK_Phone);
|
||||
addStandard("LaunchScreenSaver", KeyTable.XF86XK_ScreenSaver);
|
||||
addStandard("LaunchSpreadsheet", KeyTable.XF86XK_Excel);
|
||||
addStandard("LaunchWebBrowser", KeyTable.XF86XK_WWW);
|
||||
addStandard("LaunchWebCam", KeyTable.XF86XK_WebCam);
|
||||
addStandard("LaunchWordProcessor", KeyTable.XF86XK_Word);
|
||||
|
||||
// 3.15. Browser Keys
|
||||
|
||||
addStandard("BrowserBack", KeyTable.XF86XK_Back);
|
||||
addStandard("BrowserFavorites", KeyTable.XF86XK_Favorites);
|
||||
addStandard("BrowserForward", KeyTable.XF86XK_Forward);
|
||||
addStandard("BrowserHome", KeyTable.XF86XK_HomePage);
|
||||
addStandard("BrowserRefresh", KeyTable.XF86XK_Refresh);
|
||||
addStandard("BrowserSearch", KeyTable.XF86XK_Search);
|
||||
addStandard("BrowserStop", KeyTable.XF86XK_Stop);
|
||||
|
||||
// 3.16. Mobile Phone Keys
|
||||
|
||||
// - A whole bunch...
|
||||
|
||||
// 3.17. TV Keys
|
||||
|
||||
// - A whole bunch...
|
||||
|
||||
// 3.18. Media Controller Keys
|
||||
|
||||
// - A whole bunch...
|
||||
addStandard("Dimmer", KeyTable.XF86XK_BrightnessAdjust);
|
||||
addStandard("MediaAudioTrack", KeyTable.XF86XK_AudioCycleTrack);
|
||||
addStandard("RandomToggle", KeyTable.XF86XK_AudioRandomPlay);
|
||||
addStandard("SplitScreenToggle", KeyTable.XF86XK_SplitScreen);
|
||||
addStandard("Subtitle", KeyTable.XF86XK_Subtitle);
|
||||
addStandard("VideoModeNext", KeyTable.XF86XK_Next_VMode);
|
||||
|
||||
// Extra: Numpad
|
||||
|
||||
addNumpad("=", KeyTable.XK_equal, KeyTable.XK_KP_Equal);
|
||||
addNumpad("+", KeyTable.XK_plus, KeyTable.XK_KP_Add);
|
||||
addNumpad("-", KeyTable.XK_minus, KeyTable.XK_KP_Subtract);
|
||||
addNumpad("*", KeyTable.XK_asterisk, KeyTable.XK_KP_Multiply);
|
||||
addNumpad("/", KeyTable.XK_slash, KeyTable.XK_KP_Divide);
|
||||
addNumpad(".", KeyTable.XK_period, KeyTable.XK_KP_Decimal);
|
||||
addNumpad(",", KeyTable.XK_comma, KeyTable.XK_KP_Separator);
|
||||
addNumpad("0", KeyTable.XK_0, KeyTable.XK_KP_0);
|
||||
addNumpad("1", KeyTable.XK_1, KeyTable.XK_KP_1);
|
||||
addNumpad("2", KeyTable.XK_2, KeyTable.XK_KP_2);
|
||||
addNumpad("3", KeyTable.XK_3, KeyTable.XK_KP_3);
|
||||
addNumpad("4", KeyTable.XK_4, KeyTable.XK_KP_4);
|
||||
addNumpad("5", KeyTable.XK_5, KeyTable.XK_KP_5);
|
||||
addNumpad("6", KeyTable.XK_6, KeyTable.XK_KP_6);
|
||||
addNumpad("7", KeyTable.XK_7, KeyTable.XK_KP_7);
|
||||
addNumpad("8", KeyTable.XK_8, KeyTable.XK_KP_8);
|
||||
addNumpad("9", KeyTable.XK_9, KeyTable.XK_KP_9);
|
||||
|
||||
export default DOMKeyTable;
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2018 The noVNC authors
|
||||
* Licensed under MPL 2.0 or any later version (see LICENSE.txt)
|
||||
*/
|
||||
|
||||
/*
|
||||
* Fallback mapping between HTML key codes (physical keys) and
|
||||
* HTML key values. This only works for keys that don't vary
|
||||
* between layouts. We also omit those who manage fine by mapping the
|
||||
* Unicode representation.
|
||||
*
|
||||
* See https://www.w3.org/TR/uievents-code/ for possible codes.
|
||||
* See https://www.w3.org/TR/uievents-key/ for possible values.
|
||||
*/
|
||||
|
||||
/* eslint-disable key-spacing */
|
||||
|
||||
export default {
|
||||
|
||||
// 3.1.1.1. Writing System Keys
|
||||
|
||||
'Backspace': 'Backspace',
|
||||
|
||||
// 3.1.1.2. Functional Keys
|
||||
|
||||
'AltLeft': 'Alt',
|
||||
'AltRight': 'Alt', // This could also be 'AltGraph'
|
||||
'CapsLock': 'CapsLock',
|
||||
'ContextMenu': 'ContextMenu',
|
||||
'ControlLeft': 'Control',
|
||||
'ControlRight': 'Control',
|
||||
'Enter': 'Enter',
|
||||
'MetaLeft': 'Meta',
|
||||
'MetaRight': 'Meta',
|
||||
'ShiftLeft': 'Shift',
|
||||
'ShiftRight': 'Shift',
|
||||
'Tab': 'Tab',
|
||||
// FIXME: Japanese/Korean keys
|
||||
|
||||
// 3.1.2. Control Pad Section
|
||||
|
||||
'Delete': 'Delete',
|
||||
'End': 'End',
|
||||
'Help': 'Help',
|
||||
'Home': 'Home',
|
||||
'Insert': 'Insert',
|
||||
'PageDown': 'PageDown',
|
||||
'PageUp': 'PageUp',
|
||||
|
||||
// 3.1.3. Arrow Pad Section
|
||||
|
||||
'ArrowDown': 'ArrowDown',
|
||||
'ArrowLeft': 'ArrowLeft',
|
||||
'ArrowRight': 'ArrowRight',
|
||||
'ArrowUp': 'ArrowUp',
|
||||
|
||||
// 3.1.4. Numpad Section
|
||||
|
||||
'NumLock': 'NumLock',
|
||||
'NumpadBackspace': 'Backspace',
|
||||
'NumpadClear': 'Clear',
|
||||
|
||||
// 3.1.5. Function Section
|
||||
|
||||
'Escape': 'Escape',
|
||||
'F1': 'F1',
|
||||
'F2': 'F2',
|
||||
'F3': 'F3',
|
||||
'F4': 'F4',
|
||||
'F5': 'F5',
|
||||
'F6': 'F6',
|
||||
'F7': 'F7',
|
||||
'F8': 'F8',
|
||||
'F9': 'F9',
|
||||
'F10': 'F10',
|
||||
'F11': 'F11',
|
||||
'F12': 'F12',
|
||||
'F13': 'F13',
|
||||
'F14': 'F14',
|
||||
'F15': 'F15',
|
||||
'F16': 'F16',
|
||||
'F17': 'F17',
|
||||
'F18': 'F18',
|
||||
'F19': 'F19',
|
||||
'F20': 'F20',
|
||||
'F21': 'F21',
|
||||
'F22': 'F22',
|
||||
'F23': 'F23',
|
||||
'F24': 'F24',
|
||||
'F25': 'F25',
|
||||
'F26': 'F26',
|
||||
'F27': 'F27',
|
||||
'F28': 'F28',
|
||||
'F29': 'F29',
|
||||
'F30': 'F30',
|
||||
'F31': 'F31',
|
||||
'F32': 'F32',
|
||||
'F33': 'F33',
|
||||
'F34': 'F34',
|
||||
'F35': 'F35',
|
||||
'PrintScreen': 'PrintScreen',
|
||||
'ScrollLock': 'ScrollLock',
|
||||
'Pause': 'Pause',
|
||||
|
||||
// 3.1.6. Media Keys
|
||||
|
||||
'BrowserBack': 'BrowserBack',
|
||||
'BrowserFavorites': 'BrowserFavorites',
|
||||
'BrowserForward': 'BrowserForward',
|
||||
'BrowserHome': 'BrowserHome',
|
||||
'BrowserRefresh': 'BrowserRefresh',
|
||||
'BrowserSearch': 'BrowserSearch',
|
||||
'BrowserStop': 'BrowserStop',
|
||||
'Eject': 'Eject',
|
||||
'LaunchApp1': 'LaunchMyComputer',
|
||||
'LaunchApp2': 'LaunchCalendar',
|
||||
'LaunchMail': 'LaunchMail',
|
||||
'MediaPlayPause': 'MediaPlay',
|
||||
'MediaStop': 'MediaStop',
|
||||
'MediaTrackNext': 'MediaTrackNext',
|
||||
'MediaTrackPrevious': 'MediaTrackPrevious',
|
||||
'Power': 'Power',
|
||||
'Sleep': 'Sleep',
|
||||
'AudioVolumeDown': 'AudioVolumeDown',
|
||||
'AudioVolumeMute': 'AudioVolumeMute',
|
||||
'AudioVolumeUp': 'AudioVolumeUp',
|
||||
'WakeUp': 'WakeUp',
|
||||
};
|
||||
Vendored
+567
@@ -0,0 +1,567 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2020 The noVNC authors
|
||||
* Licensed under MPL 2.0 (see LICENSE.txt)
|
||||
*
|
||||
* See README.md for usage and integration instructions.
|
||||
*
|
||||
*/
|
||||
|
||||
const GH_NOGESTURE = 0;
|
||||
const GH_ONETAP = 1;
|
||||
const GH_TWOTAP = 2;
|
||||
const GH_THREETAP = 4;
|
||||
const GH_DRAG = 8;
|
||||
const GH_LONGPRESS = 16;
|
||||
const GH_TWODRAG = 32;
|
||||
const GH_PINCH = 64;
|
||||
|
||||
const GH_INITSTATE = 127;
|
||||
|
||||
const GH_MOVE_THRESHOLD = 50;
|
||||
const GH_ANGLE_THRESHOLD = 90; // Degrees
|
||||
|
||||
// Timeout when waiting for gestures (ms)
|
||||
const GH_MULTITOUCH_TIMEOUT = 250;
|
||||
|
||||
// Maximum time between press and release for a tap (ms)
|
||||
const GH_TAP_TIMEOUT = 1000;
|
||||
|
||||
// Timeout when waiting for longpress (ms)
|
||||
const GH_LONGPRESS_TIMEOUT = 1000;
|
||||
|
||||
// Timeout when waiting to decide between PINCH and TWODRAG (ms)
|
||||
const GH_TWOTOUCH_TIMEOUT = 50;
|
||||
|
||||
export default class GestureHandler {
|
||||
constructor() {
|
||||
this._target = null;
|
||||
|
||||
this._state = GH_INITSTATE;
|
||||
|
||||
this._tracked = [];
|
||||
this._ignored = [];
|
||||
|
||||
this._waitingRelease = false;
|
||||
this._releaseStart = 0.0;
|
||||
|
||||
this._longpressTimeoutId = null;
|
||||
this._twoTouchTimeoutId = null;
|
||||
|
||||
this._boundEventHandler = this._eventHandler.bind(this);
|
||||
}
|
||||
|
||||
attach(target) {
|
||||
this.detach();
|
||||
|
||||
this._target = target;
|
||||
this._target.addEventListener('touchstart',
|
||||
this._boundEventHandler);
|
||||
this._target.addEventListener('touchmove',
|
||||
this._boundEventHandler);
|
||||
this._target.addEventListener('touchend',
|
||||
this._boundEventHandler);
|
||||
this._target.addEventListener('touchcancel',
|
||||
this._boundEventHandler);
|
||||
}
|
||||
|
||||
detach() {
|
||||
if (!this._target) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._stopLongpressTimeout();
|
||||
this._stopTwoTouchTimeout();
|
||||
|
||||
this._target.removeEventListener('touchstart',
|
||||
this._boundEventHandler);
|
||||
this._target.removeEventListener('touchmove',
|
||||
this._boundEventHandler);
|
||||
this._target.removeEventListener('touchend',
|
||||
this._boundEventHandler);
|
||||
this._target.removeEventListener('touchcancel',
|
||||
this._boundEventHandler);
|
||||
this._target = null;
|
||||
}
|
||||
|
||||
_eventHandler(e) {
|
||||
let fn;
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
switch (e.type) {
|
||||
case 'touchstart':
|
||||
fn = this._touchStart;
|
||||
break;
|
||||
case 'touchmove':
|
||||
fn = this._touchMove;
|
||||
break;
|
||||
case 'touchend':
|
||||
case 'touchcancel':
|
||||
fn = this._touchEnd;
|
||||
break;
|
||||
}
|
||||
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
let touch = e.changedTouches[i];
|
||||
fn.call(this, touch.identifier, touch.clientX, touch.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
_touchStart(id, x, y) {
|
||||
// Ignore any new touches if there is already an active gesture,
|
||||
// or we're in a cleanup state
|
||||
if (this._hasDetectedGesture() || (this._state === GH_NOGESTURE)) {
|
||||
this._ignored.push(id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Did it take too long between touches that we should no longer
|
||||
// consider this a single gesture?
|
||||
if ((this._tracked.length > 0) &&
|
||||
((Date.now() - this._tracked[0].started) > GH_MULTITOUCH_TIMEOUT)) {
|
||||
this._state = GH_NOGESTURE;
|
||||
this._ignored.push(id);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're waiting for fingers to release then we should no longer
|
||||
// recognize new touches
|
||||
if (this._waitingRelease) {
|
||||
this._state = GH_NOGESTURE;
|
||||
this._ignored.push(id);
|
||||
return;
|
||||
}
|
||||
|
||||
this._tracked.push({
|
||||
id: id,
|
||||
started: Date.now(),
|
||||
active: true,
|
||||
firstX: x,
|
||||
firstY: y,
|
||||
lastX: x,
|
||||
lastY: y,
|
||||
angle: 0
|
||||
});
|
||||
|
||||
switch (this._tracked.length) {
|
||||
case 1:
|
||||
this._startLongpressTimeout();
|
||||
break;
|
||||
|
||||
case 2:
|
||||
this._state &= ~(GH_ONETAP | GH_DRAG | GH_LONGPRESS);
|
||||
this._stopLongpressTimeout();
|
||||
break;
|
||||
|
||||
case 3:
|
||||
this._state &= ~(GH_TWOTAP | GH_TWODRAG | GH_PINCH);
|
||||
break;
|
||||
|
||||
default:
|
||||
this._state = GH_NOGESTURE;
|
||||
}
|
||||
}
|
||||
|
||||
_touchMove(id, x, y) {
|
||||
let touch = this._tracked.find(t => t.id === id);
|
||||
|
||||
// If this is an update for a touch we're not tracking, ignore it
|
||||
if (touch === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the touches last position with the event coordinates
|
||||
touch.lastX = x;
|
||||
touch.lastY = y;
|
||||
|
||||
let deltaX = x - touch.firstX;
|
||||
let deltaY = y - touch.firstY;
|
||||
|
||||
// Update angle when the touch has moved
|
||||
if ((touch.firstX !== touch.lastX) ||
|
||||
(touch.firstY !== touch.lastY)) {
|
||||
touch.angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI;
|
||||
}
|
||||
|
||||
if (!this._hasDetectedGesture()) {
|
||||
// Ignore moves smaller than the minimum threshold
|
||||
if (Math.hypot(deltaX, deltaY) < GH_MOVE_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't be a tap or long press as we've seen movement
|
||||
this._state &= ~(GH_ONETAP | GH_TWOTAP | GH_THREETAP | GH_LONGPRESS);
|
||||
this._stopLongpressTimeout();
|
||||
|
||||
if (this._tracked.length !== 1) {
|
||||
this._state &= ~(GH_DRAG);
|
||||
}
|
||||
if (this._tracked.length !== 2) {
|
||||
this._state &= ~(GH_TWODRAG | GH_PINCH);
|
||||
}
|
||||
|
||||
// We need to figure out which of our different two touch gestures
|
||||
// this might be
|
||||
if (this._tracked.length === 2) {
|
||||
|
||||
// The other touch is the one where the id doesn't match
|
||||
let prevTouch = this._tracked.find(t => t.id !== id);
|
||||
|
||||
// How far the previous touch point has moved since start
|
||||
let prevDeltaMove = Math.hypot(prevTouch.firstX - prevTouch.lastX,
|
||||
prevTouch.firstY - prevTouch.lastY);
|
||||
|
||||
// We know that the current touch moved far enough,
|
||||
// but unless both touches moved further than their
|
||||
// threshold we don't want to disqualify any gestures
|
||||
if (prevDeltaMove > GH_MOVE_THRESHOLD) {
|
||||
|
||||
// The angle difference between the direction of the touch points
|
||||
let deltaAngle = Math.abs(touch.angle - prevTouch.angle);
|
||||
deltaAngle = Math.abs(((deltaAngle + 180) % 360) - 180);
|
||||
|
||||
// PINCH or TWODRAG can be eliminated depending on the angle
|
||||
if (deltaAngle > GH_ANGLE_THRESHOLD) {
|
||||
this._state &= ~GH_TWODRAG;
|
||||
} else {
|
||||
this._state &= ~GH_PINCH;
|
||||
}
|
||||
|
||||
if (this._isTwoTouchTimeoutRunning()) {
|
||||
this._stopTwoTouchTimeout();
|
||||
}
|
||||
} else if (!this._isTwoTouchTimeoutRunning()) {
|
||||
// We can't determine the gesture right now, let's
|
||||
// wait and see if more events are on their way
|
||||
this._startTwoTouchTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._hasDetectedGesture()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._pushEvent('gesturestart');
|
||||
}
|
||||
|
||||
this._pushEvent('gesturemove');
|
||||
}
|
||||
|
||||
_touchEnd(id, x, y) {
|
||||
// Check if this is an ignored touch
|
||||
if (this._ignored.indexOf(id) !== -1) {
|
||||
// Remove this touch from ignored
|
||||
this._ignored.splice(this._ignored.indexOf(id), 1);
|
||||
|
||||
// And reset the state if there are no more touches
|
||||
if ((this._ignored.length === 0) &&
|
||||
(this._tracked.length === 0)) {
|
||||
this._state = GH_INITSTATE;
|
||||
this._waitingRelease = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// We got a touchend before the timer triggered,
|
||||
// this cannot result in a gesture anymore.
|
||||
if (!this._hasDetectedGesture() &&
|
||||
this._isTwoTouchTimeoutRunning()) {
|
||||
this._stopTwoTouchTimeout();
|
||||
this._state = GH_NOGESTURE;
|
||||
}
|
||||
|
||||
// Some gestures don't trigger until a touch is released
|
||||
if (!this._hasDetectedGesture()) {
|
||||
// Can't be a gesture that relies on movement
|
||||
this._state &= ~(GH_DRAG | GH_TWODRAG | GH_PINCH);
|
||||
// Or something that relies on more time
|
||||
this._state &= ~GH_LONGPRESS;
|
||||
this._stopLongpressTimeout();
|
||||
|
||||
if (!this._waitingRelease) {
|
||||
this._releaseStart = Date.now();
|
||||
this._waitingRelease = true;
|
||||
|
||||
// Can't be a tap that requires more touches than we current have
|
||||
switch (this._tracked.length) {
|
||||
case 1:
|
||||
this._state &= ~(GH_TWOTAP | GH_THREETAP);
|
||||
break;
|
||||
|
||||
case 2:
|
||||
this._state &= ~(GH_ONETAP | GH_THREETAP);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Waiting for all touches to release? (i.e. some tap)
|
||||
if (this._waitingRelease) {
|
||||
// Were all touches released at roughly the same time?
|
||||
if ((Date.now() - this._releaseStart) > GH_MULTITOUCH_TIMEOUT) {
|
||||
this._state = GH_NOGESTURE;
|
||||
}
|
||||
|
||||
// Did too long time pass between press and release?
|
||||
if (this._tracked.some(t => (Date.now() - t.started) > GH_TAP_TIMEOUT)) {
|
||||
this._state = GH_NOGESTURE;
|
||||
}
|
||||
|
||||
let touch = this._tracked.find(t => t.id === id);
|
||||
touch.active = false;
|
||||
|
||||
// Are we still waiting for more releases?
|
||||
if (this._hasDetectedGesture()) {
|
||||
this._pushEvent('gesturestart');
|
||||
} else {
|
||||
// Have we reached a dead end?
|
||||
if (this._state !== GH_NOGESTURE) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this._hasDetectedGesture()) {
|
||||
this._pushEvent('gestureend');
|
||||
}
|
||||
|
||||
// Ignore any remaining touches until they are ended
|
||||
for (let i = 0; i < this._tracked.length; i++) {
|
||||
if (this._tracked[i].active) {
|
||||
this._ignored.push(this._tracked[i].id);
|
||||
}
|
||||
}
|
||||
this._tracked = [];
|
||||
|
||||
this._state = GH_NOGESTURE;
|
||||
|
||||
// Remove this touch from ignored if it's in there
|
||||
if (this._ignored.indexOf(id) !== -1) {
|
||||
this._ignored.splice(this._ignored.indexOf(id), 1);
|
||||
}
|
||||
|
||||
// We reset the state if ignored is empty
|
||||
if ((this._ignored.length === 0)) {
|
||||
this._state = GH_INITSTATE;
|
||||
this._waitingRelease = false;
|
||||
}
|
||||
}
|
||||
|
||||
_hasDetectedGesture() {
|
||||
if (this._state === GH_NOGESTURE) {
|
||||
return false;
|
||||
}
|
||||
// Check to see if the bitmask value is a power of 2
|
||||
// (i.e. only one bit set). If it is, we have a state.
|
||||
if (this._state & (this._state - 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For taps we also need to have all touches released
|
||||
// before we've fully detected the gesture
|
||||
if (this._state & (GH_ONETAP | GH_TWOTAP | GH_THREETAP)) {
|
||||
if (this._tracked.some(t => t.active)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_startLongpressTimeout() {
|
||||
this._stopLongpressTimeout();
|
||||
this._longpressTimeoutId = setTimeout(() => this._longpressTimeout(),
|
||||
GH_LONGPRESS_TIMEOUT);
|
||||
}
|
||||
|
||||
_stopLongpressTimeout() {
|
||||
clearTimeout(this._longpressTimeoutId);
|
||||
this._longpressTimeoutId = null;
|
||||
}
|
||||
|
||||
_longpressTimeout() {
|
||||
if (this._hasDetectedGesture()) {
|
||||
throw new Error("A longpress gesture failed, conflict with a different gesture");
|
||||
}
|
||||
|
||||
this._state = GH_LONGPRESS;
|
||||
this._pushEvent('gesturestart');
|
||||
}
|
||||
|
||||
_startTwoTouchTimeout() {
|
||||
this._stopTwoTouchTimeout();
|
||||
this._twoTouchTimeoutId = setTimeout(() => this._twoTouchTimeout(),
|
||||
GH_TWOTOUCH_TIMEOUT);
|
||||
}
|
||||
|
||||
_stopTwoTouchTimeout() {
|
||||
clearTimeout(this._twoTouchTimeoutId);
|
||||
this._twoTouchTimeoutId = null;
|
||||
}
|
||||
|
||||
_isTwoTouchTimeoutRunning() {
|
||||
return this._twoTouchTimeoutId !== null;
|
||||
}
|
||||
|
||||
_twoTouchTimeout() {
|
||||
if (this._tracked.length === 0) {
|
||||
throw new Error("A pinch or two drag gesture failed, no tracked touches");
|
||||
}
|
||||
|
||||
// How far each touch point has moved since start
|
||||
let avgM = this._getAverageMovement();
|
||||
let avgMoveH = Math.abs(avgM.x);
|
||||
let avgMoveV = Math.abs(avgM.y);
|
||||
|
||||
// The difference in the distance between where
|
||||
// the touch points started and where they are now
|
||||
let avgD = this._getAverageDistance();
|
||||
let deltaTouchDistance = Math.abs(Math.hypot(avgD.first.x, avgD.first.y) -
|
||||
Math.hypot(avgD.last.x, avgD.last.y));
|
||||
|
||||
if ((avgMoveV < deltaTouchDistance) &&
|
||||
(avgMoveH < deltaTouchDistance)) {
|
||||
this._state = GH_PINCH;
|
||||
} else {
|
||||
this._state = GH_TWODRAG;
|
||||
}
|
||||
|
||||
this._pushEvent('gesturestart');
|
||||
this._pushEvent('gesturemove');
|
||||
}
|
||||
|
||||
_pushEvent(type) {
|
||||
let detail = { type: this._stateToGesture(this._state) };
|
||||
|
||||
// For most gesture events the current (average) position is the
|
||||
// most useful
|
||||
let avg = this._getPosition();
|
||||
let pos = avg.last;
|
||||
|
||||
// However we have a slight distance to detect gestures, so for the
|
||||
// first gesture event we want to use the first positions we saw
|
||||
if (type === 'gesturestart') {
|
||||
pos = avg.first;
|
||||
}
|
||||
|
||||
// For these gestures, we always want the event coordinates
|
||||
// to be where the gesture began, not the current touch location.
|
||||
switch (this._state) {
|
||||
case GH_TWODRAG:
|
||||
case GH_PINCH:
|
||||
pos = avg.first;
|
||||
break;
|
||||
}
|
||||
|
||||
detail['clientX'] = pos.x;
|
||||
detail['clientY'] = pos.y;
|
||||
|
||||
// FIXME: other coordinates?
|
||||
|
||||
// Some gestures also have a magnitude
|
||||
if (this._state === GH_PINCH) {
|
||||
let distance = this._getAverageDistance();
|
||||
if (type === 'gesturestart') {
|
||||
detail['magnitudeX'] = distance.first.x;
|
||||
detail['magnitudeY'] = distance.first.y;
|
||||
} else {
|
||||
detail['magnitudeX'] = distance.last.x;
|
||||
detail['magnitudeY'] = distance.last.y;
|
||||
}
|
||||
} else if (this._state === GH_TWODRAG) {
|
||||
if (type === 'gesturestart') {
|
||||
detail['magnitudeX'] = 0.0;
|
||||
detail['magnitudeY'] = 0.0;
|
||||
} else {
|
||||
let movement = this._getAverageMovement();
|
||||
detail['magnitudeX'] = movement.x;
|
||||
detail['magnitudeY'] = movement.y;
|
||||
}
|
||||
}
|
||||
|
||||
let gev = new CustomEvent(type, { detail: detail });
|
||||
this._target.dispatchEvent(gev);
|
||||
}
|
||||
|
||||
_stateToGesture(state) {
|
||||
switch (state) {
|
||||
case GH_ONETAP:
|
||||
return 'onetap';
|
||||
case GH_TWOTAP:
|
||||
return 'twotap';
|
||||
case GH_THREETAP:
|
||||
return 'threetap';
|
||||
case GH_DRAG:
|
||||
return 'drag';
|
||||
case GH_LONGPRESS:
|
||||
return 'longpress';
|
||||
case GH_TWODRAG:
|
||||
return 'twodrag';
|
||||
case GH_PINCH:
|
||||
return 'pinch';
|
||||
}
|
||||
|
||||
throw new Error("Unknown gesture state: " + state);
|
||||
}
|
||||
|
||||
_getPosition() {
|
||||
if (this._tracked.length === 0) {
|
||||
throw new Error("Failed to get gesture position, no tracked touches");
|
||||
}
|
||||
|
||||
let size = this._tracked.length;
|
||||
let fx = 0, fy = 0, lx = 0, ly = 0;
|
||||
|
||||
for (let i = 0; i < this._tracked.length; i++) {
|
||||
fx += this._tracked[i].firstX;
|
||||
fy += this._tracked[i].firstY;
|
||||
lx += this._tracked[i].lastX;
|
||||
ly += this._tracked[i].lastY;
|
||||
}
|
||||
|
||||
return { first: { x: fx / size,
|
||||
y: fy / size },
|
||||
last: { x: lx / size,
|
||||
y: ly / size } };
|
||||
}
|
||||
|
||||
_getAverageMovement() {
|
||||
if (this._tracked.length === 0) {
|
||||
throw new Error("Failed to get gesture movement, no tracked touches");
|
||||
}
|
||||
|
||||
let totalH, totalV;
|
||||
totalH = totalV = 0;
|
||||
let size = this._tracked.length;
|
||||
|
||||
for (let i = 0; i < this._tracked.length; i++) {
|
||||
totalH += this._tracked[i].lastX - this._tracked[i].firstX;
|
||||
totalV += this._tracked[i].lastY - this._tracked[i].firstY;
|
||||
}
|
||||
|
||||
return { x: totalH / size,
|
||||
y: totalV / size };
|
||||
}
|
||||
|
||||
_getAverageDistance() {
|
||||
if (this._tracked.length === 0) {
|
||||
throw new Error("Failed to get gesture distance, no tracked touches");
|
||||
}
|
||||
|
||||
// Distance between the first and last tracked touches
|
||||
|
||||
let first = this._tracked[0];
|
||||
let last = this._tracked[this._tracked.length - 1];
|
||||
|
||||
let fdx = Math.abs(last.firstX - first.firstX);
|
||||
let fdy = Math.abs(last.firstY - first.firstY);
|
||||
|
||||
let ldx = Math.abs(last.lastX - first.lastX);
|
||||
let ldy = Math.abs(last.lastY - first.lastY);
|
||||
|
||||
return { first: { x: fdx, y: fdy },
|
||||
last: { x: ldx, y: ldy } };
|
||||
}
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2019 The noVNC authors
|
||||
* Licensed under MPL 2.0 or any later version (see LICENSE.txt)
|
||||
*/
|
||||
|
||||
import * as Log from '../util/logging.js';
|
||||
import { stopEvent } from '../util/events.js';
|
||||
import * as KeyboardUtil from "./util.js";
|
||||
import KeyTable from "./keysym.js";
|
||||
import * as browser from "../util/browser.js";
|
||||
|
||||
//
|
||||
// Keyboard event handler
|
||||
//
|
||||
|
||||
export default class Keyboard {
|
||||
constructor(target) {
|
||||
this._target = target || null;
|
||||
|
||||
this._keyDownList = {}; // List of depressed keys
|
||||
// (even if they are happy)
|
||||
this._altGrArmed = false; // Windows AltGr detection
|
||||
|
||||
// keep these here so we can refer to them later
|
||||
this._eventHandlers = {
|
||||
'keyup': this._handleKeyUp.bind(this),
|
||||
'keydown': this._handleKeyDown.bind(this),
|
||||
'blur': this._allKeysUp.bind(this),
|
||||
};
|
||||
|
||||
// ===== EVENT HANDLERS =====
|
||||
|
||||
this.onkeyevent = () => {}; // Handler for key press/release
|
||||
}
|
||||
|
||||
// ===== PRIVATE METHODS =====
|
||||
|
||||
_sendKeyEvent(keysym, code, down, numlock = null, capslock = null) {
|
||||
if (down) {
|
||||
this._keyDownList[code] = keysym;
|
||||
} else {
|
||||
// Do we really think this key is down?
|
||||
if (!(code in this._keyDownList)) {
|
||||
return;
|
||||
}
|
||||
delete this._keyDownList[code];
|
||||
}
|
||||
|
||||
Log.Debug("onkeyevent " + (down ? "down" : "up") +
|
||||
", keysym: " + keysym, ", code: " + code +
|
||||
", numlock: " + numlock + ", capslock: " + capslock);
|
||||
this.onkeyevent(keysym, code, down, numlock, capslock);
|
||||
}
|
||||
|
||||
_getKeyCode(e) {
|
||||
const code = KeyboardUtil.getKeycode(e);
|
||||
if (code !== 'Unidentified') {
|
||||
return code;
|
||||
}
|
||||
|
||||
// Unstable, but we don't have anything else to go on
|
||||
if (e.keyCode) {
|
||||
// 229 is used for composition events
|
||||
if (e.keyCode !== 229) {
|
||||
return 'Platform' + e.keyCode;
|
||||
}
|
||||
}
|
||||
|
||||
// A precursor to the final DOM3 standard. Unfortunately it
|
||||
// is not layout independent, so it is as bad as using keyCode
|
||||
if (e.keyIdentifier) {
|
||||
// Non-character key?
|
||||
if (e.keyIdentifier.substr(0, 2) !== 'U+') {
|
||||
return e.keyIdentifier;
|
||||
}
|
||||
|
||||
const codepoint = parseInt(e.keyIdentifier.substr(2), 16);
|
||||
const char = String.fromCharCode(codepoint).toUpperCase();
|
||||
|
||||
return 'Platform' + char.charCodeAt();
|
||||
}
|
||||
|
||||
return 'Unidentified';
|
||||
}
|
||||
|
||||
_handleKeyDown(e) {
|
||||
const code = this._getKeyCode(e);
|
||||
let keysym = KeyboardUtil.getKeysym(e);
|
||||
let numlock = e.getModifierState('NumLock');
|
||||
let capslock = e.getModifierState('CapsLock');
|
||||
|
||||
// getModifierState for NumLock is not supported on mac and ios and always returns false.
|
||||
// Set to null to indicate unknown/unsupported instead.
|
||||
if (browser.isMac() || browser.isIOS()) {
|
||||
numlock = null;
|
||||
}
|
||||
|
||||
// Windows doesn't have a proper AltGr, but handles it using
|
||||
// fake Ctrl+Alt. However the remote end might not be Windows,
|
||||
// so we need to merge those in to a single AltGr event. We
|
||||
// detect this case by seeing the two key events directly after
|
||||
// each other with a very short time between them (<50ms).
|
||||
if (this._altGrArmed) {
|
||||
this._altGrArmed = false;
|
||||
clearTimeout(this._altGrTimeout);
|
||||
|
||||
if ((code === "AltRight") &&
|
||||
((e.timeStamp - this._altGrCtrlTime) < 50)) {
|
||||
// FIXME: We fail to detect this if either Ctrl key is
|
||||
// first manually pressed as Windows then no
|
||||
// longer sends the fake Ctrl down event. It
|
||||
// does however happily send real Ctrl events
|
||||
// even when AltGr is already down. Some
|
||||
// browsers detect this for us though and set the
|
||||
// key to "AltGraph".
|
||||
keysym = KeyTable.XK_ISO_Level3_Shift;
|
||||
} else {
|
||||
this._sendKeyEvent(KeyTable.XK_Control_L, "ControlLeft", true, numlock, capslock);
|
||||
}
|
||||
}
|
||||
|
||||
// We cannot handle keys we cannot track, but we also need
|
||||
// to deal with virtual keyboards which omit key info
|
||||
if (code === 'Unidentified') {
|
||||
if (keysym) {
|
||||
// If it's a virtual keyboard then it should be
|
||||
// sufficient to just send press and release right
|
||||
// after each other
|
||||
this._sendKeyEvent(keysym, code, true, numlock, capslock);
|
||||
this._sendKeyEvent(keysym, code, false, numlock, capslock);
|
||||
}
|
||||
|
||||
stopEvent(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt behaves more like AltGraph on macOS, so shuffle the
|
||||
// keys around a bit to make things more sane for the remote
|
||||
// server. This method is used by RealVNC and TigerVNC (and
|
||||
// possibly others).
|
||||
if (browser.isMac() || browser.isIOS()) {
|
||||
switch (keysym) {
|
||||
case KeyTable.XK_Super_L:
|
||||
keysym = KeyTable.XK_Alt_L;
|
||||
break;
|
||||
case KeyTable.XK_Super_R:
|
||||
keysym = KeyTable.XK_Super_L;
|
||||
break;
|
||||
case KeyTable.XK_Alt_L:
|
||||
keysym = KeyTable.XK_Mode_switch;
|
||||
break;
|
||||
case KeyTable.XK_Alt_R:
|
||||
keysym = KeyTable.XK_ISO_Level3_Shift;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Is this key already pressed? If so, then we must use the
|
||||
// same keysym or we'll confuse the server
|
||||
if (code in this._keyDownList) {
|
||||
keysym = this._keyDownList[code];
|
||||
}
|
||||
|
||||
// macOS doesn't send proper key releases if a key is pressed
|
||||
// while meta is held down
|
||||
if ((browser.isMac() || browser.isIOS()) &&
|
||||
(e.metaKey && code !== 'MetaLeft' && code !== 'MetaRight')) {
|
||||
this._sendKeyEvent(keysym, code, true, numlock, capslock);
|
||||
this._sendKeyEvent(keysym, code, false, numlock, capslock);
|
||||
stopEvent(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// macOS doesn't send proper key events for modifiers, only
|
||||
// state change events. That gets extra confusing for CapsLock
|
||||
// which toggles on each press, but not on release. So pretend
|
||||
// it was a quick press and release of the button.
|
||||
if ((browser.isMac() || browser.isIOS()) && (code === 'CapsLock')) {
|
||||
this._sendKeyEvent(KeyTable.XK_Caps_Lock, 'CapsLock', true, numlock, capslock);
|
||||
this._sendKeyEvent(KeyTable.XK_Caps_Lock, 'CapsLock', false, numlock, capslock);
|
||||
stopEvent(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Windows doesn't send proper key releases for a bunch of
|
||||
// Japanese IM keys so we have to fake the release right away
|
||||
const jpBadKeys = [ KeyTable.XK_Zenkaku_Hankaku,
|
||||
KeyTable.XK_Eisu_toggle,
|
||||
KeyTable.XK_Katakana,
|
||||
KeyTable.XK_Hiragana,
|
||||
KeyTable.XK_Romaji ];
|
||||
if (browser.isWindows() && jpBadKeys.includes(keysym)) {
|
||||
this._sendKeyEvent(keysym, code, true, numlock, capslock);
|
||||
this._sendKeyEvent(keysym, code, false, numlock, capslock);
|
||||
stopEvent(e);
|
||||
return;
|
||||
}
|
||||
|
||||
stopEvent(e);
|
||||
|
||||
// Possible start of AltGr sequence? (see above)
|
||||
if ((code === "ControlLeft") && browser.isWindows() &&
|
||||
!("ControlLeft" in this._keyDownList)) {
|
||||
this._altGrArmed = true;
|
||||
this._altGrTimeout = setTimeout(this._interruptAltGrSequence.bind(this), 100);
|
||||
this._altGrCtrlTime = e.timeStamp;
|
||||
return;
|
||||
}
|
||||
|
||||
this._sendKeyEvent(keysym, code, true, numlock, capslock);
|
||||
}
|
||||
|
||||
_handleKeyUp(e) {
|
||||
stopEvent(e);
|
||||
|
||||
const code = this._getKeyCode(e);
|
||||
|
||||
// We can't get a release in the middle of an AltGr sequence, so
|
||||
// abort that detection
|
||||
this._interruptAltGrSequence();
|
||||
|
||||
// See comment in _handleKeyDown()
|
||||
if ((browser.isMac() || browser.isIOS()) && (code === 'CapsLock')) {
|
||||
this._sendKeyEvent(KeyTable.XK_Caps_Lock, 'CapsLock', true);
|
||||
this._sendKeyEvent(KeyTable.XK_Caps_Lock, 'CapsLock', false);
|
||||
return;
|
||||
}
|
||||
|
||||
this._sendKeyEvent(this._keyDownList[code], code, false);
|
||||
|
||||
// Windows has a rather nasty bug where it won't send key
|
||||
// release events for a Shift button if the other Shift is still
|
||||
// pressed
|
||||
if (browser.isWindows() && ((code === 'ShiftLeft') ||
|
||||
(code === 'ShiftRight'))) {
|
||||
if ('ShiftRight' in this._keyDownList) {
|
||||
this._sendKeyEvent(this._keyDownList['ShiftRight'],
|
||||
'ShiftRight', false);
|
||||
}
|
||||
if ('ShiftLeft' in this._keyDownList) {
|
||||
this._sendKeyEvent(this._keyDownList['ShiftLeft'],
|
||||
'ShiftLeft', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_interruptAltGrSequence() {
|
||||
if (this._altGrArmed) {
|
||||
this._altGrArmed = false;
|
||||
clearTimeout(this._altGrTimeout);
|
||||
this._sendKeyEvent(KeyTable.XK_Control_L, "ControlLeft", true);
|
||||
}
|
||||
}
|
||||
|
||||
_allKeysUp() {
|
||||
Log.Debug(">> Keyboard.allKeysUp");
|
||||
|
||||
// Prevent control key being processed after losing focus.
|
||||
this._interruptAltGrSequence();
|
||||
|
||||
for (let code in this._keyDownList) {
|
||||
this._sendKeyEvent(this._keyDownList[code], code, false);
|
||||
}
|
||||
Log.Debug("<< Keyboard.allKeysUp");
|
||||
}
|
||||
|
||||
// ===== PUBLIC METHODS =====
|
||||
|
||||
grab() {
|
||||
//Log.Debug(">> Keyboard.grab");
|
||||
|
||||
this._target.addEventListener('keydown', this._eventHandlers.keydown);
|
||||
this._target.addEventListener('keyup', this._eventHandlers.keyup);
|
||||
|
||||
// Release (key up) if window loses focus
|
||||
window.addEventListener('blur', this._eventHandlers.blur);
|
||||
|
||||
//Log.Debug("<< Keyboard.grab");
|
||||
}
|
||||
|
||||
ungrab() {
|
||||
//Log.Debug(">> Keyboard.ungrab");
|
||||
|
||||
this._target.removeEventListener('keydown', this._eventHandlers.keydown);
|
||||
this._target.removeEventListener('keyup', this._eventHandlers.keyup);
|
||||
window.removeEventListener('blur', this._eventHandlers.blur);
|
||||
|
||||
// Release (key up) all keys that are in a down state
|
||||
this._allKeysUp();
|
||||
|
||||
//Log.Debug(">> Keyboard.ungrab");
|
||||
}
|
||||
}
|
||||
+616
@@ -0,0 +1,616 @@
|
||||
/* eslint-disable key-spacing */
|
||||
|
||||
export default {
|
||||
XK_VoidSymbol: 0xffffff, /* Void symbol */
|
||||
|
||||
XK_BackSpace: 0xff08, /* Back space, back char */
|
||||
XK_Tab: 0xff09,
|
||||
XK_Linefeed: 0xff0a, /* Linefeed, LF */
|
||||
XK_Clear: 0xff0b,
|
||||
XK_Return: 0xff0d, /* Return, enter */
|
||||
XK_Pause: 0xff13, /* Pause, hold */
|
||||
XK_Scroll_Lock: 0xff14,
|
||||
XK_Sys_Req: 0xff15,
|
||||
XK_Escape: 0xff1b,
|
||||
XK_Delete: 0xffff, /* Delete, rubout */
|
||||
|
||||
/* International & multi-key character composition */
|
||||
|
||||
XK_Multi_key: 0xff20, /* Multi-key character compose */
|
||||
XK_Codeinput: 0xff37,
|
||||
XK_SingleCandidate: 0xff3c,
|
||||
XK_MultipleCandidate: 0xff3d,
|
||||
XK_PreviousCandidate: 0xff3e,
|
||||
|
||||
/* Japanese keyboard support */
|
||||
|
||||
XK_Kanji: 0xff21, /* Kanji, Kanji convert */
|
||||
XK_Muhenkan: 0xff22, /* Cancel Conversion */
|
||||
XK_Henkan_Mode: 0xff23, /* Start/Stop Conversion */
|
||||
XK_Henkan: 0xff23, /* Alias for Henkan_Mode */
|
||||
XK_Romaji: 0xff24, /* to Romaji */
|
||||
XK_Hiragana: 0xff25, /* to Hiragana */
|
||||
XK_Katakana: 0xff26, /* to Katakana */
|
||||
XK_Hiragana_Katakana: 0xff27, /* Hiragana/Katakana toggle */
|
||||
XK_Zenkaku: 0xff28, /* to Zenkaku */
|
||||
XK_Hankaku: 0xff29, /* to Hankaku */
|
||||
XK_Zenkaku_Hankaku: 0xff2a, /* Zenkaku/Hankaku toggle */
|
||||
XK_Touroku: 0xff2b, /* Add to Dictionary */
|
||||
XK_Massyo: 0xff2c, /* Delete from Dictionary */
|
||||
XK_Kana_Lock: 0xff2d, /* Kana Lock */
|
||||
XK_Kana_Shift: 0xff2e, /* Kana Shift */
|
||||
XK_Eisu_Shift: 0xff2f, /* Alphanumeric Shift */
|
||||
XK_Eisu_toggle: 0xff30, /* Alphanumeric toggle */
|
||||
XK_Kanji_Bangou: 0xff37, /* Codeinput */
|
||||
XK_Zen_Koho: 0xff3d, /* Multiple/All Candidate(s) */
|
||||
XK_Mae_Koho: 0xff3e, /* Previous Candidate */
|
||||
|
||||
/* Cursor control & motion */
|
||||
|
||||
XK_Home: 0xff50,
|
||||
XK_Left: 0xff51, /* Move left, left arrow */
|
||||
XK_Up: 0xff52, /* Move up, up arrow */
|
||||
XK_Right: 0xff53, /* Move right, right arrow */
|
||||
XK_Down: 0xff54, /* Move down, down arrow */
|
||||
XK_Prior: 0xff55, /* Prior, previous */
|
||||
XK_Page_Up: 0xff55,
|
||||
XK_Next: 0xff56, /* Next */
|
||||
XK_Page_Down: 0xff56,
|
||||
XK_End: 0xff57, /* EOL */
|
||||
XK_Begin: 0xff58, /* BOL */
|
||||
|
||||
|
||||
/* Misc functions */
|
||||
|
||||
XK_Select: 0xff60, /* Select, mark */
|
||||
XK_Print: 0xff61,
|
||||
XK_Execute: 0xff62, /* Execute, run, do */
|
||||
XK_Insert: 0xff63, /* Insert, insert here */
|
||||
XK_Undo: 0xff65,
|
||||
XK_Redo: 0xff66, /* Redo, again */
|
||||
XK_Menu: 0xff67,
|
||||
XK_Find: 0xff68, /* Find, search */
|
||||
XK_Cancel: 0xff69, /* Cancel, stop, abort, exit */
|
||||
XK_Help: 0xff6a, /* Help */
|
||||
XK_Break: 0xff6b,
|
||||
XK_Mode_switch: 0xff7e, /* Character set switch */
|
||||
XK_script_switch: 0xff7e, /* Alias for mode_switch */
|
||||
XK_Num_Lock: 0xff7f,
|
||||
|
||||
/* Keypad functions, keypad numbers cleverly chosen to map to ASCII */
|
||||
|
||||
XK_KP_Space: 0xff80, /* Space */
|
||||
XK_KP_Tab: 0xff89,
|
||||
XK_KP_Enter: 0xff8d, /* Enter */
|
||||
XK_KP_F1: 0xff91, /* PF1, KP_A, ... */
|
||||
XK_KP_F2: 0xff92,
|
||||
XK_KP_F3: 0xff93,
|
||||
XK_KP_F4: 0xff94,
|
||||
XK_KP_Home: 0xff95,
|
||||
XK_KP_Left: 0xff96,
|
||||
XK_KP_Up: 0xff97,
|
||||
XK_KP_Right: 0xff98,
|
||||
XK_KP_Down: 0xff99,
|
||||
XK_KP_Prior: 0xff9a,
|
||||
XK_KP_Page_Up: 0xff9a,
|
||||
XK_KP_Next: 0xff9b,
|
||||
XK_KP_Page_Down: 0xff9b,
|
||||
XK_KP_End: 0xff9c,
|
||||
XK_KP_Begin: 0xff9d,
|
||||
XK_KP_Insert: 0xff9e,
|
||||
XK_KP_Delete: 0xff9f,
|
||||
XK_KP_Equal: 0xffbd, /* Equals */
|
||||
XK_KP_Multiply: 0xffaa,
|
||||
XK_KP_Add: 0xffab,
|
||||
XK_KP_Separator: 0xffac, /* Separator, often comma */
|
||||
XK_KP_Subtract: 0xffad,
|
||||
XK_KP_Decimal: 0xffae,
|
||||
XK_KP_Divide: 0xffaf,
|
||||
|
||||
XK_KP_0: 0xffb0,
|
||||
XK_KP_1: 0xffb1,
|
||||
XK_KP_2: 0xffb2,
|
||||
XK_KP_3: 0xffb3,
|
||||
XK_KP_4: 0xffb4,
|
||||
XK_KP_5: 0xffb5,
|
||||
XK_KP_6: 0xffb6,
|
||||
XK_KP_7: 0xffb7,
|
||||
XK_KP_8: 0xffb8,
|
||||
XK_KP_9: 0xffb9,
|
||||
|
||||
/*
|
||||
* Auxiliary functions; note the duplicate definitions for left and right
|
||||
* function keys; Sun keyboards and a few other manufacturers have such
|
||||
* function key groups on the left and/or right sides of the keyboard.
|
||||
* We've not found a keyboard with more than 35 function keys total.
|
||||
*/
|
||||
|
||||
XK_F1: 0xffbe,
|
||||
XK_F2: 0xffbf,
|
||||
XK_F3: 0xffc0,
|
||||
XK_F4: 0xffc1,
|
||||
XK_F5: 0xffc2,
|
||||
XK_F6: 0xffc3,
|
||||
XK_F7: 0xffc4,
|
||||
XK_F8: 0xffc5,
|
||||
XK_F9: 0xffc6,
|
||||
XK_F10: 0xffc7,
|
||||
XK_F11: 0xffc8,
|
||||
XK_L1: 0xffc8,
|
||||
XK_F12: 0xffc9,
|
||||
XK_L2: 0xffc9,
|
||||
XK_F13: 0xffca,
|
||||
XK_L3: 0xffca,
|
||||
XK_F14: 0xffcb,
|
||||
XK_L4: 0xffcb,
|
||||
XK_F15: 0xffcc,
|
||||
XK_L5: 0xffcc,
|
||||
XK_F16: 0xffcd,
|
||||
XK_L6: 0xffcd,
|
||||
XK_F17: 0xffce,
|
||||
XK_L7: 0xffce,
|
||||
XK_F18: 0xffcf,
|
||||
XK_L8: 0xffcf,
|
||||
XK_F19: 0xffd0,
|
||||
XK_L9: 0xffd0,
|
||||
XK_F20: 0xffd1,
|
||||
XK_L10: 0xffd1,
|
||||
XK_F21: 0xffd2,
|
||||
XK_R1: 0xffd2,
|
||||
XK_F22: 0xffd3,
|
||||
XK_R2: 0xffd3,
|
||||
XK_F23: 0xffd4,
|
||||
XK_R3: 0xffd4,
|
||||
XK_F24: 0xffd5,
|
||||
XK_R4: 0xffd5,
|
||||
XK_F25: 0xffd6,
|
||||
XK_R5: 0xffd6,
|
||||
XK_F26: 0xffd7,
|
||||
XK_R6: 0xffd7,
|
||||
XK_F27: 0xffd8,
|
||||
XK_R7: 0xffd8,
|
||||
XK_F28: 0xffd9,
|
||||
XK_R8: 0xffd9,
|
||||
XK_F29: 0xffda,
|
||||
XK_R9: 0xffda,
|
||||
XK_F30: 0xffdb,
|
||||
XK_R10: 0xffdb,
|
||||
XK_F31: 0xffdc,
|
||||
XK_R11: 0xffdc,
|
||||
XK_F32: 0xffdd,
|
||||
XK_R12: 0xffdd,
|
||||
XK_F33: 0xffde,
|
||||
XK_R13: 0xffde,
|
||||
XK_F34: 0xffdf,
|
||||
XK_R14: 0xffdf,
|
||||
XK_F35: 0xffe0,
|
||||
XK_R15: 0xffe0,
|
||||
|
||||
/* Modifiers */
|
||||
|
||||
XK_Shift_L: 0xffe1, /* Left shift */
|
||||
XK_Shift_R: 0xffe2, /* Right shift */
|
||||
XK_Control_L: 0xffe3, /* Left control */
|
||||
XK_Control_R: 0xffe4, /* Right control */
|
||||
XK_Caps_Lock: 0xffe5, /* Caps lock */
|
||||
XK_Shift_Lock: 0xffe6, /* Shift lock */
|
||||
|
||||
XK_Meta_L: 0xffe7, /* Left meta */
|
||||
XK_Meta_R: 0xffe8, /* Right meta */
|
||||
XK_Alt_L: 0xffe9, /* Left alt */
|
||||
XK_Alt_R: 0xffea, /* Right alt */
|
||||
XK_Super_L: 0xffeb, /* Left super */
|
||||
XK_Super_R: 0xffec, /* Right super */
|
||||
XK_Hyper_L: 0xffed, /* Left hyper */
|
||||
XK_Hyper_R: 0xffee, /* Right hyper */
|
||||
|
||||
/*
|
||||
* Keyboard (XKB) Extension function and modifier keys
|
||||
* (from Appendix C of "The X Keyboard Extension: Protocol Specification")
|
||||
* Byte 3 = 0xfe
|
||||
*/
|
||||
|
||||
XK_ISO_Level3_Shift: 0xfe03, /* AltGr */
|
||||
XK_ISO_Next_Group: 0xfe08,
|
||||
XK_ISO_Prev_Group: 0xfe0a,
|
||||
XK_ISO_First_Group: 0xfe0c,
|
||||
XK_ISO_Last_Group: 0xfe0e,
|
||||
|
||||
/*
|
||||
* Latin 1
|
||||
* (ISO/IEC 8859-1: Unicode U+0020..U+00FF)
|
||||
* Byte 3: 0
|
||||
*/
|
||||
|
||||
XK_space: 0x0020, /* U+0020 SPACE */
|
||||
XK_exclam: 0x0021, /* U+0021 EXCLAMATION MARK */
|
||||
XK_quotedbl: 0x0022, /* U+0022 QUOTATION MARK */
|
||||
XK_numbersign: 0x0023, /* U+0023 NUMBER SIGN */
|
||||
XK_dollar: 0x0024, /* U+0024 DOLLAR SIGN */
|
||||
XK_percent: 0x0025, /* U+0025 PERCENT SIGN */
|
||||
XK_ampersand: 0x0026, /* U+0026 AMPERSAND */
|
||||
XK_apostrophe: 0x0027, /* U+0027 APOSTROPHE */
|
||||
XK_quoteright: 0x0027, /* deprecated */
|
||||
XK_parenleft: 0x0028, /* U+0028 LEFT PARENTHESIS */
|
||||
XK_parenright: 0x0029, /* U+0029 RIGHT PARENTHESIS */
|
||||
XK_asterisk: 0x002a, /* U+002A ASTERISK */
|
||||
XK_plus: 0x002b, /* U+002B PLUS SIGN */
|
||||
XK_comma: 0x002c, /* U+002C COMMA */
|
||||
XK_minus: 0x002d, /* U+002D HYPHEN-MINUS */
|
||||
XK_period: 0x002e, /* U+002E FULL STOP */
|
||||
XK_slash: 0x002f, /* U+002F SOLIDUS */
|
||||
XK_0: 0x0030, /* U+0030 DIGIT ZERO */
|
||||
XK_1: 0x0031, /* U+0031 DIGIT ONE */
|
||||
XK_2: 0x0032, /* U+0032 DIGIT TWO */
|
||||
XK_3: 0x0033, /* U+0033 DIGIT THREE */
|
||||
XK_4: 0x0034, /* U+0034 DIGIT FOUR */
|
||||
XK_5: 0x0035, /* U+0035 DIGIT FIVE */
|
||||
XK_6: 0x0036, /* U+0036 DIGIT SIX */
|
||||
XK_7: 0x0037, /* U+0037 DIGIT SEVEN */
|
||||
XK_8: 0x0038, /* U+0038 DIGIT EIGHT */
|
||||
XK_9: 0x0039, /* U+0039 DIGIT NINE */
|
||||
XK_colon: 0x003a, /* U+003A COLON */
|
||||
XK_semicolon: 0x003b, /* U+003B SEMICOLON */
|
||||
XK_less: 0x003c, /* U+003C LESS-THAN SIGN */
|
||||
XK_equal: 0x003d, /* U+003D EQUALS SIGN */
|
||||
XK_greater: 0x003e, /* U+003E GREATER-THAN SIGN */
|
||||
XK_question: 0x003f, /* U+003F QUESTION MARK */
|
||||
XK_at: 0x0040, /* U+0040 COMMERCIAL AT */
|
||||
XK_A: 0x0041, /* U+0041 LATIN CAPITAL LETTER A */
|
||||
XK_B: 0x0042, /* U+0042 LATIN CAPITAL LETTER B */
|
||||
XK_C: 0x0043, /* U+0043 LATIN CAPITAL LETTER C */
|
||||
XK_D: 0x0044, /* U+0044 LATIN CAPITAL LETTER D */
|
||||
XK_E: 0x0045, /* U+0045 LATIN CAPITAL LETTER E */
|
||||
XK_F: 0x0046, /* U+0046 LATIN CAPITAL LETTER F */
|
||||
XK_G: 0x0047, /* U+0047 LATIN CAPITAL LETTER G */
|
||||
XK_H: 0x0048, /* U+0048 LATIN CAPITAL LETTER H */
|
||||
XK_I: 0x0049, /* U+0049 LATIN CAPITAL LETTER I */
|
||||
XK_J: 0x004a, /* U+004A LATIN CAPITAL LETTER J */
|
||||
XK_K: 0x004b, /* U+004B LATIN CAPITAL LETTER K */
|
||||
XK_L: 0x004c, /* U+004C LATIN CAPITAL LETTER L */
|
||||
XK_M: 0x004d, /* U+004D LATIN CAPITAL LETTER M */
|
||||
XK_N: 0x004e, /* U+004E LATIN CAPITAL LETTER N */
|
||||
XK_O: 0x004f, /* U+004F LATIN CAPITAL LETTER O */
|
||||
XK_P: 0x0050, /* U+0050 LATIN CAPITAL LETTER P */
|
||||
XK_Q: 0x0051, /* U+0051 LATIN CAPITAL LETTER Q */
|
||||
XK_R: 0x0052, /* U+0052 LATIN CAPITAL LETTER R */
|
||||
XK_S: 0x0053, /* U+0053 LATIN CAPITAL LETTER S */
|
||||
XK_T: 0x0054, /* U+0054 LATIN CAPITAL LETTER T */
|
||||
XK_U: 0x0055, /* U+0055 LATIN CAPITAL LETTER U */
|
||||
XK_V: 0x0056, /* U+0056 LATIN CAPITAL LETTER V */
|
||||
XK_W: 0x0057, /* U+0057 LATIN CAPITAL LETTER W */
|
||||
XK_X: 0x0058, /* U+0058 LATIN CAPITAL LETTER X */
|
||||
XK_Y: 0x0059, /* U+0059 LATIN CAPITAL LETTER Y */
|
||||
XK_Z: 0x005a, /* U+005A LATIN CAPITAL LETTER Z */
|
||||
XK_bracketleft: 0x005b, /* U+005B LEFT SQUARE BRACKET */
|
||||
XK_backslash: 0x005c, /* U+005C REVERSE SOLIDUS */
|
||||
XK_bracketright: 0x005d, /* U+005D RIGHT SQUARE BRACKET */
|
||||
XK_asciicircum: 0x005e, /* U+005E CIRCUMFLEX ACCENT */
|
||||
XK_underscore: 0x005f, /* U+005F LOW LINE */
|
||||
XK_grave: 0x0060, /* U+0060 GRAVE ACCENT */
|
||||
XK_quoteleft: 0x0060, /* deprecated */
|
||||
XK_a: 0x0061, /* U+0061 LATIN SMALL LETTER A */
|
||||
XK_b: 0x0062, /* U+0062 LATIN SMALL LETTER B */
|
||||
XK_c: 0x0063, /* U+0063 LATIN SMALL LETTER C */
|
||||
XK_d: 0x0064, /* U+0064 LATIN SMALL LETTER D */
|
||||
XK_e: 0x0065, /* U+0065 LATIN SMALL LETTER E */
|
||||
XK_f: 0x0066, /* U+0066 LATIN SMALL LETTER F */
|
||||
XK_g: 0x0067, /* U+0067 LATIN SMALL LETTER G */
|
||||
XK_h: 0x0068, /* U+0068 LATIN SMALL LETTER H */
|
||||
XK_i: 0x0069, /* U+0069 LATIN SMALL LETTER I */
|
||||
XK_j: 0x006a, /* U+006A LATIN SMALL LETTER J */
|
||||
XK_k: 0x006b, /* U+006B LATIN SMALL LETTER K */
|
||||
XK_l: 0x006c, /* U+006C LATIN SMALL LETTER L */
|
||||
XK_m: 0x006d, /* U+006D LATIN SMALL LETTER M */
|
||||
XK_n: 0x006e, /* U+006E LATIN SMALL LETTER N */
|
||||
XK_o: 0x006f, /* U+006F LATIN SMALL LETTER O */
|
||||
XK_p: 0x0070, /* U+0070 LATIN SMALL LETTER P */
|
||||
XK_q: 0x0071, /* U+0071 LATIN SMALL LETTER Q */
|
||||
XK_r: 0x0072, /* U+0072 LATIN SMALL LETTER R */
|
||||
XK_s: 0x0073, /* U+0073 LATIN SMALL LETTER S */
|
||||
XK_t: 0x0074, /* U+0074 LATIN SMALL LETTER T */
|
||||
XK_u: 0x0075, /* U+0075 LATIN SMALL LETTER U */
|
||||
XK_v: 0x0076, /* U+0076 LATIN SMALL LETTER V */
|
||||
XK_w: 0x0077, /* U+0077 LATIN SMALL LETTER W */
|
||||
XK_x: 0x0078, /* U+0078 LATIN SMALL LETTER X */
|
||||
XK_y: 0x0079, /* U+0079 LATIN SMALL LETTER Y */
|
||||
XK_z: 0x007a, /* U+007A LATIN SMALL LETTER Z */
|
||||
XK_braceleft: 0x007b, /* U+007B LEFT CURLY BRACKET */
|
||||
XK_bar: 0x007c, /* U+007C VERTICAL LINE */
|
||||
XK_braceright: 0x007d, /* U+007D RIGHT CURLY BRACKET */
|
||||
XK_asciitilde: 0x007e, /* U+007E TILDE */
|
||||
|
||||
XK_nobreakspace: 0x00a0, /* U+00A0 NO-BREAK SPACE */
|
||||
XK_exclamdown: 0x00a1, /* U+00A1 INVERTED EXCLAMATION MARK */
|
||||
XK_cent: 0x00a2, /* U+00A2 CENT SIGN */
|
||||
XK_sterling: 0x00a3, /* U+00A3 POUND SIGN */
|
||||
XK_currency: 0x00a4, /* U+00A4 CURRENCY SIGN */
|
||||
XK_yen: 0x00a5, /* U+00A5 YEN SIGN */
|
||||
XK_brokenbar: 0x00a6, /* U+00A6 BROKEN BAR */
|
||||
XK_section: 0x00a7, /* U+00A7 SECTION SIGN */
|
||||
XK_diaeresis: 0x00a8, /* U+00A8 DIAERESIS */
|
||||
XK_copyright: 0x00a9, /* U+00A9 COPYRIGHT SIGN */
|
||||
XK_ordfeminine: 0x00aa, /* U+00AA FEMININE ORDINAL INDICATOR */
|
||||
XK_guillemotleft: 0x00ab, /* U+00AB LEFT-POINTING DOUBLE ANGLE QUOTATION MARK */
|
||||
XK_notsign: 0x00ac, /* U+00AC NOT SIGN */
|
||||
XK_hyphen: 0x00ad, /* U+00AD SOFT HYPHEN */
|
||||
XK_registered: 0x00ae, /* U+00AE REGISTERED SIGN */
|
||||
XK_macron: 0x00af, /* U+00AF MACRON */
|
||||
XK_degree: 0x00b0, /* U+00B0 DEGREE SIGN */
|
||||
XK_plusminus: 0x00b1, /* U+00B1 PLUS-MINUS SIGN */
|
||||
XK_twosuperior: 0x00b2, /* U+00B2 SUPERSCRIPT TWO */
|
||||
XK_threesuperior: 0x00b3, /* U+00B3 SUPERSCRIPT THREE */
|
||||
XK_acute: 0x00b4, /* U+00B4 ACUTE ACCENT */
|
||||
XK_mu: 0x00b5, /* U+00B5 MICRO SIGN */
|
||||
XK_paragraph: 0x00b6, /* U+00B6 PILCROW SIGN */
|
||||
XK_periodcentered: 0x00b7, /* U+00B7 MIDDLE DOT */
|
||||
XK_cedilla: 0x00b8, /* U+00B8 CEDILLA */
|
||||
XK_onesuperior: 0x00b9, /* U+00B9 SUPERSCRIPT ONE */
|
||||
XK_masculine: 0x00ba, /* U+00BA MASCULINE ORDINAL INDICATOR */
|
||||
XK_guillemotright: 0x00bb, /* U+00BB RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK */
|
||||
XK_onequarter: 0x00bc, /* U+00BC VULGAR FRACTION ONE QUARTER */
|
||||
XK_onehalf: 0x00bd, /* U+00BD VULGAR FRACTION ONE HALF */
|
||||
XK_threequarters: 0x00be, /* U+00BE VULGAR FRACTION THREE QUARTERS */
|
||||
XK_questiondown: 0x00bf, /* U+00BF INVERTED QUESTION MARK */
|
||||
XK_Agrave: 0x00c0, /* U+00C0 LATIN CAPITAL LETTER A WITH GRAVE */
|
||||
XK_Aacute: 0x00c1, /* U+00C1 LATIN CAPITAL LETTER A WITH ACUTE */
|
||||
XK_Acircumflex: 0x00c2, /* U+00C2 LATIN CAPITAL LETTER A WITH CIRCUMFLEX */
|
||||
XK_Atilde: 0x00c3, /* U+00C3 LATIN CAPITAL LETTER A WITH TILDE */
|
||||
XK_Adiaeresis: 0x00c4, /* U+00C4 LATIN CAPITAL LETTER A WITH DIAERESIS */
|
||||
XK_Aring: 0x00c5, /* U+00C5 LATIN CAPITAL LETTER A WITH RING ABOVE */
|
||||
XK_AE: 0x00c6, /* U+00C6 LATIN CAPITAL LETTER AE */
|
||||
XK_Ccedilla: 0x00c7, /* U+00C7 LATIN CAPITAL LETTER C WITH CEDILLA */
|
||||
XK_Egrave: 0x00c8, /* U+00C8 LATIN CAPITAL LETTER E WITH GRAVE */
|
||||
XK_Eacute: 0x00c9, /* U+00C9 LATIN CAPITAL LETTER E WITH ACUTE */
|
||||
XK_Ecircumflex: 0x00ca, /* U+00CA LATIN CAPITAL LETTER E WITH CIRCUMFLEX */
|
||||
XK_Ediaeresis: 0x00cb, /* U+00CB LATIN CAPITAL LETTER E WITH DIAERESIS */
|
||||
XK_Igrave: 0x00cc, /* U+00CC LATIN CAPITAL LETTER I WITH GRAVE */
|
||||
XK_Iacute: 0x00cd, /* U+00CD LATIN CAPITAL LETTER I WITH ACUTE */
|
||||
XK_Icircumflex: 0x00ce, /* U+00CE LATIN CAPITAL LETTER I WITH CIRCUMFLEX */
|
||||
XK_Idiaeresis: 0x00cf, /* U+00CF LATIN CAPITAL LETTER I WITH DIAERESIS */
|
||||
XK_ETH: 0x00d0, /* U+00D0 LATIN CAPITAL LETTER ETH */
|
||||
XK_Eth: 0x00d0, /* deprecated */
|
||||
XK_Ntilde: 0x00d1, /* U+00D1 LATIN CAPITAL LETTER N WITH TILDE */
|
||||
XK_Ograve: 0x00d2, /* U+00D2 LATIN CAPITAL LETTER O WITH GRAVE */
|
||||
XK_Oacute: 0x00d3, /* U+00D3 LATIN CAPITAL LETTER O WITH ACUTE */
|
||||
XK_Ocircumflex: 0x00d4, /* U+00D4 LATIN CAPITAL LETTER O WITH CIRCUMFLEX */
|
||||
XK_Otilde: 0x00d5, /* U+00D5 LATIN CAPITAL LETTER O WITH TILDE */
|
||||
XK_Odiaeresis: 0x00d6, /* U+00D6 LATIN CAPITAL LETTER O WITH DIAERESIS */
|
||||
XK_multiply: 0x00d7, /* U+00D7 MULTIPLICATION SIGN */
|
||||
XK_Oslash: 0x00d8, /* U+00D8 LATIN CAPITAL LETTER O WITH STROKE */
|
||||
XK_Ooblique: 0x00d8, /* U+00D8 LATIN CAPITAL LETTER O WITH STROKE */
|
||||
XK_Ugrave: 0x00d9, /* U+00D9 LATIN CAPITAL LETTER U WITH GRAVE */
|
||||
XK_Uacute: 0x00da, /* U+00DA LATIN CAPITAL LETTER U WITH ACUTE */
|
||||
XK_Ucircumflex: 0x00db, /* U+00DB LATIN CAPITAL LETTER U WITH CIRCUMFLEX */
|
||||
XK_Udiaeresis: 0x00dc, /* U+00DC LATIN CAPITAL LETTER U WITH DIAERESIS */
|
||||
XK_Yacute: 0x00dd, /* U+00DD LATIN CAPITAL LETTER Y WITH ACUTE */
|
||||
XK_THORN: 0x00de, /* U+00DE LATIN CAPITAL LETTER THORN */
|
||||
XK_Thorn: 0x00de, /* deprecated */
|
||||
XK_ssharp: 0x00df, /* U+00DF LATIN SMALL LETTER SHARP S */
|
||||
XK_agrave: 0x00e0, /* U+00E0 LATIN SMALL LETTER A WITH GRAVE */
|
||||
XK_aacute: 0x00e1, /* U+00E1 LATIN SMALL LETTER A WITH ACUTE */
|
||||
XK_acircumflex: 0x00e2, /* U+00E2 LATIN SMALL LETTER A WITH CIRCUMFLEX */
|
||||
XK_atilde: 0x00e3, /* U+00E3 LATIN SMALL LETTER A WITH TILDE */
|
||||
XK_adiaeresis: 0x00e4, /* U+00E4 LATIN SMALL LETTER A WITH DIAERESIS */
|
||||
XK_aring: 0x00e5, /* U+00E5 LATIN SMALL LETTER A WITH RING ABOVE */
|
||||
XK_ae: 0x00e6, /* U+00E6 LATIN SMALL LETTER AE */
|
||||
XK_ccedilla: 0x00e7, /* U+00E7 LATIN SMALL LETTER C WITH CEDILLA */
|
||||
XK_egrave: 0x00e8, /* U+00E8 LATIN SMALL LETTER E WITH GRAVE */
|
||||
XK_eacute: 0x00e9, /* U+00E9 LATIN SMALL LETTER E WITH ACUTE */
|
||||
XK_ecircumflex: 0x00ea, /* U+00EA LATIN SMALL LETTER E WITH CIRCUMFLEX */
|
||||
XK_ediaeresis: 0x00eb, /* U+00EB LATIN SMALL LETTER E WITH DIAERESIS */
|
||||
XK_igrave: 0x00ec, /* U+00EC LATIN SMALL LETTER I WITH GRAVE */
|
||||
XK_iacute: 0x00ed, /* U+00ED LATIN SMALL LETTER I WITH ACUTE */
|
||||
XK_icircumflex: 0x00ee, /* U+00EE LATIN SMALL LETTER I WITH CIRCUMFLEX */
|
||||
XK_idiaeresis: 0x00ef, /* U+00EF LATIN SMALL LETTER I WITH DIAERESIS */
|
||||
XK_eth: 0x00f0, /* U+00F0 LATIN SMALL LETTER ETH */
|
||||
XK_ntilde: 0x00f1, /* U+00F1 LATIN SMALL LETTER N WITH TILDE */
|
||||
XK_ograve: 0x00f2, /* U+00F2 LATIN SMALL LETTER O WITH GRAVE */
|
||||
XK_oacute: 0x00f3, /* U+00F3 LATIN SMALL LETTER O WITH ACUTE */
|
||||
XK_ocircumflex: 0x00f4, /* U+00F4 LATIN SMALL LETTER O WITH CIRCUMFLEX */
|
||||
XK_otilde: 0x00f5, /* U+00F5 LATIN SMALL LETTER O WITH TILDE */
|
||||
XK_odiaeresis: 0x00f6, /* U+00F6 LATIN SMALL LETTER O WITH DIAERESIS */
|
||||
XK_division: 0x00f7, /* U+00F7 DIVISION SIGN */
|
||||
XK_oslash: 0x00f8, /* U+00F8 LATIN SMALL LETTER O WITH STROKE */
|
||||
XK_ooblique: 0x00f8, /* U+00F8 LATIN SMALL LETTER O WITH STROKE */
|
||||
XK_ugrave: 0x00f9, /* U+00F9 LATIN SMALL LETTER U WITH GRAVE */
|
||||
XK_uacute: 0x00fa, /* U+00FA LATIN SMALL LETTER U WITH ACUTE */
|
||||
XK_ucircumflex: 0x00fb, /* U+00FB LATIN SMALL LETTER U WITH CIRCUMFLEX */
|
||||
XK_udiaeresis: 0x00fc, /* U+00FC LATIN SMALL LETTER U WITH DIAERESIS */
|
||||
XK_yacute: 0x00fd, /* U+00FD LATIN SMALL LETTER Y WITH ACUTE */
|
||||
XK_thorn: 0x00fe, /* U+00FE LATIN SMALL LETTER THORN */
|
||||
XK_ydiaeresis: 0x00ff, /* U+00FF LATIN SMALL LETTER Y WITH DIAERESIS */
|
||||
|
||||
/*
|
||||
* Korean
|
||||
* Byte 3 = 0x0e
|
||||
*/
|
||||
|
||||
XK_Hangul: 0xff31, /* Hangul start/stop(toggle) */
|
||||
XK_Hangul_Hanja: 0xff34, /* Start Hangul->Hanja Conversion */
|
||||
XK_Hangul_Jeonja: 0xff38, /* Jeonja mode */
|
||||
|
||||
/*
|
||||
* XFree86 vendor specific keysyms.
|
||||
*
|
||||
* The XFree86 keysym range is 0x10080001 - 0x1008FFFF.
|
||||
*/
|
||||
|
||||
XF86XK_ModeLock: 0x1008FF01,
|
||||
XF86XK_MonBrightnessUp: 0x1008FF02,
|
||||
XF86XK_MonBrightnessDown: 0x1008FF03,
|
||||
XF86XK_KbdLightOnOff: 0x1008FF04,
|
||||
XF86XK_KbdBrightnessUp: 0x1008FF05,
|
||||
XF86XK_KbdBrightnessDown: 0x1008FF06,
|
||||
XF86XK_Standby: 0x1008FF10,
|
||||
XF86XK_AudioLowerVolume: 0x1008FF11,
|
||||
XF86XK_AudioMute: 0x1008FF12,
|
||||
XF86XK_AudioRaiseVolume: 0x1008FF13,
|
||||
XF86XK_AudioPlay: 0x1008FF14,
|
||||
XF86XK_AudioStop: 0x1008FF15,
|
||||
XF86XK_AudioPrev: 0x1008FF16,
|
||||
XF86XK_AudioNext: 0x1008FF17,
|
||||
XF86XK_HomePage: 0x1008FF18,
|
||||
XF86XK_Mail: 0x1008FF19,
|
||||
XF86XK_Start: 0x1008FF1A,
|
||||
XF86XK_Search: 0x1008FF1B,
|
||||
XF86XK_AudioRecord: 0x1008FF1C,
|
||||
XF86XK_Calculator: 0x1008FF1D,
|
||||
XF86XK_Memo: 0x1008FF1E,
|
||||
XF86XK_ToDoList: 0x1008FF1F,
|
||||
XF86XK_Calendar: 0x1008FF20,
|
||||
XF86XK_PowerDown: 0x1008FF21,
|
||||
XF86XK_ContrastAdjust: 0x1008FF22,
|
||||
XF86XK_RockerUp: 0x1008FF23,
|
||||
XF86XK_RockerDown: 0x1008FF24,
|
||||
XF86XK_RockerEnter: 0x1008FF25,
|
||||
XF86XK_Back: 0x1008FF26,
|
||||
XF86XK_Forward: 0x1008FF27,
|
||||
XF86XK_Stop: 0x1008FF28,
|
||||
XF86XK_Refresh: 0x1008FF29,
|
||||
XF86XK_PowerOff: 0x1008FF2A,
|
||||
XF86XK_WakeUp: 0x1008FF2B,
|
||||
XF86XK_Eject: 0x1008FF2C,
|
||||
XF86XK_ScreenSaver: 0x1008FF2D,
|
||||
XF86XK_WWW: 0x1008FF2E,
|
||||
XF86XK_Sleep: 0x1008FF2F,
|
||||
XF86XK_Favorites: 0x1008FF30,
|
||||
XF86XK_AudioPause: 0x1008FF31,
|
||||
XF86XK_AudioMedia: 0x1008FF32,
|
||||
XF86XK_MyComputer: 0x1008FF33,
|
||||
XF86XK_VendorHome: 0x1008FF34,
|
||||
XF86XK_LightBulb: 0x1008FF35,
|
||||
XF86XK_Shop: 0x1008FF36,
|
||||
XF86XK_History: 0x1008FF37,
|
||||
XF86XK_OpenURL: 0x1008FF38,
|
||||
XF86XK_AddFavorite: 0x1008FF39,
|
||||
XF86XK_HotLinks: 0x1008FF3A,
|
||||
XF86XK_BrightnessAdjust: 0x1008FF3B,
|
||||
XF86XK_Finance: 0x1008FF3C,
|
||||
XF86XK_Community: 0x1008FF3D,
|
||||
XF86XK_AudioRewind: 0x1008FF3E,
|
||||
XF86XK_BackForward: 0x1008FF3F,
|
||||
XF86XK_Launch0: 0x1008FF40,
|
||||
XF86XK_Launch1: 0x1008FF41,
|
||||
XF86XK_Launch2: 0x1008FF42,
|
||||
XF86XK_Launch3: 0x1008FF43,
|
||||
XF86XK_Launch4: 0x1008FF44,
|
||||
XF86XK_Launch5: 0x1008FF45,
|
||||
XF86XK_Launch6: 0x1008FF46,
|
||||
XF86XK_Launch7: 0x1008FF47,
|
||||
XF86XK_Launch8: 0x1008FF48,
|
||||
XF86XK_Launch9: 0x1008FF49,
|
||||
XF86XK_LaunchA: 0x1008FF4A,
|
||||
XF86XK_LaunchB: 0x1008FF4B,
|
||||
XF86XK_LaunchC: 0x1008FF4C,
|
||||
XF86XK_LaunchD: 0x1008FF4D,
|
||||
XF86XK_LaunchE: 0x1008FF4E,
|
||||
XF86XK_LaunchF: 0x1008FF4F,
|
||||
XF86XK_ApplicationLeft: 0x1008FF50,
|
||||
XF86XK_ApplicationRight: 0x1008FF51,
|
||||
XF86XK_Book: 0x1008FF52,
|
||||
XF86XK_CD: 0x1008FF53,
|
||||
XF86XK_Calculater: 0x1008FF54,
|
||||
XF86XK_Clear: 0x1008FF55,
|
||||
XF86XK_Close: 0x1008FF56,
|
||||
XF86XK_Copy: 0x1008FF57,
|
||||
XF86XK_Cut: 0x1008FF58,
|
||||
XF86XK_Display: 0x1008FF59,
|
||||
XF86XK_DOS: 0x1008FF5A,
|
||||
XF86XK_Documents: 0x1008FF5B,
|
||||
XF86XK_Excel: 0x1008FF5C,
|
||||
XF86XK_Explorer: 0x1008FF5D,
|
||||
XF86XK_Game: 0x1008FF5E,
|
||||
XF86XK_Go: 0x1008FF5F,
|
||||
XF86XK_iTouch: 0x1008FF60,
|
||||
XF86XK_LogOff: 0x1008FF61,
|
||||
XF86XK_Market: 0x1008FF62,
|
||||
XF86XK_Meeting: 0x1008FF63,
|
||||
XF86XK_MenuKB: 0x1008FF65,
|
||||
XF86XK_MenuPB: 0x1008FF66,
|
||||
XF86XK_MySites: 0x1008FF67,
|
||||
XF86XK_New: 0x1008FF68,
|
||||
XF86XK_News: 0x1008FF69,
|
||||
XF86XK_OfficeHome: 0x1008FF6A,
|
||||
XF86XK_Open: 0x1008FF6B,
|
||||
XF86XK_Option: 0x1008FF6C,
|
||||
XF86XK_Paste: 0x1008FF6D,
|
||||
XF86XK_Phone: 0x1008FF6E,
|
||||
XF86XK_Q: 0x1008FF70,
|
||||
XF86XK_Reply: 0x1008FF72,
|
||||
XF86XK_Reload: 0x1008FF73,
|
||||
XF86XK_RotateWindows: 0x1008FF74,
|
||||
XF86XK_RotationPB: 0x1008FF75,
|
||||
XF86XK_RotationKB: 0x1008FF76,
|
||||
XF86XK_Save: 0x1008FF77,
|
||||
XF86XK_ScrollUp: 0x1008FF78,
|
||||
XF86XK_ScrollDown: 0x1008FF79,
|
||||
XF86XK_ScrollClick: 0x1008FF7A,
|
||||
XF86XK_Send: 0x1008FF7B,
|
||||
XF86XK_Spell: 0x1008FF7C,
|
||||
XF86XK_SplitScreen: 0x1008FF7D,
|
||||
XF86XK_Support: 0x1008FF7E,
|
||||
XF86XK_TaskPane: 0x1008FF7F,
|
||||
XF86XK_Terminal: 0x1008FF80,
|
||||
XF86XK_Tools: 0x1008FF81,
|
||||
XF86XK_Travel: 0x1008FF82,
|
||||
XF86XK_UserPB: 0x1008FF84,
|
||||
XF86XK_User1KB: 0x1008FF85,
|
||||
XF86XK_User2KB: 0x1008FF86,
|
||||
XF86XK_Video: 0x1008FF87,
|
||||
XF86XK_WheelButton: 0x1008FF88,
|
||||
XF86XK_Word: 0x1008FF89,
|
||||
XF86XK_Xfer: 0x1008FF8A,
|
||||
XF86XK_ZoomIn: 0x1008FF8B,
|
||||
XF86XK_ZoomOut: 0x1008FF8C,
|
||||
XF86XK_Away: 0x1008FF8D,
|
||||
XF86XK_Messenger: 0x1008FF8E,
|
||||
XF86XK_WebCam: 0x1008FF8F,
|
||||
XF86XK_MailForward: 0x1008FF90,
|
||||
XF86XK_Pictures: 0x1008FF91,
|
||||
XF86XK_Music: 0x1008FF92,
|
||||
XF86XK_Battery: 0x1008FF93,
|
||||
XF86XK_Bluetooth: 0x1008FF94,
|
||||
XF86XK_WLAN: 0x1008FF95,
|
||||
XF86XK_UWB: 0x1008FF96,
|
||||
XF86XK_AudioForward: 0x1008FF97,
|
||||
XF86XK_AudioRepeat: 0x1008FF98,
|
||||
XF86XK_AudioRandomPlay: 0x1008FF99,
|
||||
XF86XK_Subtitle: 0x1008FF9A,
|
||||
XF86XK_AudioCycleTrack: 0x1008FF9B,
|
||||
XF86XK_CycleAngle: 0x1008FF9C,
|
||||
XF86XK_FrameBack: 0x1008FF9D,
|
||||
XF86XK_FrameForward: 0x1008FF9E,
|
||||
XF86XK_Time: 0x1008FF9F,
|
||||
XF86XK_Select: 0x1008FFA0,
|
||||
XF86XK_View: 0x1008FFA1,
|
||||
XF86XK_TopMenu: 0x1008FFA2,
|
||||
XF86XK_Red: 0x1008FFA3,
|
||||
XF86XK_Green: 0x1008FFA4,
|
||||
XF86XK_Yellow: 0x1008FFA5,
|
||||
XF86XK_Blue: 0x1008FFA6,
|
||||
XF86XK_Suspend: 0x1008FFA7,
|
||||
XF86XK_Hibernate: 0x1008FFA8,
|
||||
XF86XK_TouchpadToggle: 0x1008FFA9,
|
||||
XF86XK_TouchpadOn: 0x1008FFB0,
|
||||
XF86XK_TouchpadOff: 0x1008FFB1,
|
||||
XF86XK_AudioMicMute: 0x1008FFB2,
|
||||
XF86XK_Switch_VT_1: 0x1008FE01,
|
||||
XF86XK_Switch_VT_2: 0x1008FE02,
|
||||
XF86XK_Switch_VT_3: 0x1008FE03,
|
||||
XF86XK_Switch_VT_4: 0x1008FE04,
|
||||
XF86XK_Switch_VT_5: 0x1008FE05,
|
||||
XF86XK_Switch_VT_6: 0x1008FE06,
|
||||
XF86XK_Switch_VT_7: 0x1008FE07,
|
||||
XF86XK_Switch_VT_8: 0x1008FE08,
|
||||
XF86XK_Switch_VT_9: 0x1008FE09,
|
||||
XF86XK_Switch_VT_10: 0x1008FE0A,
|
||||
XF86XK_Switch_VT_11: 0x1008FE0B,
|
||||
XF86XK_Switch_VT_12: 0x1008FE0C,
|
||||
XF86XK_Ungrab: 0x1008FE20,
|
||||
XF86XK_ClearGrab: 0x1008FE21,
|
||||
XF86XK_Next_VMode: 0x1008FE22,
|
||||
XF86XK_Prev_VMode: 0x1008FE23,
|
||||
XF86XK_LogWindowTree: 0x1008FE24,
|
||||
XF86XK_LogGrabInfo: 0x1008FE25,
|
||||
};
|
||||
+688
@@ -0,0 +1,688 @@
|
||||
/*
|
||||
* Mapping from Unicode codepoints to X11/RFB keysyms
|
||||
*
|
||||
* This file was automatically generated from keysymdef.h
|
||||
* DO NOT EDIT!
|
||||
*/
|
||||
|
||||
/* Functions at the bottom */
|
||||
|
||||
const codepoints = {
|
||||
0x0100: 0x03c0, // XK_Amacron
|
||||
0x0101: 0x03e0, // XK_amacron
|
||||
0x0102: 0x01c3, // XK_Abreve
|
||||
0x0103: 0x01e3, // XK_abreve
|
||||
0x0104: 0x01a1, // XK_Aogonek
|
||||
0x0105: 0x01b1, // XK_aogonek
|
||||
0x0106: 0x01c6, // XK_Cacute
|
||||
0x0107: 0x01e6, // XK_cacute
|
||||
0x0108: 0x02c6, // XK_Ccircumflex
|
||||
0x0109: 0x02e6, // XK_ccircumflex
|
||||
0x010a: 0x02c5, // XK_Cabovedot
|
||||
0x010b: 0x02e5, // XK_cabovedot
|
||||
0x010c: 0x01c8, // XK_Ccaron
|
||||
0x010d: 0x01e8, // XK_ccaron
|
||||
0x010e: 0x01cf, // XK_Dcaron
|
||||
0x010f: 0x01ef, // XK_dcaron
|
||||
0x0110: 0x01d0, // XK_Dstroke
|
||||
0x0111: 0x01f0, // XK_dstroke
|
||||
0x0112: 0x03aa, // XK_Emacron
|
||||
0x0113: 0x03ba, // XK_emacron
|
||||
0x0116: 0x03cc, // XK_Eabovedot
|
||||
0x0117: 0x03ec, // XK_eabovedot
|
||||
0x0118: 0x01ca, // XK_Eogonek
|
||||
0x0119: 0x01ea, // XK_eogonek
|
||||
0x011a: 0x01cc, // XK_Ecaron
|
||||
0x011b: 0x01ec, // XK_ecaron
|
||||
0x011c: 0x02d8, // XK_Gcircumflex
|
||||
0x011d: 0x02f8, // XK_gcircumflex
|
||||
0x011e: 0x02ab, // XK_Gbreve
|
||||
0x011f: 0x02bb, // XK_gbreve
|
||||
0x0120: 0x02d5, // XK_Gabovedot
|
||||
0x0121: 0x02f5, // XK_gabovedot
|
||||
0x0122: 0x03ab, // XK_Gcedilla
|
||||
0x0123: 0x03bb, // XK_gcedilla
|
||||
0x0124: 0x02a6, // XK_Hcircumflex
|
||||
0x0125: 0x02b6, // XK_hcircumflex
|
||||
0x0126: 0x02a1, // XK_Hstroke
|
||||
0x0127: 0x02b1, // XK_hstroke
|
||||
0x0128: 0x03a5, // XK_Itilde
|
||||
0x0129: 0x03b5, // XK_itilde
|
||||
0x012a: 0x03cf, // XK_Imacron
|
||||
0x012b: 0x03ef, // XK_imacron
|
||||
0x012e: 0x03c7, // XK_Iogonek
|
||||
0x012f: 0x03e7, // XK_iogonek
|
||||
0x0130: 0x02a9, // XK_Iabovedot
|
||||
0x0131: 0x02b9, // XK_idotless
|
||||
0x0134: 0x02ac, // XK_Jcircumflex
|
||||
0x0135: 0x02bc, // XK_jcircumflex
|
||||
0x0136: 0x03d3, // XK_Kcedilla
|
||||
0x0137: 0x03f3, // XK_kcedilla
|
||||
0x0138: 0x03a2, // XK_kra
|
||||
0x0139: 0x01c5, // XK_Lacute
|
||||
0x013a: 0x01e5, // XK_lacute
|
||||
0x013b: 0x03a6, // XK_Lcedilla
|
||||
0x013c: 0x03b6, // XK_lcedilla
|
||||
0x013d: 0x01a5, // XK_Lcaron
|
||||
0x013e: 0x01b5, // XK_lcaron
|
||||
0x0141: 0x01a3, // XK_Lstroke
|
||||
0x0142: 0x01b3, // XK_lstroke
|
||||
0x0143: 0x01d1, // XK_Nacute
|
||||
0x0144: 0x01f1, // XK_nacute
|
||||
0x0145: 0x03d1, // XK_Ncedilla
|
||||
0x0146: 0x03f1, // XK_ncedilla
|
||||
0x0147: 0x01d2, // XK_Ncaron
|
||||
0x0148: 0x01f2, // XK_ncaron
|
||||
0x014a: 0x03bd, // XK_ENG
|
||||
0x014b: 0x03bf, // XK_eng
|
||||
0x014c: 0x03d2, // XK_Omacron
|
||||
0x014d: 0x03f2, // XK_omacron
|
||||
0x0150: 0x01d5, // XK_Odoubleacute
|
||||
0x0151: 0x01f5, // XK_odoubleacute
|
||||
0x0152: 0x13bc, // XK_OE
|
||||
0x0153: 0x13bd, // XK_oe
|
||||
0x0154: 0x01c0, // XK_Racute
|
||||
0x0155: 0x01e0, // XK_racute
|
||||
0x0156: 0x03a3, // XK_Rcedilla
|
||||
0x0157: 0x03b3, // XK_rcedilla
|
||||
0x0158: 0x01d8, // XK_Rcaron
|
||||
0x0159: 0x01f8, // XK_rcaron
|
||||
0x015a: 0x01a6, // XK_Sacute
|
||||
0x015b: 0x01b6, // XK_sacute
|
||||
0x015c: 0x02de, // XK_Scircumflex
|
||||
0x015d: 0x02fe, // XK_scircumflex
|
||||
0x015e: 0x01aa, // XK_Scedilla
|
||||
0x015f: 0x01ba, // XK_scedilla
|
||||
0x0160: 0x01a9, // XK_Scaron
|
||||
0x0161: 0x01b9, // XK_scaron
|
||||
0x0162: 0x01de, // XK_Tcedilla
|
||||
0x0163: 0x01fe, // XK_tcedilla
|
||||
0x0164: 0x01ab, // XK_Tcaron
|
||||
0x0165: 0x01bb, // XK_tcaron
|
||||
0x0166: 0x03ac, // XK_Tslash
|
||||
0x0167: 0x03bc, // XK_tslash
|
||||
0x0168: 0x03dd, // XK_Utilde
|
||||
0x0169: 0x03fd, // XK_utilde
|
||||
0x016a: 0x03de, // XK_Umacron
|
||||
0x016b: 0x03fe, // XK_umacron
|
||||
0x016c: 0x02dd, // XK_Ubreve
|
||||
0x016d: 0x02fd, // XK_ubreve
|
||||
0x016e: 0x01d9, // XK_Uring
|
||||
0x016f: 0x01f9, // XK_uring
|
||||
0x0170: 0x01db, // XK_Udoubleacute
|
||||
0x0171: 0x01fb, // XK_udoubleacute
|
||||
0x0172: 0x03d9, // XK_Uogonek
|
||||
0x0173: 0x03f9, // XK_uogonek
|
||||
0x0178: 0x13be, // XK_Ydiaeresis
|
||||
0x0179: 0x01ac, // XK_Zacute
|
||||
0x017a: 0x01bc, // XK_zacute
|
||||
0x017b: 0x01af, // XK_Zabovedot
|
||||
0x017c: 0x01bf, // XK_zabovedot
|
||||
0x017d: 0x01ae, // XK_Zcaron
|
||||
0x017e: 0x01be, // XK_zcaron
|
||||
0x0192: 0x08f6, // XK_function
|
||||
0x01d2: 0x10001d1, // XK_Ocaron
|
||||
0x02c7: 0x01b7, // XK_caron
|
||||
0x02d8: 0x01a2, // XK_breve
|
||||
0x02d9: 0x01ff, // XK_abovedot
|
||||
0x02db: 0x01b2, // XK_ogonek
|
||||
0x02dd: 0x01bd, // XK_doubleacute
|
||||
0x0385: 0x07ae, // XK_Greek_accentdieresis
|
||||
0x0386: 0x07a1, // XK_Greek_ALPHAaccent
|
||||
0x0388: 0x07a2, // XK_Greek_EPSILONaccent
|
||||
0x0389: 0x07a3, // XK_Greek_ETAaccent
|
||||
0x038a: 0x07a4, // XK_Greek_IOTAaccent
|
||||
0x038c: 0x07a7, // XK_Greek_OMICRONaccent
|
||||
0x038e: 0x07a8, // XK_Greek_UPSILONaccent
|
||||
0x038f: 0x07ab, // XK_Greek_OMEGAaccent
|
||||
0x0390: 0x07b6, // XK_Greek_iotaaccentdieresis
|
||||
0x0391: 0x07c1, // XK_Greek_ALPHA
|
||||
0x0392: 0x07c2, // XK_Greek_BETA
|
||||
0x0393: 0x07c3, // XK_Greek_GAMMA
|
||||
0x0394: 0x07c4, // XK_Greek_DELTA
|
||||
0x0395: 0x07c5, // XK_Greek_EPSILON
|
||||
0x0396: 0x07c6, // XK_Greek_ZETA
|
||||
0x0397: 0x07c7, // XK_Greek_ETA
|
||||
0x0398: 0x07c8, // XK_Greek_THETA
|
||||
0x0399: 0x07c9, // XK_Greek_IOTA
|
||||
0x039a: 0x07ca, // XK_Greek_KAPPA
|
||||
0x039b: 0x07cb, // XK_Greek_LAMDA
|
||||
0x039c: 0x07cc, // XK_Greek_MU
|
||||
0x039d: 0x07cd, // XK_Greek_NU
|
||||
0x039e: 0x07ce, // XK_Greek_XI
|
||||
0x039f: 0x07cf, // XK_Greek_OMICRON
|
||||
0x03a0: 0x07d0, // XK_Greek_PI
|
||||
0x03a1: 0x07d1, // XK_Greek_RHO
|
||||
0x03a3: 0x07d2, // XK_Greek_SIGMA
|
||||
0x03a4: 0x07d4, // XK_Greek_TAU
|
||||
0x03a5: 0x07d5, // XK_Greek_UPSILON
|
||||
0x03a6: 0x07d6, // XK_Greek_PHI
|
||||
0x03a7: 0x07d7, // XK_Greek_CHI
|
||||
0x03a8: 0x07d8, // XK_Greek_PSI
|
||||
0x03a9: 0x07d9, // XK_Greek_OMEGA
|
||||
0x03aa: 0x07a5, // XK_Greek_IOTAdieresis
|
||||
0x03ab: 0x07a9, // XK_Greek_UPSILONdieresis
|
||||
0x03ac: 0x07b1, // XK_Greek_alphaaccent
|
||||
0x03ad: 0x07b2, // XK_Greek_epsilonaccent
|
||||
0x03ae: 0x07b3, // XK_Greek_etaaccent
|
||||
0x03af: 0x07b4, // XK_Greek_iotaaccent
|
||||
0x03b0: 0x07ba, // XK_Greek_upsilonaccentdieresis
|
||||
0x03b1: 0x07e1, // XK_Greek_alpha
|
||||
0x03b2: 0x07e2, // XK_Greek_beta
|
||||
0x03b3: 0x07e3, // XK_Greek_gamma
|
||||
0x03b4: 0x07e4, // XK_Greek_delta
|
||||
0x03b5: 0x07e5, // XK_Greek_epsilon
|
||||
0x03b6: 0x07e6, // XK_Greek_zeta
|
||||
0x03b7: 0x07e7, // XK_Greek_eta
|
||||
0x03b8: 0x07e8, // XK_Greek_theta
|
||||
0x03b9: 0x07e9, // XK_Greek_iota
|
||||
0x03ba: 0x07ea, // XK_Greek_kappa
|
||||
0x03bb: 0x07eb, // XK_Greek_lamda
|
||||
0x03bc: 0x07ec, // XK_Greek_mu
|
||||
0x03bd: 0x07ed, // XK_Greek_nu
|
||||
0x03be: 0x07ee, // XK_Greek_xi
|
||||
0x03bf: 0x07ef, // XK_Greek_omicron
|
||||
0x03c0: 0x07f0, // XK_Greek_pi
|
||||
0x03c1: 0x07f1, // XK_Greek_rho
|
||||
0x03c2: 0x07f3, // XK_Greek_finalsmallsigma
|
||||
0x03c3: 0x07f2, // XK_Greek_sigma
|
||||
0x03c4: 0x07f4, // XK_Greek_tau
|
||||
0x03c5: 0x07f5, // XK_Greek_upsilon
|
||||
0x03c6: 0x07f6, // XK_Greek_phi
|
||||
0x03c7: 0x07f7, // XK_Greek_chi
|
||||
0x03c8: 0x07f8, // XK_Greek_psi
|
||||
0x03c9: 0x07f9, // XK_Greek_omega
|
||||
0x03ca: 0x07b5, // XK_Greek_iotadieresis
|
||||
0x03cb: 0x07b9, // XK_Greek_upsilondieresis
|
||||
0x03cc: 0x07b7, // XK_Greek_omicronaccent
|
||||
0x03cd: 0x07b8, // XK_Greek_upsilonaccent
|
||||
0x03ce: 0x07bb, // XK_Greek_omegaaccent
|
||||
0x0401: 0x06b3, // XK_Cyrillic_IO
|
||||
0x0402: 0x06b1, // XK_Serbian_DJE
|
||||
0x0403: 0x06b2, // XK_Macedonia_GJE
|
||||
0x0404: 0x06b4, // XK_Ukrainian_IE
|
||||
0x0405: 0x06b5, // XK_Macedonia_DSE
|
||||
0x0406: 0x06b6, // XK_Ukrainian_I
|
||||
0x0407: 0x06b7, // XK_Ukrainian_YI
|
||||
0x0408: 0x06b8, // XK_Cyrillic_JE
|
||||
0x0409: 0x06b9, // XK_Cyrillic_LJE
|
||||
0x040a: 0x06ba, // XK_Cyrillic_NJE
|
||||
0x040b: 0x06bb, // XK_Serbian_TSHE
|
||||
0x040c: 0x06bc, // XK_Macedonia_KJE
|
||||
0x040e: 0x06be, // XK_Byelorussian_SHORTU
|
||||
0x040f: 0x06bf, // XK_Cyrillic_DZHE
|
||||
0x0410: 0x06e1, // XK_Cyrillic_A
|
||||
0x0411: 0x06e2, // XK_Cyrillic_BE
|
||||
0x0412: 0x06f7, // XK_Cyrillic_VE
|
||||
0x0413: 0x06e7, // XK_Cyrillic_GHE
|
||||
0x0414: 0x06e4, // XK_Cyrillic_DE
|
||||
0x0415: 0x06e5, // XK_Cyrillic_IE
|
||||
0x0416: 0x06f6, // XK_Cyrillic_ZHE
|
||||
0x0417: 0x06fa, // XK_Cyrillic_ZE
|
||||
0x0418: 0x06e9, // XK_Cyrillic_I
|
||||
0x0419: 0x06ea, // XK_Cyrillic_SHORTI
|
||||
0x041a: 0x06eb, // XK_Cyrillic_KA
|
||||
0x041b: 0x06ec, // XK_Cyrillic_EL
|
||||
0x041c: 0x06ed, // XK_Cyrillic_EM
|
||||
0x041d: 0x06ee, // XK_Cyrillic_EN
|
||||
0x041e: 0x06ef, // XK_Cyrillic_O
|
||||
0x041f: 0x06f0, // XK_Cyrillic_PE
|
||||
0x0420: 0x06f2, // XK_Cyrillic_ER
|
||||
0x0421: 0x06f3, // XK_Cyrillic_ES
|
||||
0x0422: 0x06f4, // XK_Cyrillic_TE
|
||||
0x0423: 0x06f5, // XK_Cyrillic_U
|
||||
0x0424: 0x06e6, // XK_Cyrillic_EF
|
||||
0x0425: 0x06e8, // XK_Cyrillic_HA
|
||||
0x0426: 0x06e3, // XK_Cyrillic_TSE
|
||||
0x0427: 0x06fe, // XK_Cyrillic_CHE
|
||||
0x0428: 0x06fb, // XK_Cyrillic_SHA
|
||||
0x0429: 0x06fd, // XK_Cyrillic_SHCHA
|
||||
0x042a: 0x06ff, // XK_Cyrillic_HARDSIGN
|
||||
0x042b: 0x06f9, // XK_Cyrillic_YERU
|
||||
0x042c: 0x06f8, // XK_Cyrillic_SOFTSIGN
|
||||
0x042d: 0x06fc, // XK_Cyrillic_E
|
||||
0x042e: 0x06e0, // XK_Cyrillic_YU
|
||||
0x042f: 0x06f1, // XK_Cyrillic_YA
|
||||
0x0430: 0x06c1, // XK_Cyrillic_a
|
||||
0x0431: 0x06c2, // XK_Cyrillic_be
|
||||
0x0432: 0x06d7, // XK_Cyrillic_ve
|
||||
0x0433: 0x06c7, // XK_Cyrillic_ghe
|
||||
0x0434: 0x06c4, // XK_Cyrillic_de
|
||||
0x0435: 0x06c5, // XK_Cyrillic_ie
|
||||
0x0436: 0x06d6, // XK_Cyrillic_zhe
|
||||
0x0437: 0x06da, // XK_Cyrillic_ze
|
||||
0x0438: 0x06c9, // XK_Cyrillic_i
|
||||
0x0439: 0x06ca, // XK_Cyrillic_shorti
|
||||
0x043a: 0x06cb, // XK_Cyrillic_ka
|
||||
0x043b: 0x06cc, // XK_Cyrillic_el
|
||||
0x043c: 0x06cd, // XK_Cyrillic_em
|
||||
0x043d: 0x06ce, // XK_Cyrillic_en
|
||||
0x043e: 0x06cf, // XK_Cyrillic_o
|
||||
0x043f: 0x06d0, // XK_Cyrillic_pe
|
||||
0x0440: 0x06d2, // XK_Cyrillic_er
|
||||
0x0441: 0x06d3, // XK_Cyrillic_es
|
||||
0x0442: 0x06d4, // XK_Cyrillic_te
|
||||
0x0443: 0x06d5, // XK_Cyrillic_u
|
||||
0x0444: 0x06c6, // XK_Cyrillic_ef
|
||||
0x0445: 0x06c8, // XK_Cyrillic_ha
|
||||
0x0446: 0x06c3, // XK_Cyrillic_tse
|
||||
0x0447: 0x06de, // XK_Cyrillic_che
|
||||
0x0448: 0x06db, // XK_Cyrillic_sha
|
||||
0x0449: 0x06dd, // XK_Cyrillic_shcha
|
||||
0x044a: 0x06df, // XK_Cyrillic_hardsign
|
||||
0x044b: 0x06d9, // XK_Cyrillic_yeru
|
||||
0x044c: 0x06d8, // XK_Cyrillic_softsign
|
||||
0x044d: 0x06dc, // XK_Cyrillic_e
|
||||
0x044e: 0x06c0, // XK_Cyrillic_yu
|
||||
0x044f: 0x06d1, // XK_Cyrillic_ya
|
||||
0x0451: 0x06a3, // XK_Cyrillic_io
|
||||
0x0452: 0x06a1, // XK_Serbian_dje
|
||||
0x0453: 0x06a2, // XK_Macedonia_gje
|
||||
0x0454: 0x06a4, // XK_Ukrainian_ie
|
||||
0x0455: 0x06a5, // XK_Macedonia_dse
|
||||
0x0456: 0x06a6, // XK_Ukrainian_i
|
||||
0x0457: 0x06a7, // XK_Ukrainian_yi
|
||||
0x0458: 0x06a8, // XK_Cyrillic_je
|
||||
0x0459: 0x06a9, // XK_Cyrillic_lje
|
||||
0x045a: 0x06aa, // XK_Cyrillic_nje
|
||||
0x045b: 0x06ab, // XK_Serbian_tshe
|
||||
0x045c: 0x06ac, // XK_Macedonia_kje
|
||||
0x045e: 0x06ae, // XK_Byelorussian_shortu
|
||||
0x045f: 0x06af, // XK_Cyrillic_dzhe
|
||||
0x0490: 0x06bd, // XK_Ukrainian_GHE_WITH_UPTURN
|
||||
0x0491: 0x06ad, // XK_Ukrainian_ghe_with_upturn
|
||||
0x05d0: 0x0ce0, // XK_hebrew_aleph
|
||||
0x05d1: 0x0ce1, // XK_hebrew_bet
|
||||
0x05d2: 0x0ce2, // XK_hebrew_gimel
|
||||
0x05d3: 0x0ce3, // XK_hebrew_dalet
|
||||
0x05d4: 0x0ce4, // XK_hebrew_he
|
||||
0x05d5: 0x0ce5, // XK_hebrew_waw
|
||||
0x05d6: 0x0ce6, // XK_hebrew_zain
|
||||
0x05d7: 0x0ce7, // XK_hebrew_chet
|
||||
0x05d8: 0x0ce8, // XK_hebrew_tet
|
||||
0x05d9: 0x0ce9, // XK_hebrew_yod
|
||||
0x05da: 0x0cea, // XK_hebrew_finalkaph
|
||||
0x05db: 0x0ceb, // XK_hebrew_kaph
|
||||
0x05dc: 0x0cec, // XK_hebrew_lamed
|
||||
0x05dd: 0x0ced, // XK_hebrew_finalmem
|
||||
0x05de: 0x0cee, // XK_hebrew_mem
|
||||
0x05df: 0x0cef, // XK_hebrew_finalnun
|
||||
0x05e0: 0x0cf0, // XK_hebrew_nun
|
||||
0x05e1: 0x0cf1, // XK_hebrew_samech
|
||||
0x05e2: 0x0cf2, // XK_hebrew_ayin
|
||||
0x05e3: 0x0cf3, // XK_hebrew_finalpe
|
||||
0x05e4: 0x0cf4, // XK_hebrew_pe
|
||||
0x05e5: 0x0cf5, // XK_hebrew_finalzade
|
||||
0x05e6: 0x0cf6, // XK_hebrew_zade
|
||||
0x05e7: 0x0cf7, // XK_hebrew_qoph
|
||||
0x05e8: 0x0cf8, // XK_hebrew_resh
|
||||
0x05e9: 0x0cf9, // XK_hebrew_shin
|
||||
0x05ea: 0x0cfa, // XK_hebrew_taw
|
||||
0x060c: 0x05ac, // XK_Arabic_comma
|
||||
0x061b: 0x05bb, // XK_Arabic_semicolon
|
||||
0x061f: 0x05bf, // XK_Arabic_question_mark
|
||||
0x0621: 0x05c1, // XK_Arabic_hamza
|
||||
0x0622: 0x05c2, // XK_Arabic_maddaonalef
|
||||
0x0623: 0x05c3, // XK_Arabic_hamzaonalef
|
||||
0x0624: 0x05c4, // XK_Arabic_hamzaonwaw
|
||||
0x0625: 0x05c5, // XK_Arabic_hamzaunderalef
|
||||
0x0626: 0x05c6, // XK_Arabic_hamzaonyeh
|
||||
0x0627: 0x05c7, // XK_Arabic_alef
|
||||
0x0628: 0x05c8, // XK_Arabic_beh
|
||||
0x0629: 0x05c9, // XK_Arabic_tehmarbuta
|
||||
0x062a: 0x05ca, // XK_Arabic_teh
|
||||
0x062b: 0x05cb, // XK_Arabic_theh
|
||||
0x062c: 0x05cc, // XK_Arabic_jeem
|
||||
0x062d: 0x05cd, // XK_Arabic_hah
|
||||
0x062e: 0x05ce, // XK_Arabic_khah
|
||||
0x062f: 0x05cf, // XK_Arabic_dal
|
||||
0x0630: 0x05d0, // XK_Arabic_thal
|
||||
0x0631: 0x05d1, // XK_Arabic_ra
|
||||
0x0632: 0x05d2, // XK_Arabic_zain
|
||||
0x0633: 0x05d3, // XK_Arabic_seen
|
||||
0x0634: 0x05d4, // XK_Arabic_sheen
|
||||
0x0635: 0x05d5, // XK_Arabic_sad
|
||||
0x0636: 0x05d6, // XK_Arabic_dad
|
||||
0x0637: 0x05d7, // XK_Arabic_tah
|
||||
0x0638: 0x05d8, // XK_Arabic_zah
|
||||
0x0639: 0x05d9, // XK_Arabic_ain
|
||||
0x063a: 0x05da, // XK_Arabic_ghain
|
||||
0x0640: 0x05e0, // XK_Arabic_tatweel
|
||||
0x0641: 0x05e1, // XK_Arabic_feh
|
||||
0x0642: 0x05e2, // XK_Arabic_qaf
|
||||
0x0643: 0x05e3, // XK_Arabic_kaf
|
||||
0x0644: 0x05e4, // XK_Arabic_lam
|
||||
0x0645: 0x05e5, // XK_Arabic_meem
|
||||
0x0646: 0x05e6, // XK_Arabic_noon
|
||||
0x0647: 0x05e7, // XK_Arabic_ha
|
||||
0x0648: 0x05e8, // XK_Arabic_waw
|
||||
0x0649: 0x05e9, // XK_Arabic_alefmaksura
|
||||
0x064a: 0x05ea, // XK_Arabic_yeh
|
||||
0x064b: 0x05eb, // XK_Arabic_fathatan
|
||||
0x064c: 0x05ec, // XK_Arabic_dammatan
|
||||
0x064d: 0x05ed, // XK_Arabic_kasratan
|
||||
0x064e: 0x05ee, // XK_Arabic_fatha
|
||||
0x064f: 0x05ef, // XK_Arabic_damma
|
||||
0x0650: 0x05f0, // XK_Arabic_kasra
|
||||
0x0651: 0x05f1, // XK_Arabic_shadda
|
||||
0x0652: 0x05f2, // XK_Arabic_sukun
|
||||
0x0e01: 0x0da1, // XK_Thai_kokai
|
||||
0x0e02: 0x0da2, // XK_Thai_khokhai
|
||||
0x0e03: 0x0da3, // XK_Thai_khokhuat
|
||||
0x0e04: 0x0da4, // XK_Thai_khokhwai
|
||||
0x0e05: 0x0da5, // XK_Thai_khokhon
|
||||
0x0e06: 0x0da6, // XK_Thai_khorakhang
|
||||
0x0e07: 0x0da7, // XK_Thai_ngongu
|
||||
0x0e08: 0x0da8, // XK_Thai_chochan
|
||||
0x0e09: 0x0da9, // XK_Thai_choching
|
||||
0x0e0a: 0x0daa, // XK_Thai_chochang
|
||||
0x0e0b: 0x0dab, // XK_Thai_soso
|
||||
0x0e0c: 0x0dac, // XK_Thai_chochoe
|
||||
0x0e0d: 0x0dad, // XK_Thai_yoying
|
||||
0x0e0e: 0x0dae, // XK_Thai_dochada
|
||||
0x0e0f: 0x0daf, // XK_Thai_topatak
|
||||
0x0e10: 0x0db0, // XK_Thai_thothan
|
||||
0x0e11: 0x0db1, // XK_Thai_thonangmontho
|
||||
0x0e12: 0x0db2, // XK_Thai_thophuthao
|
||||
0x0e13: 0x0db3, // XK_Thai_nonen
|
||||
0x0e14: 0x0db4, // XK_Thai_dodek
|
||||
0x0e15: 0x0db5, // XK_Thai_totao
|
||||
0x0e16: 0x0db6, // XK_Thai_thothung
|
||||
0x0e17: 0x0db7, // XK_Thai_thothahan
|
||||
0x0e18: 0x0db8, // XK_Thai_thothong
|
||||
0x0e19: 0x0db9, // XK_Thai_nonu
|
||||
0x0e1a: 0x0dba, // XK_Thai_bobaimai
|
||||
0x0e1b: 0x0dbb, // XK_Thai_popla
|
||||
0x0e1c: 0x0dbc, // XK_Thai_phophung
|
||||
0x0e1d: 0x0dbd, // XK_Thai_fofa
|
||||
0x0e1e: 0x0dbe, // XK_Thai_phophan
|
||||
0x0e1f: 0x0dbf, // XK_Thai_fofan
|
||||
0x0e20: 0x0dc0, // XK_Thai_phosamphao
|
||||
0x0e21: 0x0dc1, // XK_Thai_moma
|
||||
0x0e22: 0x0dc2, // XK_Thai_yoyak
|
||||
0x0e23: 0x0dc3, // XK_Thai_rorua
|
||||
0x0e24: 0x0dc4, // XK_Thai_ru
|
||||
0x0e25: 0x0dc5, // XK_Thai_loling
|
||||
0x0e26: 0x0dc6, // XK_Thai_lu
|
||||
0x0e27: 0x0dc7, // XK_Thai_wowaen
|
||||
0x0e28: 0x0dc8, // XK_Thai_sosala
|
||||
0x0e29: 0x0dc9, // XK_Thai_sorusi
|
||||
0x0e2a: 0x0dca, // XK_Thai_sosua
|
||||
0x0e2b: 0x0dcb, // XK_Thai_hohip
|
||||
0x0e2c: 0x0dcc, // XK_Thai_lochula
|
||||
0x0e2d: 0x0dcd, // XK_Thai_oang
|
||||
0x0e2e: 0x0dce, // XK_Thai_honokhuk
|
||||
0x0e2f: 0x0dcf, // XK_Thai_paiyannoi
|
||||
0x0e30: 0x0dd0, // XK_Thai_saraa
|
||||
0x0e31: 0x0dd1, // XK_Thai_maihanakat
|
||||
0x0e32: 0x0dd2, // XK_Thai_saraaa
|
||||
0x0e33: 0x0dd3, // XK_Thai_saraam
|
||||
0x0e34: 0x0dd4, // XK_Thai_sarai
|
||||
0x0e35: 0x0dd5, // XK_Thai_saraii
|
||||
0x0e36: 0x0dd6, // XK_Thai_saraue
|
||||
0x0e37: 0x0dd7, // XK_Thai_sarauee
|
||||
0x0e38: 0x0dd8, // XK_Thai_sarau
|
||||
0x0e39: 0x0dd9, // XK_Thai_sarauu
|
||||
0x0e3a: 0x0dda, // XK_Thai_phinthu
|
||||
0x0e3f: 0x0ddf, // XK_Thai_baht
|
||||
0x0e40: 0x0de0, // XK_Thai_sarae
|
||||
0x0e41: 0x0de1, // XK_Thai_saraae
|
||||
0x0e42: 0x0de2, // XK_Thai_sarao
|
||||
0x0e43: 0x0de3, // XK_Thai_saraaimaimuan
|
||||
0x0e44: 0x0de4, // XK_Thai_saraaimaimalai
|
||||
0x0e45: 0x0de5, // XK_Thai_lakkhangyao
|
||||
0x0e46: 0x0de6, // XK_Thai_maiyamok
|
||||
0x0e47: 0x0de7, // XK_Thai_maitaikhu
|
||||
0x0e48: 0x0de8, // XK_Thai_maiek
|
||||
0x0e49: 0x0de9, // XK_Thai_maitho
|
||||
0x0e4a: 0x0dea, // XK_Thai_maitri
|
||||
0x0e4b: 0x0deb, // XK_Thai_maichattawa
|
||||
0x0e4c: 0x0dec, // XK_Thai_thanthakhat
|
||||
0x0e4d: 0x0ded, // XK_Thai_nikhahit
|
||||
0x0e50: 0x0df0, // XK_Thai_leksun
|
||||
0x0e51: 0x0df1, // XK_Thai_leknung
|
||||
0x0e52: 0x0df2, // XK_Thai_leksong
|
||||
0x0e53: 0x0df3, // XK_Thai_leksam
|
||||
0x0e54: 0x0df4, // XK_Thai_leksi
|
||||
0x0e55: 0x0df5, // XK_Thai_lekha
|
||||
0x0e56: 0x0df6, // XK_Thai_lekhok
|
||||
0x0e57: 0x0df7, // XK_Thai_lekchet
|
||||
0x0e58: 0x0df8, // XK_Thai_lekpaet
|
||||
0x0e59: 0x0df9, // XK_Thai_lekkao
|
||||
0x2002: 0x0aa2, // XK_enspace
|
||||
0x2003: 0x0aa1, // XK_emspace
|
||||
0x2004: 0x0aa3, // XK_em3space
|
||||
0x2005: 0x0aa4, // XK_em4space
|
||||
0x2007: 0x0aa5, // XK_digitspace
|
||||
0x2008: 0x0aa6, // XK_punctspace
|
||||
0x2009: 0x0aa7, // XK_thinspace
|
||||
0x200a: 0x0aa8, // XK_hairspace
|
||||
0x2012: 0x0abb, // XK_figdash
|
||||
0x2013: 0x0aaa, // XK_endash
|
||||
0x2014: 0x0aa9, // XK_emdash
|
||||
0x2015: 0x07af, // XK_Greek_horizbar
|
||||
0x2017: 0x0cdf, // XK_hebrew_doublelowline
|
||||
0x2018: 0x0ad0, // XK_leftsinglequotemark
|
||||
0x2019: 0x0ad1, // XK_rightsinglequotemark
|
||||
0x201a: 0x0afd, // XK_singlelowquotemark
|
||||
0x201c: 0x0ad2, // XK_leftdoublequotemark
|
||||
0x201d: 0x0ad3, // XK_rightdoublequotemark
|
||||
0x201e: 0x0afe, // XK_doublelowquotemark
|
||||
0x2020: 0x0af1, // XK_dagger
|
||||
0x2021: 0x0af2, // XK_doubledagger
|
||||
0x2022: 0x0ae6, // XK_enfilledcircbullet
|
||||
0x2025: 0x0aaf, // XK_doubbaselinedot
|
||||
0x2026: 0x0aae, // XK_ellipsis
|
||||
0x2030: 0x0ad5, // XK_permille
|
||||
0x2032: 0x0ad6, // XK_minutes
|
||||
0x2033: 0x0ad7, // XK_seconds
|
||||
0x2038: 0x0afc, // XK_caret
|
||||
0x203e: 0x047e, // XK_overline
|
||||
0x20a9: 0x0eff, // XK_Korean_Won
|
||||
0x20ac: 0x20ac, // XK_EuroSign
|
||||
0x2105: 0x0ab8, // XK_careof
|
||||
0x2116: 0x06b0, // XK_numerosign
|
||||
0x2117: 0x0afb, // XK_phonographcopyright
|
||||
0x211e: 0x0ad4, // XK_prescription
|
||||
0x2122: 0x0ac9, // XK_trademark
|
||||
0x2153: 0x0ab0, // XK_onethird
|
||||
0x2154: 0x0ab1, // XK_twothirds
|
||||
0x2155: 0x0ab2, // XK_onefifth
|
||||
0x2156: 0x0ab3, // XK_twofifths
|
||||
0x2157: 0x0ab4, // XK_threefifths
|
||||
0x2158: 0x0ab5, // XK_fourfifths
|
||||
0x2159: 0x0ab6, // XK_onesixth
|
||||
0x215a: 0x0ab7, // XK_fivesixths
|
||||
0x215b: 0x0ac3, // XK_oneeighth
|
||||
0x215c: 0x0ac4, // XK_threeeighths
|
||||
0x215d: 0x0ac5, // XK_fiveeighths
|
||||
0x215e: 0x0ac6, // XK_seveneighths
|
||||
0x2190: 0x08fb, // XK_leftarrow
|
||||
0x2191: 0x08fc, // XK_uparrow
|
||||
0x2192: 0x08fd, // XK_rightarrow
|
||||
0x2193: 0x08fe, // XK_downarrow
|
||||
0x21d2: 0x08ce, // XK_implies
|
||||
0x21d4: 0x08cd, // XK_ifonlyif
|
||||
0x2202: 0x08ef, // XK_partialderivative
|
||||
0x2207: 0x08c5, // XK_nabla
|
||||
0x2218: 0x0bca, // XK_jot
|
||||
0x221a: 0x08d6, // XK_radical
|
||||
0x221d: 0x08c1, // XK_variation
|
||||
0x221e: 0x08c2, // XK_infinity
|
||||
0x2227: 0x08de, // XK_logicaland
|
||||
0x2228: 0x08df, // XK_logicalor
|
||||
0x2229: 0x08dc, // XK_intersection
|
||||
0x222a: 0x08dd, // XK_union
|
||||
0x222b: 0x08bf, // XK_integral
|
||||
0x2234: 0x08c0, // XK_therefore
|
||||
0x223c: 0x08c8, // XK_approximate
|
||||
0x2243: 0x08c9, // XK_similarequal
|
||||
0x2245: 0x1002248, // XK_approxeq
|
||||
0x2260: 0x08bd, // XK_notequal
|
||||
0x2261: 0x08cf, // XK_identical
|
||||
0x2264: 0x08bc, // XK_lessthanequal
|
||||
0x2265: 0x08be, // XK_greaterthanequal
|
||||
0x2282: 0x08da, // XK_includedin
|
||||
0x2283: 0x08db, // XK_includes
|
||||
0x22a2: 0x0bfc, // XK_righttack
|
||||
0x22a3: 0x0bdc, // XK_lefttack
|
||||
0x22a4: 0x0bc2, // XK_downtack
|
||||
0x22a5: 0x0bce, // XK_uptack
|
||||
0x2308: 0x0bd3, // XK_upstile
|
||||
0x230a: 0x0bc4, // XK_downstile
|
||||
0x2315: 0x0afa, // XK_telephonerecorder
|
||||
0x2320: 0x08a4, // XK_topintegral
|
||||
0x2321: 0x08a5, // XK_botintegral
|
||||
0x2395: 0x0bcc, // XK_quad
|
||||
0x239b: 0x08ab, // XK_topleftparens
|
||||
0x239d: 0x08ac, // XK_botleftparens
|
||||
0x239e: 0x08ad, // XK_toprightparens
|
||||
0x23a0: 0x08ae, // XK_botrightparens
|
||||
0x23a1: 0x08a7, // XK_topleftsqbracket
|
||||
0x23a3: 0x08a8, // XK_botleftsqbracket
|
||||
0x23a4: 0x08a9, // XK_toprightsqbracket
|
||||
0x23a6: 0x08aa, // XK_botrightsqbracket
|
||||
0x23a8: 0x08af, // XK_leftmiddlecurlybrace
|
||||
0x23ac: 0x08b0, // XK_rightmiddlecurlybrace
|
||||
0x23b7: 0x08a1, // XK_leftradical
|
||||
0x23ba: 0x09ef, // XK_horizlinescan1
|
||||
0x23bb: 0x09f0, // XK_horizlinescan3
|
||||
0x23bc: 0x09f2, // XK_horizlinescan7
|
||||
0x23bd: 0x09f3, // XK_horizlinescan9
|
||||
0x2409: 0x09e2, // XK_ht
|
||||
0x240a: 0x09e5, // XK_lf
|
||||
0x240b: 0x09e9, // XK_vt
|
||||
0x240c: 0x09e3, // XK_ff
|
||||
0x240d: 0x09e4, // XK_cr
|
||||
0x2423: 0x0aac, // XK_signifblank
|
||||
0x2424: 0x09e8, // XK_nl
|
||||
0x2500: 0x08a3, // XK_horizconnector
|
||||
0x2502: 0x08a6, // XK_vertconnector
|
||||
0x250c: 0x08a2, // XK_topleftradical
|
||||
0x2510: 0x09eb, // XK_uprightcorner
|
||||
0x2514: 0x09ed, // XK_lowleftcorner
|
||||
0x2518: 0x09ea, // XK_lowrightcorner
|
||||
0x251c: 0x09f4, // XK_leftt
|
||||
0x2524: 0x09f5, // XK_rightt
|
||||
0x252c: 0x09f7, // XK_topt
|
||||
0x2534: 0x09f6, // XK_bott
|
||||
0x253c: 0x09ee, // XK_crossinglines
|
||||
0x2592: 0x09e1, // XK_checkerboard
|
||||
0x25aa: 0x0ae7, // XK_enfilledsqbullet
|
||||
0x25ab: 0x0ae1, // XK_enopensquarebullet
|
||||
0x25ac: 0x0adb, // XK_filledrectbullet
|
||||
0x25ad: 0x0ae2, // XK_openrectbullet
|
||||
0x25ae: 0x0adf, // XK_emfilledrect
|
||||
0x25af: 0x0acf, // XK_emopenrectangle
|
||||
0x25b2: 0x0ae8, // XK_filledtribulletup
|
||||
0x25b3: 0x0ae3, // XK_opentribulletup
|
||||
0x25b6: 0x0add, // XK_filledrighttribullet
|
||||
0x25b7: 0x0acd, // XK_rightopentriangle
|
||||
0x25bc: 0x0ae9, // XK_filledtribulletdown
|
||||
0x25bd: 0x0ae4, // XK_opentribulletdown
|
||||
0x25c0: 0x0adc, // XK_filledlefttribullet
|
||||
0x25c1: 0x0acc, // XK_leftopentriangle
|
||||
0x25c6: 0x09e0, // XK_soliddiamond
|
||||
0x25cb: 0x0ace, // XK_emopencircle
|
||||
0x25cf: 0x0ade, // XK_emfilledcircle
|
||||
0x25e6: 0x0ae0, // XK_enopencircbullet
|
||||
0x2606: 0x0ae5, // XK_openstar
|
||||
0x260e: 0x0af9, // XK_telephone
|
||||
0x2613: 0x0aca, // XK_signaturemark
|
||||
0x261c: 0x0aea, // XK_leftpointer
|
||||
0x261e: 0x0aeb, // XK_rightpointer
|
||||
0x2640: 0x0af8, // XK_femalesymbol
|
||||
0x2642: 0x0af7, // XK_malesymbol
|
||||
0x2663: 0x0aec, // XK_club
|
||||
0x2665: 0x0aee, // XK_heart
|
||||
0x2666: 0x0aed, // XK_diamond
|
||||
0x266d: 0x0af6, // XK_musicalflat
|
||||
0x266f: 0x0af5, // XK_musicalsharp
|
||||
0x2713: 0x0af3, // XK_checkmark
|
||||
0x2717: 0x0af4, // XK_ballotcross
|
||||
0x271d: 0x0ad9, // XK_latincross
|
||||
0x2720: 0x0af0, // XK_maltesecross
|
||||
0x27e8: 0x0abc, // XK_leftanglebracket
|
||||
0x27e9: 0x0abe, // XK_rightanglebracket
|
||||
0x3001: 0x04a4, // XK_kana_comma
|
||||
0x3002: 0x04a1, // XK_kana_fullstop
|
||||
0x300c: 0x04a2, // XK_kana_openingbracket
|
||||
0x300d: 0x04a3, // XK_kana_closingbracket
|
||||
0x309b: 0x04de, // XK_voicedsound
|
||||
0x309c: 0x04df, // XK_semivoicedsound
|
||||
0x30a1: 0x04a7, // XK_kana_a
|
||||
0x30a2: 0x04b1, // XK_kana_A
|
||||
0x30a3: 0x04a8, // XK_kana_i
|
||||
0x30a4: 0x04b2, // XK_kana_I
|
||||
0x30a5: 0x04a9, // XK_kana_u
|
||||
0x30a6: 0x04b3, // XK_kana_U
|
||||
0x30a7: 0x04aa, // XK_kana_e
|
||||
0x30a8: 0x04b4, // XK_kana_E
|
||||
0x30a9: 0x04ab, // XK_kana_o
|
||||
0x30aa: 0x04b5, // XK_kana_O
|
||||
0x30ab: 0x04b6, // XK_kana_KA
|
||||
0x30ad: 0x04b7, // XK_kana_KI
|
||||
0x30af: 0x04b8, // XK_kana_KU
|
||||
0x30b1: 0x04b9, // XK_kana_KE
|
||||
0x30b3: 0x04ba, // XK_kana_KO
|
||||
0x30b5: 0x04bb, // XK_kana_SA
|
||||
0x30b7: 0x04bc, // XK_kana_SHI
|
||||
0x30b9: 0x04bd, // XK_kana_SU
|
||||
0x30bb: 0x04be, // XK_kana_SE
|
||||
0x30bd: 0x04bf, // XK_kana_SO
|
||||
0x30bf: 0x04c0, // XK_kana_TA
|
||||
0x30c1: 0x04c1, // XK_kana_CHI
|
||||
0x30c3: 0x04af, // XK_kana_tsu
|
||||
0x30c4: 0x04c2, // XK_kana_TSU
|
||||
0x30c6: 0x04c3, // XK_kana_TE
|
||||
0x30c8: 0x04c4, // XK_kana_TO
|
||||
0x30ca: 0x04c5, // XK_kana_NA
|
||||
0x30cb: 0x04c6, // XK_kana_NI
|
||||
0x30cc: 0x04c7, // XK_kana_NU
|
||||
0x30cd: 0x04c8, // XK_kana_NE
|
||||
0x30ce: 0x04c9, // XK_kana_NO
|
||||
0x30cf: 0x04ca, // XK_kana_HA
|
||||
0x30d2: 0x04cb, // XK_kana_HI
|
||||
0x30d5: 0x04cc, // XK_kana_FU
|
||||
0x30d8: 0x04cd, // XK_kana_HE
|
||||
0x30db: 0x04ce, // XK_kana_HO
|
||||
0x30de: 0x04cf, // XK_kana_MA
|
||||
0x30df: 0x04d0, // XK_kana_MI
|
||||
0x30e0: 0x04d1, // XK_kana_MU
|
||||
0x30e1: 0x04d2, // XK_kana_ME
|
||||
0x30e2: 0x04d3, // XK_kana_MO
|
||||
0x30e3: 0x04ac, // XK_kana_ya
|
||||
0x30e4: 0x04d4, // XK_kana_YA
|
||||
0x30e5: 0x04ad, // XK_kana_yu
|
||||
0x30e6: 0x04d5, // XK_kana_YU
|
||||
0x30e7: 0x04ae, // XK_kana_yo
|
||||
0x30e8: 0x04d6, // XK_kana_YO
|
||||
0x30e9: 0x04d7, // XK_kana_RA
|
||||
0x30ea: 0x04d8, // XK_kana_RI
|
||||
0x30eb: 0x04d9, // XK_kana_RU
|
||||
0x30ec: 0x04da, // XK_kana_RE
|
||||
0x30ed: 0x04db, // XK_kana_RO
|
||||
0x30ef: 0x04dc, // XK_kana_WA
|
||||
0x30f2: 0x04a6, // XK_kana_WO
|
||||
0x30f3: 0x04dd, // XK_kana_N
|
||||
0x30fb: 0x04a5, // XK_kana_conjunctive
|
||||
0x30fc: 0x04b0, // XK_prolongedsound
|
||||
};
|
||||
|
||||
export default {
|
||||
lookup(u) {
|
||||
// Latin-1 is one-to-one mapping
|
||||
if ((u >= 0x20) && (u <= 0xff)) {
|
||||
return u;
|
||||
}
|
||||
|
||||
// Lookup table (fairly random)
|
||||
const keysym = codepoints[u];
|
||||
if (keysym !== undefined) {
|
||||
return keysym;
|
||||
}
|
||||
|
||||
// General mapping as final fallback
|
||||
return 0x01000000 | u;
|
||||
},
|
||||
};
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import KeyTable from "./keysym.js";
|
||||
import keysyms from "./keysymdef.js";
|
||||
import vkeys from "./vkeys.js";
|
||||
import fixedkeys from "./fixedkeys.js";
|
||||
import DOMKeyTable from "./domkeytable.js";
|
||||
import * as browser from "../util/browser.js";
|
||||
|
||||
// Get 'KeyboardEvent.code', handling legacy browsers
|
||||
export function getKeycode(evt) {
|
||||
// Are we getting proper key identifiers?
|
||||
// (unfortunately Firefox and Chrome are crappy here and gives
|
||||
// us an empty string on some platforms, rather than leaving it
|
||||
// undefined)
|
||||
if (evt.code) {
|
||||
// Mozilla isn't fully in sync with the spec yet
|
||||
switch (evt.code) {
|
||||
case 'OSLeft': return 'MetaLeft';
|
||||
case 'OSRight': return 'MetaRight';
|
||||
}
|
||||
|
||||
return evt.code;
|
||||
}
|
||||
|
||||
// The de-facto standard is to use Windows Virtual-Key codes
|
||||
// in the 'keyCode' field for non-printable characters
|
||||
if (evt.keyCode in vkeys) {
|
||||
let code = vkeys[evt.keyCode];
|
||||
|
||||
// macOS has messed up this code for some reason
|
||||
if (browser.isMac() && (code === 'ContextMenu')) {
|
||||
code = 'MetaRight';
|
||||
}
|
||||
|
||||
// The keyCode doesn't distinguish between left and right
|
||||
// for the standard modifiers
|
||||
if (evt.location === 2) {
|
||||
switch (code) {
|
||||
case 'ShiftLeft': return 'ShiftRight';
|
||||
case 'ControlLeft': return 'ControlRight';
|
||||
case 'AltLeft': return 'AltRight';
|
||||
}
|
||||
}
|
||||
|
||||
// Nor a bunch of the numpad keys
|
||||
if (evt.location === 3) {
|
||||
switch (code) {
|
||||
case 'Delete': return 'NumpadDecimal';
|
||||
case 'Insert': return 'Numpad0';
|
||||
case 'End': return 'Numpad1';
|
||||
case 'ArrowDown': return 'Numpad2';
|
||||
case 'PageDown': return 'Numpad3';
|
||||
case 'ArrowLeft': return 'Numpad4';
|
||||
case 'ArrowRight': return 'Numpad6';
|
||||
case 'Home': return 'Numpad7';
|
||||
case 'ArrowUp': return 'Numpad8';
|
||||
case 'PageUp': return 'Numpad9';
|
||||
case 'Enter': return 'NumpadEnter';
|
||||
}
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
return 'Unidentified';
|
||||
}
|
||||
|
||||
// Get 'KeyboardEvent.key', handling legacy browsers
|
||||
export function getKey(evt) {
|
||||
// Are we getting a proper key value?
|
||||
if ((evt.key !== undefined) && (evt.key !== 'Unidentified')) {
|
||||
// Mozilla isn't fully in sync with the spec yet
|
||||
switch (evt.key) {
|
||||
case 'OS': return 'Meta';
|
||||
case 'LaunchMyComputer': return 'LaunchApplication1';
|
||||
case 'LaunchCalculator': return 'LaunchApplication2';
|
||||
}
|
||||
|
||||
// iOS leaks some OS names
|
||||
switch (evt.key) {
|
||||
case 'UIKeyInputUpArrow': return 'ArrowUp';
|
||||
case 'UIKeyInputDownArrow': return 'ArrowDown';
|
||||
case 'UIKeyInputLeftArrow': return 'ArrowLeft';
|
||||
case 'UIKeyInputRightArrow': return 'ArrowRight';
|
||||
case 'UIKeyInputEscape': return 'Escape';
|
||||
}
|
||||
|
||||
// Broken behaviour in Chrome
|
||||
if ((evt.key === '\x00') && (evt.code === 'NumpadDecimal')) {
|
||||
return 'Delete';
|
||||
}
|
||||
|
||||
return evt.key;
|
||||
}
|
||||
|
||||
// Try to deduce it based on the physical key
|
||||
const code = getKeycode(evt);
|
||||
if (code in fixedkeys) {
|
||||
return fixedkeys[code];
|
||||
}
|
||||
|
||||
// If that failed, then see if we have a printable character
|
||||
if (evt.charCode) {
|
||||
return String.fromCharCode(evt.charCode);
|
||||
}
|
||||
|
||||
// At this point we have nothing left to go on
|
||||
return 'Unidentified';
|
||||
}
|
||||
|
||||
// Get the most reliable keysym value we can get from a key event
|
||||
export function getKeysym(evt) {
|
||||
const key = getKey(evt);
|
||||
|
||||
if (key === 'Unidentified') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// First look up special keys
|
||||
if (key in DOMKeyTable) {
|
||||
let location = evt.location;
|
||||
|
||||
// Safari screws up location for the right cmd key
|
||||
if ((key === 'Meta') && (location === 0)) {
|
||||
location = 2;
|
||||
}
|
||||
|
||||
// And for Clear
|
||||
if ((key === 'Clear') && (location === 3)) {
|
||||
let code = getKeycode(evt);
|
||||
if (code === 'NumLock') {
|
||||
location = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ((location === undefined) || (location > 3)) {
|
||||
location = 0;
|
||||
}
|
||||
|
||||
// The original Meta key now gets confused with the Windows key
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1020141
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1232918
|
||||
if (key === 'Meta') {
|
||||
let code = getKeycode(evt);
|
||||
if (code === 'AltLeft') {
|
||||
return KeyTable.XK_Meta_L;
|
||||
} else if (code === 'AltRight') {
|
||||
return KeyTable.XK_Meta_R;
|
||||
}
|
||||
}
|
||||
|
||||
// macOS has Clear instead of NumLock, but the remote system is
|
||||
// probably not macOS, so lying here is probably best...
|
||||
if (key === 'Clear') {
|
||||
let code = getKeycode(evt);
|
||||
if (code === 'NumLock') {
|
||||
return KeyTable.XK_Num_Lock;
|
||||
}
|
||||
}
|
||||
|
||||
// Windows sends alternating symbols for some keys when using a
|
||||
// Japanese layout. We have no way of synchronising with the IM
|
||||
// running on the remote system, so we send some combined keysym
|
||||
// instead and hope for the best.
|
||||
if (browser.isWindows()) {
|
||||
switch (key) {
|
||||
case 'Zenkaku':
|
||||
case 'Hankaku':
|
||||
return KeyTable.XK_Zenkaku_Hankaku;
|
||||
case 'Romaji':
|
||||
case 'KanaMode':
|
||||
return KeyTable.XK_Romaji;
|
||||
}
|
||||
}
|
||||
|
||||
return DOMKeyTable[key][location];
|
||||
}
|
||||
|
||||
// Now we need to look at the Unicode symbol instead
|
||||
|
||||
// Special key? (FIXME: Should have been caught earlier)
|
||||
if (key.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codepoint = key.charCodeAt();
|
||||
if (codepoint) {
|
||||
return keysyms.lookup(codepoint);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* noVNC: HTML5 VNC client
|
||||
* Copyright (C) 2018 The noVNC authors
|
||||
* Licensed under MPL 2.0 or any later version (see LICENSE.txt)
|
||||
*/
|
||||
|
||||
/*
|
||||
* Mapping between Microsoft® Windows® Virtual-Key codes and
|
||||
* HTML key codes.
|
||||
*/
|
||||
|
||||
export default {
|
||||
0x08: 'Backspace',
|
||||
0x09: 'Tab',
|
||||
0x0a: 'NumpadClear',
|
||||
0x0d: 'Enter',
|
||||
0x10: 'ShiftLeft',
|
||||
0x11: 'ControlLeft',
|
||||
0x12: 'AltLeft',
|
||||
0x13: 'Pause',
|
||||
0x14: 'CapsLock',
|
||||
0x15: 'Lang1',
|
||||
0x19: 'Lang2',
|
||||
0x1b: 'Escape',
|
||||
0x1c: 'Convert',
|
||||
0x1d: 'NonConvert',
|
||||
0x20: 'Space',
|
||||
0x21: 'PageUp',
|
||||
0x22: 'PageDown',
|
||||
0x23: 'End',
|
||||
0x24: 'Home',
|
||||
0x25: 'ArrowLeft',
|
||||
0x26: 'ArrowUp',
|
||||
0x27: 'ArrowRight',
|
||||
0x28: 'ArrowDown',
|
||||
0x29: 'Select',
|
||||
0x2c: 'PrintScreen',
|
||||
0x2d: 'Insert',
|
||||
0x2e: 'Delete',
|
||||
0x2f: 'Help',
|
||||
0x30: 'Digit0',
|
||||
0x31: 'Digit1',
|
||||
0x32: 'Digit2',
|
||||
0x33: 'Digit3',
|
||||
0x34: 'Digit4',
|
||||
0x35: 'Digit5',
|
||||
0x36: 'Digit6',
|
||||
0x37: 'Digit7',
|
||||
0x38: 'Digit8',
|
||||
0x39: 'Digit9',
|
||||
0x5b: 'MetaLeft',
|
||||
0x5c: 'MetaRight',
|
||||
0x5d: 'ContextMenu',
|
||||
0x5f: 'Sleep',
|
||||
0x60: 'Numpad0',
|
||||
0x61: 'Numpad1',
|
||||
0x62: 'Numpad2',
|
||||
0x63: 'Numpad3',
|
||||
0x64: 'Numpad4',
|
||||
0x65: 'Numpad5',
|
||||
0x66: 'Numpad6',
|
||||
0x67: 'Numpad7',
|
||||
0x68: 'Numpad8',
|
||||
0x69: 'Numpad9',
|
||||
0x6a: 'NumpadMultiply',
|
||||
0x6b: 'NumpadAdd',
|
||||
0x6c: 'NumpadDecimal',
|
||||
0x6d: 'NumpadSubtract',
|
||||
0x6e: 'NumpadDecimal', // Duplicate, because buggy on Windows
|
||||
0x6f: 'NumpadDivide',
|
||||
0x70: 'F1',
|
||||
0x71: 'F2',
|
||||
0x72: 'F3',
|
||||
0x73: 'F4',
|
||||
0x74: 'F5',
|
||||
0x75: 'F6',
|
||||
0x76: 'F7',
|
||||
0x77: 'F8',
|
||||
0x78: 'F9',
|
||||
0x79: 'F10',
|
||||
0x7a: 'F11',
|
||||
0x7b: 'F12',
|
||||
0x7c: 'F13',
|
||||
0x7d: 'F14',
|
||||
0x7e: 'F15',
|
||||
0x7f: 'F16',
|
||||
0x80: 'F17',
|
||||
0x81: 'F18',
|
||||
0x82: 'F19',
|
||||
0x83: 'F20',
|
||||
0x84: 'F21',
|
||||
0x85: 'F22',
|
||||
0x86: 'F23',
|
||||
0x87: 'F24',
|
||||
0x90: 'NumLock',
|
||||
0x91: 'ScrollLock',
|
||||
0xa6: 'BrowserBack',
|
||||
0xa7: 'BrowserForward',
|
||||
0xa8: 'BrowserRefresh',
|
||||
0xa9: 'BrowserStop',
|
||||
0xaa: 'BrowserSearch',
|
||||
0xab: 'BrowserFavorites',
|
||||
0xac: 'BrowserHome',
|
||||
0xad: 'AudioVolumeMute',
|
||||
0xae: 'AudioVolumeDown',
|
||||
0xaf: 'AudioVolumeUp',
|
||||
0xb0: 'MediaTrackNext',
|
||||
0xb1: 'MediaTrackPrevious',
|
||||
0xb2: 'MediaStop',
|
||||
0xb3: 'MediaPlayPause',
|
||||
0xb4: 'LaunchMail',
|
||||
0xb5: 'MediaSelect',
|
||||
0xb6: 'LaunchApp1',
|
||||
0xb7: 'LaunchApp2',
|
||||
0xe1: 'AltRight', // Only when it is AltGraph
|
||||
};
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* This file is auto-generated from keymaps.csv
|
||||
* Database checksum sha256(76d68c10e97d37fe2ea459e210125ae41796253fb217e900bf2983ade13a7920)
|
||||
* To re-generate, run:
|
||||
* keymap-gen code-map --lang=js keymaps.csv html atset1
|
||||
*/
|
||||
export default {
|
||||
"Again": 0xe005, /* html:Again (Again) -> linux:129 (KEY_AGAIN) -> atset1:57349 */
|
||||
"AltLeft": 0x38, /* html:AltLeft (AltLeft) -> linux:56 (KEY_LEFTALT) -> atset1:56 */
|
||||
"AltRight": 0xe038, /* html:AltRight (AltRight) -> linux:100 (KEY_RIGHTALT) -> atset1:57400 */
|
||||
"ArrowDown": 0xe050, /* html:ArrowDown (ArrowDown) -> linux:108 (KEY_DOWN) -> atset1:57424 */
|
||||
"ArrowLeft": 0xe04b, /* html:ArrowLeft (ArrowLeft) -> linux:105 (KEY_LEFT) -> atset1:57419 */
|
||||
"ArrowRight": 0xe04d, /* html:ArrowRight (ArrowRight) -> linux:106 (KEY_RIGHT) -> atset1:57421 */
|
||||
"ArrowUp": 0xe048, /* html:ArrowUp (ArrowUp) -> linux:103 (KEY_UP) -> atset1:57416 */
|
||||
"AudioVolumeDown": 0xe02e, /* html:AudioVolumeDown (AudioVolumeDown) -> linux:114 (KEY_VOLUMEDOWN) -> atset1:57390 */
|
||||
"AudioVolumeMute": 0xe020, /* html:AudioVolumeMute (AudioVolumeMute) -> linux:113 (KEY_MUTE) -> atset1:57376 */
|
||||
"AudioVolumeUp": 0xe030, /* html:AudioVolumeUp (AudioVolumeUp) -> linux:115 (KEY_VOLUMEUP) -> atset1:57392 */
|
||||
"Backquote": 0x29, /* html:Backquote (Backquote) -> linux:41 (KEY_GRAVE) -> atset1:41 */
|
||||
"Backslash": 0x2b, /* html:Backslash (Backslash) -> linux:43 (KEY_BACKSLASH) -> atset1:43 */
|
||||
"Backspace": 0xe, /* html:Backspace (Backspace) -> linux:14 (KEY_BACKSPACE) -> atset1:14 */
|
||||
"BracketLeft": 0x1a, /* html:BracketLeft (BracketLeft) -> linux:26 (KEY_LEFTBRACE) -> atset1:26 */
|
||||
"BracketRight": 0x1b, /* html:BracketRight (BracketRight) -> linux:27 (KEY_RIGHTBRACE) -> atset1:27 */
|
||||
"BrowserBack": 0xe06a, /* html:BrowserBack (BrowserBack) -> linux:158 (KEY_BACK) -> atset1:57450 */
|
||||
"BrowserFavorites": 0xe066, /* html:BrowserFavorites (BrowserFavorites) -> linux:156 (KEY_BOOKMARKS) -> atset1:57446 */
|
||||
"BrowserForward": 0xe069, /* html:BrowserForward (BrowserForward) -> linux:159 (KEY_FORWARD) -> atset1:57449 */
|
||||
"BrowserHome": 0xe032, /* html:BrowserHome (BrowserHome) -> linux:172 (KEY_HOMEPAGE) -> atset1:57394 */
|
||||
"BrowserRefresh": 0xe067, /* html:BrowserRefresh (BrowserRefresh) -> linux:173 (KEY_REFRESH) -> atset1:57447 */
|
||||
"BrowserSearch": 0xe065, /* html:BrowserSearch (BrowserSearch) -> linux:217 (KEY_SEARCH) -> atset1:57445 */
|
||||
"BrowserStop": 0xe068, /* html:BrowserStop (BrowserStop) -> linux:128 (KEY_STOP) -> atset1:57448 */
|
||||
"CapsLock": 0x3a, /* html:CapsLock (CapsLock) -> linux:58 (KEY_CAPSLOCK) -> atset1:58 */
|
||||
"Comma": 0x33, /* html:Comma (Comma) -> linux:51 (KEY_COMMA) -> atset1:51 */
|
||||
"ContextMenu": 0xe05d, /* html:ContextMenu (ContextMenu) -> linux:127 (KEY_COMPOSE) -> atset1:57437 */
|
||||
"ControlLeft": 0x1d, /* html:ControlLeft (ControlLeft) -> linux:29 (KEY_LEFTCTRL) -> atset1:29 */
|
||||
"ControlRight": 0xe01d, /* html:ControlRight (ControlRight) -> linux:97 (KEY_RIGHTCTRL) -> atset1:57373 */
|
||||
"Convert": 0x79, /* html:Convert (Convert) -> linux:92 (KEY_HENKAN) -> atset1:121 */
|
||||
"Copy": 0xe078, /* html:Copy (Copy) -> linux:133 (KEY_COPY) -> atset1:57464 */
|
||||
"Cut": 0xe03c, /* html:Cut (Cut) -> linux:137 (KEY_CUT) -> atset1:57404 */
|
||||
"Delete": 0xe053, /* html:Delete (Delete) -> linux:111 (KEY_DELETE) -> atset1:57427 */
|
||||
"Digit0": 0xb, /* html:Digit0 (Digit0) -> linux:11 (KEY_0) -> atset1:11 */
|
||||
"Digit1": 0x2, /* html:Digit1 (Digit1) -> linux:2 (KEY_1) -> atset1:2 */
|
||||
"Digit2": 0x3, /* html:Digit2 (Digit2) -> linux:3 (KEY_2) -> atset1:3 */
|
||||
"Digit3": 0x4, /* html:Digit3 (Digit3) -> linux:4 (KEY_3) -> atset1:4 */
|
||||
"Digit4": 0x5, /* html:Digit4 (Digit4) -> linux:5 (KEY_4) -> atset1:5 */
|
||||
"Digit5": 0x6, /* html:Digit5 (Digit5) -> linux:6 (KEY_5) -> atset1:6 */
|
||||
"Digit6": 0x7, /* html:Digit6 (Digit6) -> linux:7 (KEY_6) -> atset1:7 */
|
||||
"Digit7": 0x8, /* html:Digit7 (Digit7) -> linux:8 (KEY_7) -> atset1:8 */
|
||||
"Digit8": 0x9, /* html:Digit8 (Digit8) -> linux:9 (KEY_8) -> atset1:9 */
|
||||
"Digit9": 0xa, /* html:Digit9 (Digit9) -> linux:10 (KEY_9) -> atset1:10 */
|
||||
"Eject": 0xe07d, /* html:Eject (Eject) -> linux:162 (KEY_EJECTCLOSECD) -> atset1:57469 */
|
||||
"End": 0xe04f, /* html:End (End) -> linux:107 (KEY_END) -> atset1:57423 */
|
||||
"Enter": 0x1c, /* html:Enter (Enter) -> linux:28 (KEY_ENTER) -> atset1:28 */
|
||||
"Equal": 0xd, /* html:Equal (Equal) -> linux:13 (KEY_EQUAL) -> atset1:13 */
|
||||
"Escape": 0x1, /* html:Escape (Escape) -> linux:1 (KEY_ESC) -> atset1:1 */
|
||||
"F1": 0x3b, /* html:F1 (F1) -> linux:59 (KEY_F1) -> atset1:59 */
|
||||
"F10": 0x44, /* html:F10 (F10) -> linux:68 (KEY_F10) -> atset1:68 */
|
||||
"F11": 0x57, /* html:F11 (F11) -> linux:87 (KEY_F11) -> atset1:87 */
|
||||
"F12": 0x58, /* html:F12 (F12) -> linux:88 (KEY_F12) -> atset1:88 */
|
||||
"F13": 0x5d, /* html:F13 (F13) -> linux:183 (KEY_F13) -> atset1:93 */
|
||||
"F14": 0x5e, /* html:F14 (F14) -> linux:184 (KEY_F14) -> atset1:94 */
|
||||
"F15": 0x5f, /* html:F15 (F15) -> linux:185 (KEY_F15) -> atset1:95 */
|
||||
"F16": 0x55, /* html:F16 (F16) -> linux:186 (KEY_F16) -> atset1:85 */
|
||||
"F17": 0xe003, /* html:F17 (F17) -> linux:187 (KEY_F17) -> atset1:57347 */
|
||||
"F18": 0xe077, /* html:F18 (F18) -> linux:188 (KEY_F18) -> atset1:57463 */
|
||||
"F19": 0xe004, /* html:F19 (F19) -> linux:189 (KEY_F19) -> atset1:57348 */
|
||||
"F2": 0x3c, /* html:F2 (F2) -> linux:60 (KEY_F2) -> atset1:60 */
|
||||
"F20": 0x5a, /* html:F20 (F20) -> linux:190 (KEY_F20) -> atset1:90 */
|
||||
"F21": 0x74, /* html:F21 (F21) -> linux:191 (KEY_F21) -> atset1:116 */
|
||||
"F22": 0xe079, /* html:F22 (F22) -> linux:192 (KEY_F22) -> atset1:57465 */
|
||||
"F23": 0x6d, /* html:F23 (F23) -> linux:193 (KEY_F23) -> atset1:109 */
|
||||
"F24": 0x6f, /* html:F24 (F24) -> linux:194 (KEY_F24) -> atset1:111 */
|
||||
"F3": 0x3d, /* html:F3 (F3) -> linux:61 (KEY_F3) -> atset1:61 */
|
||||
"F4": 0x3e, /* html:F4 (F4) -> linux:62 (KEY_F4) -> atset1:62 */
|
||||
"F5": 0x3f, /* html:F5 (F5) -> linux:63 (KEY_F5) -> atset1:63 */
|
||||
"F6": 0x40, /* html:F6 (F6) -> linux:64 (KEY_F6) -> atset1:64 */
|
||||
"F7": 0x41, /* html:F7 (F7) -> linux:65 (KEY_F7) -> atset1:65 */
|
||||
"F8": 0x42, /* html:F8 (F8) -> linux:66 (KEY_F8) -> atset1:66 */
|
||||
"F9": 0x43, /* html:F9 (F9) -> linux:67 (KEY_F9) -> atset1:67 */
|
||||
"Find": 0xe041, /* html:Find (Find) -> linux:136 (KEY_FIND) -> atset1:57409 */
|
||||
"Help": 0xe075, /* html:Help (Help) -> linux:138 (KEY_HELP) -> atset1:57461 */
|
||||
"Hiragana": 0x77, /* html:Hiragana (Lang4) -> linux:91 (KEY_HIRAGANA) -> atset1:119 */
|
||||
"Home": 0xe047, /* html:Home (Home) -> linux:102 (KEY_HOME) -> atset1:57415 */
|
||||
"Insert": 0xe052, /* html:Insert (Insert) -> linux:110 (KEY_INSERT) -> atset1:57426 */
|
||||
"IntlBackslash": 0x56, /* html:IntlBackslash (IntlBackslash) -> linux:86 (KEY_102ND) -> atset1:86 */
|
||||
"IntlRo": 0x73, /* html:IntlRo (IntlRo) -> linux:89 (KEY_RO) -> atset1:115 */
|
||||
"IntlYen": 0x7d, /* html:IntlYen (IntlYen) -> linux:124 (KEY_YEN) -> atset1:125 */
|
||||
"KanaMode": 0x70, /* html:KanaMode (KanaMode) -> linux:93 (KEY_KATAKANAHIRAGANA) -> atset1:112 */
|
||||
"Katakana": 0x78, /* html:Katakana (Lang3) -> linux:90 (KEY_KATAKANA) -> atset1:120 */
|
||||
"KeyA": 0x1e, /* html:KeyA (KeyA) -> linux:30 (KEY_A) -> atset1:30 */
|
||||
"KeyB": 0x30, /* html:KeyB (KeyB) -> linux:48 (KEY_B) -> atset1:48 */
|
||||
"KeyC": 0x2e, /* html:KeyC (KeyC) -> linux:46 (KEY_C) -> atset1:46 */
|
||||
"KeyD": 0x20, /* html:KeyD (KeyD) -> linux:32 (KEY_D) -> atset1:32 */
|
||||
"KeyE": 0x12, /* html:KeyE (KeyE) -> linux:18 (KEY_E) -> atset1:18 */
|
||||
"KeyF": 0x21, /* html:KeyF (KeyF) -> linux:33 (KEY_F) -> atset1:33 */
|
||||
"KeyG": 0x22, /* html:KeyG (KeyG) -> linux:34 (KEY_G) -> atset1:34 */
|
||||
"KeyH": 0x23, /* html:KeyH (KeyH) -> linux:35 (KEY_H) -> atset1:35 */
|
||||
"KeyI": 0x17, /* html:KeyI (KeyI) -> linux:23 (KEY_I) -> atset1:23 */
|
||||
"KeyJ": 0x24, /* html:KeyJ (KeyJ) -> linux:36 (KEY_J) -> atset1:36 */
|
||||
"KeyK": 0x25, /* html:KeyK (KeyK) -> linux:37 (KEY_K) -> atset1:37 */
|
||||
"KeyL": 0x26, /* html:KeyL (KeyL) -> linux:38 (KEY_L) -> atset1:38 */
|
||||
"KeyM": 0x32, /* html:KeyM (KeyM) -> linux:50 (KEY_M) -> atset1:50 */
|
||||
"KeyN": 0x31, /* html:KeyN (KeyN) -> linux:49 (KEY_N) -> atset1:49 */
|
||||
"KeyO": 0x18, /* html:KeyO (KeyO) -> linux:24 (KEY_O) -> atset1:24 */
|
||||
"KeyP": 0x19, /* html:KeyP (KeyP) -> linux:25 (KEY_P) -> atset1:25 */
|
||||
"KeyQ": 0x10, /* html:KeyQ (KeyQ) -> linux:16 (KEY_Q) -> atset1:16 */
|
||||
"KeyR": 0x13, /* html:KeyR (KeyR) -> linux:19 (KEY_R) -> atset1:19 */
|
||||
"KeyS": 0x1f, /* html:KeyS (KeyS) -> linux:31 (KEY_S) -> atset1:31 */
|
||||
"KeyT": 0x14, /* html:KeyT (KeyT) -> linux:20 (KEY_T) -> atset1:20 */
|
||||
"KeyU": 0x16, /* html:KeyU (KeyU) -> linux:22 (KEY_U) -> atset1:22 */
|
||||
"KeyV": 0x2f, /* html:KeyV (KeyV) -> linux:47 (KEY_V) -> atset1:47 */
|
||||
"KeyW": 0x11, /* html:KeyW (KeyW) -> linux:17 (KEY_W) -> atset1:17 */
|
||||
"KeyX": 0x2d, /* html:KeyX (KeyX) -> linux:45 (KEY_X) -> atset1:45 */
|
||||
"KeyY": 0x15, /* html:KeyY (KeyY) -> linux:21 (KEY_Y) -> atset1:21 */
|
||||
"KeyZ": 0x2c, /* html:KeyZ (KeyZ) -> linux:44 (KEY_Z) -> atset1:44 */
|
||||
"Lang1": 0x72, /* html:Lang1 (Lang1) -> linux:122 (KEY_HANGEUL) -> atset1:114 */
|
||||
"Lang2": 0x71, /* html:Lang2 (Lang2) -> linux:123 (KEY_HANJA) -> atset1:113 */
|
||||
"Lang3": 0x78, /* html:Lang3 (Lang3) -> linux:90 (KEY_KATAKANA) -> atset1:120 */
|
||||
"Lang4": 0x77, /* html:Lang4 (Lang4) -> linux:91 (KEY_HIRAGANA) -> atset1:119 */
|
||||
"Lang5": 0x76, /* html:Lang5 (Lang5) -> linux:85 (KEY_ZENKAKUHANKAKU) -> atset1:118 */
|
||||
"LaunchApp1": 0xe06b, /* html:LaunchApp1 (LaunchApp1) -> linux:157 (KEY_COMPUTER) -> atset1:57451 */
|
||||
"LaunchApp2": 0xe021, /* html:LaunchApp2 (LaunchApp2) -> linux:140 (KEY_CALC) -> atset1:57377 */
|
||||
"LaunchMail": 0xe06c, /* html:LaunchMail (LaunchMail) -> linux:155 (KEY_MAIL) -> atset1:57452 */
|
||||
"MediaPlayPause": 0xe022, /* html:MediaPlayPause (MediaPlayPause) -> linux:164 (KEY_PLAYPAUSE) -> atset1:57378 */
|
||||
"MediaSelect": 0xe06d, /* html:MediaSelect (MediaSelect) -> linux:226 (KEY_MEDIA) -> atset1:57453 */
|
||||
"MediaStop": 0xe024, /* html:MediaStop (MediaStop) -> linux:166 (KEY_STOPCD) -> atset1:57380 */
|
||||
"MediaTrackNext": 0xe019, /* html:MediaTrackNext (MediaTrackNext) -> linux:163 (KEY_NEXTSONG) -> atset1:57369 */
|
||||
"MediaTrackPrevious": 0xe010, /* html:MediaTrackPrevious (MediaTrackPrevious) -> linux:165 (KEY_PREVIOUSSONG) -> atset1:57360 */
|
||||
"MetaLeft": 0xe05b, /* html:MetaLeft (MetaLeft) -> linux:125 (KEY_LEFTMETA) -> atset1:57435 */
|
||||
"MetaRight": 0xe05c, /* html:MetaRight (MetaRight) -> linux:126 (KEY_RIGHTMETA) -> atset1:57436 */
|
||||
"Minus": 0xc, /* html:Minus (Minus) -> linux:12 (KEY_MINUS) -> atset1:12 */
|
||||
"NonConvert": 0x7b, /* html:NonConvert (NonConvert) -> linux:94 (KEY_MUHENKAN) -> atset1:123 */
|
||||
"NumLock": 0x45, /* html:NumLock (NumLock) -> linux:69 (KEY_NUMLOCK) -> atset1:69 */
|
||||
"Numpad0": 0x52, /* html:Numpad0 (Numpad0) -> linux:82 (KEY_KP0) -> atset1:82 */
|
||||
"Numpad1": 0x4f, /* html:Numpad1 (Numpad1) -> linux:79 (KEY_KP1) -> atset1:79 */
|
||||
"Numpad2": 0x50, /* html:Numpad2 (Numpad2) -> linux:80 (KEY_KP2) -> atset1:80 */
|
||||
"Numpad3": 0x51, /* html:Numpad3 (Numpad3) -> linux:81 (KEY_KP3) -> atset1:81 */
|
||||
"Numpad4": 0x4b, /* html:Numpad4 (Numpad4) -> linux:75 (KEY_KP4) -> atset1:75 */
|
||||
"Numpad5": 0x4c, /* html:Numpad5 (Numpad5) -> linux:76 (KEY_KP5) -> atset1:76 */
|
||||
"Numpad6": 0x4d, /* html:Numpad6 (Numpad6) -> linux:77 (KEY_KP6) -> atset1:77 */
|
||||
"Numpad7": 0x47, /* html:Numpad7 (Numpad7) -> linux:71 (KEY_KP7) -> atset1:71 */
|
||||
"Numpad8": 0x48, /* html:Numpad8 (Numpad8) -> linux:72 (KEY_KP8) -> atset1:72 */
|
||||
"Numpad9": 0x49, /* html:Numpad9 (Numpad9) -> linux:73 (KEY_KP9) -> atset1:73 */
|
||||
"NumpadAdd": 0x4e, /* html:NumpadAdd (NumpadAdd) -> linux:78 (KEY_KPPLUS) -> atset1:78 */
|
||||
"NumpadComma": 0x7e, /* html:NumpadComma (NumpadComma) -> linux:121 (KEY_KPCOMMA) -> atset1:126 */
|
||||
"NumpadDecimal": 0x53, /* html:NumpadDecimal (NumpadDecimal) -> linux:83 (KEY_KPDOT) -> atset1:83 */
|
||||
"NumpadDivide": 0xe035, /* html:NumpadDivide (NumpadDivide) -> linux:98 (KEY_KPSLASH) -> atset1:57397 */
|
||||
"NumpadEnter": 0xe01c, /* html:NumpadEnter (NumpadEnter) -> linux:96 (KEY_KPENTER) -> atset1:57372 */
|
||||
"NumpadEqual": 0x59, /* html:NumpadEqual (NumpadEqual) -> linux:117 (KEY_KPEQUAL) -> atset1:89 */
|
||||
"NumpadMultiply": 0x37, /* html:NumpadMultiply (NumpadMultiply) -> linux:55 (KEY_KPASTERISK) -> atset1:55 */
|
||||
"NumpadParenLeft": 0xe076, /* html:NumpadParenLeft (NumpadParenLeft) -> linux:179 (KEY_KPLEFTPAREN) -> atset1:57462 */
|
||||
"NumpadParenRight": 0xe07b, /* html:NumpadParenRight (NumpadParenRight) -> linux:180 (KEY_KPRIGHTPAREN) -> atset1:57467 */
|
||||
"NumpadSubtract": 0x4a, /* html:NumpadSubtract (NumpadSubtract) -> linux:74 (KEY_KPMINUS) -> atset1:74 */
|
||||
"Open": 0x64, /* html:Open (Open) -> linux:134 (KEY_OPEN) -> atset1:100 */
|
||||
"PageDown": 0xe051, /* html:PageDown (PageDown) -> linux:109 (KEY_PAGEDOWN) -> atset1:57425 */
|
||||
"PageUp": 0xe049, /* html:PageUp (PageUp) -> linux:104 (KEY_PAGEUP) -> atset1:57417 */
|
||||
"Paste": 0x65, /* html:Paste (Paste) -> linux:135 (KEY_PASTE) -> atset1:101 */
|
||||
"Pause": 0xe046, /* html:Pause (Pause) -> linux:119 (KEY_PAUSE) -> atset1:57414 */
|
||||
"Period": 0x34, /* html:Period (Period) -> linux:52 (KEY_DOT) -> atset1:52 */
|
||||
"Power": 0xe05e, /* html:Power (Power) -> linux:116 (KEY_POWER) -> atset1:57438 */
|
||||
"PrintScreen": 0x54, /* html:PrintScreen (PrintScreen) -> linux:99 (KEY_SYSRQ) -> atset1:84 */
|
||||
"Props": 0xe006, /* html:Props (Props) -> linux:130 (KEY_PROPS) -> atset1:57350 */
|
||||
"Quote": 0x28, /* html:Quote (Quote) -> linux:40 (KEY_APOSTROPHE) -> atset1:40 */
|
||||
"ScrollLock": 0x46, /* html:ScrollLock (ScrollLock) -> linux:70 (KEY_SCROLLLOCK) -> atset1:70 */
|
||||
"Semicolon": 0x27, /* html:Semicolon (Semicolon) -> linux:39 (KEY_SEMICOLON) -> atset1:39 */
|
||||
"ShiftLeft": 0x2a, /* html:ShiftLeft (ShiftLeft) -> linux:42 (KEY_LEFTSHIFT) -> atset1:42 */
|
||||
"ShiftRight": 0x36, /* html:ShiftRight (ShiftRight) -> linux:54 (KEY_RIGHTSHIFT) -> atset1:54 */
|
||||
"Slash": 0x35, /* html:Slash (Slash) -> linux:53 (KEY_SLASH) -> atset1:53 */
|
||||
"Sleep": 0xe05f, /* html:Sleep (Sleep) -> linux:142 (KEY_SLEEP) -> atset1:57439 */
|
||||
"Space": 0x39, /* html:Space (Space) -> linux:57 (KEY_SPACE) -> atset1:57 */
|
||||
"Suspend": 0xe025, /* html:Suspend (Suspend) -> linux:205 (KEY_SUSPEND) -> atset1:57381 */
|
||||
"Tab": 0xf, /* html:Tab (Tab) -> linux:15 (KEY_TAB) -> atset1:15 */
|
||||
"Undo": 0xe007, /* html:Undo (Undo) -> linux:131 (KEY_UNDO) -> atset1:57351 */
|
||||
"WakeUp": 0xe063, /* html:WakeUp (WakeUp) -> linux:143 (KEY_WAKEUP) -> atset1:57443 */
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
import { encodeUTF8 } from './util/strings.js';
|
||||
import EventTargetMixin from './util/eventtarget.js';
|
||||
import legacyCrypto from './crypto/crypto.js';
|
||||
|
||||
class RA2Cipher {
|
||||
constructor() {
|
||||
this._cipher = null;
|
||||
this._counter = new Uint8Array(16);
|
||||
}
|
||||
|
||||
async setKey(key) {
|
||||
this._cipher = await legacyCrypto.importKey(
|
||||
"raw", key, { name: "AES-EAX" }, false, ["encrypt, decrypt"]);
|
||||
}
|
||||
|
||||
async makeMessage(message) {
|
||||
const ad = new Uint8Array([(message.length & 0xff00) >>> 8, message.length & 0xff]);
|
||||
const encrypted = await legacyCrypto.encrypt({
|
||||
name: "AES-EAX",
|
||||
iv: this._counter,
|
||||
additionalData: ad,
|
||||
}, this._cipher, message);
|
||||
for (let i = 0; i < 16 && this._counter[i]++ === 255; i++);
|
||||
const res = new Uint8Array(message.length + 2 + 16);
|
||||
res.set(ad);
|
||||
res.set(encrypted, 2);
|
||||
return res;
|
||||
}
|
||||
|
||||
async receiveMessage(length, encrypted) {
|
||||
const ad = new Uint8Array([(length & 0xff00) >>> 8, length & 0xff]);
|
||||
const res = await legacyCrypto.decrypt({
|
||||
name: "AES-EAX",
|
||||
iv: this._counter,
|
||||
additionalData: ad,
|
||||
}, this._cipher, encrypted);
|
||||
for (let i = 0; i < 16 && this._counter[i]++ === 255; i++);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export default class RSAAESAuthenticationState extends EventTargetMixin {
|
||||
constructor(sock, getCredentials) {
|
||||
super();
|
||||
this._hasStarted = false;
|
||||
this._checkSock = null;
|
||||
this._checkCredentials = null;
|
||||
this._approveServerResolve = null;
|
||||
this._sockReject = null;
|
||||
this._credentialsReject = null;
|
||||
this._approveServerReject = null;
|
||||
this._sock = sock;
|
||||
this._getCredentials = getCredentials;
|
||||
}
|
||||
|
||||
_waitSockAsync(len) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hasData = () => !this._sock.rQwait('RA2', len);
|
||||
if (hasData()) {
|
||||
resolve();
|
||||
} else {
|
||||
this._checkSock = () => {
|
||||
if (hasData()) {
|
||||
resolve();
|
||||
this._checkSock = null;
|
||||
this._sockReject = null;
|
||||
}
|
||||
};
|
||||
this._sockReject = reject;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_waitApproveKeyAsync() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._approveServerResolve = resolve;
|
||||
this._approveServerReject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
_waitCredentialsAsync(subtype) {
|
||||
const hasCredentials = () => {
|
||||
if (subtype === 1 && this._getCredentials().username !== undefined &&
|
||||
this._getCredentials().password !== undefined) {
|
||||
return true;
|
||||
} else if (subtype === 2 && this._getCredentials().password !== undefined) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
if (hasCredentials()) {
|
||||
resolve();
|
||||
} else {
|
||||
this._checkCredentials = () => {
|
||||
if (hasCredentials()) {
|
||||
resolve();
|
||||
this._checkCredentials = null;
|
||||
this._credentialsReject = null;
|
||||
}
|
||||
};
|
||||
this._credentialsReject = reject;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkInternalEvents() {
|
||||
if (this._checkSock !== null) {
|
||||
this._checkSock();
|
||||
}
|
||||
if (this._checkCredentials !== null) {
|
||||
this._checkCredentials();
|
||||
}
|
||||
}
|
||||
|
||||
approveServer() {
|
||||
if (this._approveServerResolve !== null) {
|
||||
this._approveServerResolve();
|
||||
this._approveServerResolve = null;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this._sockReject !== null) {
|
||||
this._sockReject(new Error("disconnect normally"));
|
||||
this._sockReject = null;
|
||||
}
|
||||
if (this._credentialsReject !== null) {
|
||||
this._credentialsReject(new Error("disconnect normally"));
|
||||
this._credentialsReject = null;
|
||||
}
|
||||
if (this._approveServerReject !== null) {
|
||||
this._approveServerReject(new Error("disconnect normally"));
|
||||
this._approveServerReject = null;
|
||||
}
|
||||
}
|
||||
|
||||
async negotiateRA2neAuthAsync() {
|
||||
this._hasStarted = true;
|
||||
// 1: Receive server public key
|
||||
await this._waitSockAsync(4);
|
||||
const serverKeyLengthBuffer = this._sock.rQpeekBytes(4);
|
||||
const serverKeyLength = this._sock.rQshift32();
|
||||
if (serverKeyLength < 1024) {
|
||||
throw new Error("RA2: server public key is too short: " + serverKeyLength);
|
||||
} else if (serverKeyLength > 8192) {
|
||||
throw new Error("RA2: server public key is too long: " + serverKeyLength);
|
||||
}
|
||||
const serverKeyBytes = Math.ceil(serverKeyLength / 8);
|
||||
await this._waitSockAsync(serverKeyBytes * 2);
|
||||
const serverN = this._sock.rQshiftBytes(serverKeyBytes);
|
||||
const serverE = this._sock.rQshiftBytes(serverKeyBytes);
|
||||
const serverRSACipher = await legacyCrypto.importKey(
|
||||
"raw", { n: serverN, e: serverE }, { name: "RSA-PKCS1-v1_5" }, false, ["encrypt"]);
|
||||
const serverPublickey = new Uint8Array(4 + serverKeyBytes * 2);
|
||||
serverPublickey.set(serverKeyLengthBuffer);
|
||||
serverPublickey.set(serverN, 4);
|
||||
serverPublickey.set(serverE, 4 + serverKeyBytes);
|
||||
|
||||
// verify server public key
|
||||
let approveKey = this._waitApproveKeyAsync();
|
||||
this.dispatchEvent(new CustomEvent("serververification", {
|
||||
detail: { type: "RSA", publickey: serverPublickey }
|
||||
}));
|
||||
await approveKey;
|
||||
|
||||
// 2: Send client public key
|
||||
const clientKeyLength = 2048;
|
||||
const clientKeyBytes = Math.ceil(clientKeyLength / 8);
|
||||
const clientRSACipher = (await legacyCrypto.generateKey({
|
||||
name: "RSA-PKCS1-v1_5",
|
||||
modulusLength: clientKeyLength,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
}, true, ["encrypt"])).privateKey;
|
||||
const clientExportedRSAKey = await legacyCrypto.exportKey("raw", clientRSACipher);
|
||||
const clientN = clientExportedRSAKey.n;
|
||||
const clientE = clientExportedRSAKey.e;
|
||||
const clientPublicKey = new Uint8Array(4 + clientKeyBytes * 2);
|
||||
clientPublicKey[0] = (clientKeyLength & 0xff000000) >>> 24;
|
||||
clientPublicKey[1] = (clientKeyLength & 0xff0000) >>> 16;
|
||||
clientPublicKey[2] = (clientKeyLength & 0xff00) >>> 8;
|
||||
clientPublicKey[3] = clientKeyLength & 0xff;
|
||||
clientPublicKey.set(clientN, 4);
|
||||
clientPublicKey.set(clientE, 4 + clientKeyBytes);
|
||||
this._sock.sQpushBytes(clientPublicKey);
|
||||
this._sock.flush();
|
||||
|
||||
// 3: Send client random
|
||||
const clientRandom = new Uint8Array(16);
|
||||
window.crypto.getRandomValues(clientRandom);
|
||||
const clientEncryptedRandom = await legacyCrypto.encrypt(
|
||||
{ name: "RSA-PKCS1-v1_5" }, serverRSACipher, clientRandom);
|
||||
const clientRandomMessage = new Uint8Array(2 + serverKeyBytes);
|
||||
clientRandomMessage[0] = (serverKeyBytes & 0xff00) >>> 8;
|
||||
clientRandomMessage[1] = serverKeyBytes & 0xff;
|
||||
clientRandomMessage.set(clientEncryptedRandom, 2);
|
||||
this._sock.sQpushBytes(clientRandomMessage);
|
||||
this._sock.flush();
|
||||
|
||||
// 4: Receive server random
|
||||
await this._waitSockAsync(2);
|
||||
if (this._sock.rQshift16() !== clientKeyBytes) {
|
||||
throw new Error("RA2: wrong encrypted message length");
|
||||
}
|
||||
const serverEncryptedRandom = this._sock.rQshiftBytes(clientKeyBytes);
|
||||
const serverRandom = await legacyCrypto.decrypt(
|
||||
{ name: "RSA-PKCS1-v1_5" }, clientRSACipher, serverEncryptedRandom);
|
||||
if (serverRandom === null || serverRandom.length !== 16) {
|
||||
throw new Error("RA2: corrupted server encrypted random");
|
||||
}
|
||||
|
||||
// 5: Compute session keys and set ciphers
|
||||
let clientSessionKey = new Uint8Array(32);
|
||||
let serverSessionKey = new Uint8Array(32);
|
||||
clientSessionKey.set(serverRandom);
|
||||
clientSessionKey.set(clientRandom, 16);
|
||||
serverSessionKey.set(clientRandom);
|
||||
serverSessionKey.set(serverRandom, 16);
|
||||
clientSessionKey = await window.crypto.subtle.digest("SHA-1", clientSessionKey);
|
||||
clientSessionKey = new Uint8Array(clientSessionKey).slice(0, 16);
|
||||
serverSessionKey = await window.crypto.subtle.digest("SHA-1", serverSessionKey);
|
||||
serverSessionKey = new Uint8Array(serverSessionKey).slice(0, 16);
|
||||
const clientCipher = new RA2Cipher();
|
||||
await clientCipher.setKey(clientSessionKey);
|
||||
const serverCipher = new RA2Cipher();
|
||||
await serverCipher.setKey(serverSessionKey);
|
||||
|
||||
// 6: Compute and exchange hashes
|
||||
let serverHash = new Uint8Array(8 + serverKeyBytes * 2 + clientKeyBytes * 2);
|
||||
let clientHash = new Uint8Array(8 + serverKeyBytes * 2 + clientKeyBytes * 2);
|
||||
serverHash.set(serverPublickey);
|
||||
serverHash.set(clientPublicKey, 4 + serverKeyBytes * 2);
|
||||
clientHash.set(clientPublicKey);
|
||||
clientHash.set(serverPublickey, 4 + clientKeyBytes * 2);
|
||||
serverHash = await window.crypto.subtle.digest("SHA-1", serverHash);
|
||||
clientHash = await window.crypto.subtle.digest("SHA-1", clientHash);
|
||||
serverHash = new Uint8Array(serverHash);
|
||||
clientHash = new Uint8Array(clientHash);
|
||||
this._sock.sQpushBytes(await clientCipher.makeMessage(clientHash));
|
||||
this._sock.flush();
|
||||
await this._waitSockAsync(2 + 20 + 16);
|
||||
if (this._sock.rQshift16() !== 20) {
|
||||
throw new Error("RA2: wrong server hash");
|
||||
}
|
||||
const serverHashReceived = await serverCipher.receiveMessage(
|
||||
20, this._sock.rQshiftBytes(20 + 16));
|
||||
if (serverHashReceived === null) {
|
||||
throw new Error("RA2: failed to authenticate the message");
|
||||
}
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (serverHashReceived[i] !== serverHash[i]) {
|
||||
throw new Error("RA2: wrong server hash");
|
||||
}
|
||||
}
|
||||
|
||||
// 7: Receive subtype
|
||||
await this._waitSockAsync(2 + 1 + 16);
|
||||
if (this._sock.rQshift16() !== 1) {
|
||||
throw new Error("RA2: wrong subtype");
|
||||
}
|
||||
let subtype = (await serverCipher.receiveMessage(
|
||||
1, this._sock.rQshiftBytes(1 + 16)));
|
||||
if (subtype === null) {
|
||||
throw new Error("RA2: failed to authenticate the message");
|
||||
}
|
||||
subtype = subtype[0];
|
||||
let waitCredentials = this._waitCredentialsAsync(subtype);
|
||||
if (subtype === 1) {
|
||||
if (this._getCredentials().username === undefined ||
|
||||
this._getCredentials().password === undefined) {
|
||||
this.dispatchEvent(new CustomEvent(
|
||||
"credentialsrequired",
|
||||
{ detail: { types: ["username", "password"] } }));
|
||||
}
|
||||
} else if (subtype === 2) {
|
||||
if (this._getCredentials().password === undefined) {
|
||||
this.dispatchEvent(new CustomEvent(
|
||||
"credentialsrequired",
|
||||
{ detail: { types: ["password"] } }));
|
||||
}
|
||||
} else {
|
||||
throw new Error("RA2: wrong subtype");
|
||||
}
|
||||
await waitCredentials;
|
||||
let username;
|
||||
if (subtype === 1) {
|
||||
username = encodeUTF8(this._getCredentials().username).slice(0, 255);
|
||||
} else {
|
||||
username = "";
|
||||
}
|
||||
const password = encodeUTF8(this._getCredentials().password).slice(0, 255);
|
||||
const credentials = new Uint8Array(username.length + password.length + 2);
|
||||
credentials[0] = username.length;
|
||||
credentials[username.length + 1] = password.length;
|
||||
for (let i = 0; i < username.length; i++) {
|
||||
credentials[i + 1] = username.charCodeAt(i);
|
||||
}
|
||||
for (let i = 0; i < password.length; i++) {
|
||||
credentials[username.length + 2 + i] = password.charCodeAt(i);
|
||||
}
|
||||
this._sock.sQpushBytes(await clientCipher.makeMessage(credentials));
|
||||
this._sock.flush();
|
||||
}
|
||||
|
||||
get hasStarted() {
|
||||
return this._hasStarted;
|
||||
}
|
||||
|
||||
set hasStarted(s) {
|
||||
this._hasStarted = s;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user