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 <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-24 23:33:15 +03:00
parent 1196bc3d11
commit 010e053ca1
21 changed files with 1166 additions and 75 deletions

View File

@@ -6,3 +6,6 @@ NEXTAUTH_SECRET=change-me-to-a-long-random-string
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000
IPINFO_TOKEN= IPINFO_TOKEN=
SCAN_RATE_LIMIT_PER_HOUR=30 SCAN_RATE_LIMIT_PER_HOUR=30
MONITOR_INTERVAL_MS=300000
SEED_ADMIN_EMAIL=
SEED_ADMIN_PASSWORD=

View File

@@ -17,7 +17,22 @@ Modern domain intelligence and infrastructure analysis platform with premium UX,
- **Tech/CDN** — Server, Cloudflare, Vercel signals - **Tech/CDN** — Server, Cloudflare, Vercel signals
- **Export** — JSON and CSV downloads - **Export** — JSON and CSV downloads
- **Live progress** — Server-Sent Events during scans - **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) ## Quick start (Docker)
@@ -66,6 +81,9 @@ npm run dev
| `NEXTAUTH_URL` | Public app URL | | `NEXTAUTH_URL` | Public app URL |
| `IPINFO_TOKEN` | Optional IPinfo token | | `IPINFO_TOKEN` | Optional IPinfo token |
| `SCAN_RATE_LIMIT_PER_HOUR` | Per-IP scan limit (default 30) | | `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 ## API

View File

@@ -118,6 +118,39 @@ build_app() {
apt-get clean 2>/dev/null || true 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" <<EOF
[Unit]
Description=Domain Scanner monitor worker
After=network.target docker.service ${SERVICE_NAME}.service
Wants=docker.service
[Service]
Type=simple
WorkingDirectory=${INSTALL_DIR}
EnvironmentFile=${INSTALL_DIR}/.env
Environment=NODE_ENV=production
ExecStart=/usr/bin/npm run worker:monitors --silent
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}-worker.service"
systemctl restart "${SERVICE_NAME}-worker.service"
}
seed_admin_user() {
if [[ -n "${SEED_ADMIN_EMAIL:-}" && -n "${SEED_ADMIN_PASSWORD:-}" ]]; then
log "Seeding admin user..."
cd "${INSTALL_DIR}"
SEED_ADMIN_EMAIL="${SEED_ADMIN_EMAIL}" SEED_ADMIN_PASSWORD="${SEED_ADMIN_PASSWORD}" npm run seed:admin --silent
fi
}
install_systemd() { install_systemd() {
log "Installing systemd unit (${SERVICE_NAME})..." log "Installing systemd unit (${SERVICE_NAME})..."
cat >"/etc/systemd/system/${SERVICE_NAME}.service" <<EOF cat >"/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
@@ -143,6 +176,8 @@ EOF
systemctl daemon-reload systemctl daemon-reload
systemctl enable "${SERVICE_NAME}.service" systemctl enable "${SERVICE_NAME}.service"
systemctl restart "${SERVICE_NAME}.service" systemctl restart "${SERVICE_NAME}.service"
install_monitor_worker
seed_admin_user
} }
install_nginx() { install_nginx() {

519
package-lock.json generated
View File

@@ -49,6 +49,7 @@
"eslint-config-next": "^15.3.3", "eslint-config-next": "^15.3.3",
"prisma": "^6.9.0", "prisma": "^6.9.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.22.3",
"typescript": "^5" "typescript": "^5"
} }
}, },
@@ -136,6 +137,448 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=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": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "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" "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": { "node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "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": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -9077,6 +9577,25 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -10,7 +10,10 @@
"lint": "eslint", "lint": "eslint",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"db:push": "prisma db push", "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": { "dependencies": {
"@prisma/client": "^6.9.0", "@prisma/client": "^6.9.0",
@@ -53,6 +56,7 @@
"eslint-config-next": "^15.3.3", "eslint-config-next": "^15.3.3",
"prisma": "^6.9.0", "prisma": "^6.9.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.22.3",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@@ -43,6 +43,7 @@ model Scan {
domain String domain String
status ScanStatus @default(PENDING) status ScanStatus @default(PENDING)
progress Int @default(0) progress Int @default(0)
stage String?
result Json? result Json?
error String? error String?
userId String? userId String?

27
scripts/monitor-worker.ts Normal file
View File

@@ -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);
});

28
scripts/seed-admin.ts Normal file
View File

@@ -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());

42
scripts/smoke-test.sh Normal file
View File

@@ -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"

View File

@@ -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<typeof bodySchema>;
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 });
}

View File

@@ -31,6 +31,7 @@ export async function GET(
id: scan.id, id: scan.id,
status: scan.status, status: scan.status,
progress: scan.progress, progress: scan.progress,
stage: scan.stage,
domain: scan.domain, domain: scan.domain,
result: scan.status === "COMPLETED" ? scan.result : undefined, result: scan.status === "COMPLETED" ? scan.result : undefined,
error: scan.error, error: scan.error,
@@ -48,7 +49,7 @@ export async function GET(
return; return;
} }
setTimeout(poll, 1000); setTimeout(poll, 450);
}; };
await poll(); await poll();

View File

@@ -62,8 +62,11 @@ export async function POST(req: NextRequest) {
async function runScanAsync(scanId: string, domain: string, cacheKey: string) { async function runScanAsync(scanId: string, domain: string, cacheKey: string) {
try { try {
const result = await runDomainScan(domain, async (progress) => { const result = await runDomainScan(domain, async (progress, stage) => {
await prisma.scan.update({ where: { id: scanId }, data: { progress } }); await prisma.scan.update({
where: { id: scanId },
data: { progress, stage },
});
}); });
await prisma.scan.update({ await prisma.scan.update({
where: { id: scanId }, where: { id: scanId },

View File

@@ -36,7 +36,7 @@ body {
} }
.glass { .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 { .gradient-mesh {

View File

@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Providers } from "@/components/providers"; import { Providers } from "@/components/providers";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { SiteFooter } from "@/components/layout/site-footer";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -44,6 +45,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<div className="gradient-mesh min-h-screen"> <div className="gradient-mesh min-h-screen">
<Header /> <Header />
<main>{children}</main> <main>{children}</main>
<SiteFooter />
</div> </div>
</Providers> </Providers>
</body> </body>

View File

@@ -2,47 +2,113 @@
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useState } from "react"; import { useState } from "react";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
e.preventDefault(); 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 }); const res = await signIn("credentials", { email, password, redirect: false });
setLoading(false);
if (res?.error) { if (res?.error) {
setError("Invalid credentials"); setError(mode === "login" ? "Invalid credentials" : "Registered but sign-in failed");
return; return;
} }
router.push("/dashboard"); router.push("/dashboard");
}; };
return ( return (
<div className="mx-auto flex min-h-[80vh] max-w-md flex-col justify-center px-4"> <div className="mx-auto flex min-h-[80vh] max-w-md flex-col justify-center px-4 pb-24 pt-24">
<h1 className="text-3xl font-bold">Sign in</h1> <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}>
<form onSubmit={submit} className="mt-8 space-y-4"> <h1 className="text-3xl font-bold tracking-tight">
<Input {mode === "login" ? "Sign in" : "Create account"}
type="email" </h1>
placeholder="Email" <p className="mt-2 text-sm text-zinc-500">
value={email} {mode === "login"
onChange={(e) => setEmail(e.target.value)} ? "Access monitors and admin tools."
/> : "Register to save monitors and history."}
<Input </p>
type="password"
placeholder="Password" <div className="mt-6 flex gap-2 rounded-xl bg-zinc-100 p-1 dark:bg-white/5">
value={password} {(["login", "register"] as const).map((m) => (
onChange={(e) => setPassword(e.target.value)} <button
/> key={m}
{error && <p className="text-sm text-rose-400">{error}</p>} type="button"
<Button type="submit" className="w-full"> onClick={() => setMode(m)}
Sign in className={`flex-1 rounded-lg py-2 text-sm font-medium transition ${
</Button> mode === m
</form> ? "bg-white text-zinc-900 shadow dark:bg-zinc-800 dark:text-white"
: "text-zinc-500"
}`}
>
{m === "login" ? "Sign in" : "Register"}
</button>
))}
</div>
<form onSubmit={submit} className="mt-8 space-y-4">
{mode === "register" && (
<Input
placeholder="Name (optional)"
value={name}
onChange={(e) => setName(e.target.value)}
/>
)}
<Input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
type="password"
placeholder="Password (min 8 chars)"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
{error && <p className="text-sm text-rose-500">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Please wait…" : mode === "login" ? "Sign in" : "Create account"}
</Button>
</form>
<p className="mt-6 text-center text-sm text-zinc-500">
<Link href="/dashboard" className="text-violet-500 hover:underline">
Continue without account
</Link>
</p>
</motion.div>
</div> </div>
); );
} }

View File

@@ -1,23 +1,61 @@
"use client"; "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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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 { 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 <Badge variant="warning">Pending</Badge>;
if (result.status === "alert") return <Badge variant="danger">Alert</Badge>;
if (result.status === "warning") return <Badge variant="warning">Warning</Badge>;
return <Badge variant="success">OK</Badge>;
}
export default function MonitoringPage() { export default function MonitoringPage() {
const [domain, setDomain] = useState(""); const [domain, setDomain] = useState("");
const [monitors, setMonitors] = useState< const [type, setType] = useState<"DNS" | "SSL" | "UPTIME">("DNS");
{ id: string; domain: string; type: string }[] const [monitors, setMonitors] = useState<MonitorRow[]>([]);
>([]); 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 addMonitor = async () => {
const res = await fetch("/api/monitors", { const res = await fetch("/api/monitors", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain, type: "DNS" }), body: JSON.stringify({ domain, type }),
}); });
if (res.status === 401) { if (res.status === 401) {
toast.error("Sign in to add monitors"); toast.error("Sign in to add monitors");
@@ -28,52 +66,114 @@ export default function MonitoringPage() {
return; return;
} }
const data = await res.json(); const data = await res.json();
setMonitors((m) => [...m, data.monitor]); setMonitors((m) => [data.monitor, ...m]);
setDomain(""); setDomain("");
toast.success("Monitor created (cron worker: roadmap)"); toast.success("Monitor added — worker checks every ~5 min");
}; };
return ( return (
<div className="mx-auto max-w-4xl px-4 pb-24 pt-24"> <div className="mx-auto max-w-4xl px-4 pb-24 pt-24">
<h1 className="text-4xl font-bold">Monitoring</h1> <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}>
<p className="mt-2 text-zinc-400"> <h1 className="text-4xl font-bold tracking-tight md:text-5xl">Monitoring</h1>
DNS and SSL change alerts queue workers ship in v1.1. <p className="mt-2 text-zinc-500 dark:text-zinc-400">
</p> DNS change detection and SSL expiry alerts. Background worker runs on your VPS.
</p>
</motion.div>
<Card className="mt-8"> <Card className="glass mt-10">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" /> Add monitor <Bell className="h-5 w-5" /> Add monitor
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3 sm:flex-row"> <CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input <Input
placeholder="domain.com" placeholder="domain.com"
value={domain} value={domain}
onChange={(e) => setDomain(e.target.value)} onChange={(e) => setDomain(e.target.value)}
className="flex-1"
/> />
<Button onClick={addMonitor}> <select
value={type}
onChange={(e) => setType(e.target.value as typeof type)}
className="h-10 rounded-xl border border-zinc-200 bg-white/80 px-3 text-sm dark:border-white/10 dark:bg-white/5"
>
<option value="DNS">DNS</option>
<option value="SSL">SSL</option>
<option value="UPTIME">Uptime</option>
</select>
<Button onClick={addMonitor} disabled={!domain.trim()}>
<Plus className="h-4 w-4" /> Add <Plus className="h-4 w-4" /> Add
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
{monitors.length === 0 ? ( <div className="mt-6 flex items-center justify-between">
<p className="text-sm text-zinc-500">
{loading ? "Loading…" : `${monitors.length} monitor(s)`}
</p>
<Button variant="secondary" size="sm" onClick={load}>
<RefreshCw className="h-4 w-4" /> Refresh
</Button>
</div>
{!loading && monitors.length === 0 && (
<div className="mt-16 text-center"> <div className="mt-16 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-violet-500/10">
<Bell className="h-8 w-8 text-zinc-600" /> <Bell className="h-8 w-8 text-violet-400" />
</div> </div>
<p className="text-zinc-500">No monitors yet. Add one to track DNS/SSL changes.</p> <p className="text-zinc-500">No monitors yet.</p>
<p className="mt-2 text-sm text-zinc-500">
<Link href="/login" className="text-violet-500 hover:underline">
Sign in
</Link>{" "}
to track domains.
</p>
</div> </div>
) : (
<ul className="mt-8 space-y-3">
{monitors.map((m) => (
<li key={m.id} className="glass rounded-xl px-4 py-3">
{m.domain} {m.type}
</li>
))}
</ul>
)} )}
<ul className="mt-6 space-y-3">
{monitors.map((m, i) => (
<motion.li
key={m.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04 }}
className="glass rounded-2xl px-4 py-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-semibold">{m.domain}</p>
<p className="text-sm text-zinc-500">{m.type} monitor</p>
</div>
{statusBadge(m.lastResult)}
</div>
{m.lastResult?.alerts?.length ? (
<ul className="mt-3 space-y-1 text-sm text-amber-600 dark:text-amber-300">
{m.lastResult.alerts.map((a) => (
<li key={a} className="flex items-center gap-2">
<ShieldAlert className="h-4 w-4 shrink-0" />
{a}
</li>
))}
</ul>
) : m.lastResult ? (
<p className="mt-3 flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-4 w-4" /> No issues on last check
</p>
) : (
<p className="mt-3 flex items-center gap-2 text-sm text-zinc-500">
<AlertTriangle className="h-4 w-4" /> Awaiting first worker run
</p>
)}
{m.lastChecked && (
<p className="mt-2 text-xs text-zinc-500">
Last checked {new Date(m.lastChecked).toLocaleString()}
</p>
)}
</motion.li>
))}
</ul>
</div> </div>
); );
} }

View File

@@ -1,13 +1,10 @@
import { Hero } from "@/components/landing/hero"; import { Hero } from "@/components/landing/hero";
import { Features } from "@/components/landing/features"; import { Features } from "@/components/landing/features";
import { SiteFooter } from "@/components/layout/site-footer";
export default function HomePage() { export default function HomePage() {
return ( return (
<> <>
<Hero /> <Hero />
<Features /> <Features />
<SiteFooter />
</> </>
); );
} }

View File

@@ -22,7 +22,7 @@ export function Header() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<header className="fixed top-0 z-50 w-full border-b border-white/5 bg-zinc-950/70 backdrop-blur-xl"> <header className="fixed top-0 z-50 w-full border-b border-zinc-200/80 bg-white/80 backdrop-blur-xl dark:border-white/5 dark:bg-zinc-950/70">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6"> <div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6">
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400"> <div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400">
@@ -37,8 +37,9 @@ export function Header() {
key={l.href} key={l.href}
href={l.href} href={l.href}
className={cn( className={cn(
"rounded-lg px-3 py-2 text-sm text-zinc-400 transition hover: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-white/5 text-white" pathname.startsWith(l.href) &&
"bg-zinc-100 text-zinc-900 dark:bg-white/5 dark:text-white"
)} )}
> >
{l.label} {l.label}
@@ -49,6 +50,9 @@ export function Header() {
<div className="hidden items-center gap-2 md:flex"> <div className="hidden items-center gap-2 md:flex">
<ThemeToggle /> <ThemeToggle />
<Button variant="secondary" size="sm" asChild> <Button variant="secondary" size="sm" asChild>
<Link href="/login">Sign in</Link>
</Button>
<Button size="sm" asChild>
<Link href="/dashboard">Start scan</Link> <Link href="/dashboard">Start scan</Link>
</Button> </Button>
</div> </div>
@@ -62,7 +66,7 @@ export function Header() {
<motion.div <motion.div
initial={{ opacity: 0, y: -8 }} initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="border-t border-white/5 bg-zinc-950 p-4 md:hidden" className="border-t border-zinc-200 bg-white p-4 dark:border-white/5 dark:bg-zinc-950 md:hidden"
> >
{links.map((l) => ( {links.map((l) => (
<Link <Link

View File

@@ -18,6 +18,7 @@ export function ScanPanel() {
const [domain, setDomain] = useState(searchParams.get("domain") ?? ""); const [domain, setDomain] = useState(searchParams.get("domain") ?? "");
const [scanId, setScanId] = useState<string | null>(null); const [scanId, setScanId] = useState<string | null>(null);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [stage, setStage] = useState<string | null>(null);
const [status, setStatus] = useState<string>("idle"); const [status, setStatus] = useState<string>("idle");
const [result, setResult] = useState<ScanResult | null>(null); const [result, setResult] = useState<ScanResult | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -26,6 +27,7 @@ export function ScanPanel() {
setError(null); setError(null);
setResult(null); setResult(null);
setProgress(0); setProgress(0);
setStage(null);
setStatus("starting"); setStatus("starting");
const res = await fetch("/api/scan", { const res = await fetch("/api/scan", {
@@ -58,10 +60,12 @@ export function ScanPanel() {
const msg = JSON.parse(ev.data) as { const msg = JSON.parse(ev.data) as {
status: string; status: string;
progress: number; progress: number;
stage?: string;
result?: ScanResult; result?: ScanResult;
error?: string; error?: string;
}; };
setProgress(msg.progress ?? 0); setProgress(msg.progress ?? 0);
if (msg.stage) setStage(msg.stage);
if (msg.status === "COMPLETED" && msg.result) { if (msg.status === "COMPLETED" && msg.result) {
setResult(msg.result); setResult(msg.result);
setStatus("completed"); setStatus("completed");
@@ -116,7 +120,10 @@ export function ScanPanel() {
{status === "running" && !result && ( {status === "running" && !result && (
<div className="space-y-4"> <div className="space-y-4">
<div className="h-2 overflow-hidden rounded-full bg-white/10"> {stage && (
<p className="text-sm text-zinc-500 transition-opacity">{stage}</p>
)}
<div className="h-2 overflow-hidden rounded-full bg-zinc-200 dark:bg-white/10">
<motion.div <motion.div
className="h-full bg-gradient-to-r from-violet-500 to-cyan-400" className="h-full bg-gradient-to-r from-violet-500 to-cyan-400"
initial={{ width: 0 }} initial={{ width: 0 }}

167
src/lib/monitoring/check.ts Normal file
View File

@@ -0,0 +1,167 @@
import dns from "node:dns/promises";
import tls from "node:tls";
import { createHash } from "node:crypto";
import type { Monitor } from "@prisma/client";
export type MonitorLastResult = {
status: "ok" | "warning" | "alert";
alerts: string[];
snapshot: Record<string, unknown>;
checkedAt: string;
};
async function dnsFingerprint(domain: string): Promise<string> {
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<boolean> {
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<string, unknown> | undefined {
const lr = monitor.lastResult as MonitorLastResult | null;
return lr?.snapshot;
}
export async function runMonitorCheck(monitor: Monitor): Promise<MonitorLastResult> {
const alerts: string[] = [];
const snapshot: Record<string, unknown> = {};
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 };
}

View File

@@ -193,33 +193,49 @@ function detectTech(headers: Record<string, string>): TechHint[] {
const powered = headers["x-powered-by"]?.toLowerCase() ?? ""; const powered = headers["x-powered-by"]?.toLowerCase() ?? "";
const via = headers["via"]?.toLowerCase() ?? ""; const via = headers["via"]?.toLowerCase() ?? "";
const cf = headers["cf-ray"]; const cf = headers["cf-ray"];
const allKeys = Object.keys(headers).join(" ").toLowerCase();
if (cf) tech.push({ name: "Cloudflare", category: "CDN", confidence: "high" }); 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("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("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("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("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("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 (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-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" }); if (headers["x-amz-cf-id"]) tech.push({ name: "AWS CloudFront", category: "CDN", confidence: "high" });
if (headers["x-nf-request-id"]) tech.push({ name: "Netlify", category: "Hosting", confidence: "high" });
const cdnWaf: string[] = []; if (headers["x-render-origin-server"]) tech.push({ name: "Render", category: "Hosting", confidence: "high" });
if (cf) cdnWaf.push("Cloudflare"); if (headers["fly-request-id"]) tech.push({ name: "Fly.io", category: "Hosting", confidence: "high" });
if (headers["x-served-by"]?.includes("fastly")) cdnWaf.push("Fastly"); if (headers["x-powered-by"]?.includes("WP")) tech.push({ name: "WordPress", category: "CMS", confidence: "medium" });
if (headers["server"]?.includes("Akamai")) cdnWaf.push("Akamai"); if (allKeys.includes("x-drupal")) tech.push({ name: "Drupal", category: "CMS", confidence: "low" });
return tech; return tech;
} }
function detectCdnWaf(headers: Record<string, string>): string[] { function detectCdnWaf(headers: Record<string, string>): string[] {
const detected: string[] = []; const detected = new Set<string>();
if (headers["cf-ray"]) detected.push("Cloudflare"); const server = headers["server"]?.toLowerCase() ?? "";
if (headers["x-fastly-request-id"]) detected.push("Fastly"); const via = headers["via"]?.toLowerCase() ?? "";
if (headers["x-akamai-transformed"]) detected.push("Akamai");
if (headers["x-amz-cf-id"]) detected.push("AWS CloudFront"); if (headers["cf-ray"] || server.includes("cloudflare")) detected.add("Cloudflare");
if (headers["x-sucuri-id"]) detected.push("Sucuri WAF"); if (headers["x-fastly-request-id"] || via.includes("fastly") || headers["x-served-by"]?.includes("fastly"))
return detected; 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<GeoInfo | null> { async function geoLookup(domain: string): Promise<GeoInfo | null> {
@@ -271,12 +287,21 @@ async function geoLookup(domain: string): Promise<GeoInfo | null> {
export type ScanProgressCallback = (progress: number, stage: string) => void | Promise<void>; export type ScanProgressCallback = (progress: number, stage: string) => void | Promise<void>;
const STAGE_LABELS: Record<string, string> = {
dns: "Resolving DNS",
whois: "WHOIS lookup",
ssl: "Checking SSL",
http: "HTTP headers",
geo: "Geo / IP",
done: "Finalizing",
};
export async function runDomainScan( export async function runDomainScan(
domain: string, domain: string,
onProgress?: ScanProgressCallback onProgress?: ScanProgressCallback
): Promise<ScanResult> { ): Promise<ScanResult> {
const report = async (p: number, stage: string) => { const report = async (p: number, stage: string) => {
await onProgress?.(p, stage); await onProgress?.(p, STAGE_LABELS[stage] ?? stage);
}; };
await report(5, "dns"); await report(5, "dns");