Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ae69e3b7 | ||
|
|
d3c700283a |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.5.0"
|
version = "2.5.2"
|
||||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ CREDENTIALS = Resource(
|
|||||||
|
|
||||||
MAINTENANCE = Resource(
|
MAINTENANCE = Resource(
|
||||||
key="maintenance",
|
key="maintenance",
|
||||||
label="Wartung",
|
# Nicht "Wartung": so heisst der Bereich. Die Zeile sagt, was sie erlaubt.
|
||||||
|
label="Aktualisierung und Neustart",
|
||||||
actions=(A.VIEW, A.EXECUTE),
|
actions=(A.VIEW, A.EXECUTE),
|
||||||
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
|
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
|
||||||
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."},
|
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."},
|
||||||
@@ -128,9 +129,22 @@ MONITORING_AREA = Area(
|
|||||||
key="monitoring",
|
key="monitoring",
|
||||||
label="Überwachung",
|
label="Überwachung",
|
||||||
icon="cpu",
|
icon="cpu",
|
||||||
hint="Geraete, Switche, Zugangsdaten und Wartung. Ohne diesen Zugang bleibt der "
|
hint="Geraete, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
|
||||||
"gesamte Bereich verborgen.",
|
"gesamte Bereich verborgen.",
|
||||||
resources=(DEVICES, SWITCHES, CREDENTIALS, MAINTENANCE),
|
resources=(DEVICES, SWITCHES, CREDENTIALS),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als
|
||||||
|
#: eigener Punkt im Menue. Der Bereichsschluessel heisst ``servicing``, weil
|
||||||
|
#: ``maintenance`` schon die Ressource ist und Bereiche und Ressourcen sich
|
||||||
|
#: einen Namensraum teilen. Nach aussen heisst beides "Wartung".
|
||||||
|
MAINTENANCE_AREA = Area(
|
||||||
|
key="servicing",
|
||||||
|
label="Wartung",
|
||||||
|
icon="wrench",
|
||||||
|
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
|
||||||
|
"verborgen -- auch wenn einzelne Rechte darunter gesetzt sind.",
|
||||||
|
resources=(MAINTENANCE,),
|
||||||
)
|
)
|
||||||
|
|
||||||
NETWORK_AREA = Area(
|
NETWORK_AREA = Area(
|
||||||
@@ -150,6 +164,7 @@ FILES_AREA = Area(
|
|||||||
PERMISSIONS = PermissionTree(
|
PERMISSIONS = PermissionTree(
|
||||||
[
|
[
|
||||||
MONITORING_AREA,
|
MONITORING_AREA,
|
||||||
|
MAINTENANCE_AREA,
|
||||||
NETWORK_AREA,
|
NETWORK_AREA,
|
||||||
FILES_AREA,
|
FILES_AREA,
|
||||||
observability_area(LOGS_DHCP),
|
observability_area(LOGS_DHCP),
|
||||||
|
|||||||
@@ -289,10 +289,30 @@ DELETE FROM settings WHERE key = 'terminal_enabled';
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Wartung ist ein eigener Bereich geworden und hat damit einen eigenen
|
||||||
|
# Kill-Switch. Ohne diesen Schritt waere sie nach dem Update bei allen weg:
|
||||||
|
# das neue Bereichsrecht ist bei niemandem gesetzt, und ohne Bereichsrecht
|
||||||
|
# greift kein Recht darunter.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
V6 = """
|
||||||
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
|
SELECT DISTINCT group_id, 'servicing.view'
|
||||||
|
FROM group_permissions
|
||||||
|
WHERE permission IN ('maintenance.view', 'maintenance.execute')
|
||||||
|
AND group_id IN (
|
||||||
|
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.view'
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
||||||
Migration(version=1, name="baseline", sql=V1),
|
Migration(version=1, name="baseline", sql=V1),
|
||||||
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
||||||
Migration(version=3, name="restart-events", sql=V3),
|
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),
|
||||||
Migration(version=5, name="terminal-getrennt", sql=V5),
|
Migration(version=5, name="terminal-getrennt", sql=V5),
|
||||||
|
Migration(version=6, name="wartung-eigener-bereich", sql=V6),
|
||||||
)
|
)
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.5.0"
|
version = "2.5.2"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import secrets
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
from flask import Blueprint, Flask, g, render_template, request
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from flask import Blueprint, Flask, g, redirect, render_template, request
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
from . import audit, logging_setup, ui
|
from . import audit, legacy_urls, logging_setup, ui
|
||||||
from .config import CoreConfig
|
from .config import CoreConfig
|
||||||
from .core_schema import CORE_MIGRATIONS
|
from .core_schema import CORE_MIGRATIONS
|
||||||
from .db import Database
|
from .db import Database
|
||||||
@@ -146,7 +148,7 @@ def create_base_app(
|
|||||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||||
#: Anwendungen.
|
#: Anwendungen.
|
||||||
auth_tagline: str = "",
|
auth_tagline: str = "",
|
||||||
app_version: str = "2.5.0",
|
app_version: str = "2.5.2",
|
||||||
built_in_trusted_keys: tuple[str, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
@@ -356,6 +358,16 @@ def create_base_app(
|
|||||||
# -- Fehlerseiten ------------------------------------------------------ #
|
# -- Fehlerseiten ------------------------------------------------------ #
|
||||||
@app.errorhandler(HTTPException)
|
@app.errorhandler(HTTPException)
|
||||||
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
||||||
|
if error.code == 404:
|
||||||
|
# Vor der Fehlerseite: war das eine Adresse aus der Zeit vor 2.5.0?
|
||||||
|
# Erst hier gefragt, damit die Tabelle nie eine bestehende Route
|
||||||
|
# verdeckt -- auf dem Lizenzserver ist /lizenz/<ticket> echt.
|
||||||
|
ziel = legacy_urls.neue_adresse(request.path)
|
||||||
|
if ziel is not None:
|
||||||
|
parameter = legacy_urls.neue_parameter(request.args.to_dict(flat=True))
|
||||||
|
if parameter:
|
||||||
|
ziel = ziel + "?" + urlencode(parameter)
|
||||||
|
return redirect(ziel)
|
||||||
if error.code == 413:
|
if error.code == 413:
|
||||||
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
||||||
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Alte, deutsche Adressen auf die neuen englischen umlenken.
|
||||||
|
|
||||||
|
Mit 2.5.0 folgen die Adressen der Navigation und sind englisch:
|
||||||
|
``/monitoring/host-keys`` statt ``/hostschluessel``. Das ist eine gute
|
||||||
|
Aenderung und eine unangenehme zugleich -- Lesezeichen, offene Browsertabs und
|
||||||
|
ein Link in einer Mail zeigen weiter auf die alte Adresse. Ein offener Tab ist
|
||||||
|
dabei der unangenehmste Fall: die Statusuebersicht fragt alle 20 Sekunden ihre
|
||||||
|
Kacheln ab und bekam nach dem Update jedes Mal einen 404.
|
||||||
|
|
||||||
|
Deshalb diese Tabelle. Sie greift **nur, wenn sonst ein 404 herauskaeme** --
|
||||||
|
also nie vor einer bestehenden Route. Das ist keine Feinheit: auf dem
|
||||||
|
Lizenzserver ist ``/lizenz/<ticket>`` eine echte, weiterhin gueltige Adresse
|
||||||
|
(sie steht in verschickten Mails), waehrend dieselbe Wurzel in TESM zur
|
||||||
|
Lizenzseite fuehrte. Wer zuerst das Routing fragt, muss sich nicht zwischen
|
||||||
|
beidem entscheiden.
|
||||||
|
|
||||||
|
Die Tabelle ist bewusst vollstaendig und nicht nur auf Wurzeln gekuerzt: eine
|
||||||
|
Umleitung von ``/dateifreigaben/x/vorschau`` auf eine Uebersichtsseite waere
|
||||||
|
zwar kein 404 mehr, aber auch nicht das, was jemand angeklickt hat.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
#: Alte Wurzel -> (neue Wurzel, Uebersetzung der Segmente darunter).
|
||||||
|
WURZELN: dict[str, tuple[str, Mapping[str, str]]] = {
|
||||||
|
"/dateifreigaben": (
|
||||||
|
"/fileshares",
|
||||||
|
{
|
||||||
|
"herunterladen": "download",
|
||||||
|
"vorschau": "preview",
|
||||||
|
"hochladen": "upload",
|
||||||
|
"formular": "form",
|
||||||
|
"ordner": "folder",
|
||||||
|
"umbenennen": "rename",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"freigeben": "release",
|
||||||
|
"zuordnungen": "mappings",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/hostschluessel": (
|
||||||
|
"/monitoring/host-keys",
|
||||||
|
{"pruefen": "probe", "freigeben": "trust", "verwerfen": "forget", "verwaist": "orphaned"},
|
||||||
|
),
|
||||||
|
"/zugangsdaten": (
|
||||||
|
"/monitoring/credentials",
|
||||||
|
{"neu": "new", "loeschen": "delete", "anzeigen": "reveal"},
|
||||||
|
),
|
||||||
|
"/switche": (
|
||||||
|
"/monitoring/switches",
|
||||||
|
{
|
||||||
|
"neu": "new",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"hostkey": "host-key",
|
||||||
|
"pruefen": "probe",
|
||||||
|
"freigeben": "trust",
|
||||||
|
"verwerfen": "forget",
|
||||||
|
"kommando": "command",
|
||||||
|
"konsole": "console",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/clients": (
|
||||||
|
"/monitoring/clients",
|
||||||
|
{
|
||||||
|
"neu": "new",
|
||||||
|
"aktiv": "active",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"neustart": "restart",
|
||||||
|
"hostkey": "host-key",
|
||||||
|
"pruefen": "probe",
|
||||||
|
"freigeben": "trust",
|
||||||
|
"verwerfen": "forget",
|
||||||
|
"konsole": "console",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/wartung": (
|
||||||
|
"/maintenance",
|
||||||
|
{
|
||||||
|
"paket-nachinstallieren": "install-package",
|
||||||
|
"auftrag": "job",
|
||||||
|
"ausgabe": "output",
|
||||||
|
"aktualisieren": "update",
|
||||||
|
"neustarten": "restart",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/protokolle": (
|
||||||
|
"/logs",
|
||||||
|
{
|
||||||
|
"kea": "dhcp",
|
||||||
|
"neustarts": "restarts",
|
||||||
|
"laufend": "live",
|
||||||
|
"inhalt": "content",
|
||||||
|
"verlauf": "history",
|
||||||
|
"datei": "file",
|
||||||
|
"archiv": "archive",
|
||||||
|
"archiv-export": "archive-export",
|
||||||
|
"archiv-loeschen": "archive-delete",
|
||||||
|
"aenderungen": "audit",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/verwaltung": (
|
||||||
|
"/administration",
|
||||||
|
{
|
||||||
|
"benutzer": "users",
|
||||||
|
"neu": "new",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"sitzungen-beenden": "end-sessions",
|
||||||
|
"verzeichnis-suche": "directory-search",
|
||||||
|
"verzeichnis-uebernehmen": "directory-import",
|
||||||
|
"gruppen": "groups",
|
||||||
|
"vorlage-anwenden": "apply-preset",
|
||||||
|
"sitzungen": "sessions",
|
||||||
|
"beenden": "end",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/einstellungen": (
|
||||||
|
"/administration",
|
||||||
|
{
|
||||||
|
"zeitzone": "timezone",
|
||||||
|
"pruefung-jetzt": "check-now",
|
||||||
|
"netzwerk": "network",
|
||||||
|
"bestaetigen": "confirm",
|
||||||
|
"webserver": "web",
|
||||||
|
"uebernehmen": "apply",
|
||||||
|
"zertifikat": "certificate",
|
||||||
|
"selbstsigniert": "self-signed",
|
||||||
|
"verzeichnisdienst": "directory",
|
||||||
|
"gruppensuche": "group-search",
|
||||||
|
"zuordnung": "mapping",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"anbieter": "vendor",
|
||||||
|
"logo-entfernen": "remove-logo",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/sicherung": (
|
||||||
|
"/administration/backup",
|
||||||
|
{"vorschau": "preview", "uebernehmen": "apply", "verwerfen": "discard"},
|
||||||
|
),
|
||||||
|
"/papierkorb": (
|
||||||
|
"/administration/trash",
|
||||||
|
{"wiederherstellen": "restore", "endgueltig-loeschen": "purge", "leeren": "empty"},
|
||||||
|
),
|
||||||
|
"/diagnose": (
|
||||||
|
"/administration/diagnostics",
|
||||||
|
{"aufraeumen": "cleanup", "schluessel-rotieren": "rotate-keys"},
|
||||||
|
),
|
||||||
|
"/eigene-lizenz": (
|
||||||
|
"/own-license",
|
||||||
|
{
|
||||||
|
"einspielen": "install",
|
||||||
|
"aktivieren": "activate",
|
||||||
|
"deaktivieren": "deactivate",
|
||||||
|
"offline-antwort": "offline-response",
|
||||||
|
"signaturschluessel": "signing-key",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/lizenzen": (
|
||||||
|
"/licenses",
|
||||||
|
{
|
||||||
|
"aus-ticket": "from-ticket",
|
||||||
|
"datei": "file",
|
||||||
|
"neu-signieren": "resign",
|
||||||
|
"widerrufen": "revoke",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"versenden": "send",
|
||||||
|
"sub-lizenzserver": "sub-servers",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/lizenz": (
|
||||||
|
"/administration/license",
|
||||||
|
{
|
||||||
|
"einspielen": "install",
|
||||||
|
"aktivieren": "activate",
|
||||||
|
"deaktivieren": "deactivate",
|
||||||
|
"offline-antwort": "offline-response",
|
||||||
|
"serverzertifikat": "server-certificate",
|
||||||
|
"aussteller": "issuer",
|
||||||
|
"entfernen": "remove",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/kunden": ("/customers", {"neu": "new", "loeschen": "delete"}),
|
||||||
|
"/konto": (
|
||||||
|
"/account",
|
||||||
|
{
|
||||||
|
"stammdaten": "profile",
|
||||||
|
"profilbild": "avatar",
|
||||||
|
"sicherheit": "security",
|
||||||
|
"passwort": "password",
|
||||||
|
"zwei-faktor": "two-factor",
|
||||||
|
"starten": "start",
|
||||||
|
"bestaetigen": "confirm",
|
||||||
|
"deaktivieren": "disable",
|
||||||
|
"codes-erneuern": "recovery-codes",
|
||||||
|
"sitzungen": "sessions",
|
||||||
|
"beenden": "end",
|
||||||
|
"alle-beenden": "end-all",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/uebersicht": ("/overview", {"kacheln": "tiles"}),
|
||||||
|
"/gesundheit": ("/health", {}),
|
||||||
|
"/bestaetigen": ("/confirm", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Abfrageparameter, die mit umbenannt wurden.
|
||||||
|
PARAMETER = {
|
||||||
|
"freigabe": "share",
|
||||||
|
"pfad": "path",
|
||||||
|
"aktion": "action",
|
||||||
|
"benutzer": "actor",
|
||||||
|
"stufe": "severity",
|
||||||
|
"seite": "page",
|
||||||
|
"methode": "method",
|
||||||
|
"kunde": "customer",
|
||||||
|
"freischalten": "unlocked",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Wurzeln, unter denen eine unbekannte Unterseite **nicht** umgelenkt wird.
|
||||||
|
#: ``/lizenz/<ticket>`` ist auf dem Lizenzserver eine echte Adresse; ein
|
||||||
|
#: abgelaufenes oder falsches Ticket gehoert dort auf die Fehlerseite und nicht
|
||||||
|
#: in die Verwaltung.
|
||||||
|
STRENG = {"/lizenz"}
|
||||||
|
|
||||||
|
|
||||||
|
def neue_adresse(pfad: str) -> str | None:
|
||||||
|
"""Uebersetzt eine alte Adresse -- oder ``None``, wenn es keine ist."""
|
||||||
|
for alt, (neu, segmente) in WURZELN.items():
|
||||||
|
if pfad != alt and not pfad.startswith(alt + "/"):
|
||||||
|
continue
|
||||||
|
rest = pfad[len(alt) :]
|
||||||
|
teile = rest.split("/")
|
||||||
|
if alt in STRENG:
|
||||||
|
erstes = next((teil for teil in teile if teil), "")
|
||||||
|
if erstes and erstes not in segmente:
|
||||||
|
return None
|
||||||
|
return neu + "/".join(segmente.get(teil, teil) for teil in teile)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def neue_parameter(args: Mapping[str, str]) -> dict[str, str]:
|
||||||
|
return {PARAMETER.get(name, name): wert for name, wert in args.items()}
|
||||||
@@ -77,7 +77,7 @@ SETTINGS_NETWORK = Resource(
|
|||||||
|
|
||||||
BACKUP = Resource(
|
BACKUP = Resource(
|
||||||
key="backup",
|
key="backup",
|
||||||
label="Sicherung und Uebertragung",
|
label="Sicherung und Übertragung",
|
||||||
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
||||||
hint="Verschluesselter Export und Import",
|
hint="Verschluesselter Export und Import",
|
||||||
action_hints={
|
action_hints={
|
||||||
@@ -121,7 +121,7 @@ LOGS_HISTORY = Resource(
|
|||||||
|
|
||||||
LOGS_AUDIT = Resource(
|
LOGS_AUDIT = Resource(
|
||||||
key="logs_audit",
|
key="logs_audit",
|
||||||
label="Aenderungsprotokoll",
|
label="Änderungsprotokoll",
|
||||||
actions=(A.VIEW, A.EXPORT),
|
actions=(A.VIEW, A.EXPORT),
|
||||||
hint="Wer hat wann was geaendert",
|
hint="Wer hat wann was geaendert",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.5.0"
|
version = "2.5.2"
|
||||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["cryptography>=42"]
|
dependencies = ["cryptography>=42"]
|
||||||
|
|||||||
Reference in new issue
Block a user