mirror of
https://github.com/andrey271192/Domain_web.git
synced 2026-09-20 14:41:58 +00:00
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:
42
src/app/api/auth/register/route.ts
Normal file
42
src/app/api/auth/register/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
<div className="gradient-mesh min-h-screen">
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto flex min-h-[80vh] max-w-md flex-col justify-center px-4">
|
||||
<h1 className="text-3xl font-bold">Sign in</h1>
|
||||
<form onSubmit={submit} className="mt-8 space-y-4">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-sm text-rose-400">{error}</p>}
|
||||
<Button type="submit" className="w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mx-auto flex min-h-[80vh] max-w-md flex-col justify-center px-4 pb-24 pt-24">
|
||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{mode === "login" ? "Sign in" : "Create account"}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-500">
|
||||
{mode === "login"
|
||||
? "Access monitors and admin tools."
|
||||
: "Register to save monitors and history."}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex gap-2 rounded-xl bg-zinc-100 p-1 dark:bg-white/5">
|
||||
{(["login", "register"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
className={`flex-1 rounded-lg py-2 text-sm font-medium transition ${
|
||||
mode === m
|
||||
? "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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <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() {
|
||||
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<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 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 (
|
||||
<div className="mx-auto max-w-4xl px-4 pb-24 pt-24">
|
||||
<h1 className="text-4xl font-bold">Monitoring</h1>
|
||||
<p className="mt-2 text-zinc-400">
|
||||
DNS and SSL change alerts — queue workers ship in v1.1.
|
||||
</p>
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<h1 className="text-4xl font-bold tracking-tight md:text-5xl">Monitoring</h1>
|
||||
<p className="mt-2 text-zinc-500 dark:text-zinc-400">
|
||||
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>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" /> Add monitor
|
||||
</CardTitle>
|
||||
</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
|
||||
placeholder="domain.com"
|
||||
value={domain}
|
||||
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
|
||||
</Button>
|
||||
</CardContent>
|
||||
</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="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
|
||||
<Bell className="h-8 w-8 text-zinc-600" />
|
||||
<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-violet-400" />
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Hero />
|
||||
<Features />
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function Header() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
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">
|
||||
<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">
|
||||
@@ -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() {
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<ThemeToggle />
|
||||
<Button variant="secondary" size="sm" asChild>
|
||||
<Link href="/login">Sign in</Link>
|
||||
</Button>
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/dashboard">Start scan</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -62,7 +66,7 @@ export function Header() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
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) => (
|
||||
<Link
|
||||
|
||||
@@ -18,6 +18,7 @@ export function ScanPanel() {
|
||||
const [domain, setDomain] = useState(searchParams.get("domain") ?? "");
|
||||
const [scanId, setScanId] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [stage, setStage] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string>("idle");
|
||||
const [result, setResult] = useState<ScanResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 && (
|
||||
<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
|
||||
className="h-full bg-gradient-to-r from-violet-500 to-cyan-400"
|
||||
initial={{ width: 0 }}
|
||||
|
||||
167
src/lib/monitoring/check.ts
Normal file
167
src/lib/monitoring/check.ts
Normal 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 };
|
||||
}
|
||||
@@ -193,33 +193,49 @@ function detectTech(headers: Record<string, string>): 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, string>): 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<string>();
|
||||
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<GeoInfo | null> {
|
||||
@@ -271,12 +287,21 @@ async function geoLookup(domain: string): Promise<GeoInfo | null> {
|
||||
|
||||
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(
|
||||
domain: string,
|
||||
onProgress?: ScanProgressCallback
|
||||
): Promise<ScanResult> {
|
||||
const report = async (p: number, stage: string) => {
|
||||
await onProgress?.(p, stage);
|
||||
await onProgress?.(p, STAGE_LABELS[stage] ?? stage);
|
||||
};
|
||||
|
||||
await report(5, "dns");
|
||||
|
||||
Reference in New Issue
Block a user