diff --git a/.gitea/worklows/registry.yml b/.gitea/worklows/registry.yml new file mode 100644 index 0000000..7e7f681 --- /dev/null +++ b/.gitea/worklows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-latest + env: + DOCKER_ORG: sendnrw + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."git.send.nrw"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: git.send.nrw # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3df1a4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/data/ +*.pem +*.key +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8af51e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /src +COPY go.mod ./ +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/paw-toolbox . + +FROM alpine:3.24 +RUN apk add --no-cache ca-certificates tzdata openssl && addgroup -S paw && adduser -S -G paw paw +WORKDIR /app +COPY --from=builder /out/paw-toolbox /app/paw-toolbox +COPY apps.json /data/apps.json +RUN mkdir -p /data/files && chown -R paw:paw /data +USER paw +ENV ADDR=":8080" \ + SERVER_MODE="http" \ + APPS_JSON="/data/apps.json" \ + DATA_DIR="/data" \ + CLIPBOARD_DATA="/data/clipboard.json" \ + MAX_PER_ROOM="200" \ + CLIPBOARD_PERSIST_SECRETS="false" \ + FILE_MAX_BYTES="268435456" \ + AUTH_USER="" \ + AUTH_PASS="" +EXPOSE 8080 8443 +ENTRYPOINT ["/app/paw-toolbox"] diff --git a/README.md b/README.md index b6e33d2..3e3022a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,232 @@ -# pawtoolbox +# PAW Toolbox +Eine zusammengeführte Go-Anwendung für eine Privileged Access Workstation (PAW). Sie vereint die bisherigen Projekte `virtual-appstore`, `virtual-clipboard` und `virtual-clipboard-generator`, ergänzt einen einfachen Dateiaustausch und bündelt typische Sysadmin-/AD-Werkzeuge in einer gemeinsamen Oberfläche. + +## Enthaltene Funktionen + +### Kernfunktionen + +- **App Launcher**: zentrale Kachelübersicht aus `apps.json`, Suche und Kategorien. +- **Virtuelle Zwischenablage**: persistente Räume und Verläufe, klickbare Raumverwaltung mit Öffnen/Leeren/Löschen, SSE-Aktualisierung und JSON-Persistenz. +- **Secrets**: maskierte Clipboard-Einträge, TTL und optional einmaliger Abruf. +- **Passwortgenerator**: kryptographisch zufällige Kennwörter; direkte Übergabe in einen Clipboard-Raum ohne separaten Dienst. +- **Dateiaustausch**: Upload, Download und Delete; zufällige interne Datei-ID, SHA-256-Prüfsumme und konfigurierbares Größenlimit. +- **Ein Binary / ein Container**: alle Web-Funktionen liegen auf demselben Port. + +### Integrierte Admin-Werkzeuge + +1. **Onboarding Assistant** – erzeugt sAMAccountName, UPN, E-Mail, DisplayName, `New-ADUser` und Gruppenbefehle; kann ein Kennwort als einmaliges Secret ablegen. +2. **Hash / File Inspector** – Dateigröße, MIME-Type, Erweiterung, erste Bytes sowie MD5, SHA-256 und SHA-512. +3. **Certificate Inspector** – X.509-Details, SANs, Gültigkeit, Key Usage, EKU und Thumbprints für PEM/CER/CRT/DER; PFX/P12 über lokales OpenSSL. +4. **JSON / XML / YAML Formatter** – JSON/XML formatieren, minifizieren und validieren; YAML konservativ normalisieren und auf häufige Strukturfehler prüfen. +5. **Text Diff** – zeilenweiser Vergleich zweier Texte bis 500 Zeilen pro Seite. +6. **AD Converter** – UUID/GUID ↔ AD-Bytefolge, SID ↔ Binär-Hex sowie ISO/Unix/Windows FILETIME/LDAP GeneralizedTime. +7. **DNS Lookup** – A, AAAA, CNAME, MX, TXT, SRV und PTR. +8. **IP / Subnet Calculator** – IPv4 und IPv6, Netzbereich und für IPv4 zusätzlich Netzmaske/Broadcast/Hostbereich. +9. **Base64 / Hex / URL Encoder** – UTF-8-basiertes Encodieren und Decodieren. +10. **PowerShell Command Builder** – Generator für häufige AD-Befehle; führt keine Befehle aus. +11. **CSV Viewer** – CSV laden, Trennzeichen erkennen, filtern und gefilterte Daten exportieren. +12. **Connectivity Checker** – gezielter TCP-Test auf genau einen Host/Port. +13. **Regex Tester** – Regex mit Flags gegen Text testen und Treffer/Gruppen anzeigen. +14. **Text Transformer** – trimmen, sortieren, deduplizieren, Groß-/Kleinschreibung, Prefix/Suffix und Reihenfolge. +15. **ZIP Archive Viewer** – ZIP-Inhalt und Größen anzeigen, ohne Dateien zu extrahieren. +16. **Encoding Converter** – UTF-8, Windows-1252, ISO-8859-1 und UTF-16 lesen und als UTF-8 bzw. UTF-8-BOM speichern. + +## Wo werden Daten verarbeitet? + +Die Werkzeuge **Onboarding, Formatter, Diff, AD Converter, Encoder, PowerShell Builder, CSV Viewer, Regex Tester, Text Transformer und Encoding Converter** laufen vollständig im Browser. + +Die folgenden Werkzeuge benötigen die Toolbox-API: + +- Hash/File Inspector +- Certificate Inspector +- ZIP Archive Viewer +- DNS Lookup +- Connectivity Checker +- IP/Subnet Calculator + +Dateien für die drei Inspektionswerkzeuge werden nur für die jeweilige Anfrage eingelesen und nicht in den normalen Dateiaustausch übernommen. Das Analyse-Limit liegt bei 64 MiB. + +**Wichtig:** DNS Lookup und Connectivity Checker laufen aus Netzsicht des **PAW-Toolbox-Servers**. Sie zeigen nicht automatisch die Netzsicht der Browser-/Tier-VM an. + +## Typische Workflows + +### Sandbox -> geprüfte Datei -> Zielsystem + +1. Datei im Bereich **Dateien** hochladen. +2. In der Sandbox-VM dieselbe PAW Toolbox öffnen und die Datei herunterladen. +3. Datei prüfen; bei Bedarf im **Hash / File Inspector** die Prüfsumme dokumentieren. +4. Die geprüfte Datei wieder hochladen und Quelle/Bearbeiter angeben. +5. Auf der Ziel-VM herunterladen. +6. Nicht mehr benötigte Dateien löschen. + +Die SHA-256-Prüfsumme wird für jeden Upload angezeigt und zusätzlich beim Download im Header `X-Content-SHA256` mitgegeben. + +### Tier-0 -> AD-Onboarding -> Tier-2 Office + +1. Unter **Werkzeuge → Onboarding Assistant** Stammdaten erfassen. +2. Naming-Daten und den erzeugten `New-ADUser`-Befehl verwenden. +3. Über **Kennwort generieren + einmalig ablegen** ein Initialkennwort erzeugen. Das Kennwort wird als Secret mit TTL im gewählten Raum abgelegt. +4. In Tier-2 den Raum, z. B. `onboarding`, unter **Zwischenablage** öffnen. +5. Secret explizit abrufen; bei einmaligem Abruf wird der Servereintrag anschließend entfernt. +6. Onboarding-Brief in Office erstellen. + +Der Onboarding Assistant führt selbst **keine** AD-Befehle aus. + +## Sicherheitsmodell + +Die Anwendung ist technisch bewusst ein Transferpunkt zwischen VM-/Tier-Kontexten. Dadurch wird sie selbst zu einer sicherheitskritischen Komponente. + +- **TLS verwenden** oder TLS an einem vertrauenswürdigen Reverse Proxy terminieren. Basic Auth niemals unverschlüsselt über ein nicht vertrauenswürdiges Netz verwenden. +- Zugriff auf den Dienst per Firewall/ACL ausschließlich von der PAW bzw. den vorgesehenen Verwaltungsnetzen erlauben. +- `AUTH_USER`/`AUTH_PASS` oder vorgeschaltete Authentisierung aktivieren. Für produktive Tier-Grenzen ist eine identitätsbasierte Authentisierung am Reverse Proxy empfehlenswert. +- Clipboard-Räume und Inhalte werden standardmäßig **vollständig persistent** in `clipboard.json` gespeichert, einschließlich maskierter Secrets (`CLIPBOARD_PERSIST_SECRETS=true`). TTL und One-Time-Verhalten bleiben erhalten: abgelaufene bzw. verbrauchte Einträge werden entfernt. **Maskierung ist keine Verschlüsselung:** persistierte Secret-Werte liegen serverseitig in der mit Modus `0600` geschriebenen JSON-Datei und `/data` muss entsprechend geschützt werden. Wer Secrets bewusst nur im RAM halten möchte, kann `CLIPBOARD_PERSIST_SECRETS=false` setzen. +- Der Dateibereich ist **kein Malware-Scanner**. Die Sandbox-Prüfung bleibt ein expliziter Arbeitsschritt. +- Ein zentraler Dateiaustausch kann eine Tier-Grenze überbrücken. Netzwerk- und Betriebsregeln sollten klar festlegen, welche Richtungen und Dateitypen erlaubt sind. +- DNS-/TCP-Werkzeuge ermöglichen dem angemeldeten Benutzer bewusst Abfragen aus Sicht des Toolbox-Servers. Zugriff auf diese Oberfläche daher entsprechend schützen. +- Das Verzeichnis `/data` sollte auf einem geschützten Datenträger liegen; Rechte und Backup-Verfahren entsprechend behandeln. +- MD5 und SHA-1 werden nur für Kompatibilität/Identifikation angezeigt. Für Integritätsprüfungen sollte SHA-256 oder stärker verwendet werden. + +## Start mit Docker Compose + +```bash +mkdir -p data +cp apps.json data/apps.json +docker compose up -d --build +``` + +Danach: `http://:8080` + +Der Runtime-Container enthält `openssl`, damit PFX/P12-Zertifikate lokal analysiert werden können. + +## HTTPS direkt im Container + +Beispielvariablen: + +```text +SERVER_MODE=https +ADDR=:8443 +TLS_CERT_FILE=/certs/server.pem +TLS_KEY_FILE=/certs/server.key +HTTP_REDIRECT_ENABLED=true +HTTP_REDIRECT_ADDR=:8080 +``` + +Die Zertifikate müssen in den Container gemountet werden. + +## Wichtige Umgebungsvariablen + +| Variable | Standard | Bedeutung | +|---|---:|---| +| `ADDR` | `:8080` | Listen-Adresse | +| `SERVER_MODE` | `http` | `http` oder `https` | +| `APPS_JSON` | `/data/apps.json` | App-Launcher-Konfiguration | +| `DATA_DIR` | `/data` | Datenbasis für Dateiaustausch | +| `CLIPBOARD_DATA` | `/data/clipboard.json` | Clipboard-Persistenz | +| `MAX_PER_ROOM` | `200` | Max. Clipboard-Einträge je Raum | +| `CLIPBOARD_PERSIST_SECRETS` | `true` | Secrets im persistenten Clipboard-Snapshot speichern | +| `FILE_MAX_BYTES` | `268435456` | Max. Uploadgröße, Standard 256 MiB | +| `AUTH_USER` | leer | Optionaler Basic-Auth-Benutzer | +| `AUTH_PASS` | leer | Optionales Basic-Auth-Kennwort | +| `PWGEN_LENGTH` | `20` | Passwortlänge | +| `PWGEN_MIN_LOWER` | `2` | Min. Kleinbuchstaben | +| `PWGEN_MIN_UPPER` | `2` | Min. Großbuchstaben | +| `PWGEN_MIN_DIGITS` | `2` | Min. Ziffern | +| `PWGEN_MIN_SYMBOLS` | `2` | Min. Sonderzeichen | +| `PWGEN_NO_AMBIGUOUS` | `true` | Mehrdeutige Zeichen vermeiden | +| `PWGEN_NO_SEQ` | `true` | Sequenzen vermeiden | +| `PWGEN_NO_REPEAT` | `true` | Direkte Wiederholungen vermeiden | +| `PWGEN_UNIQUE` | `false` | Nur einzigartige Zeichen | +| `PWGEN_EXCLUDE` | leer | Zeichen ausschließen | +| `PWGEN_CHARSET` | leer | Zusätzliche Zeichen | +| `PWGEN_TEMPLATE` | leer | Optionales Template (`l`, `L`, `d`, `s`) | +| `PWGEN_SYMBOLS` | eingebaut | Sonderzeichensatz | + +## Clipboard API-Kompatibilität + +Die bisherigen Kernpfade bleiben erhalten: + +```text +POST /api/{room}/clip +GET /api/{room}/latest +GET /api/{room}/history +DELETE /api/{room}/history +DELETE /api/{room} +GET /api/{room}/stream +``` + +Ergänzt wurden die persistente Raumverwaltung und der explizite Secret-Abruf: + +```text +GET /api/rooms Raum-Namen auflisten +POST /api/rooms Leeren Raum anlegen/persistieren (`{"name":"tier0"}`) +GET /api/rooms/details Räume mit Anzahl/Secrets/letzter Aktivität +GET /api/{room}/clip/{id} Maskierten/One-Time-Eintrag explizit abrufen +``` + +Dieser Pfad dient insbesondere dem expliziten Abruf maskierter bzw. einmaliger Secrets. + +Beispiel zum Ablegen eines Secrets: + +```json +{ + "type": "password", + "content": "Beispielwert", + "author": "admin", + "secret": true, + "one_time": true, + "ttl_minutes": 15 +} +``` + +## Datei-API + +```text +GET /api/files +POST /api/files multipart/form-data: file, optional uploader +GET /api/files/{id} +DELETE /api/files/{id} +``` + +Originaldateinamen werden nur als Metadaten verwendet. Serverseitig wird die Datei unter einer zufälligen ID gespeichert; dadurch werden Pfadtraversal und Namenskollisionen vermieden. + +## Werkzeug-APIs + +```text +POST /api/tools/file-inspect multipart/form-data: file +POST /api/tools/cert-inspect multipart/form-data: file, optional password +POST /api/tools/archive multipart/form-data: file +POST /api/tools/dns JSON: name, type +POST /api/tools/connectivity JSON: host, port, timeout_ms +POST /api/tools/subnet JSON: cidr +``` + +## Datenlayout + +```text +/data/apps.json +/data/clipboard.json +/data/files.json +/data/files/.bin + +Die **Dateiablage ist persistent**: Dateiinhalt und Metadaten werden unter `/data` gespeichert. Mit dem mitgelieferten `compose.yaml` wird `./data:/data` als Bind-Mount eingebunden, sodass Uploads Container-Neustarts und Neu-Erstellungen überstehen. Werden `/data` bzw. `./data` gelöscht, sind auch die gespeicherten Dateien entfernt. +``` + +Die Analysewerkzeuge legen hochgeladene Dateien nicht in `/data/files` ab. + +## Migration aus den drei alten Containern + +1. Dieses Projekt bauen und zunächst parallel auf einem Test-Port starten. +2. Gewünschte externe Kacheln aus dem alten `apps.json` übernehmen. Die bisherigen Clipboard-/Passwort-/ProjectSend-Kacheln können entfallen, da diese Funktionen integriert sind. +3. Falls bestehende **nicht geheime** Clipboard-Historie übernommen werden soll, die alte Snapshot-Datei als `/data/clipboard.json` kopieren. Vorher sensible Altwerte entfernen. +4. Funktionsprüfung aus allen vorgesehenen Tier-VMs durchführen. +5. Alte Clipboard- und Passwortgenerator-Container abschalten, sobald keine Clients mehr direkt auf deren Ports zugreifen. + +## Build ohne Docker + +```bash +go build -o paw-toolbox . +ADDR=:8080 APPS_JSON=./apps.json DATA_DIR=./data CLIPBOARD_DATA=./data/clipboard.json ./paw-toolbox +``` + +Für PFX/P12-Unterstützung muss `openssl` im `PATH` vorhanden sein. Alle anderen Funktionen benötigen keine externen Laufzeitbibliotheken. diff --git a/apps.json b/apps.json new file mode 100644 index 0000000..3231935 --- /dev/null +++ b/apps.json @@ -0,0 +1,8 @@ +[ + { "title": "03-IT-Tools", "url": "http://10.50.0.20:8087", "icon": "💡", "category": "Produktivität", "color": "#f59e0b" }, + { "title": "04-WebDesktop", "url": "https://10.50.0.20:3001", "icon": "🌐", "category": "Design", "color": "#22c55e" }, + { "title": "06-JumpServer Tier 1", "url": "https://prd-srv-ts-jumpserver-t1.stadt-hilden.de/#/", "icon": "1️⃣", "category": "Administration", "color": "#2563eb" }, + { "title": "07-JumpServer Tier 0", "url": "https://prd-srv-ts-jumpserver-t0.stadt-hilden.de/#/", "icon": "0️⃣", "category": "Administration", "color": "#dc2626" }, + { "title": "08-JumpServer Tier D", "url": "https://prd-srv-ts-jumpserver-td.stadt-hilden.de/#/", "icon": "#️⃣", "category": "Administration", "color": "#7c3aed" }, + { "title": "09-Passwort-Tresor", "url": "http://pit.stadt-hilden.de", "icon": "🅿️", "category": "Administration", "color": "#0f766e" } +] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..8fa6d5e --- /dev/null +++ b/compose.yaml @@ -0,0 +1,16 @@ +services: + paw-toolbox: + build: . + container_name: paw-toolbox + restart: unless-stopped + ports: + - "8080:8080" + environment: + ADDR: ":8080" + SERVER_MODE: "http" + AUTH_USER: "" + AUTH_PASS: "" + FILE_MAX_BYTES: "268435456" + CLIPBOARD_PERSIST_SECRETS: "true" + volumes: + - ./data:/data diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..13ade56 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.send.nrw/sendnrw/paw-toolbox + +go 1.26 diff --git a/main.go b/main.go new file mode 100644 index 0000000..dc2e42f --- /dev/null +++ b/main.go @@ -0,0 +1,1425 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "io/fs" + "log" + "math" + "math/big" + "mime" + "net/http" + "os" + "os/signal" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +//go:embed web/* +var webFS embed.FS + +// -------------------- configuration -------------------- + +type Config struct { + Addr string + ServerMode string + TLSCertFile string + TLSKeyFile string + HTTPRedirectAddr string + HTTPRedirectEnabled bool + AppsJSON string + DataDir string + ClipboardData string + MaxPerRoom int + PersistSecrets bool + FileMaxBytes int64 + AuthUser string + AuthPass string + PW PWOptions +} + +type PWOptions struct { + Length int + MinLower int + MinUpper int + MinDigits int + MinSymbols int + Custom string + Exclude string + NoAmbig bool + NoSeq bool + NoRepeat bool + Unique bool + Template string + SymbolSet string +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} +func getenvInt(k string, def int) int { + if v := os.Getenv(k); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} +func getenvInt64(k string, def int64) int64 { + if v := os.Getenv(k); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return n + } + } + return def +} +func getenvBool(k string, def bool) bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(k))) + if v == "" { + return def + } + switch v { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + } + return def +} + +func loadConfig() Config { + mode := strings.ToLower(getenv("SERVER_MODE", "http")) + addr := getenv("ADDR", ":8080") + if mode == "https" && os.Getenv("ADDR") == "" { + addr = ":8443" + } + return Config{ + Addr: addr, ServerMode: mode, + TLSCertFile: getenv("TLS_CERT_FILE", ""), TLSKeyFile: getenv("TLS_KEY_FILE", ""), + HTTPRedirectAddr: getenv("HTTP_REDIRECT_ADDR", ":8080"), HTTPRedirectEnabled: getenvBool("HTTP_REDIRECT_ENABLED", true), + AppsJSON: getenv("APPS_JSON", "./data/apps.json"), DataDir: getenv("DATA_DIR", "./data"), + ClipboardData: getenv("CLIPBOARD_DATA", "./data/clipboard.json"), MaxPerRoom: getenvInt("MAX_PER_ROOM", 200), + PersistSecrets: getenvBool("CLIPBOARD_PERSIST_SECRETS", true), FileMaxBytes: getenvInt64("FILE_MAX_BYTES", 256<<20), + AuthUser: getenv("AUTH_USER", ""), AuthPass: getenv("AUTH_PASS", ""), + PW: PWOptions{ + Length: getenvInt("PWGEN_LENGTH", 20), MinLower: getenvInt("PWGEN_MIN_LOWER", 2), MinUpper: getenvInt("PWGEN_MIN_UPPER", 2), + MinDigits: getenvInt("PWGEN_MIN_DIGITS", 2), MinSymbols: getenvInt("PWGEN_MIN_SYMBOLS", 2), Custom: getenv("PWGEN_CHARSET", ""), + Exclude: getenv("PWGEN_EXCLUDE", ""), NoAmbig: getenvBool("PWGEN_NO_AMBIGUOUS", true), NoSeq: getenvBool("PWGEN_NO_SEQ", true), + NoRepeat: getenvBool("PWGEN_NO_REPEAT", true), Unique: getenvBool("PWGEN_UNIQUE", false), Template: getenv("PWGEN_TEMPLATE", ""), + SymbolSet: getenv("PWGEN_SYMBOLS", "!@#$%^&*()-=+;:,.?|"), + }, + } +} + +// -------------------- common helpers -------------------- + +type apiError struct { + Error string `json:"error"` +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func makeID() string { + var b [12]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") + if r.TLS != nil { + w.Header().Set("Strict-Transport-Security", "max-age=15552000") + } + next.ServeHTTP(w, r) + }) +} + +func basicAuth(user, pass string, next http.Handler) http.Handler { + if user == "" && pass == "" { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + userOK := subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1 + passOK := subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1 + if !ok || !userOK || !passOK { + w.Header().Set("WWW-Authenticate", `Basic realm="PAW Toolbox"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func atomicJSON(path string, v any, perm os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + tmp := path + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm) + if err != nil { + return err + } + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + _ = f.Close() + _ = os.Remove(tmp) + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + _ = os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + _ = os.Remove(tmp) + return err + } + return os.Rename(tmp, path) +} + +// -------------------- app launcher -------------------- + +type App struct { + Title string `json:"title"` + URL string `json:"url"` + Icon string `json:"icon"` + Category string `json:"category"` + Color string `json:"color"` +} + +func loadApps(path string) ([]App, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var apps []App + if err := json.Unmarshal(b, &apps); err != nil { + return nil, err + } + sort.Slice(apps, func(i, j int) bool { return strings.ToLower(apps[i].Title) < strings.ToLower(apps[j].Title) }) + return apps, nil +} + +// -------------------- clipboard -------------------- + +type Clip struct { + ID string `json:"id"` + Room string `json:"room"` + Type string `json:"type"` + Content string `json:"content,omitempty"` + Author string `json:"author,omitempty"` + Secret bool `json:"secret,omitempty"` + OneTime bool `json:"one_time,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type clipboardSnapshot struct { + Version int `json:"version"` + Rooms map[string][]*Clip `json:"rooms"` +} + +type Room struct { + name string + max int + mu sync.RWMutex + clips []*Clip + subs map[chan *Clip]struct{} + closed bool +} + +func newRoom(name string, max int) *Room { + return &Room{name: name, max: max, subs: map[chan *Clip]struct{}{}} +} +func expired(c *Clip, now time.Time) bool { return c.ExpiresAt != nil && !c.ExpiresAt.After(now) } + +func (r *Room) pruneLocked(now time.Time) { + out := r.clips[:0] + for _, c := range r.clips { + if !expired(c, now) { + out = append(out, c) + } + } + r.clips = out +} + +func publicClip(c *Clip) *Clip { + cp := *c + if c.Secret { + cp.Content = "" + } + return &cp +} + +func (r *Room) add(c *Clip) { + r.mu.Lock() + defer r.mu.Unlock() + r.pruneLocked(time.Now().UTC()) + if len(r.clips) >= r.max { + copy(r.clips, r.clips[1:]) + r.clips[len(r.clips)-1] = c + } else { + r.clips = append(r.clips, c) + } + event := c + if c.Secret { + event = publicClip(c) + } + for ch := range r.subs { + select { + case ch <- event: + default: + } + } +} + +func (r *Room) history(limit int) []*Clip { + r.mu.Lock() + defer r.mu.Unlock() + r.pruneLocked(time.Now().UTC()) + if limit <= 0 || limit > len(r.clips) { + limit = len(r.clips) + } + start := len(r.clips) - limit + out := make([]*Clip, 0, limit) + for _, c := range r.clips[start:] { + if c.Secret { + out = append(out, publicClip(c)) + } else { + cp := *c + out = append(out, &cp) + } + } + return out +} + +func (r *Room) reveal(id string) (*Clip, bool) { + r.mu.Lock() + defer r.mu.Unlock() + r.pruneLocked(time.Now().UTC()) + for i, c := range r.clips { + if c.ID == id { + cp := *c + if c.OneTime { + r.clips = append(r.clips[:i], r.clips[i+1:]...) + } + return &cp, true + } + } + return nil, false +} + +func (r *Room) latestReveal() (*Clip, bool) { + r.mu.Lock() + defer r.mu.Unlock() + r.pruneLocked(time.Now().UTC()) + if len(r.clips) == 0 { + return nil, false + } + i := len(r.clips) - 1 + cp := *r.clips[i] + if r.clips[i].OneTime { + r.clips = r.clips[:i] + } + return &cp, true +} + +func (r *Room) clear() { r.mu.Lock(); r.clips = nil; r.mu.Unlock() } +func (r *Room) subscribe() (chan *Clip, func()) { + ch := make(chan *Clip, 8) + r.mu.Lock() + if r.closed { + r.mu.Unlock() + close(ch) + return ch, func() {} + } + r.subs[ch] = struct{}{} + r.mu.Unlock() + return ch, func() { + r.mu.Lock() + if _, ok := r.subs[ch]; ok { + delete(r.subs, ch) + close(ch) + } + r.mu.Unlock() + } +} +func (r *Room) closeAll() { + r.mu.Lock() + if !r.closed { + r.closed = true + for ch := range r.subs { + close(ch) + delete(r.subs, ch) + } + } + r.mu.Unlock() +} + +type ClipboardStore struct { + mu sync.RWMutex + rooms map[string]*Room + max int + path string + persistSecrets bool +} + +func newClipboardStore(max int, path string, persistSecrets bool) *ClipboardStore { + return &ClipboardStore{rooms: map[string]*Room{}, max: max, path: path, persistSecrets: persistSecrets} +} +func validRoom(name string) bool { + if name == "" || len(name) > 64 { + return false + } + for _, r := range name { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') { + return false + } + } + return true +} +func (s *ClipboardStore) room(name string) *Room { + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.rooms[name]; ok { + return r + } + r := newRoom(name, s.max) + s.rooms[name] = r + return r +} +func (s *ClipboardStore) roomsList() []string { + s.mu.RLock() + out := make([]string, 0, len(s.rooms)) + for n := range s.rooms { + out = append(out, n) + } + s.mu.RUnlock() + sort.Strings(out) + return out +} + +type RoomInfo struct { + Name string `json:"name"` + Count int `json:"count"` + Secrets int `json:"secrets"` + LastActive *time.Time `json:"last_active,omitempty"` +} + +func (r *Room) info() RoomInfo { + r.mu.Lock() + defer r.mu.Unlock() + r.pruneLocked(time.Now().UTC()) + info := RoomInfo{Name: r.name, Count: len(r.clips)} + for _, c := range r.clips { + if c.Secret { + info.Secrets++ + } + if info.LastActive == nil || c.CreatedAt.After(*info.LastActive) { + t := c.CreatedAt + info.LastActive = &t + } + } + return info +} + +func (s *ClipboardStore) roomDetails() []RoomInfo { + s.mu.RLock() + rooms := make([]*Room, 0, len(s.rooms)) + for _, r := range s.rooms { + rooms = append(rooms, r) + } + s.mu.RUnlock() + out := make([]RoomInfo, 0, len(rooms)) + for _, r := range rooms { + out = append(out, r.info()) + } + sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) + return out +} + +func (s *ClipboardStore) getRoom(name string) (*Room, bool) { + s.mu.RLock() + r, ok := s.rooms[name] + s.mu.RUnlock() + return r, ok +} + +func (s *ClipboardStore) createRoom(name string) error { + if !validRoom(name) { + return errors.New("invalid room") + } + s.mu.Lock() + if _, ok := s.rooms[name]; !ok { + s.rooms[name] = newRoom(name, s.max) + } + s.mu.Unlock() + return s.save() +} + +func (s *ClipboardStore) clearRoom(name string) error { + r, ok := s.getRoom(name) + if !ok { + return os.ErrNotExist + } + r.clear() + return s.save() +} +func (s *ClipboardStore) load() error { + b, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var snap clipboardSnapshot + if err := json.Unmarshal(b, &snap); err != nil { + return err + } + if snap.Version != 1 { + return fmt.Errorf("unsupported clipboard snapshot version %d", snap.Version) + } + for name, list := range snap.Rooms { + if !validRoom(name) { + continue + } + r := s.room(name) + r.mu.Lock() + for _, c := range list { + if !expired(c, time.Now().UTC()) { + r.clips = append(r.clips, c) + } + } + if len(r.clips) > r.max { + r.clips = r.clips[len(r.clips)-r.max:] + } + r.mu.Unlock() + } + return nil +} +func (s *ClipboardStore) save() error { + if s.path == "" { + return nil + } + snap := clipboardSnapshot{Version: 1, Rooms: map[string][]*Clip{}} + now := time.Now().UTC() + s.mu.RLock() + for name, r := range s.rooms { + r.mu.Lock() + r.pruneLocked(now) + list := make([]*Clip, 0, len(r.clips)) + for _, c := range r.clips { + if c.Secret && !s.persistSecrets { + continue + } + cp := *c + list = append(list, &cp) + } + r.mu.Unlock() + snap.Rooms[name] = list + } + s.mu.RUnlock() + return atomicJSON(s.path, snap, 0o600) +} +func (s *ClipboardStore) deleteRoom(name string) error { + s.mu.Lock() + r, ok := s.rooms[name] + if ok { + delete(s.rooms, name) + } + s.mu.Unlock() + if ok { + r.closeAll() + } + return s.save() +} + +// -------------------- file exchange -------------------- + +type FileMeta struct { + ID string `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + Uploader string `json:"uploader,omitempty"` + UploadedAt time.Time `json:"uploaded_at"` +} + +type fileSnapshot struct { + Version int `json:"version"` + Files []*FileMeta `json:"files"` +} + +type FileStore struct { + mu sync.RWMutex + dir string + metaPath string + maxBytes int64 + files map[string]*FileMeta +} + +func newFileStore(dataDir string, maxBytes int64) *FileStore { + return &FileStore{dir: filepath.Join(dataDir, "files"), metaPath: filepath.Join(dataDir, "files.json"), maxBytes: maxBytes, files: map[string]*FileMeta{}} +} +func (s *FileStore) filePath(id string) string { return filepath.Join(s.dir, id+".bin") } +func (s *FileStore) load() error { + if err := os.MkdirAll(s.dir, 0o700); err != nil { + return err + } + b, err := os.ReadFile(s.metaPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var snap fileSnapshot + if err := json.Unmarshal(b, &snap); err != nil { + return err + } + if snap.Version != 1 { + return fmt.Errorf("unsupported file snapshot version %d", snap.Version) + } + for _, m := range snap.Files { + if _, err := os.Stat(s.filePath(m.ID)); err == nil { + s.files[m.ID] = m + } + } + return nil +} +func (s *FileStore) saveLocked() error { + list := make([]*FileMeta, 0, len(s.files)) + for _, m := range s.files { + cp := *m + list = append(list, &cp) + } + sort.Slice(list, func(i, j int) bool { return list[i].UploadedAt.After(list[j].UploadedAt) }) + return atomicJSON(s.metaPath, fileSnapshot{Version: 1, Files: list}, 0o600) +} +func (s *FileStore) list() []*FileMeta { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]*FileMeta, 0, len(s.files)) + for _, m := range s.files { + cp := *m + out = append(out, &cp) + } + sort.Slice(out, func(i, j int) bool { return out[i].UploadedAt.After(out[j].UploadedAt) }) + return out +} +func cleanOriginalName(name string) (string, error) { + name = filepath.Base(strings.TrimSpace(name)) + if name == "" || name == "." || name == ".." || len(name) > 240 { + return "", errors.New("invalid filename") + } + for _, r := range name { + if r < 32 || r == 127 { + return "", errors.New("invalid filename") + } + } + return name, nil +} +func (s *FileStore) upload(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, s.maxBytes+(2<<20)) + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeJSON(w, http.StatusBadRequest, apiError{Error: "Upload ungültig oder zu groß"}) + return + } + if r.MultipartForm != nil { + defer r.MultipartForm.RemoveAll() + } + f, h, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, apiError{Error: "Form-Feld 'file' fehlt"}) + return + } + defer f.Close() + name, err := cleanOriginalName(h.Filename) + if err != nil { + writeJSON(w, http.StatusBadRequest, apiError{Error: err.Error()}) + return + } + id := makeID() + tmp := s.filePath(id) + ".tmp" + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + writeJSON(w, 500, apiError{Error: "Datei konnte nicht angelegt werden"}) + return + } + hash := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(out, hash), io.LimitReader(f, s.maxBytes+1)) + closeErr := out.Close() + if copyErr != nil || closeErr != nil || n > s.maxBytes { + _ = os.Remove(tmp) + writeJSON(w, http.StatusBadRequest, apiError{Error: "Upload fehlgeschlagen oder Datei zu groß"}) + return + } + final := s.filePath(id) + if err := os.Rename(tmp, final); err != nil { + _ = os.Remove(tmp) + writeJSON(w, 500, apiError{Error: "Upload konnte nicht abgeschlossen werden"}) + return + } + meta := &FileMeta{ID: id, Name: name, Size: n, SHA256: hex.EncodeToString(hash.Sum(nil)), Uploader: strings.TrimSpace(r.FormValue("uploader")), UploadedAt: time.Now().UTC()} + s.mu.Lock() + s.files[id] = meta + err = s.saveLocked() + if err != nil { + delete(s.files, id) + } + s.mu.Unlock() + if err != nil { + _ = os.Remove(final) + log.Printf("file metadata save: %v", err) + writeJSON(w, http.StatusInternalServerError, apiError{Error: "Dateimetadaten konnten nicht gespeichert werden"}) + return + } + writeJSON(w, http.StatusCreated, meta) +} +func (s *FileStore) download(w http.ResponseWriter, r *http.Request, id string) { + s.mu.RLock() + m, ok := s.files[id] + if ok { + cp := *m + m = &cp + } + s.mu.RUnlock() + if !ok { + writeJSON(w, 404, apiError{Error: "Datei nicht gefunden"}) + return + } + f, err := os.Open(s.filePath(id)) + if err != nil { + writeJSON(w, 404, apiError{Error: "Datei nicht gefunden"}) + return + } + defer f.Close() + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.FormatInt(m.Size, 10)) + w.Header().Set("X-Content-SHA256", m.SHA256) + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": m.Name})) + w.Header().Set("Cache-Control", "no-store") + _, _ = io.Copy(w, f) +} +func (s *FileStore) delete(w http.ResponseWriter, id string) { + s.mu.Lock() + _, ok := s.files[id] + if ok { + delete(s.files, id) + } + var err error + if ok { + err = s.saveLocked() + } + s.mu.Unlock() + if !ok { + writeJSON(w, 404, apiError{Error: "Datei nicht gefunden"}) + return + } + _ = os.Remove(s.filePath(id)) + if err != nil { + log.Printf("file metadata save: %v", err) + } + writeJSON(w, 200, map[string]any{"status": "deleted", "id": id}) +} + +// -------------------- password generator -------------------- + +const lower = "abcdefghijklmnopqrstuvwxyz" +const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +const digits = "0123456789" + +func randInt(n int64) (int64, error) { + if n <= 0 { + return 0, errors.New("invalid n") + } + x, err := rand.Int(rand.Reader, big.NewInt(n)) + if err != nil { + return 0, err + } + return x.Int64(), nil +} +func pickRandom(set string) (byte, error) { + if len(set) == 0 { + return 0, errors.New("empty character set") + } + i, err := randInt(int64(len(set))) + if err != nil { + return 0, err + } + return set[i], nil +} +func removeChars(set, exclude string) string { + m := map[rune]bool{} + for _, r := range exclude { + m[r] = true + } + var b strings.Builder + for _, r := range set { + if !m[r] { + b.WriteRune(r) + } + } + return b.String() +} +func uniqueConcat(s string) string { + m := map[rune]bool{} + var b strings.Builder + for _, r := range s { + if !m[r] { + m[r] = true + b.WriteRune(r) + } + } + return b.String() +} +func buildSets(o PWOptions) [5]string { + ls, us, ds, ss := lower, upper, digits, o.SymbolSet + if o.NoAmbig { + ls = removeChars(ls, "l") + us = removeChars(us, "OI") + ds = removeChars(ds, "01") + ss = removeChars(ss, "|") + } + if o.Exclude != "" { + ls = removeChars(ls, o.Exclude) + us = removeChars(us, o.Exclude) + ds = removeChars(ds, o.Exclude) + ss = removeChars(ss, o.Exclude) + } + return [5]string{ls, us, ds, ss, uniqueConcat(ls + us + ds + ss + o.Custom)} +} +func shuffleBytes(b []byte) error { + for i := len(b) - 1; i > 0; i-- { + j64, err := randInt(int64(i + 1)) + if err != nil { + return err + } + j := int(j64) + b[i], b[j] = b[j], b[i] + } + return nil +} +func hasSeq(s string, window int) bool { + if window <= 1 || len(s) < window { + return false + } + for i := 0; i <= len(s)-window; i++ { + asc, desc := true, true + for j := 1; j < window; j++ { + if s[i+j] != s[i+j-1]+1 { + asc = false + } + if s[i+j] != s[i+j-1]-1 { + desc = false + } + } + if asc || desc { + return true + } + } + return false +} +func hasRepeat(s string) bool { + for i := 1; i < len(s); i++ { + if s[i] == s[i-1] { + return true + } + } + return false +} +func bytesContains(b []byte, c byte) bool { + for _, x := range b { + if x == c { + return true + } + } + return false +} +func generateTemplate(t string, sets [5]string) (string, error) { + var b strings.Builder + for i := 0; i < len(t); i++ { + var set string + switch t[i] { + case 'l': + set = sets[0] + case 'L': + set = sets[1] + case 'd': + set = sets[2] + case 's': + set = sets[3] + case '\\': + if i+1 < len(t) { + i++ + b.WriteByte(t[i]) + continue + } + default: + b.WriteByte(t[i]) + continue + } + c, err := pickRandom(set) + if err != nil { + return "", err + } + b.WriteByte(c) + } + return b.String(), nil +} +func generateOne(o PWOptions) (string, error) { + sets := buildSets(o) + if o.Template != "" { + for i := 0; i < 500; i++ { + p, err := generateTemplate(o.Template, sets) + if err != nil { + return "", err + } + if o.NoRepeat && hasRepeat(p) { + continue + } + if o.NoSeq && hasSeq(p, 3) { + continue + } + return p, nil + } + return "", errors.New("constraints could not be satisfied") + } + if o.Length <= 0 { + return "", errors.New("length must be > 0") + } + if o.MinLower+o.MinUpper+o.MinDigits+o.MinSymbols > o.Length { + return "", errors.New("sum of minimums exceeds length") + } + if len(sets[4]) == 0 { + return "", errors.New("empty character pool") + } + if o.Unique && len(sets[4]) < o.Length { + return "", errors.New("unique requested but character pool is too small") + } + for attempt := 0; attempt < 1000; attempt++ { + buf := make([]byte, 0, o.Length) + add := func(set string, n int) error { + for i := 0; i < n; i++ { + for { + c, err := pickRandom(set) + if err != nil { + return err + } + if o.Unique && bytesContains(buf, c) { + continue + } + buf = append(buf, c) + break + } + } + return nil + } + if err := add(sets[0], o.MinLower); err != nil { + return "", err + } + if err := add(sets[1], o.MinUpper); err != nil { + return "", err + } + if err := add(sets[2], o.MinDigits); err != nil { + return "", err + } + if err := add(sets[3], o.MinSymbols); err != nil { + return "", err + } + for len(buf) < o.Length { + c, err := pickRandom(sets[4]) + if err != nil { + return "", err + } + if o.Unique && bytesContains(buf, c) { + continue + } + buf = append(buf, c) + } + if err := shuffleBytes(buf); err != nil { + return "", err + } + p := string(buf) + if o.NoRepeat && hasRepeat(p) { + continue + } + if o.NoSeq && hasSeq(p, 3) { + continue + } + return p, nil + } + return "", errors.New("constraints could not be satisfied after 1000 attempts") +} +func entropyBits(o PWOptions, pwd string) float64 { + sets := buildSets(o) + pool := len(sets[4]) + if pool <= 1 { + return 0 + } + return float64(len(pwd)) * math.Log2(float64(pool)) +} + +// -------------------- HTTP application -------------------- + +type Application struct { + cfg Config + clipboard *ClipboardStore + files *FileStore + index *template.Template +} + +func newApplication(cfg Config) (*Application, error) { + cb := newClipboardStore(cfg.MaxPerRoom, cfg.ClipboardData, cfg.PersistSecrets) + if err := cb.load(); err != nil { + return nil, fmt.Errorf("clipboard load: %w", err) + } + files := newFileStore(cfg.DataDir, cfg.FileMaxBytes) + if err := files.load(); err != nil { + return nil, fmt.Errorf("files load: %w", err) + } + b, err := fs.ReadFile(webFS, "web/index.html") + if err != nil { + return nil, err + } + t, err := template.New("index").Parse(string(b)) + if err != nil { + return nil, err + } + return &Application{cfg: cfg, clipboard: cb, files: files, index: t}, nil +} + +func (a *Application) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = a.index.Execute(w, map[string]any{"FileMaxBytes": a.cfg.FileMaxBytes, "PWLength": a.cfg.PW.Length}) +} +func (a *Application) handleApps(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, 405, apiError{Error: "use GET"}) + return + } + apps, err := loadApps(a.cfg.AppsJSON) + if err != nil { + writeJSON(w, 500, apiError{Error: "Apps konnten nicht geladen werden"}) + return + } + writeJSON(w, 200, apps) +} +func (a *Application) handleStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"rooms": a.clipboard.roomsList(), "files": len(a.files.list()), "max_file_bytes": a.cfg.FileMaxBytes, "persist_secrets": a.cfg.PersistSecrets}) +} + +func parseTTL(v string) time.Duration { + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + return 0 + } + if n > 1440 { + n = 1440 + } + return time.Duration(n) * time.Minute +} + +type postClipReq struct { + Type string `json:"type"` + Content string `json:"content"` + Author string `json:"author"` + Secret bool `json:"secret"` + OneTime bool `json:"one_time"` + TTLMinutes int `json:"ttl_minutes"` +} + +func (a *Application) createClip(room string, req postClipReq) (*Clip, error) { + if !validRoom(room) { + return nil, errors.New("invalid room") + } + req.Content = strings.TrimRight(req.Content, "\r\n") + if req.Content == "" { + return nil, errors.New("content empty") + } + if len(req.Content) > 1<<20 { + return nil, errors.New("content too large") + } + if req.Type == "" { + req.Type = "text" + } + c := &Clip{ID: makeID(), Room: room, Type: req.Type, Content: req.Content, Author: req.Author, Secret: req.Secret, OneTime: req.OneTime, CreatedAt: time.Now().UTC()} + if req.TTLMinutes > 0 { + if req.TTLMinutes > 1440 { + req.TTLMinutes = 1440 + } + t := c.CreatedAt.Add(time.Duration(req.TTLMinutes) * time.Minute) + c.ExpiresAt = &t + } + a.clipboard.room(room).add(c) + if err := a.clipboard.save(); err != nil { + log.Printf("clipboard save: %v", err) + } + return c, nil +} + +func (a *Application) handleClipboardAPI(w http.ResponseWriter, r *http.Request) { + path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/"), "/") + parts := strings.Split(path, "/") + if len(parts) == 0 || !validRoom(parts[0]) { + writeJSON(w, 404, apiError{Error: "not found"}) + return + } + room := parts[0] + if len(parts) == 1 { + if r.Method == http.MethodDelete { + if err := a.clipboard.deleteRoom(room); err != nil { + writeJSON(w, 500, apiError{Error: err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"status": "deleted", "room": room}) + return + } + writeJSON(w, 404, apiError{Error: "not found"}) + return + } + switch parts[1] { + case "clip": + if len(parts) == 2 { + if r.Method != http.MethodPost { + writeJSON(w, 405, apiError{Error: "use POST"}) + return + } + var req postClipReq + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, 400, apiError{Error: "invalid JSON"}) + return + } + c, err := a.createClip(room, req) + if err != nil { + writeJSON(w, 400, apiError{Error: err.Error()}) + return + } + resp := c + if c.Secret { + resp = publicClip(c) + } + writeJSON(w, 201, resp) + return + } + if len(parts) == 3 && r.Method == http.MethodGet { + rm, exists := a.clipboard.getRoom(room) + if !exists { + writeJSON(w, 404, apiError{Error: "room not found"}) + return + } + c, ok := rm.reveal(parts[2]) + if !ok { + writeJSON(w, 404, apiError{Error: "clip not found or expired"}) + return + } + if c.OneTime { + _ = a.clipboard.save() + } + writeJSON(w, 200, c) + return + } + case "latest": + if r.Method != http.MethodGet { + writeJSON(w, 405, apiError{Error: "use GET"}) + return + } + rm, exists := a.clipboard.getRoom(room) + if !exists { + writeJSON(w, 404, apiError{Error: "room not found"}) + return + } + c, ok := rm.latestReveal() + if !ok { + writeJSON(w, 404, apiError{Error: "no clips yet"}) + return + } + if c.OneTime { + _ = a.clipboard.save() + } + writeJSON(w, 200, c) + return + case "history": + if r.Method == http.MethodDelete { + if err := a.clipboard.clearRoom(room); err != nil { + if errors.Is(err, os.ErrNotExist) { + writeJSON(w, 404, apiError{Error: "room not found"}) + } else { + writeJSON(w, 500, apiError{Error: err.Error()}) + } + return + } + writeJSON(w, 200, map[string]any{"status": "cleared", "room": room}) + return + } + if r.Method != http.MethodGet { + writeJSON(w, 405, apiError{Error: "use GET or DELETE"}) + return + } + limit := 50 + if n, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && n > 0 && n <= 200 { + limit = n + } + rm, exists := a.clipboard.getRoom(room) + if !exists { + writeJSON(w, 200, []*Clip{}) + return + } + writeJSON(w, 200, rm.history(limit)) + return + case "stream": + if r.Method != http.MethodGet { + writeJSON(w, 405, apiError{Error: "use GET"}) + return + } + a.handleStream(w, r, room) + return + } + writeJSON(w, 404, apiError{Error: "unknown endpoint"}) +} + +func (a *Application) handleStream(w http.ResponseWriter, r *http.Request, room string) { + rm, exists := a.clipboard.getRoom(room) + if !exists { + writeJSON(w, 404, apiError{Error: "room not found"}) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + fl, ok := w.(http.Flusher) + if !ok { + writeJSON(w, 500, apiError{Error: "streaming unsupported"}) + return + } + ch, unsub := rm.subscribe() + defer unsub() + tick := time.NewTicker(25 * time.Second) + defer tick.Stop() + for { + select { + case <-r.Context().Done(): + return + case c, ok := <-ch: + if !ok { + return + } + b, _ := json.Marshal(c) + fmt.Fprintf(w, "event: clip\ndata: %s\n\n", b) + fl.Flush() + case <-tick.C: + fmt.Fprintf(w, ": ping\n\n") + fl.Flush() + } + } +} + +func (a *Application) handleRooms(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeJSON(w, 200, a.clipboard.roomsList()) + case http.MethodPost: + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { + writeJSON(w, 400, apiError{Error: "invalid JSON"}) + return + } + req.Name = strings.TrimSpace(req.Name) + if err := a.clipboard.createRoom(req.Name); err != nil { + writeJSON(w, 400, apiError{Error: err.Error()}) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"status": "created", "room": req.Name}) + default: + writeJSON(w, 405, apiError{Error: "use GET or POST"}) + } +} + +func (a *Application) handleRoomDetails(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, 405, apiError{Error: "use GET"}) + return + } + writeJSON(w, 200, a.clipboard.roomDetails()) +} + +func (a *Application) handleGenerate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, 405, apiError{Error: "use POST"}) + return + } + count := 1 + if n, err := strconv.Atoi(r.URL.Query().Get("count")); err == nil && n > 0 && n <= 20 { + count = n + } + room := r.URL.Query().Get("room") + store := room != "" + if room == "" { + room = "default" + } + ttl := parseTTL(r.URL.Query().Get("ttl_minutes")) + oneTime := r.URL.Query().Get("one_time") != "false" + secret := r.URL.Query().Get("secret") != "false" + type result struct { + Password string `json:"password"` + Entropy float64 `json:"entropy_bits"` + Stored bool `json:"stored"` + ClipID string `json:"clip_id,omitempty"` + } + res := make([]result, 0, count) + for i := 0; i < count; i++ { + pwd, err := generateOne(a.cfg.PW) + if err != nil { + writeJSON(w, 400, apiError{Error: err.Error()}) + return + } + rr := result{Password: pwd, Entropy: entropyBits(a.cfg.PW, pwd)} + if store { + mins := 0 + if ttl > 0 { + mins = int(ttl / time.Minute) + } + c, err := a.createClip(room, postClipReq{Type: "password", Content: pwd, Author: "PWGEN", Secret: secret, OneTime: oneTime, TTLMinutes: mins}) + if err != nil { + writeJSON(w, 400, apiError{Error: err.Error()}) + return + } + rr.Stored = true + rr.ClipID = c.ID + } + res = append(res, rr) + } + writeJSON(w, 200, res) +} + +func (a *Application) handleFiles(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeJSON(w, 200, a.files.list()) + case http.MethodPost: + a.files.upload(w, r) + default: + writeJSON(w, 405, apiError{Error: "use GET or POST"}) + } +} +func (a *Application) handleFileByID(w http.ResponseWriter, r *http.Request) { + id := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/files/"), "/") + if id == "" || strings.Contains(id, "/") { + writeJSON(w, 404, apiError{Error: "not found"}) + return + } + switch r.Method { + case http.MethodGet: + a.files.download(w, r, id) + case http.MethodDelete: + a.files.delete(w, id) + default: + writeJSON(w, 405, apiError{Error: "use GET or DELETE"}) + } +} + +func (a *Application) routes() http.Handler { + mux := http.NewServeMux() + sub, _ := fs.Sub(webFS, "web") + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) + mux.HandleFunc("/api/status", a.handleStatus) + mux.HandleFunc("/api/apps", a.handleApps) + mux.HandleFunc("/api/rooms/details", a.handleRoomDetails) + mux.HandleFunc("/api/rooms", a.handleRooms) + mux.HandleFunc("/api/generate", a.handleGenerate) + mux.HandleFunc("/api/files", a.handleFiles) + mux.HandleFunc("/api/files/", a.handleFileByID) + mux.HandleFunc("/api/tools/", a.handleTools) + mux.HandleFunc("/api/", a.handleClipboardAPI) + mux.HandleFunc("/", a.handleIndex) + return securityHeaders(basicAuth(a.cfg.AuthUser, a.cfg.AuthPass, mux)) +} + +func main() { + cfg := loadConfig() + if cfg.ServerMode != "http" && cfg.ServerMode != "https" { + log.Fatalf("invalid SERVER_MODE %q", cfg.ServerMode) + } + if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil { + log.Fatal(err) + } + app, err := newApplication(cfg) + if err != nil { + log.Fatal(err) + } + srv := &http.Server{Addr: cfg.Addr, Handler: app.routes(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 10 * time.Minute, IdleTimeout: 60 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if cfg.ServerMode == "https" { + if cfg.TLSCertFile == "" || cfg.TLSKeyFile == "" { + log.Fatal("TLS_CERT_FILE and TLS_KEY_FILE required for https") + } + if cfg.HTTPRedirectEnabled && cfg.HTTPRedirectAddr != "" { + go func() { + rs := &http.Server{Addr: cfg.HTTPRedirectAddr, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := strings.Split(r.Host, ":")[0] + target := "https://" + host + cfg.Addr + r.URL.RequestURI() + http.Redirect(w, r, target, http.StatusMovedPermanently) + })} + if err := rs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("redirect server: %v", err) + } + }() + } + go func() { + log.Printf("PAW Toolbox HTTPS listening on %s", cfg.Addr) + if err := srv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server: %v", err) + } + }() + } else { + go func() { + log.Printf("PAW Toolbox HTTP listening on %s", cfg.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server: %v", err) + } + }() + } + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + _ = app.clipboard.save() +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..5e030ab --- /dev/null +++ b/main_test.go @@ -0,0 +1,200 @@ +package main + +import ( + "net/netip" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestPasswordGeneratorRespectsMinimums(t *testing.T) { + o := PWOptions{Length: 20, MinLower: 2, MinUpper: 2, MinDigits: 2, MinSymbols: 2, NoAmbig: true, NoSeq: true, NoRepeat: true, SymbolSet: "!@#$%"} + p, err := generateOne(o) + if err != nil { + t.Fatal(err) + } + if len(p) != 20 { + t.Fatalf("length=%d", len(p)) + } + var lo, up, di, sy int + for _, c := range p { + switch { + case strings.ContainsRune(lower, c): + lo++ + case strings.ContainsRune(upper, c): + up++ + case strings.ContainsRune(digits, c): + di++ + default: + sy++ + } + } + if lo < 2 || up < 2 || di < 2 || sy < 2 { + t.Fatalf("class minimums not met: %q", p) + } +} + +func TestOneTimeClipDisappearsAfterReveal(t *testing.T) { + r := newRoom("test", 10) + c := &Clip{ID: "x", Room: "test", Content: "secret", Secret: true, OneTime: true, CreatedAt: time.Now().UTC()} + r.add(c) + if _, ok := r.reveal("x"); !ok { + t.Fatal("first reveal failed") + } + if _, ok := r.reveal("x"); ok { + t.Fatal("one-time clip still exists") + } +} + +func TestExpiredClipIsPruned(t *testing.T) { + r := newRoom("test", 10) + exp := time.Now().UTC().Add(-time.Minute) + r.add(&Clip{ID: "old", Room: "test", Content: "x", ExpiresAt: &exp, CreatedAt: time.Now().UTC().Add(-time.Hour)}) + if got := r.history(10); len(got) != 0 { + t.Fatalf("expected expired clip to be pruned, got %d", len(got)) + } +} + +func TestFilenameIsReducedToBase(t *testing.T) { + name, err := cleanOriginalName("../../payload.exe") + if err != nil { + t.Fatal(err) + } + if name != "payload.exe" { + t.Fatalf("unexpected name %q", name) + } +} + +func TestIPv4PrefixLastAndMask(t *testing.T) { + p, err := netip.ParsePrefix("10.20.30.40/24") + if err != nil { + t.Fatal(err) + } + if got := prefixLast(p.Masked()).String(); got != "10.20.30.255" { + t.Fatalf("last=%s", got) + } + if got := prefixMask4(24); got != "255.255.255.0" { + t.Fatalf("mask=%s", got) + } +} + +func TestParseSRVName(t *testing.T) { + svc, proto, name := parseSRVName("_ldap._tcp.example.local") + if svc != "ldap" || proto != "tcp" || name != "example.local" { + t.Fatalf("got %q %q %q", svc, proto, name) + } +} + +func TestClipboardPersistsRoomsSecretsAndEmptyRooms(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "clipboard.json") + store := newClipboardStore(20, path, true) + if err := store.createRoom("alpha"); err != nil { + t.Fatal(err) + } + if err := store.createRoom("empty-room"); err != nil { + t.Fatal(err) + } + store.room("alpha").add(&Clip{ID: "normal", Room: "alpha", Type: "text", Content: "hello", CreatedAt: time.Now().UTC()}) + store.room("alpha").add(&Clip{ID: "secret", Room: "alpha", Type: "password", Content: "P@ssw0rd!", Secret: true, CreatedAt: time.Now().UTC()}) + if err := store.save(); err != nil { + t.Fatal(err) + } + + reloaded := newClipboardStore(20, path, true) + if err := reloaded.load(); err != nil { + t.Fatal(err) + } + if _, ok := reloaded.getRoom("empty-room"); !ok { + t.Fatal("empty room was not persisted") + } + r, ok := reloaded.getRoom("alpha") + if !ok { + t.Fatal("alpha room missing after reload") + } + r.mu.RLock() + defer r.mu.RUnlock() + if len(r.clips) != 2 { + t.Fatalf("expected 2 clips after reload, got %d", len(r.clips)) + } + if !r.clips[1].Secret || r.clips[1].Content != "P@ssw0rd!" { + t.Fatal("secret was not persisted correctly") + } +} + +func TestClipboardClearKeepsRoomDeleteRemovesRoomPersistently(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "clipboard.json") + store := newClipboardStore(20, path, true) + if err := store.createRoom("keep"); err != nil { + t.Fatal(err) + } + store.room("keep").add(&Clip{ID: "x", Room: "keep", Content: "value", CreatedAt: time.Now().UTC()}) + if err := store.save(); err != nil { + t.Fatal(err) + } + if err := store.clearRoom("keep"); err != nil { + t.Fatal(err) + } + + reloaded := newClipboardStore(20, path, true) + if err := reloaded.load(); err != nil { + t.Fatal(err) + } + r, ok := reloaded.getRoom("keep") + if !ok { + t.Fatal("cleared room should still exist") + } + if got := len(r.history(10)); got != 0 { + t.Fatalf("cleared room has %d clips", got) + } + if err := reloaded.deleteRoom("keep"); err != nil { + t.Fatal(err) + } + + reloadedAgain := newClipboardStore(20, path, true) + if err := reloadedAgain.load(); err != nil { + t.Fatal(err) + } + if _, ok := reloadedAgain.getRoom("keep"); ok { + t.Fatal("deleted room reappeared after reload") + } +} + +func TestFileStorePersistsMetadataAndBlob(t *testing.T) { + dir := t.TempDir() + store := newFileStore(dir, 1024*1024) + if err := os.MkdirAll(store.dir, 0o700); err != nil { + t.Fatal(err) + } + id := "persisted-file" + content := []byte("persistent file data") + if err := os.WriteFile(store.filePath(id), content, 0o600); err != nil { + t.Fatal(err) + } + store.files[id] = &FileMeta{ID: id, Name: "test.txt", Size: int64(len(content)), SHA256: "dummy", UploadedAt: time.Now().UTC()} + store.mu.Lock() + if err := store.saveLocked(); err != nil { + store.mu.Unlock() + t.Fatal(err) + } + store.mu.Unlock() + + reloaded := newFileStore(dir, 1024*1024) + if err := reloaded.load(); err != nil { + t.Fatal(err) + } + files := reloaded.list() + if len(files) != 1 || files[0].ID != id { + t.Fatalf("file metadata not restored: %#v", files) + } + got, err := os.ReadFile(reloaded.filePath(id)) + if err != nil { + t.Fatal(err) + } + if string(got) != string(content) { + t.Fatalf("blob changed: %q", got) + } +} diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..6c563cc --- /dev/null +++ b/tools.go @@ -0,0 +1,519 @@ +package main + +import ( + "archive/zip" + "bytes" + "context" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "mime/multipart" + "net" + "net/http" + "net/netip" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +const toolUploadLimit int64 = 64 << 20 // 64 MiB for inspection tools + +func toolReadUpload(w http.ResponseWriter, r *http.Request, field string) ([]byte, *multipart.FileHeader, error) { + r.Body = http.MaxBytesReader(w, r.Body, toolUploadLimit+2<<20) + if err := r.ParseMultipartForm(toolUploadLimit); err != nil { + return nil, nil, fmt.Errorf("Datei zu groß oder ungültiger Upload") + } + f, h, err := r.FormFile(field) + if err != nil { + return nil, nil, errors.New("Datei fehlt") + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, toolUploadLimit+1)) + if err != nil { + return nil, nil, err + } + if int64(len(b)) > toolUploadLimit { + return nil, nil, fmt.Errorf("maximal %d MiB für Analysewerkzeuge", toolUploadLimit>>20) + } + return b, h, nil +} + +// -------------------- file inspector -------------------- + +type fileInspectResult struct { + Name string `json:"name"` + Size int `json:"size"` + MIME string `json:"mime"` + Extension string `json:"extension"` + MagicHex string `json:"magic_hex"` + MD5 string `json:"md5"` + SHA256 string `json:"sha256"` + SHA512 string `json:"sha512"` +} + +func (a *Application) handleToolFileInspect(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, 405, apiError{Error: "use POST"}) + return + } + b, h, err := toolReadUpload(w, r, "file") + if err != nil { + writeJSON(w, 400, apiError{Error: err.Error()}) + return + } + m5 := md5.Sum(b) + s256 := sha256.Sum256(b) + s512 := sha512.Sum512(b) + magic := b + if len(magic) > 32 { + magic = magic[:32] + } + mimeType := "application/octet-stream" + if len(b) > 0 { + mimeType = http.DetectContentType(b[:minInt(len(b), 512)]) + } + writeJSON(w, 200, fileInspectResult{ + Name: filepath.Base(h.Filename), Size: len(b), MIME: mimeType, + Extension: strings.ToLower(filepath.Ext(h.Filename)), MagicHex: strings.ToUpper(hex.EncodeToString(magic)), + MD5: hex.EncodeToString(m5[:]), SHA256: hex.EncodeToString(s256[:]), SHA512: hex.EncodeToString(s512[:]), + }) +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +// -------------------- certificate inspector -------------------- + +type certInfo struct { + Subject string `json:"subject"` + Issuer string `json:"issuer"` + Serial string `json:"serial"` + DNSNames []string `json:"dns_names"` + IPAddresses []string `json:"ip_addresses"` + Emails []string `json:"emails"` + NotBefore time.Time `json:"not_before"` + NotAfter time.Time `json:"not_after"` + Expired bool `json:"expired"` + DaysLeft int `json:"days_left"` + IsCA bool `json:"is_ca"` + SignatureAlg string `json:"signature_algorithm"` + PublicKeyAlg string `json:"public_key_algorithm"` + KeyUsage []string `json:"key_usage"` + ExtKeyUsage []string `json:"ext_key_usage"` + SHA1 string `json:"sha1_thumbprint"` + SHA256 string `json:"sha256_thumbprint"` +} + +func certToInfo(c *x509.Certificate) certInfo { + now := time.Now() + ips := make([]string, 0, len(c.IPAddresses)) + for _, ip := range c.IPAddresses { + ips = append(ips, ip.String()) + } + ku := []string{} + usage := []struct { + bit x509.KeyUsage + name string + }{ + {x509.KeyUsageDigitalSignature, "Digital Signature"}, {x509.KeyUsageContentCommitment, "Content Commitment"}, {x509.KeyUsageKeyEncipherment, "Key Encipherment"}, {x509.KeyUsageDataEncipherment, "Data Encipherment"}, {x509.KeyUsageKeyAgreement, "Key Agreement"}, {x509.KeyUsageCertSign, "Certificate Sign"}, {x509.KeyUsageCRLSign, "CRL Sign"}, {x509.KeyUsageEncipherOnly, "Encipher Only"}, {x509.KeyUsageDecipherOnly, "Decipher Only"}, + } + for _, x := range usage { + if c.KeyUsage&x.bit != 0 { + ku = append(ku, x.name) + } + } + eku := []string{} + ekuNames := map[x509.ExtKeyUsage]string{ + x509.ExtKeyUsageAny: "Any", x509.ExtKeyUsageServerAuth: "Server Authentication", x509.ExtKeyUsageClientAuth: "Client Authentication", + x509.ExtKeyUsageCodeSigning: "Code Signing", x509.ExtKeyUsageEmailProtection: "E-mail Protection", x509.ExtKeyUsageTimeStamping: "Time Stamping", + x509.ExtKeyUsageOCSPSigning: "OCSP Signing", x509.ExtKeyUsageMicrosoftServerGatedCrypto: "Microsoft SGC", x509.ExtKeyUsageNetscapeServerGatedCrypto: "Netscape SGC", + } + for _, x := range c.ExtKeyUsage { + if n, ok := ekuNames[x]; ok { + eku = append(eku, n) + } else { + eku = append(eku, fmt.Sprintf("EKU %d", x)) + } + } + h1 := sha1Sum(c.Raw) + h256 := sha256.Sum256(c.Raw) + days := int(time.Until(c.NotAfter).Hours() / 24) + return certInfo{Subject: c.Subject.String(), Issuer: c.Issuer.String(), Serial: c.SerialNumber.Text(16), DNSNames: c.DNSNames, IPAddresses: ips, Emails: c.EmailAddresses, NotBefore: c.NotBefore, NotAfter: c.NotAfter, Expired: now.After(c.NotAfter), DaysLeft: days, IsCA: c.IsCA, SignatureAlg: c.SignatureAlgorithm.String(), PublicKeyAlg: c.PublicKeyAlgorithm.String(), KeyUsage: ku, ExtKeyUsage: eku, SHA1: strings.ToUpper(hex.EncodeToString(h1)), SHA256: strings.ToUpper(hex.EncodeToString(h256[:]))} +} + +func sha1Sum(b []byte) []byte { + // SHA-1 is exposed only as a legacy certificate thumbprint identifier. + s := sha1.Sum(b) + return s[:] +} + +func parseCertificates(data []byte) ([]*x509.Certificate, error) { + var certs []*x509.Certificate + rest := data + for { + block, r := pem.Decode(rest) + if block == nil { + break + } + rest = r + if block.Type == "CERTIFICATE" { + c, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + certs = append(certs, c) + } + } + if len(certs) > 0 { + return certs, nil + } + c, err := x509.ParseCertificate(data) + if err == nil { + return []*x509.Certificate{c}, nil + } + many, err2 := x509.ParseCertificates(data) + if err2 == nil && len(many) > 0 { + return many, nil + } + return nil, err +} + +func extractPFXWithOpenSSL(data []byte, password string) ([]byte, error) { + if _, err := exec.LookPath("openssl"); err != nil { + return nil, errors.New("PFX/P12 benötigt OpenSSL auf dem Server") + } + dir, err := os.MkdirTemp("", "paw-cert-") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + in := filepath.Join(dir, "input.pfx") + if err := os.WriteFile(in, data, 0o600); err != nil { + return nil, err + } + cmd := exec.Command("openssl", "pkcs12", "-in", in, "-nokeys", "-nodes", "-passin", "stdin") + cmd.Stdin = strings.NewReader(password + "\n") + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("PFX konnte nicht geöffnet werden: %s", strings.TrimSpace(string(out))) + } + return out, nil +} + +func (a *Application) handleToolCertInspect(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, 405, apiError{Error: "use POST"}) + return + } + b, h, err := toolReadUpload(w, r, "file") + if err != nil { + writeJSON(w, 400, apiError{Error: err.Error()}) + return + } + ext := strings.ToLower(filepath.Ext(h.Filename)) + if ext == ".pfx" || ext == ".p12" { + b, err = extractPFXWithOpenSSL(b, r.FormValue("password")) + if err != nil { + writeJSON(w, 400, apiError{Error: err.Error()}) + return + } + } + certs, err := parseCertificates(b) + if err != nil { + writeJSON(w, 400, apiError{Error: "Kein unterstütztes X.509-Zertifikat erkannt"}) + return + } + out := make([]certInfo, 0, len(certs)) + for _, c := range certs { + out = append(out, certToInfo(c)) + } + writeJSON(w, 200, map[string]any{"file": filepath.Base(h.Filename), "certificates": out}) +} + +// -------------------- ZIP archive viewer -------------------- + +type archiveEntry struct { + Name string `json:"name"` + Size uint64 `json:"size"` + CompressedSize uint64 `json:"compressed_size"` + Method uint16 `json:"method"` + Modified time.Time `json:"modified"` + Directory bool `json:"directory"` +} + +func (a *Application) handleToolArchive(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, 405, apiError{Error: "use POST"}) + return + } + b, h, err := toolReadUpload(w, r, "file") + if err != nil { + writeJSON(w, 400, apiError{Error: err.Error()}) + return + } + zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) + if err != nil { + writeJSON(w, 400, apiError{Error: "Keine gültige ZIP-Datei"}) + return + } + entries := make([]archiveEntry, 0, len(zr.File)) + var unpacked uint64 + for _, f := range zr.File { + unpacked += f.UncompressedSize64 + entries = append(entries, archiveEntry{Name: f.Name, Size: f.UncompressedSize64, CompressedSize: f.CompressedSize64, Method: f.Method, Modified: f.Modified, Directory: f.FileInfo().IsDir()}) + } + sort.Slice(entries, func(i, j int) bool { return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) }) + writeJSON(w, 200, map[string]any{"file": filepath.Base(h.Filename), "entries": entries, "entry_count": len(entries), "compressed_bytes": len(b), "uncompressed_bytes": unpacked}) +} + +// -------------------- DNS -------------------- + +type dnsReq struct { + Name string `json:"name"` + Type string `json:"type"` +} +type dnsResp struct { + Name string `json:"name"` + Type string `json:"type"` + Results []string `json:"results"` + DurationMS int64 `json:"duration_ms"` +} + +func (a *Application) handleToolDNS(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, 405, apiError{Error: "use POST"}) + return + } + var q dnsReq + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&q); err != nil { + writeJSON(w, 400, apiError{Error: "invalid JSON"}) + return + } + q.Name = strings.TrimSpace(q.Name) + q.Type = strings.ToUpper(strings.TrimSpace(q.Type)) + if q.Name == "" { + writeJSON(w, 400, apiError{Error: "Name fehlt"}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + res := net.DefaultResolver + start := time.Now() + var out []string + var err error + switch q.Type { + case "A", "AAAA": + var ips []net.IPAddr + ips, err = res.LookupIPAddr(ctx, q.Name) + if err == nil { + for _, x := range ips { + if (q.Type == "A" && x.IP.To4() != nil) || (q.Type == "AAAA" && x.IP.To4() == nil) { + out = append(out, x.IP.String()) + } + } + } + case "CNAME": + var x string + x, err = res.LookupCNAME(ctx, q.Name) + if err == nil { + out = []string{x} + } + case "MX": + var xs []*net.MX + xs, err = res.LookupMX(ctx, q.Name) + if err == nil { + for _, x := range xs { + out = append(out, fmt.Sprintf("%d %s", x.Pref, x.Host)) + } + } + case "TXT": + out, err = res.LookupTXT(ctx, q.Name) + case "PTR": + out, err = res.LookupAddr(ctx, q.Name) + case "SRV": + service, proto, name := parseSRVName(q.Name) + var xs []*net.SRV + _, xs, err = res.LookupSRV(ctx, service, proto, name) + if err == nil { + for _, x := range xs { + out = append(out, fmt.Sprintf("priority=%d weight=%d port=%d target=%s", x.Priority, x.Weight, x.Port, x.Target)) + } + } + default: + writeJSON(w, 400, apiError{Error: "Typ muss A, AAAA, CNAME, MX, TXT, SRV oder PTR sein"}) + return + } + if err != nil { + writeJSON(w, 502, apiError{Error: err.Error()}) + return + } + sort.Strings(out) + writeJSON(w, 200, dnsResp{Name: q.Name, Type: q.Type, Results: out, DurationMS: time.Since(start).Milliseconds()}) +} +func parseSRVName(s string) (string, string, string) { + parts := strings.Split(strings.TrimSuffix(s, "."), ".") + if len(parts) >= 3 && strings.HasPrefix(parts[0], "_") && strings.HasPrefix(parts[1], "_") { + return strings.TrimPrefix(parts[0], "_"), strings.TrimPrefix(parts[1], "_"), strings.Join(parts[2:], ".") + } + return "", "", s +} + +// -------------------- connectivity -------------------- + +type connReq struct { + Host string `json:"host"` + Port int `json:"port"` + TimeoutMS int `json:"timeout_ms"` +} + +func (a *Application) handleToolConnectivity(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, 405, apiError{Error: "use POST"}) + return + } + var q connReq + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&q); err != nil { + writeJSON(w, 400, apiError{Error: "invalid JSON"}) + return + } + q.Host = strings.TrimSpace(q.Host) + if q.Host == "" || q.Port < 1 || q.Port > 65535 { + writeJSON(w, 400, apiError{Error: "Host oder Port ungültig"}) + return + } + if q.TimeoutMS < 100 || q.TimeoutMS > 10000 { + q.TimeoutMS = 3000 + } + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(q.TimeoutMS)*time.Millisecond) + defer cancel() + ips, _ := net.DefaultResolver.LookupHost(ctx, q.Host) + start := time.Now() + d := net.Dialer{Timeout: time.Duration(q.TimeoutMS) * time.Millisecond} + c, err := d.DialContext(ctx, "tcp", net.JoinHostPort(q.Host, strconv.Itoa(q.Port))) + ms := time.Since(start).Milliseconds() + remote := "" + if err == nil { + remote = c.RemoteAddr().String() + _ = c.Close() + } + resp := map[string]any{"host": q.Host, "port": q.Port, "resolved_ips": ips, "latency_ms": ms, "reachable": err == nil, "remote": remote} + if err != nil { + resp["error"] = err.Error() + } + writeJSON(w, 200, resp) +} + +// -------------------- subnet calculator -------------------- + +type subnetReq struct { + CIDR string `json:"cidr"` +} + +func (a *Application) handleToolSubnet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, 405, apiError{Error: "use POST"}) + return + } + var q subnetReq + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&q); err != nil { + writeJSON(w, 400, apiError{Error: "invalid JSON"}) + return + } + p, err := netip.ParsePrefix(strings.TrimSpace(q.CIDR)) + if err != nil { + writeJSON(w, 400, apiError{Error: "Ungültiges CIDR"}) + return + } + p = p.Masked() + a0 := p.Addr() + bits := 128 + if a0.Is4() { + bits = 32 + } + hostBits := bits - p.Bits() + last := prefixLast(p) + total := new(big.Int).Lsh(big.NewInt(1), uint(hostBits)) + resp := map[string]any{"input": q.CIDR, "network": p.String(), "prefix_length": p.Bits(), "address_bits": bits, "first_address": a0.String(), "last_address": last.String(), "address_count": total.String(), "family": "IPv6"} + if a0.Is4() { + resp["family"] = "IPv4" + resp["netmask"] = prefixMask4(p.Bits()) + resp["broadcast"] = last.String() + if p.Bits() <= 30 { + resp["first_host"] = nextAddr(a0).String() + resp["last_host"] = prevAddr(last).String() + resp["usable_hosts"] = new(big.Int).Sub(total, big.NewInt(2)).String() + } else { + resp["first_host"] = a0.String() + resp["last_host"] = last.String() + resp["usable_hosts"] = total.String() + } + } + writeJSON(w, 200, resp) +} +func prefixLast(p netip.Prefix) netip.Addr { + a := p.Masked().Addr() + b := a.As16() + start := p.Bits() + if a.Is4() { + start += 96 + } + for i := start; i < 128; i++ { + byteIdx := i / 8 + bit := uint(7 - (i % 8)) + b[byteIdx] |= 1 << bit + } + out := netip.AddrFrom16(b) + if a.Is4() { + out = out.Unmap() + } + return out +} +func nextAddr(a netip.Addr) netip.Addr { return a.Next() } +func prevAddr(a netip.Addr) netip.Addr { return a.Prev() } +func prefixMask4(bits int) string { + if bits < 0 || bits > 32 { + return "" + } + var n uint32 + if bits > 0 { + n = ^uint32(0) << uint(32-bits) + } + return fmt.Sprintf("%d.%d.%d.%d", byte(n>>24), byte(n>>16), byte(n>>8), byte(n)) +} + +func (a *Application) handleTools(w http.ResponseWriter, r *http.Request) { + path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/tools/"), "/") + switch path { + case "file-inspect": + a.handleToolFileInspect(w, r) + case "cert-inspect": + a.handleToolCertInspect(w, r) + case "archive": + a.handleToolArchive(w, r) + case "dns": + a.handleToolDNS(w, r) + case "connectivity": + a.handleToolConnectivity(w, r) + case "subnet": + a.handleToolSubnet(w, r) + default: + writeJSON(w, 404, apiError{Error: "unknown tool"}) + } +} diff --git a/web/app.css b/web/app.css new file mode 100644 index 0000000..816ce2b --- /dev/null +++ b/web/app.css @@ -0,0 +1,159 @@ +:root { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #e7edf6; + background: #0a0f18; + --panel: #111927; + --panel2: #151f2f; + --line: #263246; + --muted: #91a0b5; + --text: #e7edf6; + --accent: #5ca7ff; + --accent2: #88c2ff; + --danger: #ff7b7b; + --ok: #54d3a0; +} +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; background: radial-gradient(900px 500px at 15% -10%, #17345a 0%, transparent 65%), #0a0f18; color: var(--text); } +button, input, textarea, select { font: inherit; } +button { color: inherit; } +.topbar { position: sticky; top: 0; z-index: 10; min-height: 72px; display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 12px 28px; border-bottom: 1px solid rgba(255,255,255,.08); background: rgba(10,15,24,.9); backdrop-filter: blur(18px); } +.brand { display: flex; align-items: center; gap: 12px; } +.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; font-weight: 800; background: linear-gradient(145deg,#77b8ff,#2c73d2); box-shadow: 0 8px 24px rgba(61,132,220,.28); } +.brand strong { display: block; font-size: 15px; letter-spacing: .02em; } +.brand span { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; } +.nav { display: flex; gap: 6px; padding: 5px; border-radius: 12px; background: #101827; } +.nav-btn { border: 0; border-radius: 8px; background: transparent; color: var(--muted); padding: 8px 12px; cursor: pointer; } +.nav-btn:hover, .nav-btn.active { background: #1b2940; color: #fff; } +.shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 38px 0 70px; } +.view { display: none; } +.view.active { display: block; } +.hero { display: grid; grid-template-columns: 1fr auto; gap: 28px; align-items: end; padding: 22px 0 30px; } +.eyebrow { margin: 0 0 7px; color: var(--accent2); font-size: 12px; font-weight: 750; letter-spacing: .12em; text-transform: uppercase; } +h1 { margin: 0; font-size: clamp(30px, 5vw, 48px); line-height: 1.02; letter-spacing: -.035em; } +h2 { margin: 0; font-size: 19px; } +.lead { max-width: 760px; margin: 16px 0 0; color: #aab8ca; font-size: 16px; line-height: 1.6; } +.hero-status { display: flex; align-items: center; gap: 8px; white-space: nowrap; padding: 9px 12px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 12px; background: rgba(17,25,39,.7); } +.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 4px rgba(84,211,160,.1); } +.quick-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 14px; margin: 6px 0 42px; } +.quick-card { min-height: 116px; border: 1px solid var(--line); border-radius: 16px; background: linear-gradient(180deg,#142034,#0f1724); text-align: left; padding: 16px; cursor: pointer; transition: transform .15s ease,border-color .15s ease; } +.quick-card:hover { transform: translateY(-2px); border-color: #3d5d88; } +.quick-card strong, .quick-card small { display: block; } +.quick-card small { margin-top: 4px; color: var(--muted); } +.quick-icon { display: inline-grid; place-items: center; width: 32px; height: 32px; margin-bottom: 14px; border-radius: 9px; background: #20314c; color: #9dccff; } +.section-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin: 4px 0 18px; } +.section-head h1 { font-size: 36px; } +.filters { display: flex; gap: 8px; } +input, textarea, select { width: 100%; border: 1px solid var(--line); border-radius: 10px; background: #0c1421; color: var(--text); padding: 10px 11px; outline: none; } +input:focus, textarea:focus, select:focus { border-color: #4f8bd3; box-shadow: 0 0 0 3px rgba(79,139,211,.13); } +textarea { resize: vertical; } +label { display: grid; gap: 7px; color: #b6c2d1; font-size: 13px; } +.apps-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(205px,1fr)); gap: 13px; } +.app-tile { min-height: 122px; display: grid; grid-template-rows: 1fr auto auto; text-decoration: none; color: inherit; padding: 16px; border: 1px solid var(--line); border-radius: 15px; background: linear-gradient(180deg,#121c2c,#0e1623); position: relative; overflow: hidden; } +.app-tile:before { content:""; position:absolute; inset:-80px auto auto -80px; width:180px; height:180px; border-radius:50%; background: var(--tile-color,#427bbd); opacity:.13; filter:blur(10px); } +.app-tile:hover { border-color: #47648a; } +.app-icon { font-size: 28px; position: relative; } +.app-title { font-weight: 700; position: relative; } +.app-cat { width: max-content; margin-top: 7px; color: var(--muted); font-size: 11px; border: 1px solid #2a3850; border-radius: 999px; padding: 3px 7px; position: relative; } +.two-col { display: grid; grid-template-columns: minmax(0,.9fr) minmax(0,1.1fr); gap: 16px; } +.panel { border: 1px solid var(--line); border-radius: 17px; background: linear-gradient(180deg,rgba(20,31,48,.96),rgba(14,22,35,.96)); padding: 18px; box-shadow: 0 18px 50px rgba(0,0,0,.12); } +.panel > h2 { margin-bottom: 16px; } +.panel label + label { margin-top: 13px; } +.form-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 12px; margin-top: 13px; } +.form-grid label + label { margin-top: 0; } +.checks { display: flex; flex-wrap: wrap; gap: 14px; margin: 16px 0; } +.check { display: flex; align-items: center; gap: 7px; } +.check input { width: auto; } +.actions { display: flex; gap: 9px; flex-wrap: wrap; } +.actions.compact { justify-content: flex-end; } +.btn { border: 1px solid #33445e; border-radius: 10px; background: #172338; padding: 10px 13px; cursor: pointer; } +.btn:hover { border-color: #4b6487; } +.btn.primary { color: #07111e; border-color: #73b5ff; background: #73b5ff; font-weight: 750; } +.btn.danger { color: #ffc1c1; border-color: #703e49; background: #2b1920; } +.btn.small { padding: 7px 9px; font-size: 12px; } +.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; } +.panel-head > div { display: flex; align-items: center; gap: 9px; } +.badge { display: inline-flex; align-items: center; border: 1px solid #30425c; border-radius: 999px; color: #a8bad0; font-size: 11px; padding: 4px 8px; } +.hint { color: var(--muted); font-size: 12px; line-height: 1.5; } +.clip-list { display: grid; gap: 9px; max-height: 560px; overflow: auto; padding-right: 2px; } +.clip-item { border: 1px solid #26364d; border-radius: 12px; background: #0d1624; padding: 12px; } +.clip-top { display: flex; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 11px; } +.clip-content { margin: 9px 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace,SFMono-Regular,Consolas,monospace; font-size: 13px; color: #dce8f8; } +.clip-secret { letter-spacing: .12em; color: #8494aa; } +.clip-actions { display: flex; gap: 7px; } +.room-panel { margin-bottom: 16px; } +.room-hint { margin: 4px 0 0; } +.room-list { display: grid; grid-template-columns: repeat(auto-fill,minmax(230px,1fr)); gap: 9px; margin-top: 12px; } +.room-item { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 9px; align-items: center; padding: 9px; border: 1px solid #26364d; border-radius: 12px; background: #0d1624; } +.room-item.active { border-color: #4b82c6; background: #101d30; } +.room-open { min-width: 0; border: 0; background: transparent; color: inherit; padding: 3px; text-align: left; cursor: pointer; } +.room-name { display: block; font-weight: 700; overflow-wrap: anywhere; } +.room-meta { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; } +.room-actions { display: flex; gap: 5px; } +.secret-output { min-height: 120px; margin: 0; padding: 14px; white-space: pre-wrap; overflow-wrap: anywhere; border: 1px solid #26364d; border-radius: 12px; background: #09101a; color: #e9f2ff; font: 15px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace; } +.meta-row { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 12px; } +.meta-pill { border: 1px solid #2b3d57; border-radius: 999px; padding: 4px 8px; color: var(--muted); font-size: 11px; } +.upload-panel form { display: grid; grid-template-columns: minmax(0,2fr) minmax(180px,1fr) auto; align-items: end; gap: 12px; } +.file-drop { min-height: 66px; display: grid; place-items: center; border: 1px dashed #46617f; border-radius: 12px; background: #0c1522; cursor: pointer; padding: 10px; text-align: center; } +.file-drop input { display: none; } +.file-drop span { color: var(--muted); font-size: 12px; } +.file-list { display: grid; gap: 10px; margin-top: 14px; } +.file-row { display: grid; grid-template-columns: minmax(0,1.4fr) minmax(180px,.8fr) auto; align-items: center; gap: 14px; padding: 13px 15px; border: 1px solid var(--line); border-radius: 13px; background: #101927; } +.file-name { font-weight: 700; overflow-wrap: anywhere; } +.file-sub, .file-hash { margin-top: 4px; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; } +.file-hash { font-family: ui-monospace,SFMono-Regular,Consolas,monospace; } +.file-actions { display: flex; gap: 7px; } +.file-actions a { text-decoration: none; } +.empty { padding: 28px 12px; text-align: center; color: var(--muted); border: 1px dashed #2c3c53; border-radius: 12px; } +.toast { position: fixed; right: 20px; bottom: 20px; max-width: 360px; transform: translateY(20px); opacity: 0; pointer-events:none; transition:.2s ease; padding: 11px 14px; border: 1px solid #375171; border-radius: 10px; background:#14233a; box-shadow:0 15px 40px rgba(0,0,0,.28); } +.toast.show { opacity:1; transform:translateY(0); } +code { color: #b7d7ff; } +@media (max-width: 850px) { .topbar { align-items:flex-start; flex-direction:column; } .nav { width:100%; overflow:auto; } .hero { grid-template-columns:1fr; } .quick-grid,.two-col { grid-template-columns:1fr; } .upload-panel form,.file-row { grid-template-columns:1fr; } } +@media (max-width: 560px) { .shell { width:min(100% - 20px,1180px); padding-top:24px; } .topbar { padding:12px; } .section-head { align-items:stretch; flex-direction:column; } .filters,.form-grid { grid-template-columns:1fr; display:grid; } .quick-grid { gap:9px; } .actions.compact { justify-content:flex-start; } } + +/* integrated admin tools */ +.quick-grid.four { grid-template-columns: repeat(4,1fr); } +.small-lead { font-size: 14px; margin-top: 10px; } +.tool-head { align-items: flex-end; } +.tool-search { max-width: 260px; } +.tool-layout { display: grid; grid-template-columns: 250px minmax(0,1fr); gap: 16px; align-items: start; } +.tool-nav { position: sticky; top: 92px; display: grid; gap: 5px; max-height: calc(100vh - 112px); overflow: auto; padding: 9px; border: 1px solid var(--line); border-radius: 15px; background: #0e1724; } +.tool-nav-btn { width: 100%; border: 0; border-radius: 9px; background: transparent; color: var(--muted); text-align: left; padding: 9px 10px; cursor: pointer; font-size: 12px; } +.tool-nav-btn:hover, .tool-nav-btn.active { color: #fff; background: #1a2940; } +.tool-nav-btn[hidden] { display: none; } +.tool-panel { display: none; min-height: 460px; } +.tool-panel.active { display: block; } +.form-grid.three { grid-template-columns: repeat(3,1fr); } +.top-gap { margin-top: 14px; } +.align-end { align-items: end; } +.mono { font-family: ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace; } +.inline-form { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 12px; align-items: end; } +.result-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 9px; } +.result-grid > div { min-width: 0; padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: #0d1624; } +.result-grid code { display: block; margin-top: 5px; overflow-wrap: anywhere; } +.result-label { color: var(--muted); font-size: 11px; } +.split-edit { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.tool-output, .diff-output { min-height: 80px; margin: 0; padding: 13px; white-space: pre-wrap; overflow-wrap: anywhere; overflow: auto; border: 1px solid #26364d; border-radius: 11px; background: #09101a; color: #dce8f8; font: 12px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; } +.diff-output { min-height: 180px; } +.diff-add { color: #8ee8bd; } +.diff-del { color: #ffaaaa; } +.diff-same { color: #8d9caf; } +.kv-list { display: grid; gap: 7px; } +.kv-row { display: grid; grid-template-columns: 170px minmax(0,1fr); gap: 12px; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,.06); } +.kv-key { color: var(--muted); font-size: 12px; } +.kv-value { min-width: 0; overflow-wrap: anywhere; font: 12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace; } +.subtools { display: grid; grid-template-columns: repeat(3,1fr); gap: 12px; } +.subpanel { padding: 14px; border: 1px solid var(--line); border-radius: 13px; background: #0d1624; } +.subpanel h3 { margin: 0 0 13px; font-size: 14px; } +.cert-card { margin-top: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 13px; background:#0d1624; } +.cert-card h3 { margin: 0 0 10px; font-size: 14px; } +.table-wrap { width: 100%; overflow: auto; border: 1px solid var(--line); border-radius: 11px; } +.data-table { width: 100%; border-collapse: collapse; font-size: 12px; } +.data-table th, .data-table td { padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,.06); text-align: left; white-space: nowrap; } +.data-table th { position: sticky; top: 0; background: #142034; color: #cfe3ff; } +.data-table td { color: #c4cfdd; } +.status-ok { color: var(--ok); } +.status-bad { color: var(--danger); } +.status-warn { color: #ffd178; } +@media (max-width: 1020px) { .quick-grid.four { grid-template-columns: repeat(2,1fr); } .tool-layout { grid-template-columns: 1fr; } .tool-nav { position: static; display: flex; max-height: none; overflow-x: auto; } .tool-nav-btn { width: max-content; white-space: nowrap; } .subtools { grid-template-columns: 1fr; } } +@media (max-width: 850px) { .form-grid.three,.split-edit,.result-grid { grid-template-columns: 1fr; } .inline-form { grid-template-columns: 1fr; } } +@media (max-width: 560px) { .quick-grid.four { grid-template-columns:1fr; } .tool-search { max-width:none; } .kv-row { grid-template-columns:1fr; gap:3px; } } diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..ab11de8 --- /dev/null +++ b/web/app.js @@ -0,0 +1,281 @@ +'use strict'; + +const $ = s => document.querySelector(s); +const $$ = s => [...document.querySelectorAll(s)]; +let apps = []; +let clipStream = null; +let toastTimer = null; +let csvRows = []; +let csvCurrentDelimiter = ';'; +let lastFileSHA256 = ''; + +function toast(msg) { + const el = $('#toast'); + el.textContent = msg; + el.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.classList.remove('show'), 2600); +} + +async function api(url, options = {}) { + const res = await fetch(url, options); + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { const j = await res.json(); if (j.error) msg = j.error; } catch (_) {} + throw new Error(msg); + } + const ct = res.headers.get('content-type') || ''; + return ct.includes('application/json') ? res.json() : res.text(); +} +function fmtBytes(n) { + const u = ['B','KB','MB','GB','TB']; let i=0, v=Number(n||0); + while (v >= 1024 && i < u.length-1) { v/=1024; i++; } + return `${v.toFixed(i ? 1 : 0)} ${u[i]}`; +} +function fmtDate(s) { try { return new Intl.DateTimeFormat('de-DE',{dateStyle:'short',timeStyle:'medium'}).format(new Date(s)); } catch (_) { return s; } } +function psQuote(v) { return `'${String(v ?? '').replaceAll("'", "''")}'`; } +function downloadBlob(name, bytes, type='application/octet-stream') { const blob = bytes instanceof Blob ? bytes : new Blob([bytes],{type}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=name; a.click(); setTimeout(()=>URL.revokeObjectURL(url),1000); } +function kvRender(target, obj, labels={}) { const el=typeof target==='string'?$(target):target; el.replaceChildren(); for (const [k,v] of Object.entries(obj)) { if(v===undefined||v===null||v==='') continue; const row=document.createElement('div');row.className='kv-row';const key=document.createElement('div');key.className='kv-key';key.textContent=labels[k]||k;const val=document.createElement('div');val.className='kv-value';val.textContent=Array.isArray(v)?v.join(', '):String(v);row.append(key,val);el.append(row); } } + +// -------------------- navigation and launcher -------------------- +function openView(name) { + $$('.view').forEach(v => v.classList.toggle('active', v.id === `view-${name}`)); + $$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === name)); + if (name === 'clipboard') { loadClipRooms(); loadClipHistory(); startClipStream(); } + if (name === 'files') loadFiles(); +} +$$('.nav-btn').forEach(b => b.addEventListener('click', () => openView(b.dataset.view))); +$$('[data-open-view]').forEach(b => b.addEventListener('click', () => openView(b.dataset.openView))); + +async function loadStatus() { + try { const s = await api('/api/status'); $('#statusText').textContent = `${s.rooms.length} Räume · ${s.files} Dateien · 16 Werkzeuge`; $('#uploadStatus').textContent = `Maximale Dateigröße: ${fmtBytes(s.max_file_bytes)}.`; } + catch (_) { $('#statusText').textContent = 'Status nicht verfügbar'; } +} +async function loadApps() { + try { apps = await api('/api/apps'); const cats = [...new Set(apps.map(a => a.category).filter(Boolean))].sort(); cats.forEach(c => { const o=document.createElement('option');o.value=c;o.textContent=c;$('#appCategory').append(o); }); renderApps(); } + catch (_) { $('#appsGrid').innerHTML = `
Apps konnten nicht geladen werden.
`; } +} +function renderApps() { + const q = ($('#appSearch').value || '').toLocaleLowerCase('de'); const cat = $('#appCategory').value; const grid=$('#appsGrid'); grid.replaceChildren(); + apps.filter(a => (!q || (a.title||'').toLocaleLowerCase('de').includes(q)) && (!cat || a.category===cat)).forEach(a => { + const link=document.createElement('a'); link.className='app-tile'; link.href=a.url; link.target='_blank'; link.rel='noopener noreferrer'; link.style.setProperty('--tile-color',a.color||'#427bbd'); + const icon=document.createElement('div');icon.className='app-icon';icon.textContent=a.icon||'↗'; + const title=document.createElement('div');title.className='app-title';title.textContent=a.title; + const badge=document.createElement('div');badge.className='app-cat';badge.textContent=a.category||'Anwendung'; + link.append(icon,title,badge);grid.append(link); + }); + if (!grid.children.length) { const e=document.createElement('div'); e.className='empty'; e.textContent='Keine passenden Anwendungen.'; grid.append(e); } +} +$('#appSearch').addEventListener('input',renderApps); $('#appCategory').addEventListener('change',renderApps); + +// -------------------- clipboard -------------------- +function room() { return ($('#clipRoom').value || 'default').trim(); } + +async function loadClipRooms() { + const list=$('#clipRooms'); + try { + const rooms=await api('/api/rooms/details'); + list.replaceChildren(); + rooms.forEach(info=>list.append(renderRoom(info))); + if(!rooms.length){const e=document.createElement('div');e.className='empty';e.textContent='Noch keine Räume vorhanden. Über „Raum öffnen / anlegen“ kannst du den ersten Raum erstellen.';list.append(e);} + return rooms; + } catch (_) { + list.innerHTML='
Raumliste konnte nicht geladen werden.
'; + return []; + } +} + +function renderRoom(info) { + const row=document.createElement('div'); row.className='room-item'; + if(info.name===room()) row.classList.add('active'); + const open=document.createElement('button'); open.className='room-open'; open.type='button'; + const name=document.createElement('span'); name.className='room-name'; name.textContent=info.name; + const meta=document.createElement('span'); meta.className='room-meta'; + const parts=[`${info.count} Einträge`]; if(info.secrets) parts.push(`${info.secrets} Secrets`); if(info.last_active) parts.push(`zuletzt ${fmtDate(info.last_active)}`); meta.textContent=parts.join(' · '); + open.append(name,meta); open.addEventListener('click',()=>openClipRoom(info.name)); + const actions=document.createElement('div'); actions.className='room-actions'; + const clear=document.createElement('button'); clear.className='btn small'; clear.type='button'; clear.textContent='Leeren'; + clear.addEventListener('click',async(e)=>{e.stopPropagation();await clearClipRoom(info.name);}); + const del=document.createElement('button'); del.className='btn small danger'; del.type='button'; del.textContent='Löschen'; + del.addEventListener('click',async(e)=>{e.stopPropagation();await deleteClipRoom(info.name);}); + actions.append(clear,del); row.append(open,actions); return row; +} + +async function openClipRoom(name, create=false) { + name=String(name||'').trim(); if(!name) return toast('Raumname fehlt'); + try { + if(create) await api('/api/rooms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})}); + $('#clipRoom').value=name; $('#clipRoomBadge').textContent=name; + await loadClipHistory(); startClipStream(); await loadClipRooms(); loadStatus(); + } catch(e) { toast(e.message); } +} + +async function clearClipRoom(name) { + if(!confirm(`Verlauf von „${name}“ wirklich leeren? Der Raum bleibt erhalten.`)) return; + try { + await api(`/api/${encodeURIComponent(name)}/history`,{method:'DELETE'}); + if(name===room()) await loadClipHistory(); + await loadClipRooms(); loadStatus(); toast(`Raum „${name}“ geleert`); + } catch(e) { toast(e.message); } +} + +async function deleteClipRoom(name) { + if(!confirm(`Raum „${name}“ inklusive aller Einträge wirklich dauerhaft löschen?`)) return; + try { + await api(`/api/${encodeURIComponent(name)}`,{method:'DELETE'}); + if(name===room()) { + if(clipStream){clipStream.close();clipStream=null;} + const remaining=await api('/api/rooms/details'); + const next=remaining[0]?.name || 'default'; $('#clipRoom').value=next; $('#clipRoomBadge').textContent=next; await loadClipHistory(); if(remaining.length) startClipStream(); + } + await loadClipRooms(); loadStatus(); toast(`Raum „${name}“ gelöscht`); + } catch(e) { toast(e.message); } +} + +async function loadClipHistory() { + const r=room(); $('#clipRoomBadge').textContent=r; + try { const items=await api(`/api/${encodeURIComponent(r)}/history?limit=100`); const list=$('#clipHistory'); list.replaceChildren(); [...items].reverse().forEach(c=>list.append(renderClip(c))); if(!items.length){const e=document.createElement('div');e.className='empty';e.textContent='Noch keine Einträge in diesem Raum.';list.append(e);} } + catch(_){ $('#clipHistory').innerHTML='
Verlauf konnte nicht geladen werden.
'; } +} +function renderClip(c) { + const item=document.createElement('div');item.className='clip-item'; + const top=document.createElement('div');top.className='clip-top'; const left=document.createElement('span');left.textContent=`${c.type||'text'}${c.author?' · '+c.author:''}`; const right=document.createElement('span');right.textContent=fmtDate(c.created_at);top.append(left,right); + const content=document.createElement('div');content.className='clip-content'; content.textContent=c.secret?'••••••••••••':(c.content||''); if(c.secret)content.classList.add('clip-secret'); + const actions=document.createElement('div');actions.className='clip-actions'; + if(c.secret){const reveal=document.createElement('button');reveal.className='btn small';reveal.textContent=c.one_time?'Einmalig abrufen':'Secret abrufen';reveal.addEventListener('click',async()=>{try{const full=await api(`/api/${encodeURIComponent(c.room)}/clip/${encodeURIComponent(c.id)}`);content.textContent=full.content;content.classList.remove('clip-secret');await navigator.clipboard.writeText(full.content).catch(()=>{});toast('Secret abgerufen und lokal kopiert');if(full.one_time){setTimeout(loadClipHistory,300);setTimeout(loadClipRooms,300);}}catch(e){toast(e.message)}});actions.append(reveal);} else {const copy=document.createElement('button');copy.className='btn small';copy.textContent='Kopieren';copy.addEventListener('click',async()=>{await navigator.clipboard.writeText(c.content||'');toast('Lokal kopiert');});actions.append(copy);} + if(c.expires_at){const exp=document.createElement('span');exp.className='badge';exp.textContent=`bis ${fmtDate(c.expires_at)}`;actions.append(exp);} item.append(top,content,actions);return item; +} +function startClipStream(){ if(clipStream)clipStream.close(); clipStream=null; try{clipStream=new EventSource(`/api/${encodeURIComponent(room())}/stream`);clipStream.addEventListener('clip',()=>{loadClipHistory();loadClipRooms();});}catch(_){} } +$('#clipRoom').addEventListener('change',()=>openClipRoom(room(),true)); +$('#clipOpenRoom').addEventListener('click',()=>openClipRoom(room(),true)); +$('#clipRoomsRefresh').addEventListener('click',loadClipRooms); +$('#clipRefresh').addEventListener('click',()=>{loadClipHistory();loadClipRooms();}); +$('#clipSubmit').addEventListener('click',async()=>{const r=room();const body={type:'text',content:$('#clipContent').value,author:$('#clipAuthor').value,secret:$('#clipSecret').checked,one_time:$('#clipOneTime').checked,ttl_minutes:Number($('#clipTTL').value||0)};try{await api(`/api/${encodeURIComponent(r)}/clip`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});$('#clipContent').value='';toast('Eintrag abgelegt');await loadClipHistory();await loadClipRooms();startClipStream();loadStatus();}catch(e){toast(e.message)}}); +$('#clipPasteLocal').addEventListener('click',async()=>{try{$('#clipContent').value=await navigator.clipboard.readText();}catch(_){toast('Browser-Zwischenablage konnte nicht gelesen werden')}}); +$('#clipClear').addEventListener('click',()=>clearClipRoom(room())); + +// -------------------- password -------------------- +$('#pwGenerate').addEventListener('click',async()=>{const btn=$('#pwGenerate');btn.disabled=true;try{const count=Math.max(1,Math.min(20,Number($('#pwCount').value||1)));const params=new URLSearchParams({count:String(count)});if($('#pwStore').checked){params.set('room',($('#pwRoom').value||'default').trim());params.set('ttl_minutes',String(Number($('#pwTTL').value||0)));params.set('one_time',String($('#pwOneTime').checked));params.set('secret','true');}const data=await api(`/api/generate?${params}`,{method:'POST'});$('#pwOutput').textContent=data.map(x=>x.password).join('\n');const meta=$('#pwMeta');meta.replaceChildren();const ent=document.createElement('span');ent.className='meta-pill';ent.textContent=`Entropy ≈ ${data[0]?.entropy_bits?.toFixed(1)||'–'} bit`;meta.append(ent);if(data.some(x=>x.stored)){const p=document.createElement('span');p.className='meta-pill';p.textContent=`Im Raum ${$('#pwRoom').value||'default'} abgelegt`;meta.append(p);}toast('Passwort erzeugt');loadStatus();}catch(e){$('#pwOutput').textContent=`Fehler: ${e.message}`;}finally{btn.disabled=false;}}); +$('#pwCopy').addEventListener('click',async()=>{try{await navigator.clipboard.writeText($('#pwOutput').textContent);toast('Lokal kopiert');}catch(_){toast('Kopieren fehlgeschlagen')}}); + +// -------------------- file transfer -------------------- +async function loadFiles(){try{const files=await api('/api/files');const list=$('#filesList');list.replaceChildren();files.forEach(f=>list.append(renderFile(f)));if(!files.length){const e=document.createElement('div');e.className='empty';e.textContent='Noch keine Dateien vorhanden.';list.append(e);}loadStatus();}catch(_){$('#filesList').innerHTML='
Dateiliste konnte nicht geladen werden.
';}} +function renderFile(f){const row=document.createElement('div');row.className='file-row';const a=document.createElement('div');const n=document.createElement('div');n.className='file-name';n.textContent=f.name;const sub=document.createElement('div');sub.className='file-sub';sub.textContent=`${fmtBytes(f.size)} · ${fmtDate(f.uploaded_at)}${f.uploader?' · '+f.uploader:''}`;a.append(n,sub);const b=document.createElement('div');const h=document.createElement('div');h.className='file-hash';h.textContent=`SHA-256 ${f.sha256}`;b.append(h);const actions=document.createElement('div');actions.className='file-actions';const dl=document.createElement('a');dl.className='btn small';dl.href=`/api/files/${encodeURIComponent(f.id)}`;dl.textContent='Download';const del=document.createElement('button');del.className='btn small danger';del.textContent='Löschen';del.addEventListener('click',async()=>{if(!confirm(`„${f.name}“ wirklich löschen?`))return;try{await api(`/api/files/${encodeURIComponent(f.id)}`,{method:'DELETE'});toast('Datei gelöscht');loadFiles();}catch(e){toast(e.message)}});actions.append(dl,del);row.append(a,b,actions);return row;} +$('#uploadFile').addEventListener('change',()=>{$('#filePicked').textContent=$('#uploadFile').files[0]?.name||'Keine Datei ausgewählt';}); +$('#uploadForm').addEventListener('submit',async(e)=>{e.preventDefault();const file=$('#uploadFile').files[0];if(!file)return;const form=new FormData();form.append('file',file);form.append('uploader',$('#fileUploader').value);const btn=e.currentTarget.querySelector('button[type=submit]');btn.disabled=true;$('#uploadStatus').textContent='Upload läuft…';try{const m=await api('/api/files',{method:'POST',body:form});$('#uploadStatus').textContent=`Hochgeladen: ${m.name} · SHA-256 ${m.sha256}`;$('#uploadFile').value='';$('#filePicked').textContent='Keine Datei ausgewählt';toast('Datei hochgeladen');loadFiles();}catch(err){$('#uploadStatus').textContent=`Fehler: ${err.message}`;}finally{btn.disabled=false;}}); +$('#filesRefresh').addEventListener('click',loadFiles); + +// -------------------- tools navigation -------------------- +function openTool(name) { $$('.tool-panel').forEach(p=>p.classList.toggle('active',p.id===`tool-${name}`)); $$('.tool-nav-btn').forEach(b=>b.classList.toggle('active',b.dataset.tool===name)); } +$$('.tool-nav-btn').forEach(b=>b.addEventListener('click',()=>openTool(b.dataset.tool))); +$('#toolSearch').addEventListener('input',()=>{const q=$('#toolSearch').value.toLocaleLowerCase('de').trim(); const buttons=$$('.tool-nav-btn'); buttons.forEach(b=>b.hidden=!!q&&!`${b.textContent} ${b.dataset.keywords||''}`.toLocaleLowerCase('de').includes(q)); const active=buttons.find(b=>b.classList.contains('active')); if(active?.hidden){const first=buttons.find(b=>!b.hidden);if(first)openTool(first.dataset.tool);}}); + +// -------------------- onboarding assistant -------------------- +function asciiName(v) { return String(v||'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/ß/g,'ss').replace(/[^A-Za-z0-9.-]/g,'').toLowerCase(); } +function buildOnboarding() { + const first=$('#onbFirst').value.trim(), last=$('#onbLast').value.trim(); if(!first||!last) throw new Error('Vorname und Nachname fehlen'); + const sam=(asciiName(first).slice(0,1)+asciiName(last)).slice(0,20); const domain=$('#onbDomain').value.trim(); const mailDomain=($('#onbMailDomain').value.trim()||domain); const display=$('#onbDisplayMode').value==='lastfirst'?`${last}, ${first}`:`${first} ${last}`; const upn=domain?`${sam}@${domain}`:sam; const mail=mailDomain?`${asciiName(first)}.${asciiName(last)}@${mailDomain}`:''; + $('#onbSam').textContent=sam;$('#onbUPN').textContent=upn;$('#onbMail').textContent=mail||'–';$('#onbDisplay').textContent=display; + const args=[`-Name ${psQuote(display)}`,`-GivenName ${psQuote(first)}`,`-Surname ${psQuote(last)}`,`-DisplayName ${psQuote(display)}`,`-SamAccountName ${psQuote(sam)}`,`-UserPrincipalName ${psQuote(upn)}`]; + const optional=[['#onbOU','-Path'],['#onbDepartment','-Department'],['#onbTitle','-Title'],['#onbOffice','-Office']]; optional.forEach(([id,arg])=>{const v=$(id).value.trim();if(v)args.push(`${arg} ${psQuote(v)}`);}); if(mail)args.push(`-EmailAddress ${psQuote(mail)}`); + args.push('-AccountPassword $InitialPassword','-Enabled $true','-ChangePasswordAtLogon $true'); + const bt=String.fromCharCode(96); + let ps="# Kennwort separat aus dem PAW-Secret-Transfer übernehmen\n$InitialPassword = Read-Host 'Initiales Kennwort' -AsSecureString\n\nNew-ADUser "+bt+"\n "+args.join(" "+bt+"\n ")+"\n"; + const groups=$('#onbGroups').value.split(',').map(x=>x.trim()).filter(Boolean); if(groups.length){ps+='\n# Gruppenmitgliedschaften\n'+groups.map(g=>`Add-ADGroupMember -Identity ${psQuote(g)} -Members ${psQuote(sam)}`).join('\n')+'\n';} + $('#onbPS').value=ps; return {sam,upn,mail,display}; +} +$('#onbBuild').addEventListener('click',()=>{try{buildOnboarding();toast('Onboarding-Daten erzeugt')}catch(e){toast(e.message)}}); +$('#onbCopy').addEventListener('click',async()=>{try{if(!$('#onbPS').value)buildOnboarding();await navigator.clipboard.writeText($('#onbPS').value);toast('PowerShell kopiert')}catch(e){toast(e.message)}}); +$('#onbPassword').addEventListener('click',async()=>{try{const data=buildOnboarding();const params=new URLSearchParams({count:'1',room:($('#onbRoom').value||'onboarding').trim(),ttl_minutes:String(Number($('#onbTTL').value||15)),one_time:'true',secret:'true'});const r=await api(`/api/generate?${params}`,{method:'POST'});$('#onbSecretStatus').textContent=`Kennwort für ${data.sam} als einmaliges Secret abgelegt · Raum ${$('#onbRoom').value||'onboarding'} · Clip ${r[0].clip_id}.`;toast('Kennwort erzeugt und als Secret abgelegt');loadStatus();}catch(e){toast(e.message)}}); + +// -------------------- file/hash inspector -------------------- +$('#fiFile').addEventListener('change',()=>$('#fiPicked').textContent=$('#fiFile').files[0]?.name||'Keine Datei ausgewählt'); +$('#fiForm').addEventListener('submit',async e=>{e.preventDefault();const f=$('#fiFile').files[0];if(!f)return;const fd=new FormData();fd.append('file',f);try{const x=await api('/api/tools/file-inspect',{method:'POST',body:fd});lastFileSHA256=x.sha256;kvRender('#fiResult',x,{name:'Dateiname',size:'Größe (Bytes)',mime:'Erkannter MIME-Type',extension:'Erweiterung',magic_hex:'Erste Bytes (Hex)',md5:'MD5',sha256:'SHA-256',sha512:'SHA-512'});}catch(err){toast(err.message)}}); +$('#fiCopyHash').addEventListener('click',async()=>{if(!lastFileSHA256)return toast('Zuerst Datei analysieren');await navigator.clipboard.writeText(lastFileSHA256);toast('SHA-256 kopiert')}); + +// -------------------- certificate inspector -------------------- +$('#certFile').addEventListener('change',()=>$('#certPicked').textContent=$('#certFile').files[0]?.name||'Keine Datei ausgewählt'); +$('#certForm').addEventListener('submit',async e=>{e.preventDefault();const f=$('#certFile').files[0];if(!f)return;const fd=new FormData();fd.append('file',f);fd.append('password',$('#certPassword').value);const out=$('#certResult');out.textContent='Analyse läuft…';try{const x=await api('/api/tools/cert-inspect',{method:'POST',body:fd});out.replaceChildren();x.certificates.forEach((c,i)=>{const card=document.createElement('section');card.className='cert-card';const h=document.createElement('h3');h.textContent=`Zertifikat ${i+1}${c.expired?' · ABGELAUFEN':''}`; if(c.expired)h.classList.add('status-bad');card.append(h);const kv=document.createElement('div');kv.className='kv-list';kvRender(kv,{subject:c.subject,issuer:c.issuer,serial:c.serial,dns_names:c.dns_names,ip_addresses:c.ip_addresses,emails:c.emails,not_before:fmtDate(c.not_before),not_after:fmtDate(c.not_after),days_left:c.days_left,is_ca:c.is_ca,signature_algorithm:c.signature_algorithm,public_key_algorithm:c.public_key_algorithm,key_usage:c.key_usage,ext_key_usage:c.ext_key_usage,sha1_thumbprint:c.sha1_thumbprint,sha256_thumbprint:c.sha256_thumbprint},{subject:'Subject',issuer:'Issuer',serial:'Seriennummer',dns_names:'DNS SANs',ip_addresses:'IP SANs',emails:'E-Mail SANs',not_before:'Gültig ab',not_after:'Gültig bis',days_left:'Tage verbleibend',is_ca:'CA-Zertifikat',signature_algorithm:'Signaturalgorithmus',public_key_algorithm:'Public-Key-Algorithmus',key_usage:'Key Usage',ext_key_usage:'Extended Key Usage',sha1_thumbprint:'SHA-1 Thumbprint',sha256_thumbprint:'SHA-256 Thumbprint'});card.append(kv);out.append(card);});}catch(err){out.textContent='';toast(err.message)}}); + +// -------------------- formatter -------------------- +function formatXML(xml, compact=false) { const parser=new DOMParser();const doc=parser.parseFromString(xml,'application/xml');const err=doc.querySelector('parsererror');if(err)throw new Error(err.textContent.split('\n')[0]||'Ungültiges XML');let s=new XMLSerializer().serializeToString(doc);if(compact)return s;s=s.replace(/>\s*<').replace(/(>)(<)(\/?)/g,'$1\n$2$3');let depth=0;return s.split('\n').map(line=>{const t=line.trim();if(/^<\//.test(t))depth=Math.max(0,depth-1);const out=' '.repeat(depth)+t;if(/^<[^!?/][^>]*[^/]?>$/.test(t)&&!/<\/[^>]+>$/.test(t))depth++;return out;}).join('\n'); } +function basicYAML(text, action) { const lines=text.replace(/\r\n/g,'\n').split('\n');for(let i=0;ix.trimEnd()).filter(x=>x.trim()!=='').join('\n');return lines.map(x=>x.replace(/[ \t]+$/,'')).join('\n').trim()+"\n"; } +$('#fmtRun').addEventListener('click',()=>{try{const type=$('#fmtType').value,act=$('#fmtAction').value,input=$('#fmtInput').value;let out='';if(type==='JSON'){const o=JSON.parse(input);out=act==='validate'?'JSON ist syntaktisch gültig.':JSON.stringify(o,null,act==='pretty'?2:0);}else if(type==='XML'){out=act==='validate'?(formatXML(input,true),'XML ist syntaktisch gültig.'):formatXML(input,act==='minify');}else out=basicYAML(input,act);$('#fmtOutput').value=out;$('#fmtStatus').textContent=type==='YAML'?'YAML-Basisprüfung/Normalisierung lokal abgeschlossen.':'Lokale Verarbeitung erfolgreich.';}catch(e){$('#fmtOutput').value='';$('#fmtStatus').textContent=`Fehler: ${e.message}`;}}); +$('#fmtCopy').addEventListener('click',async()=>{await navigator.clipboard.writeText($('#fmtOutput').value);toast('Ergebnis kopiert')}); + +// -------------------- text diff -------------------- +function lineDiff(a,b){const A=a.replace(/\r\n/g,'\n').split('\n'),B=b.replace(/\r\n/g,'\n').split('\n');if(A.length>500||B.length>500)throw new Error('Diff ist auf 500 Zeilen je Seite begrenzt');const dp=Array.from({length:A.length+1},()=>new Uint16Array(B.length+1));for(let i=A.length-1;i>=0;i--)for(let j=B.length-1;j>=0;j--)dp[i][j]=A[i]===B[j]?dp[i+1][j+1]+1:Math.max(dp[i+1][j],dp[i][j+1]);let i=0,j=0,out=[];while(i=dp[i+1][j])){out.push(['+',B[j++]]);}else{out.push(['-',A[i++]]);}}return out;} +$('#diffRun').addEventListener('click',()=>{const out=$('#diffOutput');out.replaceChildren();try{for(const [t,line] of lineDiff($('#diffA').value,$('#diffB').value)){const s=document.createElement('span');s.className=t==='+'?'diff-add':t==='-'?'diff-del':'diff-same';s.textContent=`${t} ${line}\n`;out.append(s);}}catch(e){out.textContent=`Fehler: ${e.message}`;}}); + +// -------------------- AD converters -------------------- +function newUUID(){const b=crypto.getRandomValues(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;const h=[...b].map(x=>x.toString(16).padStart(2,'0'));return `${h.slice(0,4).join('')}-${h.slice(4,6).join('')}-${h.slice(6,8).join('')}-${h.slice(8,10).join('')}-${h.slice(10).join('')}`;} +function guidToAD(g){const raw=g.replace(/[{}-]/g,'').toLowerCase();if(!/^[0-9a-f]{32}$/.test(raw))throw new Error('GUID/Hex muss 16 Bytes enthalten');const b=raw.match(/../g);const ad=[...b.slice(0,4).reverse(),...b.slice(4,6).reverse(),...b.slice(6,8).reverse(),...b.slice(8)];return {hex:ad.join(''),ldap:ad.map(x=>'\\'+x).join('')};} +function adHexToGuid(h){const raw=h.replace(/[^0-9a-f]/gi,'').toLowerCase();if(raw.length!==32)throw new Error('AD-Hex muss 16 Bytes enthalten');const b=raw.match(/../g);const n=[...b.slice(0,4).reverse(),...b.slice(4,6).reverse(),...b.slice(6,8).reverse(),...b.slice(8)];return `${n.slice(0,4).join('')}-${n.slice(4,6).join('')}-${n.slice(6,8).join('')}-${n.slice(8,10).join('')}-${n.slice(10).join('')}`;} +$('#guidNew').addEventListener('click',()=>{$('#guidInput').value=newUUID();$('#guidOutput').textContent=''}); +$('#guidConvert').addEventListener('click',()=>{try{const v=$('#guidInput').value.trim();if(v.includes('-')){const x=guidToAD(v);$('#guidOutput').textContent=`AD objectGUID Hex:\n${x.hex}\n\nLDAP escaped:\n${x.ldap}`;}else{$('#guidOutput').textContent=`GUID aus AD-Bytefolge:\n${adHexToGuid(v)}`;}}catch(e){$('#guidOutput').textContent=`Fehler: ${e.message}`;}}); +function sidStringToHex(s){const p=s.trim().split('-');if(p[0]!=='S'||p.length<4)throw new Error('Ungültige SID');const rev=Number(p[1]),auth=BigInt(p[2]),subs=p.slice(3).map(BigInt);if(subs.length>255)throw new Error('Zu viele Subauthorities');const out=[rev,subs.length];for(let i=5;i>=0;i--)out.push(Number((auth>>BigInt(i*8))&255n));for(const x of subs)for(let i=0;i<4;i++)out.push(Number((x>>BigInt(i*8))&255n));return out.map(x=>x.toString(16).padStart(2,'0')).join('');} +function sidHexToString(h){const raw=h.replace(/[^0-9a-f]/gi,'');if(raw.length<16||raw.length%2)throw new Error('Ungültiges SID-Hex');const b=raw.match(/../g).map(x=>parseInt(x,16));const rev=b[0],cnt=b[1];if(b.length<8+cnt*4)throw new Error('SID-Hex zu kurz');let auth=0n;for(let i=2;i<8;i++)auth=(auth<<8n)|BigInt(b[i]);const subs=[];let o=8;for(let n=0;n{try{const v=$('#sidInput').value.trim();$('#sidOutput').textContent=v.toUpperCase().startsWith('S-')?`Binär/Hex (little endian SubAuthorities):\n${sidStringToHex(v)}`:`SID:\n${sidHexToString(v)}`;}catch(e){$('#sidOutput').textContent=`Fehler: ${e.message}`;}}); +function parseTimeInput(v){v=v.trim();const gen=v.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(?:\.\d+)?Z$/);if(gen)return new Date(Date.UTC(+gen[1],+gen[2]-1,+gen[3],+gen[4],+gen[5],+gen[6]));if(/^\d+$/.test(v)){const n=BigInt(v);if(n>1000000000000000n){const ms=Number(n/10000n-11644473600000n);return new Date(ms);}if(n>100000000000n)return new Date(Number(n));return new Date(Number(n)*1000);}const d=new Date(v);if(Number.isNaN(d.getTime()))throw new Error('Zeitwert nicht erkannt');return d;} +function formatGeneralized(d){const p=n=>String(n).padStart(2,'0');return `${d.getUTCFullYear()}${p(d.getUTCMonth()+1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}.0Z`;} +function convertTime(){try{const d=parseTimeInput($('#timeInput').value);const ms=BigInt(d.getTime());const ft=(ms+11644473600000n)*10000n;$('#timeOutput').textContent=`ISO 8601: ${d.toISOString()}\nLokal: ${d.toLocaleString('de-DE')}\nUnix Sekunden: ${Math.floor(d.getTime()/1000)}\nUnix Millisekunden: ${d.getTime()}\nWindows FILETIME / AD: ${ft}\nLDAP GeneralizedTime: ${formatGeneralized(d)}`;}catch(e){$('#timeOutput').textContent=`Fehler: ${e.message}`;}} +$('#timeNow').addEventListener('click',()=>{$('#timeInput').value=new Date().toISOString();convertTime();});$('#timeConvert').addEventListener('click',convertTime); + +// -------------------- DNS / subnet / connectivity -------------------- +$('#dnsRun').addEventListener('click',async()=>{const out=$('#dnsOutput');out.textContent='Abfrage läuft…';try{const x=await api('/api/tools/dns',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:$('#dnsName').value,type:$('#dnsType').value})});out.textContent=`${x.type} ${x.name} · ${x.duration_ms} ms\n\n${x.results.length?x.results.join('\n'):'Keine Ergebnisse'}`;}catch(e){out.textContent=`Fehler: ${e.message}`;}}); +$('#subnetRun').addEventListener('click',async()=>{try{const x=await api('/api/tools/subnet',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cidr:$('#subnetInput').value})});kvRender('#subnetOutput',x,{input:'Eingabe',network:'Netz',prefix_length:'Präfix',address_bits:'Adressbits',family:'Familie',netmask:'Netzmaske',broadcast:'Broadcast',first_address:'Erste Adresse',last_address:'Letzte Adresse',first_host:'Erster Host',last_host:'Letzter Host',address_count:'Adressen gesamt',usable_hosts:'Nutzbare Hosts'});}catch(e){toast(e.message)}}); +$('#connRun').addEventListener('click',async()=>{try{const x=await api('/api/tools/connectivity',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({host:$('#connHost').value,port:Number($('#connPort').value),timeout_ms:Number($('#connTimeout').value)})});kvRender('#connOutput',x,{host:'Host',port:'Port',resolved_ips:'DNS-Auflösung',latency_ms:'TCP-Latenz ms',reachable:'Erreichbar',remote:'Remote Endpoint',error:'Fehler'});const first=$('#connOutput .kv-row:nth-child(5) .kv-value');if(first)first.classList.add(x.reachable?'status-ok':'status-bad');}catch(e){toast(e.message)}}); + +// -------------------- encoder -------------------- +function bytesToBase64(bytes){let s='';const step=0x8000;for(let i=0;ic.charCodeAt(0));} +function encoderRun(){try{const type=$('#encType').value,mode=$('#encMode').value,input=$('#encInput').value;let out='';if(type==='url')out=mode==='encode'?encodeURIComponent(input):decodeURIComponent(input);else if(type==='base64')out=mode==='encode'?bytesToBase64(new TextEncoder().encode(input)):new TextDecoder().decode(base64ToBytes(input));else if(mode==='encode')out=[...new TextEncoder().encode(input)].map(b=>b.toString(16).padStart(2,'0')).join('');else{const h=input.replace(/\s+/g,'');if(!/^[0-9a-f]*$/i.test(h)||h.length%2)throw new Error('Ungültiges Hex');out=new TextDecoder().decode(Uint8Array.from(h.match(/../g)||[],x=>parseInt(x,16)));}$('#encOutput').value=out;}catch(e){$('#encOutput').value=`Fehler: ${e.message}`;}} +$('#encRun').addEventListener('click',encoderRun);$('#encSwap').addEventListener('click',()=>{const x=$('#encInput').value;$('#encInput').value=$('#encOutput').value;$('#encOutput').value=x;$('#encMode').value=$('#encMode').value==='encode'?'decode':'encode';}); + +// -------------------- PowerShell builder -------------------- +function psBuild(){ + const t=$('#psTemplate').value,id=$('#psIdentity').value.trim(),g=$('#psGroup').value.trim(),f=$('#psFirst').value.trim(),l=$('#psLast').value.trim(),ou=$('#psOU').value.trim(); + let s=''; + switch(t){ + case 'getuser': s=`Get-ADUser -Identity ${psQuote(id)} -Properties *`; break; + case 'unlock': s=`Unlock-ADAccount -Identity ${psQuote(id)}`; break; + case 'addgroup': s=`Add-ADGroupMember -Identity ${psQuote(g)} -Members ${psQuote(id)}`; break; + case 'resetpw': s=`$NewPassword = Read-Host 'Neues Kennwort' -AsSecureString\nSet-ADAccountPassword -Identity ${psQuote(id)} -Reset -NewPassword $NewPassword\nSet-ADUser -Identity ${psQuote(id)} -ChangePasswordAtLogon $true`; break; + case 'newuser': { + const display=`${l}, ${f}`.replace(/^, |, $/,''); + const args=[`-Name ${psQuote(display)}`,`-GivenName ${psQuote(f)}`,`-Surname ${psQuote(l)}`]; + if(id)args.push(`-SamAccountName ${psQuote(id)}`); if(ou)args.push(`-Path ${psQuote(ou)}`); + const bt=String.fromCharCode(96); + s="$InitialPassword = Read-Host 'Initiales Kennwort' -AsSecureString\nNew-ADUser "+bt+"\n "+args.join(" "+bt+"\n ")+" "+bt+"\n -AccountPassword $InitialPassword -Enabled $true -ChangePasswordAtLogon $true"; + break; + } + } + $('#psOutput').textContent=s; return s; +} +$('#psBuild').addEventListener('click',()=>{psBuild();toast('Befehl erzeugt')});$('#psCopy').addEventListener('click',async()=>{await navigator.clipboard.writeText(psBuild());toast('PowerShell kopiert')}); + +// -------------------- CSV viewer -------------------- +function detectDelimiter(text){const first=(text.split(/\r?\n/).find(x=>x.trim())||'');const cand=[';',',','\t'];return cand.map(d=>[d,(first.match(new RegExp(d==='\t'?'\\t':`\\${d}`,'g'))||[]).length]).sort((a,b)=>b[1]-a[1])[0][0];} +function parseCSV(text,delim){const rows=[];let row=[],field='',quoted=false;for(let i=0;i!q||r.some(c=>c.toLocaleLowerCase('de').includes(q)));const table=document.createElement('table');table.className='data-table';const tr=document.createElement('tr');head.forEach(h=>{const th=document.createElement('th');th.textContent=h;tr.append(th)});const thead=document.createElement('thead');thead.append(tr);table.append(thead);const body=document.createElement('tbody');data.slice(0,1000).forEach(r=>{const tr=document.createElement('tr');for(let i=0;i1000?' · Anzeige auf 1000 Zeilen begrenzt':''}`;} +$('#csvFile').addEventListener('change',async()=>{const f=$('#csvFile').files[0];$('#csvPicked').textContent=f?.name||'Keine Datei ausgewählt';if(!f)return;const text=await f.text();const sel=$('#csvDelimiter').value;csvCurrentDelimiter=sel==='auto'?detectDelimiter(text):sel;csvRows=parseCSV(text,csvCurrentDelimiter);renderCSV();});$('#csvFilter').addEventListener('input',renderCSV);$('#csvDelimiter').addEventListener('change',()=>{$('#csvFile').dispatchEvent(new Event('change'))}); +function csvEscape(v,d){v=String(v??'');return /["\r\n]/.test(v)||v.includes(d)?`"${v.replaceAll('"','""')}"`:v;} +$('#csvExport').addEventListener('click',()=>{if(!csvRows.length)return toast('Keine CSV geladen');const q=$('#csvFilter').value.toLocaleLowerCase('de');const rows=[csvRows[0],...csvRows.slice(1).filter(r=>!q||r.some(c=>c.toLocaleLowerCase('de').includes(q)))];downloadBlob('filtered.csv','\uFEFF'+rows.map(r=>r.map(v=>csvEscape(v,csvCurrentDelimiter)).join(csvCurrentDelimiter)).join('\r\n'),'text/csv;charset=utf-8');}); + +// -------------------- regex -------------------- +$('#regexRun').addEventListener('click',()=>{const out=$('#regexOutput');try{const flags=$('#regexFlags').value.replace(/[^dgimsuvy]/g,'');const global=flags.includes('g')?flags:flags+'g';const re=new RegExp($('#regexPattern').value,global);const text=$('#regexText').value;const matches=[...text.matchAll(re)];out.textContent=matches.length?matches.slice(0,200).map((m,i)=>`#${i+1} index=${m.index} ${JSON.stringify(m[0])}${m.length>1?' groups='+JSON.stringify(m.slice(1)):''}`).join('\n'):'Keine Treffer.';}catch(e){out.textContent=`Fehler: ${e.message}`;}}); + +// -------------------- text transformer -------------------- +$('#transformRun').addEventListener('click',()=>{let lines=$('#transformInput').value.replace(/\r\n/g,'\n').split('\n');const a=$('#transformAction').value,x=$('#transformAffix').value;switch(a){case'trim':lines=lines.map(s=>s.trim());break;case'sort':lines.sort((a,b)=>a.localeCompare(b,'de',{numeric:true}));break;case'unique':lines=[...new Set(lines)];break;case'upper':lines=lines.map(s=>s.toLocaleUpperCase('de'));break;case'lower':lines=lines.map(s=>s.toLocaleLowerCase('de'));break;case'prefix':lines=lines.map(s=>x+s);break;case'suffix':lines=lines.map(s=>s+x);break;case'reverse':lines.reverse();break;}$('#transformOutput').value=lines.join('\n');}); + +// -------------------- ZIP archive viewer -------------------- +$('#archiveFile').addEventListener('change',()=>$('#archivePicked').textContent=$('#archiveFile').files[0]?.name||'Keine Datei ausgewählt'); +$('#archiveForm').addEventListener('submit',async e=>{e.preventDefault();const f=$('#archiveFile').files[0];if(!f)return;const fd=new FormData();fd.append('file',f);try{const x=await api('/api/tools/archive',{method:'POST',body:fd});$('#archiveMeta').textContent=`${x.entry_count} Einträge · komprimiert ${fmtBytes(x.compressed_bytes)} · entpackt ${fmtBytes(x.uncompressed_bytes)}`;const wrap=$('#archiveTable');wrap.replaceChildren();const table=document.createElement('table');table.className='data-table';const head=document.createElement('tr');['Name','Größe','Komprimiert','Typ','Geändert'].forEach(x=>{const th=document.createElement('th');th.textContent=x;head.append(th)});const thead=document.createElement('thead');thead.append(head);table.append(thead);const body=document.createElement('tbody');x.entries.forEach(en=>{const tr=document.createElement('tr');[en.name,fmtBytes(en.size),fmtBytes(en.compressed_size),en.directory?'Verzeichnis':`ZIP method ${en.method}`,fmtDate(en.modified)].forEach(v=>{const td=document.createElement('td');td.textContent=v;tr.append(td)});body.append(tr)});table.append(body);wrap.append(table);}catch(err){toast(err.message)}}); + +// -------------------- encoding converter -------------------- +let encodingOriginalName='converted.txt'; +$('#encodingFile').addEventListener('change',async()=>{const f=$('#encodingFile').files[0];$('#encodingPicked').textContent=f?.name||'Keine Datei ausgewählt';if(!f)return;encodingOriginalName=f.name;try{const b=await f.arrayBuffer();$('#encodingText').value=new TextDecoder($('#encodingSource').value).decode(b);}catch(e){toast(`Encoding konnte nicht gelesen werden: ${e.message}`)}}); +$('#encodingSource').addEventListener('change',()=>{$('#encodingFile').dispatchEvent(new Event('change'))}); +$('#encodingDownload').addEventListener('click',()=>{let bytes=new TextEncoder().encode($('#encodingText').value);if($('#encodingTarget').value==='utf8bom'){const out=new Uint8Array(bytes.length+3);out.set([0xef,0xbb,0xbf]);out.set(bytes,3);bytes=out;}downloadBlob(encodingOriginalName.replace(/(\.[^.]*)?$/,'.utf8$1'),bytes,'text/plain;charset=utf-8');}); + +loadStatus(); +loadApps(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..56b6b6c --- /dev/null +++ b/web/index.html @@ -0,0 +1,250 @@ + + + + + + + PAW Toolbox + + + +
+
+
P
+
PAW ToolboxApps · Transfer · Admin-Werkzeuge
+
+ +
+ +
+
+
+
+

Zentrale Arbeitsoberfläche

+

Ein Einstiegspunkt für die PAW

+

Interne Anwendungen öffnen, Werte zwischen Sitzungen übertragen, Kennwörter erzeugen, Dateien austauschen und typische Admin-Aufgaben ohne externe Webseiten erledigen.

+
+
Status wird geladen…
+
+ +
+ + + + +
+ +
+

App Launcher

Interne Anwendungen

+
+
+
+
+ +
+

Tier-übergreifender Transfer

Virtuelle Zwischenablage

+
+
+

Räume

Klick auf einen Raum öffnet ihn. Leeren behält den Raum, Löschen entfernt ihn dauerhaft.

+ +
+
+
+
+
+

Eintrag ablegen

+ +
+ +
+ + +
+
+ + +
+
+

Secrets sind in der Verlaufsliste maskiert und werden standardmäßig persistent gespeichert. Bei „einmalig“ wird der Eintrag nach dem expliziten Abruf dauerhaft entfernt.

+
+ +
+

Verlauf

default
+
+
+
+
+ +
+

Kryptographisch zufällig

Passwortgenerator

+
+
+

Generieren

+
+ + + +
+
+ + +
+ +

Generator-Grundeinstellungen werden per PWGEN_*-Umgebungsvariablen festgelegt.

+
+
+

Ergebnis

+
Noch kein Passwort erzeugt.
+
+
+
+
+ +
+

Einfacher Dateiaustausch

Dateien

+
+
+ + + +
+

Maximale Dateigröße: wird vom Server vorgegeben.

+
+
+
+ +
+
+

Lokale Admin-Helfer

Werkzeuge

Die meisten Werkzeuge verarbeiten Inhalte ausschließlich im Browser. Nur DNS, TCP-Test und serverseitige Datei-/Zertifikats-/ZIP-Analyse verwenden die Toolbox-API.

+ +
+ +
+ + +
+
+

Onboarding Assistant

AD
+
+ + + + +
+
+
sAMAccountName
UPN
E-Mail
DisplayName
+ +

Der Assistant erzeugt Befehle, führt aber keine AD-Änderungen selbst aus.

+
+ +
+

Hash / File Inspector

+
+
+
+ +
+

Certificate Inspector

+
+
+
+ +
+

JSON / XML / YAML Formatter

+
+
+

YAML wird konservativ auf Einrückung und häufige Strukturfehler geprüft; es erfolgt keine semantische Neuordnung.

+
+ +
+

Text Diff

+
+

+          
+ +
+

AD SID / GUID / Timestamp Converter

+
+

GUID / UUID

+

SID

+

Zeit

+
+
+ +
+

DNS Lookup

Die DNS-Abfrage erfolgt aus Sicht des PAW-Toolbox-Servers.


+          
+ +
+

IP / Subnet Calculator

+
+ +
+

Base64 / Hex / URL Encoder

+
+
+
+ +
+

PowerShell Command Builder

+
+

Nur Generator: Die Toolbox führt keine PowerShell-Kommandos aus.

+
+ +
+

CSV Viewer

+
+
+
+ +
+

Connectivity Checker

Der TCP-Test erfolgt aus Sicht des PAW-Toolbox-Servers, nicht aus Sicht des Browsers der Tier-VM.

+
+ +
+

Regex Tester


+          
+ +
+

Text Transformer

+
+ +
+

ZIP Archive Viewer

+
+ +
+

Encoding Converter

Die Konvertierung läuft vollständig im Browser.

+
+
+
+
+
+ +
+ + +