mirror of
https://github.com/MrUnknownDE/utools.git
synced 2026-05-30 16:10:06 +02:00
Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8deca43f20 | |||
| 1cdec3c54a | |||
| 27d9e7b154 | |||
| 014d1704de | |||
| ea0d192365 | |||
| 413810e298 | |||
| 972741b2fd | |||
| 97ccc32832 | |||
| 85ce9106db | |||
| 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 | |||
| e95770bdce | |||
| bab3b59750 | |||
| 1c6802995f | |||
| 7f11612aa6 | |||
| aa38eddc07 | |||
| 2498d10f6d | |||
| b9cfe43986 | |||
| 0da70547aa | |||
| 6dfc86ce48 | |||
| be3aba6d86 | |||
| 3c1b5156a9 | |||
| 12ad5a86be | |||
| 8e2caf1e73 | |||
| 85c03bc483 | |||
| 3cd55bb7a9 | |||
| 75614e7cd2 | |||
| c0ddd1cf7d | |||
| 93a4574553 | |||
| 0fa3e6ac7c | |||
| 5fd7d49602 | |||
| cb4adabbc2 | |||
| 47efadd6d6 | |||
| 0e153db2c4 | |||
| 95b83f060b | |||
| 33e8400d51 | |||
| 98796edcbb | |||
| 320606ca3f | |||
| 4d00fd02cf | |||
| 168618c4fb | |||
| e6ee946cc3 | |||
| 2207a0d325 | |||
| 12370ea173 | |||
| 06e4971f1f | |||
| 84611f36f5 | |||
| f36e52a2fe | |||
| 12bb9a157e | |||
| d5b18295c0 | |||
| 039fda5797 | |||
| 4f8718c18a | |||
| 5d415a597b | |||
| b98f9047ef | |||
| 57c671e17b | |||
| 815cc48e9b | |||
| 2f070acaf4 | |||
| c59341552f | |||
| 7c38595984 | |||
| e560c8358c | |||
| 6dae7c356f | |||
| bee37d3eda | |||
| 02079ff639 | |||
| c399ea9d26 | |||
| 45e012c817 | |||
| 99eb24f665 | |||
| c1d9e41d43 | |||
| 4535631e9f | |||
| 654df54fa7 | |||
| 06862e2023 | |||
| f9daddc122 | |||
| 0a2163ff38 | |||
| b5c1908b04 | |||
| 0e52a0cd06 | |||
| 5266b05ce3 | |||
| b001ca6d29 | |||
| 7eac8b00ae |
@@ -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
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
name: Docker Build and Push (Docker Hub, Multi-Arch)
|
||||||
|
|
||||||
|
on:
|
||||||
|
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
|
||||||
|
env:
|
||||||
|
REGISTRY: docker.io
|
||||||
|
DOCKERHUB_USER_LC: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
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 }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# -------- BACKEND --------
|
||||||
|
- name: Build & Push backend (multi-arch)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Tag backend 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-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 }}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Update MaxMind GeoLite2 DBs
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 1 * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-db:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
|
||||||
|
- name: Download latest geoipupdate
|
||||||
|
run: |
|
||||||
|
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/
|
||||||
|
geoipupdate -V
|
||||||
|
|
||||||
|
- name: Create GeoIP.conf
|
||||||
|
run: |
|
||||||
|
cat << EOF > GeoIP.conf
|
||||||
|
AccountID ${{ secrets.MAXMIND_ACCOUNT_ID }}
|
||||||
|
LicenseKey ${{ secrets.MAXMIND_LICENSE_KEY }}
|
||||||
|
EditionIDs GeoLite2-ASN GeoLite2-City
|
||||||
|
EOF
|
||||||
|
env:
|
||||||
|
MAXMIND_ACCOUNT_ID: ${{ secrets.MAXMIND_ACCOUNT_ID }}
|
||||||
|
MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
|
||||||
|
|
||||||
|
- name: Run geoipupdate
|
||||||
|
run: geoipupdate -f GeoIP.conf -d ./backend/data -v
|
||||||
|
|
||||||
|
- name: Configure Git and LFS
|
||||||
|
run: |
|
||||||
|
git config --global user.name 'github-actions[bot]'
|
||||||
|
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||||
|
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 }}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
# Created by https://www.toptal.com/developers/gitignore/api/node
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=node
|
||||||
|
|
||||||
|
### Node ###
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
### Node Patch ###
|
||||||
|
# Serverless Webpack directories
|
||||||
|
.webpack/
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
|
||||||
|
# SvelteKit build / generate output
|
||||||
|
.svelte-kit
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/node
|
||||||
@@ -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)!
|
||||||
@@ -1,3 +1,96 @@
|
|||||||
# utools
|
# ✨ utools - IP Information & Diagnostics Webapp ✨
|
||||||
|
|
||||||
Ich versuche mal was :)
|
[](https://github.com/mrunknownde/utools/actions/workflows/docker-build-push.yml)
|
||||||
|
[](https://github.com/MrUnknownDE/utools/actions/workflows/maxmind-update.yml)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Preview: https://utools.mrunk.de
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
* **Client IP Info:** Automatically detects and displays the visitor's public IP address.
|
||||||
|
* **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 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 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 (address ranges, usable hosts) for IPv4 subnets.
|
||||||
|
* **WHOIS Lookup:** Retrieves WHOIS information for a given domain or IP address.
|
||||||
|
* **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, `oui`, `whois-json`, `@sentry/node`.
|
||||||
|
* **Frontend:** Vanilla JS, Tailwind CSS, Leaflet.js.
|
||||||
|
* **Deployment:** Docker, GitHub Actions.
|
||||||
|
|
||||||
|
## 🏁 Getting Started
|
||||||
|
|
||||||
|
### Using Pre-built Images (Recommended)
|
||||||
|
|
||||||
|
1. **Create `compose.yml`:**
|
||||||
|
(See provided `compose.yml` in repository)
|
||||||
|
|
||||||
|
2. **Start:**
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Access:** `http://localhost:8080`
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
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:** [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
|
||||||
|
|
||||||
|
MIT License.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Stage 1: Build Dependencies
|
||||||
|
# Use an official Node.js runtime as a parent image
|
||||||
|
FROM node:24-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install OS dependencies needed for ping/traceroute
|
||||||
|
# Using apk add --no-cache reduces layer size
|
||||||
|
RUN apk add --no-cache iputils-ping traceroute
|
||||||
|
|
||||||
|
# Copy package.json and package-lock.json (or yarn.lock)
|
||||||
|
# Ensure these files include 'oui' as a dependency before building!
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install app dependencies using npm ci for faster, reliable builds
|
||||||
|
# --only=production installs only production dependencies (including 'oui')
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# Stage 2: Production Image
|
||||||
|
FROM node:24-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install only necessary OS dependencies again for the final image
|
||||||
|
RUN apk add --no-cache iputils-ping traceroute
|
||||||
|
|
||||||
|
# Copy dependencies from the builder stage
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Copy MaxMind data (assuming it's in ./data)
|
||||||
|
# Ensure the 'data' directory exists in your project root
|
||||||
|
COPY ./data ./data
|
||||||
|
|
||||||
|
# Create a non-root user and group
|
||||||
|
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Make port specified in environment variable available to the world outside this container
|
||||||
|
# Default to 3000 if not specified
|
||||||
|
ARG PORT=3000
|
||||||
|
ENV PORT=${PORT}
|
||||||
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
|
# Define environment variable for Node environment (important for Pino, Express etc.)
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# Define default Log Level if not set externally
|
||||||
|
ENV LOG_LEVEL=info
|
||||||
|
# Define default Ping Count if not set externally
|
||||||
|
ENV PING_COUNT=4
|
||||||
|
# Define paths to GeoIP DBs (can be overridden by external .env or docker run -e)
|
||||||
|
ENV GEOIP_CITY_DB=./data/GeoLite2-City.mmdb
|
||||||
|
ENV GEOIP_ASN_DB=./data/GeoLite2-ASN.mmdb
|
||||||
|
|
||||||
|
# Define build argument and environment variable for Git commit SHA
|
||||||
|
ARG GIT_COMMIT_SHA=unknown
|
||||||
|
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
|
||||||
|
|
||||||
|
# Define build argument and environment variable for Sentry DSN
|
||||||
|
ARG SENTRY_DSN
|
||||||
|
ENV SENTRY_DSN=${SENTRY_DSN}
|
||||||
|
|
||||||
|
|
||||||
|
# Run the app when the container launches
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:be75726475eaa093155243a8743f461633ca1b5cbbfbd3fc2a4707d08636447e
|
||||||
|
size 12178607
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:06f0aca0b9062cecc3a4f6ebe19028ba321b1b4942534c870fd49874fa292fee
|
||||||
|
size 65982658
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# .env
|
||||||
|
GEOIP_CITY_DB=./data/GeoLite2-City.mmdb
|
||||||
|
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
|
||||||
|
RATE_LIMIT_MAX=200
|
||||||
|
RATE_LIMIT_WINDOW_MS=300000 # 5 Minuten
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// backend/maxmind.js
|
||||||
|
const geoip = require('@maxmind/geoip2-node');
|
||||||
|
const pino = require('pino');
|
||||||
|
const Sentry = require("@sentry/node");
|
||||||
|
|
||||||
|
// Minimaler Logger für dieses Modul
|
||||||
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
|
let cityReaderInstance = null;
|
||||||
|
let asnReaderInstance = null;
|
||||||
|
|
||||||
|
async function initializeMaxMind() {
|
||||||
|
if (cityReaderInstance && asnReaderInstance) {
|
||||||
|
logger.debug('MaxMind databases already loaded.');
|
||||||
|
return { cityReader: cityReaderInstance, asnReader: asnReaderInstance };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('Loading MaxMind databases...');
|
||||||
|
const cityDbPath = process.env.GEOIP_CITY_DB || './data/GeoLite2-City.mmdb';
|
||||||
|
const asnDbPath = process.env.GEOIP_ASN_DB || './data/GeoLite2-ASN.mmdb';
|
||||||
|
logger.info({ cityDbPath, asnDbPath }, 'Database paths');
|
||||||
|
|
||||||
|
// Verwende Promise.all für paralleles Laden
|
||||||
|
const [cityReader, asnReader] = await Promise.all([
|
||||||
|
geoip.Reader.open(cityDbPath),
|
||||||
|
geoip.Reader.open(asnDbPath)
|
||||||
|
]);
|
||||||
|
|
||||||
|
cityReaderInstance = cityReader;
|
||||||
|
asnReaderInstance = asnReader;
|
||||||
|
logger.info('MaxMind databases loaded successfully.');
|
||||||
|
return { cityReader: cityReaderInstance, asnReader: asnReaderInstance };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.fatal({ error: error.message, stack: error.stack }, 'Could not initialize MaxMind databases.');
|
||||||
|
Sentry.captureException(error);
|
||||||
|
// Wirf den Fehler weiter, damit der Serverstart fehlschlägt
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Funktion zum Abrufen der Reader (stellt sicher, dass sie initialisiert wurden)
|
||||||
|
function getMaxMindReaders() {
|
||||||
|
if (!cityReaderInstance || !asnReaderInstance) {
|
||||||
|
// Dieser Fall sollte im normalen Betrieb nicht auftreten, da initialize() beim Serverstart aufgerufen wird.
|
||||||
|
logger.error('MaxMind readers accessed before initialization!');
|
||||||
|
throw new Error('MaxMind readers not initialized. Call initializeMaxMind() first.');
|
||||||
|
}
|
||||||
|
return { cityReader: cityReaderInstance, asnReader: asnReaderInstance };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initializeMaxMind,
|
||||||
|
getMaxMindReaders,
|
||||||
|
};
|
||||||
Generated
+2681
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "utrools-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@maxmind/geoip2-node": "^6.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",
|
||||||
|
"ip-address": ">=10.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,115 @@
|
|||||||
|
// backend/routes/dnsLookup.js
|
||||||
|
const express = require('express');
|
||||||
|
const Sentry = require("@sentry/node");
|
||||||
|
const dns = require('dns').promises;
|
||||||
|
const pino = require('pino');
|
||||||
|
|
||||||
|
// Import utilities
|
||||||
|
const { isValidDomain } = require('../utils');
|
||||||
|
|
||||||
|
// Logger for this module
|
||||||
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Supported DNS record types
|
||||||
|
const VALID_DNS_TYPES = ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME', 'SOA', 'SRV', 'PTR', 'ANY'];
|
||||||
|
|
||||||
|
// Route handler for / (relative to /api/dns-lookup)
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
const domainRaw = req.query.domain;
|
||||||
|
const domain = typeof domainRaw === 'string' ? domainRaw.trim() : domainRaw;
|
||||||
|
const typeRaw = req.query.type;
|
||||||
|
// Default to 'ANY' if type is missing or invalid, convert valid types to uppercase
|
||||||
|
let type = typeof typeRaw === 'string' ? typeRaw.trim().toUpperCase() : 'ANY';
|
||||||
|
if (!VALID_DNS_TYPES.includes(type)) {
|
||||||
|
logger.warn({ requestIp: req.ip, domain, requestedType: typeRaw }, 'Invalid record type requested, defaulting to ANY');
|
||||||
|
type = 'ANY'; // Default to 'ANY' for invalid types
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestIp = req.ip || req.socket.remoteAddress;
|
||||||
|
|
||||||
|
logger.info({ requestIp, domain, type }, 'DNS lookup request received');
|
||||||
|
|
||||||
|
if (!isValidDomain(domain)) {
|
||||||
|
logger.warn({ requestIp, domain }, 'Invalid domain for DNS lookup');
|
||||||
|
return res.status(400).json({ success: false, error: 'Invalid domain name provided.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: No isPrivateIp check here as DNS lookups for internal domains might be valid use cases.
|
||||||
|
|
||||||
|
try {
|
||||||
|
let records;
|
||||||
|
if (type === 'ANY') {
|
||||||
|
// Define types to query for 'ANY' - exclude PTR as it requires an IP
|
||||||
|
const typesToQuery = ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME', 'SOA', 'SRV'];
|
||||||
|
const promises = typesToQuery.map(t =>
|
||||||
|
dns.resolve(domain, t)
|
||||||
|
.then(result => ({ type: t, records: result })) // Wrap result with type
|
||||||
|
.catch(err => ({ type: t, error: err })) // Wrap error with type
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
records = {};
|
||||||
|
results.forEach(result => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
const data = result.value;
|
||||||
|
if (data.error) {
|
||||||
|
// Log DNS resolution errors for specific types as warnings/debug
|
||||||
|
if (data.error.code !== 'ENOTFOUND' && data.error.code !== 'ENODATA') {
|
||||||
|
logger.warn({ requestIp, domain, type: data.type, error: data.error.message, code: data.error.code }, `DNS lookup failed for type ${data.type}`);
|
||||||
|
} else {
|
||||||
|
logger.debug({ requestIp, domain, type: data.type, code: data.error.code }, `No record found for type ${data.type}`);
|
||||||
|
}
|
||||||
|
// Optionally include error details in response (or just omit the type)
|
||||||
|
// records[data.type] = { error: `Lookup failed (${data.error.code || 'Unknown'})` };
|
||||||
|
} else if (data.records && data.records.length > 0) {
|
||||||
|
// Only add if records exist
|
||||||
|
records[data.type] = data.records;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle unexpected errors from Promise.allSettled (should be rare)
|
||||||
|
logger.error({ requestIp, domain, type: 'ANY', error: result.reason?.message }, 'Unexpected error during Promise.allSettled for ANY DNS lookup');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(records).length === 0) {
|
||||||
|
// If no records found for any type
|
||||||
|
logger.info({ requestIp, domain, type }, 'DNS lookup for ANY type yielded no records.');
|
||||||
|
// Send success: true, but with an empty records object or a note
|
||||||
|
// return res.json({ success: true, domain, type, records: {}, note: 'No records found for queried types.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Handle specific type query
|
||||||
|
try {
|
||||||
|
records = await dns.resolve(domain, type);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOTFOUND' || error.code === 'ENODATA') {
|
||||||
|
logger.info({ requestIp, domain, type, code: error.code }, `DNS lookup failed (No record) for type ${type}`);
|
||||||
|
// Return success: true, but indicate no records found
|
||||||
|
return res.json({ success: true, domain, type, records: [], note: `No ${type} records found.` });
|
||||||
|
} else {
|
||||||
|
// Rethrow other errors to be caught by the outer catch block
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ requestIp, domain, type }, 'DNS lookup successful');
|
||||||
|
// For specific type, records will be an array. For ANY, it's an object.
|
||||||
|
res.json({ success: true, domain, type, records });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Catches errors from specific type lookups (not ENOTFOUND/ENODATA) or unexpected errors
|
||||||
|
logger.error({ requestIp, domain, type, error: error.message, code: error.code }, 'DNS lookup failed');
|
||||||
|
Sentry.captureException(error, { extra: { requestIp, domain, type } });
|
||||||
|
// Send appropriate status code based on error if possible, otherwise 500
|
||||||
|
const statusCode = error.code === 'ESERVFAIL' ? 502 : 500;
|
||||||
|
res.status(statusCode).json({ success: false, error: `DNS lookup failed: ${error.message} (Code: ${error.code || 'Unknown'})` });
|
||||||
|
// next(error); // Optional: Pass to Sentry error handler
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// backend/routes/ipinfo.js
|
||||||
|
const express = require('express');
|
||||||
|
const Sentry = require("@sentry/node");
|
||||||
|
const dns = require('dns').promises;
|
||||||
|
const pino = require('pino'); // Assuming logger is needed, or pass it down
|
||||||
|
|
||||||
|
// Import utilities and MaxMind reader access
|
||||||
|
const { isValidIp, getCleanIp } = require('../utils');
|
||||||
|
const { getMaxMindReaders } = require('../maxmind');
|
||||||
|
|
||||||
|
// Create a logger instance for this route module
|
||||||
|
// Ideally, the main logger instance should be passed down or configured globally
|
||||||
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Route handler for / (relative to where this router is mounted, e.g., /api/ipinfo)
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
const requestIp = req.ip || req.socket.remoteAddress;
|
||||||
|
logger.info({ ip: requestIp, method: req.method, url: req.originalUrl }, 'ipinfo request received');
|
||||||
|
const clientIp = getCleanIp(requestIp);
|
||||||
|
logger.debug({ rawIp: requestIp, cleanedIp: clientIp }, 'IP cleaning result');
|
||||||
|
|
||||||
|
if (!clientIp || !isValidIp(clientIp)) {
|
||||||
|
if (clientIp === '127.0.0.1' || clientIp === '::1') {
|
||||||
|
logger.info({ ip: clientIp }, 'Responding with localhost info');
|
||||||
|
return res.json({
|
||||||
|
ip: clientIp,
|
||||||
|
geo: { note: 'Localhost IP, no Geo data available.' },
|
||||||
|
asn: { note: 'Localhost IP, no ASN data available.' },
|
||||||
|
rdns: ['localhost'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.error({ rawIp: requestIp, cleanedIp: clientIp }, 'Could not determine a valid client IP');
|
||||||
|
Sentry.captureMessage('Could not determine a valid client IP', {
|
||||||
|
level: 'error',
|
||||||
|
extra: { rawIp: requestIp, cleanedIp: clientIp }
|
||||||
|
});
|
||||||
|
// Use 400 for client error (invalid IP derived)
|
||||||
|
return res.status(400).json({ error: 'Could not determine a valid client IP address.', rawIp: requestIp, cleanedIp: clientIp });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get initialized MaxMind readers
|
||||||
|
const { cityReader, asnReader } = getMaxMindReaders();
|
||||||
|
|
||||||
|
let geo = null;
|
||||||
|
try {
|
||||||
|
const geoData = cityReader.city(clientIp);
|
||||||
|
geo = {
|
||||||
|
city: geoData.city?.names?.en,
|
||||||
|
region: geoData.subdivisions?.[0]?.isoCode,
|
||||||
|
country: geoData.country?.isoCode,
|
||||||
|
countryName: geoData.country?.names?.en,
|
||||||
|
postalCode: geoData.postal?.code,
|
||||||
|
latitude: geoData.location?.latitude,
|
||||||
|
longitude: geoData.location?.longitude,
|
||||||
|
timezone: geoData.location?.timeZone,
|
||||||
|
};
|
||||||
|
// Remove null/undefined values
|
||||||
|
geo = Object.fromEntries(Object.entries(geo).filter(([_, v]) => v != null));
|
||||||
|
logger.debug({ ip: clientIp, geo }, 'GeoIP lookup successful');
|
||||||
|
} catch (e) {
|
||||||
|
// Log as warning, as this is expected for private IPs or IPs not in DB
|
||||||
|
logger.warn({ ip: clientIp, error: e.message }, `MaxMind City lookup failed`);
|
||||||
|
geo = { error: 'GeoIP lookup failed (IP not found in database or private range).' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let asn = null;
|
||||||
|
try {
|
||||||
|
const asnData = asnReader.asn(clientIp);
|
||||||
|
asn = {
|
||||||
|
number: asnData.autonomousSystemNumber,
|
||||||
|
organization: asnData.autonomousSystemOrganization,
|
||||||
|
};
|
||||||
|
asn = Object.fromEntries(Object.entries(asn).filter(([_, v]) => v != null));
|
||||||
|
logger.debug({ ip: clientIp, asn }, 'ASN lookup successful');
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn({ ip: clientIp, error: e.message }, `MaxMind ASN lookup failed`);
|
||||||
|
asn = { error: 'ASN lookup failed (IP not found in database or private range).' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let rdns = null;
|
||||||
|
try {
|
||||||
|
const hostnames = await dns.reverse(clientIp);
|
||||||
|
rdns = hostnames;
|
||||||
|
logger.debug({ ip: clientIp, rdns }, 'rDNS lookup successful');
|
||||||
|
} catch (e) {
|
||||||
|
// Log non-existence as debug, other errors as warn
|
||||||
|
if (e.code !== 'ENOTFOUND' && e.code !== 'ENODATA') {
|
||||||
|
logger.warn({ ip: clientIp, error: e.message, code: e.code }, `rDNS lookup error`);
|
||||||
|
} else {
|
||||||
|
logger.debug({ ip: clientIp, code: e.code }, 'rDNS lookup failed (No record)');
|
||||||
|
}
|
||||||
|
// Provide a structured error in the response
|
||||||
|
rdns = { error: `rDNS lookup failed (${e.code || 'Unknown error'})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ip: clientIp,
|
||||||
|
// Only include geo/asn if they don't contain an error and have data
|
||||||
|
geo: geo.error ? geo : (Object.keys(geo).length > 0 ? geo : null),
|
||||||
|
asn: asn.error ? asn : (Object.keys(asn).length > 0 ? asn : null),
|
||||||
|
rdns // rdns will contain either the array of hostnames or the error object
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Catch unexpected errors during processing (e.g., issues with getMaxMindReaders)
|
||||||
|
logger.error({ ip: clientIp, error: error.message, stack: error.stack }, 'Error processing ipinfo');
|
||||||
|
Sentry.captureException(error, { extra: { ip: clientIp } });
|
||||||
|
// Pass the error to the Sentry error handler middleware
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// backend/routes/lookup.js
|
||||||
|
const express = require('express');
|
||||||
|
const Sentry = require("@sentry/node");
|
||||||
|
const dns = require('dns').promises;
|
||||||
|
const pino = require('pino');
|
||||||
|
|
||||||
|
// Import utilities and MaxMind reader access
|
||||||
|
const { isValidIp, isPrivateIp } = require('../utils');
|
||||||
|
const { getMaxMindReaders } = require('../maxmind');
|
||||||
|
|
||||||
|
// Logger for this module
|
||||||
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Route handler for / (relative to /api/lookup)
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
const targetIpRaw = req.query.targetIp;
|
||||||
|
const targetIp = typeof targetIpRaw === 'string' ? targetIpRaw.trim() : targetIpRaw;
|
||||||
|
const requestIp = req.ip || req.socket.remoteAddress; // IP of the client making the request
|
||||||
|
|
||||||
|
logger.info({ requestIp, targetIp }, 'Lookup request received');
|
||||||
|
|
||||||
|
if (!isValidIp(targetIp)) {
|
||||||
|
logger.warn({ requestIp, targetIp }, 'Invalid target IP for lookup');
|
||||||
|
return res.status(400).json({ success: false, error: 'Invalid IP address provided for lookup.' });
|
||||||
|
}
|
||||||
|
if (isPrivateIp(targetIp)) {
|
||||||
|
logger.warn({ requestIp, targetIp }, 'Attempt to lookup private IP blocked');
|
||||||
|
return res.status(403).json({ success: false, error: 'Lookup for private or local IP addresses is not supported.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get initialized MaxMind readers
|
||||||
|
const { cityReader, asnReader } = getMaxMindReaders();
|
||||||
|
|
||||||
|
let geoResult = null;
|
||||||
|
try {
|
||||||
|
const geoData = cityReader.city(targetIp); // Synchronous call
|
||||||
|
let geo = {
|
||||||
|
city: geoData.city?.names?.en, region: geoData.subdivisions?.[0]?.isoCode,
|
||||||
|
country: geoData.country?.isoCode, countryName: geoData.country?.names?.en,
|
||||||
|
postalCode: geoData.postal?.code, latitude: geoData.location?.latitude,
|
||||||
|
longitude: geoData.location?.longitude, timezone: geoData.location?.timeZone,
|
||||||
|
};
|
||||||
|
geo = Object.fromEntries(Object.entries(geo).filter(([_, v]) => v != null));
|
||||||
|
logger.debug({ targetIp, geo }, 'GeoIP lookup successful for lookup');
|
||||||
|
geoResult = Object.keys(geo).length > 0 ? geo : null; // Assign result or null
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn({ targetIp, error: e.message }, `MaxMind City lookup failed for lookup`);
|
||||||
|
geoResult = { error: 'GeoIP lookup failed (IP not found in database or private range).' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let asnResult = null;
|
||||||
|
try {
|
||||||
|
const asnData = asnReader.asn(targetIp); // Synchronous call
|
||||||
|
let asn = { number: asnData.autonomousSystemNumber, organization: asnData.autonomousSystemOrganization };
|
||||||
|
asn = Object.fromEntries(Object.entries(asn).filter(([_, v]) => v != null));
|
||||||
|
logger.debug({ targetIp, asn }, 'ASN lookup successful for lookup');
|
||||||
|
asnResult = Object.keys(asn).length > 0 ? asn : null; // Assign result or null
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn({ targetIp, error: e.message }, `MaxMind ASN lookup failed for lookup`);
|
||||||
|
asnResult = { error: 'ASN lookup failed (IP not found in database or private range).' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform async rDNS lookup
|
||||||
|
const rdnsResult = await dns.reverse(targetIp)
|
||||||
|
.then(hostnames => {
|
||||||
|
logger.debug({ targetIp, rdns: hostnames }, 'rDNS lookup successful for lookup');
|
||||||
|
return hostnames; // Returns array of hostnames
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (e.code !== 'ENOTFOUND' && e.code !== 'ENODATA') {
|
||||||
|
logger.warn({ targetIp, error: e.message, code: e.code }, `rDNS lookup error for lookup`);
|
||||||
|
} else {
|
||||||
|
logger.debug({ targetIp, code: e.code }, 'rDNS lookup failed (No record) for lookup');
|
||||||
|
}
|
||||||
|
return { error: `rDNS lookup failed (${e.code || 'Unknown error'})` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine results and send response
|
||||||
|
res.json({
|
||||||
|
success: true, // Indicate overall success of the request processing
|
||||||
|
ip: targetIp,
|
||||||
|
geo: geoResult, // Result from the sync try...catch
|
||||||
|
asn: asnResult, // Result from the sync try...catch
|
||||||
|
rdns: rdnsResult // Result from the async operation
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Catch unexpected errors (e.g., issue with getMaxMindReaders or dns.reverse if not caught above)
|
||||||
|
logger.error({ targetIp, requestIp, error: error.message, stack: error.stack }, 'Error processing lookup');
|
||||||
|
Sentry.captureException(error, { extra: { targetIp, requestIp } });
|
||||||
|
next(error); // Pass to the main error handler
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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,10 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getCleanIp } = require('../utils');
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
const ip = getCleanIp(req.ip || req.socket.remoteAddress);
|
||||||
|
res.json({ ip: ip || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// backend/routes/ping.js
|
||||||
|
const express = require('express');
|
||||||
|
const Sentry = require("@sentry/node");
|
||||||
|
const pino = require('pino');
|
||||||
|
|
||||||
|
// Import utilities
|
||||||
|
const { isValidIp, isPrivateIp, executeCommand, parsePingOutput } = require('../utils');
|
||||||
|
|
||||||
|
// Logger for this module
|
||||||
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Route handler for / (relative to /api/ping)
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
const targetIpRaw = req.query.targetIp;
|
||||||
|
const targetIp = typeof targetIpRaw === 'string' ? targetIpRaw.trim() : targetIpRaw;
|
||||||
|
const requestIp = req.ip || req.socket.remoteAddress;
|
||||||
|
|
||||||
|
logger.info({ requestIp, targetIp }, 'Ping request received');
|
||||||
|
|
||||||
|
if (!isValidIp(targetIp)) {
|
||||||
|
logger.warn({ requestIp, targetIp }, 'Invalid target IP for ping');
|
||||||
|
return res.status(400).json({ success: false, error: 'Invalid target IP address provided.' });
|
||||||
|
}
|
||||||
|
if (isPrivateIp(targetIp)) {
|
||||||
|
logger.warn({ requestIp, targetIp }, 'Attempt to ping private IP blocked');
|
||||||
|
return res.status(403).json({ success: false, error: 'Operations on private or local IP addresses are not allowed.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pingCount = process.env.PING_COUNT || '4';
|
||||||
|
let countArg = parseInt(pingCount, 10); // Use let as it might be reassigned
|
||||||
|
// Validate countArg to prevent potential issues
|
||||||
|
if (isNaN(countArg) || countArg <= 0 || countArg > 10) { // Limit count for safety
|
||||||
|
logger.warn({ requestIp, targetIp, requestedCount: pingCount }, 'Invalid or excessive ping count requested, using default.');
|
||||||
|
countArg = 4; // Default to 4 if invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ['-c', `${countArg}`, targetIp];
|
||||||
|
const command = 'ping';
|
||||||
|
|
||||||
|
logger.info({ requestIp, targetIp, command: `${command} ${args.join(' ')}` }, 'Executing ping');
|
||||||
|
const output = await executeCommand(command, args);
|
||||||
|
const parsedResult = parsePingOutput(output);
|
||||||
|
|
||||||
|
if (parsedResult.error) {
|
||||||
|
logger.warn({ requestIp, targetIp, error: parsedResult.error, rawOutput: parsedResult.rawOutput }, 'Ping command executed but resulted in an error state');
|
||||||
|
// Send 200 OK but indicate failure in the response body
|
||||||
|
return res.status(200).json({
|
||||||
|
success: false,
|
||||||
|
error: parsedResult.error,
|
||||||
|
rawOutput: parsedResult.rawOutput,
|
||||||
|
stats: parsedResult.stats // Include stats even if there's an error message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ requestIp, targetIp, stats: parsedResult.stats }, 'Ping successful');
|
||||||
|
res.json({ success: true, ...parsedResult });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// This catch block handles errors from executeCommand (e.g., command not found, non-zero exit code)
|
||||||
|
logger.error({ requestIp, targetIp, error: error.message, stderr: error.stderr }, 'Ping command failed execution');
|
||||||
|
Sentry.captureException(error, { extra: { requestIp, targetIp, stderr: error.stderr } });
|
||||||
|
|
||||||
|
// Attempt to parse the error output (might be stdout or stderr from the error object)
|
||||||
|
const errorOutput = error.stderr || error.stdout || error.message;
|
||||||
|
const parsedError = parsePingOutput(errorOutput);
|
||||||
|
|
||||||
|
// Send 500 Internal Server Error, but include parsed details if available
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
// Prioritize parsed error message, fallback to original error message
|
||||||
|
error: `Ping command failed: ${parsedError.error || error.message}`,
|
||||||
|
rawOutput: parsedError.rawOutput || errorOutput // Include raw output for debugging
|
||||||
|
});
|
||||||
|
// Optionally call next(error) if you want the main Sentry error handler to also catch this
|
||||||
|
// next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const dns = require('dns').promises;
|
||||||
|
const pino = require('pino');
|
||||||
|
const { getMaxMindReaders } = require('../maxmind');
|
||||||
|
|
||||||
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
|
// ASN org-name patterns that strongly suggest a VPN service
|
||||||
|
const VPN_PATTERNS = [
|
||||||
|
/\bvpn\b/i, /nordvpn/i, /expressvpn/i, /mullvad/i, /31173\s+services/i,
|
||||||
|
/owl\s+limited/i, /surfshark/i, /protonvpn/i, /cyberghost/i, /ipvanish/i,
|
||||||
|
/purevpn/i, /tunnelbear/i, /private.?internet.?access/i, /\bpia\b/i,
|
||||||
|
/hide\.?my\.?ip/i, /hidemyass/i, /windscribe/i, /perfect.?privacy/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ASN org-name patterns that suggest cloud/datacenter/hosting (but not necessarily VPN)
|
||||||
|
const DATACENTER_PATTERNS = [
|
||||||
|
/amazon/i, /\baws\b/i, /google.*cloud/i, /microsoft/i, /\bazure\b/i,
|
||||||
|
/cloudflare/i, /digitalocean/i, /linode/i, /vultr/i, /hetzner/i,
|
||||||
|
/\bovh\b/i, /leaseweb/i, /rackspace/i, /choopa/i, /equinix/i,
|
||||||
|
/hostinger/i, /\bhosting\b/i, /data.?cent(?:er|re)/i, /\bcloud\b/i,
|
||||||
|
/\bcoloc\b/i, /\bvps\b/i, /akamai/i, /fastly/i, /\bcdn\b/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tor DNS exit-node check via torproject.org DNSBL
|
||||||
|
async function isTorExit(ip) {
|
||||||
|
if (!ip || ip.includes(':')) return false; // IPv6 not supported by this DNSBL
|
||||||
|
try {
|
||||||
|
const reversed = ip.split('.').reverse().join('.');
|
||||||
|
await dns.resolve4(`${reversed}.dnsel.torproject.org`);
|
||||||
|
return true; // resolves → known Tor exit node
|
||||||
|
} catch {
|
||||||
|
return false; // NXDOMAIN → not a Tor exit node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/:ip', async (req, res, next) => {
|
||||||
|
const { ip } = req.params;
|
||||||
|
const flags = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ASN-based classification (synchronous, no network call)
|
||||||
|
try {
|
||||||
|
const { asnReader } = getMaxMindReaders();
|
||||||
|
const asnData = asnReader.asn(ip);
|
||||||
|
const org = asnData?.autonomousSystemOrganization || '';
|
||||||
|
|
||||||
|
if (VPN_PATTERNS.some(p => p.test(org))) {
|
||||||
|
flags.push({ id: 'vpn', label: 'VPN', color: 'yellow' });
|
||||||
|
} else if (DATACENTER_PATTERNS.some(p => p.test(org))) {
|
||||||
|
flags.push({ id: 'datacenter', label: 'Datacenter / Hosting', color: 'orange' });
|
||||||
|
} else if (org) {
|
||||||
|
flags.push({ id: 'residential', label: 'Residential / ISP', color: 'green' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug({ ip, err: e.message }, 'ASN lookup skipped for privacy check');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tor exit-node check (async DNS, ~100–300 ms)
|
||||||
|
const tor = await isTorExit(ip);
|
||||||
|
if (tor) {
|
||||||
|
flags.length = 0; // Tor supersedes all network-type flags — showing "Residential" alongside Tor is misleading
|
||||||
|
flags.push({ id: 'tor', label: 'Tor Exit Node', color: 'red' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ ip, flags: flags.map(f => f.id) }, 'Privacy check complete');
|
||||||
|
res.json({ success: true, ip, flags });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ ip, err: err.message }, 'Privacy check failed');
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const Sentry = require("@sentry/node");
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const pino = require('pino');
|
||||||
|
|
||||||
|
// Import utilities
|
||||||
|
const { isValidIp, isPrivateIp, parseTracerouteLine } = require('../utils');
|
||||||
|
|
||||||
|
// Logger for this module
|
||||||
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Helper function to safely create an error message string
|
||||||
|
function getErrorMessage(err, defaultMessage = 'An unknown error occurred') {
|
||||||
|
if (typeof err === 'string') return err;
|
||||||
|
if (err && typeof err.message === 'string' && err.message.trim() !== '') return err.message;
|
||||||
|
return defaultMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Route handler for / (relative to /api/traceroute)
|
||||||
|
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 }, 'Traceroute stream request received');
|
||||||
|
|
||||||
|
if (!isValidIp(targetIp)) {
|
||||||
|
logger.warn({ requestIp, targetIp }, 'Invalid target IP for traceroute');
|
||||||
|
return res.status(400).json({ success: false, error: 'Invalid target IP address provided.' });
|
||||||
|
}
|
||||||
|
if (isPrivateIp(targetIp)) {
|
||||||
|
logger.warn({ requestIp, targetIp }, 'Attempt to traceroute private IP blocked');
|
||||||
|
return res.status(403).json({ success: false, error: 'Operations on private or local IP addresses are not allowed.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info({ requestIp, targetIp }, `Starting traceroute stream...`);
|
||||||
|
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 args = ['-n', targetIp];
|
||||||
|
const command = 'traceroute';
|
||||||
|
const proc = spawn(command, args);
|
||||||
|
logger.info({ requestIp, targetIp, command: `${command} ${args.join(' ')}` }, 'Spawned traceroute process');
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
const sendEvent = (event, data) => {
|
||||||
|
try {
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
if (event === 'error' && (!data || typeof data.error !== 'string')) {
|
||||||
|
const safeErrorMessage = getErrorMessage(data?.error, 'Traceroute encountered an unspecified error.');
|
||||||
|
logger.warn({ requestIp, targetIp, originalData: data }, `Corrected invalid error event data. Sending: ${safeErrorMessage}`);
|
||||||
|
data = { error: safeErrorMessage };
|
||||||
|
}
|
||||||
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||||
|
} else {
|
||||||
|
logger.warn({ requestIp, targetIp, event }, "Attempted to write to closed SSE stream.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// This catch handles errors during res.write, likely client disconnect
|
||||||
|
logger.error({ requestIp, targetIp, event, error: e.message }, "Error writing to SSE stream (client likely disconnected)");
|
||||||
|
Sentry.captureException(e, { level: 'warning', extra: { requestIp, targetIp, event } });
|
||||||
|
if (proc && !proc.killed) proc.kill();
|
||||||
|
if (!res.writableEnded) res.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
proc.stdout.on('data', (data) => {
|
||||||
|
buffer += data.toString();
|
||||||
|
let lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
lines.forEach(line => {
|
||||||
|
const parsed = parseTracerouteLine(line);
|
||||||
|
if (parsed) {
|
||||||
|
sendEvent('hop', parsed);
|
||||||
|
} else if (line.trim()) {
|
||||||
|
sendEvent('info', { message: line.trim() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on('data', (data) => {
|
||||||
|
const errorMsg = getErrorMessage(data.toString().trim(), 'Traceroute produced unknown stderr output.');
|
||||||
|
logger.warn({ requestIp, targetIp, stderr: errorMsg }, 'Traceroute stderr output');
|
||||||
|
Sentry.captureMessage('Traceroute stderr output', { level: 'warning', extra: { requestIp, targetIp, stderr: errorMsg } });
|
||||||
|
sendEvent('error', { error: errorMsg });
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
const errorMsg = getErrorMessage(err, 'Failed to start traceroute command due to an unknown error.');
|
||||||
|
logger.error({ requestIp, targetIp, error: errorMsg }, `Failed to start traceroute command`);
|
||||||
|
Sentry.captureException(err, { extra: { requestIp, targetIp } }); // Capture original error
|
||||||
|
sendEvent('error', { error: `Failed to start traceroute: ${errorMsg}` });
|
||||||
|
if (!res.writableEnded) res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (buffer) {
|
||||||
|
const parsed = parseTracerouteLine(buffer);
|
||||||
|
if (parsed) sendEvent('hop', parsed);
|
||||||
|
else if (buffer.trim()) sendEvent('info', { message: buffer.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
const errorMsg = `Traceroute command failed with exit code ${code}`;
|
||||||
|
logger.error({ requestIp, targetIp, exitCode: code }, errorMsg);
|
||||||
|
Sentry.captureMessage('Traceroute command failed', { level: 'error', extra: { requestIp, targetIp, exitCode: code } });
|
||||||
|
sendEvent('error', { error: errorMsg });
|
||||||
|
} else {
|
||||||
|
logger.info({ requestIp, targetIp }, `Traceroute stream completed successfully.`);
|
||||||
|
}
|
||||||
|
sendEvent('end', { exitCode: code });
|
||||||
|
if (!res.writableEnded) res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
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 } });
|
||||||
|
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ success: false, error: `Failed to initiate traceroute: ${errorMsg}` });
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
sendEvent('error', { error: `Internal server error during setup: ${errorMsg}` });
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
} catch (e) { logger.error({ requestIp, targetIp, error: e.message }, "Error writing final setup error to SSE stream"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// backend/routes/version.js
|
||||||
|
const express = require('express');
|
||||||
|
const pino = require('pino');
|
||||||
|
|
||||||
|
// Logger for this module
|
||||||
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Route handler for / (relative to /api/version)
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
// Read commit SHA from environment variable (set during build/deploy)
|
||||||
|
const commitSha = process.env.GIT_COMMIT_SHA || 'unknown';
|
||||||
|
const requestIp = req.ip || req.socket.remoteAddress;
|
||||||
|
|
||||||
|
logger.info({ requestIp, commitSha }, 'Version request received');
|
||||||
|
res.json({ commitSha });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// backend/routes/whoisLookup.js
|
||||||
|
const express = require('express');
|
||||||
|
const Sentry = require("@sentry/node");
|
||||||
|
const whois = require('whois-json');
|
||||||
|
const pino = require('pino');
|
||||||
|
|
||||||
|
// Import utilities
|
||||||
|
const { isValidIp, isValidDomain, isPrivateIp } = require('../utils');
|
||||||
|
|
||||||
|
// Logger for this module
|
||||||
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Route handler for / (relative to /api/whois-lookup)
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
const queryRaw = req.query.query;
|
||||||
|
const query = typeof queryRaw === 'string' ? queryRaw.trim() : queryRaw;
|
||||||
|
const requestIp = req.ip || req.socket.remoteAddress;
|
||||||
|
|
||||||
|
logger.info({ requestIp, query }, 'WHOIS lookup request received');
|
||||||
|
|
||||||
|
// Validate if the query is either a valid IP or a valid domain
|
||||||
|
if (!isValidIp(query) && !isValidDomain(query)) {
|
||||||
|
logger.warn({ requestIp, query }, 'Invalid query for WHOIS lookup');
|
||||||
|
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.
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute WHOIS lookup with a timeout
|
||||||
|
const result = await whois(query, {
|
||||||
|
timeout: parseInt(process.env.WHOIS_TIMEOUT || '10000', 10), // Configurable timeout (default 10s), ensure integer
|
||||||
|
// follow: 3, // Optional: limit number of redirects followed
|
||||||
|
// verbose: true // Optional: get raw text output as well
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if the result indicates an error (some servers return structured errors)
|
||||||
|
// This check might need adjustment based on the 'whois-json' library's output for errors.
|
||||||
|
if (result && (result.error || result.Error)) {
|
||||||
|
logger.warn({ requestIp, query, whoisResult: result }, 'WHOIS lookup returned an error structure');
|
||||||
|
return res.status(404).json({ success: false, error: `WHOIS lookup failed: ${result.error || result.Error}`, result });
|
||||||
|
}
|
||||||
|
// Basic check if the result is empty or just contains the query itself (might indicate no data)
|
||||||
|
if (!result || Object.keys(result).length === 0 || (Object.keys(result).length === 1 && (result.domainName === query || result.query === query))) {
|
||||||
|
logger.info({ requestIp, query }, 'WHOIS lookup returned no detailed data.');
|
||||||
|
// Consider 404 Not Found if no data is available
|
||||||
|
return res.status(404).json({ success: false, error: 'No detailed WHOIS information found for the query.', query });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
logger.info({ requestIp, query }, 'WHOIS lookup successful');
|
||||||
|
res.json({ success: true, query, result });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ requestIp, query, error: error.message }, 'WHOIS lookup failed');
|
||||||
|
Sentry.captureException(error, { extra: { requestIp, query } });
|
||||||
|
|
||||||
|
// Provide more user-friendly error messages based on common errors
|
||||||
|
let errorMessage = error.message;
|
||||||
|
let statusCode = 500; // Default to Internal Server Error
|
||||||
|
|
||||||
|
if (error.message.includes('ETIMEDOUT') || error.message.includes('ESOCKETTIMEDOUT')) {
|
||||||
|
errorMessage = 'WHOIS server timed out.';
|
||||||
|
statusCode = 504; // Gateway Timeout
|
||||||
|
} else if (error.message.includes('ENOTFOUND')) {
|
||||||
|
// This might indicate the domain doesn't exist or the WHOIS server for the TLD couldn't be found
|
||||||
|
errorMessage = 'Domain or IP not found, or the corresponding WHOIS server is unavailable.';
|
||||||
|
statusCode = 404; // Not Found
|
||||||
|
} else if (error.message.includes('ECONNREFUSED')) {
|
||||||
|
errorMessage = 'Connection to WHOIS server refused.';
|
||||||
|
statusCode = 503; // Service Unavailable
|
||||||
|
} else if (error.message.includes('No WHOIS server found for')) {
|
||||||
|
errorMessage = 'Could not find a WHOIS server for the requested domain/TLD.';
|
||||||
|
statusCode = 404; // Not Found (as the server for it isn't known)
|
||||||
|
}
|
||||||
|
// Add more specific error handling if needed based on observed errors
|
||||||
|
|
||||||
|
res.status(statusCode).json({ success: false, error: `WHOIS lookup failed: ${errorMessage}` });
|
||||||
|
// next(error); // Optional: Pass to Sentry error handler
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
// --- Sentry Initialisierung (GANZ OBEN, nach dotenv) ---
|
||||||
|
const Sentry = require("@sentry/node");
|
||||||
|
|
||||||
|
// Initialize Sentry BEFORE requiring any other modules!
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.SENTRY_DSN,
|
||||||
|
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
|
||||||
|
integrations: [
|
||||||
|
Sentry.consoleLoggingIntegration({ levels: ["warn", "error"] }),
|
||||||
|
],
|
||||||
|
enableLogs: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Require necessary core modules AFTER Sentry is initialized
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const pino = require('pino'); // Logging library
|
||||||
|
const rateLimit = require('express-rate-limit'); // Rate limiting middleware
|
||||||
|
|
||||||
|
// Import local modules
|
||||||
|
const { initializeMaxMind } = require('./maxmind'); // MaxMind DB initialization
|
||||||
|
const ipinfoRoutes = require('./routes/ipinfo');
|
||||||
|
const pingRoutes = require('./routes/ping');
|
||||||
|
const tracerouteRoutes = require('./routes/traceroute');
|
||||||
|
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');
|
||||||
|
const privacyRoutes = require('./routes/privacy');
|
||||||
|
const myipRoutes = require('./routes/myip');
|
||||||
|
|
||||||
|
// --- 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Express app instance
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// --- Core Middleware ---
|
||||||
|
app.use(cors()); // Enable CORS
|
||||||
|
app.use(express.json()); // Parse JSON bodies
|
||||||
|
app.set('trust proxy', parseInt(process.env.TRUST_PROXY_COUNT || '2', 10)); // Adjust based on your proxy setup, ensure integer
|
||||||
|
|
||||||
|
|
||||||
|
// --- Rate Limiter ---
|
||||||
|
// Apply a general limiter to most routes
|
||||||
|
const generalLimiter = rateLimit({
|
||||||
|
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 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');
|
||||||
|
Sentry.captureMessage('Rate limit exceeded', {
|
||||||
|
level: 'warning',
|
||||||
|
extra: { ip: req.ip, route: req.originalUrl }
|
||||||
|
});
|
||||||
|
res.status(options.statusCode).send(options.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply the limiter to ALL API routes
|
||||||
|
app.use('/api', generalLimiter);
|
||||||
|
|
||||||
|
|
||||||
|
// --- API Routes ---
|
||||||
|
// Mount the imported route handlers
|
||||||
|
app.use('/api/ipinfo', ipinfoRoutes);
|
||||||
|
app.use('/api/ping', pingRoutes);
|
||||||
|
app.use('/api/traceroute', tracerouteRoutes);
|
||||||
|
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);
|
||||||
|
app.use('/api/privacy', privacyRoutes);
|
||||||
|
app.use('/api/myip', myipRoutes);
|
||||||
|
|
||||||
|
|
||||||
|
// Sentry error handler — must be after routes, before custom error handler
|
||||||
|
Sentry.setupExpressErrorHandler(app);
|
||||||
|
|
||||||
|
|
||||||
|
// --- Fallback Error Handler ---
|
||||||
|
// Optional: Catches errors not handled by Sentry or passed via next(err)
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
logger.error({
|
||||||
|
error: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
url: req.originalUrl,
|
||||||
|
method: req.method,
|
||||||
|
status: err.status,
|
||||||
|
sentryId: res.sentry // Sentry ID if available
|
||||||
|
}, 'Unhandled error caught by fallback handler');
|
||||||
|
|
||||||
|
// Avoid sending stack trace in production
|
||||||
|
const errorResponse = {
|
||||||
|
error: err.message || 'Internal Server Error',
|
||||||
|
...(res.sentry && { sentryId: res.sentry }) // Include Sentry ID if available
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(err.status || 500).json(errorResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Server Start ---
|
||||||
|
let server; // Variable to hold the server instance for graceful shutdown
|
||||||
|
|
||||||
|
// Initialize external resources (like MaxMind DBs) then start the server
|
||||||
|
initializeMaxMind().then(() => {
|
||||||
|
server = app.listen(PORT, () => {
|
||||||
|
logger.info({ port: PORT, node_env: process.env.NODE_ENV || 'development' }, `Server listening`);
|
||||||
|
// Log available routes (optional)
|
||||||
|
logger.info(`API base URL: http://localhost:${PORT}/api`);
|
||||||
|
});
|
||||||
|
}).catch(error => {
|
||||||
|
logger.fatal({ error: error.message, stack: error.stack }, "Server could not start due to initialization errors.");
|
||||||
|
Sentry.captureException(error); // Capture initialization errors
|
||||||
|
process.exit(1); // Exit if initialization fails
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Graceful Shutdown ---
|
||||||
|
const signals = { 'SIGINT': 2, 'SIGTERM': 15 };
|
||||||
|
|
||||||
|
async function gracefulShutdown(signal) {
|
||||||
|
logger.info(`Received ${signal}, shutting down gracefully...`);
|
||||||
|
if (server) {
|
||||||
|
server.close(async () => {
|
||||||
|
logger.info('HTTP server closed.');
|
||||||
|
// Close Sentry to allow time for events to be sent
|
||||||
|
try {
|
||||||
|
await Sentry.close(2000); // 2 second timeout
|
||||||
|
logger.info('Sentry closed.');
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({ error: e.message }, 'Error closing Sentry');
|
||||||
|
} finally {
|
||||||
|
process.exit(128 + signals[signal]); // Standard exit code for signals
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If server never started, still try to close Sentry and exit
|
||||||
|
logger.warn('Server was not running, attempting to close Sentry and exit.');
|
||||||
|
try {
|
||||||
|
await Sentry.close(2000);
|
||||||
|
logger.info('Sentry closed (server never started).');
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({ error: e.message }, 'Error closing Sentry (server never started)');
|
||||||
|
} finally {
|
||||||
|
process.exit(128 + signals[signal]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force exit after a timeout if graceful shutdown hangs
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.warn('Graceful shutdown timed out, forcing exit.');
|
||||||
|
process.exit(1);
|
||||||
|
}, 5000); // 5 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register signal handlers
|
||||||
|
Object.keys(signals).forEach((signal) => {
|
||||||
|
process.on(signal, () => gracefulShutdown(signal));
|
||||||
|
});
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
// backend/utils.js
|
||||||
|
const net = require('net'); // Node.js built-in module for IP validation
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const pino = require('pino'); // Import pino for logging within utils if needed
|
||||||
|
const Sentry = require("@sentry/node"); // Import Sentry for error reporting
|
||||||
|
|
||||||
|
// Logger instance (assuming a logger is initialized elsewhere and passed or created here)
|
||||||
|
// For simplicity, creating a basic logger here. Ideally, pass the main logger instance.
|
||||||
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert eine IP-Adresse (v4 oder v6) mit Node.js' eingebautem net Modul.
|
||||||
|
* @param {string} ip - Die zu validierende IP-Adresse.
|
||||||
|
* @returns {boolean} True, wenn gültig (als v4 oder v6), sonst false.
|
||||||
|
*/
|
||||||
|
function isValidIp(ip) {
|
||||||
|
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const trimmedIp = ip.trim();
|
||||||
|
const ipVersion = net.isIP(trimmedIp); // Gibt 0, 4 oder 6 zurück
|
||||||
|
return ipVersion === 4 || ipVersion === 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob eine IP-Adresse im privaten, Loopback- oder Link-Local-Bereich liegt.
|
||||||
|
* @param {string} ip - Die zu prüfende IP-Adresse (bereits validiert).
|
||||||
|
* @returns {boolean} True, wenn die IP privat/lokal ist, sonst false.
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
const parts = ip.split('.').map(Number);
|
||||||
|
return (
|
||||||
|
parts[0] === 10 || // 10.0.0.0/8
|
||||||
|
(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)
|
||||||
|
// 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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert einen Domainnamen (sehr einfache Prüfung).
|
||||||
|
* @param {string} domain - Der zu validierende Domainname.
|
||||||
|
* @returns {boolean} True, wenn wahrscheinlich gültig, sonst false.
|
||||||
|
*/
|
||||||
|
function isValidDomain(domain) {
|
||||||
|
if (!domain || typeof domain !== 'string' || domain.trim().length < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Regex updated to be more robust and handle international characters (IDNs)
|
||||||
|
const domainRegex = /^(?:[a-z0-9\p{L}](?:[a-z0-9\p{L}-]{0,61}[a-z0-9\p{L}])?\.)+[a-z0-9\p{L}][a-z0-9\p{L}-]{0,61}[a-z0-9\p{L}]$/iu;
|
||||||
|
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).
|
||||||
|
* @param {string} ip - Die IP-Adresse.
|
||||||
|
* @returns {string} Die bereinigte IP-Adresse.
|
||||||
|
*/
|
||||||
|
function getCleanIp(ip) {
|
||||||
|
if (!ip) return ip;
|
||||||
|
const trimmedIp = ip.trim();
|
||||||
|
if (trimmedIp.startsWith('::ffff:')) {
|
||||||
|
const potentialIp4 = trimmedIp.substring(7);
|
||||||
|
if (net.isIP(potentialIp4) === 4) {
|
||||||
|
return potentialIp4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep localhost IPs as they are
|
||||||
|
if (trimmedIp === '::1' || trimmedIp === '127.0.0.1') {
|
||||||
|
return trimmedIp;
|
||||||
|
}
|
||||||
|
// Return trimmed IP for other cases
|
||||||
|
return trimmedIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt einen Shell-Befehl sicher aus und gibt stdout zurück. (Nur für Ping verwendet)
|
||||||
|
* @param {string} command - Der Befehl (z.B. 'ping').
|
||||||
|
* @param {string[]} args - Die Argumente als Array.
|
||||||
|
* @returns {Promise<string>} Eine Promise, die mit stdout aufgelöst wird.
|
||||||
|
*/
|
||||||
|
function executeCommand(command, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Basic argument validation
|
||||||
|
args.forEach(arg => {
|
||||||
|
if (typeof arg === 'string' && /[;&|`$()<>]/.test(arg)) {
|
||||||
|
const error = new Error(`Invalid character detected in command argument.`);
|
||||||
|
logger.error({ command, arg }, "Potential command injection attempt detected in argument");
|
||||||
|
Sentry.captureException(error); // Send to Sentry
|
||||||
|
return reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const proc = spawn(command, args);
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
||||||
|
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
const error = new Error(`Failed to start command ${command}: ${err.message}`);
|
||||||
|
logger.error({ command, args, error: err.message }, `Failed to start command`);
|
||||||
|
Sentry.captureException(error); // Send to Sentry
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
const error = new Error(`Command ${command} failed with code ${code}: ${stderr || 'No stderr output'}`);
|
||||||
|
// Attach stdout/stderr to the error object for better context in rejection
|
||||||
|
error.stdout = stdout;
|
||||||
|
error.stderr = stderr;
|
||||||
|
logger.error({ command, args, exitCode: code, stderr: stderr.trim(), stdout: stdout.trim() }, `Command failed`);
|
||||||
|
Sentry.captureException(error, { extra: { stdout: stdout.trim(), stderr: stderr.trim() } }); // Send to Sentry
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve(stdout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst die Ausgabe des Linux/macOS ping Befehls.
|
||||||
|
* @param {string} pingOutput - Die rohe stdout Ausgabe von ping.
|
||||||
|
* @returns {object} Ein Objekt mit geparsten Daten oder Fehlern.
|
||||||
|
*/
|
||||||
|
function parsePingOutput(pingOutput) {
|
||||||
|
const result = {
|
||||||
|
rawOutput: pingOutput,
|
||||||
|
stats: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let packetsTransmitted = 0;
|
||||||
|
let packetsReceived = 0;
|
||||||
|
let packetLossPercent = 100;
|
||||||
|
let rtt = { min: null, avg: null, max: null, mdev: null };
|
||||||
|
|
||||||
|
const lines = pingOutput.trim().split('\n');
|
||||||
|
const statsLine = lines.find(line => line.includes('packets transmitted'));
|
||||||
|
if (statsLine) {
|
||||||
|
const transmittedMatch = statsLine.match(/(\d+)\s+packets transmitted/);
|
||||||
|
const receivedMatch = statsLine.match(/(\d+)\s+(?:received|packets received)/);
|
||||||
|
const lossMatch = statsLine.match(/([\d.]+)%\s+packet loss/);
|
||||||
|
if (transmittedMatch) packetsTransmitted = parseInt(transmittedMatch[1], 10);
|
||||||
|
if (receivedMatch) packetsReceived = parseInt(receivedMatch[1], 10);
|
||||||
|
if (lossMatch) packetLossPercent = parseFloat(lossMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const rttMatch = rttLine.match(/([\d.]+)\/([\d.]+)\/([\d.]+)\/([\d.]+)/);
|
||||||
|
if (rttMatch) {
|
||||||
|
rtt = {
|
||||||
|
min: parseFloat(rttMatch[1]),
|
||||||
|
avg: parseFloat(rttMatch[2]),
|
||||||
|
max: parseFloat(rttMatch[3]),
|
||||||
|
mdev: parseFloat(rttMatch[4]), // Note: mdev/stddev might have different meanings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.stats = {
|
||||||
|
packets: { transmitted: packetsTransmitted, received: packetsReceived, lossPercent: packetLossPercent },
|
||||||
|
rtt: rtt.avg !== null ? rtt : null, // Only include RTT if average is available
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for common error messages or patterns
|
||||||
|
if (packetsTransmitted > 0 && packetsReceived === 0) {
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error({ error: parseError.message, output: pingOutput }, "Failed to parse ping output");
|
||||||
|
Sentry.captureException(parseError, { extra: { pingOutput } }); // Send to Sentry
|
||||||
|
result.error = "Failed to parse ping output.";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst eine einzelne Zeile der Linux/macOS traceroute Ausgabe.
|
||||||
|
* @param {string} line - Eine Zeile aus stdout.
|
||||||
|
* @returns {object | null} Ein Objekt mit Hop-Daten oder null bei uninteressanten Zeilen.
|
||||||
|
*/
|
||||||
|
function parseTracerouteLine(line) {
|
||||||
|
line = line.trim();
|
||||||
|
// Ignore header lines and empty lines
|
||||||
|
if (!line || line.startsWith('traceroute to') || line.includes('hops max')) return null;
|
||||||
|
|
||||||
|
// Regex to capture hop number, hostname (optional), IP address, and RTT times
|
||||||
|
// Handles cases with or without hostname, and different spacing
|
||||||
|
const hopMatch = line.match(/^(\s*\d+)\s+(?:([a-zA-Z0-9\.\-]+)\s+\(([\d\.:a-fA-F]+)\)|([\d\.:a-fA-F]+))\s+(.*)$/);
|
||||||
|
const timeoutMatch = line.match(/^(\s*\d+)\s+(\*\s+\*\s+\*)/); // Match lines with only timeouts
|
||||||
|
|
||||||
|
if (timeoutMatch) {
|
||||||
|
// Handle timeout line
|
||||||
|
return {
|
||||||
|
hop: parseInt(timeoutMatch[1].trim(), 10),
|
||||||
|
hostname: null,
|
||||||
|
ip: null,
|
||||||
|
rtt: ['*', '*', '*'], // Represent timeouts as '*'
|
||||||
|
rawLine: line,
|
||||||
|
};
|
||||||
|
} else if (hopMatch) {
|
||||||
|
// Handle successful hop line
|
||||||
|
const hop = parseInt(hopMatch[1].trim(), 10);
|
||||||
|
const hostname = hopMatch[2]; // Hostname if present
|
||||||
|
const ipInParen = hopMatch[3]; // IP if hostname is present
|
||||||
|
const ipDirect = hopMatch[4]; // IP if hostname is not present
|
||||||
|
const restOfLine = hopMatch[5].trim();
|
||||||
|
const ip = ipInParen || ipDirect; // Determine the correct IP
|
||||||
|
|
||||||
|
// Extract RTT times, handling '*' for timeouts and removing ' ms' units
|
||||||
|
const rttParts = restOfLine.split(/\s+/);
|
||||||
|
const rtts = rttParts
|
||||||
|
.map(p => p === '*' ? '*' : p.replace(/\s*ms$/, '')) // Keep '*' or remove ' ms'
|
||||||
|
.filter(p => p === '*' || !isNaN(parseFloat(p))) // Ensure it's '*' or a number
|
||||||
|
.slice(0, 3); // Take the first 3 valid RTT values
|
||||||
|
|
||||||
|
// Pad with '*' if fewer than 3 RTTs were found (e.g., due to timeouts)
|
||||||
|
while (rtts.length < 3) rtts.push('*');
|
||||||
|
|
||||||
|
return {
|
||||||
|
hop: hop,
|
||||||
|
hostname: hostname || null, // Use null if hostname wasn't captured
|
||||||
|
ip: ip,
|
||||||
|
rtt: rtts,
|
||||||
|
rawLine: line,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return null if the line doesn't match expected formats
|
||||||
|
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,
|
||||||
|
checkPort,
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Setzt die Git-Commit-Variable für den Build-Prozess
|
||||||
|
export GIT_COMMIT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
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
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
services:
|
||||||
|
# Backend Service (Node.js App)
|
||||||
|
backend:
|
||||||
|
image: mrunknownde/utools-backend
|
||||||
|
container_name: utools_backend
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "0" # Run as root so ASN cache volume is writable
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
LOG_LEVEL: info
|
||||||
|
PING_COUNT: 4
|
||||||
|
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
|
||||||
|
- 1.0.0.1
|
||||||
|
- 8.8.8.8
|
||||||
|
- 8.8.4.4
|
||||||
|
networks:
|
||||||
|
- utools_network
|
||||||
|
|
||||||
|
# Frontend Service (Nginx)
|
||||||
|
frontend:
|
||||||
|
image: mrunknownde/utools-frontend
|
||||||
|
container_name: utools_frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- utools_network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
utools_network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
# Named volume — ASN cache persists across container restarts
|
||||||
|
volumes:
|
||||||
|
asn_cache:
|
||||||
|
driver: local
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Stage 1: Build (falls wir später einen Build-Schritt hätten, z.B. für Tailwind Purge)
|
||||||
|
# Aktuell nicht nötig, da wir CDN/statische Dateien haben.
|
||||||
|
|
||||||
|
# Stage 2: Production Environment using Nginx
|
||||||
|
FROM nginx:1.31.0-alpine
|
||||||
|
|
||||||
|
# Arbeitsverzeichnis im Container (optional, aber gute Praxis)
|
||||||
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Entferne die Standard Nginx Willkommensseite
|
||||||
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Kopiere unsere eigene Nginx Konfiguration
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Kopiere die Frontend-Dateien in das Verzeichnis, das Nginx ausliefert
|
||||||
|
COPY app/ .
|
||||||
|
# Falls du später CSS-Dateien oder Bilder hast, kopiere sie auch:
|
||||||
|
# COPY styles.css .
|
||||||
|
# COPY images/ ./images
|
||||||
|
|
||||||
|
# Nginx lauscht standardmäßig auf Port 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Der Basis-Image startet Nginx bereits. Kein CMD nötig, außer wir wollen Optionen ändern.
|
||||||
|
# CMD ["nginx", "-g", "daemon off;"] # Standard-CMD im Basis-Image
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<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-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>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,565 @@
|
|||||||
|
export const page = {
|
||||||
|
title: 'Subnet Calculator',
|
||||||
|
|
||||||
|
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-4 text-center text-gradient">IP Subnet Calculator</h2>
|
||||||
|
|
||||||
|
<!-- Mode Toggle -->
|
||||||
|
<div class="flex justify-center mb-8">
|
||||||
|
<div class="flex bg-gray-900/70 border border-gray-700/50 rounded-xl p-1 gap-1">
|
||||||
|
<button id="btn-beginner" class="px-6 py-2 rounded-lg text-sm font-semibold transition-all duration-200 bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg">
|
||||||
|
Beginner
|
||||||
|
</button>
|
||||||
|
<button id="btn-pro" class="px-6 py-2 rounded-lg text-sm font-semibold transition-all duration-200 text-gray-400 hover:text-gray-200">
|
||||||
|
Expert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- BEGINNER MODE -->
|
||||||
|
<div id="beginner-mode">
|
||||||
|
<div class="glass-card p-6 rounded-xl mb-6">
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-gray-400 text-sm font-bold mb-2 uppercase tracking-wide">IP Address</label>
|
||||||
|
<input type="text" id="beg-ip" value="192.168.1.0" placeholder="e.g. 192.168.1.0"
|
||||||
|
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 font-mono transition-all">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Enter an IPv4 address, e.g. 192.168.1.0</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<label class="text-gray-400 text-sm font-bold uppercase tracking-wide">Network Size</label>
|
||||||
|
<span id="beg-cidr-label" class="text-3xl font-mono font-bold text-purple-300">/24</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="beg-slider" min="1" max="30" value="24" list="cidr-marks"
|
||||||
|
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-purple-500">
|
||||||
|
<datalist id="cidr-marks">
|
||||||
|
<option value="8"></option>
|
||||||
|
<option value="16"></option>
|
||||||
|
<option value="24"></option>
|
||||||
|
</datalist>
|
||||||
|
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||||
|
<span>/1 — huge</span>
|
||||||
|
<span>/8</span>
|
||||||
|
<span>/16</span>
|
||||||
|
<span>/24</span>
|
||||||
|
<span>/30 — tiny</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Host count highlight -->
|
||||||
|
<div class="mb-4 p-4 bg-gray-900/60 rounded-xl border border-purple-500/20 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 uppercase tracking-wider mb-1">Usable Hosts</p>
|
||||||
|
<p id="beg-hosts-count" class="text-4xl font-bold font-mono text-green-400">254</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-xs text-gray-500 uppercase tracking-wider mb-1">Subnet Mask</p>
|
||||||
|
<p id="beg-mask-display" class="text-sm font-mono text-gray-300">255.255.255.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visual bar -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<span class="text-xs text-gray-500 uppercase tracking-wider">Relative Network Size (logarithmic)</span>
|
||||||
|
<div class="h-3 bg-gray-800 rounded-full overflow-hidden border border-gray-700/50 mt-1">
|
||||||
|
<div id="beg-bar" class="h-full bg-gradient-to-r from-purple-600 to-pink-500 transition-all duration-500 rounded-full" style="width:26%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-xs text-gray-600 mt-1">
|
||||||
|
<span>2 hosts (/30)</span>
|
||||||
|
<span>2 billion hosts (/1)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results grid -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div class="bg-gray-900/50 rounded-lg p-3 text-center border border-gray-700/30">
|
||||||
|
<p class="text-xs text-gray-500 mb-1">Network ID</p>
|
||||||
|
<p id="beg-network" class="font-mono text-white text-sm font-bold">-</p>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">network address</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900/50 rounded-lg p-3 text-center border border-gray-700/30">
|
||||||
|
<p class="text-xs text-gray-500 mb-1">First Host</p>
|
||||||
|
<p id="beg-first" class="font-mono text-blue-300 text-sm font-bold">-</p>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">1st usable address</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900/50 rounded-lg p-3 text-center border border-gray-700/30">
|
||||||
|
<p class="text-xs text-gray-500 mb-1">Last Host</p>
|
||||||
|
<p id="beg-last" class="font-mono text-blue-300 text-sm font-bold">-</p>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">last usable address</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900/50 rounded-lg p-3 text-center border border-gray-700/30">
|
||||||
|
<p class="text-xs text-gray-500 mb-1">Broadcast</p>
|
||||||
|
<p id="beg-broadcast" class="font-mono text-purple-400 text-sm font-bold">-</p>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">send to all devices</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Explanation card -->
|
||||||
|
<div class="glass-card p-6 rounded-xl border border-purple-500/20 mb-6">
|
||||||
|
<h3 class="text-base font-bold text-purple-300 mb-3 flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
What does this mean?
|
||||||
|
</h3>
|
||||||
|
<p id="beg-explain-text" class="text-sm text-gray-300 leading-relaxed mb-4"></p>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
||||||
|
<div class="p-3 bg-gray-900/40 rounded-lg border border-gray-700/30">
|
||||||
|
<p class="text-white font-semibold mb-1">Network Address</p>
|
||||||
|
<p class="text-gray-400">The first address — identifies the network itself. No device can use this address.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-gray-900/40 rounded-lg border border-gray-700/30">
|
||||||
|
<p class="text-white font-semibold mb-1">Host Addresses</p>
|
||||||
|
<p class="text-gray-400">All addresses in between — assignable to devices like PCs, servers, or printers.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-gray-900/40 rounded-lg border border-gray-700/30">
|
||||||
|
<p class="text-white font-semibold mb-1">Broadcast Address</p>
|
||||||
|
<p class="text-gray-400">The last address — packets sent here are delivered to every device in the network.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick examples -->
|
||||||
|
<div class="glass-card p-4 rounded-xl mb-6">
|
||||||
|
<p class="text-xs text-gray-500 uppercase tracking-wider mb-3 font-bold">Typical Networks — click to try</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button class="beg-example px-3 py-1.5 text-xs bg-gray-800 hover:bg-purple-900/50 border border-gray-700/50 hover:border-purple-500/50 rounded-lg font-mono text-gray-300 hover:text-white transition-all" data-ip="192.168.1.0" data-cidr="24">192.168.1.0/24 — home network (254 hosts)</button>
|
||||||
|
<button class="beg-example px-3 py-1.5 text-xs bg-gray-800 hover:bg-purple-900/50 border border-gray-700/50 hover:border-purple-500/50 rounded-lg font-mono text-gray-300 hover:text-white transition-all" data-ip="192.168.0.0" data-cidr="16">192.168.0.0/16 — large (65k hosts)</button>
|
||||||
|
<button class="beg-example px-3 py-1.5 text-xs bg-gray-800 hover:bg-purple-900/50 border border-gray-700/50 hover:border-purple-500/50 rounded-lg font-mono text-gray-300 hover:text-white transition-all" data-ip="10.0.0.0" data-cidr="8">10.0.0.0/8 — huge (16M hosts)</button>
|
||||||
|
<button class="beg-example px-3 py-1.5 text-xs bg-gray-800 hover:bg-purple-900/50 border border-gray-700/50 hover:border-purple-500/50 rounded-lg font-mono text-gray-300 hover:text-white transition-all" data-ip="192.168.1.0" data-cidr="28">192.168.1.0/28 — small (14 hosts)</button>
|
||||||
|
<button class="beg-example px-3 py-1.5 text-xs bg-gray-800 hover:bg-purple-900/50 border border-gray-700/50 hover:border-purple-500/50 rounded-lg font-mono text-gray-300 hover:text-white transition-all" data-ip="10.0.0.0" data-cidr="30">10.0.0.0/30 — P2P link (2 hosts)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subnet explainer -->
|
||||||
|
<details class="glass-card rounded-xl border border-gray-700/30 group">
|
||||||
|
<summary class="flex items-center justify-between p-5 cursor-pointer select-none list-none">
|
||||||
|
<span class="text-sm font-bold text-gray-300 flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||||
|
</svg>
|
||||||
|
What is a subnet, exactly?
|
||||||
|
</span>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-500 transition-transform duration-200 group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="px-5 pb-6 space-y-6 border-t border-gray-700/30 pt-5">
|
||||||
|
|
||||||
|
<!-- Analogy -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-bold text-purple-300 mb-2">The neighbourhood analogy</h4>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed mb-3">
|
||||||
|
Think of a city. Every house has a full address: <span class="font-mono text-gray-200">district + house number</span>.
|
||||||
|
A subnet works the same way — every IP address is split into two parts.
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div class="p-3 bg-purple-900/20 border border-purple-500/30 rounded-lg">
|
||||||
|
<p class="text-xs font-bold text-purple-300 uppercase tracking-wider mb-1">Network part (district)</p>
|
||||||
|
<p class="font-mono text-white text-sm mb-1">192.168.1.<span class="text-gray-500">___</span></p>
|
||||||
|
<p class="text-xs text-gray-400">All devices in the same subnet share this part — like neighbours on the same street.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-blue-900/20 border border-blue-500/30 rounded-lg">
|
||||||
|
<p class="text-xs font-bold text-blue-300 uppercase tracking-wider mb-1">Host part (house number)</p>
|
||||||
|
<p class="font-mono text-white text-sm mb-1"><span class="text-gray-500">___</span>.42</p>
|
||||||
|
<p class="text-xs text-gray-400">Each device gets a unique number within the network.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- What does /24 mean -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-bold text-purple-300 mb-2">What does the slash mean?</h4>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed mb-3">
|
||||||
|
An IP address is made up of exactly <strong class="text-white">32 bits</strong> (ones and zeros) under the hood.
|
||||||
|
The <span class="font-mono text-purple-300">/24</span> says: “the first 24 bits belong to the network, the remaining 8 bits are the host number.”
|
||||||
|
</p>
|
||||||
|
<div class="p-3 bg-gray-900/60 rounded-lg border border-gray-700/30 font-mono text-xs overflow-x-auto">
|
||||||
|
<div class="flex items-center gap-2 mb-1 min-w-max">
|
||||||
|
<span class="text-gray-500 w-20 shrink-0">192.168.1.42</span>
|
||||||
|
<span class="text-gray-600">=</span>
|
||||||
|
<span class="text-purple-300">11000000.10101000.00000001</span><span class="text-gray-600">.</span><span class="text-blue-300">00101010</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 min-w-max">
|
||||||
|
<span class="text-gray-500 w-20 shrink-0">/24 mask</span>
|
||||||
|
<span class="text-gray-600">=</span>
|
||||||
|
<span class="text-purple-300">11111111.11111111.11111111</span><span class="text-gray-600">.</span><span class="text-blue-300">00000000</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mt-2 min-w-max">
|
||||||
|
<span class="w-20 shrink-0"></span>
|
||||||
|
<span class="text-gray-600 ml-1"> </span>
|
||||||
|
<span class="text-purple-400 text-xs">←——— network (24 bits) ———→</span>
|
||||||
|
<span class="text-blue-400 text-xs">← host (8 bits) →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">8 host bits = 2<sup>8</sup> = 256 addresses, 254 usable (minus network ID and broadcast).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Why subnets -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-bold text-purple-300 mb-2">Why do subnets exist?</h4>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
||||||
|
<div class="p-3 bg-gray-900/40 rounded-lg border border-gray-700/30">
|
||||||
|
<p class="font-semibold text-white mb-1">Organisation</p>
|
||||||
|
<p class="text-gray-400">Group devices logically — e.g. keep office PCs separate from servers or guest Wi-Fi.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-gray-900/40 rounded-lg border border-gray-700/30">
|
||||||
|
<p class="font-semibold text-white mb-1">Security</p>
|
||||||
|
<p class="text-gray-400">Isolate networks from each other — malware on the guest network can't reach corporate systems.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-gray-900/40 rounded-lg border border-gray-700/30">
|
||||||
|
<p class="font-semibold text-white mb-1">Efficiency</p>
|
||||||
|
<p class="text-gray-400">Broadcast traffic stays within the subnet — no unnecessary noise for the rest of the network.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EXPERT MODE -->
|
||||||
|
<div id="pro-mode" class="hidden">
|
||||||
|
<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 Address:</label>
|
||||||
|
<input type="text" id="ip-address" name="ip-address" placeholder="e.g. 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">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">IPv4 in dotted-decimal notation</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="cidr" class="block text-gray-400 text-sm font-bold mb-2 uppercase tracking-wide">CIDR / Mask:</label>
|
||||||
|
<input type="text" id="cidr" name="cidr" placeholder="e.g. 24 or 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">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">CIDR (0–32) or subnet mask (e.g. 255.255.255.0)</p>
|
||||||
|
</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">
|
||||||
|
Calculate
|
||||||
|
</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>
|
||||||
|
Results:
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-2 text-sm mb-6">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 border-b border-gray-700/50 pb-2 items-start">
|
||||||
|
<span class="text-gray-400 font-semibold">Network Address:</span>
|
||||||
|
<span id="network-address" class="font-mono text-white font-semibold">-</span>
|
||||||
|
<span class="text-xs text-gray-500 italic">First address of the network — not assignable to hosts</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 border-b border-gray-700/50 pb-2 items-start">
|
||||||
|
<span class="text-gray-400 font-semibold">Broadcast Address:</span>
|
||||||
|
<span id="broadcast-address" class="font-mono text-purple-400 font-semibold">-</span>
|
||||||
|
<span class="text-xs text-gray-500 italic">Last address — delivers packets to all hosts simultaneously</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 border-b border-gray-700/50 pb-2 items-start">
|
||||||
|
<span class="text-gray-400 font-semibold">Subnet Mask:</span>
|
||||||
|
<span id="subnet-mask" class="font-mono text-gray-300">-</span>
|
||||||
|
<span class="text-xs text-gray-500 italic">Separates the network and host portions of the IP address</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 border-b border-gray-700/50 pb-2 items-start">
|
||||||
|
<span class="text-gray-400 font-semibold">Usable Hosts:</span>
|
||||||
|
<span id="host-count" class="font-mono text-green-400 font-bold">-</span>
|
||||||
|
<span class="text-xs text-gray-500 italic">Usable IPs = 2<sup>host bits</sup> − 2</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 border-b border-gray-700/50 pb-2 items-start">
|
||||||
|
<span class="text-gray-400 font-semibold">First Host:</span>
|
||||||
|
<span id="first-host" class="font-mono text-blue-300">-</span>
|
||||||
|
<span class="text-xs text-gray-500 italic">Network address + 1</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 items-start">
|
||||||
|
<span class="text-gray-400 font-semibold">Last Host:</span>
|
||||||
|
<span id="last-host" class="font-mono text-blue-300">-</span>
|
||||||
|
<span class="text-xs text-gray-500 italic">Broadcast − 1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Binary visualization -->
|
||||||
|
<div class="mt-4 p-4 bg-gray-900/60 rounded-lg border border-gray-700/30">
|
||||||
|
<h4 class="text-xs text-gray-400 uppercase tracking-wider font-bold mb-3">Binary Representation</h4>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="font-mono text-xs space-y-2 min-w-max">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-gray-500 w-16 text-right shrink-0 text-xs">IP:</span>
|
||||||
|
<span id="bin-ip" class="tracking-wide"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-gray-500 w-16 text-right shrink-0 text-xs">Mask:</span>
|
||||||
|
<span id="bin-mask" class="tracking-wide"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-gray-500 w-16 text-right shrink-0 text-xs">Network:</span>
|
||||||
|
<span id="bin-net" class="tracking-wide"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 pt-1 border-t border-gray-700/30">
|
||||||
|
<span class="w-16 shrink-0"></span>
|
||||||
|
<span id="bin-legend" class="text-xs text-gray-500"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
Example Subnets (Private Address Ranges)
|
||||||
|
</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">Range</th>
|
||||||
|
<th class="px-6 py-3">CIDR</th>
|
||||||
|
<th class="px-6 py-3">Subnet Mask</th>
|
||||||
|
<th class="px-6 py-3">Description</th>
|
||||||
|
<th class="px-6 py-3">Action</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 (total)</td>
|
||||||
|
<td class="px-6 py-4 font-mono">255.255.0.0</td>
|
||||||
|
<td class="px-6 py-4">Class C (commonly used as /24)</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">Example /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 (total)</td>
|
||||||
|
<td class="px-6 py-4 font-mono">255.240.0.0</td>
|
||||||
|
<td class="px-6 py-4">Class 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">Example /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 (total)</td>
|
||||||
|
<td class="px-6 py-4 font-mono">255.0.0.0</td>
|
||||||
|
<td class="px-6 py-4">Class 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">Example /8</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-xs text-gray-500 italic">Click an example to populate the fields and run the calculation.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// ─── Shared helpers ────────────────────────────────────────────────────────
|
||||||
|
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 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 calcSubnet(ip, 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);
|
||||||
|
}
|
||||||
|
return { network: binaryToIp(netBin), broadcast: binaryToIp(bcBin), mask: cidrToMask(cidr), hosts, first, last, netBin, ipBin, maskBin, cidr, hostBits };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mode toggle ──────────────────────────────────────────────────────────
|
||||||
|
const beginnerEl = document.getElementById('beginner-mode');
|
||||||
|
const proEl = document.getElementById('pro-mode');
|
||||||
|
const btnBeg = document.getElementById('btn-beginner');
|
||||||
|
const btnPro = document.getElementById('btn-pro');
|
||||||
|
const activeClass = 'px-6 py-2 rounded-lg text-sm font-semibold transition-all duration-200 bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg';
|
||||||
|
const inactiveClass = 'px-6 py-2 rounded-lg text-sm font-semibold transition-all duration-200 text-gray-400 hover:text-gray-200';
|
||||||
|
|
||||||
|
function setMode(mode) {
|
||||||
|
const isBeg = mode === 'beginner';
|
||||||
|
beginnerEl.classList.toggle('hidden', !isBeg);
|
||||||
|
proEl.classList.toggle('hidden', isBeg);
|
||||||
|
btnBeg.className = isBeg ? activeClass : inactiveClass;
|
||||||
|
btnPro.className = isBeg ? inactiveClass : activeClass;
|
||||||
|
}
|
||||||
|
btnBeg.addEventListener('click', () => setMode('beginner'));
|
||||||
|
btnPro.addEventListener('click', () => setMode('pro'));
|
||||||
|
|
||||||
|
// ─── Beginner mode ────────────────────────────────────────────────────────
|
||||||
|
const begIpInput = document.getElementById('beg-ip');
|
||||||
|
const begSlider = document.getElementById('beg-slider');
|
||||||
|
const begCidrLabel = document.getElementById('beg-cidr-label');
|
||||||
|
const begBar = document.getElementById('beg-bar');
|
||||||
|
const begHostsCount = document.getElementById('beg-hosts-count');
|
||||||
|
const begMaskDisplay = document.getElementById('beg-mask-display');
|
||||||
|
const begNetwork = document.getElementById('beg-network');
|
||||||
|
const begFirst = document.getElementById('beg-first');
|
||||||
|
const begLast = document.getElementById('beg-last');
|
||||||
|
const begBroadcast = document.getElementById('beg-broadcast');
|
||||||
|
const begExplain = document.getElementById('beg-explain-text');
|
||||||
|
|
||||||
|
function updateBeginner() {
|
||||||
|
const ip = begIpInput.value.trim();
|
||||||
|
const cidr = parseInt(begSlider.value, 10);
|
||||||
|
|
||||||
|
begCidrLabel.textContent = `/${cidr}`;
|
||||||
|
|
||||||
|
// bar: log scale via host-bit count
|
||||||
|
const barPct = Math.max(3, ((32 - cidr) / 31) * 100);
|
||||||
|
begBar.style.width = barPct + '%';
|
||||||
|
|
||||||
|
if (!isValidIP(ip)) return;
|
||||||
|
|
||||||
|
const r = calcSubnet(ip, cidr);
|
||||||
|
|
||||||
|
begHostsCount.textContent = r.hosts.toLocaleString('en');
|
||||||
|
begMaskDisplay.textContent = r.mask;
|
||||||
|
begNetwork.textContent = r.network;
|
||||||
|
begFirst.textContent = r.first;
|
||||||
|
begLast.textContent = r.last;
|
||||||
|
begBroadcast.textContent = r.broadcast;
|
||||||
|
|
||||||
|
let sizeDesc;
|
||||||
|
if (cidr <= 8) sizeDesc = 'This is a massive network — only found in large data centres or at internet service providers.';
|
||||||
|
else if (cidr <= 12) sizeDesc = 'This is a very large network, typical for big enterprises or campus environments.';
|
||||||
|
else if (cidr <= 16) sizeDesc = 'This is a large network, common in mid-sized companies or universities.';
|
||||||
|
else if (cidr <= 20) sizeDesc = 'This is a medium-sized network, e.g. for a large office building or campus.';
|
||||||
|
else if (cidr <= 24) sizeDesc = 'This is a typical home or small office network — the default on most routers.';
|
||||||
|
else if (cidr <= 27) sizeDesc = 'This is a small network, e.g. for a single department or server cluster.';
|
||||||
|
else sizeDesc = 'This is a very small network, usually used for direct point-to-point links.';
|
||||||
|
|
||||||
|
begExplain.innerHTML = `
|
||||||
|
A <strong class="text-purple-300">/${cidr}</strong> network reserves
|
||||||
|
<strong class="text-purple-300">${cidr} bits for the network address</strong> and leaves
|
||||||
|
<strong class="text-purple-300">${r.hostBits} bits for hosts</strong>.
|
||||||
|
That gives <strong class="text-green-400">${r.hosts.toLocaleString('en')} usable IP addresses</strong>
|
||||||
|
(2<sup>${r.hostBits}</sup>−2, since the network ID and broadcast are reserved).
|
||||||
|
<br><br>${sizeDesc}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
begSlider.addEventListener('input', updateBeginner);
|
||||||
|
begIpInput.addEventListener('input', updateBeginner);
|
||||||
|
|
||||||
|
document.querySelectorAll('.beg-example').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
begIpInput.value = btn.dataset.ip;
|
||||||
|
begSlider.value = btn.dataset.cidr;
|
||||||
|
updateBeginner();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
updateBeginner();
|
||||||
|
|
||||||
|
// ─── Expert mode ──────────────────────────────────────────────────────────
|
||||||
|
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 coloredBits(bits, cidr, hostChar) {
|
||||||
|
let html = '';
|
||||||
|
for (let i = 0; i < 32; i++) {
|
||||||
|
if (i > 0 && i % 8 === 0) html += '<span class="text-gray-600 select-none">.</span>';
|
||||||
|
const isNet = i < cidr;
|
||||||
|
const ch = hostChar && !isNet ? hostChar : bits[i];
|
||||||
|
html += `<span class="${isNet ? 'text-purple-300' : 'text-gray-500'}">${ch}</span>`;
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBinary(r) {
|
||||||
|
const maskBits = '1'.repeat(r.cidr) + '0'.repeat(r.hostBits);
|
||||||
|
document.getElementById('bin-ip').innerHTML = coloredBits(r.ipBin, r.cidr, null);
|
||||||
|
document.getElementById('bin-mask').innerHTML = coloredBits(maskBits, r.cidr, null);
|
||||||
|
document.getElementById('bin-net').innerHTML = coloredBits(r.netBin, r.cidr, 'x');
|
||||||
|
document.getElementById('bin-legend').innerHTML =
|
||||||
|
`<span class="text-purple-400">■</span> network bit (${r.cidr}) ` +
|
||||||
|
`<span class="text-gray-600">□</span> host bit (${r.hostBits}) — ` +
|
||||||
|
`x = any host address within this network`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculate() {
|
||||||
|
showInlineError(null);
|
||||||
|
const ip = ipInput.value.trim();
|
||||||
|
const cidrRaw = cidrInput.value.trim();
|
||||||
|
|
||||||
|
if (!isValidIP(ip)) { showInlineError('Please enter a valid IPv4 address.'); return; }
|
||||||
|
|
||||||
|
let cidr;
|
||||||
|
if (cidrRaw.includes('.')) {
|
||||||
|
if (!isValidIP(cidrRaw)) { showInlineError('Please enter a valid subnet mask.'); return; }
|
||||||
|
cidr = maskToCidr(cidrRaw);
|
||||||
|
if (cidr === null) { showInlineError('Invalid subnet mask — must be a contiguous sequence of ones (e.g. 255.255.255.0).'); return; }
|
||||||
|
} else {
|
||||||
|
cidr = parseInt(cidrRaw, 10);
|
||||||
|
if (isNaN(cidr) || cidr < 0 || cidr > 32) { showInlineError('Please enter a valid CIDR value (0–32).'); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = calcSubnet(ip, cidr);
|
||||||
|
|
||||||
|
document.getElementById('network-address').textContent = r.network;
|
||||||
|
document.getElementById('broadcast-address').textContent = r.broadcast;
|
||||||
|
document.getElementById('subnet-mask').textContent = r.mask;
|
||||||
|
document.getElementById('host-count').textContent = r.hosts.toLocaleString('en');
|
||||||
|
document.getElementById('first-host').textContent = r.first;
|
||||||
|
document.getElementById('last-host').textContent = r.last;
|
||||||
|
|
||||||
|
renderBinary(r);
|
||||||
|
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,100 @@
|
|||||||
|
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 => {
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
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 });
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
/* ── 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: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tool action buttons (Ping / Traceroute / Port Scan) ──────── */
|
||||||
|
.action-tool-btn { text-align: center; }
|
||||||
|
|
||||||
|
/* ── 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 ─────────────────────────────── */
|
||||||
|
/* Hop rows — structured grid layout with RDNS on its own line */
|
||||||
|
#traceroute-output .hop-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2rem 1fr;
|
||||||
|
align-items: start;
|
||||||
|
gap: 0 0.5rem;
|
||||||
|
padding: 3px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
transition: border-left-color .2s, background .2s;
|
||||||
|
}
|
||||||
|
#traceroute-output .hop-row:hover { border-left-color: #a855f7; background: rgba(255,255,255,.025); }
|
||||||
|
#traceroute-output .hop-number { text-align: right; color: #4b5563; font-weight: 700; font-size: .8em; padding-top: 2px; }
|
||||||
|
#traceroute-output .hop-body { min-width: 0; }
|
||||||
|
#traceroute-output .hop-ip-line { display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
#traceroute-output .hop-ip { color: #60a5fa; font-weight: 500; flex-shrink: 0; }
|
||||||
|
#traceroute-output .hop-rtts { display: flex; gap: 0.4rem; margin-left: auto; }
|
||||||
|
#traceroute-output .hop-rtt { color: #34d399; font-size: .8em; }
|
||||||
|
#traceroute-output .hop-timeout { color: #f87171; font-size: .85em; }
|
||||||
|
/* RDNS hostname on its own line — clearly distinguishable from the IP */
|
||||||
|
#traceroute-output .hop-rdns {
|
||||||
|
font-size: .75em;
|
||||||
|
color: #c084fc;
|
||||||
|
opacity: .85;
|
||||||
|
margin-top: 1px;
|
||||||
|
padding-left: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
/* Non-hop lines (info, error, end) */
|
||||||
|
#traceroute-output .info-line { color: #fbbf24; font-style: italic; font-size: .85em; }
|
||||||
|
#traceroute-output .error-line { color: #f87171; font-weight: bold; border-left: 3px solid #f87171; padding-left: 8px; }
|
||||||
|
#traceroute-output .end-line { color: #d8b4fe; font-weight: bold; margin-top: 8px; text-transform: uppercase; letter-spacing: .05em; border-top: 1px solid rgba(255,255,255,.08); padding-top: 8px; }
|
||||||
|
|
||||||
|
/* ── Home page — Maps ───────────────────────────────────────────── */
|
||||||
|
/* Containers use h-[420px] / h-[260px] in HTML; maps fill 100% via ID selector (higher specificity than Tailwind) */
|
||||||
|
#map { height: 420px; }
|
||||||
|
#lookup-map { height: 260px; }
|
||||||
|
|
||||||
|
/* ── 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost; # Oder deine Domain
|
||||||
|
|
||||||
|
# Root-Verzeichnis für statische Dateien
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Logging (optional, aber nützlich)
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
|
||||||
|
# SPA: all routes fall back to index.html; static assets are served directly
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API-Anfragen an den Backend-Service weiterleiten
|
||||||
|
location /api/ {
|
||||||
|
# Der Name 'backend' muss dem Service-Namen in docker-compose.yml entsprechen
|
||||||
|
proxy_pass http://backend:3000; # Leitet an den Backend-Container auf Port 3000 weiter
|
||||||
|
|
||||||
|
# Wichtige Proxy-Header setzen
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Header für Server-Sent Events (Traceroute)
|
||||||
|
proxy_set_header Connection '';
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off; # Wichtig für Streaming
|
||||||
|
proxy_cache off; # Wichtig für Streaming
|
||||||
|
proxy_read_timeout 300s; # Längerer Timeout für potenziell lange Traceroutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upstream-Definition (optional, aber sauberer für proxy_pass)
|
||||||
|
# upstream backend_server {
|
||||||
|
# server backend:3000;
|
||||||
|
# }
|
||||||
|
# Dann in location /api/: proxy_pass http://backend_server;
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<script async src='https://maps.googleapis.com/maps/api/js?key=AIzaSyCMPtVMDhHelORhyk2AAc9FtjgnjybvdMU&callback=initMap&v=weekly'></script>
|
|
||||||
<script type="module" src="./maps.js"></script>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
$IP = $_SERVER['REMOTE_ADDR'];
|
|
||||||
$ip = htmlentities($_GET["ip"]);
|
|
||||||
$host = gethostbyaddr($ip);
|
|
||||||
$latitude = htmlentities($_POST['latitude'], ENT_QUOTES, 'UTF-8');
|
|
||||||
$longitude = htmlentities($_POST['longitude'], ENT_QUOTES, 'UTF-8');
|
|
||||||
$city = htmlentities($_POST['city'], ENT_QUOTES, 'UTF-8');
|
|
||||||
$details = json_decode(file_get_contents("http://ipinfo.io/{$IP}/json?token=391da55dff40d9"));
|
|
||||||
$location = json_decode(file_get_contents("http://ipinfo.io/{$IP}/json?token=391da55dff40d9"));
|
|
||||||
echo "<b>IP: </b>" .$details->ip;
|
|
||||||
?>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<?php
|
|
||||||
|
|
||||||
$IP = $_SERVER['REMOTE_ADDR'];
|
|
||||||
$ip = htmlentities($_GET["ip"]);
|
|
||||||
$latitude = htmlentities($_POST['latitude'], ENT_QUOTES, 'UTF-8');
|
|
||||||
$longitude = htmlentities($_POST['longitude'], ENT_QUOTES, 'UTF-8');
|
|
||||||
$city = htmlentities($_POST['city'], ENT_QUOTES, 'UTF-8');
|
|
||||||
$details = json_decode(file_get_contents("http://ipinfo.io/{$ip}/json?token=391da55dff40d9"));
|
|
||||||
$location = json_decode(file_get_contents("http://ipinfo.io/{$ip}/json?token=391da55dff40d9"));
|
|
||||||
|
|
||||||
if(isset($_GET['ip']))
|
|
||||||
{
|
|
||||||
print ('<form method="get" action="">
|
|
||||||
<h1 class="h3 mb-3 fw-normal">IP Lookup</h1>
|
|
||||||
<div class="form-floating">
|
|
||||||
<form method="get" action="">
|
|
||||||
<input type="text" class="form-control" id="floatingInput" maxlength="32" placeholder="IP" title="Enter IP Address here" />
|
|
||||||
<br/>
|
|
||||||
<button class="w-100 btn btn-lg btn-primary button" type="submit">Let`s Go</button>
|
|
||||||
</div>
|
|
||||||
<p class="mt-5 mb-3 text-muted">sysLUL.de © 2022</p>
|
|
||||||
</form>');
|
|
||||||
Vendored
-7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,40 +0,0 @@
|
|||||||
html,
|
|
||||||
body {
|
|
||||||
height: 100%;
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding-top: 40px;
|
|
||||||
padding-bottom: 40px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-signin {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 330px;
|
|
||||||
padding: 15px;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-signin .checkbox {
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-signin .form-floating:focus-within {
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-signin input[type="email"] {
|
|
||||||
margin-bottom: -1px;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-signin input[type="password"] {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="description" content="">
|
|
||||||
<meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
|
|
||||||
<meta name="generator" content="Hugo 0.84.0">
|
|
||||||
<title>Jumbotron example · Bootstrap v5.0</title>
|
|
||||||
|
|
||||||
<link rel="canonical" href="https://getbootstrap.com/docs/5.0/examples/jumbotron/">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Bootstrap core CSS -->
|
|
||||||
<link href="../assets/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.bd-placeholder-img {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
text-anchor: middle;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.bd-placeholder-img-lg {
|
|
||||||
font-size: 3.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<div class="container py-4">
|
|
||||||
<header class="pb-3 mb-4 border-bottom">
|
|
||||||
<a href="/" class="d-flex align-items-center text-dark text-decoration-none">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="32" class="me-2" viewBox="0 0 118 94" role="img"><title>Bootstrap</title><path fill-rule="evenodd" clip-rule="evenodd" d="M24.509 0c-6.733 0-11.715 5.893-11.492 12.284.214 6.14-.064 14.092-2.066 20.577C8.943 39.365 5.547 43.485 0 44.014v5.972c5.547.529 8.943 4.649 10.951 11.153 2.002 6.485 2.28 14.437 2.066 20.577C12.794 88.106 17.776 94 24.51 94H93.5c6.733 0 11.714-5.893 11.491-12.284-.214-6.14.064-14.092 2.066-20.577 2.009-6.504 5.396-10.624 10.943-11.153v-5.972c-5.547-.529-8.934-4.649-10.943-11.153-2.002-6.484-2.28-14.437-2.066-20.577C105.214 5.894 100.233 0 93.5 0H24.508zM80 57.863C80 66.663 73.436 72 62.543 72H44a2 2 0 01-2-2V24a2 2 0 012-2h18.437c9.083 0 15.044 4.92 15.044 12.474 0 5.302-4.01 10.049-9.119 10.88v.277C75.317 46.394 80 51.21 80 57.863zM60.521 28.34H49.948v14.934h8.905c6.884 0 10.68-2.772 10.68-7.727 0-4.643-3.264-7.207-9.012-7.207zM49.948 49.2v16.458H60.91c7.167 0 10.964-2.876 10.964-8.281 0-5.406-3.903-8.178-11.425-8.178H49.948z" fill="currentColor"></path></svg>
|
|
||||||
<span class="fs-4">Jumbotron example</span>
|
|
||||||
</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="p-5 mb-4 bg-light rounded-3">
|
|
||||||
<div class="container-fluid py-5">
|
|
||||||
<h1 class="display-5 fw-bold">Custom jumbotron</h1>
|
|
||||||
<p class="col-md-8 fs-4">Using a series of utilities, you can create this jumbotron, just like the one in previous versions of Bootstrap. Check out the examples below for how you can remix and restyle it to your liking.</p>
|
|
||||||
<button class="btn btn-primary btn-lg" type="button">Example button</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row align-items-md-stretch">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="h-100 p-5 text-white bg-dark rounded-3">
|
|
||||||
<h2>Change the background</h2>
|
|
||||||
<p>Swap the background-color utility and add a `.text-*` color utility to mix up the jumbotron look. Then, mix and match with additional component themes and more.</p>
|
|
||||||
<button class="btn btn-outline-light" type="button">Example button</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="h-100 p-5 bg-light border rounded-3">
|
|
||||||
<h2>Add borders</h2>
|
|
||||||
<p>Or, keep it light and add a border for some added definition to the boundaries of your content. Be sure to look under the hood at the source HTML here as we've adjusted the alignment and sizing of both column's content for equal-height.</p>
|
|
||||||
<button class="btn btn-outline-secondary" type="button">Example button</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="pt-3 mt-4 text-muted border-top">
|
|
||||||
© 2021
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<title>uTraceMe</title>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="description" content="IP Lookup">
|
|
||||||
<meta name="keywords" content="ip lookup, what is my ip, my ip address, my ip, ip address lookup, ip geolocation, latitude longitude finder, ip lookup php script, ip2location, geolocation, ip-location, my ip lookup, ip-lookup, geoip, geo ip, ip finder, ip tools, ip tools, ip location finder, location finder, what is my ip location, ip address geolocation, ????? ????, ???? ???? ?????, ????? ip, ?????? ????">
|
|
||||||
<meta name="author" content="Johannes Krüger">
|
|
||||||
<link href="css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="css/main.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.bd-placeholder-img {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
text-anchor: middle;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.bd-placeholder-img-lg {
|
|
||||||
font-size: 3.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<div class="container py-4">
|
|
||||||
<header class="pb-3 mb-4 border-bottom">
|
|
||||||
<a href="/" class="d-flex align-items-center text-dark text-decoration-none">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="32" class="me-2" viewBox="0 0 118 94" role="img"><title>Bootstrap</title><path fill-rule="evenodd" clip-rule="evenodd" d="M24.509 0c-6.733 0-11.715 5.893-11.492 12.284.214 6.14-.064 14.092-2.066 20.577C8.943 39.365 5.547 43.485 0 44.014v5.972c5.547.529 8.943 4.649 10.951 11.153 2.002 6.485 2.28 14.437 2.066 20.577C12.794 88.106 17.776 94 24.51 94H93.5c6.733 0 11.714-5.893 11.491-12.284-.214-6.14.064-14.092 2.066-20.577 2.009-6.504 5.396-10.624 10.943-11.153v-5.972c-5.547-.529-8.934-4.649-10.943-11.153-2.002-6.484-2.28-14.437-2.066-20.577C105.214 5.894 100.233 0 93.5 0H24.508zM80 57.863C80 66.663 73.436 72 62.543 72H44a2 2 0 01-2-2V24a2 2 0 012-2h18.437c9.083 0 15.044 4.92 15.044 12.474 0 5.302-4.01 10.049-9.119 10.88v.277C75.317 46.394 80 51.21 80 57.863zM60.521 28.34H49.948v14.934h8.905c6.884 0 10.68-2.772 10.68-7.727 0-4.643-3.264-7.207-9.012-7.207zM49.948 49.2v16.458H60.91c7.167 0 10.964-2.876 10.964-8.281 0-5.406-3.903-8.178-11.425-8.178H49.948z" fill="currentColor"></path></svg>
|
|
||||||
<span class="fs-4">Jumbotron example</span>
|
|
||||||
</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="p-5 mb-4 bg-light rounded-3">
|
|
||||||
<div class="container-fluid py-5">
|
|
||||||
<h1 class="display-5 fw-bold">Custom jumbotron</h1>
|
|
||||||
<p class="col-md-8 fs-4">Using a series of utilities, you can create this jumbotron, just like the one in previous versions of Bootstrap. Check out the examples below for how you can remix and restyle it to your liking.</p>
|
|
||||||
<button class="btn btn-primary btn-lg" type="button">Example button</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row align-items-md-stretch">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="h-100 p-5 text-white bg-dark rounded-3">
|
|
||||||
<h2>Change the background</h2>
|
|
||||||
<p>Swap the background-color utility and add a `.text-*` color utility to mix up the jumbotron look. Then, mix and match with additional component themes and more.</p>
|
|
||||||
<button class="btn btn-outline-light" type="button">Example button</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="h-100 p-5 bg-light border rounded-3">
|
|
||||||
<h2>Add borders</h2>
|
|
||||||
<p>Or, keep it light and add a border for some added definition to the boundaries of your content. Be sure to look under the hood at the source HTML here as we've adjusted the alignment and sizing of both column's content for equal-height.</p>
|
|
||||||
<button class="btn btn-outline-secondary" type="button">Example button</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="pt-3 mt-4 text-muted border-top">
|
|
||||||
© 2021
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</body>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// Google Maps
|
|
||||||
|
|
||||||
// Initialize and add the map
|
|
||||||
function initMap() {
|
|
||||||
// The map, centered at Uluru
|
|
||||||
const map = new google.maps.Map(document.getElementById("map"), {
|
|
||||||
zoom: 4,
|
|
||||||
center: '{$details->loc}',
|
|
||||||
});
|
|
||||||
// The marker, positioned at Uluru
|
|
||||||
const marker = new google.maps.Marker({
|
|
||||||
position: {'{$details->loc}',
|
|
||||||
map: map,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.initMap = initMap;
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<title>uTraceMe - IP Lookup</title>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="description" content="IP Lookup">
|
|
||||||
<meta name="keywords" content="ip lookup, what is my ip, my ip address, my ip, ip address lookup, ip geolocation, latitude longitude finder, ip lookup php script, ip2location, geolocation, ip-location, my ip lookup, ip-lookup, geoip, geo ip, ip finder, ip tools, ip tools, ip location finder, location finder, what is my ip location, ip address geolocation, ????? ????, ???? ???? ?????, ????? ip, ?????? ????">
|
|
||||||
<meta name="author" content="Johannes Krüger">
|
|
||||||
<script async src='https://maps.googleapis.com/maps/api/js?key=AIzaSyCMPtVMDhHelORhyk2AAc9FtjgnjybvdMU&callback=initMap&v=weekly'></script>
|
|
||||||
<script type="module" src="./maps.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h2>Lookup IP Address Location</h2>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
|
|
||||||
// Variable
|
|
||||||
$IP = $_SERVER['REMOTE_ADDR'];
|
|
||||||
$ip = htmlentities($_GET["ip"]);
|
|
||||||
$latitude = htmlentities($_POST['latitude'], ENT_QUOTES, 'UTF-8');
|
|
||||||
$longitude = htmlentities($_POST['longitude'], ENT_QUOTES, 'UTF-8');
|
|
||||||
$city = htmlentities($_POST['city'], ENT_QUOTES, 'UTF-8');
|
|
||||||
$details = json_decode(file_get_contents("http://ipinfo.io/{$ip}/json?token=391da55dff40d9"));
|
|
||||||
$location = json_decode(file_get_contents("http://ipinfo.io/{$ip}/json?token=391da55dff40d9"));
|
|
||||||
|
|
||||||
|
|
||||||
// start public Code
|
|
||||||
|
|
||||||
if(isset($_GET['ip']))
|
|
||||||
{
|
|
||||||
echo '<form method="get" action="">
|
|
||||||
<input type="text" name="ip" id="ip" maxlength="32" placeholder="IP" title="Enter IP Address here" />
|
|
||||||
<input type="submit" class="button" value="Lookup IP Address" />
|
|
||||||
</form>';
|
|
||||||
|
|
||||||
echo "<br><br><b>Short View</b><br>";
|
|
||||||
echo "<b>IP: </b>" .$details->ip;
|
|
||||||
echo "<br><b>Organisation: </b>" .$details->org;
|
|
||||||
echo "<br><b>Stadt: </b>" .$details->city;
|
|
||||||
echo "<br><b>Postleitzahl: </b>" .$details->postal;
|
|
||||||
echo "<br><b>Bundesland: </b>" .$details->region;
|
|
||||||
echo "<br><b>Land: </b>" .$details->country;
|
|
||||||
echo "<br><b>Lage: </b>" .$details->loc;
|
|
||||||
echo "<br><b>Hostname (rDNS): </b>" .$details->hostname;
|
|
||||||
echo "<br>";
|
|
||||||
|
|
||||||
|
|
||||||
//echo "<div style="border-radius: 10px;width: 480px;height: 240px;"><iframe src='https://maps.googleapis.com/maps/api/staticmap?center={$details->loc}&markers=color:red%7Clabel:S{$details->loc}&zoom=10&size=480x240&key=AIzaSyCMPtVMDhHelORhyk2AAc9FtjgnjybvdMU' FRAMEBORDER=NO FRAMESPACING=0 BORDER=0 ></iframe></div><br>";
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
else {
|
|
||||||
|
|
||||||
print ('<form method="get" action="">
|
|
||||||
<input type="text" name="ip" id="ip" maxlength="15" placeholder="IP" title="Enter IP Address here" value="'.$IP.'" />
|
|
||||||
<input type="submit" class="button" value="Lookup IP Address" />
|
|
||||||
</form>');
|
|
||||||
echo "<br>Here's what you will find out:<br><br>
|
|
||||||
<li>Your IP (but you can check other IP)</li>
|
|
||||||
<li>IP type</li>
|
|
||||||
<li>Continent code</li>
|
|
||||||
<li>Continent name</li>
|
|
||||||
<li>Country code</li>
|
|
||||||
<li>Country name</li>
|
|
||||||
<li>City</li>
|
|
||||||
<li>State/Region</li>
|
|
||||||
<li>Region code</li>
|
|
||||||
<li>Zip code</li>
|
|
||||||
<li>Calling code</li>
|
|
||||||
<li>Latitude</li>
|
|
||||||
<li>Longitude</li>
|
|
||||||
<li>Timezone</li>
|
|
||||||
<li>Currency</li>
|
|
||||||
<li>Mobile</li>
|
|
||||||
<li>Proxy</li>
|
|
||||||
<li>Organization</li>
|
|
||||||
<li>Hostname</li>
|
|
||||||
<li>Your Browser User-Agent</li>
|
|
||||||
<li>Geolocation Map</li>
|
|
||||||
<li>Map Latitude Longitude finder</li>
|
|
||||||
";
|
|
||||||
}
|
|
||||||
|
|
||||||
?>
|
|
||||||
Reference in New Issue
Block a user