mirror of
https://github.com/MrUnknownDE/utools.git
synced 2026-05-30 16:10:06 +02:00
Compare commits
94 Commits
dev
...
f0b2f7c8ff
| Author | SHA1 | Date | |
|---|---|---|---|
| f0b2f7c8ff | |||
| 13954c0fd6 | |||
| 10deecfb35 | |||
| a42f1b87e9 | |||
| 07bc5ffd9f | |||
| b78b19b58b | |||
| 636cd9a1e4 | |||
| 8bcc6270ca | |||
| 828103be06 | |||
| a606d8e649 | |||
| 9fdd7d58a9 | |||
| b226b81775 | |||
| f21b06e6ad | |||
| 6552d198cf | |||
| 564596e06a | |||
| 2d25dfc262 | |||
| 49708b7b58 | |||
| d119ecf4a2 | |||
| b3c7a7bef3 | |||
| aed28b982a | |||
| 080fed1008 | |||
| 33b7d5dffc | |||
| 6a47880288 | |||
| d584e11453 | |||
| cde424e881 | |||
| eec2ed1adb | |||
| 7f3566888d | |||
| a0ea88b2dd | |||
| a7d8654d3c | |||
| ff0fd1098b | |||
| 29fd909340 | |||
| a7d189d89d | |||
| fdc753b32f | |||
| 7a3b159105 | |||
| 652010a92f | |||
| e5902e9747 | |||
| 068a8cd472 | |||
| eb3f43953a | |||
| ac6ec7e535 | |||
| db806fd06c | |||
| f7fad027db | |||
| b74dcbff38 | |||
| 0ce8eec6e5 | |||
| 2ce5916fb3 | |||
| fab48185c8 | |||
| 9597644607 | |||
| 6daa4ef4bc | |||
| 26c22f907b | |||
| e354d8aabd | |||
| 26137c4ed0 | |||
| e3ae926043 | |||
| dce367ce78 | |||
| 9b4e2552dd | |||
| 2f3241e36a | |||
| dff70e2b07 | |||
| 21e9c62441 | |||
| 8962cfadd9 | |||
| bb7fa35496 | |||
| dfdfbbdf68 | |||
| f21da6b888 | |||
| eabd59e945 | |||
| 93132c256d | |||
| 39a6c3dd8b | |||
| b93b91d352 | |||
| 9693238eb0 | |||
| e465887edc | |||
| ac63f5e5bb | |||
| 8ad92755ed | |||
| 6d3d7f4efb | |||
| 9804a68ea5 | |||
| 2730ebb174 | |||
| e4221f2e8f | |||
| 5c9df4e6b1 | |||
| a1753f4e85 | |||
| c9846b0d88 | |||
| b7c98e7f4d | |||
| ea4c74747d | |||
| aad232e6f4 | |||
| c6cd5c59ea | |||
| 9c493cc01e | |||
| 104ebd4f83 | |||
| d444b0f46b | |||
| 7d382bf18e | |||
| 6ed691e465 | |||
| 2e7cc5f2c9 | |||
| 940f5ff8fb | |||
| 44d09e09c1 | |||
| 8c3e72a632 | |||
| 0a9d43da13 | |||
| bc27677967 | |||
| 9e969b5745 | |||
| 5241de94b6 | |||
| 6b931cf99c | |||
| ac5b1abd7b |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git -C /c/Users/unknown/Documents/Github/utools log --oneline)",
|
||||
"Bash(gh api *)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('rule',{}\\).get\\('description',''\\), '\\\\n', d.get\\('most_recent_instance',{}\\).get\\('location',{}\\), '\\\\n', d.get\\('rule',{}\\).get\\('id',''\\)\\)\")",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); a=d['security_advisory']; print\\(a['summary'], '\\\\nPackage:', d['dependency']['package']['name'], '\\\\nVulnerable:', a['vulnerabilities'][0]['vulnerable_version_range'], '\\\\nFixed:', a['vulnerabilities'][0]['first_patched_version']['identifier'] if a['vulnerabilities'][0].get\\('first_patched_version'\\) else 'N/A', '\\\\nSeverity:', a['severity']\\)\" gh api repos/MrUnknownDE/utools/dependabot/alerts/6)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); a=d['security_advisory']; print\\(a['summary'], '\\\\nPackage:', d['dependency']['package']['name'], '\\\\nVulnerable:', a['vulnerabilities'][0]['vulnerable_version_range'], '\\\\nFixed:', a['vulnerabilities'][0]['first_patched_version']['identifier'] if a['vulnerabilities'][0].get\\('first_patched_version'\\) else 'N/A', '\\\\nSeverity:', a['severity']\\)\")",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('most_recent_instance',{}\\), indent=2\\)\\)\")",
|
||||
"Bash(npm ls *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
backend/data/*.mmdb filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -1,97 +1,93 @@
|
||||
name: Build and Push Docker Images
|
||||
name: Docker Build and Push (Docker Hub, Multi-Arch)
|
||||
|
||||
# Trigger: Wann soll der Workflow laufen?
|
||||
on:
|
||||
workflow_dispatch: # Ermöglicht manuelles Starten über die GitHub UI
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
extra_tag:
|
||||
description: "Optionaler Zusatz-Tag (z.B. v1.2.3). Kommt zusätzlich zu :latest und :<sha>."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest # Verwendet den neuesten Ubuntu-Runner von GitHub
|
||||
|
||||
# Berechtigungen für den GITHUB_TOKEN, um nach GHCR pushen zu können
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
REGISTRY: docker.io
|
||||
DOCKERHUB_USER_LC: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
permissions:
|
||||
contents: read # Zum Auschecken des Codes
|
||||
packages: write # Zum Pushen nach GitHub Packages (GHCR)
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
# 1. Code auschecken
|
||||
- name: Checkout repository
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 2. Docker Metadaten extrahieren (Tags, Labels)
|
||||
# Wir definieren hier die Namen für beide Images
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository_owner }}/utools-backend
|
||||
ghcr.io/${{ github.repository_owner }}/utools-frontend
|
||||
# Tags generieren:
|
||||
# - Typ 'sha' -> Kurzer Commit-Hash (z.B. sha-a1b2c3d)
|
||||
# - Typ 'ref' für Events 'branch' -> Branch-Name (z.B. 'main' wird zu 'latest')
|
||||
# - Typ 'ref' für Events 'tag' -> Git-Tag-Name (z.B. v1.0.0)
|
||||
tags: |
|
||||
type=sha,prefix=sha-
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=ref,event=pr # Nur für PR-Events
|
||||
fetch-depth: 0
|
||||
lfs: true
|
||||
|
||||
- name: Get short SHA
|
||||
id: vars
|
||||
run: echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# 3. QEMU für Multi-Plattform Builds (optional, aber gute Praxis)
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# 4. Docker Buildx einrichten (verbesserter Builder)
|
||||
- name: Set up Docker Buildx
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 5. Login bei GitHub Container Registry (GHCR)
|
||||
# Verwendet den automatisch generierten GITHUB_TOKEN
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
# -------- BACKEND --------
|
||||
- name: Build & Push backend (multi-arch)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }} # Benutzer oder Organisation, dem das Repo gehört
|
||||
password: ${{ secrets.GHCR_PUSH_TOKEN }}
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.DOCKERHUB_USER_LC }}/utools-backend:latest
|
||||
${{ env.REGISTRY }}/${{ env.DOCKERHUB_USER_LC }}/utools-backend:${{ steps.vars.outputs.sha }}
|
||||
build-args: |
|
||||
GIT_COMMIT_SHA=${{ steps.vars.outputs.sha }}
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||
cache-from: type=gha,scope=backend
|
||||
cache-to: type=gha,mode=max,scope=backend
|
||||
|
||||
# 6. Backend Image bauen und pushen
|
||||
- name: Build and push Backend image
|
||||
id: build-backend
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend # Pfad zum Backend-Dockerfile
|
||||
# Nur pushen, wenn es ein Push zum main-Branch oder ein Git-Tag ist
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'create' && startsWith(github.ref, 'refs/tags/') }}
|
||||
# Tags und Labels aus dem Metadaten-Schritt verwenden (gefiltert für Backend)
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Filter für das spezifische Backend-Image (Index 0 in der 'images'-Liste oben)
|
||||
# Wichtig: Passe den Index an, falls du die Reihenfolge änderst!
|
||||
image-name-index: 0 # Index des Backend-Images in der 'images'-Liste
|
||||
cache-from: type=gha # GitHub Actions Cache verwenden (Lesen)
|
||||
cache-to: type=gha,mode=max # GitHub Actions Cache verwenden (Schreiben)
|
||||
|
||||
# 7. Frontend Image bauen und pushen
|
||||
- name: Build and push Frontend image
|
||||
id: build-frontend
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend # Pfad zum Frontend-Dockerfile
|
||||
# Nur pushen, wenn es ein Push zum main-Branch oder ein Git-Tag ist
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'create' && startsWith(github.ref, 'refs/tags/') }}
|
||||
# Tags und Labels aus dem Metadaten-Schritt verwenden (gefiltert für Frontend)
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Filter für das spezifische Frontend-Image (Index 1 in der 'images'-Liste oben)
|
||||
image-name-index: 1 # Index des Frontend-Images in der 'images'-Liste
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# 8. (Optional) Output der Image-Namen und Tags
|
||||
- name: Print image names and tags
|
||||
if: always() # Auch ausführen, wenn vorherige Schritte fehlschlagen (zum Debuggen)
|
||||
- name: Tag backend with extra_tag (manifest retag)
|
||||
if: ${{ github.event.inputs.extra_tag && github.event.inputs.extra_tag != '' }}
|
||||
run: |
|
||||
echo "Backend Image Tags: ${{ steps.meta.outputs.tags }}"
|
||||
echo "Frontend Image Tags: ${{ steps.meta.outputs.tags }}"
|
||||
echo "Backend Image Digest: ${{ steps.build-backend.outputs.digest }}"
|
||||
echo "Frontend Image Digest: ${{ steps.build-frontend.outputs.digest }}"
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.REGISTRY }}/${{ env.DOCKERHUB_USER_LC }}/utools-backend:${{ github.event.inputs.extra_tag }} \
|
||||
${{ env.REGISTRY }}/${{ env.DOCKERHUB_USER_LC }}/utools-backend:${{ steps.vars.outputs.sha }}
|
||||
|
||||
# -------- FRONTEND --------
|
||||
- name: Build & Push frontend (multi-arch)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.DOCKERHUB_USER_LC }}/utools-frontend:latest
|
||||
${{ env.REGISTRY }}/${{ env.DOCKERHUB_USER_LC }}/utools-frontend:${{ steps.vars.outputs.sha }}
|
||||
build-args: |
|
||||
GIT_COMMIT_SHA=${{ steps.vars.outputs.sha }}
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||
cache-from: type=gha,scope=frontend
|
||||
cache-to: type=gha,mode=max,scope=frontend
|
||||
|
||||
- name: Tag frontend with extra_tag (manifest retag)
|
||||
if: ${{ github.event.inputs.extra_tag && github.event.inputs.extra_tag != '' }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.REGISTRY }}/${{ env.DOCKERHUB_USER_LC }}/utools-frontend:${{ github.event.inputs.extra_tag }} \
|
||||
${{ env.REGISTRY }}/${{ env.DOCKERHUB_USER_LC }}/utools-frontend:${{ steps.vars.outputs.sha }}
|
||||
|
||||
@@ -1,93 +1,60 @@
|
||||
name: Update MaxMind GeoLite2 DBs
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Ermöglicht manuelles Starten
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Läuft jeden Dienstag um 05:00 UTC (anpassbar)
|
||||
- cron: '0 5 * * 2'
|
||||
- cron: '0 0 1 * *'
|
||||
|
||||
jobs:
|
||||
update-db:
|
||||
runs-on: ubuntu-latest
|
||||
# Berechtigung, um Änderungen zurück ins Repo zu pushen
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Download geoipupdate tool
|
||||
- name: Download latest geoipupdate
|
||||
run: |
|
||||
# Lade eine spezifische Version oder die neueste herunter
|
||||
GEOIPUPDATE_VERSION="4.11.1" # Beispielversion, prüfe auf neuere Releases
|
||||
wget "https://github.com/maxmind/geoipupdate/releases/download/v${GEOIPUPDATE_VERSION}/geoipupdate_${GEOIPUPDATE_VERSION}_linux_amd64.tar.gz"
|
||||
tar -zxvf "geoipupdate_${GEOIPUPDATE_VERSION}_linux_amd64.tar.gz"
|
||||
# Verschiebe das Binary in einen bekannten Pfad und mache es ausführbar
|
||||
GEOIPUPDATE_VERSION=$(curl -fsSL https://api.github.com/repos/maxmind/geoipupdate/releases/latest | jq -r '.tag_name | ltrimstr("v")')
|
||||
echo "Installing geoipupdate v${GEOIPUPDATE_VERSION}"
|
||||
wget -q "https://github.com/maxmind/geoipupdate/releases/download/v${GEOIPUPDATE_VERSION}/geoipupdate_${GEOIPUPDATE_VERSION}_linux_amd64.tar.gz"
|
||||
tar -xzf "geoipupdate_${GEOIPUPDATE_VERSION}_linux_amd64.tar.gz"
|
||||
sudo mv "geoipupdate_${GEOIPUPDATE_VERSION}_linux_amd64/geoipupdate" /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/geoipupdate
|
||||
# Überprüfe die Version
|
||||
geoipupdate -V
|
||||
|
||||
- name: Create GeoIP.conf
|
||||
# Erstellt die Konfigurationsdatei für geoipupdate mit den Secrets
|
||||
# Wichtig: Secrets nicht direkt im Log ausgeben!
|
||||
run: |
|
||||
echo "Creating GeoIP.conf..."
|
||||
cat << EOF > GeoIP.conf
|
||||
# GeoIP.conf file for geoipupdate
|
||||
# Replace with your actual AccountID and LicenseKey from GitHub Secrets
|
||||
AccountID ${{ secrets.MAXMIND_ACCOUNT_ID }}
|
||||
LicenseKey ${{ secrets.MAXMIND_LICENSE_KEY }}
|
||||
|
||||
# Specify the editions to download
|
||||
EditionIDs GeoLite2-ASN GeoLite2-City
|
||||
EOF
|
||||
echo "GeoIP.conf created."
|
||||
# Umgebungsvariablen für Sicherheit (werden nicht geloggt)
|
||||
env:
|
||||
MAXMIND_ACCOUNT_ID: ${{ secrets.MAXMIND_ACCOUNT_ID }}
|
||||
MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
|
||||
|
||||
- name: Run geoipupdate
|
||||
run: |
|
||||
echo "Running geoipupdate..."
|
||||
# -f gibt die Konfigurationsdatei an
|
||||
# -d gibt das Zielverzeichnis an (relativ zum Repo-Root)
|
||||
# -v für ausführliche Ausgabe (hilft beim Debuggen)
|
||||
geoipupdate -f GeoIP.conf -d ./backend/data -v
|
||||
echo "geoipupdate finished."
|
||||
run: geoipupdate -f GeoIP.conf -d ./backend/data -v
|
||||
|
||||
- name: Check for changes
|
||||
id: check_changes
|
||||
- name: Configure Git and LFS
|
||||
run: |
|
||||
# Prüfe, ob sich die .mmdb Dateien geändert haben
|
||||
if git status --porcelain | grep -q 'backend/data/.*\.mmdb'; then
|
||||
echo "Changes detected in MaxMind databases."
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "No changes detected in MaxMind databases."
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit and push changes
|
||||
# Nur ausführen, wenn Schritt 'check_changes' Änderungen gemeldet hat
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
echo "Committing and pushing changes..."
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
git add ./backend/data/*.mmdb
|
||||
# Erstelle Commit-Nachricht mit Datum
|
||||
COMMIT_DATE=$(date -u +"%Y-%m-%d")
|
||||
git commit -m "Update MaxMind GeoLite2 databases (${COMMIT_DATE})"
|
||||
# Pushe zum aktuellen Branch (z.B. main)
|
||||
git push
|
||||
echo "Changes pushed."
|
||||
# Umgebungsvariable für den Token (wird automatisch von GitHub bereitgestellt)
|
||||
git lfs install
|
||||
|
||||
- name: Commit and push updated databases
|
||||
run: |
|
||||
git lfs track "backend/data/*.mmdb"
|
||||
git add .gitattributes ./backend/data/*.mmdb
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes detected in MaxMind databases."
|
||||
else
|
||||
git commit -m "Update MaxMind GeoLite2 databases (LFS) ($(date -u +%Y-%m-%d))"
|
||||
git push
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: No changes to commit
|
||||
if: steps.check_changes.outputs.changed == 'false'
|
||||
run: echo "Skipping commit as no database files were updated."
|
||||
@@ -0,0 +1,128 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**uTools** is a containerized full-stack web app for IP information and network diagnostics (geolocation, ASN, reverse DNS, ping, traceroute, port scan, WHOIS, DNS, subnet calculator, MAC lookup). Live at https://utools.mrunk.de.
|
||||
|
||||
## Commands
|
||||
|
||||
### Local Development & Deployment
|
||||
|
||||
```bash
|
||||
# Build and start containers locally
|
||||
./build.sh
|
||||
|
||||
# Or manually:
|
||||
docker compose down
|
||||
export GIT_COMMIT_SHA=$(git rev-parse --short HEAD)
|
||||
export SENTRY_DSN="<your-dsn>"
|
||||
docker compose -f compose.build.yml build
|
||||
docker compose -f compose.yml up -d
|
||||
|
||||
# Start only (using pre-built images from Docker Hub)
|
||||
docker compose up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
```
|
||||
|
||||
### Backend (local, without Docker)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp example.env .env # configure env vars
|
||||
npm install
|
||||
npm start # or: node server.js
|
||||
```
|
||||
|
||||
No lint or test scripts are configured.
|
||||
|
||||
### Git LFS
|
||||
|
||||
MaxMind databases are stored in Git LFS. After cloning, run:
|
||||
```bash
|
||||
git lfs pull
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
utools/
|
||||
├── backend/ # Node.js Express API (port 3000)
|
||||
│ ├── server.js # Entry point: Express setup, Sentry, middleware, route mounting
|
||||
│ ├── maxmind.js # Singleton MaxMind reader initialization (GeoLite2-City + ASN)
|
||||
│ ├── utils.js # IP/domain/MAC validation helpers
|
||||
│ └── routes/ # One file per API endpoint
|
||||
├── frontend/ # Nginx static server (port 8080)
|
||||
│ ├── app/ # Vanilla HTML/JS/CSS (no build step)
|
||||
│ │ ├── index.html / script.js # Main IP info dashboard
|
||||
│ │ └── *.html # Tool-specific pages (dns, whois, mac, subnet, asn)
|
||||
│ └── nginx.conf # Clean URL rewrites + /api/* reverse proxy to backend:3000
|
||||
├── compose.yml # Production: pulls from Docker Hub
|
||||
├── compose.build.yml # Build: builds images locally
|
||||
└── build.sh # Local build + deploy script
|
||||
```
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
Browser → Nginx (port 8080)
|
||||
├── static files → frontend/app/
|
||||
└── /api/* → Express backend (port 3000)
|
||||
├── MaxMind .mmdb files (GeoLite2 from Git LFS)
|
||||
├── Sentry (error tracking)
|
||||
└── System commands (ping, traceroute via exec)
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Response type | Notes |
|
||||
|---|---|---|
|
||||
| `GET /api/ipinfo/:ip` | JSON | Geo + ASN for an IP |
|
||||
| `GET /api/lookup/:query` | JSON | Resolve domain → IP → geo |
|
||||
| `GET /api/dns-lookup` | JSON | DNS records |
|
||||
| `GET /api/whois-lookup` | JSON | WHOIS data |
|
||||
| `GET /api/ping` | JSON | ICMP ping |
|
||||
| `GET /api/traceroute` | **SSE** | Streaming hop-by-hop output |
|
||||
| `GET /api/port-scan` | **SSE** | Streaming port scan results |
|
||||
| `GET /api/asn-lookup` | JSON | ASN details (cached to filesystem) |
|
||||
| `GET /api/mac-lookup` | JSON | MAC OUI vendor lookup |
|
||||
| `GET /api/version` | JSON | Git commit SHA |
|
||||
|
||||
Streaming endpoints use Server-Sent Events (EventSource). Nginx is configured with `proxy_buffering off` for these.
|
||||
|
||||
### Key Implementation Details
|
||||
|
||||
- **Proxy trust:** `app.set('trust proxy', 2)` — backend sits behind Nginx + any upstream proxy.
|
||||
- **MaxMind readers** are initialized once at startup (`maxmind.js`) and reused across requests.
|
||||
- **ASN cache** is persisted to `/app/asn-cache` (Docker volume) to reduce external calls.
|
||||
- **Rate limiting** is configured via env vars (`RATE_LIMIT_MAX`, `RATE_LIMIT_WINDOW_MS`).
|
||||
- **Private IP detection** (RFC1918, loopback, link-local) is handled in `utils.js` before any lookup.
|
||||
- **Sentry** is initialized before Express and wraps request/error handlers.
|
||||
- The backend Dockerfile installs OS packages for `ping` and `traceroute` (`iputils-ping`, `traceroute`).
|
||||
|
||||
### Environment Variables (backend)
|
||||
|
||||
See `backend/example.env`. Key variables:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `GEOIP_CITY_DB` | `./data/GeoLite2-City.mmdb` | Path to MaxMind City DB |
|
||||
| `GEOIP_ASN_DB` | `./data/GeoLite2-ASN.mmdb` | Path to MaxMind ASN DB |
|
||||
| `PORT` | `3000` | Express listen port |
|
||||
| `LOG_LEVEL` | `debug` | Pino log level |
|
||||
| `PING_COUNT` | `4` | Packets per ping |
|
||||
| `RATE_LIMIT_MAX` | `200` | Max requests per window |
|
||||
| `RATE_LIMIT_WINDOW_MS` | `300000` | Rate limit window (5 min) |
|
||||
| `SENTRY_DSN` | — | Sentry ingest URL |
|
||||
| `ASN_CACHE_DIR` | — | Directory for ASN response cache |
|
||||
|
||||
### CI/CD
|
||||
|
||||
- **`docker-build-push.yml`**: Triggered on push to `main`. Builds multi-arch images (`linux/amd64`, `linux/arm64`) and pushes to Docker Hub as `mrunknownde/utools-backend` and `mrunknownde/utools-frontend` with `:latest` and `:<short-sha>` tags. Requires LFS checkout for MaxMind databases.
|
||||
- **`maxmind-update.yml`**: Runs on the 1st of each month. Downloads updated GeoLite2 databases via `geoipupdate` and commits them back to Git LFS.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Code of Conduct - uTools
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to make participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behaviour that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologising to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behaviour include:
|
||||
|
||||
* The use of sexualised language or imagery, and sexual attention or advances
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing our standards of
|
||||
acceptable behaviour and will take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behaviour.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, or to ban
|
||||
temporarily or permanently any contributor for other behaviours that they deem
|
||||
inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behaviour may be
|
||||
reported to the community leaders responsible for enforcement at .
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org/), version
|
||||
[1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct/code_of_conduct.md) and
|
||||
[2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct/code_of_conduct.md),
|
||||
and was generated by [contributing.md](https://contributing.md/generator).
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
<!-- omit in toc -->
|
||||
# Contributing to uTools
|
||||
|
||||
First off, thanks for taking the time to contribute! ❤️
|
||||
|
||||
All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
|
||||
|
||||
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
|
||||
> - Star the project
|
||||
> - Tweet about it
|
||||
> - Refer this project in your project's readme
|
||||
> - Mention the project at local meetups and tell your friends/colleagues
|
||||
|
||||
<!-- omit in toc -->
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [I Have a Question](#i-have-a-question)
|
||||
- [I Want To Contribute](#i-want-to-contribute)
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Suggesting Enhancements](#suggesting-enhancements)
|
||||
- [Your First Code Contribution](#your-first-code-contribution)
|
||||
- [Improving The Documentation](#improving-the-documentation)
|
||||
- [Styleguides](#styleguides)
|
||||
- [Commit Messages](#commit-messages)
|
||||
- [Join The Project Team](#join-the-project-team)
|
||||
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project and everyone participating in it is governed by the
|
||||
[uTools Code of Conduct](https://github.com/MrUnknownDE/utools/blob/main/CODE_OF_CONDUCT.md).
|
||||
By participating, you are expected to uphold this code. Please report unacceptable behavior
|
||||
to .
|
||||
|
||||
|
||||
## I Have a Question
|
||||
|
||||
> If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/MrUnknownDE/utools).
|
||||
|
||||
Before you ask a question, it is best to search for existing [Issues](https://github.com/MrUnknownDE/utools/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first.
|
||||
|
||||
If you then still feel the need to ask a question and need clarification, we recommend the following:
|
||||
|
||||
- Open an [Issue](https://github.com/MrUnknownDE/utools/issues/new).
|
||||
- Provide as much context as you can about what you're running into.
|
||||
- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant.
|
||||
|
||||
We will then take care of the issue as soon as possible.
|
||||
|
||||
<!--
|
||||
You might want to create a separate issue tag for questions and include it in this description. People should then tag their issues accordingly.
|
||||
|
||||
Depending on how large the project is, you may want to outsource the questioning, e.g. to Stack Overflow or Gitter. You may add additional contact and information possibilities:
|
||||
- IRC
|
||||
- Slack
|
||||
- Gitter
|
||||
- Stack Overflow tag
|
||||
- Blog
|
||||
- FAQ
|
||||
- Roadmap
|
||||
- E-Mail List
|
||||
- Forum
|
||||
-->
|
||||
|
||||
## I Want To Contribute
|
||||
|
||||
> ### Legal Notice <!-- omit in toc -->
|
||||
> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project licence.
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
<!-- omit in toc -->
|
||||
#### Before Submitting a Bug Report
|
||||
|
||||
A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.
|
||||
|
||||
- Make sure that you are using the latest version.
|
||||
- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/MrUnknownDE/utools). If you are looking for support, you might want to check [this section](#i-have-a-question)).
|
||||
- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/MrUnknownDE/utools/issues?q=label%3Abug).
|
||||
- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue.
|
||||
- Collect information about the bug:
|
||||
- Stack trace (Traceback)
|
||||
- OS, Platform and Version (Windows, Linux, macOS, x86, ARM)
|
||||
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant.
|
||||
- Possibly your input and the output
|
||||
- Can you reliably reproduce the issue? And can you also reproduce it with older versions?
|
||||
|
||||
<!-- omit in toc -->
|
||||
#### How Do I Submit a Good Bug Report?
|
||||
|
||||
> You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to .
|
||||
<!-- You may add a PGP key to allow the messages to be sent encrypted as well. -->
|
||||
|
||||
We use GitHub issues to track bugs and errors. If you run into an issue with the project:
|
||||
|
||||
- Open an [Issue](https://github.com/MrUnknownDE/utools/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.)
|
||||
- Explain the behavior you would expect and the actual behavior.
|
||||
- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case.
|
||||
- Provide the information you collected in the previous section.
|
||||
|
||||
Once it's filed:
|
||||
|
||||
- The project team will label the issue accordingly.
|
||||
- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
|
||||
- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution).
|
||||
|
||||
<!-- You might want to create an issue template for bugs and errors that can be used as a guide and that defines the structure of the information to be included. If you do so, reference it here in the description. -->
|
||||
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
This section guides you through submitting an enhancement suggestion for uTools, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
|
||||
|
||||
<!-- omit in toc -->
|
||||
#### Before Submitting an Enhancement
|
||||
|
||||
- Make sure that you are using the latest version.
|
||||
- Read the [documentation](https://github.com/MrUnknownDE/utools) carefully and find out if the functionality is already covered, maybe by an individual configuration.
|
||||
- Perform a [search](https://github.com/MrUnknownDE/utools/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
|
||||
- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
|
||||
|
||||
<!-- omit in toc -->
|
||||
#### How Do I Submit a Good Enhancement Suggestion?
|
||||
|
||||
Enhancement suggestions are tracked as [GitHub issues](https://github.com/MrUnknownDE/utools/issues).
|
||||
|
||||
- Use a **clear and descriptive title** for the issue to identify the suggestion.
|
||||
- Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
|
||||
- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
|
||||
- You may want to **include screenshots or screen recordings** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [LICEcap](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and the built-in [screen recorder in GNOME](https://help.gnome.org/users/gnome-help/stable/screen-shot-record.html.en) or [SimpleScreenRecorder](https://github.com/MaartenBaert/ssr) on Linux. <!-- this should only be included if the project has a GUI -->
|
||||
- **Explain why this enhancement would be useful** to most uTools users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
|
||||
|
||||
<!-- You might want to create an issue template for enhancement suggestions that can be used as a guide and that defines the structure of the information to be included. If you do so, reference it here in the description. -->
|
||||
|
||||
### Your First Code Contribution
|
||||
<!-- TODO
|
||||
include Setup of env, IDE and typical getting started instructions?
|
||||
|
||||
-->
|
||||
|
||||
### Improving The Documentation
|
||||
<!-- TODO
|
||||
Updating, improving and correcting the documentation
|
||||
|
||||
-->
|
||||
|
||||
## Styleguides
|
||||
### Commit Messages
|
||||
<!-- TODO
|
||||
|
||||
-->
|
||||
|
||||
## Join The Project Team
|
||||
<!-- TODO -->
|
||||
|
||||
<!-- omit in toc -->
|
||||
## Attribution
|
||||
This guide is based on the [contributing.md](https://contributing.md/generator)!
|
||||
@@ -5,10 +5,7 @@
|
||||
|
||||
A modern web application that displays detailed information about a client's IP address, including geolocation, ASN, rDNS, and provides network diagnostic tools like Ping, Traceroute, DNS Lookup, Subnet Calculation, and WHOIS Lookup. It also allows looking up information for any public IP address.
|
||||
|
||||
<!-- Optional: Füge hier einen Screenshot hinzu -->
|
||||
<!--  -->
|
||||
|
||||
### Livedemo: https://utools.johanneskr.de
|
||||
### Preview: https://utools.mrunk.de
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
@@ -16,150 +13,84 @@ A modern web application that displays detailed information about a client's IP
|
||||
* **Geolocation:** Shows Country, Region, City, Postal Code, Coordinates, and Timezone based on the IP.
|
||||
* **ASN Information:** Displays the Autonomous System Number (ASN) and organization name.
|
||||
* **Reverse DNS (rDNS):** Performs a reverse DNS lookup for the IP address.
|
||||
* **Interactive Map:** Visualizes the geolocation on an OpenStreetMap.
|
||||
* **Interactive Dark Mode Map:** Visualizes the geolocation on a dark-themed OpenStreetMap/CartoDB map.
|
||||
* **Glassmorphism UI:** Features a premium, modern transparent design with animated gradients.
|
||||
* **IP Lookup:** Allows users to enter any public IP address to retrieve its Geo, ASN, and rDNS information.
|
||||
* **Traceroute:** Initiates a server-side traceroute (via SSE stream) to the client's IP (on click) or a looked-up IP.
|
||||
* **Traceroute:** Initiates a server-side traceroute (via SSE stream) to the client's IP or a looked-up IP.
|
||||
* **Ping:** Performs a server-side ping test to a looked-up IP.
|
||||
* **DNS Lookup:** Performs various DNS record lookups (A, AAAA, MX, NS, TXT, SOA) for a given domain.
|
||||
* **Subnet Calculator:** Calculates network details (Network Address, Broadcast Address, Usable Hosts, etc.) for a given IP and CIDR or Subnet Mask.
|
||||
* **Subnet Calculator:** Calculates network details (address ranges, usable hosts) for IPv4 subnets.
|
||||
* **WHOIS Lookup:** Retrieves WHOIS information for a given domain or IP address.
|
||||
* **Dockerized:** Both frontend and backend are containerized for easy deployment.
|
||||
* **Modern UI:** Built with Tailwind CSS for a clean and responsive interface.
|
||||
* **MAC Address Lookup:** Identifies the vendor/manufacturer of a network interface using OUI data.
|
||||
* **Port Scan:** Scans common ports of a target IP (via SSE stream).
|
||||
* **Dockerized:** both frontend and backend are containerized for easy deployment.
|
||||
|
||||
## 📚 API Usage
|
||||
|
||||
The backend exposes several RESTful endpoints that return JSON data.
|
||||
|
||||
| Endpoint | Method | Params | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `/api/ipinfo` | `GET` | None | Returns info for the requestor's IP. |
|
||||
| `/api/lookup` | `GET` | `targetIp` | Returns Geo/ASN info for a specific IP. |
|
||||
| `/api/dns-lookup` | `GET` | `domain`, `type` | Resolves DNS records (A, AAAA, MX, etc.). |
|
||||
| `/api/whois-lookup` | `GET` | `query` | Performs a WHOIS lookup for a domain or IP. |
|
||||
| `/api/mac-lookup` | `GET` | `mac` | Returns the vendor for a MAC address. |
|
||||
| `/api/ping` | `GET` | `targetIp` | Pings an IP address (returns 4 packets). |
|
||||
| `/api/traceroute` | `GET` | `targetIp` | Streams traceroute hops via Server-Sent Events (SSE). |
|
||||
| `/api/port-scan` | `GET` | `targetIp` | Streams port scan results via Server-Sent Events (SSE). |
|
||||
|
||||
### Examples
|
||||
|
||||
**Lookup an IP:**
|
||||
```bash
|
||||
curl "http://localhost:3000/api/lookup?targetIp=8.8.8.8"
|
||||
```
|
||||
|
||||
**DNS Lookup:**
|
||||
```bash
|
||||
curl "http://localhost:3000/api/dns-lookup?domain=google.com&type=A"
|
||||
```
|
||||
|
||||
**MAC Vendor Lookup:**
|
||||
```bash
|
||||
curl "http://localhost:3000/api/mac-lookup?mac=00:50:56:C0:00:08"
|
||||
```
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
* **Backend:**
|
||||
* Node.js
|
||||
* Express.js
|
||||
* MaxMind GeoLite2 Databases (for GeoIP and ASN)
|
||||
* Pino (for logging)
|
||||
* `whois-json` (for WHOIS lookups)
|
||||
* `net`, `dns`, `child_process` (Node.js built-ins for Ping, Traceroute, rDNS, DNS Lookup)
|
||||
* `@sentry/node` (optional error tracking)
|
||||
* **Frontend:**
|
||||
* Vanilla JavaScript (ES6+)
|
||||
* Tailwind CSS (via Play CDN for simplicity, can be built)
|
||||
* Leaflet.js (for OpenStreetMap)
|
||||
* Nginx (for serving static files and as a reverse proxy)
|
||||
* **Deployment:**
|
||||
* Docker & Docker Compose
|
||||
* GitHub Actions (for CI/CD - building and pushing images to GHCR)
|
||||
* **Backend:** Node.js, Express.js, MaxMind GeoLite2, `oui`, `whois-json`, `@sentry/node`.
|
||||
* **Frontend:** Vanilla JS, Tailwind CSS, Leaflet.js.
|
||||
* **Deployment:** Docker, GitHub Actions.
|
||||
|
||||
## 🏁 Getting Started
|
||||
|
||||
You can run this application easily using Docker and Docker Compose.
|
||||
### Using Pre-built Images (Recommended)
|
||||
|
||||
### Prerequisites
|
||||
1. **Create `compose.yml`:**
|
||||
(See provided `compose.yml` in repository)
|
||||
|
||||
* [Docker](https://docs.docker.com/get-docker/) installed
|
||||
* [Docker Compose](https://docs.docker.com/compose/install/) installed (usually included with Docker Desktop)
|
||||
|
||||
### Option 1: Using Pre-built Images (Recommended)
|
||||
|
||||
This method uses the Docker images automatically built and pushed to GitHub Container Registry (GHCR) by the GitHub Actions workflow.
|
||||
|
||||
1. **Create `docker-compose.yml`:**
|
||||
Save the following content as `docker-compose.yml` in a new directory on your machine:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
# Use the pre-built image from GHCR
|
||||
image: ghcr.io/mrunknownde/utools-backend:latest # Or specify a specific tag/sha
|
||||
container_name: utools_backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Production environment settings
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
LOG_LEVEL: info # Adjust log level if needed (e.g., 'debug', 'warn')
|
||||
PING_COUNT: 4
|
||||
# Optional: Set Sentry DSN for error tracking if you use Sentry
|
||||
# SENTRY_DSN: "YOUR_SENTRY_DSN"
|
||||
dns:
|
||||
# Explicitly set reliable public DNS servers for rDNS lookups inside the container
|
||||
- 1.1.1.1 # Cloudflare DNS
|
||||
- 1.0.0.1 # Cloudflare DNS
|
||||
- 8.8.8.8 # Google DNS
|
||||
- 8.8.4.4 # Google DNS
|
||||
networks:
|
||||
- utools_network
|
||||
# Note: No ports exposed directly, access is via frontend proxy
|
||||
|
||||
frontend:
|
||||
# Use the pre-built image from GHCR
|
||||
image: ghcr.io/mrunknownde/utools-frontend:latest # Or specify a specific tag/sha
|
||||
container_name: utools_frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Expose port 8080 on the host, mapping to port 80 in the container (Nginx)
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- backend # Ensures backend service is started first
|
||||
networks:
|
||||
- utools_network
|
||||
|
||||
networks:
|
||||
utools_network:
|
||||
driver: bridge
|
||||
name: utools_network # Give the network a specific name
|
||||
```
|
||||
|
||||
2. **Start the Application:**
|
||||
Open a terminal in the directory where you saved the `docker-compose.yml` file and run:
|
||||
2. **Start:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
*(Note: Use `docker-compose` (with hyphen) if you have an older version)*
|
||||
This will download the images (if not already present) and start the containers in the background.
|
||||
|
||||
3. **Access the Webapp:**
|
||||
Open your web browser and navigate to `http://localhost:8080`.
|
||||
|
||||
### Option 2: Building Images from Source
|
||||
|
||||
If you want to modify the code or build the images yourself:
|
||||
|
||||
1. **Clone the Repository:**
|
||||
```bash
|
||||
git clone https://github.com/mrunknownde/utools.git
|
||||
cd utools
|
||||
```
|
||||
2. **Build and Start:**
|
||||
Use Docker Compose to build the images based on the `Dockerfile`s in the `backend` and `frontend` directories and then start the containers:
|
||||
```bash
|
||||
# Optional: Set GIT_COMMIT_SHA for build args if needed
|
||||
# export GIT_COMMIT_SHA=$(git rev-parse --short HEAD)
|
||||
docker compose -f compose.yml up -d --build
|
||||
```
|
||||
*(Note: Use `docker-compose` (with hyphen) if you have an older version. The `compose.yml` in the repository correctly uses `build:` directives)*
|
||||
|
||||
3. **Access the Webapp:**
|
||||
Open your web browser and navigate to `http://localhost:8080`.
|
||||
3. **Access:** `http://localhost:8080`
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
The application is configured mainly through environment variables set in the `docker-compose.yml` file for the `backend` service:
|
||||
|
||||
* `NODE_ENV`: Set to `production` for optimal performance and JSON logging.
|
||||
* `PORT`: The internal port the Node.js application listens on (default: `3000`).
|
||||
* `LOG_LEVEL`: Controls the logging verbosity (e.g., `debug`, `info`, `warn`, `error`).
|
||||
* `PING_COUNT`: Number of ping packets to send (default: `4`).
|
||||
* `SENTRY_DSN` (Optional): Your Sentry Data Source Name for error tracking. Can be set during build via args or at runtime via environment variable.
|
||||
* `dns` (in compose): Specifies DNS servers for the backend container, crucial for reliable rDNS lookups.
|
||||
|
||||
The MaxMind database paths (`GEOIP_CITY_DB`, `GEOIP_ASN_DB`) are set within the backend's Dockerfile but could potentially be overridden if needed (e.g., using volumes).
|
||||
Key environment variables for the backend:
|
||||
* `NODE_ENV`: `production` or `development`.
|
||||
* `PORT`: Internal port (default 3000).
|
||||
* `RATE_LIMIT_MAX`: Requests per window (e.g., 50).
|
||||
* `SENTRY_DSN`: Optional Sentry integration.
|
||||
|
||||
## 🌐 Data Sources
|
||||
|
||||
* **Geolocation & ASN:** This tool uses GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com).
|
||||
* **Important:** The GeoLite2 databases require periodic updates. You need a MaxMind account (free) to download them. The Docker images contain the databases at build time. For long-term use, you should implement a process to update the `.mmdb` files within the `backend/data` directory (if using volumes) or rebuild the backend image regularly using the provided GitHub Action workflow (`maxmind-update.yml`).
|
||||
* **Map Tiles:** Provided by OpenStreetMap contributors.
|
||||
* **WHOIS Data:** Retrieved in real-time using the `whois-json` library, which queries standard WHOIS servers.
|
||||
* **DNS Data:** Retrieved in real-time using Node.js' built-in `dns` module.
|
||||
* **Geolocation:** [MaxMind GeoLite2](https://www.maxmind.com).
|
||||
* **Map Tiles:** [OpenStreetMap](https://www.openstreetmap.org) & [CartoDB](https://carto.com).
|
||||
* **MAC Data:** [IEEE OUI](https://standards.ieee.org/products-services/regauth/oui/index.html).
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the MIT License. See the `LICENSE` file for details.
|
||||
MIT License.
|
||||
+10
-6
@@ -1,6 +1,6 @@
|
||||
# Stage 1: Build Dependencies
|
||||
# Use an official Node.js runtime as a parent image
|
||||
FROM node:18-alpine AS builder
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -14,11 +14,10 @@ COPY package*.json ./
|
||||
|
||||
# Install app dependencies using npm ci for faster, reliable builds
|
||||
# --only=production installs only production dependencies (including 'oui')
|
||||
RUN npm ci --only=production
|
||||
# REMOVED: RUN npm i oui (should be installed by npm ci now)
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Stage 2: Production Image
|
||||
FROM node:18-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -37,8 +36,13 @@ COPY ./data ./data
|
||||
|
||||
# Create a non-root user and group
|
||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
# Optional: Change ownership of app files to the new user
|
||||
# RUN chown -R appuser:appgroup /app
|
||||
|
||||
# Create ASN cache directory and set correct ownership BEFORE switching user
|
||||
# This ensures the Docker volume mount is writable by appuser
|
||||
RUN mkdir -p /app/asn-cache && chown -R appuser:appgroup /app/asn-cache
|
||||
|
||||
# Change ownership of all app files to the new user
|
||||
RUN chown -R appuser:appgroup /app
|
||||
|
||||
# Switch to the non-root user
|
||||
USER appuser
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 58 MiB After Width: | Height: | Size: 133 B |
+3
-1
@@ -4,4 +4,6 @@ GEOIP_ASN_DB=./data/GeoLite2-ASN.mmdb
|
||||
PORT=3000
|
||||
LOG_LEVEL=debug # z.B. für mehr Details im Development
|
||||
PING_COUNT=4
|
||||
# NODE_ENV=development # Setze dies ggf. für pino-pretty
|
||||
# NODE_ENV=development # Setze dies ggf. für pino-pretty
|
||||
RATE_LIMIT_MAX=200
|
||||
RATE_LIMIT_WINDOW_MS=300000 # 5 Minuten
|
||||
Generated
+920
-740
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -9,16 +9,24 @@
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"dependencies": {
|
||||
"@maxmind/geoip2-node": "^6.0.0",
|
||||
"@sentry/node": "^8.0.0",
|
||||
"@sentry/node": "^10.42.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"mac-oui-lookup": "^1.1.4",
|
||||
"macaddress": "^0.5.3",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"qs": "^6.14.2",
|
||||
"whois-json": "^2.0.4"
|
||||
},
|
||||
"overrides": {
|
||||
"underscore": "1.13.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// backend/routes/asnLookup.js
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pino = require('pino');
|
||||
const Sentry = require('@sentry/node');
|
||||
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||
const router = express.Router();
|
||||
|
||||
// ─── Filesystem Cache (24h TTL) ───────────────────────────────────────────────
|
||||
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const CACHE_DIR = process.env.ASN_CACHE_DIR || path.join(__dirname, '..', 'data', 'asn-cache');
|
||||
|
||||
// Ensure cache directory exists
|
||||
try {
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
} catch (e) {
|
||||
logger.warn({ error: e.message }, 'Could not create ASN cache directory');
|
||||
}
|
||||
|
||||
function cacheFilePath(key) {
|
||||
// Sanitize key to safe filename
|
||||
return path.join(CACHE_DIR, key.replace(/[^a-zA-Z0-9_:-]/g, '_') + '.json');
|
||||
}
|
||||
|
||||
function getCached(key) {
|
||||
try {
|
||||
const file = cacheFilePath(key);
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
const entry = JSON.parse(raw);
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
fs.unlinkSync(file);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
} catch {
|
||||
return null; // File doesn't exist or parse failed
|
||||
}
|
||||
}
|
||||
|
||||
function setCache(key, data) {
|
||||
try {
|
||||
const entry = { data, expiresAt: Date.now() + CACHE_TTL_MS };
|
||||
fs.writeFileSync(cacheFilePath(key), JSON.stringify(entry), 'utf8');
|
||||
} catch (e) {
|
||||
logger.warn({ key, error: e.message }, 'ASN cache write failed');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP Helper ──────────────────────────────────────────────────────────────
|
||||
function fetchJson(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'uTools-Network-Suite/1.0 (https://github.com/MrUnknownDE/utools)',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
timeout: 15000,
|
||||
}, (res) => {
|
||||
let raw = '';
|
||||
res.on('data', (chunk) => { raw += chunk; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
||||
}
|
||||
try { resolve(JSON.parse(raw)); }
|
||||
catch (e) { reject(new Error(`JSON parse error: ${e.message}`)); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error(`Timeout: ${url}`)); });
|
||||
});
|
||||
}
|
||||
|
||||
// ─── ASN Validation ───────────────────────────────────────────────────────────
|
||||
function parseAsn(raw) {
|
||||
if (!raw || typeof raw !== 'string') return null;
|
||||
const cleaned = raw.trim().toUpperCase().replace(/^AS/, '');
|
||||
const n = parseInt(cleaned, 10);
|
||||
if (isNaN(n) || n < 1 || n > 4294967295 || String(n) !== cleaned) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ─── RIPE Stat Fetchers ───────────────────────────────────────────────────────
|
||||
async function fetchOverview(asn) {
|
||||
const key = `overview:${asn}`;
|
||||
const cached = getCached(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const json = await fetchJson(`https://stat.ripe.net/data/as-overview/data.json?resource=AS${asn}`);
|
||||
const d = json?.data;
|
||||
const result = {
|
||||
asn,
|
||||
name: d?.holder || null,
|
||||
announced: d?.announced ?? false,
|
||||
type: d?.type || null,
|
||||
block: d?.block || null,
|
||||
};
|
||||
setCache(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchNeighbours(asn) {
|
||||
const key = `neighbours:${asn}`;
|
||||
const cached = getCached(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const json = await fetchJson(`https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS${asn}`);
|
||||
const neighbours = (json?.data?.neighbours || []).map(n => ({
|
||||
asn: n.asn,
|
||||
type: n.type, // 'left' = upstream, 'right' = downstream
|
||||
power: n.power || 0,
|
||||
v4_peers: n.v4_peers || 0,
|
||||
v6_peers: n.v6_peers || 0,
|
||||
}));
|
||||
setCache(key, neighbours);
|
||||
return neighbours;
|
||||
}
|
||||
|
||||
async function fetchPrefixes(asn) {
|
||||
const key = `prefixes:${asn}`;
|
||||
const cached = getCached(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const json = await fetchJson(`https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}`);
|
||||
const prefixes = (json?.data?.prefixes || []).map(p => p.prefix);
|
||||
setCache(key, prefixes);
|
||||
return prefixes;
|
||||
}
|
||||
|
||||
async function fetchPeeringDb(asn) {
|
||||
const key = `peeringdb:${asn}`;
|
||||
const cached = getCached(key);
|
||||
if (cached !== null) return cached;
|
||||
|
||||
try {
|
||||
const json = await fetchJson(`https://www.peeringdb.com/api/net?asn=${asn}&depth=2`);
|
||||
const net = json?.data?.[0];
|
||||
if (!net) { setCache(key, null); return null; }
|
||||
|
||||
const result = {
|
||||
peeringPolicy: net.policy_general || null,
|
||||
infoType: net.info_type || null,
|
||||
infoTraffic: net.info_traffic || null,
|
||||
infoRatio: net.info_ratio || null,
|
||||
infoScope: net.info_scope || null,
|
||||
website: net.website || null,
|
||||
ixps: (net.netixlan_set || []).map(ix => ({
|
||||
name: ix.name,
|
||||
speed: ix.speed,
|
||||
ipv4: ix.ipaddr4 || null,
|
||||
ipv6: ix.ipaddr6 || null,
|
||||
})).slice(0, 20),
|
||||
};
|
||||
setCache(key, result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
logger.warn({ asn, error: e.message }, 'PeeringDB fetch failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resolve names for a list of ASNs ────────────────────────────────────────
|
||||
async function resolveNames(asnList) {
|
||||
const results = await Promise.allSettled(asnList.map(a => fetchOverview(a)));
|
||||
const map = {};
|
||||
results.forEach((r, i) => {
|
||||
map[asnList[i]] = r.status === 'fulfilled' ? (r.value.name || null) : null;
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
// ─── Route ────────────────────────────────────────────────────────────────────
|
||||
router.get('/', async (req, res, next) => {
|
||||
const rawAsn = req.query.asn;
|
||||
const requestIp = req.ip;
|
||||
|
||||
const asn = parseAsn(String(rawAsn || ''));
|
||||
if (!asn) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid ASN. Please provide a number between 1 and 4294967295, e.g. ?asn=15169'
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({ requestIp, asn }, 'ASN lookup request');
|
||||
|
||||
try {
|
||||
// Level 1 + Level 2: fetch all base data in parallel (allSettled = one failure won't crash everything)
|
||||
const [overviewResult, neighboursResult, prefixesResult, peeringdbResult] = await Promise.allSettled([
|
||||
fetchOverview(asn),
|
||||
fetchNeighbours(asn),
|
||||
fetchPrefixes(asn),
|
||||
fetchPeeringDb(asn),
|
||||
]);
|
||||
|
||||
const overview = overviewResult.status === 'fulfilled' ? overviewResult.value : { asn, name: null, announced: false, type: null };
|
||||
const neighbours = neighboursResult.status === 'fulfilled' ? neighboursResult.value : [];
|
||||
const prefixes = prefixesResult.status === 'fulfilled' ? prefixesResult.value : [];
|
||||
const peeringdb = peeringdbResult.status === 'fulfilled' ? peeringdbResult.value : null;
|
||||
|
||||
if (overviewResult.status === 'rejected') logger.warn({ asn, error: overviewResult.reason?.message }, 'Overview fetch failed, continuing with partial data');
|
||||
if (neighboursResult.status === 'rejected') logger.warn({ asn, error: neighboursResult.reason?.message }, 'Neighbours fetch failed, continuing with partial data');
|
||||
if (prefixesResult.status === 'rejected') logger.warn({ asn, error: prefixesResult.reason?.message }, 'Prefixes fetch failed, continuing with partial data');
|
||||
|
||||
// Split neighbours (keep ALL of them, sorted by power)
|
||||
const allUpstreams = neighbours.filter(n => n.type === 'left').sort((a, b) => b.power - a.power);
|
||||
const allDownstreams = neighbours.filter(n => n.type === 'right').sort((a, b) => b.power - a.power);
|
||||
|
||||
// Resolve names for only the Top 25 of each, to prevent hammering the RIPE API (rate limits)
|
||||
const topLevel2Asns = [...new Set([
|
||||
...allUpstreams.slice(0, 25),
|
||||
...allDownstreams.slice(0, 25)
|
||||
].map(n => n.asn))];
|
||||
const level2Names = await resolveNames(topLevel2Asns);
|
||||
|
||||
// Level 3: fetch upstreams-of-upstreams for top 5 Level 2 upstreams
|
||||
const level3Raw = await Promise.allSettled(
|
||||
allUpstreams.slice(0, 5).map(async (upstreamNode) => {
|
||||
const theirNeighbours = await fetchNeighbours(upstreamNode.asn);
|
||||
const theirUpstreams = theirNeighbours
|
||||
.filter(n => n.type === 'left')
|
||||
.sort((a, b) => b.power - a.power)
|
||||
.slice(0, 3);
|
||||
return { parentAsn: upstreamNode.asn, theirUpstreams };
|
||||
})
|
||||
);
|
||||
|
||||
const level3Data = level3Raw
|
||||
.filter(r => r.status === 'fulfilled')
|
||||
.map(r => r.value);
|
||||
|
||||
// Resolve names for Level 3 nodes
|
||||
const level3Asns = [...new Set(level3Data.flatMap(d => d.theirUpstreams.map(n => n.asn)))];
|
||||
const level3Names = await resolveNames(level3Asns);
|
||||
|
||||
// ── Build graph ───────────────────────────────────────────────────────
|
||||
const graph = {
|
||||
center: { asn, name: overview.name },
|
||||
level2: {
|
||||
upstreams: allUpstreams.map(n => ({ asn: n.asn, name: level2Names[n.asn] || null, power: n.power, v4: n.v4_peers, v6: n.v6_peers })),
|
||||
downstreams: allDownstreams.map(n => ({ asn: n.asn, name: level2Names[n.asn] || null, power: n.power, v4: n.v4_peers, v6: n.v6_peers })),
|
||||
},
|
||||
level3: level3Data.map(d => ({
|
||||
parentAsn: d.parentAsn,
|
||||
parentName: level2Names[d.parentAsn] || null,
|
||||
upstreams: d.theirUpstreams.map(n => ({
|
||||
asn: n.asn,
|
||||
name: level3Names[n.asn] || null,
|
||||
power: n.power,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
asn,
|
||||
name: overview.name,
|
||||
announced: overview.announced,
|
||||
type: overview.type,
|
||||
prefixes: prefixes, // Export all prefixes without limit
|
||||
peeringdb,
|
||||
graph,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error({ asn, requestIp, error: error.message }, 'ASN lookup failed');
|
||||
Sentry.captureException(error, { extra: { asn, requestIp } });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,39 @@
|
||||
const express = require('express');
|
||||
const Sentry = require("@sentry/node");
|
||||
const pino = require('pino');
|
||||
const { getVendor } = require('mac-oui-lookup');
|
||||
const { isValidMacAddress } = require('../utils');
|
||||
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const macRaw = req.query.mac;
|
||||
const mac = typeof macRaw === 'string' ? macRaw.trim() : macRaw;
|
||||
const requestIp = req.ip || req.socket.remoteAddress;
|
||||
|
||||
logger.info({ requestIp, mac }, 'MAC lookup request received');
|
||||
|
||||
if (!isValidMacAddress(mac)) {
|
||||
logger.warn({ requestIp, mac }, 'Invalid MAC address for lookup');
|
||||
return res.status(400).json({ success: false, error: 'Invalid MAC address format provided.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const vendor = getVendor(mac);
|
||||
|
||||
if (vendor) {
|
||||
logger.info({ requestIp, mac, vendor }, 'MAC lookup successful');
|
||||
res.json({ success: true, mac, vendor });
|
||||
} else {
|
||||
logger.info({ requestIp, mac }, 'MAC address not found in OUI database');
|
||||
res.status(404).json({ success: false, error: 'Vendor not found for this MAC address.' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ requestIp, mac, error: error.message }, 'MAC lookup failed');
|
||||
Sentry.captureException(error, { extra: { requestIp, mac } });
|
||||
res.status(500).json({ success: false, error: 'An unexpected error occurred during the MAC lookup.' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,76 @@
|
||||
const express = require('express');
|
||||
const Sentry = require("@sentry/node");
|
||||
const pino = require('pino');
|
||||
const { isValidIp, isPrivateIp, checkPort } = require('../utils');
|
||||
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||
const router = express.Router();
|
||||
|
||||
const COMMON_PORTS_TO_SCAN = [
|
||||
21, 22, 25, 53, 80, 110, 143, 443, 3306, 3389, 5432, 8080, 8443
|
||||
];
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const targetIpRaw = req.query.targetIp;
|
||||
const targetIp = typeof targetIpRaw === 'string' ? targetIpRaw.trim() : targetIpRaw;
|
||||
const requestIp = req.ip || req.socket.remoteAddress;
|
||||
|
||||
logger.info({ requestIp, targetIp }, 'Port scan stream request received');
|
||||
|
||||
if (!isValidIp(targetIp)) {
|
||||
logger.warn({ requestIp, targetIp }, 'Invalid target IP for port scan');
|
||||
return res.status(400).json({ success: false, error: 'Invalid target IP address provided.' });
|
||||
}
|
||||
if (isPrivateIp(targetIp)) {
|
||||
logger.warn({ requestIp, targetIp }, 'Attempt to scan private IP blocked');
|
||||
return res.status(403).json({ success: false, error: 'Operations on private or local IP addresses are not allowed.' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
const sendEvent = (event, data) => {
|
||||
if (!res.writableEnded) {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
};
|
||||
|
||||
let isConnectionClosed = false;
|
||||
req.on('close', () => {
|
||||
logger.info({ requestIp, targetIp }, 'Client disconnected from port scan stream.');
|
||||
isConnectionClosed = true;
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
sendEvent('info', { message: `Starting scan of ${COMMON_PORTS_TO_SCAN.length} common ports on ${targetIp}...` });
|
||||
|
||||
for (const port of COMMON_PORTS_TO_SCAN) {
|
||||
if (isConnectionClosed) break;
|
||||
const result = await checkPort(port, targetIp, 2000);
|
||||
sendEvent('port_status', result);
|
||||
}
|
||||
|
||||
if (!isConnectionClosed) {
|
||||
logger.info({ requestIp, targetIp }, 'Port scan stream completed successfully.');
|
||||
sendEvent('end', { message: 'Scan complete.' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ requestIp, targetIp, error: error.message }, 'Error during port scan stream');
|
||||
// Add context directly to the captureException call
|
||||
Sentry.captureException(error, { extra: { requestIp, targetIp } });
|
||||
if (!isConnectionClosed) {
|
||||
sendEvent('error', { error: 'An unexpected error occurred during the scan.' });
|
||||
}
|
||||
} finally {
|
||||
if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,4 +1,3 @@
|
||||
// backend/routes/traceroute.js
|
||||
const express = require('express');
|
||||
const Sentry = require("@sentry/node");
|
||||
const { spawn } = require('child_process');
|
||||
@@ -37,13 +36,6 @@ router.get('/', (req, res) => {
|
||||
return res.status(403).json({ success: false, error: 'Operations on private or local IP addresses are not allowed.' });
|
||||
}
|
||||
|
||||
// Add specific context for this request to the current Sentry scope
|
||||
// Errors/Messages captured later in this request handler will have this context.
|
||||
Sentry.configureScope(scope => {
|
||||
scope.setContext("traceroute_details", { targetIp: targetIp, requestIp: requestIp });
|
||||
});
|
||||
|
||||
|
||||
try {
|
||||
logger.info({ requestIp, targetIp }, `Starting traceroute stream...`);
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
@@ -77,7 +69,6 @@ router.get('/', (req, res) => {
|
||||
Sentry.captureException(e, { level: 'warning', extra: { requestIp, targetIp, event } });
|
||||
if (proc && !proc.killed) proc.kill();
|
||||
if (!res.writableEnded) res.end();
|
||||
// No manual transaction finishing needed here
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,10 +79,8 @@ router.get('/', (req, res) => {
|
||||
lines.forEach(line => {
|
||||
const parsed = parseTracerouteLine(line);
|
||||
if (parsed) {
|
||||
// logger.debug({ requestIp, targetIp, hop: parsed.hop, ip: parsed.ip }, 'Sending hop data');
|
||||
sendEvent('hop', parsed);
|
||||
} else if (line.trim()) {
|
||||
// logger.debug({ requestIp, targetIp, message: line.trim() }, 'Sending info data');
|
||||
sendEvent('info', { message: line.trim() });
|
||||
}
|
||||
});
|
||||
@@ -110,7 +99,6 @@ router.get('/', (req, res) => {
|
||||
Sentry.captureException(err, { extra: { requestIp, targetIp } }); // Capture original error
|
||||
sendEvent('error', { error: `Failed to start traceroute: ${errorMsg}` });
|
||||
if (!res.writableEnded) res.end();
|
||||
// No manual transaction finishing needed here
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
@@ -125,30 +113,23 @@ router.get('/', (req, res) => {
|
||||
logger.error({ requestIp, targetIp, exitCode: code }, errorMsg);
|
||||
Sentry.captureMessage('Traceroute command failed', { level: 'error', extra: { requestIp, targetIp, exitCode: code } });
|
||||
sendEvent('error', { error: errorMsg });
|
||||
// Transaction status will be inferred by Sentry based on errors captured
|
||||
} else {
|
||||
logger.info({ requestIp, targetIp }, `Traceroute stream completed successfully.`);
|
||||
// Transaction status will likely be 'ok' if no errors were captured
|
||||
}
|
||||
sendEvent('end', { exitCode: code });
|
||||
if (!res.writableEnded) res.end();
|
||||
// No manual transaction finishing needed here
|
||||
});
|
||||
|
||||
req.on('close', () => {
|
||||
logger.info({ requestIp, targetIp }, 'Client disconnected from traceroute stream, killing process.');
|
||||
if (proc && !proc.killed) proc.kill();
|
||||
if (!res.writableEnded) res.end();
|
||||
// Sentry transaction might be marked as 'cancelled' automatically or based on timeout
|
||||
// No manual transaction finishing needed here
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// This catch handles errors during the initial setup (e.g., spawn fails immediately)
|
||||
const errorMsg = getErrorMessage(error, 'Failed to initiate traceroute due to an internal server error.');
|
||||
logger.error({ requestIp, targetIp, error: errorMsg, stack: error.stack }, 'Error setting up traceroute stream');
|
||||
Sentry.captureException(error, { extra: { requestIp, targetIp } }); // Capture original error
|
||||
// No manual transaction finishing needed here
|
||||
Sentry.captureException(error, { extra: { requestIp, targetIp } });
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ success: false, error: `Failed to initiate traceroute: ${errorMsg}` });
|
||||
|
||||
@@ -5,7 +5,7 @@ const whois = require('whois-json');
|
||||
const pino = require('pino');
|
||||
|
||||
// Import utilities
|
||||
const { isValidIp, isValidDomain } = require('../utils');
|
||||
const { isValidIp, isValidDomain, isPrivateIp } = require('../utils');
|
||||
|
||||
// Logger for this module
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||
@@ -26,6 +26,11 @@ router.get('/', async (req, res, next) => {
|
||||
return res.status(400).json({ success: false, error: 'Invalid domain name or IP address provided for WHOIS lookup.' });
|
||||
}
|
||||
|
||||
if (isValidIp(query) && isPrivateIp(query)) {
|
||||
logger.warn({ requestIp, query }, 'Attempt to WHOIS lookup private IP blocked');
|
||||
return res.status(403).json({ success: false, error: 'WHOIS lookup for private or local IP addresses is not supported.' });
|
||||
}
|
||||
|
||||
// Note: No isPrivateIp check here, as WHOIS for IPs might be desired regardless of range,
|
||||
// and domain lookups don't involve IP ranges.
|
||||
|
||||
|
||||
+28
-60
@@ -1,5 +1,3 @@
|
||||
// server.js
|
||||
// Load .env variables FIRST!
|
||||
require('dotenv').config();
|
||||
|
||||
// --- Sentry Initialisierung (GANZ OBEN, nach dotenv) ---
|
||||
@@ -7,16 +5,14 @@ const Sentry = require("@sentry/node");
|
||||
|
||||
// Initialize Sentry BEFORE requiring any other modules!
|
||||
Sentry.init({
|
||||
// DSN should now be available from process.env if set in .env
|
||||
dsn: process.env.SENTRY_DSN || "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@oooooooooooooooo.ingest.sentry.io/123456",
|
||||
// Enable tracing - Adjust sample rate as needed
|
||||
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
|
||||
integrations: [
|
||||
Sentry.consoleLoggingIntegration({ levels: ["warn", "error"] }),
|
||||
],
|
||||
enableLogs: true,
|
||||
});
|
||||
|
||||
// DEBUG: Check Sentry object after init
|
||||
console.log("Sentry object after init:", typeof Sentry, Sentry ? Object.keys(Sentry) : 'Sentry is undefined/null');
|
||||
// --- Ende Sentry Initialisierung ---
|
||||
|
||||
|
||||
// Require necessary core modules AFTER Sentry is initialized
|
||||
const express = require('express');
|
||||
@@ -33,33 +29,22 @@ const lookupRoutes = require('./routes/lookup');
|
||||
const dnsLookupRoutes = require('./routes/dnsLookup');
|
||||
const whoisLookupRoutes = require('./routes/whoisLookup');
|
||||
const versionRoutes = require('./routes/version');
|
||||
const portScanRoutes = require('./routes/portScan');
|
||||
const macLookupRoutes = require('./routes/macLookup');
|
||||
const asnLookupRoutes = require('./routes/asnLookup');
|
||||
|
||||
// --- Logger Initialisierung ---
|
||||
const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: process.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname' } }
|
||||
: undefined,
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: process.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname' } }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Create Express app instance
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// --- Sentry Middleware (Request Handler & Tracing) ---
|
||||
// Must be the first middleware
|
||||
if (Sentry.Handlers && Sentry.Handlers.requestHandler) {
|
||||
app.use(Sentry.Handlers.requestHandler());
|
||||
} else {
|
||||
logger.error("Sentry.Handlers.requestHandler is not available!");
|
||||
}
|
||||
// Must be after requestHandler, before routes
|
||||
if (Sentry.Handlers && Sentry.Handlers.tracingHandler) {
|
||||
app.use(Sentry.Handlers.tracingHandler());
|
||||
} else {
|
||||
logger.error("Sentry.Handlers.tracingHandler is not available!");
|
||||
}
|
||||
// --- Ende Sentry Middleware ---
|
||||
|
||||
|
||||
// --- Core Middleware ---
|
||||
@@ -71,11 +56,11 @@ app.set('trust proxy', parseInt(process.env.TRUST_PROXY_COUNT || '2', 10)); // A
|
||||
// --- Rate Limiter ---
|
||||
// Apply a general limiter to most routes
|
||||
const generalLimiter = rateLimit({
|
||||
windowMs: 5 * 60 * 1000, // 5 minutes
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX || (process.env.NODE_ENV === 'production' ? '20' : '200'), 10), // Requests per window per IP, ensure integer
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || (5 * 60 * 1000).toString(), 10), // Default 5 minutes
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX || (process.env.NODE_ENV === 'production' ? '20' : '200'), 10), // Requests per window per IP
|
||||
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
||||
message: { success: false, error: 'Too many requests from this IP, please try again after 5 minutes' },
|
||||
message: { success: false, error: 'Too many requests from this IP, please try again after a while' },
|
||||
keyGenerator: (req, res) => req.ip, // Use client IP address from Express
|
||||
handler: (req, res, next, options) => {
|
||||
logger.warn({ ip: req.ip, route: req.originalUrl }, 'Rate limit exceeded');
|
||||
@@ -87,13 +72,8 @@ const generalLimiter = rateLimit({
|
||||
}
|
||||
});
|
||||
|
||||
// Apply the limiter to specific API routes that perform external actions
|
||||
// Note: /api/ipinfo and /api/version are often excluded as they are less resource-intensive
|
||||
app.use('/api/ping', generalLimiter);
|
||||
app.use('/api/traceroute', generalLimiter);
|
||||
app.use('/api/lookup', generalLimiter);
|
||||
app.use('/api/dns-lookup', generalLimiter);
|
||||
app.use('/api/whois-lookup', generalLimiter);
|
||||
// Apply the limiter to ALL API routes
|
||||
app.use('/api', generalLimiter);
|
||||
|
||||
|
||||
// --- API Routes ---
|
||||
@@ -105,25 +85,13 @@ app.use('/api/lookup', lookupRoutes);
|
||||
app.use('/api/dns-lookup', dnsLookupRoutes);
|
||||
app.use('/api/whois-lookup', whoisLookupRoutes);
|
||||
app.use('/api/version', versionRoutes);
|
||||
app.use('/api/port-scan', portScanRoutes);
|
||||
app.use('/api/mac-lookup', macLookupRoutes);
|
||||
app.use('/api/asn-lookup', asnLookupRoutes);
|
||||
|
||||
|
||||
// --- Sentry Error Handler ---
|
||||
// Must be AFTER all controllers and BEFORE any other error handling middleware
|
||||
if (Sentry.Handlers && Sentry.Handlers.errorHandler) {
|
||||
app.use(Sentry.Handlers.errorHandler({
|
||||
shouldHandleError(error) {
|
||||
// Capture all 500 errors
|
||||
if (error.status === 500) return true;
|
||||
// Capture specific client errors if needed, e.g., 403
|
||||
// if (error.status === 403) return true;
|
||||
// By default, capture only server errors (5xx)
|
||||
return error.status >= 500;
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
logger.error("Sentry.Handlers.errorHandler is not available!");
|
||||
}
|
||||
// --- Ende Sentry Error Handler ---
|
||||
// Sentry error handler — must be after routes, before custom error handler
|
||||
Sentry.setupExpressErrorHandler(app);
|
||||
|
||||
|
||||
// --- Fallback Error Handler ---
|
||||
@@ -178,9 +146,9 @@ async function gracefulShutdown(signal) {
|
||||
await Sentry.close(2000); // 2 second timeout
|
||||
logger.info('Sentry closed.');
|
||||
} catch (e) {
|
||||
logger.error({ error: e.message }, 'Error closing Sentry');
|
||||
logger.error({ error: e.message }, 'Error closing Sentry');
|
||||
} finally {
|
||||
process.exit(128 + signals[signal]); // Standard exit code for signals
|
||||
process.exit(128 + signals[signal]); // Standard exit code for signals
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -190,9 +158,9 @@ async function gracefulShutdown(signal) {
|
||||
await Sentry.close(2000);
|
||||
logger.info('Sentry closed (server never started).');
|
||||
} catch (e) {
|
||||
logger.error({ error: e.message }, 'Error closing Sentry (server never started)');
|
||||
logger.error({ error: e.message }, 'Error closing Sentry (server never started)');
|
||||
} finally {
|
||||
process.exit(128 + signals[signal]);
|
||||
process.exit(128 + signals[signal]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,5 +173,5 @@ async function gracefulShutdown(signal) {
|
||||
|
||||
// Register signal handlers
|
||||
Object.keys(signals).forEach((signal) => {
|
||||
process.on(signal, () => gracefulShutdown(signal));
|
||||
process.on(signal, () => gracefulShutdown(signal));
|
||||
});
|
||||
+69
-7
@@ -29,6 +29,12 @@ function isValidIp(ip) {
|
||||
*/
|
||||
function isPrivateIp(ip) {
|
||||
if (!ip) return false;
|
||||
|
||||
// Normalize IPv6-mapped IPv4 addresses (e.g., ::ffff:192.168.1.1 -> 192.168.1.1)
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
ip = ip.substring(7);
|
||||
}
|
||||
|
||||
const ipVersion = net.isIP(ip);
|
||||
|
||||
if (ipVersion === 4) {
|
||||
@@ -38,12 +44,15 @@ function isPrivateIp(ip) {
|
||||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12
|
||||
(parts[0] === 192 && parts[1] === 168) || // 192.168.0.0/16
|
||||
parts[0] === 127 || // 127.0.0.0/8 (Loopback)
|
||||
(parts[0] === 169 && parts[1] === 254) // 169.254.0.0/16 (Link-local)
|
||||
(parts[0] === 169 && parts[1] === 254) || // 169.254.0.0/16 (Link-local)
|
||||
// Block 0.0.0.0 (Commonly "Any" or "Current Network")
|
||||
(parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0)
|
||||
);
|
||||
} else if (ipVersion === 6) {
|
||||
const lowerCaseIp = ip.toLowerCase();
|
||||
return (
|
||||
lowerCaseIp === '::1' || // ::1/128 (Loopback)
|
||||
lowerCaseIp === '::' || // ::/128 (Unspecified)
|
||||
lowerCaseIp.startsWith('fc') || lowerCaseIp.startsWith('fd') || // fc00::/7 (Unique Local)
|
||||
lowerCaseIp.startsWith('fe8') || lowerCaseIp.startsWith('fe9') || // fe80::/10 (Link-local)
|
||||
lowerCaseIp.startsWith('fea') || lowerCaseIp.startsWith('feb')
|
||||
@@ -66,6 +75,20 @@ function isValidDomain(domain) {
|
||||
return domainRegex.test(domain.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert eine MAC-Adresse.
|
||||
* @param {string} mac - Die zu validierende MAC-Adresse.
|
||||
* @returns {boolean} True, wenn das Format gültig ist, sonst false.
|
||||
*/
|
||||
function isValidMacAddress(mac) {
|
||||
if (!mac || typeof mac !== 'string') {
|
||||
return false;
|
||||
}
|
||||
// This regex matches common MAC address formats (e.g., 00:1A:2B:3C:4D:5E, 00-1A-2B-3C-4D-5E, 001A2B3C4D5E)
|
||||
const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$|^([0-9A-Fa-f]{12})$/;
|
||||
return macRegex.test(mac.trim());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Bereinigt eine IP-Adresse (z.B. entfernt ::ffff: Präfix von IPv4-mapped IPv6).
|
||||
@@ -167,7 +190,7 @@ function parsePingOutput(pingOutput) {
|
||||
|
||||
// Handle both 'rtt' and 'round-trip' prefixes for broader compatibility
|
||||
const rttLine = lines.find(line => line.startsWith('rtt min/avg/max/mdev') || line.startsWith('round-trip min/avg/max/stddev'));
|
||||
if (rttLine) {
|
||||
if (rttLine) {
|
||||
const rttMatch = rttLine.match(/([\d.]+)\/([\d.]+)\/([\d.]+)\/([\d.]+)/);
|
||||
if (rttMatch) {
|
||||
rtt = {
|
||||
@@ -186,7 +209,7 @@ function parsePingOutput(pingOutput) {
|
||||
|
||||
// Check for common error messages or patterns
|
||||
if (packetsTransmitted > 0 && packetsReceived === 0) {
|
||||
result.error = "Request timed out or host unreachable.";
|
||||
result.error = "Request timed out or host unreachable.";
|
||||
} else if (pingOutput.includes('unknown host') || pingOutput.includes('Name or service not known')) {
|
||||
result.error = "Unknown host.";
|
||||
}
|
||||
@@ -216,8 +239,8 @@ function parseTracerouteLine(line) {
|
||||
const timeoutMatch = line.match(/^(\s*\d+)\s+(\*\s+\*\s+\*)/); // Match lines with only timeouts
|
||||
|
||||
if (timeoutMatch) {
|
||||
// Handle timeout line
|
||||
return {
|
||||
// Handle timeout line
|
||||
return {
|
||||
hop: parseInt(timeoutMatch[1].trim(), 10),
|
||||
hostname: null,
|
||||
ip: null,
|
||||
@@ -243,7 +266,7 @@ function parseTracerouteLine(line) {
|
||||
// Pad with '*' if fewer than 3 RTTs were found (e.g., due to timeouts)
|
||||
while (rtts.length < 3) rtts.push('*');
|
||||
|
||||
return {
|
||||
return {
|
||||
hop: hop,
|
||||
hostname: hostname || null, // Use null if hostname wasn't captured
|
||||
ip: ip,
|
||||
@@ -256,14 +279,53 @@ function parseTracerouteLine(line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific TCP port is open on a given host.
|
||||
* @param {number} port - The port to check.
|
||||
* @param {string} host - The target host IP address.
|
||||
* @param {number} timeout - Connection timeout in milliseconds.
|
||||
* @returns {Promise<{port: number, status: 'open'|'closed'|'timeout', service: string}>} A promise that resolves with the port status.
|
||||
*/
|
||||
function checkPort(port, host, timeout = 2000) {
|
||||
const commonPorts = {
|
||||
21: 'FTP', 22: 'SSH', 23: 'Telnet', 25: 'SMTP', 53: 'DNS', 80: 'HTTP',
|
||||
110: 'POP3', 143: 'IMAP', 443: 'HTTPS', 445: 'SMB', 993: 'IMAPS',
|
||||
995: 'POP3S', 1433: 'MSSQL', 1521: 'Oracle', 3306: 'MySQL', 3389: 'RDP',
|
||||
5432: 'PostgreSQL', 5900: 'VNC', 8080: 'HTTP-Alt', 8443: 'HTTPS-Alt'
|
||||
};
|
||||
const service = commonPorts[port] || 'Unknown';
|
||||
|
||||
// Validate before any network operation — throw so CodeQL tracks this as a hard barrier
|
||||
if (!isValidIp(host) || isPrivateIp(host)) {
|
||||
logger.warn({ host, port }, 'Blocked attempt to scan restricted IP in checkPort');
|
||||
throw new Error(`Scanning restricted: ${host} is not a valid public IP.`);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
socket.setTimeout(timeout);
|
||||
|
||||
socket.on('connect', () => { socket.destroy(); resolve({ port, status: 'open', service }); });
|
||||
socket.on('timeout', () => { socket.destroy(); resolve({ port, status: 'timeout', service }); });
|
||||
socket.on('error', (err) => {
|
||||
socket.destroy();
|
||||
const status = err.code === 'ECONNREFUSED' ? 'closed' : 'error';
|
||||
resolve({ port, status, service, error: err.code });
|
||||
});
|
||||
|
||||
socket.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
isValidIp,
|
||||
isPrivateIp,
|
||||
isValidDomain,
|
||||
isValidMacAddress,
|
||||
getCleanIp,
|
||||
executeCommand,
|
||||
parsePingOutput,
|
||||
parseTracerouteLine,
|
||||
// Note: logger is not exported, assuming it's managed globally or passed where needed
|
||||
checkPort,
|
||||
};
|
||||
@@ -1,3 +1,13 @@
|
||||
docker compose down
|
||||
|
||||
# Setzt die Git-Commit-Variable für den Build-Prozess
|
||||
export GIT_COMMIT_SHA=$(git rev-parse --short HEAD)
|
||||
docker compose -f compose.yml up -d --build
|
||||
export SENTRY_DSN="https://7ea70caba68f548fb96482a573006a7b@o447623.ingest.us.sentry.io/4509062020333568"
|
||||
|
||||
# Schritt 1: Baue die Images mit der compose.build.yml
|
||||
echo "--- Building Docker images ---"
|
||||
docker compose -f compose.build.yml build
|
||||
|
||||
# Schritt 2: Starte die Container mit der compose.yml, die die gebauten Images verwendet
|
||||
echo "--- Starting containers ---"
|
||||
docker compose -f compose.yml up -d
|
||||
@@ -0,0 +1,16 @@
|
||||
# compose.build.yml
|
||||
services:
|
||||
backend:
|
||||
# Definiert, wie das Image gebaut wird
|
||||
build:
|
||||
context: ./backend
|
||||
args:
|
||||
- GIT_COMMIT_SHA=${GIT_COMMIT_SHA:-unknown}
|
||||
- SENTRY_DSN=${SENTRY_DSN:-}
|
||||
image: mrunknownde/utools-backend:latest
|
||||
|
||||
frontend:
|
||||
# Definiert, wie das Image gebaut wird
|
||||
build:
|
||||
context: ./frontend
|
||||
image: mrunknownde/utools-frontend:latest
|
||||
+26
-31
@@ -1,51 +1,46 @@
|
||||
services:
|
||||
# Backend Service (Node.js App)
|
||||
backend:
|
||||
build:
|
||||
context: ./backend # Pfad zum Verzeichnis mit dem Backend-Dockerfile
|
||||
args:
|
||||
# Übergibt den Git Commit Hash als Build-Argument.
|
||||
# Erwartet, dass GIT_COMMIT_SHA in der Shell-Umgebung gesetzt ist (z.B. export GIT_COMMIT_SHA=$(git rev-parse --short HEAD))
|
||||
- GIT_COMMIT_SHA=${GIT_COMMIT_SHA:-unknown}
|
||||
# Übergibt den Sentry DSN als Build-Argument (optional, falls im Code benötigt)
|
||||
- SENTRY_DSN="https://7ea70caba68f548fb96482a573006a7b@o447623.ingest.us.sentry.io/4509062020333568"
|
||||
container_name: utools_backend # Eindeutiger Name für den Container
|
||||
image: mrunknownde/utools-backend
|
||||
container_name: utools_backend
|
||||
restart: unless-stopped
|
||||
user: "0" # Run as root so ASN cache volume is writable
|
||||
environment:
|
||||
# Setze Umgebungsvariablen für das Backend
|
||||
NODE_ENV: production # Wichtig für Performance und Logging
|
||||
PORT: 3000 # Port innerhalb des Containers
|
||||
LOG_LEVEL: info # Oder 'warn' für weniger Logs in Produktion
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
LOG_LEVEL: info
|
||||
PING_COUNT: 4
|
||||
# Die DB-Pfade werden aus dem Backend-Dockerfile ENV genommen,
|
||||
# könnten hier aber überschrieben werden, falls nötig.
|
||||
# GEOIP_CITY_DB: ./data/GeoLite2-City.mmdb
|
||||
# GEOIP_ASN_DB: ./data/GeoLite2-ASN.mmdb
|
||||
# Sentry DSN aus der Umgebung/ .env Datei übernehmen
|
||||
SENTRY_DSN: "https://7ea70caba68f548fb96482a573006a7b@o447623.ingest.us.sentry.io/4509062020333568" # Wichtig für die Laufzeit
|
||||
SENTRY_DSN: "https://7ea70caba68f548fb96482a573006a7b@o447623.ingest.us.sentry.io/4509062020333568"
|
||||
# ASN Cache directory (filesystem persistence across restarts)
|
||||
ASN_CACHE_DIR: /app/asn-cache
|
||||
volumes:
|
||||
# Persistent ASN lookup cache — survives container restarts
|
||||
- asn_cache:/app/asn-cache
|
||||
dns:
|
||||
- 1.1.1.1 # Cloudflare DNS
|
||||
- 1.0.0.1 # Cloudflare DNS
|
||||
- 8.8.8.8 # Google DNS
|
||||
- 8.8.4.4 # Google DNS
|
||||
- 1.1.1.1
|
||||
- 1.0.0.1
|
||||
- 8.8.8.8
|
||||
- 8.8.4.4
|
||||
networks:
|
||||
- utools_network # Verbinde mit unserem benutzerdefinierten Netzwerk
|
||||
- utools_network
|
||||
|
||||
# Frontend Service (Nginx)
|
||||
frontend:
|
||||
build: ./frontend # Pfad zum Verzeichnis mit dem Frontend-Dockerfile
|
||||
image: mrunknownde/utools-frontend
|
||||
container_name: utools_frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Mappe Port 8080 vom Host auf Port 80 im Container (wo Nginx lauscht)
|
||||
# Zugriff von außen (Browser) erfolgt über localhost:8080
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- backend # Stellt sicher, dass Backend gestartet wird (aber nicht unbedingt bereit ist)
|
||||
- backend
|
||||
networks:
|
||||
- utools_network # Verbinde mit unserem benutzerdefinierten Netzwerk
|
||||
- utools_network
|
||||
|
||||
# Definiere ein benutzerdefiniertes Netzwerk (gute Praxis)
|
||||
networks:
|
||||
utools_network:
|
||||
driver: bridge # Standard-Netzwerktreiber
|
||||
driver: bridge
|
||||
|
||||
# Named volume — ASN cache persists across container restarts
|
||||
volumes:
|
||||
asn_cache:
|
||||
driver: local
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
# Aktuell nicht nötig, da wir CDN/statische Dateien haben.
|
||||
|
||||
# Stage 2: Production Environment using Nginx
|
||||
FROM nginx:1.25-alpine
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
# Arbeitsverzeichnis im Container (optional, aber gute Praxis)
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DNS Lookup - uTools</title>
|
||||
<!-- Tailwind CSS Play CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Eigene Styles -->
|
||||
<style>
|
||||
/* Einfacher Lade-Spinner */
|
||||
.loader {
|
||||
border: 4px solid rgba(168, 85, 247, 0.3); /* Lila transparent */
|
||||
border-left-color: #a855f7; /* Lila */
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
/* Ergebnis-Pre-Formatierung */
|
||||
.result-pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
background-color: #1f2937; /* Dunkelgrau */
|
||||
color: #d1d5db; /* Hellgrau */
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem; /* rounded-md */
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
}
|
||||
/* Navigations-Styling */
|
||||
nav ul { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 1rem; }
|
||||
nav a { color: #c4b5fd; text-decoration: none; white-space: nowrap; }
|
||||
nav a:hover { color: #a78bfa; text-decoration: underline; }
|
||||
header { background-color: #374151; padding: 1rem; margin-bottom: 1.5rem; border-radius: 0.5rem; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
|
||||
@media (min-width: 768px) { header { flex-direction: row; justify-content: space-between; } }
|
||||
header h1 { font-size: 1.5rem; font-weight: bold; color: #e5e7eb; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-200 font-sans p-4 md:p-8">
|
||||
|
||||
<header>
|
||||
<h1>uTools Network Suite</h1>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="index.html">IP Info & Tools</a></li>
|
||||
<li><a href="subnet-calculator.html">Subnetz Rechner</a></li>
|
||||
<li><a href="dns-lookup.html">DNS Lookup</a></li>
|
||||
<li><a href="whois-lookup.html">WHOIS Lookup</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="container mx-auto max-w-4xl bg-gray-800 rounded-lg shadow-xl p-6">
|
||||
|
||||
<h1 class="text-3xl font-bold mb-6 text-purple-400 text-center">DNS Lookup</h1>
|
||||
|
||||
<!-- Bereich für DNS Lookup -->
|
||||
<div class="mt-8 p-4 bg-gray-700 rounded">
|
||||
<div class="flex flex-col sm:flex-row gap-2 mb-4">
|
||||
<input type="text" id="dns-domain-input" placeholder="Enter domain name (e.g., google.com)"
|
||||
class="flex-grow px-3 py-2 bg-gray-800 border border-gray-600 rounded text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono">
|
||||
<select id="dns-type-select" class="px-3 py-2 bg-gray-800 border border-gray-600 rounded text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="ANY">ANY</option>
|
||||
<option value="A">A</option>
|
||||
<option value="AAAA">AAAA</option>
|
||||
<option value="MX">MX</option>
|
||||
<option value="TXT">TXT</option>
|
||||
<option value="NS">NS</option>
|
||||
<option value="CNAME">CNAME</option>
|
||||
<option value="SOA">SOA</option>
|
||||
<option value="SRV">SRV</option>
|
||||
<option value="PTR">PTR (Reverse)</option>
|
||||
</select>
|
||||
<button id="dns-lookup-button"
|
||||
class="bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded transition duration-150 ease-in-out">
|
||||
Lookup DNS
|
||||
</button>
|
||||
</div>
|
||||
<div id="dns-lookup-error" class="text-red-400 mb-4 hidden"></div>
|
||||
<div id="dns-lookup-results-section" class="hidden mt-4 border-t border-gray-600 pt-4">
|
||||
<h3 class="text-lg font-semibold text-purple-300 mb-2">DNS Results for: <span id="dns-lookup-query" class="font-mono text-purple-400"></span></h3>
|
||||
<div id="dns-lookup-loader" class="loader hidden mb-2"></div>
|
||||
<pre id="dns-lookup-output" class="result-pre"></pre> <!-- Ergebnisbereich -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Globaler Fehlerbereich -->
|
||||
<div id="global-error" class="mt-6 p-4 bg-red-800 text-red-100 rounded hidden"></div>
|
||||
|
||||
<!-- Footer für Version -->
|
||||
<footer class="mt-8 pt-4 border-t border-gray-600 text-center text-xs text-gray-500">
|
||||
<p>© 2025 <a href="https://johanneskr.de" class="text-purple-400 hover:underline">Johannes Krüger</a></p>
|
||||
<p>Version: <span id="commit-sha" class="font-mono">loading...</span></p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Eigene JS-Logik für diese Seite -->
|
||||
<script src="dns-lookup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,132 +0,0 @@
|
||||
// frontend/dns-lookup.js
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- DOM Elements (DNS Lookup) ---
|
||||
const dnsDomainInput = document.getElementById('dns-domain-input');
|
||||
const dnsTypeSelect = document.getElementById('dns-type-select');
|
||||
const dnsLookupButton = document.getElementById('dns-lookup-button');
|
||||
const dnsLookupErrorEl = document.getElementById('dns-lookup-error');
|
||||
const dnsLookupResultsSection = document.getElementById('dns-lookup-results-section');
|
||||
const dnsLookupQueryEl = document.getElementById('dns-lookup-query');
|
||||
const dnsLookupLoader = document.getElementById('dns-lookup-loader');
|
||||
const dnsLookupOutputEl = document.getElementById('dns-lookup-output');
|
||||
|
||||
// --- DOM Elements (Common) ---
|
||||
const globalErrorEl = document.getElementById('global-error');
|
||||
const commitShaEl = document.getElementById('commit-sha');
|
||||
|
||||
// --- Configuration ---
|
||||
const API_BASE_URL = '/api'; // Anpassen, falls nötig
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
/** Zeigt globale Fehler an */
|
||||
function showGlobalError(message) {
|
||||
if (!globalErrorEl) return;
|
||||
globalErrorEl.textContent = `Error: ${message}`;
|
||||
globalErrorEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/** Versteckt globale Fehler */
|
||||
function hideGlobalError() {
|
||||
if (!globalErrorEl) return;
|
||||
globalErrorEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generische Funktion zum Abrufen und Anzeigen von Lookup-Ergebnissen.
|
||||
* @param {string} endpoint - Der API-Endpunkt (z.B. '/dns-lookup').
|
||||
* @param {object} params - Query-Parameter als Objekt (z.B. { domain: '...', type: '...' }).
|
||||
* @param {HTMLElement} resultsSection - Der Container für die Ergebnisse.
|
||||
* @param {HTMLElement} loaderElement - Das Loader-Element.
|
||||
* @param {HTMLElement} errorElement - Das Fehleranzeige-Element für diesen Lookup.
|
||||
* @param {HTMLElement} queryElement - Das Element zur Anzeige der Suchanfrage.
|
||||
* @param {HTMLElement} outputElement - Das Element zur Anzeige der Ergebnisse (<pre> oder <p>).
|
||||
* @param {function} displayFn - Funktion zur Formatierung und Anzeige der Daten im outputElement.
|
||||
*/
|
||||
async function fetchAndDisplay(endpoint, params, resultsSection, loaderElement, errorElement, queryElement, outputElement, displayFn) {
|
||||
resultsSection.classList.remove('hidden');
|
||||
loaderElement.classList.remove('hidden');
|
||||
errorElement.classList.add('hidden');
|
||||
outputElement.textContent = ''; // Clear previous results
|
||||
if (queryElement) queryElement.textContent = Object.values(params).join(', '); // Display query
|
||||
hideGlobalError(); // Hide global errors before new request
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
const url = `${API_BASE_URL}${endpoint}?${urlParams.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
console.log(`Received ${endpoint} data:`, data);
|
||||
displayFn(data, outputElement); // Call the specific display function
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch ${endpoint}:`, error);
|
||||
errorElement.textContent = `Error: ${error.message}`;
|
||||
errorElement.classList.remove('hidden');
|
||||
outputElement.textContent = ''; // Clear output on error
|
||||
} finally {
|
||||
loaderElement.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/** Ruft die Versionsinformationen (Commit SHA) ab */
|
||||
async function fetchVersionInfo() {
|
||||
if (!commitShaEl) return; // Don't fetch if element doesn't exist
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/version`);
|
||||
if (!response.ok) throw new Error(`Network response: ${response.statusText} (${response.status})`);
|
||||
const data = await response.json();
|
||||
commitShaEl.textContent = data.commitSha || 'unknown';
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version info:', error);
|
||||
commitShaEl.textContent = 'error';
|
||||
// Optionally show global error
|
||||
// showGlobalError(`Could not load version info: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- DNS Lookup Specific Functions ---
|
||||
function displayDnsResults(data, outputEl) {
|
||||
if (!data.records || Object.keys(data.records).length === 0) {
|
||||
outputEl.textContent = 'No records found for this domain and type.';
|
||||
return;
|
||||
}
|
||||
// Format output as JSON string for simplicity
|
||||
outputEl.textContent = JSON.stringify(data.records, null, 2);
|
||||
}
|
||||
|
||||
function handleDnsLookupClick() {
|
||||
const domain = dnsDomainInput.value.trim();
|
||||
const type = dnsTypeSelect.value;
|
||||
if (!domain) {
|
||||
dnsLookupErrorEl.textContent = 'Please enter a domain name.';
|
||||
dnsLookupErrorEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
fetchAndDisplay(
|
||||
'/dns-lookup',
|
||||
{ domain, type },
|
||||
dnsLookupResultsSection,
|
||||
dnsLookupLoader,
|
||||
dnsLookupErrorEl,
|
||||
dnsLookupQueryEl,
|
||||
dnsLookupOutputEl,
|
||||
displayDnsResults
|
||||
);
|
||||
}
|
||||
|
||||
// --- Initial Load & Event Listeners ---
|
||||
fetchVersionInfo(); // Lade Versionsinfo für Footer
|
||||
|
||||
dnsLookupButton.addEventListener('click', handleDnsLookupClick);
|
||||
dnsDomainInput.addEventListener('keypress', (event) => {
|
||||
if (event.key === 'Enter') handleDnsLookupClick();
|
||||
});
|
||||
|
||||
}); // End DOMContentLoaded
|
||||
+41
-253
@@ -1,261 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IP Info & Network Tools - uTools</title> <!-- Titel angepasst -->
|
||||
<!-- Tailwind CSS Play CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin=""/>
|
||||
<!-- Eigene Styles -->
|
||||
<style>
|
||||
/* Container für Karten müssen eine Höhe haben */
|
||||
#map { height: 300px; }
|
||||
#lookup-map { height: 250px; } /* Höhe für Lookup-Karte */
|
||||
|
||||
/* Einfacher Lade-Spinner (Tailwind animiert) */
|
||||
.loader {
|
||||
border: 4px solid rgba(168, 85, 247, 0.3); /* Lila transparent */
|
||||
border-left-color: #a855f7; /* Lila */
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Basis für Glitch-Effekt (Beispiel: Text-Schatten) */
|
||||
.glitch-text:hover {
|
||||
text-shadow:
|
||||
1px 1px 0px rgba(168, 85, 247, 0.7), /* Lila */
|
||||
-1px -1px 0px rgba(76, 29, 149, 0.7); /* Dunkleres Lila */
|
||||
}
|
||||
/* Klickbarer IP-Cursor und Link-Styling */
|
||||
#ip-address-link {
|
||||
cursor: pointer;
|
||||
text-decoration: none; /* Standard-Link-Unterstreichung entfernen */
|
||||
}
|
||||
#ip-address-link:hover {
|
||||
text-decoration: underline; /* Unterstreichung beim Hover */
|
||||
}
|
||||
|
||||
/* Traceroute Output Formatierung */
|
||||
#traceroute-output pre, .result-pre { /* Gemeinsamer Stil für <pre> */
|
||||
white-space: pre-wrap; /* Zeilenumbruch */
|
||||
word-break: break-all; /* Lange Zeilen umbrechen */
|
||||
font-family: monospace;
|
||||
background-color: #1f2937; /* Dunkelgrau */
|
||||
color: #d1d5db; /* Hellgrau */
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem; /* rounded-md */
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
}
|
||||
#traceroute-output .hop-line { margin-bottom: 0.25rem; }
|
||||
#traceroute-output .hop-number { display: inline-block; width: 30px; text-align: right; margin-right: 10px; color: #9ca3af; } /* Grau */
|
||||
#traceroute-output .hop-ip { color: #60a5fa; } /* Blau */
|
||||
#traceroute-output .hop-hostname { color: #a78bfa; } /* Lila */
|
||||
#traceroute-output .hop-rtt { color: #34d399; margin-left: 5px;} /* Grün */
|
||||
#traceroute-output .hop-timeout { color: #f87171; } /* Rot */
|
||||
#traceroute-output .info-line { color: #fbbf24; font-style: italic;} /* Gelb */
|
||||
#traceroute-output .error-line { color: #f87171; font-weight: bold;} /* Rot */
|
||||
#traceroute-output .end-line { color: #a78bfa; font-weight: bold; margin-top: 10px;} /* Lila */
|
||||
|
||||
/* Navigations-Styling */
|
||||
nav ul { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 1rem; } /* flex-wrap hinzugefügt */
|
||||
nav a { color: #c4b5fd; /* purple-300 */ text-decoration: none; white-space: nowrap; } /* nowrap hinzugefügt */
|
||||
nav a:hover { color: #a78bfa; /* purple-400 */ text-decoration: underline; }
|
||||
header { background-color: #374151; /* gray-700 */ padding: 1rem; margin-bottom: 1.5rem; border-radius: 0.5rem; /* rounded-lg */ display: flex; flex-direction: column; align-items: center; gap: 0.5rem; } /* Flex direction geändert */
|
||||
@media (min-width: 768px) { /* md breakpoint */
|
||||
header { flex-direction: row; justify-content: space-between; }
|
||||
}
|
||||
header h1 { font-size: 1.5rem; /* text-2xl */ font-weight: bold; color: #e5e7eb; /* gray-200 */ }
|
||||
|
||||
/* Hilfsklasse zum Verstecken */
|
||||
.hidden { display: none; }
|
||||
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>uTools – Network Suite</title>
|
||||
<link rel="icon" href="https://mrunk.de/pic/favicon/favicon.svg" type="image/svg+xml">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
||||
<link rel="stylesheet" href="shared.css">
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-200 font-sans p-4 md:p-8">
|
||||
|
||||
<header>
|
||||
<h1>uTools Network Suite</h1> <!-- Name angepasst -->
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="index.html">IP Info & Tools</a></li> <!-- Angepasst -->
|
||||
<li><a href="subnet-calculator.html">Subnetz Rechner</a></li>
|
||||
<li><a href="dns-lookup.html">DNS Lookup</a></li>
|
||||
<li><a href="whois-lookup.html">WHOIS Lookup</a></li>
|
||||
<!-- REMOVED: MAC Lookup Link -->
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="container mx-auto max-w-4xl bg-gray-800 rounded-lg shadow-xl p-6">
|
||||
|
||||
<h1 class="text-3xl font-bold mb-6 text-purple-400 glitch-text text-center">IP Information</h1> <!-- Titel angepasst -->
|
||||
|
||||
<!-- Bereich für EIGENE IP-Infos -->
|
||||
<div id="info-section" class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
|
||||
<!-- Linke Spalte: Eigene IP, Geo, ASN, rDNS -->
|
||||
<div class="space-y-4 p-4 bg-gray-700 rounded">
|
||||
<h2 class="text-xl font-semibold text-purple-300 border-b border-purple-500 pb-1">Your Public IP</h2>
|
||||
<div id="ip-info" class="min-h-[50px]">
|
||||
<div id="ip-loader" class="loader"></div>
|
||||
<!-- Geändert zu <a> Tag -->
|
||||
<a id="ip-address-link" href="#" class="text-2xl font-mono font-bold text-purple-400 break-all hidden" title="Go to WHOIS Lookup for this IP">
|
||||
<span id="ip-address"></span> <!-- Span für den eigentlichen Text -->
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold text-purple-300 border-b border-purple-500 pb-1">Geolocation</h2>
|
||||
<div id="geo-info" class="min-h-[100px] space-y-1 text-sm">
|
||||
<div id="geo-loader" class="loader"></div>
|
||||
<div class="hidden"> <!-- Hide data until loaded -->
|
||||
<p><strong>Country:</strong> <span id="country">-</span></p>
|
||||
<p><strong>Region:</strong> <span id="region">-</span></p>
|
||||
<p><strong>City:</strong> <span id="city">-</span></p>
|
||||
<p><strong>Postal Code:</strong> <span id="postal">-</span></p>
|
||||
<p><strong>Coordinates:</strong> <span id="coords">-</span></p>
|
||||
<p><strong>Timezone:</strong> <span id="timezone">-</span></p>
|
||||
<p id="geo-error" class="text-red-400"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold text-purple-300 border-b border-purple-500 pb-1">ASN</h2>
|
||||
<div id="asn-info" class="min-h-[50px] space-y-1 text-sm">
|
||||
<div id="asn-loader" class="loader"></div>
|
||||
<div class="hidden"> <!-- Hide data until loaded -->
|
||||
<p><strong>Number:</strong> <span id="asn-number">-</span></p>
|
||||
<p><strong>Organization:</strong> <span id="asn-org">-</span></p>
|
||||
<p id="asn-error" class="text-red-400"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold text-purple-300 border-b border-purple-500 pb-1">Reverse DNS (rDNS)</h2>
|
||||
<div id="rdns-info" class="min-h-[50px] space-y-1 text-sm">
|
||||
<div id="rdns-loader" class="loader"></div>
|
||||
<div class="hidden"> <!-- Hide data until loaded -->
|
||||
<ul id="rdns-list" class="list-disc list-inside"><li>-</li></ul>
|
||||
<p id="rdns-error" class="text-red-400"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechte Spalte: Eigene Karte -->
|
||||
<div class="space-y-4 p-4 bg-gray-700 rounded">
|
||||
<h2 class="text-xl font-semibold text-purple-300 border-b border-purple-500 pb-1">Your Location Map</h2>
|
||||
<div id="map-container" class="bg-gray-600 rounded min-h-[300px] flex items-center justify-center relative">
|
||||
<div id="map-loader" class="loader absolute"></div>
|
||||
<div id="map" class="w-full rounded hidden"></div>
|
||||
<p id="map-message" class="text-gray-400 hidden absolute">Could not load map.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bereich für IP Lookup -->
|
||||
<div class="mt-8 p-4 bg-gray-700 rounded">
|
||||
<h2 class="text-xl font-semibold text-purple-300 border-b border-purple-500 pb-1 mb-4">IP Address Lookup</h2>
|
||||
<div class="flex flex-col sm:flex-row gap-2 mb-4">
|
||||
<input type="text" id="lookup-ip-input" placeholder="Enter IP address (e.g., 8.8.8.8)"
|
||||
class="flex-grow px-3 py-2 bg-gray-800 border border-gray-600 rounded text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono">
|
||||
<button id="lookup-button"
|
||||
class="bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded transition duration-150 ease-in-out">
|
||||
Lookup IP
|
||||
</button>
|
||||
</div>
|
||||
<div id="lookup-error" class="text-red-400 mb-4 hidden"></div>
|
||||
|
||||
<!-- Ergebnisse des Lookups (initial versteckt) -->
|
||||
<div id="lookup-results-section" class="hidden grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 border-t border-gray-600 pt-4">
|
||||
<!-- Linke Spalte: IP, Geo, ASN, rDNS -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-purple-300">Lookup Result for: <span id="lookup-ip-address" class="font-mono text-purple-400"></span></h3>
|
||||
<div id="lookup-result-loader" class="loader hidden"></div> <!-- Loader für den gesamten Block -->
|
||||
|
||||
<div id="lookup-geo-info" class="space-y-1 text-sm">
|
||||
<h4 class="font-semibold text-purple-300">Geolocation</h4>
|
||||
<p><strong>Country:</strong> <span id="lookup-country">-</span></p>
|
||||
<p><strong>Region:</strong> <span id="lookup-region">-</span></p>
|
||||
<p><strong>City:</strong> <span id="lookup-city">-</span></p>
|
||||
<p><strong>Postal Code:</strong> <span id="lookup-postal">-</span></p>
|
||||
<p><strong>Coordinates:</strong> <span id="lookup-coords">-</span></p>
|
||||
<p><strong>Timezone:</strong> <span id="lookup-timezone">-</span></p>
|
||||
<p id="lookup-geo-error" class="text-red-400"></p>
|
||||
</div>
|
||||
|
||||
<div id="lookup-asn-info" class="space-y-1 text-sm">
|
||||
<h4 class="font-semibold text-purple-300">ASN</h4>
|
||||
<p><strong>Number:</strong> <span id="lookup-asn-number">-</span></p>
|
||||
<p><strong>Organization:</strong> <span id="lookup-asn-org">-</span></p>
|
||||
<p id="lookup-asn-error" class="text-red-400"></p>
|
||||
</div>
|
||||
|
||||
<div id="lookup-rdns-info" class="space-y-1 text-sm">
|
||||
<h4 class="font-semibold text-purple-300">Reverse DNS (rDNS)</h4>
|
||||
<ul id="lookup-rdns-list" class="list-disc list-inside"><li>-</li></ul>
|
||||
<p id="lookup-rdns-error" class="text-red-400"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechte Spalte: Karte & Aktionen -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold text-purple-300">Location Map</h4>
|
||||
<div id="lookup-map-container" class="bg-gray-600 rounded min-h-[250px] flex items-center justify-center relative">
|
||||
<div id="lookup-map-loader" class="loader hidden absolute"></div>
|
||||
<div id="lookup-map" class="w-full rounded hidden"></div> <!-- Höhe via CSS -->
|
||||
<p id="lookup-map-message" class="text-gray-400 hidden absolute">Could not load map.</p>
|
||||
</div>
|
||||
<!-- Optional: Buttons für Ping/Trace für diese IP -->
|
||||
<div class="mt-4 space-x-2">
|
||||
<button id="lookup-ping-button" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded text-sm transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed" disabled>Ping this IP</button>
|
||||
<button id="lookup-trace-button" class="bg-teal-600 hover:bg-teal-700 text-white font-bold py-1 px-3 rounded text-sm transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed" disabled>Trace this IP</button>
|
||||
</div>
|
||||
<!-- Bereich für Ping-Ergebnisse (Lookup) -->
|
||||
<div id="lookup-ping-results" class="mt-2 text-sm hidden">
|
||||
<h4 class="font-semibold text-purple-300">Ping Results</h4>
|
||||
<div id="lookup-ping-loader" class="loader hidden"></div>
|
||||
<pre id="lookup-ping-output" class="mt-1 whitespace-pre-wrap break-all font-mono bg-gray-900 text-gray-300 p-2 rounded text-xs"></pre>
|
||||
<p id="lookup-ping-error" class="text-red-400"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bereich für Traceroute -->
|
||||
<div id="traceroute-section" class="mt-8 p-4 bg-gray-700 rounded hidden">
|
||||
<h2 class="text-xl font-semibold text-purple-300 border-b border-purple-500 pb-1 mb-4">Traceroute Results</h2>
|
||||
<div id="traceroute-status" class="flex items-center mb-2">
|
||||
<div id="traceroute-loader" class="loader mr-2 hidden"></div>
|
||||
<span id="traceroute-message" class="text-gray-400"></span>
|
||||
</div>
|
||||
<div id="traceroute-output"><pre></pre></div>
|
||||
</div>
|
||||
|
||||
<!-- Globaler Fehlerbereich -->
|
||||
<div id="global-error" class="mt-6 p-4 bg-red-800 text-red-100 rounded hidden"></div>
|
||||
|
||||
<!-- Footer für Version -->
|
||||
<footer class="mt-8 pt-4 border-t border-gray-600 text-center text-xs text-gray-500">
|
||||
<p>© 2025 <a href="https://johanneskr.de" class="text-purple-400 hover:underline">Johannes Krüger</a></p>
|
||||
<p>Version: <span id="commit-sha" class="font-mono">loading...</span></p>
|
||||
</footer>
|
||||
|
||||
<body class="bg-gray-950 text-gray-100 font-sans p-4 md:p-8 min-h-screen bg-[url('https://tailwindcss.com/_next/static/media/hero-dark.939eb757.png')] bg-cover bg-center bg-fixed selection:bg-purple-500 selection:text-white">
|
||||
|
||||
<header class="glass-panel">
|
||||
<div class="header-top">
|
||||
<h1 style="background:linear-gradient(to right,#c084fc,#e879f9);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent" class="text-2xl font-bold">
|
||||
uTools <span style="-webkit-text-fill-color:#9ca3af" class="text-sm font-normal tracking-wider uppercase ml-2">Network Suite</span>
|
||||
</h1>
|
||||
<button id="nav-toggle" class="nav-toggle" aria-label="Toggle navigation" aria-expanded="false">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
<nav id="main-nav">
|
||||
<ul>
|
||||
<li><a href="/">IP Info & Tools</a></li>
|
||||
<li><a href="/subnet">Subnetz Rechner</a></li>
|
||||
<li><a href="/dns">DNS Lookup</a></li>
|
||||
<li><a href="/whois">WHOIS Lookup</a></li>
|
||||
<li><a href="/mac">MAC Lookup</a></li>
|
||||
<li><a href="/asn">ASN Lookup</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""></script>
|
||||
<!-- Eigene JS-Logik -->
|
||||
<script src="script.js"></script>
|
||||
<div id="app"></div>
|
||||
|
||||
<footer class="mt-8 pb-4 text-center text-xs text-gray-500">
|
||||
<p>© 2026 <a href="https://mrunk.de" class="text-purple-400 hover:text-purple-300 transition-colors">MrUnknownDE</a>
|
||||
· Version: <span id="commit-sha" class="font-mono text-gray-400">loading…</span></p>
|
||||
</footer>
|
||||
|
||||
<!-- Global libs loaded once for all pages -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||||
<script type="module" src="router.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import { API } from '../shared.js';
|
||||
|
||||
export const page = {
|
||||
title: 'ASN Lookup',
|
||||
|
||||
template: () => `
|
||||
<div class="container mx-auto max-w-6xl glass-panel rounded-xl shadow-2xl p-6 md:p-8 backdrop-blur-xl border border-gray-800/50">
|
||||
<h1 class="text-3xl font-bold mb-2 text-center text-gradient">AS / ASN Lookup</h1>
|
||||
<p class="text-center text-gray-400 text-sm mb-8">Peering graph, prefixes & IXP connections for any Autonomous System</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-6 max-w-2xl mx-auto">
|
||||
<input type="text" id="asn-input" placeholder="Enter ASN (e.g. 15169 or AS3320)"
|
||||
class="flex-grow px-4 py-3 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono transition-all placeholder-gray-600">
|
||||
<button id="lookup-button" disabled
|
||||
class="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold py-3 px-6 rounded-lg shadow-lg transition-all duration-200">
|
||||
Lookup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="error-box" class="hidden max-w-2xl mx-auto mb-6 p-4 bg-red-900/30 border border-red-500/40 text-red-300 rounded-lg text-sm"></div>
|
||||
|
||||
<div id="loading-section" class="hidden flex flex-col items-center gap-3 py-16">
|
||||
<div class="loader" style="width:40px;height:40px;border-width:5px;"></div>
|
||||
<p class="text-gray-400 text-sm" id="loading-msg">Querying RIPE Stat & PeeringDB…</p>
|
||||
<p class="text-xs text-amber-400/80 bg-amber-400/10 border border-amber-400/20 rounded-lg px-4 py-2 max-w-sm text-center mt-1">
|
||||
⏳ Large ASes (Cloudflare, Google, Tier-1 carriers) can take up to 15 s on first lookup — subsequent lookups are cached for 7 days.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="results-section" class="hidden fade-in">
|
||||
<div class="glass-card rounded-xl p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span id="res-asn" class="font-mono text-2xl font-bold text-purple-400"></span>
|
||||
<span id="res-announced-badge" class="hidden text-xs px-2 py-0.5 bg-green-500/20 border border-green-500/40 text-green-400 rounded-full">Announced</span>
|
||||
<span id="res-type-badge" class="text-xs px-2 py-0.5 bg-blue-500/20 border border-blue-500/40 text-blue-300 rounded-full"></span>
|
||||
</div>
|
||||
<h2 id="res-name" class="text-xl font-semibold text-white mb-1"></h2>
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-400 mb-2">
|
||||
<span id="res-policy-container" class="hidden">Peering Policy: <span id="res-policy" class="text-gray-200"></span></span>
|
||||
<span id="res-website-container" class="hidden">Website: <a id="res-website" href="#" target="_blank" rel="noopener" class="text-purple-400 hover:text-purple-300 transition-colors"></a></span>
|
||||
</div>
|
||||
<div id="res-rich-info" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4 pt-4 border-t border-gray-700/50 hidden">
|
||||
<div id="res-info-type-container" class="hidden"><div class="text-xs text-gray-500 uppercase tracking-widest">Type</div><div id="res-info-type" class="text-sm text-gray-200 capitalize mt-0.5"></div></div>
|
||||
<div id="res-info-scope-container" class="hidden"><div class="text-xs text-gray-500 uppercase tracking-widest">Scope</div><div id="res-info-scope" class="text-sm text-gray-200 capitalize mt-0.5"></div></div>
|
||||
<div id="res-info-traffic-container" class="hidden"><div class="text-xs text-gray-500 uppercase tracking-widest">Traffic</div><div id="res-info-traffic" class="text-sm text-gray-200 capitalize mt-0.5"></div></div>
|
||||
<div id="res-info-ratio-container" class="hidden"><div class="text-xs text-gray-500 uppercase tracking-widest">Ratio</div><div id="res-info-ratio" class="text-sm text-gray-200 capitalize mt-0.5"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 text-center">
|
||||
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3">
|
||||
<div id="res-upstream-count" class="text-xl font-bold text-blue-400">—</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">Upstreams</div>
|
||||
</div>
|
||||
<div class="bg-green-500/10 border border-green-500/20 rounded-lg p-3">
|
||||
<div id="res-downstream-count" class="text-xl font-bold text-green-400">—</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">Downstreams</div>
|
||||
</div>
|
||||
<div class="bg-purple-500/10 border border-purple-500/20 rounded-lg p-3 col-span-2 sm:col-span-1">
|
||||
<div id="res-prefix-count" class="text-xl font-bold text-purple-400">—</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">Prefixes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card rounded-xl p-6 mb-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<h3 class="text-lg font-bold text-purple-300">Network Map</h3>
|
||||
<div class="flex gap-3 text-xs text-gray-400">
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-3 h-3 rounded-full bg-gray-500"></span>Tier-1</span>
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-3 h-3 rounded-full bg-blue-500"></span>Upstream</span>
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-3 h-3 rounded-full bg-purple-500"></span>This AS</span>
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-3 h-3 rounded-full bg-green-500"></span>Downstream</span>
|
||||
</div>
|
||||
<span class="ml-auto text-xs text-gray-500">Scroll to zoom · Drag to pan · Click node to open</span>
|
||||
</div>
|
||||
<div id="graph-container">
|
||||
<svg id="graph-svg"></svg>
|
||||
<div id="graph-tooltip"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div class="glass-card rounded-xl p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-bold text-gray-400 uppercase tracking-widest">Announced Prefixes</h3>
|
||||
<button id="prefix-toggle" class="text-xs text-purple-400 hover:text-purple-300 transition-colors">Show all</button>
|
||||
</div>
|
||||
<div id="prefix-list" class="max-h-48 overflow-y-auto"></div>
|
||||
<p id="prefix-empty" class="hidden text-sm text-gray-500 italic">No prefix data available.</p>
|
||||
</div>
|
||||
<div class="glass-card rounded-xl p-5">
|
||||
<h3 class="text-sm font-bold text-gray-400 uppercase tracking-widest mb-3">IXP Presence <span class="text-xs font-normal text-gray-500">(via PeeringDB)</span></h3>
|
||||
<div id="ixp-list" class="space-y-1 text-sm max-h-48 overflow-y-auto">
|
||||
<p id="ixp-empty" class="text-gray-500 italic text-sm">Not listed on PeeringDB.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card rounded-xl p-5">
|
||||
<h3 class="text-sm font-bold text-gray-400 uppercase tracking-widest mb-3">Direct Neighbours <span class="text-xs font-normal text-gray-500">(Level 2 · via RIPE Stat)</span></h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-blue-400 mb-2">↑ Upstreams (Transit Providers)</h4>
|
||||
<div id="upstream-table" class="space-y-1 text-xs font-mono max-h-52 overflow-y-auto"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-green-400 mb-2">↓ Downstreams (Customers)</h4>
|
||||
<div id="downstream-table" class="space-y-1 text-xs font-mono max-h-52 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
async init(search) {
|
||||
const asnInput = document.getElementById('asn-input');
|
||||
const lookupButton = document.getElementById('lookup-button');
|
||||
const errorBox = document.getElementById('error-box');
|
||||
const loadingSection = document.getElementById('loading-section');
|
||||
const resultsSection = document.getElementById('results-section');
|
||||
|
||||
let currentData = null;
|
||||
let showAllPrefixes = false;
|
||||
|
||||
const syncBtn = () => { lookupButton.disabled = !asnInput.value.trim(); };
|
||||
asnInput.addEventListener('input', syncBtn);
|
||||
|
||||
function showError(msg) {
|
||||
errorBox.textContent = msg;
|
||||
errorBox.classList.toggle('hidden', !msg);
|
||||
loadingSection.classList.add('hidden');
|
||||
resultsSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
function setLoading(msg) {
|
||||
errorBox.classList.add('hidden');
|
||||
document.getElementById('loading-msg').textContent = msg;
|
||||
loadingSection.classList.remove('hidden');
|
||||
resultsSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
async function doLookup(rawAsn) {
|
||||
const asn = String(rawAsn || '').trim().toUpperCase().replace(/^AS/, '');
|
||||
if (!asn || isNaN(Number(asn))) { showError('Please enter a valid ASN (e.g. 15169 or AS3320).'); return; }
|
||||
|
||||
setLoading('Querying RIPE Stat & PeeringDB…');
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('asn', asn);
|
||||
history.replaceState({}, '', url);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/asn-lookup?asn=${encodeURIComponent(asn)}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) { showError(data.error || `Request failed (HTTP ${res.status})`); return; }
|
||||
currentData = data;
|
||||
renderResults(data);
|
||||
} catch (err) {
|
||||
showError(`Network error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderResults(data) {
|
||||
loadingSection.classList.add('hidden');
|
||||
resultsSection.classList.remove('hidden');
|
||||
|
||||
document.getElementById('res-asn').textContent = `AS${data.asn}`;
|
||||
document.getElementById('res-name').textContent = data.name || 'Unknown';
|
||||
|
||||
const announcedBadge = document.getElementById('res-announced-badge');
|
||||
announcedBadge.classList.toggle('hidden', !data.announced);
|
||||
|
||||
const typeBadge = document.getElementById('res-type-badge');
|
||||
typeBadge.textContent = data.type || '';
|
||||
typeBadge.classList.toggle('hidden', !data.type);
|
||||
|
||||
const policyContainer = document.getElementById('res-policy-container');
|
||||
const policyEl = document.getElementById('res-policy');
|
||||
if (data.peeringdb?.peeringPolicy) {
|
||||
policyEl.textContent = data.peeringdb.peeringPolicy;
|
||||
policyContainer.classList.remove('hidden');
|
||||
} else { policyContainer.classList.add('hidden'); }
|
||||
|
||||
const websiteContainer = document.getElementById('res-website-container');
|
||||
const websiteEl = document.getElementById('res-website');
|
||||
if (data.peeringdb?.website) {
|
||||
websiteEl.href = data.peeringdb.website;
|
||||
websiteEl.textContent = data.peeringdb.website.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
websiteContainer.classList.remove('hidden');
|
||||
} else { websiteContainer.classList.add('hidden'); }
|
||||
|
||||
const richInfo = document.getElementById('res-rich-info');
|
||||
let hasRich = false;
|
||||
[['type', data.peeringdb?.infoType], ['scope', data.peeringdb?.infoScope],
|
||||
['traffic', data.peeringdb?.infoTraffic], ['ratio', data.peeringdb?.infoRatio]].forEach(([id, val]) => {
|
||||
const c = document.getElementById(`res-info-${id}-container`);
|
||||
const e = document.getElementById(`res-info-${id}`);
|
||||
if (c && e) { if (val) { e.textContent = val; c.classList.remove('hidden'); hasRich = true; } else c.classList.add('hidden'); }
|
||||
});
|
||||
richInfo.classList.toggle('hidden', !hasRich);
|
||||
|
||||
document.getElementById('res-upstream-count').textContent = data.graph?.level2?.upstreams?.length ?? '?';
|
||||
document.getElementById('res-downstream-count').textContent = data.graph?.level2?.downstreams?.length ?? '?';
|
||||
document.getElementById('res-prefix-count').textContent = data.prefixes?.length ?? '?';
|
||||
|
||||
renderPrefixes(data.prefixes);
|
||||
renderIxps(data.peeringdb?.ixps);
|
||||
renderNeighbourTable('upstream-table', data.graph?.level2?.upstreams ?? [], 'blue');
|
||||
renderNeighbourTable('downstream-table', data.graph?.level2?.downstreams ?? [], 'green');
|
||||
if (data.graph) renderGraph(data.graph);
|
||||
}
|
||||
|
||||
function renderPrefixes(prefixes) {
|
||||
const list = document.getElementById('prefix-list');
|
||||
const empty = document.getElementById('prefix-empty');
|
||||
const toggle = document.getElementById('prefix-toggle');
|
||||
if (!prefixes?.length) { list.classList.add('hidden'); empty.classList.remove('hidden'); toggle.classList.add('hidden'); return; }
|
||||
empty.classList.add('hidden'); toggle.classList.remove('hidden');
|
||||
const toShow = showAllPrefixes ? prefixes : prefixes.slice(0, 20);
|
||||
list.innerHTML = toShow.map(p => `<span class="prefix-tag">${p}</span>`).join('');
|
||||
toggle.textContent = showAllPrefixes ? 'Show less' : `Show all (${prefixes.length})`;
|
||||
}
|
||||
|
||||
document.getElementById('prefix-toggle').addEventListener('click', () => {
|
||||
showAllPrefixes = !showAllPrefixes;
|
||||
if (currentData) renderPrefixes(currentData.prefixes);
|
||||
});
|
||||
|
||||
function renderIxps(ixps) {
|
||||
const list = document.getElementById('ixp-list');
|
||||
const empty = document.getElementById('ixp-empty');
|
||||
if (!ixps?.length) { list.innerHTML = ''; empty.classList.remove('hidden'); return; }
|
||||
empty.classList.add('hidden');
|
||||
list.innerHTML = ixps.map(ix => `
|
||||
<div class="ixp-row py-1.5 flex items-center justify-between gap-2 text-sm">
|
||||
<span class="text-gray-200 truncate">${ix.name}</span>
|
||||
<span class="text-xs text-gray-500 shrink-0">${ix.speed >= 1000 ? ix.speed / 1000 + 'G' : ix.speed + 'M'}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderNeighbourTable(elId, nodes, colour) {
|
||||
const el = document.getElementById(elId);
|
||||
if (!nodes?.length) { el.innerHTML = '<p class="text-gray-500 italic">None reported.</p>'; return; }
|
||||
const col = colour === 'blue' ? 'text-blue-400' : 'text-green-400';
|
||||
el.innerHTML = nodes.map(n => `
|
||||
<div class="flex items-center gap-2 py-0.5 hover:bg-white/5 rounded px-1 cursor-pointer group"
|
||||
onclick="window._router.navigate('/asn',{asn:'${n.asn}'})">
|
||||
<span class="${col} font-bold w-14 shrink-0">AS${n.asn}</span>
|
||||
<span class="text-gray-300 truncate flex-1 group-hover:text-white">${n.name || '—'}</span>
|
||||
<span class="text-gray-600 shrink-0">${n.power ? 'pwr:' + n.power : ''}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderGraph(graph) {
|
||||
const container = document.getElementById('graph-container');
|
||||
const svg = d3.select('#graph-svg');
|
||||
svg.selectAll('*').remove();
|
||||
const W = container.clientWidth, H = container.clientHeight;
|
||||
|
||||
const nodeMap = new Map();
|
||||
function addNode(asn, name, role) {
|
||||
const key = String(asn);
|
||||
if (!nodeMap.has(key)) nodeMap.set(key, { id: key, asn, name: name || `AS${asn}`, role });
|
||||
}
|
||||
addNode(graph.center.asn, graph.center.name, 'center');
|
||||
const vizUp = graph.level2.upstreams.slice(0, 15);
|
||||
const vizDown = graph.level2.downstreams.slice(0, 15);
|
||||
vizUp.forEach(n => addNode(n.asn, n.name, 'upstream'));
|
||||
vizDown.forEach(n => addNode(n.asn, n.name, 'downstream'));
|
||||
graph.level3.forEach(d => d.upstreams.forEach(n => addNode(n.asn, n.name, 'tier1')));
|
||||
|
||||
const nodes = Array.from(nodeMap.values());
|
||||
const links = [];
|
||||
const cid = String(graph.center.asn);
|
||||
vizUp.forEach(n => links.push({ source: String(n.asn), target: cid, type: 'upstream', power: n.power || 1 }));
|
||||
vizDown.forEach(n => links.push({ source: cid, target: String(n.asn), type: 'downstream', power: n.power || 1 }));
|
||||
graph.level3.forEach(d => d.upstreams.forEach(n => links.push({ source: String(n.asn), target: String(d.parentAsn), type: 'tier1', power: n.power || 1 })));
|
||||
const uniqueLinks = Array.from(new Map(links.map(l => [`${l.source}-${l.target}`, l])).values());
|
||||
|
||||
const layerX = { tier1: W * 0.08, upstream: W * 0.3, center: W * 0.55, downstream: W * 0.8 };
|
||||
nodes.forEach(n => { n.fx = layerX[n.role] ?? W / 2; });
|
||||
|
||||
const maxPow = Math.max(...uniqueLinks.map(l => l.power), 1);
|
||||
const strokeSc = d3.scaleLinear().domain([0, maxPow]).range([0.5, 4]);
|
||||
const nodeRadius = { center: 20, upstream: 11, downstream: 11, tier1: 8 };
|
||||
|
||||
const sim = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(uniqueLinks).id(d => d.id).distance(d => d.type === 'tier1' ? 90 : 120).strength(0.6))
|
||||
.force('charge', d3.forceManyBody().strength(-220))
|
||||
.force('y', d3.forceY(H / 2).strength(0.04))
|
||||
.force('collide', d3.forceCollide().radius(d => nodeRadius[d.role] + 14))
|
||||
.alphaDecay(0.025);
|
||||
|
||||
const g = svg.append('g');
|
||||
svg.call(d3.zoom().scaleExtent([0.3, 3]).on('zoom', evt => g.attr('transform', evt.transform)));
|
||||
|
||||
const link = g.append('g').selectAll('line').data(uniqueLinks).join('line')
|
||||
.attr('class', d => `link link-${d.type}`)
|
||||
.attr('stroke-width', d => strokeSc(d.power));
|
||||
|
||||
const tooltip = document.getElementById('graph-tooltip');
|
||||
const node = g.append('g').selectAll('g').data(nodes).join('g')
|
||||
.attr('class', d => `node node-${d.role}`)
|
||||
.style('cursor', 'pointer')
|
||||
.on('click', (_, d) => { if (d.role !== 'center') window._router.navigate('/asn', { asn: d.asn }); })
|
||||
.on('mouseenter', (_, d) => {
|
||||
tooltip.style.opacity = '1';
|
||||
tooltip.innerHTML = `<strong class="text-purple-300">AS${d.asn}</strong><br><span class="text-gray-300">${d.name}</span><br><span class="text-gray-500 text-xs capitalize">${d.role === 'tier1' ? 'Tier-1 / Transit' : d.role}</span>`;
|
||||
})
|
||||
.on('mousemove', evt => {
|
||||
const r = container.getBoundingClientRect();
|
||||
let x = evt.clientX - r.left + 14, y = evt.clientY - r.top - 10;
|
||||
if (x + 230 > W) x -= 244;
|
||||
tooltip.style.left = x + 'px'; tooltip.style.top = y + 'px';
|
||||
})
|
||||
.on('mouseleave', () => { tooltip.style.opacity = '0'; })
|
||||
.call(d3.drag()
|
||||
.on('start', (evt, d) => { if (!evt.active) sim.alphaTarget(0.3).restart(); d.fy = d.y; })
|
||||
.on('drag', (evt, d) => { d.fy = evt.y; })
|
||||
.on('end', (evt, d) => { if (!evt.active) sim.alphaTarget(0); d.fy = null; }));
|
||||
|
||||
node.append('circle').attr('r', d => nodeRadius[d.role]);
|
||||
node.append('text').attr('dy', d => nodeRadius[d.role] + 13).attr('font-size', d => d.role === 'center' ? 12 : 9).text(d => `AS${d.asn}`);
|
||||
node.append('text').attr('dy', d => nodeRadius[d.role] + 23)
|
||||
.attr('font-size', d => d.role === 'tier1' ? 7 : 8)
|
||||
.attr('fill', d => d.role === 'tier1' ? '#6b7280' : '#9ca3af')
|
||||
.text(d => {
|
||||
if (!d.name) return '';
|
||||
const max = d.role === 'center' ? 22 : d.role === 'tier1' ? 12 : 16;
|
||||
return d.name.length > max ? d.name.slice(0, max) + '…' : d.name;
|
||||
});
|
||||
|
||||
sim.on('tick', () => {
|
||||
nodes.forEach(n => { n.y = Math.max(30, Math.min(H - 30, n.y)); });
|
||||
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
||||
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
}
|
||||
|
||||
lookupButton.addEventListener('click', () => doLookup(asnInput.value));
|
||||
asnInput.addEventListener('keypress', e => { if (e.key === 'Enter' && !lookupButton.disabled) doLookup(asnInput.value); });
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
const urlAsn = params.get('asn');
|
||||
if (urlAsn) { asnInput.value = urlAsn; syncBtn(); doLookup(urlAsn); }
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { API, setupCopyBtn, showError } from '../shared.js';
|
||||
|
||||
export const page = {
|
||||
title: 'DNS Lookup',
|
||||
|
||||
template: () => `
|
||||
<div class="container mx-auto max-w-5xl glass-panel rounded-xl shadow-2xl p-6 md:p-8 backdrop-blur-xl border border-gray-800/50">
|
||||
<h1 class="text-3xl font-bold mb-8 text-center text-gradient">DNS Lookup</h1>
|
||||
|
||||
<div class="p-6 glass-card rounded-xl">
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<input type="text" id="dns-domain-input" placeholder="Enter domain (e.g., google.com)"
|
||||
class="flex-grow px-4 py-3 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono transition-all placeholder-gray-600">
|
||||
<select id="dns-type-select"
|
||||
class="px-4 py-3 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 cursor-pointer">
|
||||
<option value="ANY">ANY</option><option value="A">A</option><option value="AAAA">AAAA</option>
|
||||
<option value="MX">MX</option><option value="TXT">TXT</option><option value="NS">NS</option>
|
||||
<option value="CNAME">CNAME</option><option value="SOA">SOA</option><option value="SRV">SRV</option>
|
||||
<option value="PTR">PTR (Reverse)</option>
|
||||
</select>
|
||||
<button id="dns-lookup-button" disabled
|
||||
class="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold py-3 px-6 rounded-lg shadow-lg transition-all duration-200">
|
||||
Lookup DNS
|
||||
</button>
|
||||
</div>
|
||||
<div id="dns-lookup-error" class="hidden mb-4 p-3 bg-red-900/20 border border-red-500/30 rounded text-red-400 text-sm"></div>
|
||||
|
||||
<div id="dns-lookup-results-section" class="hidden mt-6 border-t border-gray-700/50 pt-6 fade-in">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-purple-300 flex items-center gap-2">
|
||||
<div class="w-1.5 h-6 bg-purple-500 rounded-full"></div>
|
||||
DNS Results for: <span id="dns-lookup-query" class="font-mono text-purple-400 ml-1"></span>
|
||||
</h3>
|
||||
<button id="copy-dns-btn" class="copy-btn">copy</button>
|
||||
</div>
|
||||
<div id="dns-lookup-loader" class="loader hidden mb-4"></div>
|
||||
<pre id="dns-lookup-output" class="result-pre"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="global-error" class="mt-6 p-4 bg-red-900/50 border border-red-500/50 text-red-100 rounded-lg hidden backdrop-blur shadow-lg"></div>
|
||||
</div>`,
|
||||
|
||||
async init(search) {
|
||||
const input = document.getElementById('dns-domain-input');
|
||||
const select = document.getElementById('dns-type-select');
|
||||
const btn = document.getElementById('dns-lookup-button');
|
||||
const errorEl = document.getElementById('dns-lookup-error');
|
||||
const section = document.getElementById('dns-lookup-results-section');
|
||||
const queryEl = document.getElementById('dns-lookup-query');
|
||||
const loader = document.getElementById('dns-lookup-loader');
|
||||
const output = document.getElementById('dns-lookup-output');
|
||||
const copyBtn = document.getElementById('copy-dns-btn');
|
||||
|
||||
const syncBtn = () => { btn.disabled = !input.value.trim(); };
|
||||
input.addEventListener('input', syncBtn);
|
||||
|
||||
setupCopyBtn(copyBtn, () => output.textContent);
|
||||
|
||||
async function doLookup() {
|
||||
const domain = input.value.trim();
|
||||
const type = select.value;
|
||||
if (!domain) return;
|
||||
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('domain', domain);
|
||||
url.searchParams.set('type', type);
|
||||
history.replaceState({}, '', url);
|
||||
|
||||
showError(errorEl, null);
|
||||
section.classList.remove('hidden');
|
||||
loader.classList.remove('hidden');
|
||||
output.textContent = '';
|
||||
queryEl.textContent = `${domain} (${type})`;
|
||||
|
||||
try {
|
||||
const r = await fetch(`${API}/dns-lookup?domain=${encodeURIComponent(domain)}&type=${encodeURIComponent(type)}`);
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) throw new Error(data.error || `HTTP ${r.status}`);
|
||||
output.textContent = JSON.stringify(data.records, null, 2);
|
||||
} catch (err) {
|
||||
showError(errorEl, err.message);
|
||||
output.textContent = '';
|
||||
} finally {
|
||||
loader.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', doLookup);
|
||||
input.addEventListener('keypress', e => { if (e.key === 'Enter' && !btn.disabled) doLookup(); });
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
const d = params.get('domain');
|
||||
if (d) {
|
||||
input.value = d;
|
||||
const t = params.get('type');
|
||||
if (t) select.value = t;
|
||||
syncBtn();
|
||||
doLookup();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,667 @@
|
||||
import { API, setupCopyBtn } from '../shared.js';
|
||||
|
||||
export const page = {
|
||||
title: 'IP Info & Tools',
|
||||
|
||||
template: () => `
|
||||
<div class="container mx-auto max-w-5xl glass-panel rounded-xl shadow-2xl p-6 md:p-8 backdrop-blur-xl border border-gray-800/50">
|
||||
<h1 class="text-3xl font-bold mb-8 text-center text-gradient glitch-text">Your Digital Footprint</h1>
|
||||
|
||||
<!-- Own IP info -->
|
||||
<div id="info-section" class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
||||
<!-- Left column -->
|
||||
<div class="space-y-6 fade-in" style="animation-delay:.1s">
|
||||
<div class="glass-card rounded-lg p-5 relative overflow-hidden group">
|
||||
<div class="absolute top-0 right-0 p-2 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xs font-bold text-purple-400 uppercase tracking-widest mb-2">Your Public IP</h2>
|
||||
<div id="ip-info" class="min-h-[40px] flex items-center gap-2">
|
||||
<div id="ip-loader" class="loader"></div>
|
||||
<a id="ip-address-link" href="#" class="text-3xl font-mono font-bold text-white tracking-tight break-all hidden hover:text-purple-300 transition-colors" title="Click for WHOIS Lookup">
|
||||
<span id="ip-address"></span>
|
||||
</a>
|
||||
<button id="copy-ip-btn" class="copy-btn hidden">copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card rounded-lg p-5 space-y-4">
|
||||
<div>
|
||||
<h2 class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-2 border-b border-gray-700 pb-1">Location Details</h2>
|
||||
<div id="geo-info" class="min-h-[80px] space-y-1 text-sm text-gray-300">
|
||||
<div id="geo-loader" class="loader"></div>
|
||||
<div class="hidden grid grid-cols-2 gap-x-2 gap-y-1">
|
||||
<p><span class="text-gray-500">Country:</span> <span id="country" class="text-gray-200 font-medium">-</span></p>
|
||||
<p><span class="text-gray-500">Region:</span> <span id="region" class="text-gray-200 font-medium">-</span></p>
|
||||
<p><span class="text-gray-500">City:</span> <span id="city" class="text-gray-200 font-medium">-</span></p>
|
||||
<p><span class="text-gray-500">Zip:</span> <span id="postal" class="text-gray-200 font-medium">-</span></p>
|
||||
<p class="col-span-2"><span class="text-gray-500">Coords:</span> <span id="coords" class="font-mono text-xs text-purple-300">-</span></p>
|
||||
<p class="col-span-2"><span class="text-gray-500">Time:</span> <span id="timezone" class="text-gray-200 font-medium">-</span></p>
|
||||
<p id="geo-error" class="text-red-400 col-span-2 text-xs"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-2 border-b border-gray-700 pb-1">Network (ASN)</h2>
|
||||
<div id="asn-info" class="min-h-[40px] space-y-1 text-sm text-gray-300">
|
||||
<div id="asn-loader" class="loader"></div>
|
||||
<div class="hidden">
|
||||
<p><span class="text-gray-500">AS Number:</span> <span id="asn-number" class="font-mono text-purple-300">-</span></p>
|
||||
<p><span class="text-gray-500">Org:</span> <span id="asn-org" class="font-medium text-white">-</span></p>
|
||||
<p id="asn-error" class="text-red-400 text-xs"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card rounded-lg p-5">
|
||||
<h2 class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-2 border-b border-gray-700 pb-1">Hostname (rDNS)</h2>
|
||||
<div id="rdns-info" class="min-h-[30px] text-sm text-gray-300">
|
||||
<div id="rdns-loader" class="loader"></div>
|
||||
<div class="hidden">
|
||||
<ul id="rdns-list" class="list-none space-y-1 font-mono text-xs text-green-400"><li>-</li></ul>
|
||||
<p id="rdns-error" class="text-red-400 text-xs"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: map -->
|
||||
<div class="space-y-4 fade-in" style="animation-delay:.2s">
|
||||
<h2 class="text-lg font-semibold text-gray-200 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Location Visualization
|
||||
</h2>
|
||||
<div id="map-container" class="bg-gray-800/50 rounded-lg min-h-[400px] h-full flex items-center justify-center relative border border-gray-700/50 shadow-inner overflow-hidden">
|
||||
<div id="map-loader" class="loader absolute z-10"></div>
|
||||
<div id="map" class="w-full h-full rounded-lg hidden z-0 opacity-80 hover:opacity-100 transition-opacity duration-700"></div>
|
||||
<p id="map-message" class="text-gray-400 hidden absolute text-sm">Could not load map.</p>
|
||||
<div class="absolute inset-0 pointer-events-none rounded-lg ring-1 ring-inset ring-white/10"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP Lookup -->
|
||||
<div class="mt-8 p-6 glass-card rounded-xl">
|
||||
<h2 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-pink-500 mb-4 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-pink-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
IP Address / Domain Lookup
|
||||
</h2>
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<input type="text" id="lookup-ip-input" placeholder="Enter IP or Domain (e.g., 8.8.8.8 or google.com)"
|
||||
class="flex-grow px-4 py-3 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono transition-all placeholder-gray-600">
|
||||
<button id="lookup-button" disabled
|
||||
class="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold py-3 px-6 rounded-lg shadow-lg hover:shadow-purple-500/25 transition-all duration-200">
|
||||
Lookup
|
||||
</button>
|
||||
</div>
|
||||
<div id="lookup-error" class="text-red-400 mb-4 hidden p-3 bg-red-900/20 border border-red-500/30 rounded text-sm"></div>
|
||||
|
||||
<div id="lookup-results-section" class="hidden grid grid-cols-1 md:grid-cols-2 gap-8 mt-6 border-t border-gray-700/50 pt-6 fade-in">
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-lg font-semibold text-gray-200">Result for: <span id="lookup-ip-address" class="font-mono text-purple-400 bg-purple-500/10 px-2 py-0.5 rounded"></span>
|
||||
<button id="copy-lookup-ip-btn" class="copy-btn ml-2">copy</button>
|
||||
</h3>
|
||||
<div id="lookup-result-loader" class="loader hidden"></div>
|
||||
<div id="lookup-geo-info" class="space-y-1 text-sm text-gray-300">
|
||||
<h4 class="font-bold text-gray-500 uppercase text-xs tracking-wider mb-2">Geolocation</h4>
|
||||
<div class="grid grid-cols-2 gap-x-2 gap-y-1">
|
||||
<p><span class="text-gray-500">Country:</span> <span id="lookup-country" class="text-white">-</span></p>
|
||||
<p><span class="text-gray-500">Region:</span> <span id="lookup-region" class="text-white">-</span></p>
|
||||
<p><span class="text-gray-500">City:</span> <span id="lookup-city" class="text-white">-</span></p>
|
||||
<p><span class="text-gray-500">Zip:</span> <span id="lookup-postal" class="text-white">-</span></p>
|
||||
<p class="col-span-2"><span class="text-gray-500">Coords:</span> <span id="lookup-coords" class="font-mono text-purple-300">-</span></p>
|
||||
<p class="col-span-2"><span class="text-gray-500">Time:</span> <span id="lookup-timezone" class="text-white">-</span></p>
|
||||
<p id="lookup-geo-error" class="text-red-400 col-span-2"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lookup-asn-info" class="space-y-1 text-sm text-gray-300">
|
||||
<h4 class="font-bold text-gray-500 uppercase text-xs tracking-wider mb-2 mt-4">ASN</h4>
|
||||
<p><span class="text-gray-500">Number:</span> <span id="lookup-asn-number" class="text-white font-mono">-</span></p>
|
||||
<p><span class="text-gray-500">Org:</span> <span id="lookup-asn-org" class="text-white">-</span></p>
|
||||
<p id="lookup-asn-error" class="text-red-400"></p>
|
||||
</div>
|
||||
<div id="lookup-rdns-info" class="space-y-1 text-sm text-gray-300">
|
||||
<h4 class="font-bold text-gray-500 uppercase text-xs tracking-wider mb-2 mt-4">Reverse DNS</h4>
|
||||
<ul id="lookup-rdns-list" class="list-none space-y-1 font-mono text-xs text-green-400"><li>-</li></ul>
|
||||
<p id="lookup-rdns-error" class="text-red-400"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h4 class="font-bold text-gray-500 uppercase text-xs tracking-wider mb-2">Location Map</h4>
|
||||
<div id="lookup-map-container" class="glass-panel rounded-lg min-h-[250px] flex items-center justify-center relative overflow-hidden">
|
||||
<div id="lookup-map-loader" class="loader hidden absolute z-10"></div>
|
||||
<div id="lookup-map" class="w-full rounded hidden opacity-90"></div>
|
||||
<p id="lookup-map-message" class="text-gray-400 hidden absolute">Could not load map.</p>
|
||||
<div class="absolute inset-0 pointer-events-none ring-1 ring-inset ring-white/10 rounded-lg"></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 pt-2">
|
||||
<button id="lookup-ping-button" disabled class="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded-lg text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed border border-gray-600 hover:border-gray-500 shadow-md">Ping</button>
|
||||
<button id="lookup-trace-button" disabled class="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded-lg text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed border border-gray-600 hover:border-gray-500 shadow-md">Trace</button>
|
||||
<button id="lookup-scan-button" disabled class="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded-lg text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed border border-gray-600 hover:border-gray-500 shadow-md">Port Scan</button>
|
||||
</div>
|
||||
<div id="lookup-ping-results" class="mt-4 text-sm hidden fade-in">
|
||||
<h4 class="font-bold text-purple-400 mb-2 flex items-center gap-2">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> Ping Results
|
||||
</h4>
|
||||
<div id="lookup-ping-loader" class="loader hidden"></div>
|
||||
<pre id="lookup-ping-output" class="result-pre mt-1"></pre>
|
||||
<p id="lookup-ping-error" class="text-red-400 mt-2"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Traceroute -->
|
||||
<div id="traceroute-section" class="mt-8 p-6 glass-card rounded-xl hidden fade-in">
|
||||
<h2 class="text-xl font-bold text-purple-300 border-b border-purple-500/30 pb-2 mb-4">Traceroute Results</h2>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div id="traceroute-loader" class="loader hidden"></div>
|
||||
<span id="traceroute-message" class="text-gray-300"></span>
|
||||
</div>
|
||||
<button id="traceroute-stop-btn" class="stop-btn hidden">■ Stop</button>
|
||||
</div>
|
||||
<div id="traceroute-output" class="rounded-lg overflow-hidden"><pre class="m-0"></pre></div>
|
||||
</div>
|
||||
|
||||
<!-- Port Scan -->
|
||||
<div id="port-scan-section" class="mt-8 p-6 glass-card rounded-xl hidden fade-in">
|
||||
<h2 class="text-xl font-bold text-purple-300 border-b border-purple-500/30 pb-2 mb-4">Port Scan Results</h2>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div id="port-scan-loader" class="loader hidden"></div>
|
||||
<span id="port-scan-message" class="text-gray-300"></span>
|
||||
</div>
|
||||
<button id="port-scan-stop-btn" class="stop-btn hidden">■ Stop</button>
|
||||
</div>
|
||||
<div id="port-scan-output" class="text-sm font-mono bg-gray-900/50 p-4 rounded-lg border border-gray-700/50 max-h-[300px] overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<div id="global-error" class="mt-6 p-4 bg-red-900/50 border border-red-500/50 text-red-100 rounded-lg hidden backdrop-blur shadow-lg"></div>
|
||||
</div>`,
|
||||
|
||||
async init(search) {
|
||||
// ── DOM refs ──────────────────────────────────────────────────
|
||||
const ipAddressLink = document.getElementById('ip-address-link');
|
||||
const ipAddressSpan = document.getElementById('ip-address');
|
||||
const copyIpBtn = document.getElementById('copy-ip-btn');
|
||||
const ipLoader = document.getElementById('ip-loader');
|
||||
const geoLoader = document.getElementById('geo-loader');
|
||||
const asnLoader = document.getElementById('asn-loader');
|
||||
const rdnsLoader = document.getElementById('rdns-loader');
|
||||
const mapLoader = document.getElementById('map-loader');
|
||||
const mapEl = document.getElementById('map');
|
||||
const mapMessage = document.getElementById('map-message');
|
||||
const globalError = document.getElementById('global-error');
|
||||
|
||||
const lookupInput = document.getElementById('lookup-ip-input');
|
||||
const lookupBtn = document.getElementById('lookup-button');
|
||||
const lookupErrorEl = document.getElementById('lookup-error');
|
||||
const lookupSection = document.getElementById('lookup-results-section');
|
||||
const lookupIpEl = document.getElementById('lookup-ip-address');
|
||||
const copyLookupIpBtn = document.getElementById('copy-lookup-ip-btn');
|
||||
const lookupResLoader = document.getElementById('lookup-result-loader');
|
||||
const lookupMapEl = document.getElementById('lookup-map');
|
||||
const lookupMapLoader = document.getElementById('lookup-map-loader');
|
||||
const lookupMapMsg = document.getElementById('lookup-map-message');
|
||||
const lookupPingBtn = document.getElementById('lookup-ping-button');
|
||||
const lookupTraceBtn = document.getElementById('lookup-trace-button');
|
||||
const lookupScanBtn = document.getElementById('lookup-scan-button');
|
||||
const lookupPingRes = document.getElementById('lookup-ping-results');
|
||||
const lookupPingLoader= document.getElementById('lookup-ping-loader');
|
||||
const lookupPingOutput= document.getElementById('lookup-ping-output');
|
||||
const lookupPingError = document.getElementById('lookup-ping-error');
|
||||
|
||||
const tracerouteSection = document.getElementById('traceroute-section');
|
||||
const tracerouteOutput = document.querySelector('#traceroute-output pre');
|
||||
const tracerouteLoader = document.getElementById('traceroute-loader');
|
||||
const tracerouteMessage = document.getElementById('traceroute-message');
|
||||
const tracerouteStopBtn = document.getElementById('traceroute-stop-btn');
|
||||
|
||||
const portScanSection = document.getElementById('port-scan-section');
|
||||
const portScanOutput = document.getElementById('port-scan-output');
|
||||
const portScanLoader = document.getElementById('port-scan-loader');
|
||||
const portScanMessage = document.getElementById('port-scan-message');
|
||||
const portScanStopBtn = document.getElementById('port-scan-stop-btn');
|
||||
|
||||
// ── State ────────────────────────────────────────────────────
|
||||
let map = null, lookupMap = null, currentIp = null, currentLookupIp = null;
|
||||
let eventSource = null, portScanEventSource = null;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
function showGlobalErr(msg) {
|
||||
if (!globalError) return;
|
||||
globalError.textContent = `Error: ${msg}`;
|
||||
globalError.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function isValidIp(input) {
|
||||
const v4 = /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
|
||||
const v6 = /^(?:(?:[a-fA-F0-9]{1,4}:){7}[a-fA-F0-9]{1,4}|(?:[a-fA-F0-9]{1,4}:){1,7}:|(?:[a-fA-F0-9]{1,4}:){1,6}:[a-fA-F0-9]{1,4}|(?:[a-fA-F0-9]{1,4}:){1,5}(?::[a-fA-F0-9]{1,4}){1,2}|(?:[a-fA-F0-9]{1,4}:){1,4}(?::[a-fA-F0-9]{1,4}){1,3}|(?:[a-fA-F0-9]{1,4}:){1,3}(?::[a-fA-F0-9]{1,4}){1,4}|(?:[a-fA-F0-9]{1,4}:){1,2}(?::[a-fA-F0-9]{1,4}){1,5}|[a-fA-F0-9]{1,4}:(?::[a-fA-F0-9]{1,4}){1,6}|:(?::[a-fA-F0-9]{1,4}){1,7}|::)$/;
|
||||
return v4.test(input) || v6.test(input);
|
||||
}
|
||||
|
||||
function updateField(el, val, loaderEl = null, errorEl = null, def = '-') {
|
||||
if (loaderEl) loaderEl.classList.add('hidden');
|
||||
if (errorEl) errorEl.textContent = '';
|
||||
const container = el?.closest('div:not(.loader)');
|
||||
if (container?.classList.contains('hidden')) container.classList.remove('hidden');
|
||||
if (val && typeof val === 'object' && val.error) {
|
||||
if (el) el.textContent = def;
|
||||
if (errorEl) errorEl.textContent = val.error;
|
||||
} else if (val != null && val !== '') {
|
||||
if (el) el.textContent = val;
|
||||
} else {
|
||||
if (el) el.textContent = def;
|
||||
}
|
||||
}
|
||||
|
||||
function updateRdns(listEl, data, loaderEl = null, errorEl = null) {
|
||||
if (loaderEl) loaderEl.classList.add('hidden');
|
||||
if (listEl) listEl.innerHTML = '';
|
||||
if (errorEl) errorEl.textContent = '';
|
||||
const container = listEl?.closest('div:not(.loader)');
|
||||
if (container?.classList.contains('hidden')) container.classList.remove('hidden');
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length > 0) data.forEach(h => { const li = document.createElement('li'); li.textContent = h; listEl.appendChild(li); });
|
||||
else if (listEl) listEl.innerHTML = '<li>No rDNS records found.</li>';
|
||||
} else if (data?.error) {
|
||||
if (listEl) listEl.innerHTML = '<li>-</li>';
|
||||
if (errorEl) errorEl.textContent = data.error;
|
||||
} else {
|
||||
if (listEl) listEl.innerHTML = '<li>-</li>';
|
||||
}
|
||||
}
|
||||
|
||||
function initOrUpdateMap(mapId, lat, lon, mapElement, loaderEl, msgEl) {
|
||||
if (!mapElement || !loaderEl || !msgEl) return null;
|
||||
loaderEl.classList.add('hidden');
|
||||
let inst = window[mapId + '_instance'];
|
||||
if (lat != null && lon != null) {
|
||||
mapElement.classList.remove('hidden');
|
||||
msgEl.classList.add('hidden');
|
||||
if (inst) {
|
||||
inst.setView([lat, lon], 13);
|
||||
inst.eachLayer(l => { if (l instanceof L.Marker) inst.removeLayer(l); });
|
||||
L.marker([lat, lon]).addTo(inst).bindPopup('Approximate Location').openPopup();
|
||||
} else {
|
||||
try {
|
||||
inst = L.map(mapId).setView([lat, lon], 13);
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: 'abcd', maxZoom: 19
|
||||
}).addTo(inst);
|
||||
L.marker([lat, lon]).addTo(inst).bindPopup('Approximate Location').openPopup();
|
||||
window[mapId + '_instance'] = inst;
|
||||
} catch (e) {
|
||||
console.error('Map init failed:', e);
|
||||
mapElement.classList.add('hidden');
|
||||
msgEl.classList.remove('hidden');
|
||||
msgEl.textContent = 'Error initializing map.';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
setTimeout(() => { if (window[mapId + '_instance']) window[mapId + '_instance'].invalidateSize(); }, 100);
|
||||
return inst;
|
||||
} else {
|
||||
mapElement.classList.add('hidden');
|
||||
msgEl.classList.remove('hidden');
|
||||
msgEl.textContent = 'Map could not be loaded (missing coordinates).';
|
||||
if (inst) { inst.remove(); window[mapId + '_instance'] = null; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Copy buttons ─────────────────────────────────────────────
|
||||
setupCopyBtn(copyIpBtn, () => ipAddressSpan.textContent);
|
||||
setupCopyBtn(copyLookupIpBtn, () => lookupIpEl.textContent);
|
||||
|
||||
// ── Lookup button enable/disable ─────────────────────────────
|
||||
const syncLookupBtn = () => { lookupBtn.disabled = !lookupInput.value.trim(); };
|
||||
lookupInput.addEventListener('input', syncLookupBtn);
|
||||
|
||||
// ── Own IP fetch ─────────────────────────────────────────────
|
||||
async function fetchIpInfo() {
|
||||
globalError.classList.add('hidden');
|
||||
[ipLoader, geoLoader, asnLoader, rdnsLoader, mapLoader].forEach(l => l?.classList.remove('hidden'));
|
||||
ipAddressLink.classList.add('hidden');
|
||||
copyIpBtn.classList.add('hidden');
|
||||
mapEl.classList.add('hidden');
|
||||
mapMessage.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const r = await fetch(`${API}/ipinfo`);
|
||||
if (!r.ok) throw new Error(`${r.statusText} (${r.status})`);
|
||||
const data = await r.json();
|
||||
currentIp = data.ip;
|
||||
|
||||
ipAddressSpan.textContent = data.ip;
|
||||
ipAddressLink.classList.remove('hidden');
|
||||
copyIpBtn.classList.remove('hidden');
|
||||
ipLoader.classList.add('hidden');
|
||||
ipAddressLink.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (currentIp) window._router.navigate('/whois', { query: currentIp });
|
||||
});
|
||||
|
||||
updateField(document.getElementById('country'), data.geo?.countryName ? `${data.geo.countryName} (${data.geo.country})` : null, null, document.getElementById('geo-error'));
|
||||
updateField(document.getElementById('region'), data.geo?.region);
|
||||
updateField(document.getElementById('city'), data.geo?.city);
|
||||
updateField(document.getElementById('postal'), data.geo?.postalCode);
|
||||
updateField(document.getElementById('coords'), data.geo?.latitude ? `${data.geo.latitude}, ${data.geo.longitude}` : null);
|
||||
updateField(document.getElementById('timezone'), data.geo?.timezone, geoLoader);
|
||||
|
||||
const asnNum = (data.asn && !data.asn.error) ? data.asn.number : null;
|
||||
if (asnNum) {
|
||||
const asnContainer = document.getElementById('asn-number')?.closest('div:not(.loader)');
|
||||
if (asnContainer) asnContainer.classList.remove('hidden');
|
||||
document.getElementById('asn-number').innerHTML =
|
||||
`<a href="/asn?asn=${asnNum}" class="hover:text-purple-200 underline decoration-dotted transition-colors" title="Open ASN Lookup">AS${asnNum}</a>`;
|
||||
} else {
|
||||
updateField(document.getElementById('asn-number'), null, null, document.getElementById('asn-error'), data.asn?.error || '-');
|
||||
}
|
||||
updateField(document.getElementById('asn-org'), data.asn?.organization, asnLoader);
|
||||
updateRdns(document.getElementById('rdns-list'), data.rdns, rdnsLoader, document.getElementById('rdns-error'));
|
||||
map = initOrUpdateMap('map', data.geo?.latitude, data.geo?.longitude, mapEl, mapLoader, mapMessage);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch IP info:', err);
|
||||
showGlobalErr(`Could not load IP information. ${err.message}`);
|
||||
[ipLoader, geoLoader, asnLoader, rdnsLoader, mapLoader].forEach(l => l?.classList.add('hidden'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lookup ───────────────────────────────────────────────────
|
||||
function resetLookup() {
|
||||
lookupSection.classList.add('hidden');
|
||||
lookupResLoader.classList.add('hidden');
|
||||
lookupMapLoader.classList.add('hidden');
|
||||
lookupMapEl.classList.add('hidden');
|
||||
lookupMapMsg.classList.add('hidden');
|
||||
lookupPingRes.classList.add('hidden');
|
||||
lookupPingLoader.classList.add('hidden');
|
||||
portScanSection.classList.add('hidden');
|
||||
portScanOutput.innerHTML = '';
|
||||
[lookupIpEl, document.getElementById('lookup-country'), document.getElementById('lookup-region'),
|
||||
document.getElementById('lookup-city'), document.getElementById('lookup-postal'),
|
||||
document.getElementById('lookup-coords'), document.getElementById('lookup-timezone'),
|
||||
document.getElementById('lookup-asn-number'), document.getElementById('lookup-asn-org'),
|
||||
document.getElementById('lookup-geo-error'), document.getElementById('lookup-asn-error'),
|
||||
document.getElementById('lookup-rdns-error')].forEach(el => { if (el) el.textContent = ''; });
|
||||
document.getElementById('lookup-rdns-list').innerHTML = '<li>-</li>';
|
||||
lookupPingOutput.textContent = '';
|
||||
lookupPingError.textContent = '';
|
||||
[lookupPingBtn, lookupTraceBtn, lookupScanBtn].forEach(b => { if (b) b.disabled = true; });
|
||||
currentLookupIp = null;
|
||||
if (window['lookup-map_instance']) { window['lookup-map_instance'].remove(); window['lookup-map_instance'] = null; }
|
||||
if (portScanEventSource) { portScanEventSource.close(); portScanEventSource = null; }
|
||||
}
|
||||
|
||||
async function doLookup(query) {
|
||||
resetLookup();
|
||||
lookupErrorEl.classList.add('hidden');
|
||||
globalError.classList.add('hidden');
|
||||
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('ip', query);
|
||||
history.replaceState({}, '', url);
|
||||
|
||||
lookupSection.classList.remove('hidden');
|
||||
lookupResLoader.classList.remove('hidden');
|
||||
lookupMapLoader.classList.remove('hidden');
|
||||
|
||||
let ipToLookup = query;
|
||||
if (!isValidIp(query)) {
|
||||
try {
|
||||
const r = await fetch(`${API}/dns-lookup?domain=${encodeURIComponent(query)}&type=ANY`);
|
||||
const data = await r.json();
|
||||
if (r.ok && data.success) {
|
||||
const ip = data.records?.A?.[0] ?? data.records?.AAAA?.[0];
|
||||
if (ip) ipToLookup = ip;
|
||||
else throw new Error('No A or AAAA records found.');
|
||||
} else {
|
||||
throw new Error(data.error || 'DNS lookup failed.');
|
||||
}
|
||||
} catch (err) {
|
||||
lookupErrorEl.textContent = `Error: Could not resolve domain — ${err.message}`;
|
||||
lookupErrorEl.classList.remove('hidden');
|
||||
resetLookup();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch(`${API}/lookup?targetIp=${encodeURIComponent(ipToLookup)}`);
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
||||
currentLookupIp = data.ip;
|
||||
|
||||
updateField(lookupIpEl, data.ip);
|
||||
updateField(document.getElementById('lookup-country'), data.geo?.countryName ? `${data.geo.countryName} (${data.geo.country})` : null, null, document.getElementById('lookup-geo-error'));
|
||||
updateField(document.getElementById('lookup-region'), data.geo?.region);
|
||||
updateField(document.getElementById('lookup-city'), data.geo?.city);
|
||||
updateField(document.getElementById('lookup-postal'), data.geo?.postalCode);
|
||||
updateField(document.getElementById('lookup-coords'), data.geo?.latitude ? `${data.geo.latitude}, ${data.geo.longitude}` : null);
|
||||
updateField(document.getElementById('lookup-timezone'), data.geo?.timezone);
|
||||
|
||||
if (data.asn?.number) {
|
||||
document.getElementById('lookup-asn-number').innerHTML =
|
||||
`<a href="/asn?asn=${data.asn.number}" class="text-purple-400 hover:text-purple-300 underline decoration-dotted transition-colors font-mono">AS${data.asn.number}</a>`;
|
||||
} else {
|
||||
updateField(document.getElementById('lookup-asn-number'), data.asn?.number, null, document.getElementById('lookup-asn-error'));
|
||||
}
|
||||
updateField(document.getElementById('lookup-asn-org'), data.asn?.organization);
|
||||
updateRdns(document.getElementById('lookup-rdns-list'), data.rdns, null, document.getElementById('lookup-rdns-error'));
|
||||
lookupMap = initOrUpdateMap('lookup-map', data.geo?.latitude, data.geo?.longitude, lookupMapEl, lookupMapLoader, lookupMapMsg);
|
||||
[lookupPingBtn, lookupTraceBtn, lookupScanBtn].forEach(b => { if (b) b.disabled = false; });
|
||||
|
||||
} catch (err) {
|
||||
lookupErrorEl.textContent = `Error: Lookup failed — ${err.message}`;
|
||||
lookupErrorEl.classList.remove('hidden');
|
||||
lookupMapMsg.textContent = 'Map could not be loaded.';
|
||||
lookupMapMsg.classList.remove('hidden');
|
||||
lookupMapEl.classList.add('hidden');
|
||||
lookupMapLoader.classList.add('hidden');
|
||||
resetLookup();
|
||||
} finally {
|
||||
lookupResLoader.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ping ─────────────────────────────────────────────────────
|
||||
async function runPing(ip) {
|
||||
lookupPingRes.classList.remove('hidden');
|
||||
lookupPingLoader.classList.remove('hidden');
|
||||
lookupPingOutput.textContent = '';
|
||||
lookupPingError.textContent = '';
|
||||
try {
|
||||
const r = await fetch(`${API}/ping?targetIp=${encodeURIComponent(ip)}`);
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) throw new Error(data.error || `HTTP ${r.status}`);
|
||||
let out = `--- Ping Statistics for ${ip} ---\n`;
|
||||
if (data.stats) {
|
||||
out += `Packets: ${data.stats.packets.transmitted} sent, ${data.stats.packets.received} received, ${data.stats.packets.lossPercent}% loss\n`;
|
||||
if (data.stats.rtt) out += `RTT (ms): min=${data.stats.rtt.min} avg=${data.stats.rtt.avg} max=${data.stats.rtt.max}\n`;
|
||||
}
|
||||
out += `\n--- Raw Output ---\n${data.rawOutput || ''}`;
|
||||
lookupPingOutput.textContent = out;
|
||||
} catch (err) {
|
||||
lookupPingError.textContent = `Ping Error: ${err.message}`;
|
||||
} finally {
|
||||
lookupPingLoader.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Traceroute ───────────────────────────────────────────────
|
||||
function startTraceroute(ip) {
|
||||
if (eventSource) { eventSource.close(); eventSource = null; }
|
||||
tracerouteSection.classList.remove('hidden');
|
||||
tracerouteOutput.textContent = '';
|
||||
tracerouteLoader.classList.remove('hidden');
|
||||
tracerouteStopBtn.classList.remove('hidden');
|
||||
tracerouteMessage.textContent = `Starting traceroute to ${ip}…`;
|
||||
globalError.classList.add('hidden');
|
||||
|
||||
eventSource = new EventSource(`${API}/traceroute?targetIp=${encodeURIComponent(ip)}`);
|
||||
|
||||
eventSource.onopen = () => { tracerouteMessage.textContent = `Traceroute to ${ip} in progress…`; };
|
||||
eventSource.onerror = () => {
|
||||
tracerouteMessage.textContent = eventSource.readyState === EventSource.CLOSED
|
||||
? 'Connection closed.' : 'Connection error.';
|
||||
tracerouteLoader.classList.add('hidden');
|
||||
tracerouteStopBtn.classList.add('hidden');
|
||||
eventSource.close();
|
||||
};
|
||||
eventSource.addEventListener('hop', e => {
|
||||
try { displayHop(JSON.parse(e.data)); } catch { displayTraceLine(`[Parse error: ${e.data}]`, 'error-line'); }
|
||||
});
|
||||
eventSource.addEventListener('info', e => {
|
||||
try { displayTraceLine(JSON.parse(e.data).message, 'info-line'); } catch {}
|
||||
});
|
||||
eventSource.addEventListener('error', e => {
|
||||
try { const d = JSON.parse(e.data); displayTraceLine(d.error, 'error-line'); } catch {}
|
||||
});
|
||||
eventSource.addEventListener('end', e => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
const msg = `Traceroute finished${d.exitCode === 0 ? ' successfully' : ` (exit code ${d.exitCode})`}.`;
|
||||
displayTraceLine(msg, 'end-line');
|
||||
tracerouteMessage.textContent = msg;
|
||||
} catch {}
|
||||
tracerouteLoader.classList.add('hidden');
|
||||
tracerouteStopBtn.classList.add('hidden');
|
||||
eventSource.close();
|
||||
});
|
||||
}
|
||||
|
||||
function stopTraceroute() {
|
||||
if (eventSource) { eventSource.close(); eventSource = null; }
|
||||
tracerouteLoader.classList.add('hidden');
|
||||
tracerouteStopBtn.classList.add('hidden');
|
||||
tracerouteMessage.textContent = 'Traceroute stopped.';
|
||||
displayTraceLine('— Stopped by user —', 'info-line');
|
||||
}
|
||||
|
||||
function displayTraceLine(text, cls = '') {
|
||||
const div = document.createElement('div');
|
||||
if (cls) div.classList.add(cls);
|
||||
div.classList.add('fade-in');
|
||||
div.textContent = text;
|
||||
tracerouteOutput.appendChild(div);
|
||||
tracerouteOutput.scrollTop = tracerouteOutput.scrollHeight;
|
||||
}
|
||||
|
||||
function displayHop(hop) {
|
||||
const div = document.createElement('div');
|
||||
div.classList.add('hop-line', 'fade-in');
|
||||
const num = document.createElement('span'); num.classList.add('hop-number'); num.textContent = hop.hop || '?'; div.appendChild(num);
|
||||
if (hop.ip) {
|
||||
const ip = document.createElement('span'); ip.classList.add('hop-ip'); ip.textContent = hop.ip; div.appendChild(ip);
|
||||
if (hop.hostname) { const h = document.createElement('span'); h.classList.add('hop-hostname'); h.textContent = ` (${hop.hostname})`; div.appendChild(h); }
|
||||
} else if (hop.rtt?.every(r => r === '*')) {
|
||||
const t = document.createElement('span'); t.classList.add('hop-timeout'); t.textContent = '* * *'; div.appendChild(t);
|
||||
} else {
|
||||
div.appendChild(document.createTextNode(hop.rawLine || 'Unknown hop'));
|
||||
}
|
||||
if (Array.isArray(hop.rtt)) {
|
||||
hop.rtt.forEach(r => {
|
||||
const s = document.createElement('span');
|
||||
s.classList.add(r === '*' ? 'hop-timeout' : 'hop-rtt');
|
||||
s.textContent = r === '*' ? ' *' : ` ${r} ms`;
|
||||
div.appendChild(s);
|
||||
});
|
||||
}
|
||||
tracerouteOutput.appendChild(div);
|
||||
tracerouteOutput.scrollTop = tracerouteOutput.scrollHeight;
|
||||
}
|
||||
|
||||
// ── Port Scan ────────────────────────────────────────────────
|
||||
function startPortScan(ip) {
|
||||
if (portScanEventSource) { portScanEventSource.close(); portScanEventSource = null; }
|
||||
portScanSection.classList.remove('hidden');
|
||||
portScanOutput.innerHTML = '';
|
||||
portScanLoader.classList.remove('hidden');
|
||||
portScanStopBtn.classList.remove('hidden');
|
||||
portScanMessage.textContent = `Starting port scan for ${ip}…`;
|
||||
|
||||
portScanEventSource = new EventSource(`${API}/port-scan?targetIp=${encodeURIComponent(ip)}`);
|
||||
portScanEventSource.onopen = () => {};
|
||||
portScanEventSource.onerror = () => {
|
||||
portScanMessage.textContent = 'Connection error during port scan.';
|
||||
portScanLoader.classList.add('hidden');
|
||||
portScanStopBtn.classList.add('hidden');
|
||||
portScanEventSource.close();
|
||||
};
|
||||
portScanEventSource.addEventListener('info', e => {
|
||||
try { portScanMessage.textContent = JSON.parse(e.data).message; } catch {}
|
||||
});
|
||||
portScanEventSource.addEventListener('port_status', e => {
|
||||
try { displayPortResult(JSON.parse(e.data)); } catch {}
|
||||
});
|
||||
portScanEventSource.addEventListener('error', e => {
|
||||
try { displayPortResult({ error: JSON.parse(e.data).error }); } catch {}
|
||||
});
|
||||
portScanEventSource.addEventListener('end', e => {
|
||||
try { portScanMessage.textContent = JSON.parse(e.data).message; } catch {}
|
||||
portScanLoader.classList.add('hidden');
|
||||
portScanStopBtn.classList.add('hidden');
|
||||
portScanEventSource.close();
|
||||
});
|
||||
}
|
||||
|
||||
function stopPortScan() {
|
||||
if (portScanEventSource) { portScanEventSource.close(); portScanEventSource = null; }
|
||||
portScanLoader.classList.add('hidden');
|
||||
portScanStopBtn.classList.add('hidden');
|
||||
portScanMessage.textContent = 'Port scan stopped.';
|
||||
}
|
||||
|
||||
function displayPortResult(data) {
|
||||
const div = document.createElement('div');
|
||||
div.classList.add('mb-1', 'fade-in');
|
||||
if (data.error) {
|
||||
div.innerHTML = `<span class="text-red-400">Error: ${data.error}</span>`;
|
||||
} else {
|
||||
const colors = { open: 'text-green-400', closed: 'text-red-400', timeout: 'text-yellow-400' };
|
||||
const labels = { open: 'OPEN', closed: 'CLOSED', timeout: 'TIMEOUT (Filtered?)' };
|
||||
const col = colors[data.status] || 'text-gray-400';
|
||||
const lbl = labels[data.status] || (data.status || '').toUpperCase();
|
||||
div.innerHTML = `Port <span class="font-bold w-12 inline-block">${data.port}</span> <span class="w-24 inline-block">(${data.service})</span>: <span class="font-bold ${col}">${lbl}</span>`;
|
||||
}
|
||||
portScanOutput.appendChild(div);
|
||||
portScanOutput.scrollTop = portScanOutput.scrollHeight;
|
||||
}
|
||||
|
||||
// ── Event listeners ──────────────────────────────────────────
|
||||
lookupBtn.addEventListener('click', () => { const q = lookupInput.value.trim(); if (q) doLookup(q); });
|
||||
lookupInput.addEventListener('keypress', e => { if (e.key === 'Enter' && !lookupBtn.disabled) { const q = lookupInput.value.trim(); if (q) doLookup(q); } });
|
||||
lookupPingBtn.addEventListener('click', () => { if (currentLookupIp) runPing(currentLookupIp); });
|
||||
lookupTraceBtn.addEventListener('click', () => { if (currentLookupIp) startTraceroute(currentLookupIp); });
|
||||
lookupScanBtn.addEventListener('click', () => { if (currentLookupIp) startPortScan(currentLookupIp); });
|
||||
tracerouteStopBtn.addEventListener('click', stopTraceroute);
|
||||
portScanStopBtn.addEventListener('click', stopPortScan);
|
||||
|
||||
// ── Bootstrap ────────────────────────────────────────────────
|
||||
fetchIpInfo();
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
const ipParam = params.get('ip');
|
||||
if (ipParam) { lookupInput.value = ipParam; syncLookupBtn(); doLookup(ipParam); }
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────
|
||||
return () => {
|
||||
if (eventSource) { eventSource.close(); eventSource = null; }
|
||||
if (portScanEventSource) { portScanEventSource.close(); portScanEventSource = null; }
|
||||
if (window['map_instance']) { window['map_instance'].remove(); window['map_instance'] = null; }
|
||||
if (window['lookup-map_instance']) { window['lookup-map_instance'].remove(); window['lookup-map_instance'] = null; }
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { API, showError } from '../shared.js';
|
||||
|
||||
export const page = {
|
||||
title: 'MAC Vendor Lookup',
|
||||
|
||||
template: () => `
|
||||
<div class="container mx-auto max-w-5xl glass-panel rounded-xl shadow-2xl p-6 md:p-8 backdrop-blur-xl border border-gray-800/50">
|
||||
<h1 class="text-3xl font-bold mb-8 text-center text-gradient">MAC Address Vendor Lookup</h1>
|
||||
|
||||
<div class="p-6 glass-card rounded-xl">
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<input type="text" id="mac-input" placeholder="Enter MAC address (e.g., 00:1A:2B:3C:4D:5E)"
|
||||
class="flex-grow px-4 py-3 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono transition-all placeholder-gray-600">
|
||||
<button id="mac-lookup-button" disabled
|
||||
class="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold py-3 px-6 rounded-lg shadow-lg transition-all duration-200">
|
||||
Find Vendor
|
||||
</button>
|
||||
</div>
|
||||
<div id="mac-lookup-error" class="hidden mb-4 p-3 bg-red-900/20 border border-red-500/30 rounded text-red-400 text-sm"></div>
|
||||
|
||||
<div id="mac-lookup-results-section" class="hidden mt-6 border-t border-gray-700/50 pt-6 fade-in">
|
||||
<h3 class="text-lg font-semibold text-purple-300 mb-4 flex items-center justify-center gap-2">
|
||||
<div class="w-1.5 h-6 bg-purple-500 rounded-full"></div>
|
||||
Vendor for: <span id="mac-lookup-query" class="font-mono text-purple-400 ml-1"></span>
|
||||
</h3>
|
||||
<div id="mac-lookup-loader" class="loader hidden mb-4 mx-auto"></div>
|
||||
<pre id="mac-lookup-output" class="result-pre text-center text-xl"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="global-error" class="mt-6 p-4 bg-red-900/50 border border-red-500/50 text-red-100 rounded-lg hidden backdrop-blur shadow-lg"></div>
|
||||
</div>`,
|
||||
|
||||
async init(search) {
|
||||
const input = document.getElementById('mac-input');
|
||||
const btn = document.getElementById('mac-lookup-button');
|
||||
const errorEl = document.getElementById('mac-lookup-error');
|
||||
const section = document.getElementById('mac-lookup-results-section');
|
||||
const queryEl = document.getElementById('mac-lookup-query');
|
||||
const loader = document.getElementById('mac-lookup-loader');
|
||||
const output = document.getElementById('mac-lookup-output');
|
||||
|
||||
const syncBtn = () => { btn.disabled = !input.value.trim(); };
|
||||
input.addEventListener('input', syncBtn);
|
||||
|
||||
async function doLookup() {
|
||||
const mac = input.value.trim();
|
||||
if (!mac) return;
|
||||
|
||||
showError(errorEl, null);
|
||||
section.classList.remove('hidden');
|
||||
loader.classList.remove('hidden');
|
||||
output.textContent = '';
|
||||
queryEl.textContent = mac;
|
||||
|
||||
try {
|
||||
const r = await fetch(`${API}/mac-lookup?mac=${encodeURIComponent(mac)}`);
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) throw new Error(data.error || `HTTP ${r.status}`);
|
||||
output.textContent = data.vendor || 'No vendor found.';
|
||||
} catch (err) {
|
||||
showError(errorEl, err.message);
|
||||
output.textContent = '';
|
||||
} finally {
|
||||
loader.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', doLookup);
|
||||
input.addEventListener('keypress', e => { if (e.key === 'Enter' && !btn.disabled) doLookup(); });
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
const m = params.get('mac');
|
||||
if (m) { input.value = m; syncBtn(); doLookup(); }
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
export const page = {
|
||||
title: 'Subnetz Rechner',
|
||||
|
||||
template: () => `
|
||||
<div class="container mx-auto max-w-5xl glass-panel rounded-xl shadow-2xl p-6 md:p-8 backdrop-blur-xl border border-gray-800/50">
|
||||
<h2 class="text-3xl font-bold mb-8 text-center text-gradient">IP Subnetz Rechner</h2>
|
||||
|
||||
<form id="subnet-form" class="mb-8 glass-card p-6 rounded-xl">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4">
|
||||
<div>
|
||||
<label for="ip-address" class="block text-gray-400 text-sm font-bold mb-2 uppercase tracking-wide">IP Adresse:</label>
|
||||
<input type="text" id="ip-address" name="ip-address" placeholder="z.B. 192.168.1.1" required
|
||||
class="w-full px-4 py-3 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="cidr" class="block text-gray-400 text-sm font-bold mb-2 uppercase tracking-wide">CIDR / Maske:</label>
|
||||
<input type="text" id="cidr" name="cidr" placeholder="z.B. 24 oder 255.255.255.0" required
|
||||
class="w-full px-4 py-3 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono transition-all">
|
||||
</div>
|
||||
</div>
|
||||
<div id="subnet-error" class="hidden mb-4 p-3 bg-red-900/20 border border-red-500/30 rounded text-red-400 text-sm"></div>
|
||||
<button type="submit"
|
||||
class="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold py-3 px-6 rounded-lg shadow-lg hover:shadow-purple-500/25 transition-all duration-200 ease-in-out transform hover:-translate-y-0.5">
|
||||
Berechnen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="results" class="glass-card rounded-xl p-6 hidden fade-in">
|
||||
<h3 class="text-xl font-bold text-purple-300 border-b border-purple-500/30 pb-2 mb-4 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Ergebnisse:
|
||||
</h3>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 border-b border-gray-700/50 pb-2">
|
||||
<span class="text-gray-400">Netzwerkadresse:</span>
|
||||
<span id="network-address" class="font-mono text-white font-semibold">-</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 border-b border-gray-700/50 pb-2">
|
||||
<span class="text-gray-400">Broadcast-Adresse:</span>
|
||||
<span id="broadcast-address" class="font-mono text-purple-400 font-semibold">-</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 border-b border-gray-700/50 pb-2">
|
||||
<span class="text-gray-400">Subnetzmaske:</span>
|
||||
<span id="subnet-mask" class="font-mono text-gray-300">-</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 border-b border-gray-700/50 pb-2">
|
||||
<span class="text-gray-400">Anzahl der Hosts:</span>
|
||||
<span id="host-count" class="font-mono text-green-400 font-bold">-</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 border-b border-gray-700/50 pb-2">
|
||||
<span class="text-gray-400">Erste Host-Adresse:</span>
|
||||
<span id="first-host" class="font-mono text-blue-300">-</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<span class="text-gray-400">Letzte Host-Adresse:</span>
|
||||
<span id="last-host" class="font-mono text-blue-300">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Example subnets -->
|
||||
<div class="glass-card rounded-xl p-6 mt-8">
|
||||
<h3 class="text-lg font-bold text-gray-400 uppercase tracking-wider border-b border-gray-700/50 pb-2 mb-4">
|
||||
Beispiel-Subnetze (Private Adressbereiche)
|
||||
</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm text-left text-gray-400">
|
||||
<thead class="text-xs uppercase bg-gray-800/50 text-gray-200">
|
||||
<tr>
|
||||
<th class="px-6 py-3">Bereich</th>
|
||||
<th class="px-6 py-3">CIDR</th>
|
||||
<th class="px-6 py-3">Subnetzmaske</th>
|
||||
<th class="px-6 py-3">Beschreibung</th>
|
||||
<th class="px-6 py-3">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700/50">
|
||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
||||
<td class="px-6 py-4 font-mono text-white">192.168.0.0 – 192.168.255.255</td>
|
||||
<td class="px-6 py-4 font-mono">/16 (Gesamt)</td>
|
||||
<td class="px-6 py-4 font-mono">255.255.0.0</td>
|
||||
<td class="px-6 py-4">Klasse C (oft als /24 genutzt)</td>
|
||||
<td class="px-6 py-4"><span class="example-link text-purple-400 hover:text-purple-300 cursor-pointer underline" data-ip="192.168.1.1" data-cidr="24">Beispiel /24</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
||||
<td class="px-6 py-4 font-mono text-white">172.16.0.0 – 172.31.255.255</td>
|
||||
<td class="px-6 py-4 font-mono">/12 (Gesamt)</td>
|
||||
<td class="px-6 py-4 font-mono">255.240.0.0</td>
|
||||
<td class="px-6 py-4">Klasse B</td>
|
||||
<td class="px-6 py-4"><span class="example-link text-purple-400 hover:text-purple-300 cursor-pointer underline" data-ip="172.16.10.5" data-cidr="16">Beispiel /16</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
||||
<td class="px-6 py-4 font-mono text-white">10.0.0.0 – 10.255.255.255</td>
|
||||
<td class="px-6 py-4 font-mono">/8 (Gesamt)</td>
|
||||
<td class="px-6 py-4 font-mono">255.0.0.0</td>
|
||||
<td class="px-6 py-4">Klasse A</td>
|
||||
<td class="px-6 py-4"><span class="example-link text-purple-400 hover:text-purple-300 cursor-pointer underline" data-ip="10.0.50.100" data-cidr="8">Beispiel /8</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-gray-500 italic">Klicken Sie auf "Beispiel", um die Felder auszufüllen und die Berechnung zu starten.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
init() {
|
||||
const form = document.getElementById('subnet-form');
|
||||
const ipInput = document.getElementById('ip-address');
|
||||
const cidrInput = document.getElementById('cidr');
|
||||
const errorEl = document.getElementById('subnet-error');
|
||||
const resultsEl = document.getElementById('results');
|
||||
|
||||
function showInlineError(msg) {
|
||||
errorEl.textContent = msg;
|
||||
errorEl.classList.toggle('hidden', !msg);
|
||||
}
|
||||
|
||||
function isValidIP(ip) {
|
||||
return /^(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)$/.test(ip);
|
||||
}
|
||||
|
||||
function ipToBinary(ip) {
|
||||
return ip.split('.').map(o => parseInt(o, 10).toString(2).padStart(8, '0')).join('');
|
||||
}
|
||||
|
||||
function binaryToIp(b) {
|
||||
const parts = [];
|
||||
for (let i = 0; i < 32; i += 8) parts.push(parseInt(b.slice(i, i + 8), 2));
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
function cidrToMask(cidr) {
|
||||
return binaryToIp('1'.repeat(cidr) + '0'.repeat(32 - cidr));
|
||||
}
|
||||
|
||||
function maskToCidr(mask) {
|
||||
const b = ipToBinary(mask);
|
||||
if (/^1*0*$/.test(b)) return b.replace(/0+$/, '').length;
|
||||
return null;
|
||||
}
|
||||
|
||||
function calculate() {
|
||||
showInlineError(null);
|
||||
const ip = ipInput.value.trim();
|
||||
const cidrRaw = cidrInput.value.trim();
|
||||
|
||||
if (!isValidIP(ip)) { showInlineError('Bitte eine gültige IPv4-Adresse eingeben.'); return; }
|
||||
|
||||
let cidr, mask;
|
||||
if (cidrRaw.includes('.')) {
|
||||
if (!isValidIP(cidrRaw)) { showInlineError('Bitte eine gültige Subnetzmaske eingeben.'); return; }
|
||||
cidr = maskToCidr(cidrRaw);
|
||||
if (cidr === null) { showInlineError('Ungültige Subnetzmaske — muss eine kontinuierliche Folge von Einsen sein (z.B. 255.255.255.0).'); return; }
|
||||
mask = cidrRaw;
|
||||
} else {
|
||||
cidr = parseInt(cidrRaw, 10);
|
||||
if (isNaN(cidr) || cidr < 0 || cidr > 32) { showInlineError('Bitte einen gültigen CIDR-Wert (0–32) eingeben.'); return; }
|
||||
mask = cidrToMask(cidr);
|
||||
}
|
||||
|
||||
const ipBin = ipToBinary(ip);
|
||||
const maskBin = '1'.repeat(cidr) + '0'.repeat(32 - cidr);
|
||||
let netBin = '';
|
||||
for (let i = 0; i < 32; i++) netBin += (parseInt(ipBin[i]) & parseInt(maskBin[i])).toString();
|
||||
|
||||
const hostBits = 32 - cidr;
|
||||
const bcBin = netBin.slice(0, cidr) + '1'.repeat(hostBits);
|
||||
const netNum = parseInt(netBin, 2);
|
||||
const bcNum = parseInt(bcBin, 2);
|
||||
|
||||
let hosts, first, last;
|
||||
if (hostBits >= 2) {
|
||||
hosts = Math.pow(2, hostBits) - 2;
|
||||
first = binaryToIp((netNum + 1).toString(2).padStart(32, '0'));
|
||||
last = binaryToIp((bcNum - 1).toString(2).padStart(32, '0'));
|
||||
} else if (cidr === 31) {
|
||||
hosts = 2; first = binaryToIp(netBin); last = binaryToIp(bcBin);
|
||||
} else {
|
||||
hosts = 1; first = binaryToIp(netBin); last = binaryToIp(netBin);
|
||||
}
|
||||
|
||||
document.getElementById('network-address').textContent = binaryToIp(netBin);
|
||||
document.getElementById('broadcast-address').textContent = binaryToIp(bcBin);
|
||||
document.getElementById('subnet-mask').textContent = mask;
|
||||
document.getElementById('host-count').textContent = hosts.toLocaleString();
|
||||
document.getElementById('first-host').textContent = first;
|
||||
document.getElementById('last-host').textContent = last;
|
||||
|
||||
resultsEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
form.addEventListener('submit', e => { e.preventDefault(); calculate(); });
|
||||
|
||||
document.querySelectorAll('.example-link').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
ipInput.value = link.dataset.ip;
|
||||
cidrInput.value = link.dataset.cidr;
|
||||
calculate();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { API, setupCopyBtn, showError } from '../shared.js';
|
||||
|
||||
export const page = {
|
||||
title: 'WHOIS Lookup',
|
||||
|
||||
template: () => `
|
||||
<div class="container mx-auto max-w-5xl glass-panel rounded-xl shadow-2xl p-6 md:p-8 backdrop-blur-xl border border-gray-800/50">
|
||||
<h1 class="text-3xl font-bold mb-8 text-center text-gradient">WHOIS Lookup</h1>
|
||||
|
||||
<div class="p-6 glass-card rounded-xl">
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<input type="text" id="whois-query-input" placeholder="Enter domain or IP (e.g., google.com or 8.8.8.8)"
|
||||
class="flex-grow px-4 py-3 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono transition-all placeholder-gray-600">
|
||||
<button id="whois-lookup-button" disabled
|
||||
class="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold py-3 px-6 rounded-lg shadow-lg transition-all duration-200">
|
||||
Lookup WHOIS
|
||||
</button>
|
||||
</div>
|
||||
<div id="whois-lookup-error" class="hidden mb-4 p-3 bg-red-900/20 border border-red-500/30 rounded text-red-400 text-sm"></div>
|
||||
|
||||
<div id="whois-lookup-results-section" class="hidden mt-6 border-t border-gray-700/50 pt-6 fade-in">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-purple-300 flex items-center gap-2">
|
||||
<div class="w-1.5 h-6 bg-purple-500 rounded-full"></div>
|
||||
WHOIS Results for: <span id="whois-lookup-query" class="font-mono text-purple-400 ml-1"></span>
|
||||
</h3>
|
||||
<button id="copy-whois-btn" class="copy-btn">copy</button>
|
||||
</div>
|
||||
<div id="whois-lookup-loader" class="loader hidden mb-4"></div>
|
||||
<pre id="whois-lookup-output" class="result-pre"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="global-error" class="mt-6 p-4 bg-red-900/50 border border-red-500/50 text-red-100 rounded-lg hidden backdrop-blur shadow-lg"></div>
|
||||
</div>`,
|
||||
|
||||
async init(search) {
|
||||
const input = document.getElementById('whois-query-input');
|
||||
const btn = document.getElementById('whois-lookup-button');
|
||||
const errorEl = document.getElementById('whois-lookup-error');
|
||||
const section = document.getElementById('whois-lookup-results-section');
|
||||
const queryEl = document.getElementById('whois-lookup-query');
|
||||
const loader = document.getElementById('whois-lookup-loader');
|
||||
const output = document.getElementById('whois-lookup-output');
|
||||
const copyBtn = document.getElementById('copy-whois-btn');
|
||||
|
||||
const syncBtn = () => { btn.disabled = !input.value.trim(); };
|
||||
input.addEventListener('input', syncBtn);
|
||||
|
||||
setupCopyBtn(copyBtn, () => output.textContent);
|
||||
|
||||
async function doLookup() {
|
||||
const query = input.value.trim();
|
||||
if (!query) return;
|
||||
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('query', query);
|
||||
history.replaceState({}, '', url);
|
||||
|
||||
showError(errorEl, null);
|
||||
section.classList.remove('hidden');
|
||||
loader.classList.remove('hidden');
|
||||
output.textContent = '';
|
||||
queryEl.textContent = query;
|
||||
|
||||
try {
|
||||
const r = await fetch(`${API}/whois-lookup?query=${encodeURIComponent(query)}`);
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) throw new Error(data.error || `HTTP ${r.status}`);
|
||||
output.textContent = typeof data.result === 'string'
|
||||
? data.result
|
||||
: JSON.stringify(data.result, null, 2);
|
||||
} catch (err) {
|
||||
showError(errorEl, err.message);
|
||||
output.textContent = '';
|
||||
} finally {
|
||||
loader.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', doLookup);
|
||||
input.addEventListener('keypress', e => { if (e.key === 'Enter' && !btn.disabled) doLookup(); });
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
const q = params.get('query');
|
||||
if (q) { input.value = q; syncBtn(); doLookup(); }
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { page as homePage } from './pages/home.js';
|
||||
import { page as subnetPage } from './pages/subnet.js';
|
||||
import { page as dnsPage } from './pages/dns.js';
|
||||
import { page as whoisPage } from './pages/whois.js';
|
||||
import { page as macPage } from './pages/mac.js';
|
||||
import { page as asnPage } from './pages/asn.js';
|
||||
|
||||
const routes = {
|
||||
'/': homePage,
|
||||
'/subnet': subnetPage,
|
||||
'/dns': dnsPage,
|
||||
'/whois': whoisPage,
|
||||
'/mac': macPage,
|
||||
'/asn': asnPage,
|
||||
};
|
||||
|
||||
const app = document.getElementById('app');
|
||||
const header = document.querySelector('header');
|
||||
let currentCleanup = null;
|
||||
|
||||
// ── Hamburger toggle ─────────────────────────────────────────────
|
||||
const navToggle = document.getElementById('nav-toggle');
|
||||
if (navToggle) {
|
||||
navToggle.addEventListener('click', () => {
|
||||
const open = header.classList.toggle('nav-open');
|
||||
navToggle.setAttribute('aria-expanded', open);
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveNav(path) {
|
||||
document.querySelectorAll('nav a').forEach(a => {
|
||||
try {
|
||||
const p = new URL(a.href).pathname;
|
||||
a.classList.toggle('active-link', p === path);
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
async function navigate(path, { push = true, search = '' } = {}) {
|
||||
const route = routes[path] ?? routes['/'];
|
||||
|
||||
// ── close mobile nav on navigate ────────────────────────────
|
||||
if (header) { header.classList.remove('nav-open'); navToggle?.setAttribute('aria-expanded', 'false'); }
|
||||
|
||||
// ── leave animation ──────────────────────────────────────────
|
||||
app.classList.add('page-leaving');
|
||||
if (currentCleanup) { try { currentCleanup(); } catch {} currentCleanup = null; }
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
// ── swap content ─────────────────────────────────────────────
|
||||
app.innerHTML = route.template();
|
||||
document.title = route.title ? `${route.title} – uTools` : 'uTools – Network Suite';
|
||||
setActiveNav(path);
|
||||
|
||||
const fullUrl = path + (search ? (search.startsWith('?') ? search : '?' + search) : '');
|
||||
if (push) history.pushState({ path }, '', fullUrl);
|
||||
|
||||
// ── enter animation ──────────────────────────────────────────
|
||||
app.classList.remove('page-leaving');
|
||||
app.classList.add('page-entering');
|
||||
setTimeout(() => app.classList.remove('page-entering'), 300);
|
||||
|
||||
// ── init page ────────────────────────────────────────────────
|
||||
const cleanup = await route.init(search);
|
||||
currentCleanup = typeof cleanup === 'function' ? cleanup : null;
|
||||
}
|
||||
|
||||
// ── Intercept same-origin link clicks ───────────────────────────
|
||||
document.addEventListener('click', e => {
|
||||
const a = e.target.closest('a[href]');
|
||||
if (!a) return;
|
||||
let url;
|
||||
try { url = new URL(a.href); } catch { return; }
|
||||
if (url.origin !== location.origin) return;
|
||||
if (!(url.pathname in routes)) return;
|
||||
e.preventDefault();
|
||||
navigate(url.pathname, { push: true, search: url.search });
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
navigate(location.pathname, { push: false, search: location.search });
|
||||
});
|
||||
|
||||
// ── Expose for programmatic navigation ──────────────────────────
|
||||
window._router = {
|
||||
navigate(path, searchObj = {}) {
|
||||
const s = new URLSearchParams(searchObj).toString();
|
||||
navigate(path, { push: true, search: s ? '?' + s : '' });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Fetch version once ───────────────────────────────────────────
|
||||
fetch('/api/version')
|
||||
.then(r => r.json())
|
||||
.then(d => { const el = document.getElementById('commit-sha'); if (el) el.textContent = d.commitSha || 'unknown'; })
|
||||
.catch(() => { const el = document.getElementById('commit-sha'); if (el) el.textContent = 'error'; });
|
||||
|
||||
// ── Initial render ───────────────────────────────────────────────
|
||||
navigate(location.pathname, { push: false, search: location.search });
|
||||
@@ -1,736 +0,0 @@
|
||||
// script.js - Hauptlogik für index.html (IP Info, IP Lookup, Traceroute)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- DOM Elements (User IP Info) ---
|
||||
const ipAddressLinkEl = document.getElementById('ip-address-link'); // Geändert von ip-address
|
||||
const ipAddressSpanEl = document.getElementById('ip-address'); // Das Span *innerhalb* des Links
|
||||
const countryEl = document.getElementById('country');
|
||||
const regionEl = document.getElementById('region');
|
||||
const cityEl = document.getElementById('city');
|
||||
const postalEl = document.getElementById('postal');
|
||||
const coordsEl = document.getElementById('coords');
|
||||
const timezoneEl = document.getElementById('timezone');
|
||||
const asnNumberEl = document.getElementById('asn-number');
|
||||
const asnOrgEl = document.getElementById('asn-org');
|
||||
const rdnsListEl = document.getElementById('rdns-list');
|
||||
const mapContainer = document.getElementById('map-container');
|
||||
const mapEl = document.getElementById('map');
|
||||
const mapMessageEl = document.getElementById('map-message');
|
||||
const globalErrorEl = document.getElementById('global-error');
|
||||
const ipLoader = document.getElementById('ip-loader');
|
||||
const geoLoader = document.getElementById('geo-loader');
|
||||
const asnLoader = document.getElementById('asn-loader');
|
||||
const rdnsLoader = document.getElementById('rdns-loader');
|
||||
const mapLoader = document.getElementById('map-loader');
|
||||
const geoErrorEl = document.getElementById('geo-error');
|
||||
const asnErrorEl = document.getElementById('asn-error');
|
||||
const rdnsErrorEl = document.getElementById('rdns-error');
|
||||
const geoInfo = document.getElementById('geo-info');
|
||||
const asnInfo = document.getElementById('asn-info');
|
||||
const rdnsInfo = document.getElementById('rdns-info');
|
||||
|
||||
|
||||
// --- DOM Elements (Lookup) ---
|
||||
const lookupIpInput = document.getElementById('lookup-ip-input');
|
||||
const lookupButton = document.getElementById('lookup-button');
|
||||
const lookupErrorEl = document.getElementById('lookup-error');
|
||||
const lookupStatusEl = document.getElementById('lookup-status'); // Optional: für Statusmeldungen wie "Resolving..."
|
||||
const lookupResultsSection = document.getElementById('lookup-results-section');
|
||||
const lookupIpAddressEl = document.getElementById('lookup-ip-address');
|
||||
const lookupResultLoader = document.getElementById('lookup-result-loader');
|
||||
const lookupCountryEl = document.getElementById('lookup-country');
|
||||
const lookupRegionEl = document.getElementById('lookup-region');
|
||||
const lookupCityEl = document.getElementById('lookup-city');
|
||||
const lookupPostalEl = document.getElementById('lookup-postal');
|
||||
const lookupCoordsEl = document.getElementById('lookup-coords');
|
||||
const lookupTimezoneEl = document.getElementById('lookup-timezone');
|
||||
const lookupGeoErrorEl = document.getElementById('lookup-geo-error');
|
||||
const lookupAsnNumberEl = document.getElementById('lookup-asn-number');
|
||||
const lookupAsnOrgEl = document.getElementById('lookup-asn-org');
|
||||
const lookupAsnErrorEl = document.getElementById('lookup-asn-error');
|
||||
const lookupRdnsListEl = document.getElementById('lookup-rdns-list');
|
||||
const lookupRdnsErrorEl = document.getElementById('lookup-rdns-error');
|
||||
const lookupMapContainer = document.getElementById('lookup-map-container');
|
||||
const lookupMapEl = document.getElementById('lookup-map');
|
||||
const lookupMapLoader = document.getElementById('lookup-map-loader');
|
||||
const lookupMapMessageEl = document.getElementById('lookup-map-message');
|
||||
const lookupPingButton = document.getElementById('lookup-ping-button');
|
||||
const lookupTraceButton = document.getElementById('lookup-trace-button');
|
||||
const lookupPingResultsEl = document.getElementById('lookup-ping-results');
|
||||
const lookupPingLoader = document.getElementById('lookup-ping-loader');
|
||||
const lookupPingOutputEl = document.getElementById('lookup-ping-output');
|
||||
const lookupPingErrorEl = document.getElementById('lookup-ping-error');
|
||||
|
||||
|
||||
// --- DOM Elements (Traceroute) ---
|
||||
const tracerouteSection = document.getElementById('traceroute-section');
|
||||
const tracerouteOutputEl = document.querySelector('#traceroute-output pre');
|
||||
const tracerouteLoader = document.getElementById('traceroute-loader');
|
||||
const tracerouteMessage = document.getElementById('traceroute-message');
|
||||
|
||||
// --- DOM Elements (Footer) ---
|
||||
const commitShaEl = document.getElementById('commit-sha');
|
||||
|
||||
// --- Configuration ---
|
||||
const API_BASE_URL = '/api'; // Anpassen, falls nötig
|
||||
|
||||
// --- State ---
|
||||
let map = null; // Leaflet map instance for user's IP
|
||||
let lookupMap = null; // Leaflet map instance for lookup results
|
||||
let currentIp = null; // Store the user's fetched IP
|
||||
let currentLookupIp = null; // Store the last successfully looked-up IP
|
||||
let eventSource = null; // Store the EventSource instance for traceroute
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
/** Zeigt globale Fehler an */
|
||||
function showGlobalError(message) {
|
||||
if (!globalErrorEl) return;
|
||||
globalErrorEl.textContent = `Error: ${message}`;
|
||||
globalErrorEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/** Versteckt globale Fehler */
|
||||
function hideGlobalError() {
|
||||
if (!globalErrorEl) return;
|
||||
globalErrorEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob der String eine gültige IPv4 oder IPv6 Adresse ist.
|
||||
* @param {string} input - Der zu prüfende String.
|
||||
* @returns {boolean} True, wenn es eine gültige IP ist, sonst false.
|
||||
*/
|
||||
function isValidIpAddress(input) {
|
||||
if (!input || typeof input !== 'string') return false;
|
||||
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
// Sehr einfache IPv6 Regex (erkennt gültige Zeichen, aber nicht alle komplexen Formate perfekt)
|
||||
const ipv6Regex = /^[a-fA-F0-9:]+$/;
|
||||
// Komplexere IPv6 Regex (versucht mehr Fälle abzudecken, aber immer noch nicht perfekt)
|
||||
const complexIpv6Regex = /^(?:(?:[a-fA-F0-9]{1,4}:){7}[a-fA-F0-9]{1,4}|(?:[a-fA-F0-9]{1,4}:){1,7}:|(?:[a-fA-F0-9]{1,4}:){1,6}:[a-fA-F0-9]{1,4}|(?:[a-fA-F0-9]{1,4}:){1,5}(?::[a-fA-F0-9]{1,4}){1,2}|(?:[a-fA-F0-9]{1,4}:){1,4}(?::[a-fA-F0-9]{1,4}){1,3}|(?:[a-fA-F0-9]{1,4}:){1,3}(?::[a-fA-F0-9]{1,4}){1,4}|(?:[a-fA-F0-9]{1,4}:){1,2}(?::[a-fA-F0-9]{1,4}){1,5}|[a-fA-F0-9]{1,4}:(?:(?::[a-fA-F0-9]{1,4}){1,6})|:(?:(?::[a-fA-F0-9]{1,4}){1,7}|:)|fe80:(?::[a-fA-F0-9]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(?:ffff(?::0{1,4}){0,1}:){0,1}(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])|(?:[a-fA-F0-9]{1,4}:){1,4}:(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
|
||||
|
||||
return ipv4Regex.test(input) || complexIpv6Regex.test(input);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Aktualisiert ein Info-Feld und versteckt optional einen Loader.
|
||||
* @param {HTMLElement} valueElement - Das Element, das den Wert anzeigt.
|
||||
* @param {any} value - Der anzuzeigende Wert oder ein Fehlerobjekt {error: string}.
|
||||
* @param {HTMLElement} [loaderElement] - Das zu versteckende Loader-Element.
|
||||
* @param {HTMLElement} [errorElement] - Das Element zur Anzeige von Fehlern für dieses Feld.
|
||||
* @param {string} [defaultValue='-'] - Standardwert bei fehlenden Daten.
|
||||
*/
|
||||
function updateField(valueElement, value, loaderElement = null, errorElement = null, defaultValue = '-') {
|
||||
if (loaderElement) loaderElement.classList.add('hidden');
|
||||
if (errorElement) errorElement.textContent = ''; // Clear previous error
|
||||
|
||||
// Zeige das Elternelement des valueElements, falls es vorher versteckt war (für initiale Ladeanzeige)
|
||||
const dataContainer = valueElement?.closest('div:not(.loader)'); // Find closest parent div that isn't a loader
|
||||
if (dataContainer?.classList.contains('hidden')) {
|
||||
dataContainer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object' && value.error) {
|
||||
if (valueElement) valueElement.textContent = defaultValue;
|
||||
if (errorElement) errorElement.textContent = value.error;
|
||||
else console.warn(`Error in field ${valueElement?.id}: ${value.error}`);
|
||||
} else if (value !== null && value !== undefined && value !== '') {
|
||||
if (valueElement) valueElement.textContent = value;
|
||||
} else {
|
||||
if (valueElement) valueElement.textContent = defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die rDNS Liste generisch.
|
||||
* @param {HTMLElement} listElement - Das UL Element.
|
||||
* @param {Array|object} rdnsData - Die rDNS Daten oder ein Fehlerobjekt.
|
||||
* @param {HTMLElement} [loaderElement] - Das zu versteckende Loader-Element.
|
||||
* @param {HTMLElement} [errorElement] - Das Element zur Anzeige von Fehlern.
|
||||
*/
|
||||
function updateRdns(listElement, rdnsData, loaderElement = null, errorElement = null) {
|
||||
if (loaderElement) loaderElement.classList.add('hidden');
|
||||
if (listElement) listElement.innerHTML = ''; // Clear previous entries
|
||||
if (errorElement) errorElement.textContent = '';
|
||||
|
||||
// Zeige das Elternelement des listElements, falls es vorher versteckt war
|
||||
const dataContainer = listElement?.closest('div:not(.loader)');
|
||||
if (dataContainer?.classList.contains('hidden')) {
|
||||
dataContainer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
if (rdnsData && Array.isArray(rdnsData)) {
|
||||
if (rdnsData.length > 0) {
|
||||
rdnsData.forEach(hostname => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = hostname;
|
||||
if (listElement) listElement.appendChild(li);
|
||||
});
|
||||
} else {
|
||||
if (listElement) listElement.innerHTML = '<li>No rDNS records found.</li>'; // Klarere Meldung
|
||||
}
|
||||
} else if (rdnsData && rdnsData.error) {
|
||||
if (listElement) listElement.innerHTML = '<li>-</li>';
|
||||
if (errorElement) errorElement.textContent = rdnsData.error;
|
||||
} else {
|
||||
if (listElement) listElement.innerHTML = '<li>-</li>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisiert oder aktualisiert eine Leaflet-Karte.
|
||||
* @param {string} mapId - Die ID des Map-Containers ('map' oder 'lookup-map').
|
||||
* @param {number|null} lat - Breitengrad.
|
||||
* @param {number|null} lon - Längengrad.
|
||||
* @param {HTMLElement} mapElement - Das Karten-Div.
|
||||
* @param {HTMLElement} loaderElement - Das Loader-Element für die Karte.
|
||||
* @param {HTMLElement} messageElement - Das Nachrichten-Element für die Karte.
|
||||
* @returns {L.Map | null} Die Karteninstanz oder null bei Fehler.
|
||||
*/
|
||||
function initOrUpdateMap(mapId, lat, lon, mapElement, loaderElement, messageElement) {
|
||||
if (!mapElement || !loaderElement || !messageElement) return null; // Exit if elements are missing
|
||||
loaderElement.classList.add('hidden'); // Hide loader first
|
||||
|
||||
// Use a unique variable name for the map instance based on mapId
|
||||
let mapInstance = window[mapId + '_instance'];
|
||||
|
||||
if (lat != null && lon != null) { // Check for non-null coordinates
|
||||
mapElement.classList.remove('hidden');
|
||||
messageElement.classList.add('hidden');
|
||||
|
||||
if (mapInstance) {
|
||||
mapInstance.setView([lat, lon], 13);
|
||||
mapInstance.eachLayer((layer) => {
|
||||
if (layer instanceof L.Marker) mapInstance.removeLayer(layer);
|
||||
});
|
||||
L.marker([lat, lon]).addTo(mapInstance).bindPopup(`Approximate Location`).openPopup();
|
||||
} else {
|
||||
try {
|
||||
mapInstance = L.map(mapId).setView([lat, lon], 13);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(mapInstance);
|
||||
L.marker([lat, lon]).addTo(mapInstance).bindPopup(`Approximate Location`).openPopup();
|
||||
window[mapId + '_instance'] = mapInstance; // Store instance
|
||||
} catch (e) {
|
||||
console.error(`Leaflet map initialization failed for ${mapId}:`, e);
|
||||
mapElement.classList.add('hidden');
|
||||
messageElement.classList.remove('hidden');
|
||||
messageElement.textContent = 'Error initializing map.';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Invalidate size after showing/updating to prevent grey tiles
|
||||
setTimeout(() => {
|
||||
if (window[mapId + '_instance']) { // Check if map still exists
|
||||
window[mapId + '_instance'].invalidateSize();
|
||||
}
|
||||
}, 100);
|
||||
return mapInstance;
|
||||
} else {
|
||||
mapElement.classList.add('hidden');
|
||||
messageElement.classList.remove('hidden');
|
||||
messageElement.textContent = 'Map could not be loaded (missing or invalid coordinates).';
|
||||
// If map existed, remove it to clean up resources
|
||||
if(mapInstance) {
|
||||
mapInstance.remove();
|
||||
window[mapId + '_instance'] = null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ruft die IP-Informationen für die eigene IP ab */
|
||||
async function fetchIpInfo() {
|
||||
hideGlobalError();
|
||||
[ipLoader, geoLoader, asnLoader, rdnsLoader, mapLoader].forEach(l => l?.classList.remove('hidden'));
|
||||
// Hide data elements initially (containers are hidden by default in HTML)
|
||||
if (ipAddressLinkEl) ipAddressLinkEl.classList.add('hidden'); // Hide link initially
|
||||
if (mapEl) mapEl.classList.add('hidden');
|
||||
// Ensure map message is hidden initially
|
||||
if (mapMessageEl) mapMessageEl.classList.add('hidden');
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/ipinfo`);
|
||||
if (!response.ok) throw new Error(`Network response: ${response.statusText} (${response.status})`);
|
||||
const data = await response.json();
|
||||
console.log('Received User IP Info:', data);
|
||||
|
||||
currentIp = data.ip;
|
||||
// Update the span inside the link
|
||||
updateField(ipAddressSpanEl, data.ip, ipLoader);
|
||||
if (ipAddressLinkEl) {
|
||||
ipAddressLinkEl.classList.remove('hidden'); // Show link element
|
||||
if (data.ip) {
|
||||
// Remove old listener if it exists (safety)
|
||||
ipAddressLinkEl.removeEventListener('click', handleIpClick);
|
||||
// Add new listener
|
||||
ipAddressLinkEl.addEventListener('click', handleIpClick);
|
||||
}
|
||||
}
|
||||
|
||||
updateField(countryEl, data.geo?.countryName ? `${data.geo.countryName} (${data.geo.country})` : null, null, geoErrorEl);
|
||||
updateField(regionEl, data.geo?.region);
|
||||
updateField(cityEl, data.geo?.city);
|
||||
updateField(postalEl, data.geo?.postalCode);
|
||||
updateField(coordsEl, data.geo?.latitude ? `${data.geo.latitude}, ${data.geo.longitude}` : null);
|
||||
updateField(timezoneEl, data.geo?.timezone, geoLoader); // Hide loader on last geo field
|
||||
|
||||
updateField(asnNumberEl, data.asn?.number, null, asnErrorEl);
|
||||
updateField(asnOrgEl, data.asn?.organization, asnLoader);
|
||||
|
||||
updateRdns(rdnsListEl, data.rdns, rdnsLoader, rdnsErrorEl);
|
||||
|
||||
map = initOrUpdateMap('map', data.geo?.latitude, data.geo?.longitude, mapEl, mapLoader, mapMessageEl);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user IP info:', error);
|
||||
showGlobalError(`Could not load initial IP information. ${error.message}`);
|
||||
[ipLoader, geoLoader, asnLoader, rdnsLoader, mapLoader].forEach(l => l?.classList.add('hidden'));
|
||||
// Ensure data containers are visible to show potential errors inside them
|
||||
[geoInfo, asnInfo, rdnsInfo].forEach(container => {
|
||||
const dataDiv = container?.querySelector('div:not(.loader)'); // Select the data div, not the loader
|
||||
if (dataDiv) dataDiv.classList.remove('hidden');
|
||||
});
|
||||
if (mapMessageEl) {
|
||||
mapMessageEl.textContent = 'Map could not be loaded due to an error.';
|
||||
mapMessageEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ruft die Versionsinformationen (Commit SHA) ab */
|
||||
async function fetchVersionInfo() {
|
||||
if (!commitShaEl) return; // Don't fetch if element doesn't exist
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/version`);
|
||||
if (!response.ok) throw new Error(`Network response: ${response.statusText} (${response.status})`);
|
||||
const data = await response.json();
|
||||
commitShaEl.textContent = data.commitSha || 'unknown';
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version info:', error);
|
||||
commitShaEl.textContent = 'error';
|
||||
// Optionally show global error
|
||||
// showGlobalError(`Could not load version info: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lookup Functions ---
|
||||
|
||||
/** Zeigt Fehler im Lookup-Bereich an */
|
||||
function showLookupError(message) {
|
||||
if (!lookupErrorEl) return;
|
||||
lookupErrorEl.textContent = `Error: ${message}`;
|
||||
lookupErrorEl.classList.remove('hidden');
|
||||
// Hide status message if error occurs
|
||||
if (lookupStatusEl) lookupStatusEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
/** Versteckt Fehler im Lookup-Bereich */
|
||||
function hideLookupError() {
|
||||
if (!lookupErrorEl) return;
|
||||
lookupErrorEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
/** Zeigt eine Statusmeldung im Lookup-Bereich an */
|
||||
function showLookupStatus(message) {
|
||||
if (!lookupStatusEl) return;
|
||||
lookupStatusEl.textContent = message;
|
||||
lookupStatusEl.classList.remove('hidden');
|
||||
hideLookupError(); // Hide errors when showing status
|
||||
}
|
||||
|
||||
/** Versteckt die Statusmeldung im Lookup-Bereich */
|
||||
function hideLookupStatus() {
|
||||
if (!lookupStatusEl) return;
|
||||
lookupStatusEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
|
||||
/** Setzt den Lookup-Ergebnisbereich zurück */
|
||||
function resetLookupResults() {
|
||||
if (!lookupResultsSection) return;
|
||||
lookupResultsSection.classList.add('hidden');
|
||||
if (lookupResultLoader) lookupResultLoader.classList.add('hidden');
|
||||
if (lookupMapLoader) lookupMapLoader.classList.add('hidden');
|
||||
if (lookupMapEl) lookupMapEl.classList.add('hidden');
|
||||
if (lookupMapMessageEl) lookupMapMessageEl.classList.add('hidden');
|
||||
if (lookupPingResultsEl) lookupPingResultsEl.classList.add('hidden'); // Hide ping results too
|
||||
if (lookupPingLoader) lookupPingLoader.classList.add('hidden');
|
||||
if (lookupPingOutputEl) lookupPingOutputEl.textContent = '';
|
||||
if (lookupPingErrorEl) lookupPingErrorEl.textContent = '';
|
||||
hideLookupStatus(); // Hide status on reset
|
||||
|
||||
const fieldsToClear = [
|
||||
lookupIpAddressEl, lookupCountryEl, lookupRegionEl, lookupCityEl,
|
||||
lookupPostalEl, lookupCoordsEl, lookupTimezoneEl, lookupAsnNumberEl,
|
||||
lookupAsnOrgEl, lookupGeoErrorEl, lookupAsnErrorEl, lookupRdnsErrorEl
|
||||
];
|
||||
fieldsToClear.forEach(el => { if (el) el.textContent = ''; });
|
||||
if (lookupRdnsListEl) lookupRdnsListEl.innerHTML = '<li>-</li>';
|
||||
|
||||
if (lookupPingButton) lookupPingButton.disabled = true;
|
||||
if (lookupTraceButton) lookupTraceButton.disabled = true;
|
||||
currentLookupIp = null;
|
||||
|
||||
// Remove lookup map instance if it exists
|
||||
if (window['lookup-map_instance']) {
|
||||
window['lookup-map_instance'].remove();
|
||||
window['lookup-map_instance'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ruft Informationen für eine spezifische IP ab */
|
||||
async function fetchLookupInfo(ipToLookup) {
|
||||
resetLookupResults(); // Reset before showing new results/loaders
|
||||
hideLookupError();
|
||||
hideGlobalError();
|
||||
if (!lookupResultsSection || !lookupResultLoader || !lookupMapLoader) return; // Exit if elements missing
|
||||
|
||||
lookupResultsSection.classList.remove('hidden');
|
||||
lookupResultLoader.classList.remove('hidden');
|
||||
lookupMapLoader.classList.remove('hidden'); // Show map loader initially
|
||||
hideLookupStatus(); // Hide status like "Resolving..."
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/lookup?targetIp=${encodeURIComponent(ipToLookup)}`); // Use targetIp parameter
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `Network response: ${response.statusText} (${response.status})`);
|
||||
}
|
||||
|
||||
console.log(`Received Lookup Info for ${ipToLookup}:`, data);
|
||||
currentLookupIp = data.ip; // Store the IP that was actually looked up
|
||||
|
||||
updateField(lookupIpAddressEl, data.ip); // Display the looked-up IP
|
||||
updateField(lookupCountryEl, data.geo?.countryName ? `${data.geo.countryName} (${data.geo.country})` : null, null, lookupGeoErrorEl);
|
||||
updateField(lookupRegionEl, data.geo?.region);
|
||||
updateField(lookupCityEl, data.geo?.city);
|
||||
updateField(lookupPostalEl, data.geo?.postalCode);
|
||||
updateField(lookupCoordsEl, data.geo?.latitude ? `${data.geo.latitude}, ${data.geo.longitude}` : null);
|
||||
updateField(lookupTimezoneEl, data.geo?.timezone);
|
||||
|
||||
updateField(lookupAsnNumberEl, data.asn?.number, null, lookupAsnErrorEl);
|
||||
updateField(lookupAsnOrgEl, data.asn?.organization);
|
||||
|
||||
updateRdns(lookupRdnsListEl, data.rdns, null, lookupRdnsErrorEl);
|
||||
|
||||
lookupMap = initOrUpdateMap('lookup-map', data.geo?.latitude, data.geo?.longitude, lookupMapEl, lookupMapLoader, lookupMapMessageEl);
|
||||
|
||||
if (lookupPingButton) lookupPingButton.disabled = false;
|
||||
if (lookupTraceButton) lookupTraceButton.disabled = false;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch lookup info for ${ipToLookup}:`, error);
|
||||
showLookupError(`Lookup failed: ${error.message}`);
|
||||
if (lookupMapMessageEl) {
|
||||
lookupMapMessageEl.textContent = 'Map could not be loaded due to an error.';
|
||||
lookupMapMessageEl.classList.remove('hidden');
|
||||
}
|
||||
if (lookupMapEl) lookupMapEl.classList.add('hidden');
|
||||
if (lookupMapLoader) lookupMapLoader.classList.add('hidden'); // Hide loader on error
|
||||
resetLookupResults(); // Hide the section again on error
|
||||
|
||||
} finally {
|
||||
if (lookupResultLoader) lookupResultLoader.classList.add('hidden'); // Hide main loader
|
||||
// Map loader is handled by initOrUpdateMap
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst einen Domainnamen zu einer IP-Adresse auf (bevorzugt IPv4).
|
||||
* @param {string} domain - Der aufzulösende Domainname.
|
||||
* @returns {Promise<string|null>} Eine Promise, die mit der IP-Adresse oder null aufgelöst wird.
|
||||
*/
|
||||
async function resolveDomainToIp(domain) {
|
||||
console.log(`Attempting to resolve domain: ${domain}`);
|
||||
try {
|
||||
// 1. Versuche A Record (IPv4)
|
||||
let response = await fetch(`${API_BASE_URL}/dns-lookup?domain=${encodeURIComponent(domain)}&type=A`);
|
||||
let data = await response.json();
|
||||
|
||||
if (response.ok && data.success && data.records && Array.isArray(data.records) && data.records.length > 0) {
|
||||
console.log(`Resolved ${domain} to IPv4: ${data.records[0]}`);
|
||||
return data.records[0]; // Nimm die erste IPv4-Adresse
|
||||
}
|
||||
|
||||
// 2. Wenn kein A-Record, versuche AAAA Record (IPv6)
|
||||
console.log(`No A record found for ${domain}, trying AAAA.`);
|
||||
response = await fetch(`${API_BASE_URL}/dns-lookup?domain=${encodeURIComponent(domain)}&type=AAAA`);
|
||||
data = await response.json();
|
||||
|
||||
if (response.ok && data.success && data.records && Array.isArray(data.records) && data.records.length > 0) {
|
||||
console.log(`Resolved ${domain} to IPv6: ${data.records[0]}`);
|
||||
return data.records[0]; // Nimm die erste IPv6-Adresse
|
||||
}
|
||||
|
||||
// 3. Wenn beides fehlschlägt oder keine Records gefunden wurden
|
||||
console.warn(`Could not resolve domain ${domain} to an IP address.`);
|
||||
throw new Error(data.error || 'No A or AAAA records found.');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`DNS resolution failed for ${domain}:`, error);
|
||||
throw new Error(`Could not resolve domain: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Ping Function (for Lookup) ---
|
||||
async function runLookupPing(ip) {
|
||||
if (!ip || !lookupPingResultsEl || !lookupPingLoader || !lookupPingOutputEl || !lookupPingErrorEl) return;
|
||||
|
||||
lookupPingResultsEl.classList.remove('hidden');
|
||||
lookupPingLoader.classList.remove('hidden');
|
||||
lookupPingOutputEl.textContent = '';
|
||||
lookupPingErrorEl.textContent = '';
|
||||
hideLookupError(); // Hide general lookup errors
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/ping?targetIp=${encodeURIComponent(ip)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || `Ping request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
console.log(`Ping results for ${ip}:`, data);
|
||||
|
||||
// Display parsed results nicely
|
||||
let outputText = `--- Ping Statistics for ${ip} ---\n`;
|
||||
if (data.stats) {
|
||||
outputText += `Packets: ${data.stats.packets.transmitted} transmitted, ${data.stats.packets.received} received, ${data.stats.packets.lossPercent}% loss\n`;
|
||||
if (data.stats.rtt) {
|
||||
outputText += `Round Trip Time (ms): min=${data.stats.rtt.min}, avg=${data.stats.rtt.avg}, max=${data.stats.rtt.max}, mdev=${data.stats.rtt.mdev}\n`;
|
||||
} else if (data.stats.packets.received === 0) {
|
||||
outputText += `Status: Host unreachable or request timed out.\n`;
|
||||
}
|
||||
} else {
|
||||
outputText += `Could not parse statistics.\n`;
|
||||
}
|
||||
outputText += `\n--- Raw Output ---\n${data.rawOutput || 'No raw output available.'}`;
|
||||
lookupPingOutputEl.textContent = outputText;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to run ping for ${ip}:`, error);
|
||||
lookupPingErrorEl.textContent = `Ping Error: ${error.message}`;
|
||||
} finally {
|
||||
lookupPingLoader.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Traceroute Functions ---
|
||||
function startTraceroute(ip) {
|
||||
if (!ip) {
|
||||
showGlobalError('Cannot start traceroute: IP address is missing.');
|
||||
return;
|
||||
}
|
||||
if (!tracerouteSection || !tracerouteOutputEl || !tracerouteLoader || !tracerouteMessage) return;
|
||||
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
console.log('Previous EventSource closed.');
|
||||
}
|
||||
|
||||
tracerouteSection.classList.remove('hidden');
|
||||
tracerouteOutputEl.textContent = '';
|
||||
tracerouteLoader.classList.remove('hidden');
|
||||
tracerouteMessage.textContent = `Starting traceroute to ${ip}...`;
|
||||
hideGlobalError();
|
||||
hideLookupError();
|
||||
|
||||
const url = `${API_BASE_URL}/traceroute?targetIp=${encodeURIComponent(ip)}`;
|
||||
eventSource = new EventSource(url);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('SSE connection opened for traceroute.');
|
||||
tracerouteMessage.textContent = `Traceroute to ${ip} in progress...`;
|
||||
};
|
||||
|
||||
eventSource.onerror = (event) => {
|
||||
console.error('EventSource failed:', event);
|
||||
let errorMsg = 'Connection error during traceroute.';
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
errorMsg = 'Connection closed. Server might have stopped or a network issue occurred.';
|
||||
}
|
||||
tracerouteMessage.textContent = errorMsg;
|
||||
tracerouteLoader.classList.add('hidden');
|
||||
// Don't show global error here, as it might be a normal close
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
eventSource.addEventListener('hop', (event) => {
|
||||
try {
|
||||
const hopData = JSON.parse(event.data);
|
||||
displayTracerouteHop(hopData);
|
||||
} catch (e) { displayTracerouteLine(`[Error parsing hop data: ${event.data}]`, 'error-line'); }
|
||||
});
|
||||
|
||||
eventSource.addEventListener('info', (event) => {
|
||||
try {
|
||||
const infoData = JSON.parse(event.data);
|
||||
displayTracerouteLine(infoData.message, 'info-line');
|
||||
} catch (e) { displayTracerouteLine(`[Error parsing info data: ${event.data}]`, 'error-line'); }
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', (event) => { // Backend error event
|
||||
try {
|
||||
const errorData = JSON.parse(event.data);
|
||||
displayTracerouteLine(errorData.error, 'error-line');
|
||||
tracerouteMessage.textContent = `Error during traceroute: ${errorData.error}`;
|
||||
} catch (e) { displayTracerouteLine(`[Received unparseable error event: ${event.data}]`, 'error-line'); }
|
||||
});
|
||||
|
||||
eventSource.addEventListener('end', (event) => {
|
||||
console.log('SSE connection closed by server (end event).');
|
||||
try {
|
||||
const endData = JSON.parse(event.data);
|
||||
const endMessage = `Traceroute finished ${endData.exitCode === 0 ? 'successfully' : `with exit code ${endData.exitCode}`}.`;
|
||||
displayTracerouteLine(endMessage, 'end-line');
|
||||
tracerouteMessage.textContent = endMessage;
|
||||
} catch (e) { displayTracerouteLine('[Traceroute finished, error parsing end event]', 'end-line'); }
|
||||
tracerouteLoader.classList.add('hidden');
|
||||
eventSource.close();
|
||||
});
|
||||
}
|
||||
|
||||
function displayTracerouteLine(text, className = '') {
|
||||
if (!tracerouteOutputEl) return;
|
||||
const lineDiv = document.createElement('div');
|
||||
if (className) lineDiv.classList.add(className);
|
||||
lineDiv.textContent = text;
|
||||
tracerouteOutputEl.appendChild(lineDiv);
|
||||
tracerouteOutputEl.scrollTop = tracerouteOutputEl.scrollHeight;
|
||||
}
|
||||
|
||||
function displayTracerouteHop(hopData) {
|
||||
if (!tracerouteOutputEl) return;
|
||||
const lineDiv = document.createElement('div');
|
||||
lineDiv.classList.add('hop-line');
|
||||
|
||||
const hopNumSpan = document.createElement('span');
|
||||
hopNumSpan.classList.add('hop-number');
|
||||
hopNumSpan.textContent = hopData.hop || '?';
|
||||
lineDiv.appendChild(hopNumSpan);
|
||||
|
||||
if (hopData.ip) {
|
||||
const ipSpan = document.createElement('span');
|
||||
ipSpan.classList.add('hop-ip');
|
||||
ipSpan.textContent = hopData.ip;
|
||||
lineDiv.appendChild(ipSpan);
|
||||
if (hopData.hostname) {
|
||||
const hostSpan = document.createElement('span');
|
||||
hostSpan.classList.add('hop-hostname');
|
||||
hostSpan.textContent = ` (${hopData.hostname})`;
|
||||
lineDiv.appendChild(hostSpan);
|
||||
}
|
||||
} else if (hopData.rtt && hopData.rtt.every(r => r === '*')) {
|
||||
const timeoutSpan = document.createElement('span');
|
||||
timeoutSpan.classList.add('hop-timeout');
|
||||
timeoutSpan.textContent = '* * *';
|
||||
lineDiv.appendChild(timeoutSpan);
|
||||
} else {
|
||||
lineDiv.appendChild(document.createTextNode(hopData.rawLine || 'Unknown hop format'));
|
||||
}
|
||||
|
||||
if (hopData.rtt && Array.isArray(hopData.rtt)) {
|
||||
hopData.rtt.forEach(rtt => {
|
||||
const rttSpan = document.createElement('span');
|
||||
if (rtt === '*') {
|
||||
rttSpan.classList.add('hop-timeout');
|
||||
rttSpan.textContent = ' *';
|
||||
} else {
|
||||
rttSpan.classList.add('hop-rtt');
|
||||
rttSpan.textContent = ` ${rtt} ms`;
|
||||
}
|
||||
lineDiv.appendChild(rttSpan);
|
||||
});
|
||||
}
|
||||
tracerouteOutputEl.appendChild(lineDiv);
|
||||
tracerouteOutputEl.scrollTop = tracerouteOutputEl.scrollHeight;
|
||||
}
|
||||
|
||||
// --- Event Handlers ---
|
||||
function handleIpClick(event) {
|
||||
event.preventDefault(); // Verhindert das Standardverhalten des Links (#)
|
||||
if (currentIp) {
|
||||
console.log(`User IP link clicked: ${currentIp}. Redirecting to WHOIS lookup...`);
|
||||
// Leite zur Whois-Seite weiter und übergebe die IP als 'query'-Parameter
|
||||
window.location.href = `whois-lookup.html?query=${encodeURIComponent(currentIp)}`;
|
||||
} else {
|
||||
console.warn('Cannot redirect to WHOIS: current IP is not available.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLookupClick() {
|
||||
if (!lookupIpInput) return;
|
||||
const query = lookupIpInput.value.trim();
|
||||
if (!query) {
|
||||
showLookupError('Please enter an IP address or domain name.');
|
||||
return;
|
||||
}
|
||||
|
||||
resetLookupResults(); // Reset results before starting
|
||||
hideLookupError();
|
||||
|
||||
if (isValidIpAddress(query)) {
|
||||
// Input is an IP address
|
||||
console.log(`Lookup button clicked for IP: ${query}`);
|
||||
fetchLookupInfo(query);
|
||||
} else {
|
||||
// Input is likely a domain name
|
||||
console.log(`Lookup button clicked for domain: ${query}`);
|
||||
showLookupStatus(`Resolving domain ${query}...`); // Show status
|
||||
try {
|
||||
const resolvedIp = await resolveDomainToIp(query);
|
||||
if (resolvedIp) {
|
||||
console.log(`Domain ${query} resolved to ${resolvedIp}. Fetching lookup info...`);
|
||||
// Optional: Update input field with resolved IP? Maybe not, keep original query.
|
||||
// lookupIpInput.value = resolvedIp;
|
||||
fetchLookupInfo(resolvedIp); // Fetch info for the resolved IP
|
||||
} else {
|
||||
// Should be caught by the error in resolveDomainToIp, but as a fallback:
|
||||
showLookupError(`Could not resolve domain ${query} to an IP address.`);
|
||||
}
|
||||
} catch (error) {
|
||||
showLookupError(error.message); // Display resolution error
|
||||
} finally {
|
||||
hideLookupStatus(); // Hide status message regardless of outcome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleLookupPingClick() {
|
||||
if (currentLookupIp) {
|
||||
console.log(`Starting ping for looked-up IP: ${currentLookupIp}`);
|
||||
runLookupPing(currentLookupIp); // Call the new ping function
|
||||
}
|
||||
}
|
||||
|
||||
function handleLookupTraceClick() {
|
||||
if (currentLookupIp) {
|
||||
console.log(`Starting traceroute for looked-up IP: ${currentLookupIp}`);
|
||||
startTraceroute(currentLookupIp);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Initial Load & Event Listeners ---
|
||||
fetchIpInfo(); // Lade Infos zur eigenen IP
|
||||
fetchVersionInfo(); // Lade Versionsinfo für Footer
|
||||
|
||||
// IP Lookup Listeners (nur wenn Elemente existieren)
|
||||
if (lookupButton) lookupButton.addEventListener('click', handleLookupClick);
|
||||
if (lookupIpInput) lookupIpInput.addEventListener('keypress', (event) => {
|
||||
if (event.key === 'Enter') handleLookupClick();
|
||||
});
|
||||
if (lookupPingButton) lookupPingButton.addEventListener('click', handleLookupPingClick);
|
||||
if (lookupTraceButton) lookupTraceButton.addEventListener('click', handleLookupTraceClick);
|
||||
|
||||
// Der Event Listener für den IP-Link wird jetzt in fetchIpInfo() hinzugefügt,
|
||||
// nachdem die IP erfolgreich abgerufen wurde.
|
||||
|
||||
}); // End DOMContentLoaded
|
||||
@@ -0,0 +1,281 @@
|
||||
/* ── Spinner ───────────────────────────────────────────────────── */
|
||||
.loader {
|
||||
border: 4px solid rgba(168, 85, 247, 0.1);
|
||||
border-left-color: #d8b4fe;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
animation: spin 1s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Glassmorphism ─────────────────────────────────────────────── */
|
||||
.glass-panel {
|
||||
background: rgba(17, 24, 39, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.glass-card {
|
||||
background: rgba(31, 41, 55, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
.glass-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* ── Page transition animations ───────────────────────────────── */
|
||||
@keyframes pageOut {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(-8px); }
|
||||
}
|
||||
@keyframes pageIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
#app.page-leaving {
|
||||
animation: pageOut 0.18s ease forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
#app.page-entering {
|
||||
animation: pageIn 0.26s ease forwards;
|
||||
}
|
||||
|
||||
/* ── Content fade-in ───────────────────────────────────────────── */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.fade-in { animation: fadeIn 0.4s ease-out forwards; }
|
||||
|
||||
/* ── Result pre-block ──────────────────────────────────────────── */
|
||||
.result-pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
color: #e5e7eb;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ── Scrollbar ─────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: rgba(31, 41, 55, 0.5); }
|
||||
::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #6b7280; }
|
||||
|
||||
/* ── Navigation ────────────────────────────────────────────────── */
|
||||
nav ul { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
nav a {
|
||||
color: #d1d5db;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: inline-block;
|
||||
}
|
||||
nav a:hover {
|
||||
color: #fff;
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
border-color: rgba(168, 85, 247, 0.4);
|
||||
box-shadow: 0 0 15px rgba(168, 85, 247, 0.1);
|
||||
}
|
||||
nav a.active-link {
|
||||
background: rgba(168, 85, 247, 0.3);
|
||||
color: #fff;
|
||||
border-color: #a855f7;
|
||||
}
|
||||
|
||||
/* ── Header ────────────────────────────────────────────────────── */
|
||||
header {
|
||||
background: rgba(31, 41, 55, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -1px rgba(0,0,0,.06);
|
||||
}
|
||||
header h1 { background-clip: text; }
|
||||
|
||||
/* ── Header top row (title + hamburger) ────────────────────────── */
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Hamburger button ──────────────────────────────────────────── */
|
||||
.nav-toggle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nav-toggle span {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: #d1d5db;
|
||||
border-radius: 2px;
|
||||
transition: transform 0.25s ease, opacity 0.2s ease;
|
||||
}
|
||||
header.nav-open .nav-toggle span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
|
||||
header.nav-open .nav-toggle span:nth-child(2) { opacity: 0; transform: scaleX(0); }
|
||||
header.nav-open .nav-toggle span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
|
||||
|
||||
/* ── Mobile nav ────────────────────────────────────────────────── */
|
||||
#main-nav {
|
||||
display: none;
|
||||
padding-top: 1rem;
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
header.nav-open #main-nav { display: block; }
|
||||
|
||||
/* ── Desktop nav ───────────────────────────────────────────────── */
|
||||
@media (min-width: 768px) {
|
||||
header { flex-direction: row; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||
.header-top { flex: 0 0 auto; }
|
||||
.nav-toggle { display: none; }
|
||||
#main-nav { display: block !important; padding-top: 0; margin-top: 0; border-top: none; }
|
||||
}
|
||||
|
||||
/* ── Text gradient ─────────────────────────────────────────────── */
|
||||
.text-gradient {
|
||||
background: linear-gradient(to right, #c084fc, #e879f9);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.glitch-text:hover {
|
||||
text-shadow: 2px 2px 0 rgba(168,85,247,.4), -2px -2px 0 rgba(236,72,153,.4);
|
||||
}
|
||||
|
||||
/* ── Copy button ───────────────────────────────────────────────── */
|
||||
.copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 0.3rem;
|
||||
border: 1px solid rgba(168, 85, 247, 0.35);
|
||||
color: #a78bfa;
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.copy-btn:hover { background: rgba(168, 85, 247, 0.25); color: #c4b5fd; }
|
||||
.copy-btn.copied { border-color: rgba(52,211,153,.4); color: #34d399; background: rgba(52,211,153,.1); }
|
||||
|
||||
/* ── Stop button ───────────────────────────────────────────────── */
|
||||
.stop-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(248,113,113,.4);
|
||||
color: #f87171;
|
||||
background: rgba(248,113,113,.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.stop-btn:hover { background: rgba(248,113,113,.2); }
|
||||
|
||||
/* ── Home page — IP link ────────────────────────────────────────── */
|
||||
#ip-address-link {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
#ip-address-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
transform: scaleX(0);
|
||||
height: 2px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: #d8b4fe;
|
||||
transform-origin: bottom right;
|
||||
transition: transform 0.25s ease-out;
|
||||
}
|
||||
#ip-address-link:hover::after { transform: scaleX(1); transform-origin: bottom left; }
|
||||
|
||||
/* ── Home page — Traceroute output ─────────────────────────────── */
|
||||
#traceroute-output pre, .result-pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
background-color: rgba(0,0,0,.3);
|
||||
color: #e5e7eb;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid rgba(255,255,255,.05);
|
||||
box-shadow: inset 0 2px 4px 0 rgba(0,0,0,.3);
|
||||
}
|
||||
#traceroute-output .hop-line { margin-bottom: .25rem; padding-left: .5rem; border-left: 2px solid transparent; transition: border-left-color .3s; }
|
||||
#traceroute-output .hop-line:hover { border-left-color: #a855f7; background: rgba(255,255,255,.02); }
|
||||
#traceroute-output .hop-number { display: inline-block; width: 30px; text-align: right; margin-right: 15px; color: #6b7280; font-weight: bold; }
|
||||
#traceroute-output .hop-ip { color: #60a5fa; font-weight: 500; }
|
||||
#traceroute-output .hop-hostname { color: #c084fc; }
|
||||
#traceroute-output .hop-rtt { color: #34d399; margin-left: 8px; font-size: .85em; }
|
||||
#traceroute-output .hop-timeout { color: #f87171; }
|
||||
#traceroute-output .info-line { color: #fbbf24; font-style: italic; }
|
||||
#traceroute-output .error-line { color: #f87171; font-weight: bold; border-left: 3px solid #f87171; padding-left: 10px; }
|
||||
#traceroute-output .end-line { color: #d8b4fe; font-weight: bold; margin-top: 15px; text-transform: uppercase; letter-spacing: .05em; border-top: 1px solid rgba(255,255,255,.1); padding-top: 10px; }
|
||||
|
||||
/* ── Home page — Maps ───────────────────────────────────────────── */
|
||||
#map { height: 300px; }
|
||||
#lookup-map { height: 250px; }
|
||||
|
||||
/* ── ASN page — Graph ───────────────────────────────────────────── */
|
||||
#graph-container { width: 100%; height: 600px; background: rgba(0,0,0,.3); border-radius: .75rem; border: 1px solid rgba(255,255,255,.06); overflow: hidden; position: relative; }
|
||||
#graph-svg { width: 100%; height: 100%; cursor: grab; }
|
||||
#graph-svg:active { cursor: grabbing; }
|
||||
.node-center circle { fill: #a855f7; stroke: #d8b4fe; stroke-width: 2.5; }
|
||||
.node-upstream circle { fill: #3b82f6; stroke: #93c5fd; stroke-width: 1.5; }
|
||||
.node-downstream circle { fill: #10b981; stroke: #6ee7b7; stroke-width: 1.5; }
|
||||
.node-tier1 circle { fill: #6b7280; stroke: #9ca3af; stroke-width: 1.5; }
|
||||
.node text { fill: #e5e7eb; font-size: 11px; font-family: 'Courier New',monospace; pointer-events: none; text-anchor: middle; }
|
||||
.node:hover circle { filter: brightness(1.4); cursor: pointer; }
|
||||
.link { stroke: rgba(255,255,255,.12); stroke-linecap: round; }
|
||||
.link-upstream { stroke: rgba(59,130,246,.35); }
|
||||
.link-tier1 { stroke: rgba(107,114,128,.3); stroke-dasharray: 4 3; }
|
||||
.link-downstream { stroke: rgba(16,185,129,.35); }
|
||||
#graph-tooltip { position: absolute; pointer-events: none; background: rgba(17,24,39,.95); backdrop-filter: blur(8px); border: 1px solid rgba(168,85,247,.4); border-radius: .5rem; padding: .6rem .9rem; font-size: 12px; color: #e5e7eb; max-width: 220px; z-index: 50; opacity: 0; transition: opacity .15s; }
|
||||
.prefix-tag { display: inline-block; font-family: monospace; font-size: 11px; background: rgba(168,85,247,.15); color: #c084fc; border: 1px solid rgba(168,85,247,.3); border-radius: 4px; padding: 2px 6px; margin: 2px; }
|
||||
.ixp-row { border-bottom: 1px solid rgba(255,255,255,.05); }
|
||||
.ixp-row:last-child { border-bottom: none; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
@@ -0,0 +1,21 @@
|
||||
export const API = '/api';
|
||||
|
||||
export function setupCopyBtn(btn, getText) {
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', () => {
|
||||
const text = getText();
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '✓ copied';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); }, 1500);
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
export function showError(el, msg) {
|
||||
if (!el) return;
|
||||
el.textContent = msg ? `Error: ${msg}` : '';
|
||||
el.classList.toggle('hidden', !msg);
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IP Subnetz Rechner - uTools</title> <!-- Titel angepasst -->
|
||||
<!-- Tailwind CSS Play CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Eigene Styles (für Navigation etc., wie in index.html) -->
|
||||
<style>
|
||||
/* Navigations-Styling */
|
||||
nav ul { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 1rem; } /* flex-wrap hinzugefügt */
|
||||
nav a { color: #c4b5fd; /* purple-300 */ text-decoration: none; white-space: nowrap; } /* nowrap hinzugefügt */
|
||||
nav a:hover { color: #a78bfa; /* purple-400 */ text-decoration: underline; }
|
||||
header { background-color: #374151; /* gray-700 */ padding: 1rem; margin-bottom: 1.5rem; border-radius: 0.5rem; /* rounded-lg */ display: flex; flex-direction: column; align-items: center; gap: 0.5rem; } /* Flex direction geändert */
|
||||
@media (min-width: 768px) { /* md breakpoint */
|
||||
header { flex-direction: row; justify-content: space-between; }
|
||||
}
|
||||
header h1 { font-size: 1.5rem; /* text-2xl */ font-weight: bold; color: #e5e7eb; /* gray-200 */ }
|
||||
|
||||
/* Styling für Formular und Ergebnisse */
|
||||
label { display: block; margin-bottom: 0.5rem; font-weight: 600; color: #d1d5db; /* gray-300 */ }
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
background-color: #4b5563; /* gray-600 */
|
||||
border: 1px solid #6b7280; /* gray-500 */
|
||||
border-radius: 0.375rem; /* rounded-md */
|
||||
color: #e5e7eb; /* gray-200 */
|
||||
font-family: monospace;
|
||||
}
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #a78bfa; /* purple-400 */
|
||||
box-shadow: 0 0 0 2px rgba(167, 139, 250, 0.5);
|
||||
}
|
||||
button[type="submit"] {
|
||||
background-color: #8b5cf6; /* purple-500 */
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem; /* rounded-md */
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
button[type="submit"]:hover {
|
||||
background-color: #7c3aed; /* purple-600 */
|
||||
}
|
||||
#results, #examples {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #374151; /* gray-700 */
|
||||
border-radius: 0.5rem; /* rounded-lg */
|
||||
}
|
||||
#results h3, #examples h3 {
|
||||
font-size: 1.25rem; /* text-xl */
|
||||
font-weight: 600;
|
||||
color: #c4b5fd; /* purple-300 */
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid #6b7280; /* gray-500 */
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
#results p {
|
||||
margin-bottom: 0.75rem;
|
||||
color: #d1d5db; /* gray-300 */
|
||||
}
|
||||
#results p strong {
|
||||
color: #e5e7eb; /* gray-200 */
|
||||
min-width: 150px; /* Für bessere Ausrichtung */
|
||||
display: inline-block;
|
||||
}
|
||||
#results span {
|
||||
font-family: monospace;
|
||||
color: #a78bfa; /* purple-400 */
|
||||
}
|
||||
/* Styling für Beispiel-Tabelle */
|
||||
#examples table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
#examples th, #examples td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #4b5563; /* gray-600 */
|
||||
color: #d1d5db; /* gray-300 */
|
||||
}
|
||||
#examples th {
|
||||
color: #e5e7eb; /* gray-200 */
|
||||
font-weight: 600;
|
||||
}
|
||||
#examples td code {
|
||||
font-family: monospace;
|
||||
background-color: #4b5563; /* gray-600 */
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
color: #c4b5fd; /* purple-300 */
|
||||
}
|
||||
#examples .example-link {
|
||||
color: #a78bfa; /* purple-400 */
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
#examples .example-link:hover {
|
||||
color: #c4b5fd; /* purple-300 */
|
||||
}
|
||||
.hidden { display: none; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-200 font-sans p-4 md:p-8">
|
||||
|
||||
<header>
|
||||
<h1>uTools Network Suite</h1> <!-- Titel angepasst -->
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="index.html">IP Info & Tools</a></li> <!-- Angepasst -->
|
||||
<li><a href="subnet-calculator.html">Subnetz Rechner</a></li>
|
||||
<li><a href="dns-lookup.html">DNS Lookup</a></li> <!-- Neu -->
|
||||
<li><a href="whois-lookup.html">WHOIS Lookup</a></li> <!-- Neu -->
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="container mx-auto max-w-4xl bg-gray-800 rounded-lg shadow-xl p-6">
|
||||
|
||||
<h2 class="text-2xl font-bold mb-6 text-purple-400 text-center">IP Subnetz Rechner</h2>
|
||||
|
||||
<form id="subnet-form" class="mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="ip-address">IP Adresse:</label>
|
||||
<input type="text" id="ip-address" name="ip-address" placeholder="z.B. 192.168.1.1" required
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono">
|
||||
</div>
|
||||
<div>
|
||||
<label for="cidr">CIDR / Subnetzmaske:</label>
|
||||
<input type="text" id="cidr" name="cidr" placeholder="z.B. 24 oder 255.255.255.0" required
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded transition duration-150 ease-in-out">
|
||||
Berechnen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="results" class="bg-gray-700 rounded p-6 hidden"> <!-- Ergebnisse initial verstecken -->
|
||||
<h3 class="text-xl font-semibold text-purple-300 border-b border-purple-500 pb-2 mb-4">Ergebnisse:</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<p><strong>Netzwerkadresse:</strong> <span id="network-address" class="font-mono text-purple-400">-</span></p>
|
||||
<p><strong>Broadcast-Adresse:</strong> <span id="broadcast-address" class="font-mono text-purple-400">-</span></p>
|
||||
<p><strong>Subnetzmaske:</strong> <span id="subnet-mask" class="font-mono text-purple-400">-</span></p>
|
||||
<p><strong>Anzahl der Hosts:</strong> <span id="host-count" class="font-mono text-purple-400">-</span></p>
|
||||
<p><strong>Erste Host-Adresse:</strong> <span id="first-host" class="font-mono text-purple-400">-</span></p>
|
||||
<p><strong>Letzte Host-Adresse:</strong> <span id="last-host" class="font-mono text-purple-400">-</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beispiel-Subnetze -->
|
||||
<div id="examples" class="bg-gray-700 rounded p-6 mt-8">
|
||||
<h3 class="text-xl font-semibold text-purple-300 border-b border-purple-500 pb-2 mb-4">Beispiel-Subnetze (Private Adressbereiche)</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bereich</th>
|
||||
<th>CIDR</th>
|
||||
<th>Subnetzmaske</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-600">
|
||||
<tr>
|
||||
<td><code>192.168.0.0 - 192.168.255.255</code></td>
|
||||
<td><code>/16</code> (Gesamt)</td>
|
||||
<td><code>255.255.0.0</code></td>
|
||||
<td>Klasse C (oft als /24 genutzt)</td>
|
||||
<td><span class="example-link" data-ip="192.168.1.1" data-cidr="24">Beispiel /24</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>172.16.0.0 - 172.31.255.255</code></td>
|
||||
<td><code>/12</code> (Gesamt)</td>
|
||||
<td><code>255.240.0.0</code></td>
|
||||
<td>Klasse B</td>
|
||||
<td><span class="example-link" data-ip="172.16.10.5" data-cidr="16">Beispiel /16</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>10.0.0.0 - 10.255.255.255</code></td>
|
||||
<td><code>/8</code> (Gesamt)</td>
|
||||
<td><code>255.0.0.0</code></td>
|
||||
<td>Klasse A</td>
|
||||
<td><span class="example-link" data-ip="10.0.50.100" data-cidr="8">Beispiel /8</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-gray-400">Klicken Sie auf "Beispiel", um die Felder oben auszufüllen und die Berechnung zu starten.</p>
|
||||
</div>
|
||||
|
||||
<!-- Globaler Fehlerbereich -->
|
||||
<div id="global-error" class="mt-6 p-4 bg-red-800 text-red-100 rounded hidden"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="mt-8 pt-4 border-t border-gray-600 text-center text-xs text-gray-500">
|
||||
<p>© 2025 <a href="https://johanneskr.de" class="text-purple-400 hover:underline">Johannes Krüger</a></p>
|
||||
<p>Version: <span id="commit-sha" class="font-mono">loading...</span></p> <!-- Footer mit Version hinzugefügt -->
|
||||
</footer>
|
||||
|
||||
<!-- Nur das Skript für den Rechner laden -->
|
||||
<script src="subnet-calculator.js"></script>
|
||||
<script>
|
||||
// Kleine Ergänzung, um die Beispiel-Links klickbar zu machen und Version zu laden
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Beispiel-Links
|
||||
document.querySelectorAll('.example-link').forEach(link => {
|
||||
link.addEventListener('click', (event) => {
|
||||
const ip = event.target.getAttribute('data-ip');
|
||||
const cidr = event.target.getAttribute('data-cidr');
|
||||
document.getElementById('ip-address').value = ip;
|
||||
document.getElementById('cidr').value = cidr;
|
||||
// Berechnung direkt auslösen
|
||||
document.getElementById('subnet-form').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' }); // Nach oben scrollen
|
||||
});
|
||||
});
|
||||
|
||||
// Version laden (gemeinsame Funktion)
|
||||
const commitShaEl = document.getElementById('commit-sha');
|
||||
const globalErrorEl = document.getElementById('global-error');
|
||||
const API_BASE_URL = '/api'; // Muss hier definiert sein, wenn nicht global
|
||||
|
||||
async function fetchVersionInfo() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/version`);
|
||||
if (!response.ok) throw new Error(`Network response: ${response.statusText} (${response.status})`);
|
||||
const data = await response.json();
|
||||
if (commitShaEl) {
|
||||
commitShaEl.textContent = data.commitSha || 'unknown';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version info:', error);
|
||||
if (commitShaEl) commitShaEl.textContent = 'error';
|
||||
if (globalErrorEl) { // Zeige Fehler global an, wenn Element existiert
|
||||
globalErrorEl.textContent = `Error loading version: ${error.message}`;
|
||||
globalErrorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchVersionInfo();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,211 +0,0 @@
|
||||
// Event Listener hinzufügen, sobald das DOM geladen ist
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('subnet-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', handleSubnetCalculation);
|
||||
} else {
|
||||
console.error("Subnetz-Formular (ID: subnet-form) nicht gefunden!");
|
||||
}
|
||||
});
|
||||
|
||||
// Funktion zur Behandlung der Subnetzberechnung bei Formularübermittlung
|
||||
function handleSubnetCalculation(event) {
|
||||
event.preventDefault(); // Verhindert das Neuladen der Seite
|
||||
clearResults(); // Ergebnisse zuerst löschen/verstecken
|
||||
|
||||
const ipAddressInput = document.getElementById('ip-address').value.trim();
|
||||
const cidrInput = document.getElementById('cidr').value.trim();
|
||||
const resultsDiv = document.getElementById('results'); // Ergebnis-Div holen
|
||||
|
||||
// Einfache Validierung
|
||||
if (!isValidIP(ipAddressInput)) {
|
||||
alert("Bitte geben Sie eine gültige IPv4-Adresse ein.");
|
||||
return;
|
||||
}
|
||||
|
||||
let cidr;
|
||||
let subnetMask;
|
||||
|
||||
// Prüfen, ob CIDR oder Subnetzmaske eingegeben wurde
|
||||
if (cidrInput.includes('.')) { // Annahme: Subnetzmaske im Format xxx.xxx.xxx.xxx
|
||||
if (!isValidIP(cidrInput)) {
|
||||
alert("Bitte geben Sie eine gültige Subnetzmaske ein.");
|
||||
return;
|
||||
}
|
||||
subnetMask = cidrInput;
|
||||
cidr = maskToCidr(subnetMask);
|
||||
if (cidr === null) {
|
||||
alert("Ungültige Subnetzmaske. Sie muss aus einer kontinuierlichen Folge von Einsen gefolgt von Nullen bestehen (z.B. 255.255.255.0, nicht 255.255.0.255).");
|
||||
return;
|
||||
}
|
||||
} else { // Annahme: CIDR-Notation
|
||||
cidr = parseInt(cidrInput, 10);
|
||||
if (isNaN(cidr) || cidr < 0 || cidr > 32) {
|
||||
alert("Bitte geben Sie einen gültigen CIDR-Wert (0-32) ein.");
|
||||
return;
|
||||
}
|
||||
subnetMask = cidrToMask(cidr);
|
||||
if (subnetMask === null) {
|
||||
alert("Interner Fehler bei der Umwandlung von CIDR zu Maske.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Berechnung durchführen und Ergebnisse anzeigen
|
||||
try {
|
||||
const results = calculateSubnet(ipAddressInput, cidr);
|
||||
displayResults(results, subnetMask);
|
||||
if (resultsDiv) {
|
||||
resultsDiv.classList.remove('hidden'); // Ergebnisbereich sichtbar machen
|
||||
} else {
|
||||
console.error("Ergebnis-Div (ID: results) nicht gefunden!");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Fehler bei der Subnetzberechnung:", error);
|
||||
alert("Fehler bei der Berechnung: " + error.message);
|
||||
clearResults();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Validierungs- und Hilfsfunktionen ---
|
||||
|
||||
function isValidIP(ip) {
|
||||
const ipPattern = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
return ipPattern.test(ip);
|
||||
}
|
||||
|
||||
function ipToBinary(ip) {
|
||||
return ip.split('.').map(octet => parseInt(octet, 10).toString(2).padStart(8, '0')).join('');
|
||||
}
|
||||
|
||||
function binaryToIp(binary) {
|
||||
if (binary.length !== 32) return null;
|
||||
const octets = [];
|
||||
for (let i = 0; i < 32; i += 8) {
|
||||
octets.push(parseInt(binary.substring(i, i + 8), 2));
|
||||
}
|
||||
return octets.join('.');
|
||||
}
|
||||
|
||||
function cidrToMask(cidr) {
|
||||
if (cidr < 0 || cidr > 32) return null;
|
||||
const maskBinary = '1'.repeat(cidr) + '0'.repeat(32 - cidr);
|
||||
return binaryToIp(maskBinary);
|
||||
}
|
||||
|
||||
function maskToCidr(mask) {
|
||||
if (!isValidIP(mask)) return null;
|
||||
const binaryMask = ipToBinary(mask);
|
||||
let encounteredZero = false;
|
||||
for (let i = 0; i < 32; i++) {
|
||||
if (binaryMask[i] === '1') {
|
||||
if (encounteredZero) return null;
|
||||
} else {
|
||||
encounteredZero = true;
|
||||
}
|
||||
}
|
||||
let cidr = 0;
|
||||
for(let i = 0; i < 32; i++) {
|
||||
if (binaryMask[i] === '1') {
|
||||
cidr++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return cidr;
|
||||
}
|
||||
|
||||
// --- Berechnungsfunktion ---
|
||||
|
||||
function calculateSubnet(ip, cidr) {
|
||||
const ipBinary = ipToBinary(ip);
|
||||
const maskBinary = '1'.repeat(cidr) + '0'.repeat(32 - cidr);
|
||||
|
||||
// Netzwerkadresse berechnen (Bitweises UND von IP und Maske)
|
||||
let networkBinary = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
networkBinary += (parseInt(ipBinary[i], 10) & parseInt(maskBinary[i], 10)).toString();
|
||||
}
|
||||
const networkAddress = binaryToIp(networkBinary);
|
||||
const networkNum = parseInt(networkBinary, 2); // Netzwerkadresse als Zahl
|
||||
|
||||
// Broadcast-Adresse berechnen (Netzwerk-Teil + Host-Teil mit Einsen) - Korrigierte Methode
|
||||
const hostBitsCount = 32 - cidr;
|
||||
let broadcastBinary = networkBinary.substring(0, cidr) + '1'.repeat(hostBitsCount);
|
||||
// Sicherstellen, dass die Länge 32 Bit beträgt (sollte sie aber ohnehin)
|
||||
broadcastBinary = broadcastBinary.padEnd(32, '1'); // Auffüllen mit 1, falls Länge < 32 (unwahrscheinlich)
|
||||
|
||||
const broadcastAddress = binaryToIp(broadcastBinary);
|
||||
// broadcastNum wird für die letzte Host-Adresse benötigt
|
||||
const broadcastNum = parseInt(broadcastBinary, 2);
|
||||
|
||||
// Anzahl der Hosts
|
||||
const hostBits = 32 - cidr; // hostBitsCount umbenannt für Konsistenz
|
||||
let hostCount = 0;
|
||||
if (hostBits >= 2) { // Mindestens /30 für 2 Hosts (-2)
|
||||
hostCount = Math.pow(2, hostBits) - 2;
|
||||
} else if (hostBits === 1) { // /31 hat 2 Adressen, beide nutzbar (RFC 3021)
|
||||
hostCount = 2;
|
||||
} else { // /32 hat nur 1 Adresse
|
||||
hostCount = 1;
|
||||
}
|
||||
|
||||
// Erste Host-Adresse
|
||||
let firstHost = '-';
|
||||
if (hostBits >= 2) { // /30 oder größer: Netzwerkadresse + 1
|
||||
// Sicherstellen, dass die Addition korrekt behandelt wird (als Zahl)
|
||||
const firstHostNum = networkNum + 1;
|
||||
const firstHostBinary = firstHostNum.toString(2).padStart(32, '0');
|
||||
firstHost = binaryToIp(firstHostBinary);
|
||||
} else if (cidr === 31) { // /31: Die erste Adresse des /31
|
||||
firstHost = networkAddress;
|
||||
} else { // /32: Nur die eine Adresse
|
||||
firstHost = networkAddress;
|
||||
}
|
||||
|
||||
// Letzte Host-Adresse
|
||||
let lastHost = '-';
|
||||
if (hostBits >= 2) { // /30 oder größer: Broadcast-Adresse - 1
|
||||
// Sicherstellen, dass die Subtraktion korrekt behandelt wird (als Zahl)
|
||||
const lastHostNum = broadcastNum - 1;
|
||||
const lastHostBinary = lastHostNum.toString(2).padStart(32, '0');
|
||||
lastHost = binaryToIp(lastHostBinary);
|
||||
} else if (cidr === 31) { // /31: Die zweite Adresse des /31
|
||||
lastHost = broadcastAddress;
|
||||
} else { // /32: Nur die eine Adresse
|
||||
lastHost = networkAddress;
|
||||
}
|
||||
|
||||
return {
|
||||
networkAddress,
|
||||
broadcastAddress,
|
||||
hostCount,
|
||||
firstHost,
|
||||
lastHost
|
||||
};
|
||||
}
|
||||
|
||||
// --- Anzeige-Funktionen ---
|
||||
|
||||
function displayResults(results, subnetMask) {
|
||||
document.getElementById('network-address').textContent = results.networkAddress;
|
||||
document.getElementById('broadcast-address').textContent = results.broadcastAddress;
|
||||
document.getElementById('host-count').textContent = results.hostCount >= 0 ? results.hostCount.toLocaleString() : '-';
|
||||
document.getElementById('first-host').textContent = results.firstHost;
|
||||
document.getElementById('last-host').textContent = results.lastHost;
|
||||
document.getElementById('subnet-mask').textContent = subnetMask;
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
document.getElementById('network-address').textContent = '-';
|
||||
document.getElementById('broadcast-address').textContent = '-';
|
||||
document.getElementById('host-count').textContent = '-';
|
||||
document.getElementById('first-host').textContent = '-';
|
||||
document.getElementById('last-host').textContent = '-';
|
||||
document.getElementById('subnet-mask').textContent = '-';
|
||||
|
||||
const resultsDiv = document.getElementById('results');
|
||||
if (resultsDiv && !resultsDiv.classList.contains('hidden')) {
|
||||
resultsDiv.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WHOIS Lookup - uTools</title>
|
||||
<!-- Tailwind CSS Play CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Eigene Styles -->
|
||||
<style>
|
||||
/* Einfacher Lade-Spinner */
|
||||
.loader {
|
||||
border: 4px solid rgba(168, 85, 247, 0.3); /* Lila transparent */
|
||||
border-left-color: #a855f7; /* Lila */
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
/* Ergebnis-Pre-Formatierung */
|
||||
.result-pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
background-color: #1f2937; /* Dunkelgrau */
|
||||
color: #d1d5db; /* Hellgrau */
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem; /* rounded-md */
|
||||
max-height: 600px; /* Mehr Höhe für WHOIS */
|
||||
overflow-y: auto;
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
}
|
||||
/* Navigations-Styling */
|
||||
nav ul { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 1rem; }
|
||||
nav a { color: #c4b5fd; text-decoration: none; white-space: nowrap; }
|
||||
nav a:hover { color: #a78bfa; text-decoration: underline; }
|
||||
header { background-color: #374151; padding: 1rem; margin-bottom: 1.5rem; border-radius: 0.5rem; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
|
||||
@media (min-width: 768px) { header { flex-direction: row; justify-content: space-between; } }
|
||||
header h1 { font-size: 1.5rem; font-weight: bold; color: #e5e7eb; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-200 font-sans p-4 md:p-8">
|
||||
|
||||
<header>
|
||||
<h1>uTools Network Suite</h1>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="index.html">IP Info & Tools</a></li>
|
||||
<li><a href="subnet-calculator.html">Subnetz Rechner</a></li>
|
||||
<li><a href="dns-lookup.html">DNS Lookup</a></li>
|
||||
<li><a href="whois-lookup.html">WHOIS Lookup</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="container mx-auto max-w-4xl bg-gray-800 rounded-lg shadow-xl p-6">
|
||||
|
||||
<h1 class="text-3xl font-bold mb-6 text-purple-400 text-center">WHOIS Lookup</h1>
|
||||
|
||||
<!-- Bereich für WHOIS Lookup -->
|
||||
<div class="mt-8 p-4 bg-gray-700 rounded">
|
||||
<div class="flex flex-col sm:flex-row gap-2 mb-4">
|
||||
<input type="text" id="whois-query-input" placeholder="Enter domain or IP (e.g., google.com or 8.8.8.8)"
|
||||
class="flex-grow px-3 py-2 bg-gray-800 border border-gray-600 rounded text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono">
|
||||
<button id="whois-lookup-button"
|
||||
class="bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded transition duration-150 ease-in-out">
|
||||
Lookup WHOIS
|
||||
</button>
|
||||
</div>
|
||||
<div id="whois-lookup-error" class="text-red-400 mb-4 hidden"></div>
|
||||
<div id="whois-lookup-results-section" class="hidden mt-4 border-t border-gray-600 pt-4">
|
||||
<h3 class="text-lg font-semibold text-purple-300 mb-2">WHOIS Results for: <span id="whois-lookup-query" class="font-mono text-purple-400"></span></h3>
|
||||
<div id="whois-lookup-loader" class="loader hidden mb-2"></div>
|
||||
<pre id="whois-lookup-output" class="result-pre"></pre> <!-- Ergebnisbereich -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Globaler Fehlerbereich -->
|
||||
<div id="global-error" class="mt-6 p-4 bg-red-800 text-red-100 rounded hidden"></div>
|
||||
|
||||
<!-- Footer für Version -->
|
||||
<footer class="mt-8 pt-4 border-t border-gray-600 text-center text-xs text-gray-500">
|
||||
<p>© 2025 <a href="https://johanneskr.de" class="text-purple-400 hover:underline">Johannes Krüger</a></p>
|
||||
<p>Version: <span id="commit-sha" class="font-mono">loading...</span></p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Eigene JS-Logik für diese Seite -->
|
||||
<script src="whois-lookup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,146 +0,0 @@
|
||||
// frontend/whois-lookup.js
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- DOM Elements (WHOIS Lookup) ---
|
||||
const whoisQueryInput = document.getElementById('whois-query-input');
|
||||
const whoisLookupButton = document.getElementById('whois-lookup-button');
|
||||
const whoisLookupErrorEl = document.getElementById('whois-lookup-error');
|
||||
const whoisLookupResultsSection = document.getElementById('whois-lookup-results-section');
|
||||
const whoisLookupQueryEl = document.getElementById('whois-lookup-query');
|
||||
const whoisLookupLoader = document.getElementById('whois-lookup-loader');
|
||||
const whoisLookupOutputEl = document.getElementById('whois-lookup-output');
|
||||
|
||||
// --- DOM Elements (Common) ---
|
||||
const globalErrorEl = document.getElementById('global-error');
|
||||
const commitShaEl = document.getElementById('commit-sha');
|
||||
|
||||
// --- Configuration ---
|
||||
const API_BASE_URL = '/api'; // Anpassen, falls nötig
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
/** Zeigt globale Fehler an */
|
||||
function showGlobalError(message) {
|
||||
if (!globalErrorEl) return;
|
||||
globalErrorEl.textContent = `Error: ${message}`;
|
||||
globalErrorEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/** Versteckt globale Fehler */
|
||||
function hideGlobalError() {
|
||||
if (!globalErrorEl) return;
|
||||
globalErrorEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generische Funktion zum Abrufen und Anzeigen von Lookup-Ergebnissen.
|
||||
* @param {string} endpoint - Der API-Endpunkt (z.B. '/whois-lookup').
|
||||
* @param {object} params - Query-Parameter als Objekt (z.B. { query: '...' }).
|
||||
* @param {HTMLElement} resultsSection - Der Container für die Ergebnisse.
|
||||
* @param {HTMLElement} loaderElement - Das Loader-Element.
|
||||
* @param {HTMLElement} errorElement - Das Fehleranzeige-Element für diesen Lookup.
|
||||
* @param {HTMLElement} queryElement - Das Element zur Anzeige der Suchanfrage.
|
||||
* @param {HTMLElement} outputElement - Das Element zur Anzeige der Ergebnisse (<pre> oder <p>).
|
||||
* @param {function} displayFn - Funktion zur Formatierung und Anzeige der Daten im outputElement.
|
||||
*/
|
||||
async function fetchAndDisplay(endpoint, params, resultsSection, loaderElement, errorElement, queryElement, outputElement, displayFn) {
|
||||
resultsSection.classList.remove('hidden');
|
||||
loaderElement.classList.remove('hidden');
|
||||
errorElement.classList.add('hidden');
|
||||
outputElement.textContent = ''; // Clear previous results
|
||||
if (queryElement) queryElement.textContent = Object.values(params).join(', '); // Display query
|
||||
hideGlobalError(); // Hide global errors before new request
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
const url = `${API_BASE_URL}${endpoint}?${urlParams.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
console.log(`Received ${endpoint} data:`, data);
|
||||
displayFn(data, outputElement); // Call the specific display function
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch ${endpoint}:`, error);
|
||||
errorElement.textContent = `Error: ${error.message}`;
|
||||
errorElement.classList.remove('hidden');
|
||||
outputElement.textContent = ''; // Clear output on error
|
||||
} finally {
|
||||
loaderElement.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/** Ruft die Versionsinformationen (Commit SHA) ab */
|
||||
async function fetchVersionInfo() {
|
||||
if (!commitShaEl) return; // Don't fetch if element doesn't exist
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/version`);
|
||||
if (!response.ok) throw new Error(`Network response: ${response.statusText} (${response.status})`);
|
||||
const data = await response.json();
|
||||
commitShaEl.textContent = data.commitSha || 'unknown';
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version info:', error);
|
||||
commitShaEl.textContent = 'error';
|
||||
// Optionally show global error
|
||||
// showGlobalError(`Could not load version info: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- WHOIS Lookup Specific Functions ---
|
||||
function displayWhoisResults(data, outputEl) {
|
||||
// WHOIS data can be large and unstructured, display as raw text
|
||||
if (typeof data.result === 'string') {
|
||||
outputEl.textContent = data.result; // Display raw text
|
||||
} else {
|
||||
// Fallback if the result is not a string (shouldn't happen with current backend)
|
||||
outputEl.textContent = JSON.stringify(data.result, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWhoisLookupClick() {
|
||||
const query = whoisQueryInput.value.trim();
|
||||
if (!query) {
|
||||
whoisLookupErrorEl.textContent = 'Please enter a domain or IP address.';
|
||||
whoisLookupErrorEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
fetchAndDisplay(
|
||||
'/whois-lookup',
|
||||
{ query },
|
||||
whoisLookupResultsSection,
|
||||
whoisLookupLoader,
|
||||
whoisLookupErrorEl,
|
||||
whoisLookupQueryEl,
|
||||
whoisLookupOutputEl,
|
||||
displayWhoisResults
|
||||
);
|
||||
}
|
||||
|
||||
/** Prüft URL-Parameter und startet ggf. den Lookup */
|
||||
function checkUrlParamsAndLookup() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const queryFromUrl = urlParams.get('query');
|
||||
|
||||
if (queryFromUrl && whoisQueryInput) {
|
||||
console.log(`Found query parameter in URL: ${queryFromUrl}`);
|
||||
whoisQueryInput.value = queryFromUrl; // Set input field value
|
||||
handleWhoisLookupClick(); // Trigger the lookup
|
||||
}
|
||||
}
|
||||
|
||||
// --- Initial Load & Event Listeners ---
|
||||
fetchVersionInfo(); // Lade Versionsinfo für Footer
|
||||
|
||||
whoisLookupButton.addEventListener('click', handleWhoisLookupClick);
|
||||
whoisQueryInput.addEventListener('keypress', (event) => {
|
||||
if (event.key === 'Enter') handleWhoisLookupClick();
|
||||
});
|
||||
|
||||
// Prüfe URL-Parameter nach dem Setup der Listener
|
||||
checkUrlParamsAndLookup();
|
||||
|
||||
}); // End DOMContentLoaded
|
||||
+2
-2
@@ -10,9 +10,9 @@ server {
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Statische Dateien direkt ausliefern
|
||||
# SPA: all routes fall back to index.html; static assets are served directly
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html; # Wichtig für Single-Page-Apps (auch wenn wir keine sind)
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API-Anfragen an den Backend-Service weiterleiten
|
||||
|
||||
Reference in New Issue
Block a user