From 010e053ca1dbafe9f4b9e7a1f20f62539765b923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Sun, 24 May 2026 23:33:15 +0300 Subject: [PATCH] feat: monitors worker, auth register, scan polish Add background DNS/SSL monitor checks, registration API, smoother SSE with stage labels, improved CDN/WAF detection, light-theme UI polish, and README roadmap updates. Co-authored-by: Cursor --- .env.example | 3 + README.md | 20 +- install.sh | 35 ++ package-lock.json | 519 ++++++++++++++++++++++++++ package.json | 6 +- prisma/schema.prisma | 1 + scripts/monitor-worker.ts | 27 ++ scripts/seed-admin.ts | 28 ++ scripts/smoke-test.sh | 42 +++ src/app/api/auth/register/route.ts | 42 +++ src/app/api/scan/[id]/stream/route.ts | 3 +- src/app/api/scan/route.ts | 7 +- src/app/globals.css | 2 +- src/app/layout.tsx | 2 + src/app/login/page.tsx | 108 ++++-- src/app/monitoring/page.tsx | 154 ++++++-- src/app/page.tsx | 3 - src/components/layout/header.tsx | 12 +- src/components/scan/scan-panel.tsx | 9 +- src/lib/monitoring/check.ts | 167 +++++++++ src/lib/scanner/index.ts | 51 ++- 21 files changed, 1166 insertions(+), 75 deletions(-) create mode 100644 scripts/monitor-worker.ts create mode 100644 scripts/seed-admin.ts create mode 100644 scripts/smoke-test.sh create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/lib/monitoring/check.ts diff --git a/.env.example b/.env.example index 1a41310..4bf10e5 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,6 @@ NEXTAUTH_SECRET=change-me-to-a-long-random-string NEXT_PUBLIC_APP_URL=http://localhost:3000 IPINFO_TOKEN= SCAN_RATE_LIMIT_PER_HOUR=30 +MONITOR_INTERVAL_MS=300000 +SEED_ADMIN_EMAIL= +SEED_ADMIN_PASSWORD= diff --git a/README.md b/README.md index 738c1e6..7b3dce0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,22 @@ Modern domain intelligence and infrastructure analysis platform with premium UX, - **Tech/CDN** — Server, Cloudflare, Vercel signals - **Export** — JSON and CSV downloads - **Live progress** — Server-Sent Events during scans -- **Monitoring** — DB schema + API for DNS/SSL monitors (workers: roadmap) +- **Monitoring** — DNS change + SSL expiry checks via background worker +- **Analytics** — 7-day charts from scan history (Recharts) +- **Auth** — Register / sign-in (NextAuth credentials) + +## Roadmap status + +| Area | Status | +|------|--------| +| Premium UI (glass, motion, dark/light) | Done | +| Scan API + SSE progress + CDN/WAF heuristics | Done | +| Analytics from DB | Done | +| Monitor worker (DNS/SSL MVP) | Done | +| Auth register + optional admin seed | Done | +| Email/Slack alert delivery | Planned | +| GraphQL public API | Planned | +| Paid tiers / billing | Planned | ## Quick start (Docker) @@ -66,6 +81,9 @@ npm run dev | `NEXTAUTH_URL` | Public app URL | | `IPINFO_TOKEN` | Optional IPinfo token | | `SCAN_RATE_LIMIT_PER_HOUR` | Per-IP scan limit (default 30) | +| `MONITOR_INTERVAL_MS` | Monitor worker interval (default 300000) | +| `SEED_ADMIN_EMAIL` | Optional admin email on install | +| `SEED_ADMIN_PASSWORD` | Optional admin password on install | ## API diff --git a/install.sh b/install.sh index 9a46967..3709434 100755 --- a/install.sh +++ b/install.sh @@ -118,6 +118,39 @@ build_app() { apt-get clean 2>/dev/null || true } +install_monitor_worker() { + log "Installing monitor worker (${SERVICE_NAME}-worker)..." + cat >"/etc/systemd/system/${SERVICE_NAME}-worker.service" <"/etc/systemd/system/${SERVICE_NAME}.service" <=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -5257,6 +5700,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -5890,6 +6375,21 @@ } } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -9077,6 +9577,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 294f059..3a6acc1 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "lint": "eslint", "postinstall": "prisma generate", "db:push": "prisma db push", - "db:studio": "prisma studio" + "db:studio": "prisma studio", + "worker:monitors": "npx tsx scripts/monitor-worker.ts", + "seed:admin": "npx tsx scripts/seed-admin.ts", + "smoke": "bash scripts/smoke-test.sh" }, "dependencies": { "@prisma/client": "^6.9.0", @@ -53,6 +56,7 @@ "eslint-config-next": "^15.3.3", "prisma": "^6.9.0", "tailwindcss": "^4", + "tsx": "^4.22.3", "typescript": "^5" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d655a05..e1b6ec2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,6 +43,7 @@ model Scan { domain String status ScanStatus @default(PENDING) progress Int @default(0) + stage String? result Json? error String? userId String? diff --git a/scripts/monitor-worker.ts b/scripts/monitor-worker.ts new file mode 100644 index 0000000..6fef658 --- /dev/null +++ b/scripts/monitor-worker.ts @@ -0,0 +1,27 @@ +/** + * Background monitor checks (DNS change + SSL expiry MVP). + * Run via: npm run worker:monitors + */ +import { runAllMonitorChecks } from "../src/lib/monitoring/check"; + +const intervalMs = Number(process.env.MONITOR_INTERVAL_MS ?? 300_000); + +async function tick() { + const summary = await runAllMonitorChecks(); + console.log( + `[monitor-worker] ${new Date().toISOString()} checked=${summary.checked} with_alerts=${summary.alerts}` + ); +} + +async function main() { + await tick(); + setInterval(() => { + tick().catch((e) => console.error("[monitor-worker]", e)); + }, intervalMs); + console.log(`[monitor-worker] interval ${intervalMs / 1000}s`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/seed-admin.ts b/scripts/seed-admin.ts new file mode 100644 index 0000000..1c60f5f --- /dev/null +++ b/scripts/seed-admin.ts @@ -0,0 +1,28 @@ +import bcrypt from "bcryptjs"; +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +async function main() { + const email = process.env.SEED_ADMIN_EMAIL?.toLowerCase(); + const password = process.env.SEED_ADMIN_PASSWORD; + if (!email || !password) { + console.log("[seed-admin] SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD not set — skip"); + return; + } + + const passwordHash = await bcrypt.hash(password, 12); + await prisma.user.upsert({ + where: { email }, + create: { email, passwordHash, role: "ADMIN", name: "Admin" }, + update: { passwordHash, role: "ADMIN" }, + }); + console.log(`[seed-admin] admin ready: ${email}`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100644 index 0000000..9e27185 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${SMOKE_BASE_URL:-http://127.0.0.1:3000}" +DOMAIN="${SMOKE_DOMAIN:-example.com}" + +echo "[smoke] health" +curl -fsS "${BASE}/api/health" | grep -q '"ok"' || { echo "health failed"; exit 1; } + +echo "[smoke] scan POST" +SCAN_JSON="$(curl -fsS -X POST "${BASE}/api/scan" \ + -H 'Content-Type: application/json' \ + -d "{\"domain\":\"${DOMAIN}\"}")" +SCAN_ID="$(echo "${SCAN_JSON}" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)" +if [[ -z "${SCAN_ID}" ]]; then + echo "scan id missing: ${SCAN_JSON}" + exit 1 +fi + +echo "[smoke] wait scan ${SCAN_ID}" +for _ in $(seq 1 90); do + STATUS="$(curl -fsS "${BASE}/api/scan/${SCAN_ID}" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p' | head -1)" + if [[ "${STATUS}" == "COMPLETED" || "${STATUS}" == "FAILED" ]]; then + break + fi + sleep 1 +done +if [[ "${STATUS}" != "COMPLETED" ]]; then + echo "scan did not complete: ${STATUS}" + exit 1 +fi + +for path in / /dashboard /analytics /monitoring /api-docs /login; do + echo "[smoke] GET ${path}" + code="$(curl -s -o /dev/null -w '%{http_code}' "${BASE}${path}")" + if [[ "${code}" != "200" ]]; then + echo "page ${path} returned ${code}" + exit 1 + fi +done + +echo "[smoke] OK" diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..d6aa3ea --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/prisma"; + +const bodySchema = z.object({ + email: z.string().email().max(255), + password: z.string().min(8).max(128), + name: z.string().max(120).optional(), +}); + +export async function POST(req: NextRequest) { + let body: z.infer; + try { + body = bodySchema.parse(await req.json()); + } catch { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + } + + const email = body.email.toLowerCase(); + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + return NextResponse.json({ error: "Email already registered" }, { status: 409 }); + } + + const seedEmail = process.env.SEED_ADMIN_EMAIL?.toLowerCase(); + const role = + seedEmail && email === seedEmail ? ("ADMIN" as const) : ("USER" as const); + + const passwordHash = await bcrypt.hash(body.password, 12); + const user = await prisma.user.create({ + data: { + email, + name: body.name, + passwordHash, + role, + }, + select: { id: true, email: true, role: true }, + }); + + return NextResponse.json({ user }, { status: 201 }); +} diff --git a/src/app/api/scan/[id]/stream/route.ts b/src/app/api/scan/[id]/stream/route.ts index 1c32a95..d9539ff 100644 --- a/src/app/api/scan/[id]/stream/route.ts +++ b/src/app/api/scan/[id]/stream/route.ts @@ -31,6 +31,7 @@ export async function GET( id: scan.id, status: scan.status, progress: scan.progress, + stage: scan.stage, domain: scan.domain, result: scan.status === "COMPLETED" ? scan.result : undefined, error: scan.error, @@ -48,7 +49,7 @@ export async function GET( return; } - setTimeout(poll, 1000); + setTimeout(poll, 450); }; await poll(); diff --git a/src/app/api/scan/route.ts b/src/app/api/scan/route.ts index 096453e..8ed9787 100644 --- a/src/app/api/scan/route.ts +++ b/src/app/api/scan/route.ts @@ -62,8 +62,11 @@ export async function POST(req: NextRequest) { async function runScanAsync(scanId: string, domain: string, cacheKey: string) { try { - const result = await runDomainScan(domain, async (progress) => { - await prisma.scan.update({ where: { id: scanId }, data: { progress } }); + const result = await runDomainScan(domain, async (progress, stage) => { + await prisma.scan.update({ + where: { id: scanId }, + data: { progress, stage }, + }); }); await prisma.scan.update({ where: { id: scanId }, diff --git a/src/app/globals.css b/src/app/globals.css index beabfc4..7ffe2f4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -36,7 +36,7 @@ body { } .glass { - @apply border border-white/10 bg-white/5 backdrop-blur-xl; + @apply border border-zinc-200/80 bg-white/70 backdrop-blur-xl dark:border-white/10 dark:bg-white/5; } .gradient-mesh { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c88fd39..5dfee19 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { Providers } from "@/components/providers"; import { Header } from "@/components/layout/header"; +import { SiteFooter } from "@/components/layout/site-footer"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -44,6 +45,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
+
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5d3a620..3b4368e 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,47 +2,113 @@ import { signIn } from "next-auth/react"; import { useState } from "react"; +import { motion } from "framer-motion"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useRouter } from "next/navigation"; +import Link from "next/link"; export default function LoginPage() { const router = useRouter(); + const [mode, setMode] = useState<"login" | "register">("login"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [name, setName] = useState(""); const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); const submit = async (e: React.FormEvent) => { e.preventDefault(); + setError(""); + setLoading(true); + + if (mode === "register") { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, name: name || undefined }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error ?? "Registration failed"); + setLoading(false); + return; + } + } + const res = await signIn("credentials", { email, password, redirect: false }); + setLoading(false); if (res?.error) { - setError("Invalid credentials"); + setError(mode === "login" ? "Invalid credentials" : "Registered but sign-in failed"); return; } router.push("/dashboard"); }; return ( -
-

Sign in

-
- setEmail(e.target.value)} - /> - setPassword(e.target.value)} - /> - {error &&

{error}

} - -
+
+ +

+ {mode === "login" ? "Sign in" : "Create account"} +

+

+ {mode === "login" + ? "Access monitors and admin tools." + : "Register to save monitors and history."} +

+ +
+ {(["login", "register"] as const).map((m) => ( + + ))} +
+ +
+ {mode === "register" && ( + setName(e.target.value)} + /> + )} + setEmail(e.target.value)} + required + /> + setPassword(e.target.value)} + required + minLength={8} + /> + {error &&

{error}

} + +
+ +

+ + Continue without account + +

+
); } diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index 72ae1ca..414f810 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -1,23 +1,61 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import { Bell, Plus, RefreshCw, ShieldAlert, CheckCircle2, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Bell, Plus } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; +import Link from "next/link"; +import type { MonitorLastResult } from "@/lib/monitoring/check"; + +type MonitorRow = { + id: string; + domain: string; + type: string; + enabled: boolean; + lastChecked: string | null; + lastResult: MonitorLastResult | null; +}; + +function statusBadge(result: MonitorLastResult | null) { + if (!result) return Pending; + if (result.status === "alert") return Alert; + if (result.status === "warning") return Warning; + return OK; +} export default function MonitoringPage() { const [domain, setDomain] = useState(""); - const [monitors, setMonitors] = useState< - { id: string; domain: string; type: string }[] - >([]); + const [type, setType] = useState<"DNS" | "SSL" | "UPTIME">("DNS"); + const [monitors, setMonitors] = useState([]); + const [loading, setLoading] = useState(true); + + const load = async () => { + const res = await fetch("/api/monitors"); + if (res.status === 401) { + setMonitors([]); + setLoading(false); + return; + } + if (res.ok) { + const data = await res.json(); + setMonitors(data.monitors ?? []); + } + setLoading(false); + }; + + useEffect(() => { + load(); + }, []); const addMonitor = async () => { const res = await fetch("/api/monitors", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ domain, type: "DNS" }), + body: JSON.stringify({ domain, type }), }); if (res.status === 401) { toast.error("Sign in to add monitors"); @@ -28,52 +66,114 @@ export default function MonitoringPage() { return; } const data = await res.json(); - setMonitors((m) => [...m, data.monitor]); + setMonitors((m) => [data.monitor, ...m]); setDomain(""); - toast.success("Monitor created (cron worker: roadmap)"); + toast.success("Monitor added — worker checks every ~5 min"); }; return (
-

Monitoring

-

- DNS and SSL change alerts — queue workers ship in v1.1. -

+ +

Monitoring

+

+ DNS change detection and SSL expiry alerts. Background worker runs on your VPS. +

+
- + Add monitor - + setDomain(e.target.value)} + className="flex-1" /> - - {monitors.length === 0 ? ( +
+

+ {loading ? "Loading…" : `${monitors.length} monitor(s)`} +

+ +
+ + {!loading && monitors.length === 0 && (
-
- +
+
-

No monitors yet. Add one to track DNS/SSL changes.

+

No monitors yet.

+

+ + Sign in + {" "} + to track domains. +

- ) : ( -
    - {monitors.map((m) => ( -
  • - {m.domain} — {m.type} -
  • - ))} -
)} + +
    + {monitors.map((m, i) => ( + +
    +
    +

    {m.domain}

    +

    {m.type} monitor

    +
    + {statusBadge(m.lastResult)} +
    + {m.lastResult?.alerts?.length ? ( +
      + {m.lastResult.alerts.map((a) => ( +
    • + + {a} +
    • + ))} +
    + ) : m.lastResult ? ( +

    + No issues on last check +

    + ) : ( +

    + Awaiting first worker run +

    + )} + {m.lastChecked && ( +

    + Last checked {new Date(m.lastChecked).toLocaleString()} +

    + )} +
    + ))} +
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index fed7912..6d3439b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,13 +1,10 @@ import { Hero } from "@/components/landing/hero"; import { Features } from "@/components/landing/features"; -import { SiteFooter } from "@/components/layout/site-footer"; - export default function HomePage() { return ( <> - ); } diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index 274a248..9e35b09 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -22,7 +22,7 @@ export function Header() { const [open, setOpen] = useState(false); return ( -
+
@@ -37,8 +37,9 @@ export function Header() { key={l.href} href={l.href} className={cn( - "rounded-lg px-3 py-2 text-sm text-zinc-400 transition hover:text-white", - pathname.startsWith(l.href) && "bg-white/5 text-white" + "rounded-lg px-3 py-2 text-sm text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white", + pathname.startsWith(l.href) && + "bg-zinc-100 text-zinc-900 dark:bg-white/5 dark:text-white" )} > {l.label} @@ -49,6 +50,9 @@ export function Header() {
+
@@ -62,7 +66,7 @@ export function Header() { {links.map((l) => ( (null); const [progress, setProgress] = useState(0); + const [stage, setStage] = useState(null); const [status, setStatus] = useState("idle"); const [result, setResult] = useState(null); const [error, setError] = useState(null); @@ -26,6 +27,7 @@ export function ScanPanel() { setError(null); setResult(null); setProgress(0); + setStage(null); setStatus("starting"); const res = await fetch("/api/scan", { @@ -58,10 +60,12 @@ export function ScanPanel() { const msg = JSON.parse(ev.data) as { status: string; progress: number; + stage?: string; result?: ScanResult; error?: string; }; setProgress(msg.progress ?? 0); + if (msg.stage) setStage(msg.stage); if (msg.status === "COMPLETED" && msg.result) { setResult(msg.result); setStatus("completed"); @@ -116,7 +120,10 @@ export function ScanPanel() { {status === "running" && !result && (
-
+ {stage && ( +

{stage}

+ )} +
; + checkedAt: string; +}; + +async function dnsFingerprint(domain: string): Promise { + const parts: string[] = []; + try { + const a = await dns.resolve4(domain); + parts.push(`A:${[...a].sort().join(",")}`); + } catch { + parts.push("A:"); + } + try { + const ns = await dns.resolveNs(domain); + parts.push(`NS:${[...ns].sort().join(",")}`); + } catch { + parts.push("NS:"); + } + try { + const mx = await dns.resolveMx(domain); + parts.push(`MX:${mx.map((m) => m.exchange).sort().join(",")}`); + } catch { + parts.push("MX:"); + } + return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 16); +} + +async function sslDaysRemaining(domain: string): Promise<{ valid: boolean; daysRemaining?: number }> { + return new Promise((resolve) => { + const socket = tls.connect( + { host: domain, port: 443, servername: domain, rejectUnauthorized: false, timeout: 10000 }, + () => { + const cert = socket.getPeerCertificate(); + socket.end(); + if (!cert?.valid_to) { + resolve({ valid: false }); + return; + } + const validTo = new Date(cert.valid_to); + const daysRemaining = Math.floor((validTo.getTime() - Date.now()) / 86400000); + resolve({ valid: daysRemaining > 0, daysRemaining }); + } + ); + socket.on("error", () => resolve({ valid: false })); + socket.on("timeout", () => { + socket.destroy(); + resolve({ valid: false }); + }); + }); +} + +async function checkReachable(domain: string): Promise { + try { + const res = await fetch(`https://${domain}`, { + method: "HEAD", + redirect: "follow", + signal: AbortSignal.timeout(12000), + }); + return res.status < 500; + } catch { + try { + const res = await fetch(`http://${domain}`, { + method: "HEAD", + redirect: "follow", + signal: AbortSignal.timeout(12000), + }); + return res.status < 500; + } catch { + return false; + } + } +} + +function previousSnapshot(monitor: Monitor): Record | undefined { + const lr = monitor.lastResult as MonitorLastResult | null; + return lr?.snapshot; +} + +export async function runMonitorCheck(monitor: Monitor): Promise { + const alerts: string[] = []; + const snapshot: Record = {}; + let status: MonitorLastResult["status"] = "ok"; + const prev = previousSnapshot(monitor); + + if (monitor.type === "DNS" || monitor.type === "UPTIME") { + const dnsHash = await dnsFingerprint(monitor.domain); + snapshot.dnsHash = dnsHash; + if (prev?.dnsHash && prev.dnsHash !== dnsHash) { + alerts.push("DNS records changed"); + status = "alert"; + } + } + + if (monitor.type === "SSL" || monitor.type === "UPTIME") { + const ssl = await sslDaysRemaining(monitor.domain); + snapshot.sslValid = ssl.valid; + snapshot.sslDays = ssl.daysRemaining; + if (!ssl.valid) { + alerts.push("SSL certificate invalid or unreachable"); + status = "alert"; + } else if (ssl.daysRemaining !== undefined && ssl.daysRemaining <= 14) { + alerts.push(`SSL expires in ${ssl.daysRemaining} day(s)`); + status = status === "alert" ? "alert" : "warning"; + } else if ( + prev?.sslDays !== undefined && + ssl.daysRemaining !== undefined && + ssl.daysRemaining < (prev.sslDays as number) + ) { + alerts.push("SSL expiry window shortened"); + if (status === "ok") status = "warning"; + } + } + + if (monitor.type === "UPTIME") { + const reachable = await checkReachable(monitor.domain); + snapshot.reachable = reachable; + if (!reachable) { + alerts.push("Host unreachable over HTTP(S)"); + status = "alert"; + } + } + + return { + status: alerts.length ? status : "ok", + alerts, + snapshot, + checkedAt: new Date().toISOString(), + }; +} + +export async function runAllMonitorChecks(): Promise<{ checked: number; alerts: number }> { + const { prisma } = await import("../prisma"); + const monitors = await prisma.monitor.findMany({ where: { enabled: true } }); + let alerts = 0; + for (const monitor of monitors) { + try { + const result = await runMonitorCheck(monitor); + if (result.alerts.length) alerts += 1; + await prisma.monitor.update({ + where: { id: monitor.id }, + data: { lastChecked: new Date(), lastResult: result as object }, + }); + } catch { + await prisma.monitor.update({ + where: { id: monitor.id }, + data: { + lastChecked: new Date(), + lastResult: { + status: "alert", + alerts: ["Check failed"], + snapshot: {}, + checkedAt: new Date().toISOString(), + }, + }, + }); + } + } + return { checked: monitors.length, alerts }; +} diff --git a/src/lib/scanner/index.ts b/src/lib/scanner/index.ts index bd94651..8ee0d2e 100644 --- a/src/lib/scanner/index.ts +++ b/src/lib/scanner/index.ts @@ -193,33 +193,49 @@ function detectTech(headers: Record): TechHint[] { const powered = headers["x-powered-by"]?.toLowerCase() ?? ""; const via = headers["via"]?.toLowerCase() ?? ""; const cf = headers["cf-ray"]; + const allKeys = Object.keys(headers).join(" ").toLowerCase(); if (cf) tech.push({ name: "Cloudflare", category: "CDN", confidence: "high" }); if (server.includes("nginx")) tech.push({ name: "nginx", category: "Web Server", confidence: "high" }); if (server.includes("apache")) tech.push({ name: "Apache", category: "Web Server", confidence: "high" }); if (server.includes("cloudflare")) tech.push({ name: "Cloudflare", category: "CDN", confidence: "high" }); + if (server.includes("caddy")) tech.push({ name: "Caddy", category: "Web Server", confidence: "high" }); + if (server.includes("openresty")) tech.push({ name: "OpenResty", category: "Web Server", confidence: "medium" }); if (powered.includes("next")) tech.push({ name: "Next.js", category: "Framework", confidence: "medium" }); if (powered.includes("express")) tech.push({ name: "Express", category: "Framework", confidence: "medium" }); + if (powered.includes("php")) tech.push({ name: "PHP", category: "Runtime", confidence: "medium" }); if (via.includes("varnish")) tech.push({ name: "Varnish", category: "Cache", confidence: "medium" }); if (headers["x-vercel-id"]) tech.push({ name: "Vercel", category: "Hosting", confidence: "high" }); if (headers["x-amz-cf-id"]) tech.push({ name: "AWS CloudFront", category: "CDN", confidence: "high" }); - - const cdnWaf: string[] = []; - if (cf) cdnWaf.push("Cloudflare"); - if (headers["x-served-by"]?.includes("fastly")) cdnWaf.push("Fastly"); - if (headers["server"]?.includes("Akamai")) cdnWaf.push("Akamai"); + if (headers["x-nf-request-id"]) tech.push({ name: "Netlify", category: "Hosting", confidence: "high" }); + if (headers["x-render-origin-server"]) tech.push({ name: "Render", category: "Hosting", confidence: "high" }); + if (headers["fly-request-id"]) tech.push({ name: "Fly.io", category: "Hosting", confidence: "high" }); + if (headers["x-powered-by"]?.includes("WP")) tech.push({ name: "WordPress", category: "CMS", confidence: "medium" }); + if (allKeys.includes("x-drupal")) tech.push({ name: "Drupal", category: "CMS", confidence: "low" }); return tech; } function detectCdnWaf(headers: Record): string[] { - const detected: string[] = []; - if (headers["cf-ray"]) detected.push("Cloudflare"); - if (headers["x-fastly-request-id"]) detected.push("Fastly"); - if (headers["x-akamai-transformed"]) detected.push("Akamai"); - if (headers["x-amz-cf-id"]) detected.push("AWS CloudFront"); - if (headers["x-sucuri-id"]) detected.push("Sucuri WAF"); - return detected; + const detected = new Set(); + const server = headers["server"]?.toLowerCase() ?? ""; + const via = headers["via"]?.toLowerCase() ?? ""; + + if (headers["cf-ray"] || server.includes("cloudflare")) detected.add("Cloudflare"); + if (headers["x-fastly-request-id"] || via.includes("fastly") || headers["x-served-by"]?.includes("fastly")) + detected.add("Fastly"); + if (headers["x-akamai-transformed"] || server.includes("akamai")) detected.add("Akamai"); + if (headers["x-amz-cf-id"]) detected.add("AWS CloudFront"); + if (headers["x-sucuri-id"]) detected.add("Sucuri WAF"); + if (headers["x-incap-client-ip"] || headers["x-cdn"]?.includes("incapsula")) detected.add("Imperva"); + if (headers["x-azure-ref"]) detected.add("Azure Front Door"); + if (headers["x-goog-cache-control"] || headers["x-gfe-backend"]) detected.add("Google CDN"); + if (headers["x-bunnycdn"]) detected.add("BunnyCDN"); + if (headers["x-cache"]?.includes("netlify")) detected.add("Netlify Edge"); + if (headers["server"]?.includes("ddos-guard")) detected.add("DDoS-Guard"); + if (headers["x-fw-server"] || headers["x-served-by"]?.includes("fly")) detected.add("Fly.io Edge"); + + return [...detected]; } async function geoLookup(domain: string): Promise { @@ -271,12 +287,21 @@ async function geoLookup(domain: string): Promise { export type ScanProgressCallback = (progress: number, stage: string) => void | Promise; +const STAGE_LABELS: Record = { + dns: "Resolving DNS", + whois: "WHOIS lookup", + ssl: "Checking SSL", + http: "HTTP headers", + geo: "Geo / IP", + done: "Finalizing", +}; + export async function runDomainScan( domain: string, onProgress?: ScanProgressCallback ): Promise { const report = async (p: number, stage: string) => { - await onProgress?.(p, stage); + await onProgress?.(p, STAGE_LABELS[stage] ?? stage); }; await report(5, "dns");