v0.1.30 — fix: username-login; feature: IP-allowlist für Admin-UI

- fix: username-Spalte in DB-DDL ergänzt + Migration für Bestandsdatenbanken;
  createSchema in /api/users speichert username jetzt korrekt (war immer NULL)
- feature: IP-Allowlist für Admin-UI — IPs/CIDR-Bereiche in Einstellungen
  konfigurierbar; Enforcement in server.ts vor Next.js-Handoff; /api/v1 bleibt
  offen; Lockout-Warnung wenn eigene IP nicht in der Liste
This commit is contained in:
Hendrik Garske 2026-05-06 19:43:41 +02:00
parent a34fa9bfa8
commit ee3a72ce50
7 changed files with 215 additions and 8 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2, RefreshCcw, ArrowUpCircle, Globe2, CheckCircle2, Trash2, Mail, Send, Pencil, Server, Bell } from "lucide-react";
import { Loader2, RefreshCcw, ArrowUpCircle, Globe2, CheckCircle2, Trash2, Mail, Send, Pencil, Server, Bell, ShieldCheck, AlertTriangle } from "lucide-react";
import { PageHeader } from "@/components/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@ -18,6 +18,8 @@ type Settings = {
webhook_url: string | null;
};
type IpAllowlist = { allowlist: string[]; my_ip: string };
type UpdateStatus = {
current: string;
latest: string | null;
@ -41,6 +43,7 @@ export default function SettingsPage() {
const [status, setStatus] = useState<UpdateStatus | null>(null);
const [geo, setGeo] = useState<{ available: boolean; path: string } | null>(null);
const [smtp, setSmtp] = useState<Smtp | null>(null);
const [ipAllowlist, setIpAllowlist] = useState<IpAllowlist | null>(null);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
@ -50,13 +53,15 @@ export default function SettingsPage() {
const [smtpOpen, setSmtpOpen] = useState(false);
const [geoOpen, setGeoOpen] = useState(false);
const [notifyOpen, setNotifyOpen] = useState(false);
const [ipOpen, setIpOpen] = useState(false);
async function load() {
const [s, u, g, m] = await Promise.all([
const [s, u, g, m, ip] = await Promise.all([
fetch("/api/settings").then((r) => r.json()),
fetch("/api/update/check").then((r) => r.json()),
fetch("/api/settings/geo").then((r) => r.json()),
fetch("/api/settings/smtp").then((r) => r.json()),
fetch("/api/settings/ip-allowlist").then((r) => r.json()),
]);
setSettings(s);
setStatus(u);
@ -69,6 +74,7 @@ export default function SettingsPage() {
smtp_from: m.smtp_from || "",
smtp_secure: m.smtp_secure || "false",
});
setIpAllowlist(ip as IpAllowlist);
}
useEffect(() => { load(); }, []);
@ -119,7 +125,7 @@ export default function SettingsPage() {
}
}
if (!settings || !status || !geo || !smtp) {
if (!settings || !status || !geo || !smtp || !ipAllowlist) {
return <div className="flex justify-center p-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
}
@ -238,6 +244,32 @@ export default function SettingsPage() {
</CardContent>
</Card>
{/* IP-Allowlist */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4" />IP-Zugriffsbeschränkung
{ipAllowlist.allowlist.length > 0
? <Badge variant="green"><CheckCircle2 className="mr-1 h-3 w-3" />{ipAllowlist.allowlist.length} Einträge</Badge>
: <Badge variant="zinc">offen</Badge>}
</CardTitle>
<CardDescription>Admin-UI nur für bestimmte IPs / CIDR-Bereiche</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<Row k="Deine IP"><span className="font-mono text-xs">{ipAllowlist.my_ip}</span></Row>
{ipAllowlist.allowlist.length > 0 ? (
<Row k="Freigegeben">
<span className="font-mono text-xs text-right">{ipAllowlist.allowlist.slice(0, 2).join(", ")}{ipAllowlist.allowlist.length > 2 ? ` +${ipAllowlist.allowlist.length - 2}` : ""}</span>
</Row>
) : (
<p className="text-xs text-muted-foreground">Kein Filter alle IPs haben Zugriff.</p>
)}
<Button onClick={() => setIpOpen(true)} variant="outline" size="sm" className="w-full">
<Pencil className="mr-2 h-3 w-3" />Bearbeiten
</Button>
</CardContent>
</Card>
{/* Benachrichtigungen / Retention */}
<Card className="lg:col-span-2">
<CardHeader>
@ -258,6 +290,7 @@ export default function SettingsPage() {
<SmtpDialog open={smtpOpen} onClose={() => setSmtpOpen(false)} initial={smtp} onSaved={load} />
<GeoDialog open={geoOpen} onClose={() => setGeoOpen(false)} status={geo} onChanged={load} />
<NotifyDialog open={notifyOpen} onClose={() => setNotifyOpen(false)} settings={settings} onSave={saveSettings} />
<IpAllowlistDialog open={ipOpen} onClose={() => setIpOpen(false)} initial={ipAllowlist} onSaved={load} />
</div>
);
}
@ -420,6 +453,69 @@ function GeoDialog({ open, onClose, status, onChanged }: { open: boolean; onClos
);
}
function IpAllowlistDialog({ open, onClose, initial, onSaved }: { open: boolean; onClose: () => void; initial: IpAllowlist; onSaved: () => void }) {
const [text, setText] = useState(initial.allowlist.join("\n"));
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState("");
useEffect(() => { if (open) { setText(initial.allowlist.join("\n")); setMsg(""); } }, [open, initial]);
const parsed = text.split("\n").map((l) => l.trim()).filter(Boolean);
const myIpIncluded = parsed.length === 0 || parsed.some((e) => e === initial.my_ip || initial.my_ip.startsWith(e.split("/")[0]));
async function save() {
setBusy(true); setMsg("");
try {
const r = await fetch("/api/settings/ip-allowlist", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ allowlist: parsed }) });
const d = await r.json();
if (!r.ok) { setMsg(`Fehler: ${d.error}`); return; }
onSaved();
onClose();
} finally { setBusy(false); }
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>IP-Zugriffsbeschränkung</DialogTitle>
<DialogDescription>Eine IP oder CIDR pro Zeile. Leer = alle IPs erlaubt. Gilt für die gesamte Admin-UI (außer <code>/api/v1</code>).</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-md border border-zinc-700/50 bg-zinc-900/40 px-3 py-2 text-xs flex items-center gap-2">
<span className="text-muted-foreground">Deine IP:</span>
<code className="font-mono">{initial.my_ip}</code>
</div>
{!myIpIncluded && (
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
<AlertTriangle className="h-3 w-3 mt-0.5 shrink-0" />
<span>Deine IP ist nicht in der Liste du sperrst dich selbst aus!</span>
</div>
)}
<div className="space-y-1">
<Label className="text-xs">IPs / CIDR-Bereiche (eine pro Zeile)</Label>
<textarea
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs font-mono text-zinc-100 placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-cyan-500 resize-none"
rows={6}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={"192.168.1.0/24\n10.0.0.1\n2001:db8::1"}
/>
<p className="text-[11px] text-muted-foreground">{parsed.length === 0 ? "Kein Filter — alle IPs haben Zugriff." : `${parsed.length} Eintrag/Einträge aktiv.`}</p>
</div>
{msg && <p className="text-xs text-red-400">{msg}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>Abbrechen</Button>
<Button onClick={save} disabled={busy} variant={!myIpIncluded && parsed.length > 0 ? "destructive" : "default"}>
{busy ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : null}
{!myIpIncluded && parsed.length > 0 ? "Trotzdem speichern" : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function NotifyDialog({ open, onClose, settings, onSave }: { open: boolean; onClose: () => void; settings: Settings; onSave: (p: Partial<Settings>) => Promise<void> }) {
const [retention, setRetention] = useState(settings.hits_retention_days ?? "365");
const [webhook, setWebhook] = useState(settings.webhook_url ?? "");

View file

@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSetting, setSetting, logAudit } from "@/lib/db";
import { requireAdmin } from "@/lib/auth-helpers";
export async function GET(req: Request) {
const u = await requireAdmin();
if (u instanceof NextResponse) return u;
const raw = getSetting("admin_ip_allowlist");
const allowlist: string[] = raw ? (JSON.parse(raw) as string[]) : [];
const xff = req.headers.get("x-forwarded-for");
const myIp = xff ? xff.split(",")[0].trim() : "unknown";
return NextResponse.json({ allowlist, my_ip: myIp });
}
const schema = z.object({
allowlist: z.array(z.string().min(1)).max(200),
});
export async function PUT(req: Request) {
const u = await requireAdmin();
if (u instanceof NextResponse) return u;
const body = await req.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "invalid", details: parsed.error.flatten() }, { status: 400 });
const cleaned = parsed.data.allowlist.map((s) => s.trim()).filter(Boolean);
setSetting("admin_ip_allowlist", JSON.stringify(cleaned));
logAudit({ user_id: Number(u.id), user_email: u.email, action: "settings.ip_allowlist.update", details: { count: cleaned.length } });
return NextResponse.json({ ok: true });
}

View file

@ -8,12 +8,13 @@ import { validatePassword } from "@/lib/passwords";
export async function GET() {
const u = await requireAdmin();
if (u instanceof NextResponse) return u;
const rows = getDb().prepare("SELECT id, email, role, created_at FROM users ORDER BY created_at").all();
const rows = getDb().prepare("SELECT id, email, username, role, created_at FROM users ORDER BY created_at").all();
return NextResponse.json({ users: rows });
}
const createSchema = z.object({
email: z.string().email().transform((s) => s.toLowerCase().trim()),
username: z.string().regex(/^[a-zA-Z0-9_-]+$/).min(3).max(40).optional(),
password: z.string().min(10),
role: z.enum(["admin", "user"]).default("user"),
});
@ -30,11 +31,14 @@ export async function POST(req: Request) {
if (db.prepare("SELECT id FROM users WHERE email = ?").get(parsed.data.email)) {
return NextResponse.json({ error: "email_exists" }, { status: 409 });
}
if (parsed.data.username && db.prepare("SELECT id FROM users WHERE username = ?").get(parsed.data.username)) {
return NextResponse.json({ error: "username_exists" }, { status: 409 });
}
const pwdCheck = await validatePassword(parsed.data.password);
if (!pwdCheck.ok) return NextResponse.json({ error: "weak_password", reason: pwdCheck.reason }, { status: 400 });
const hash = await bcrypt.hash(parsed.data.password, 12);
const result = db.prepare("INSERT INTO users (email, password_hash, role, created_at) VALUES (?, ?, ?, ?)").run(parsed.data.email, hash, parsed.data.role, Date.now());
const row = db.prepare("SELECT id, email, role, created_at FROM users WHERE id = ?").get(result.lastInsertRowid) as UserRow;
const result = db.prepare("INSERT INTO users (email, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)").run(parsed.data.email, parsed.data.username ?? null, hash, parsed.data.role, Date.now());
const row = db.prepare("SELECT id, email, username, role, created_at FROM users WHERE id = ?").get(result.lastInsertRowid) as UserRow;
logAudit({ user_id: Number(u.id), user_email: u.email, action: "user.create", target_type: "user", target_id: row.id, details: { email: row.email, role: row.role } });
return NextResponse.json({ user: row }, { status: 201 });
}

View file

@ -24,6 +24,7 @@ function ensureSchema(db: Database.Database) {
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
username TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
created_at INTEGER NOT NULL
@ -158,6 +159,12 @@ function runMigrations(db: Database.Database) {
db.exec("ALTER TABLE domains ADD COLUMN sunset_config TEXT");
}
setSettingDirect(db, "m_sunset_column", "done");
// username column on users table
if (!hasColumn(db, "users", "username")) {
db.exec("ALTER TABLE users ADD COLUMN username TEXT");
try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username) WHERE username IS NOT NULL"); } catch {}
}
}
export function getSetting(key: string): string | null {

50
lib/ip-allowlist.ts Normal file
View file

@ -0,0 +1,50 @@
import { isIPv4 } from "net";
function normalizeIp(ip: string): string {
if (ip === "::1") return "127.0.0.1";
if (ip.startsWith("::ffff:")) {
const v4 = ip.slice(7);
if (isIPv4(v4)) return v4;
}
return ip;
}
function ipv4ToNum(ip: string): number {
return ip.split(".").reduce((acc, p) => acc * 256 + parseInt(p, 10), 0);
}
function isInCidr4(ip: string, cidr: string): boolean {
const slash = cidr.indexOf("/");
if (slash === -1) return ip === cidr;
const base = cidr.slice(0, slash);
const prefix = parseInt(cidr.slice(slash + 1), 10);
if (isNaN(prefix) || prefix < 0 || prefix > 32) return false;
if (!isIPv4(base) || !isIPv4(ip)) return false;
const shift = 32 - prefix;
return (ipv4ToNum(ip) >>> shift) === (ipv4ToNum(base) >>> shift);
}
export function parseAllowlist(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
return (parsed as unknown[])
.filter((s): s is string => typeof s === "string" && s.trim() !== "")
.map((s) => s.trim());
}
} catch {}
return [];
}
export function isIpAllowed(rawIp: string, allowlist: string[]): boolean {
if (allowlist.length === 0) return true;
const ip = normalizeIp(rawIp);
for (const entry of allowlist) {
if (entry === ip || entry === rawIp) return true;
if (entry.includes("/") && isIPv4(ip) && isIPv4(entry.split("/")[0])) {
if (isInCidr4(ip, entry)) return true;
}
}
return false;
}

View file

@ -1,6 +1,6 @@
{
"name": "corex-nexredirect",
"version": "0.1.29",
"version": "0.1.30",
"license": "MIT",
"overrides": {
"postcss": "^8.5.13",

View file

@ -12,7 +12,8 @@ import { recordHit, shouldRecord } from "./lib/hits";
import { renderSunsetPage } from "./lib/sunset-html";
import { isBlocked } from "./lib/blocklist";
import { startJobs } from "./lib/jobs";
import { hashIp } from "./lib/db";
import { hashIp, getSetting } from "./lib/db";
import { parseAllowlist, isIpAllowed } from "./lib/ip-allowlist";
const dev = process.env.NODE_ENV !== "production";
const port = parseInt(process.env.PORT || "3000", 10);
@ -100,6 +101,25 @@ app.prepare().then(() => {
return;
}
// IP allowlist for admin UI (skips /api/v1 public API)
const reqPath = parsedUrl.pathname || "/";
if (!reqPath.startsWith("/api/v1")) {
const allowlist = parseAllowlist(getSetting("admin_ip_allowlist"));
if (allowlist.length > 0) {
const clientIp =
((req.headers["x-forwarded-for"] || "") as string).split(",")[0].trim() ||
req.socket.remoteAddress ||
"unknown";
if (!isIpAllowed(clientIp, allowlist)) {
res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" });
res.end(
`<!doctype html><html><head><title>403 Forbidden</title><style>body{background:#0a0c10;color:#e5e7eb;font-family:ui-monospace,monospace;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}</style></head><body><div style="text-align:center"><h1 style="color:#f87171">403 Forbidden</h1><p>Deine IP-Adresse (<code>${clientIp}</code>) ist nicht in der Zugriffsliste.</p></div></body></html>`
);
return;
}
}
}
await handle(req, res, parsedUrl);
} catch (err) {
console.error("[server] error", err);