Init
This commit is contained in:
51
.gitea/worklows/registry.yml
Normal file
51
.gitea/worklows/registry.yml
Normal file
@@ -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 }}
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/data/
|
||||
*.pem
|
||||
*.key
|
||||
.DS_Store
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -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"]
|
||||
232
README.md
232
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://<PAW-Toolbox>: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/<zufällige-id>.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.
|
||||
|
||||
8
apps.json
Normal file
8
apps.json
Normal file
@@ -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" }
|
||||
]
|
||||
16
compose.yaml
Normal file
16
compose.yaml
Normal file
@@ -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
|
||||
200
main_test.go
Normal file
200
main_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
519
tools.go
Normal file
519
tools.go
Normal file
@@ -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"})
|
||||
}
|
||||
}
|
||||
159
web/app.css
Normal file
159
web/app.css
Normal file
@@ -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; } }
|
||||
281
web/app.js
Normal file
281
web/app.js
Normal file
@@ -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 = `<div class="empty">Apps konnten nicht geladen werden.</div>`; }
|
||||
}
|
||||
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='<div class="empty">Raumliste konnte nicht geladen werden.</div>';
|
||||
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='<div class="empty">Verlauf konnte nicht geladen werden.</div>'; }
|
||||
}
|
||||
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='<div class="empty">Dateiliste konnte nicht geladen werden.</div>';}}
|
||||
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*</g,'><').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;i<lines.length;i++){if(/^\t+/.test(lines[i])||/^ +\t/.test(lines[i]))throw new Error(`YAML: Tab-Einrückung in Zeile ${i+1}`);const raw=lines[i].trim();if(!raw||raw.startsWith('#'))continue;if(/^[-?:]\s*$/.test(raw))continue;const quotes=(raw.match(/"/g)||[]).length;if(quotes%2)throw new Error(`YAML: ungerade Anzahl doppelter Anführungszeichen in Zeile ${i+1}`);}if(action==='validate')return 'Basisprüfung erfolgreich. Hinweis: Dies ist keine vollständige YAML-Schema-/Semantikprüfung.';if(action==='minify')return lines.map(x=>x.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<A.length||j<B.length){if(i<A.length&&j<B.length&&A[i]===B[j]){out.push([' ',A[i]]);i++;j++;}else if(j<B.length&&(i===A.length||dp[i][j+1]>=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<cnt;n++){let x=0n;for(let i=0;i<4;i++)x|=BigInt(b[o++])<<BigInt(i*8);subs.push(x);}return `S-${rev}-${auth}-${subs.join('-')}`;}
|
||||
$('#sidConvert').addEventListener('click',()=>{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;i<bytes.length;i+=step)s+=String.fromCharCode(...bytes.subarray(i,i+step));return btoa(s);}function base64ToBytes(s){const bin=atob(s.replace(/\s+/g,''));return Uint8Array.from(bin,c=>c.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<text.length;i++){const c=text[i];if(quoted){if(c==='"'&&text[i+1]==='"'){field+='"';i++;}else if(c==='"')quoted=false;else field+=c;}else if(c==='"')quoted=true;else if(c===delim){row.push(field);field='';}else if(c==='\n'){row.push(field.replace(/\r$/,''));rows.push(row);row=[];field='';}else field+=c;}if(field||row.length){row.push(field.replace(/\r$/,''));rows.push(row);}return rows;}
|
||||
function renderCSV(){const q=$('#csvFilter').value.toLocaleLowerCase('de');const wrap=$('#csvTableWrap');wrap.replaceChildren();if(!csvRows.length)return;const head=csvRows[0];const data=csvRows.slice(1).filter(r=>!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;i<head.length;i++){const td=document.createElement('td');td.textContent=r[i]??'';tr.append(td)}body.append(tr)});table.append(body);wrap.append(table);$('#csvMeta').textContent=`${data.length} von ${Math.max(0,csvRows.length-1)} Datenzeilen · ${head.length} Spalten${data.length>1000?' · 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();
|
||||
250
web/index.html
Normal file
250
web/index.html
Normal file
@@ -0,0 +1,250 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>PAW Toolbox</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">P</div>
|
||||
<div><strong>PAW Toolbox</strong><span>Apps · Transfer · Admin-Werkzeuge</span></div>
|
||||
</div>
|
||||
<nav class="nav" aria-label="Hauptnavigation">
|
||||
<button class="nav-btn active" data-view="dashboard">Übersicht</button>
|
||||
<button class="nav-btn" data-view="clipboard">Zwischenablage</button>
|
||||
<button class="nav-btn" data-view="password">Passwort</button>
|
||||
<button class="nav-btn" data-view="files">Dateien</button>
|
||||
<button class="nav-btn" data-view="tools">Werkzeuge</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="shell">
|
||||
<section id="view-dashboard" class="view active">
|
||||
<div class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Zentrale Arbeitsoberfläche</p>
|
||||
<h1>Ein Einstiegspunkt für die PAW</h1>
|
||||
<p class="lead">Interne Anwendungen öffnen, Werte zwischen Sitzungen übertragen, Kennwörter erzeugen, Dateien austauschen und typische Admin-Aufgaben ohne externe Webseiten erledigen.</p>
|
||||
</div>
|
||||
<div class="hero-status"><span class="dot"></span><span id="statusText">Status wird geladen…</span></div>
|
||||
</div>
|
||||
|
||||
<div class="quick-grid four">
|
||||
<button class="quick-card" data-open-view="clipboard"><span class="quick-icon">⌘</span><strong>Zwischenablage</strong><small>Text und einmalige Secrets</small></button>
|
||||
<button class="quick-card" data-open-view="password"><span class="quick-icon">✦</span><strong>Passwortgenerator</strong><small>Direkt in einen Raum ablegen</small></button>
|
||||
<button class="quick-card" data-open-view="files"><span class="quick-icon">⇅</span><strong>Dateiaustausch</strong><small>Upload, Download, Löschen, SHA-256</small></button>
|
||||
<button class="quick-card" data-open-view="tools"><span class="quick-icon">⌁</span><strong>Admin-Werkzeuge</strong><small>16 Offline- und Netzwerkhelfer</small></button>
|
||||
</div>
|
||||
|
||||
<div class="section-head">
|
||||
<div><p class="eyebrow">App Launcher</p><h2>Interne Anwendungen</h2></div>
|
||||
<div class="filters"><input id="appSearch" type="search" placeholder="Suchen…"><select id="appCategory"><option value="">Alle Kategorien</option></select></div>
|
||||
</div>
|
||||
<div id="appsGrid" class="apps-grid"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-clipboard" class="view">
|
||||
<div class="section-head"><div><p class="eyebrow">Tier-übergreifender Transfer</p><h1>Virtuelle Zwischenablage</h1></div></div>
|
||||
<article class="panel room-panel">
|
||||
<div class="panel-head">
|
||||
<div><h2>Räume</h2><p class="hint room-hint">Klick auf einen Raum öffnet ihn. Leeren behält den Raum, Löschen entfernt ihn dauerhaft.</p></div>
|
||||
<button id="clipRoomsRefresh" class="btn small">Aktualisieren</button>
|
||||
</div>
|
||||
<div id="clipRooms" class="room-list"></div>
|
||||
</article>
|
||||
<div class="two-col">
|
||||
<article class="panel">
|
||||
<h2>Eintrag ablegen</h2>
|
||||
<label>Raum<input id="clipRoom" value="default" maxlength="64" autocomplete="off"></label>
|
||||
<div class="actions"><button id="clipOpenRoom" class="btn small" type="button">Raum öffnen / anlegen</button></div>
|
||||
<label>Inhalt<textarea id="clipContent" rows="7" placeholder="Text oder Wert…"></textarea></label>
|
||||
<div class="form-grid">
|
||||
<label>Autor<input id="clipAuthor" placeholder="optional" autocomplete="off"></label>
|
||||
<label>TTL in Minuten<input id="clipTTL" type="number" min="0" max="1440" value="30"></label>
|
||||
</div>
|
||||
<div class="checks">
|
||||
<label class="check"><input id="clipSecret" type="checkbox"> <span>Als Secret maskieren</span></label>
|
||||
<label class="check"><input id="clipOneTime" type="checkbox"> <span>Nach erstem Abruf löschen</span></label>
|
||||
</div>
|
||||
<div class="actions"><button id="clipSubmit" class="btn primary">Ablegen</button><button id="clipPasteLocal" class="btn">Lokale Zwischenablage einfügen</button></div>
|
||||
<p class="hint">Secrets sind in der Verlaufsliste maskiert und werden standardmäßig persistent gespeichert. Bei „einmalig“ wird der Eintrag nach dem expliziten Abruf dauerhaft entfernt.</p>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head"><div><h2>Verlauf</h2><span id="clipRoomBadge" class="badge">default</span></div><div class="actions compact"><button id="clipRefresh" class="btn small">Aktualisieren</button><button id="clipClear" class="btn small danger">Leeren</button></div></div>
|
||||
<div id="clipHistory" class="clip-list"></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="view-password" class="view">
|
||||
<div class="section-head"><div><p class="eyebrow">Kryptographisch zufällig</p><h1>Passwortgenerator</h1></div></div>
|
||||
<div class="two-col">
|
||||
<article class="panel">
|
||||
<h2>Generieren</h2>
|
||||
<div class="form-grid">
|
||||
<label>Anzahl<input id="pwCount" type="number" min="1" max="20" value="1"></label>
|
||||
<label>Zielraum<input id="pwRoom" value="default" maxlength="64"></label>
|
||||
<label>TTL in Minuten<input id="pwTTL" type="number" min="0" max="1440" value="15"></label>
|
||||
</div>
|
||||
<div class="checks">
|
||||
<label class="check"><input id="pwStore" type="checkbox" checked> <span>In virtueller Zwischenablage ablegen</span></label>
|
||||
<label class="check"><input id="pwOneTime" type="checkbox" checked> <span>Nur einmal abrufbar</span></label>
|
||||
</div>
|
||||
<button id="pwGenerate" class="btn primary">Passwort generieren</button>
|
||||
<p class="hint">Generator-Grundeinstellungen werden per <code>PWGEN_*</code>-Umgebungsvariablen festgelegt.</p>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<div class="panel-head"><h2>Ergebnis</h2><button id="pwCopy" class="btn small">Lokal kopieren</button></div>
|
||||
<pre id="pwOutput" class="secret-output">Noch kein Passwort erzeugt.</pre>
|
||||
<div id="pwMeta" class="meta-row"></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="view-files" class="view">
|
||||
<div class="section-head"><div><p class="eyebrow">Einfacher Dateiaustausch</p><h1>Dateien</h1></div><button id="filesRefresh" class="btn">Aktualisieren</button></div>
|
||||
<article class="panel upload-panel">
|
||||
<form id="uploadForm">
|
||||
<label class="file-drop">Datei auswählen<input id="uploadFile" name="file" type="file" required><span id="filePicked">Keine Datei ausgewählt</span></label>
|
||||
<label>Quelle / Bearbeiter<input id="fileUploader" name="uploader" placeholder="z. B. Sandbox, Tier-0, Kürzel"></label>
|
||||
<button class="btn primary" type="submit">Hochladen</button>
|
||||
</form>
|
||||
<p id="uploadStatus" class="hint">Maximale Dateigröße: wird vom Server vorgegeben.</p>
|
||||
</article>
|
||||
<div id="filesList" class="file-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-tools" class="view">
|
||||
<div class="section-head tool-head">
|
||||
<div><p class="eyebrow">Lokale Admin-Helfer</p><h1>Werkzeuge</h1><p class="lead small-lead">Die meisten Werkzeuge verarbeiten Inhalte ausschließlich im Browser. Nur DNS, TCP-Test und serverseitige Datei-/Zertifikats-/ZIP-Analyse verwenden die Toolbox-API.</p></div>
|
||||
<input id="toolSearch" class="tool-search" type="search" placeholder="Werkzeug suchen…">
|
||||
</div>
|
||||
|
||||
<div class="tool-layout">
|
||||
<aside id="toolNav" class="tool-nav" aria-label="Werkzeuge">
|
||||
<button class="tool-nav-btn active" data-tool="onboarding" data-keywords="ad benutzer user onboarding account">Onboarding Assistant</button>
|
||||
<button class="tool-nav-btn" data-tool="fileinspect" data-keywords="hash sha md5 datei file magic mime">Hash / File Inspector</button>
|
||||
<button class="tool-nav-btn" data-tool="cert" data-keywords="zertifikat certificate pem der pfx crt cer tls">Certificate Inspector</button>
|
||||
<button class="tool-nav-btn" data-tool="formatter" data-keywords="json xml yaml formatter config">JSON / XML / YAML</button>
|
||||
<button class="tool-nav-btn" data-tool="diff" data-keywords="text diff vergleich">Text Diff</button>
|
||||
<button class="tool-nav-btn" data-tool="adconv" data-keywords="ad sid guid uuid timestamp filetime ldap zeit">AD Converter</button>
|
||||
<button class="tool-nav-btn" data-tool="dns" data-keywords="dns a aaaa ptr cname mx txt srv ldap kerberos">DNS Lookup</button>
|
||||
<button class="tool-nav-btn" data-tool="subnet" data-keywords="ip subnet cidr ipv4 ipv6 netz">IP / Subnet Calculator</button>
|
||||
<button class="tool-nav-btn" data-tool="encoder" data-keywords="base64 hex url encode decode">Base64 / Hex / URL</button>
|
||||
<button class="tool-nav-btn" data-tool="powershell" data-keywords="powershell command builder ad">PowerShell Builder</button>
|
||||
<button class="tool-nav-btn" data-tool="csv" data-keywords="csv tabelle filter export">CSV Viewer</button>
|
||||
<button class="tool-nav-btn" data-tool="connectivity" data-keywords="tcp port connectivity host verbindung">Connectivity Checker</button>
|
||||
<button class="tool-nav-btn" data-tool="regex" data-keywords="regex regexp tester">Regex Tester</button>
|
||||
<button class="tool-nav-btn" data-tool="transform" data-keywords="text transform sort unique prefix suffix case">Text Transformer</button>
|
||||
<button class="tool-nav-btn" data-tool="archive" data-keywords="zip archive inhalt viewer">ZIP Archive Viewer</button>
|
||||
<button class="tool-nav-btn" data-tool="encoding" data-keywords="encoding utf8 bom windows1252 charset">Encoding Converter</button>
|
||||
</aside>
|
||||
|
||||
<div class="tool-content">
|
||||
<article id="tool-onboarding" class="panel tool-panel active">
|
||||
<div class="panel-head"><div><h2>Onboarding Assistant</h2><span class="badge">AD</span></div><button id="onbCopy" class="btn small">PowerShell kopieren</button></div>
|
||||
<div class="form-grid three">
|
||||
<label>Vorname<input id="onbFirst" placeholder="Max"></label><label>Nachname<input id="onbLast" placeholder="Mustermann"></label><label>DisplayName-Format<select id="onbDisplayMode"><option value="lastfirst">Nachname, Vorname</option><option value="firstlast">Vorname Nachname</option></select></label>
|
||||
<label>UPN-Suffix<input id="onbDomain" placeholder="example.local"></label><label>E-Mail-Domain<input id="onbMailDomain" placeholder="example.de"></label><label>Basis-OU<input id="onbOU" placeholder="OU=Users,DC=example,DC=local"></label>
|
||||
<label>Abteilung<input id="onbDepartment"></label><label>Funktion<input id="onbTitle"></label><label>Standort<input id="onbOffice"></label>
|
||||
<label>Gruppen (kommagetrennt)<input id="onbGroups" placeholder="GG_VPN, GG_Office"></label><label>Zielraum für Kennwort<input id="onbRoom" value="onboarding"></label><label>TTL Minuten<input id="onbTTL" type="number" min="1" max="1440" value="15"></label>
|
||||
</div>
|
||||
<div class="actions top-gap"><button id="onbBuild" class="btn primary">Daten erzeugen</button><button id="onbPassword" class="btn">Kennwort generieren + einmalig ablegen</button></div>
|
||||
<div class="result-grid top-gap"><div><span class="result-label">sAMAccountName</span><code id="onbSam">–</code></div><div><span class="result-label">UPN</span><code id="onbUPN">–</code></div><div><span class="result-label">E-Mail</span><code id="onbMail">–</code></div><div><span class="result-label">DisplayName</span><code id="onbDisplay">–</code></div></div>
|
||||
<label class="top-gap">PowerShell<textarea id="onbPS" rows="11" class="mono" readonly></textarea></label>
|
||||
<p id="onbSecretStatus" class="hint">Der Assistant erzeugt Befehle, führt aber keine AD-Änderungen selbst aus.</p>
|
||||
</article>
|
||||
|
||||
<article id="tool-fileinspect" class="panel tool-panel">
|
||||
<div class="panel-head"><h2>Hash / File Inspector</h2><button id="fiCopyHash" class="btn small">SHA-256 kopieren</button></div>
|
||||
<form id="fiForm" class="inline-form"><label class="file-drop">Datei analysieren<input id="fiFile" type="file" required><span id="fiPicked">Keine Datei ausgewählt</span></label><button class="btn primary" type="submit">Analysieren</button></form>
|
||||
<div id="fiResult" class="kv-list top-gap"></div>
|
||||
</article>
|
||||
|
||||
<article id="tool-cert" class="panel tool-panel">
|
||||
<h2>Certificate Inspector</h2>
|
||||
<form id="certForm" class="form-grid top-gap"><label class="file-drop">PEM / CER / CRT / DER / PFX<input id="certFile" type="file" accept=".pem,.cer,.crt,.der,.pfx,.p12" required><span id="certPicked">Keine Datei ausgewählt</span></label><label>PFX/P12-Kennwort (optional)<input id="certPassword" type="password" autocomplete="off"><span class="hint">Nur für die lokale Serveranalyse verwendet.</span></label><div class="actions"><button class="btn primary" type="submit">Zertifikat prüfen</button></div></form>
|
||||
<div id="certResult" class="top-gap"></div>
|
||||
</article>
|
||||
|
||||
<article id="tool-formatter" class="panel tool-panel">
|
||||
<div class="panel-head"><h2>JSON / XML / YAML Formatter</h2><div class="actions"><button id="fmtCopy" class="btn small">Ergebnis kopieren</button></div></div>
|
||||
<div class="form-grid"><label>Format<select id="fmtType"><option>JSON</option><option>XML</option><option>YAML</option></select></label><label>Aktion<select id="fmtAction"><option value="pretty">Formatieren</option><option value="minify">Minify / kompakt</option><option value="validate">Validieren</option></select></label></div>
|
||||
<div class="split-edit top-gap"><label>Eingabe<textarea id="fmtInput" rows="15" class="mono"></textarea></label><label>Ergebnis<textarea id="fmtOutput" rows="15" class="mono" readonly></textarea></label></div>
|
||||
<div class="actions top-gap"><button id="fmtRun" class="btn primary">Ausführen</button></div><p id="fmtStatus" class="hint">YAML wird konservativ auf Einrückung und häufige Strukturfehler geprüft; es erfolgt keine semantische Neuordnung.</p>
|
||||
</article>
|
||||
|
||||
<article id="tool-diff" class="panel tool-panel">
|
||||
<div class="panel-head"><h2>Text Diff</h2><button id="diffRun" class="btn primary small">Vergleichen</button></div>
|
||||
<div class="split-edit"><label>Links<textarea id="diffA" rows="14" class="mono"></textarea></label><label>Rechts<textarea id="diffB" rows="14" class="mono"></textarea></label></div>
|
||||
<pre id="diffOutput" class="diff-output top-gap"></pre>
|
||||
</article>
|
||||
|
||||
<article id="tool-adconv" class="panel tool-panel">
|
||||
<h2>AD SID / GUID / Timestamp Converter</h2>
|
||||
<div class="subtools top-gap">
|
||||
<section class="subpanel"><h3>GUID / UUID</h3><label>GUID<input id="guidInput" class="mono" placeholder="00112233-4455-6677-8899-aabbccddeeff"></label><div class="actions top-gap"><button id="guidNew" class="btn">Neue UUID</button><button id="guidConvert" class="btn primary">AD-Bytefolge</button></div><pre id="guidOutput" class="tool-output"></pre></section>
|
||||
<section class="subpanel"><h3>SID</h3><label>SID oder Hex<textarea id="sidInput" rows="3" class="mono" placeholder="S-1-5-21-... oder 0105..."></textarea></label><div class="actions top-gap"><button id="sidConvert" class="btn primary">Konvertieren</button></div><pre id="sidOutput" class="tool-output"></pre></section>
|
||||
<section class="subpanel"><h3>Zeit</h3><label>ISO, Unix, Unix-ms oder Windows FILETIME<input id="timeInput" class="mono" placeholder="2026-08-12T18:00:00Z"></label><div class="actions top-gap"><button id="timeNow" class="btn">Jetzt</button><button id="timeConvert" class="btn primary">Umrechnen</button></div><pre id="timeOutput" class="tool-output"></pre></section>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="tool-dns" class="panel tool-panel">
|
||||
<h2>DNS Lookup</h2><p class="hint">Die DNS-Abfrage erfolgt aus Sicht des PAW-Toolbox-Servers.</p><div class="form-grid three top-gap"><label>Name / IP<input id="dnsName" class="mono" placeholder="_ldap._tcp.example.local"></label><label>Record-Typ<select id="dnsType"><option>A</option><option>AAAA</option><option>CNAME</option><option>MX</option><option>TXT</option><option>SRV</option><option>PTR</option></select></label><div class="actions align-end"><button id="dnsRun" class="btn primary">Abfragen</button></div></div><pre id="dnsOutput" class="tool-output top-gap"></pre>
|
||||
</article>
|
||||
|
||||
<article id="tool-subnet" class="panel tool-panel">
|
||||
<h2>IP / Subnet Calculator</h2><div class="form-grid top-gap"><label>IPv4/IPv6 mit CIDR<input id="subnetInput" class="mono" placeholder="10.20.30.40/24"></label><div class="actions align-end"><button id="subnetRun" class="btn primary">Berechnen</button></div></div><div id="subnetOutput" class="kv-list top-gap"></div>
|
||||
</article>
|
||||
|
||||
<article id="tool-encoder" class="panel tool-panel">
|
||||
<div class="panel-head"><h2>Base64 / Hex / URL Encoder</h2><button id="encSwap" class="btn small">Eingabe ⇄ Ergebnis</button></div>
|
||||
<div class="form-grid three"><label>Verfahren<select id="encType"><option value="base64">Base64 (UTF-8)</option><option value="hex">Hex (UTF-8)</option><option value="url">URL Component</option></select></label><label>Richtung<select id="encMode"><option value="encode">Encode</option><option value="decode">Decode</option></select></label><div class="actions align-end"><button id="encRun" class="btn primary">Ausführen</button></div></div>
|
||||
<div class="split-edit top-gap"><label>Eingabe<textarea id="encInput" rows="12" class="mono"></textarea></label><label>Ergebnis<textarea id="encOutput" rows="12" class="mono" readonly></textarea></label></div>
|
||||
</article>
|
||||
|
||||
<article id="tool-powershell" class="panel tool-panel">
|
||||
<div class="panel-head"><h2>PowerShell Command Builder</h2><button id="psCopy" class="btn small">Kopieren</button></div>
|
||||
<div class="form-grid three"><label>Befehl<select id="psTemplate"><option value="getuser">Get-ADUser</option><option value="unlock">Unlock-ADAccount</option><option value="addgroup">Add-ADGroupMember</option><option value="resetpw">Set-ADAccountPassword</option><option value="newuser">New-ADUser</option></select></label><label>Benutzer / Identity<input id="psIdentity"></label><label>Gruppe<input id="psGroup"></label><label>Vorname<input id="psFirst"></label><label>Nachname<input id="psLast"></label><label>OU-Pfad<input id="psOU"></label></div>
|
||||
<div class="actions top-gap"><button id="psBuild" class="btn primary">Befehl erzeugen</button></div><pre id="psOutput" class="tool-output top-gap"></pre><p class="hint">Nur Generator: Die Toolbox führt keine PowerShell-Kommandos aus.</p>
|
||||
</article>
|
||||
|
||||
<article id="tool-csv" class="panel tool-panel">
|
||||
<div class="panel-head"><h2>CSV Viewer</h2><div class="actions"><button id="csvExport" class="btn small">Gefiltert exportieren</button></div></div>
|
||||
<div class="form-grid three"><label class="file-drop">CSV laden<input id="csvFile" type="file" accept=".csv,.txt"><span id="csvPicked">Keine Datei ausgewählt</span></label><label>Trennzeichen<select id="csvDelimiter"><option value="auto">Automatisch</option><option value=";">Semikolon ;</option><option value=",">Komma ,</option><option value="\t">Tab</option></select></label><label>Filter<input id="csvFilter" type="search" placeholder="Zeilen filtern…"></label></div>
|
||||
<div id="csvMeta" class="hint top-gap"></div><div id="csvTableWrap" class="table-wrap top-gap"></div>
|
||||
</article>
|
||||
|
||||
<article id="tool-connectivity" class="panel tool-panel">
|
||||
<h2>Connectivity Checker</h2><p class="hint">Der TCP-Test erfolgt aus Sicht des PAW-Toolbox-Servers, nicht aus Sicht des Browsers der Tier-VM.</p><div class="form-grid three top-gap"><label>Hostname / IP<input id="connHost" class="mono" placeholder="dc01.example.local"></label><label>Port<input id="connPort" type="number" min="1" max="65535" value="636"></label><label>Timeout ms<input id="connTimeout" type="number" min="100" max="10000" value="3000"></label></div><div class="actions top-gap"><button id="connRun" class="btn primary">TCP-Verbindung prüfen</button></div><div id="connOutput" class="kv-list top-gap"></div>
|
||||
</article>
|
||||
|
||||
<article id="tool-regex" class="panel tool-panel">
|
||||
<div class="panel-head"><h2>Regex Tester</h2><button id="regexRun" class="btn primary small">Testen</button></div><div class="form-grid"><label>Regulärer Ausdruck<input id="regexPattern" class="mono" placeholder="^CN=(.*?),"></label><label>Flags<input id="regexFlags" class="mono" value="gim"></label></div><label class="top-gap">Testtext<textarea id="regexText" rows="10" class="mono"></textarea></label><pre id="regexOutput" class="tool-output top-gap"></pre>
|
||||
</article>
|
||||
|
||||
<article id="tool-transform" class="panel tool-panel">
|
||||
<h2>Text Transformer</h2><div class="form-grid three top-gap"><label>Aktion<select id="transformAction"><option value="trim">Zeilen trimmen</option><option value="sort">Zeilen sortieren</option><option value="unique">Duplikate entfernen</option><option value="upper">GROSSSCHRIFT</option><option value="lower">kleinschrift</option><option value="prefix">Prefix hinzufügen</option><option value="suffix">Suffix hinzufügen</option><option value="reverse">Zeilenreihenfolge umkehren</option></select></label><label>Prefix / Suffix<input id="transformAffix"></label><div class="actions align-end"><button id="transformRun" class="btn primary">Transformieren</button></div></div><div class="split-edit top-gap"><label>Eingabe<textarea id="transformInput" rows="13" class="mono"></textarea></label><label>Ergebnis<textarea id="transformOutput" rows="13" class="mono" readonly></textarea></label></div>
|
||||
</article>
|
||||
|
||||
<article id="tool-archive" class="panel tool-panel">
|
||||
<h2>ZIP Archive Viewer</h2><form id="archiveForm" class="inline-form top-gap"><label class="file-drop">ZIP auswählen<input id="archiveFile" type="file" accept=".zip" required><span id="archivePicked">Keine Datei ausgewählt</span></label><button class="btn primary" type="submit">Inhalt anzeigen</button></form><div id="archiveMeta" class="hint top-gap"></div><div id="archiveTable" class="table-wrap top-gap"></div>
|
||||
</article>
|
||||
|
||||
<article id="tool-encoding" class="panel tool-panel">
|
||||
<h2>Encoding Converter</h2><div class="form-grid three top-gap"><label class="file-drop">Textdatei laden<input id="encodingFile" type="file"><span id="encodingPicked">Keine Datei ausgewählt</span></label><label>Quell-Encoding<select id="encodingSource"><option value="utf-8">UTF-8</option><option value="windows-1252">Windows-1252</option><option value="iso-8859-1">ISO-8859-1</option><option value="utf-16le">UTF-16 LE</option><option value="utf-16be">UTF-16 BE</option></select></label><label>Ziel<select id="encodingTarget"><option value="utf8">UTF-8</option><option value="utf8bom">UTF-8 mit BOM</option></select></label></div><label class="top-gap">Vorschau<textarea id="encodingText" rows="13" class="mono"></textarea></label><div class="actions top-gap"><button id="encodingDownload" class="btn primary">Konvertierte Datei herunterladen</button></div><p class="hint">Die Konvertierung läuft vollständig im Browser.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user