v0.1.20 — jobs (hits-retention, dns-health), login rate-limit, IP-blocklist, security headers, search/sort/csv-import on domains, test-call + per-domain PDF, webhooks, extended health
This commit is contained in:
parent
3b209db090
commit
eb283f487c
16 changed files with 487 additions and 6 deletions
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { RefreshCcw, Trash2, Loader2 } from "lucide-react";
|
||||
import { RefreshCcw, Trash2, Loader2, ExternalLink, FileDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function DomainActions({ id, status, hitsTotal = 0, domainName = "" }: { id: number; status: string; hitsTotal?: number; domainName?: string }) {
|
||||
|
|
@ -42,6 +42,16 @@ export function DomainActions({ id, status, hitsTotal = 0, domainName = "" }: {
|
|||
{busy === "verify" ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <RefreshCcw className="mr-2 h-3 w-3" />}
|
||||
DNS erneut prüfen
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm" className="w-full">
|
||||
<a href={`https://${domainName}`} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="mr-2 h-3 w-3" />Test-Aufruf
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm" className="w-full">
|
||||
<a href={`/api/domains/${id}/report.pdf`} download>
|
||||
<FileDown className="mr-2 h-3 w-3" />PDF-Report
|
||||
</a>
|
||||
</Button>
|
||||
<Button onClick={del} variant="destructive" size="sm" className="w-full" disabled={busy !== null}>
|
||||
{busy === "delete" ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <Trash2 className="mr-2 h-3 w-3" />}
|
||||
Domain löschen
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ type Settings = {
|
|||
admin_email: string | null;
|
||||
update_auto: string | null;
|
||||
update_include_prereleases: string | null;
|
||||
hits_retention_days: string | null;
|
||||
webhook_url: string | null;
|
||||
};
|
||||
|
||||
type UpdateStatus = {
|
||||
|
|
@ -246,6 +248,29 @@ export default function SettingsPage() {
|
|||
/>
|
||||
<p className="text-[11px] text-muted-foreground">Wird von Caddy für ACME/Let's Encrypt benötigt.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retention">Hit-Retention (Tage)</Label>
|
||||
<Input
|
||||
id="retention"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="365"
|
||||
defaultValue={settings.hits_retention_days ?? "365"}
|
||||
onBlur={(e) => save({ hits_retention_days: e.target.value || "365" })}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">Hits älter als diese Anzahl Tage werden täglich gelöscht. 0 = nie löschen.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook">Webhook-URL</Label>
|
||||
<Input
|
||||
id="webhook"
|
||||
type="url"
|
||||
placeholder="https://hooks.example.com/..."
|
||||
defaultValue={settings.webhook_url ?? ""}
|
||||
onBlur={(e) => save({ webhook_url: e.target.value.trim() })}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">POST mit JSON bei Domain-Verify-Fail / Update-Available. Leer = aus.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
61
app/api/domains/[id]/report.pdf/route.ts
Normal file
61
app/api/domains/[id]/report.pdf/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { getDb, type DomainRow } from "@/lib/db";
|
||||
import { createPdfToken } from "@/lib/pdf-token";
|
||||
|
||||
const CHROME_PATHS = [
|
||||
process.env.NEXREDIRECT_CHROME_PATH,
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
async function findChrome(): Promise<string | null> {
|
||||
for (const p of CHROME_PATHS) {
|
||||
try { await fs.access(p); return p; } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
const { id } = await params;
|
||||
const row = getDb().prepare("SELECT * FROM domains WHERE id = ?").get(Number(id)) as DomainRow | undefined;
|
||||
if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||
|
||||
const chrome = await findChrome();
|
||||
if (!chrome) return NextResponse.json({ error: "chrome_not_found" }, { status: 500 });
|
||||
|
||||
const token = createPdfToken({ domain_id: String(row.id), title: `Report: ${row.domain}`, kind: "domain" }, 90);
|
||||
const port = process.env.PORT || "3000";
|
||||
const url = `http://127.0.0.1:${port}/r/${token}`;
|
||||
|
||||
try {
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
const browser = await puppeteer.default.launch({ executablePath: chrome, args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"], headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle0", timeout: 30_000 });
|
||||
await page.waitForFunction(() => document.body.dataset.pdfReady === "1", { timeout: 10_000 }).catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const buf = await page.pdf({ format: "A4", printBackground: true, preferCSSPageSize: true, margin: { top: 0, right: 0, bottom: 0, left: 0 } });
|
||||
const filename = `nexredirect-${row.domain}-${new Date().toISOString().slice(0, 10)}.pdf`;
|
||||
return new NextResponse(buf as unknown as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: "pdf_render_failed", detail: e instanceof Error ? e.message : String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { getDb, type DomainRow } from "@/lib/db";
|
|||
import { checkDomainDns } from "@/lib/dns";
|
||||
import { reloadCaddy } from "@/lib/caddy";
|
||||
import { invalidateRedirectCache } from "@/lib/redirect-resolver";
|
||||
import { fireWebhook } from "@/lib/webhook";
|
||||
|
||||
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
|
@ -25,5 +26,6 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st
|
|||
}
|
||||
|
||||
db.prepare("UPDATE domains SET status = 'pending' WHERE id = ?").run(row.id);
|
||||
fireWebhook("domain.verify_failed", { domain: row.domain, missing: result.missing }).catch(() => {});
|
||||
return NextResponse.json({ ok: false, result });
|
||||
}
|
||||
|
|
|
|||
93
app/api/domains/import.csv/route.ts
Normal file
93
app/api/domains/import.csv/route.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { getDb, logAudit } from "@/lib/db";
|
||||
import { isValidDomain } from "@/lib/dns";
|
||||
|
||||
function parseCsv(text: string): Record<string, string>[] {
|
||||
const lines = text.replace(/^/, "").split(/\r?\n/).filter((l) => l.trim());
|
||||
if (lines.length === 0) return [];
|
||||
const sep = lines[0].includes(";") && !lines[0].includes(",") ? ";" : ",";
|
||||
const split = (l: string): string[] => {
|
||||
const out: string[] = [];
|
||||
let cur = "";
|
||||
let inQ = false;
|
||||
for (let i = 0; i < l.length; i++) {
|
||||
const c = l[i];
|
||||
if (inQ) {
|
||||
if (c === '"' && l[i + 1] === '"') { cur += '"'; i++; }
|
||||
else if (c === '"') { inQ = false; }
|
||||
else cur += c;
|
||||
} else {
|
||||
if (c === '"') inQ = true;
|
||||
else if (c === sep) { out.push(cur); cur = ""; }
|
||||
else cur += c;
|
||||
}
|
||||
}
|
||||
out.push(cur);
|
||||
return out;
|
||||
};
|
||||
const head = split(lines[0]).map((h) => h.trim().toLowerCase());
|
||||
return lines.slice(1).map((l) => {
|
||||
const cols = split(l);
|
||||
const r: Record<string, string> = {};
|
||||
head.forEach((h, i) => { r[h] = (cols[i] || "").trim(); });
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
|
||||
const text = await req.text();
|
||||
const rows = parseCsv(text);
|
||||
if (rows.length === 0) return NextResponse.json({ error: "empty_csv" }, { status: 400 });
|
||||
if (rows.length > 1000) return NextResponse.json({ error: "too_many", limit: 1000 }, { status: 400 });
|
||||
|
||||
const db = getDb();
|
||||
const now = Date.now();
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
// Optional pre-resolve groups by name
|
||||
const groupNames = new Set(rows.map((r) => r.group).filter(Boolean));
|
||||
const groups = groupNames.size > 0
|
||||
? (db.prepare(`SELECT id, name FROM domain_groups WHERE name IN (${Array.from(groupNames).map(() => "?").join(",")})`).all(...Array.from(groupNames)) as { id: number; name: string }[])
|
||||
: [];
|
||||
const groupByName = new Map(groups.map((g) => [g.name, g.id]));
|
||||
|
||||
const insert = db.prepare(`INSERT INTO domains (domain, status, target_url, group_id, redirect_code, preserve_path, include_www, created_by, created_at) VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?)`);
|
||||
|
||||
let imported = 0;
|
||||
const errors: { row: number; domain: string; error: string }[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
const domain = (r.domain || r.host || "").toLowerCase().trim();
|
||||
if (!domain) continue;
|
||||
if (!isValidDomain(domain)) { errors.push({ row: i + 2, domain, error: "invalid_domain" }); continue; }
|
||||
|
||||
const targetUrl = (r.target_url || r.target || r.url || "").trim() || null;
|
||||
const groupName = (r.group || r.gruppe || "").trim();
|
||||
const groupId = groupName ? groupByName.get(groupName) ?? null : null;
|
||||
if (!targetUrl && !groupId) { errors.push({ row: i + 2, domain, error: "no_target" }); continue; }
|
||||
|
||||
const code = Number(r.redirect_code || r.code || 302);
|
||||
const preserve = ![ "0", "false", "nein", "no" ].includes((r.preserve_path || r.preserve || "1").toLowerCase());
|
||||
const incWww = ![ "0", "false", "nein", "no" ].includes((r.include_www || r.www || "1").toLowerCase());
|
||||
|
||||
try {
|
||||
if (db.prepare("SELECT id FROM domains WHERE domain = ?").get(domain)) {
|
||||
errors.push({ row: i + 2, domain, error: "exists" });
|
||||
continue;
|
||||
}
|
||||
insert.run(domain, targetUrl, groupId, [301,302,307,308].includes(code) ? code : 302, preserve ? 1 : 0, incWww ? 1 : 0, userId, now + i);
|
||||
imported++;
|
||||
} catch (e) {
|
||||
errors.push({ row: i + 2, domain, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
logAudit({ user_id: userId, user_email: session.user.email, action: "domain.import", target_type: "domain", target_id: String(imported), details: { imported, errors: errors.length } });
|
||||
return NextResponse.json({ imported, errors });
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { getServerSession } from "next-auth";
|
|||
import { authOptions } from "@/lib/auth";
|
||||
import { getSetting, setSetting } from "@/lib/db";
|
||||
|
||||
const PUBLIC_KEYS = ["base_domain", "admin_email", "update_auto", "update_include_prereleases"];
|
||||
const PUBLIC_KEYS = ["base_domain", "admin_email", "update_auto", "update_include_prereleases", "hits_retention_days", "webhook_url"];
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,37 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getDb } from "@/lib/db";
|
||||
import pkg from "../../../../package.json";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ ok: true, ts: Date.now() });
|
||||
const db = getDb();
|
||||
const since24h = Date.now() - 24 * 60 * 60 * 1000;
|
||||
|
||||
let dbSize = 0;
|
||||
try {
|
||||
const dbPath = path.join(process.env.NEXREDIRECT_DATA_DIR || path.join(process.cwd(), "data"), "nexredirect.db");
|
||||
dbSize = fs.statSync(dbPath).size;
|
||||
} catch {}
|
||||
|
||||
const totals = db.prepare(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM domains) AS total,
|
||||
(SELECT COUNT(*) FROM domains WHERE status='active') AS active,
|
||||
(SELECT COUNT(*) FROM domains WHERE status='pending') AS pending,
|
||||
(SELECT COUNT(*) FROM domains WHERE status='error') AS errored
|
||||
`).get() as { total: number; active: number; pending: number; errored: number };
|
||||
|
||||
const hits24h = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE ts > ?").get(since24h) as { n: number }).n;
|
||||
const blocklist = (db.prepare("SELECT COUNT(*) AS n FROM ip_blocklist WHERE expires_at > ?").get(Date.now()) as { n: number }).n;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
ts: Date.now(),
|
||||
version: pkg.version,
|
||||
domains: totals,
|
||||
hits_24h: hits24h,
|
||||
blocked_ips: blocklist,
|
||||
db_bytes: dbSize,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getDb, type DomainRow } from "@/lib/db";
|
||||
import { verifyPdfToken } from "@/lib/pdf-token";
|
||||
import { ReportClient } from "./ReportClient";
|
||||
|
||||
|
|
@ -13,6 +13,48 @@ export default async function PublicReportPage({ params }: { params: Promise<{ t
|
|||
const days = Math.min(365, Math.max(1, Number(p.days || 30)));
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const db = getDb();
|
||||
const isDomainReport = p.kind === "domain" && p.domain_id;
|
||||
|
||||
if (isDomainReport) {
|
||||
const did = Number(p.domain_id);
|
||||
const domain = db.prepare("SELECT * FROM domains WHERE id = ?").get(did) as DomainRow | undefined;
|
||||
if (!domain) notFound();
|
||||
|
||||
const totalHits = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE domain_id = ? AND ts > ?").get(did, since) as { n: number }).n;
|
||||
const uniqueIps = (db.prepare("SELECT COUNT(DISTINCT ip_hash) AS n FROM hits WHERE domain_id = ? AND ts > ?").get(did, since) as { n: number }).n;
|
||||
const daily = db.prepare(`SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits FROM hits WHERE domain_id = ? AND ts > ? GROUP BY day ORDER BY day`).all(did, since) as { day: string; hits: number }[];
|
||||
const country = db.prepare(`SELECT COALESCE(country,'??') AS country, COUNT(*) AS hits FROM hits WHERE domain_id = ? AND ts > ? GROUP BY country ORDER BY hits DESC`).all(did, since) as { country: string; hits: number }[];
|
||||
const recentHits = db.prepare(`SELECT h.ts, d.domain, h.country, h.path FROM hits h JOIN domains d ON d.id = h.domain_id WHERE h.domain_id = ? AND h.ts > ? ORDER BY h.ts DESC LIMIT 200`).all(did, since) as { ts: number; domain: string; country: string | null; path: string | null }[];
|
||||
|
||||
return (
|
||||
<ReportClient
|
||||
data={{
|
||||
days,
|
||||
sections: {
|
||||
summary: true,
|
||||
daily: true,
|
||||
top: false,
|
||||
country: country.length > 0,
|
||||
perDomain: false,
|
||||
dead: false,
|
||||
hits: true,
|
||||
title: typeof p.title === "string" ? p.title : `Report: ${domain.domain}`,
|
||||
},
|
||||
totalDomains: 1,
|
||||
activeDomains: domain.status === "active" ? 1 : 0,
|
||||
totalHits,
|
||||
uniqueIps,
|
||||
daily,
|
||||
top: [{ domain: domain.domain, hits: totalHits }],
|
||||
country,
|
||||
dead: [],
|
||||
perDomain: [],
|
||||
recentHits,
|
||||
generatedAt: Date.now(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = {
|
||||
summary: p.summary === "1",
|
||||
|
|
|
|||
35
lib/auth.ts
35
lib/auth.ts
|
|
@ -2,6 +2,17 @@ import type { NextAuthOptions } from "next-auth";
|
|||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { getDb, type UserRow } from "./db";
|
||||
import { checkLimit } from "./rate-limit";
|
||||
|
||||
function ipFromReq(req: unknown): string {
|
||||
if (!req || typeof req !== "object") return "unknown";
|
||||
const headers = (req as { headers?: Record<string, string | string[] | undefined> }).headers || {};
|
||||
const xff = headers["x-forwarded-for"];
|
||||
const xffStr = Array.isArray(xff) ? xff[0] : xff;
|
||||
if (xffStr) return xffStr.split(",")[0].trim();
|
||||
const sock = (req as { socket?: { remoteAddress?: string } }).socket;
|
||||
return sock?.remoteAddress || "unknown";
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
secret: process.env.NEXTAUTH_SECRET || "nexredirect-dev-secret-please-change",
|
||||
|
|
@ -14,15 +25,35 @@ export const authOptions: NextAuthOptions = {
|
|||
email: { label: "E-Mail", type: "email" },
|
||||
password: { label: "Passwort", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
async authorize(credentials, req) {
|
||||
if (!credentials?.email || !credentials?.password) return null;
|
||||
const ip = ipFromReq(req);
|
||||
|
||||
// Rate-limit: 8 attempts per IP per 5 minutes
|
||||
const ipLimit = checkLimit(`login:ip:${ip}`, 8, 5 * 60 * 1000);
|
||||
if (!ipLimit.allowed) {
|
||||
console.warn(`[auth] rate-limited login from ${ip} (retry in ${ipLimit.retryAfterSec}s)`);
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
return null;
|
||||
}
|
||||
|
||||
const email = credentials.email.toLowerCase().trim();
|
||||
// Per-email attempt limit: 5 in 15 minutes (slows targeted brute-force)
|
||||
const emailLimit = checkLimit(`login:email:${email}`, 5, 15 * 60 * 1000);
|
||||
if (!emailLimit.allowed) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = getDb()
|
||||
.prepare("SELECT id, email, password_hash, role, created_at FROM users WHERE email = ? LIMIT 1")
|
||||
.get(email) as UserRow | undefined;
|
||||
|
||||
if (!user) return null;
|
||||
if (!user) {
|
||||
// Constant-time delay to prevent user-enumeration timing
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return null;
|
||||
}
|
||||
const valid = await bcrypt.compare(credentials.password, user.password_hash);
|
||||
if (!valid) return null;
|
||||
|
||||
|
|
|
|||
21
lib/blocklist.ts
Normal file
21
lib/blocklist.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { getDb } from "./db";
|
||||
|
||||
export function isBlocked(ipHash: string): boolean {
|
||||
const row = getDb().prepare("SELECT 1 FROM ip_blocklist WHERE ip_hash = ? AND expires_at > ?").get(ipHash, Date.now());
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function block(ipHash: string, hours = 24, reason = "scanner") {
|
||||
const expiresAt = Date.now() + hours * 60 * 60 * 1000;
|
||||
getDb()
|
||||
.prepare("INSERT INTO ip_blocklist (ip_hash, blocked_at, expires_at, reason) VALUES (?, ?, ?, ?) ON CONFLICT(ip_hash) DO UPDATE SET expires_at = excluded.expires_at, reason = excluded.reason")
|
||||
.run(ipHash, Date.now(), expiresAt, reason);
|
||||
}
|
||||
|
||||
export function unblock(ipHash: string) {
|
||||
getDb().prepare("DELETE FROM ip_blocklist WHERE ip_hash = ?").run(ipHash);
|
||||
}
|
||||
|
||||
export function listBlocked(): { ip_hash: string; blocked_at: number; expires_at: number; reason: string }[] {
|
||||
return getDb().prepare("SELECT ip_hash, blocked_at, expires_at, reason FROM ip_blocklist WHERE expires_at > ? ORDER BY blocked_at DESC").all(Date.now()) as { ip_hash: string; blocked_at: number; expires_at: number; reason: string }[];
|
||||
}
|
||||
12
lib/caddy.ts
12
lib/caddy.ts
|
|
@ -25,10 +25,22 @@ export function buildCaddyfile(): string {
|
|||
lines.push(`}`);
|
||||
lines.push(``);
|
||||
|
||||
const adminHeaders = [
|
||||
` header {`,
|
||||
` Strict-Transport-Security "max-age=31536000; includeSubDomains"`,
|
||||
` X-Content-Type-Options "nosniff"`,
|
||||
` X-Frame-Options "DENY"`,
|
||||
` Referrer-Policy "strict-origin-when-cross-origin"`,
|
||||
` Permissions-Policy "geolocation=(), microphone=(), camera=()"`,
|
||||
` -Server`,
|
||||
` }`,
|
||||
];
|
||||
|
||||
// Admin-UI
|
||||
const adminHosts: string[] = [":80"];
|
||||
if (baseDomain) adminHosts.push(baseDomain);
|
||||
lines.push(`${adminHosts.join(", ")} {`);
|
||||
lines.push(...adminHeaders);
|
||||
lines.push(` reverse_proxy localhost:${APP_PORT}`);
|
||||
lines.push(`}`);
|
||||
lines.push(``);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { getDb, hashIp } from "./db";
|
||||
import { lookupCountry } from "./geo";
|
||||
import { block } from "./blocklist";
|
||||
|
||||
// Patterns we don't want polluting analytics
|
||||
const BOT_UA = /bot|crawl|spider|slurp|curl|wget|httpclient|python-requests|axios|node-fetch|monitor|uptime|pingdom|datadog|prometheus|scanner|fetch|preview|whatsapp|telegrambot|facebookexternalhit|linkedinbot|twitterbot|discordbot|skypeuripreview|mastodon|matrix-bot|preconnect|dnsperf|sentry|newrelic|gtmetrix|lighthouse|headlesschrome|phantomjs|puppeteer|playwright|chrome-lighthouse|go-http-client|java\/|okhttp|libwww|mechanize|nikto|sqlmap|nmap|masscan|zgrab|nuclei|acunetix|netcraft|expanse|censys|shodan|fuzz|burp|arachni|w3af|nikto|wpscan|gobuster|ffuf|dirb|dirbuster/i;
|
||||
|
|
@ -93,6 +94,8 @@ const SCAN_WINDOW_MS = 30_000;
|
|||
type Tracker = { paths: Set<string>; firstSeen: number };
|
||||
const ipTracker = new Map<string, Tracker>();
|
||||
|
||||
const PERSIST_BLOCK_THRESHOLD = 12; // sustained burst → DB blocklist (24h)
|
||||
|
||||
function ipScanCheck(ipHash: string, path: string): boolean {
|
||||
const now = Date.now();
|
||||
const cur = ipTracker.get(ipHash);
|
||||
|
|
@ -101,6 +104,9 @@ function ipScanCheck(ipHash: string, path: string): boolean {
|
|||
return false;
|
||||
}
|
||||
cur.paths.add(path);
|
||||
if (cur.paths.size === PERSIST_BLOCK_THRESHOLD) {
|
||||
try { block(ipHash, 24, "scanner_burst"); } catch {}
|
||||
}
|
||||
if (cur.paths.size > SCAN_THRESHOLD) return true;
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
90
lib/jobs.ts
Normal file
90
lib/jobs.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Boot-time background job runner. Started once from server.ts after Next.js prepare().
|
||||
import { getDb, getSetting } from "./db";
|
||||
import { checkDomainDns } from "./dns";
|
||||
import { reloadCaddy } from "./caddy";
|
||||
import { invalidateRedirectCache } from "./redirect-resolver";
|
||||
|
||||
let started = false;
|
||||
const timers: NodeJS.Timeout[] = [];
|
||||
|
||||
function schedule(fn: () => void | Promise<void>, intervalMs: number, immediate = false) {
|
||||
if (immediate) Promise.resolve(fn()).catch((e) => console.error("[job] error", e));
|
||||
const t = setInterval(() => {
|
||||
Promise.resolve(fn()).catch((e) => console.error("[job] error", e));
|
||||
}, intervalMs);
|
||||
t.unref?.();
|
||||
timers.push(t);
|
||||
}
|
||||
|
||||
async function pruneHits() {
|
||||
const days = Number(getSetting("hits_retention_days") || 365);
|
||||
if (!Number.isFinite(days) || days <= 0) return;
|
||||
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const result = getDb().prepare("DELETE FROM hits WHERE ts < ?").run(cutoff);
|
||||
if (result.changes > 0) {
|
||||
console.log(`[job:hits-retention] pruned ${result.changes} hits older than ${days}d`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneAuditLog() {
|
||||
// keep last 5000 entries
|
||||
getDb().exec(`DELETE FROM audit_log WHERE id NOT IN (SELECT id FROM audit_log ORDER BY id DESC LIMIT 5000)`);
|
||||
}
|
||||
|
||||
async function dnsHealthCheck() {
|
||||
const db = getDb();
|
||||
const rows = db.prepare("SELECT id, domain, include_www, status FROM domains WHERE status IN ('active','error')").all() as { id: number; domain: string; include_www: number; status: string }[];
|
||||
let changed = 0;
|
||||
for (const d of rows) {
|
||||
try {
|
||||
const r = await checkDomainDns(d.domain, !!d.include_www);
|
||||
if (r.ok && d.status !== "active") {
|
||||
db.prepare("UPDATE domains SET status='active', verified_at=? WHERE id=?").run(Date.now(), d.id);
|
||||
changed++;
|
||||
} else if (!r.ok && d.status === "active") {
|
||||
db.prepare("UPDATE domains SET status='error' WHERE id=?").run(d.id);
|
||||
changed++;
|
||||
}
|
||||
} catch {
|
||||
// ignore individual lookup failures
|
||||
}
|
||||
}
|
||||
if (changed > 0) {
|
||||
invalidateRedirectCache();
|
||||
reloadCaddy().catch(() => {});
|
||||
console.log(`[job:dns-health] status changed for ${changed} domains`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneIpBlocklist() {
|
||||
// Auto-expire entries older than 24h
|
||||
getDb().prepare("DELETE FROM ip_blocklist WHERE expires_at < ?").run(Date.now());
|
||||
}
|
||||
|
||||
export function startJobs() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
// Hits retention: daily
|
||||
schedule(pruneHits, DAY);
|
||||
// Audit-log retention: daily
|
||||
schedule(pruneAuditLog, DAY);
|
||||
// DNS health check: every 6h, first run after 5min boot to give DNS time
|
||||
setTimeout(() => {
|
||||
dnsHealthCheck().catch(() => {});
|
||||
schedule(dnsHealthCheck, 6 * HOUR);
|
||||
}, 5 * 60 * 1000);
|
||||
// IP blocklist cleanup: hourly
|
||||
schedule(pruneIpBlocklist, HOUR);
|
||||
|
||||
console.log("[jobs] background jobs started");
|
||||
}
|
||||
|
||||
export function stopJobs() {
|
||||
for (const t of timers) clearInterval(t);
|
||||
timers.length = 0;
|
||||
started = false;
|
||||
}
|
||||
31
lib/rate-limit.ts
Normal file
31
lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// In-memory token bucket. Simple per-key sliding limit.
|
||||
|
||||
type Bucket = { count: number; resetAt: number };
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
if (typeof setInterval !== "undefined") {
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of buckets.entries()) {
|
||||
if (v.resetAt < now) buckets.delete(k);
|
||||
}
|
||||
}, 60_000).unref?.();
|
||||
}
|
||||
|
||||
export function checkLimit(key: string, limit: number, windowMs: number): { allowed: boolean; retryAfterSec?: number } {
|
||||
const now = Date.now();
|
||||
const cur = buckets.get(key);
|
||||
if (!cur || cur.resetAt < now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { allowed: true };
|
||||
}
|
||||
cur.count++;
|
||||
if (cur.count > limit) {
|
||||
return { allowed: false, retryAfterSec: Math.ceil((cur.resetAt - now) / 1000) };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
export function resetLimit(key: string) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
16
lib/webhook.ts
Normal file
16
lib/webhook.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { getSetting } from "./db";
|
||||
|
||||
export async function fireWebhook(event: string, payload: Record<string, unknown>): Promise<void> {
|
||||
const url = getSetting("webhook_url");
|
||||
if (!url) return;
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "User-Agent": "corex-nexredirect" },
|
||||
body: JSON.stringify({ event, ts: Date.now(), payload }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[webhook] failed", e);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import next from "next";
|
|||
import { resolveHost, isAdminHost } from "./lib/redirect-resolver";
|
||||
import { recordHit, shouldRecord } from "./lib/hits";
|
||||
import { renderSunsetPage } from "./lib/sunset-html";
|
||||
import { isBlocked } from "./lib/blocklist";
|
||||
import { startJobs } from "./lib/jobs";
|
||||
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
const port = parseInt(process.env.PORT || "3000", 10);
|
||||
|
|
@ -19,6 +21,7 @@ const app = next({ dev, hostname, port });
|
|||
const handle = app.getRequestHandler();
|
||||
|
||||
app.prepare().then(() => {
|
||||
startJobs();
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const host = (req.headers.host || "").split(":")[0].toLowerCase();
|
||||
|
|
@ -35,6 +38,12 @@ app.prepare().then(() => {
|
|||
// Hash IP early so we can use it for the scan-detector check
|
||||
const { hashIp } = await import("./lib/db");
|
||||
const ipHash = hashIp(ip);
|
||||
// Persistent blocklist: drop without recording or redirecting
|
||||
if (isBlocked(ipHash)) {
|
||||
res.writeHead(403, { "Content-Type": "text/plain" });
|
||||
res.end("Blocked");
|
||||
return;
|
||||
}
|
||||
const signals = {
|
||||
accept: (req.headers["accept"] as string) || null,
|
||||
acceptLanguage: (req.headers["accept-language"] as string) || null,
|
||||
|
|
|
|||
Loading…
Reference in a new issue