Throttle login attempts and lock admin actions to privileged sessions
This commit is contained in:
253
README.md
253
README.md
@@ -1,74 +1,64 @@
|
||||
# Caddy Proxy Manager
|
||||
|
||||
[https://caddyproxymanager.com](https://caddyproxymanager.com)
|
||||
An admin-only control plane for driving the Caddy admin API. Manage reverse proxies, redirects, maintenance pages, certificates, and supporting access-control lists with a modern Next.js 16 dashboard.
|
||||
|
||||
Caddy Proxy Manager is a modern control panel for Caddy that simplifies reverse proxy configuration, TLS automation, access control, and observability. The stack is built with Next.js 16 (App Router), Material UI, and a lightweight SQLite data layer. It features simple username/password authentication, first-class Caddy admin API integration, and tooling for Cloudflare DNS challenge automation.
|
||||
---
|
||||
|
||||
## Highlights
|
||||
## Project Status
|
||||
|
||||
- **Next.js 16 App Router** – server components for data loading, client components for interactivity, and a unified API surface.
|
||||
- **Material UI dark mode** – fast, responsive dashboard with ready-made components and accessibility baked in.
|
||||
- **Simple authentication** – environment-based username/password login configured via docker-compose.
|
||||
- **End-to-end Caddy orchestration** – generate JSON for HTTP(S) proxies, redirects, 404 hosts via the Caddy admin API.
|
||||
- **Cloudflare DNS challenge support** – xcaddy build bundles the `cloudflare` DNS and `layer4` modules; credentials are configurable in the UI.
|
||||
- **Security-by-default** – HSTS (`Strict-Transport-Security: max-age=63072000`) applied to every managed host.
|
||||
- **Embedded audit log** – every configuration change is recorded with actor, summary, and timestamp.
|
||||
- **Deployment model:** single administrative user (configured via environment variables)
|
||||
- **Authentication:** credentials flow rate-limited to 5 attempts / 5 minutes with a 15 minute cooldown after repeated failures
|
||||
- **Authorization:** all mutating actions require admin privileges; read-only pages stay accessible to the authenticated session
|
||||
- **Secrets management:** Cloudflare API tokens are accepted through the UI but never rendered back to the browser; existing tokens can be revoked explicitly
|
||||
- **Known limitation:** Imported certificates are stored in SQLite without encryption (planned improvement)
|
||||
|
||||
## Project Structure
|
||||
---
|
||||
|
||||
## Feature Highlights
|
||||
|
||||
- **Next.js 16 App Router** – hybrid server/client rendering, server actions, and streaming layouts
|
||||
- **Material UI** – responsive dark-themed dashboard with polished defaults
|
||||
- **Caddy integration** – generates JSON and pushes it directly to the Caddy admin API for proxies, redirects, and dead/maintenance responses
|
||||
- **Certificate lifecycle** – manage ACME (Cloudflare DNS-01) or import PEM certificates; certificates written to disk with restrictive permissions
|
||||
- **Access control** – bcrypt-backed HTTP basic-auth lists with assignment to proxy hosts
|
||||
- **Observability** – built-in audit log records actor, action, and summary for every change
|
||||
- **Security defaults** – strict session secret enforcement, mandatory credential rotation in production, HSTS injection for managed hosts, login throttling, and admin-only mutations
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
.
|
||||
├── app/ # Next.js App Router entrypoint (layouts, routes, server actions)
|
||||
│ ├── (auth)/ # Login flow
|
||||
│ ├── (dashboard)/ # Dashboard layout, feature surface, client renderers
|
||||
│ ├── api/ # Route handlers for auth callbacks/logout
|
||||
│ ├── providers.tsx # Global MUI theme + CssBaseline
|
||||
│ └── layout.tsx # Root HTML/body wrapper
|
||||
├── app/ # Next.js App Router (layouts, routes, server actions)
|
||||
│ ├── (auth)/ # Login experience
|
||||
│ ├── (dashboard)/ # Dashboard layout, feature modules, client renderers
|
||||
│ ├── api/ # NextAuth handlers and health probe
|
||||
│ ├── globals.css # Global styles
|
||||
│ └── providers.tsx # MUI theme provider
|
||||
├── src/
|
||||
│ └── lib/ # SQLite integration, models, Caddy config builder
|
||||
│ └── lib/ # Prisma client, domain models, Caddy config builder, helpers
|
||||
├── prisma/ # Prisma schema
|
||||
├── docker/
|
||||
│ ├── web/ # Next.js production image (standalone output)
|
||||
│ ├── web/ # Next.js production image
|
||||
│ └── caddy/ # xcaddy build with Cloudflare + layer4 modules
|
||||
├── docker-compose.yml # Multi-container deployment (Next.js app + Caddy)
|
||||
├── data/ # Generated at runtime (SQLite DB, cert storage, Caddy state)
|
||||
└── README.md # You are here
|
||||
├── docker-compose.yml # Sample two-container deployment (Next.js + Caddy)
|
||||
└── data/ # Runtime SQLite database and certificate output
|
||||
```
|
||||
|
||||
### Dashboard Modules
|
||||
### Dashboard Surface
|
||||
|
||||
- `ProxyHostsClient.tsx` – create/update/delete HTTP(S) reverse proxies, assign certs/access lists.
|
||||
- `RedirectsClient.tsx` – manage 301/302 redirects with optional path/query preservation.
|
||||
- `DeadHostsClient.tsx` – serve custom offline pages with programmable status codes.
|
||||
- `AccessListsClient.tsx` – manage HTTP basic auth credentials and membership.
|
||||
- `CertificatesClient.tsx` – import PEMs or request managed ACME certificates.
|
||||
- `SettingsClient.tsx` – general metadata and Cloudflare DNS token configuration.
|
||||
- `AuditLogClient.tsx` – list chronological administrative activity.
|
||||
| Module | Purpose |
|
||||
| --- | --- |
|
||||
| Proxy Hosts | Configure HTTP(S) reverse proxies, upstream pools, headers, and Authentik forward auth support |
|
||||
| Redirects | Define 301/302 redirects with optional query preservation |
|
||||
| Dead Hosts | Serve branded maintenance responses with custom status codes |
|
||||
| Access Lists | Create & assign HTTP basic-auth user lists |
|
||||
| Certificates | Request ACME-managed or import custom PEM certificates |
|
||||
| Settings | Configure primary domain, ACME email, and Cloudflare DNS automation |
|
||||
| Audit Log | Review a chronological feed of administrative actions |
|
||||
|
||||
## Feature Overview
|
||||
|
||||
### Authentication & Authorization
|
||||
- Simple username/password authentication configured via environment variables.
|
||||
- Credentials set in docker-compose or `.env` file.
|
||||
- Session persistence via signed JWT tokens.
|
||||
|
||||
### Reverse Proxy Management
|
||||
- HTTP(S) proxy hosts with TLS enforcement, WebSocket + HTTP/2 toggles.
|
||||
- Redirect hosts with custom status codes and query preservation.
|
||||
- Dead/maintenance hosts with custom responses.
|
||||
- Access list (basic auth) integration for protected hosts.
|
||||
- TLS certificate lifecycle: managed ACME (DNS-01 via Cloudflare) or imported PEMs.
|
||||
|
||||
### Operations & Observability
|
||||
- Full audit log with actor/action/summary/time.
|
||||
- One-click revalidation of Caddy configuration after mutations.
|
||||
- Migrations run automatically on startup; upgrades are seamless.
|
||||
- Docker-first deployment, HSTS defaults, Cloudflare DNS automation.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20+ (development)
|
||||
- Docker + Docker Compose v2 (deployment)
|
||||
- Optional: Cloudflare DNS API token for automated certificate issuance
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -80,112 +70,125 @@ Caddy Proxy Manager is a modern control panel for Caddy that simplifies reverse
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Create environment file**
|
||||
2. **Configure environment**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your admin credentials:
|
||||
Set secure values:
|
||||
```env
|
||||
ADMIN_USERNAME=your-username
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
SESSION_SECRET=your-random-secret-here
|
||||
ADMIN_USERNAME=your-admin
|
||||
ADMIN_PASSWORD=your-strong-password
|
||||
SESSION_SECRET=$(openssl rand -base64 32)
|
||||
```
|
||||
|
||||
3. **Run the development server**
|
||||
3. **Run Prisma client generation (optional in dev)**
|
||||
|
||||
```bash
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
4. **Start the dev server**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. **Login**
|
||||
5. **Login**
|
||||
|
||||
- Visit `http://localhost:3000/login`
|
||||
- Enter your configured username and password
|
||||
- You're now logged in as administrator
|
||||
- Navigate to `http://localhost:3000/login`
|
||||
- Enter the configured credentials (remember that failed attempts are throttled)
|
||||
|
||||
5. **Configure Cloudflare DNS (optional)**
|
||||
### Docker Compose
|
||||
|
||||
- Navigate to **Settings → Cloudflare DNS**.
|
||||
- Provide an API token with `Zone.DNS:Edit` scope and the relevant zone/account IDs.
|
||||
- Any managed certificates attached to hosts will now request TLS via DNS validation.
|
||||
The bundled `docker-compose.yml` spins up:
|
||||
|
||||
### Production Deployment
|
||||
|
||||
## Docker Compose
|
||||
|
||||
`docker-compose.yml` defines a two-container stack:
|
||||
|
||||
- `web`: Next.js server with SQLite database and certificate store in `/data`.
|
||||
- `caddy`: xcaddy-built binary with Cloudflare DNS provider and layer4 modules.
|
||||
|
||||
Launch the stack:
|
||||
- `web`: Next.js standalone output (Node 20) with SQLite in `/app/data`
|
||||
- `caddy`: xcaddy-built binary with Cloudflare DNS & layer4 modules enabled
|
||||
|
||||
```bash
|
||||
# Create .env file with your credentials
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env and set secure values for:
|
||||
# - ADMIN_USERNAME
|
||||
# - ADMIN_PASSWORD
|
||||
# - SESSION_SECRET
|
||||
|
||||
# Start the containers
|
||||
cp .env.example .env # set ADMIN_*/SESSION_SECRET values
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
Volumes:
|
||||
|
||||
**Required (Security):**
|
||||
- `./data` → `/app/data` (SQLite database & imported cert material)
|
||||
- `./caddy-data` (Caddy ACME storage)
|
||||
- `./caddy-config` (Caddy runtime config state)
|
||||
|
||||
- `SESSION_SECRET`: Random 32+ character string used to sign session tokens. Generate with: `openssl rand -base64 32`
|
||||
- `ADMIN_USERNAME`: Username for admin login (default: `admin`)
|
||||
- `ADMIN_PASSWORD`: Password for admin login (default: `admin`)
|
||||
---
|
||||
|
||||
**Optional (Application):**
|
||||
## Configuration Reference
|
||||
|
||||
- `BASE_URL`: Public base URL for the application (default: `http://localhost:3000`)
|
||||
- `PRIMARY_DOMAIN`: Default domain served by Caddy (default: `caddyproxymanager.com`)
|
||||
- `CADDY_API_URL`: URL for the Caddy admin API (default: `http://caddy:2019`)
|
||||
- `DATABASE_PATH`: Path to the SQLite database (default: `/app/data/caddy-proxy-manager.db`)
|
||||
| Variable | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `ADMIN_USERNAME` | Admin login username | `admin` (development only) |
|
||||
| `ADMIN_PASSWORD` | Admin login password | `admin` (development only) |
|
||||
| `SESSION_SECRET` | 32+ char string for JWT/session signing | _required_ |
|
||||
| `BASE_URL` | Public URL for the dashboard | `http://localhost:3000` |
|
||||
| `CADDY_API_URL` | Internal Caddy admin API endpoint | `http://caddy:2019` (production container) |
|
||||
| `DATABASE_PATH` | SQLite file path | `/app/data/caddy-proxy-manager.db` |
|
||||
| `PRIMARY_DOMAIN` | Default domain for generated Caddy config | `caddyproxymanager.com` |
|
||||
|
||||
⚠️ **Important**: Always change the default `ADMIN_USERNAME` and `ADMIN_PASSWORD` in production!
|
||||
⚠️ **Production deployments must override `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `SESSION_SECRET`.**
|
||||
|
||||
## Data Locations
|
||||
---
|
||||
|
||||
- `data/caddy-proxy-manager.db`: SQLite database storing configuration, sessions, and audit log.
|
||||
- `data/certs/`: Imported TLS certificates and keys generated by the UI.
|
||||
- `caddy-data/`: Autogenerated Caddy state (ACME storage, etc.).
|
||||
- `caddy-config/`: Caddy configuration storage.
|
||||
## Cloudflare DNS Automation
|
||||
|
||||
## UI Features
|
||||
- Provide a Cloudflare API token with `Zone.DNS:Edit` permissions.
|
||||
- The token field is rendered as a password input and never pre-filled.
|
||||
- To revoke a stored token, select **Remove existing token** and submit.
|
||||
- Zone ID / Account ID fields are optional but recommended for multi-zone setups.
|
||||
- Managed certificates rely on valid credentials; otherwise, import PEMs manually.
|
||||
|
||||
- **Proxy Hosts:** HTTP(S) reverse proxies with HSTS, access lists, optional custom certificates, and WebSocket support. Hosts stay offline until a certificate (imported or managed with Cloudflare automation) is linked.
|
||||
- **Redirects:** 301/302 responses with optional path/query preservation.
|
||||
- **Dead Hosts:** Branded responses for offline services.
|
||||
- **Access Lists:** Bcrypt-backed basic auth credentials, assignable to proxy hosts.
|
||||
- **Certificates:** Managed (ACME) or imported PEM certificates with audit history.
|
||||
- **Audit Log:** Chronological record of every configuration change and actor.
|
||||
- **Settings:** General metadata and Cloudflare DNS credentials.
|
||||
---
|
||||
|
||||
## Security Posture
|
||||
|
||||
- **Session Secret Enforcement:** Production boots only when `SESSION_SECRET` is strong and not a known placeholder.
|
||||
- **Admin Credential Guardrails:** Default credentials rejected at runtime; production requires 12+ char password with letters & numbers.
|
||||
- **Login Throttling:** Per-IP+username throttling (5 attempts / 5 minutes, 15 minute lockout).
|
||||
- **Admin-Only Mutations:** All server actions that modify state require `requireAdmin()`.
|
||||
- **Certificate Handling:** Imported certificates and keys are projected to disk with `0600` permissions; certificate directory forced to `0700`. _Note: database storage for imported key material is not yet encrypted._
|
||||
- **Audit Trail:** Every mutation logs actor/action/summary to SQLite.
|
||||
- **Transport Security:** HSTS (`Strict-Transport-Security: max-age=63072000`) applied to managed hosts by default.
|
||||
|
||||
---
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `npm run dev` | Start Next.js in development mode |
|
||||
| `npm run build` | Create production build |
|
||||
| `npm start` | Run the production output |
|
||||
| `npm run typecheck` | TypeScript project check |
|
||||
|
||||
> `npm run lint` is intentionally omitted until the lint pipeline is finalized for this workspace.
|
||||
|
||||
---
|
||||
|
||||
## Development Notes
|
||||
|
||||
- SQLite schema migrations are embedded and run automatically on startup via Prisma.
|
||||
- Caddy configuration is rebuilt on every change and pushed via the admin API. Failures are surfaced to the UI.
|
||||
- Authentication uses NextAuth.js with JWT session strategy.
|
||||
- Type checking: `npm run typecheck`
|
||||
- Build: `npm run build`
|
||||
- Prisma manages the SQLite schema. `npx prisma db push` runs during builds and entrypoint start.
|
||||
- Caddy configuration is rebuilt on each mutation and pushed via the admin API; failures are surfaced to the UI with actionable errors.
|
||||
- Login throttling state is kept in-memory per Node process; scale-out deployments should front the app with a shared cache (Redis/Memcached) for consistent limiting across replicas.
|
||||
- The project currently supports a single administrator; introducing multi-role access will require revisiting authorization logic.
|
||||
|
||||
## Security Considerations
|
||||
---
|
||||
|
||||
1. **Change default credentials**: Never use `admin/admin` in production
|
||||
2. **Use strong SESSION_SECRET**: Generate with `openssl rand -base64 32`
|
||||
3. **Use HTTPS in production**: Configure BASE_URL with `https://` protocol
|
||||
4. **Restrict network access**: Ensure port 3000 is only accessible via reverse proxy
|
||||
5. **Keep updated**: Regularly update dependencies and Docker images
|
||||
6. **Provide Cloudflare credentials before using managed certificates**: The built-in automation only issues TLS certificates when valid Cloudflare API credentials are configured; otherwise, services (including `PRIMARY_DOMAIN`) remain on HTTP or require imported certificates.
|
||||
## Roadmap & Known Gaps
|
||||
|
||||
- Encrypt imported certificate material before persistence.
|
||||
- Shared rate-limiting store for horizontally scaled deployments.
|
||||
- Non-admin roles with scoped permissions.
|
||||
- Additional DNS providers for managed certificates.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License © Caddy Proxy Manager contributors.
|
||||
MIT License © Caddy Proxy Manager contributors
|
||||
|
||||
@@ -32,8 +32,18 @@ export default function LoginClient() {
|
||||
password
|
||||
});
|
||||
|
||||
if (!result || result.error) {
|
||||
setLoginError("Invalid username or password.");
|
||||
if (!result || result.error || result.ok === false) {
|
||||
let message: string | null = null;
|
||||
|
||||
if (result?.status === 429) {
|
||||
message = result.error && result.error !== "CredentialsSignin"
|
||||
? result.error
|
||||
: "Too many login attempts. Try again in a few minutes.";
|
||||
} else if (result?.error && result.error !== "CredentialsSignin") {
|
||||
message = result.error;
|
||||
}
|
||||
|
||||
setLoginError(message ?? "Invalid username or password.");
|
||||
setLoginPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireUser } from "@/src/lib/auth";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
import {
|
||||
addAccessListEntry,
|
||||
createAccessList,
|
||||
@@ -11,9 +11,8 @@ import {
|
||||
} from "@/src/lib/models/access-lists";
|
||||
|
||||
export async function createAccessListAction(formData: FormData) {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
const rawUsers = String(formData.get("users") ?? "");
|
||||
const accounts = rawUsers
|
||||
.split("\n")
|
||||
@@ -37,9 +36,8 @@ export async function createAccessListAction(formData: FormData) {
|
||||
}
|
||||
|
||||
export async function updateAccessListAction(id: number, formData: FormData) {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await updateAccessList(
|
||||
id,
|
||||
{
|
||||
@@ -52,17 +50,15 @@ export async function updateAccessListAction(id: number, formData: FormData) {
|
||||
}
|
||||
|
||||
export async function deleteAccessListAction(id: number) {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await deleteAccessList(id, userId);
|
||||
revalidatePath("/access-lists");
|
||||
}
|
||||
|
||||
export async function addAccessEntryAction(id: number, formData: FormData) {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await addAccessListEntry(
|
||||
id,
|
||||
{
|
||||
@@ -75,9 +71,8 @@ export async function addAccessEntryAction(id: number, formData: FormData) {
|
||||
}
|
||||
|
||||
export async function deleteAccessEntryAction(accessListId: number, entryId: number) {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await removeAccessListEntry(accessListId, entryId, userId);
|
||||
revalidatePath("/access-lists");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireUser } from "@/src/lib/auth";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
import { createCertificate, deleteCertificate, updateCertificate } from "@/src/lib/models/certificates";
|
||||
|
||||
function parseDomains(value: FormDataEntryValue | null): string[] {
|
||||
@@ -16,9 +16,8 @@ function parseDomains(value: FormDataEntryValue | null): string[] {
|
||||
}
|
||||
|
||||
export async function createCertificateAction(formData: FormData) {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
const type = String(formData.get("type") ?? "managed") as "managed" | "imported";
|
||||
await createCertificate(
|
||||
{
|
||||
@@ -35,9 +34,8 @@ export async function createCertificateAction(formData: FormData) {
|
||||
}
|
||||
|
||||
export async function updateCertificateAction(id: number, formData: FormData) {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
const type = formData.get("type") ? (String(formData.get("type")) as "managed" | "imported") : undefined;
|
||||
await updateCertificate(
|
||||
id,
|
||||
@@ -55,9 +53,8 @@ export async function updateCertificateAction(id: number, formData: FormData) {
|
||||
}
|
||||
|
||||
export async function deleteCertificateAction(id: number) {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await deleteCertificate(id, userId);
|
||||
revalidatePath("/certificates");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireUser } from "@/src/lib/auth";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
import { createDeadHost, deleteDeadHost, updateDeadHost } from "@/src/lib/models/dead-hosts";
|
||||
|
||||
function parseDomains(value: FormDataEntryValue | null): string[] {
|
||||
@@ -16,9 +16,8 @@ function parseDomains(value: FormDataEntryValue | null): string[] {
|
||||
}
|
||||
|
||||
export async function createDeadHostAction(formData: FormData) {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await createDeadHost(
|
||||
{
|
||||
name: String(formData.get("name") ?? "Dead host"),
|
||||
@@ -33,9 +32,8 @@ export async function createDeadHostAction(formData: FormData) {
|
||||
}
|
||||
|
||||
export async function updateDeadHostAction(id: number, formData: FormData) {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await updateDeadHost(
|
||||
id,
|
||||
{
|
||||
@@ -51,9 +49,8 @@ export async function updateDeadHostAction(id: number, formData: FormData) {
|
||||
}
|
||||
|
||||
export async function deleteDeadHostAction(id: number) {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await deleteDeadHost(id, userId);
|
||||
revalidatePath("/dead-hosts");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireUser } from "@/src/lib/auth";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
import { actionError, actionSuccess, INITIAL_ACTION_STATE, type ActionState } from "@/src/lib/actions";
|
||||
import { createProxyHost, deleteProxyHost, updateProxyHost, type ProxyHostAuthentikInput } from "@/src/lib/models/proxy-hosts";
|
||||
|
||||
@@ -79,9 +79,8 @@ export async function createProxyHostAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await createProxyHost(
|
||||
{
|
||||
name: String(formData.get("name") ?? "Untitled"),
|
||||
@@ -112,9 +111,8 @@ export async function updateProxyHostAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
const boolField = (key: string) => (formData.has(`${key}_present`) ? parseCheckbox(formData.get(key)) : undefined);
|
||||
await updateProxyHost(
|
||||
id,
|
||||
@@ -150,9 +148,8 @@ export async function deleteProxyHostAction(
|
||||
_prevState: ActionState = INITIAL_ACTION_STATE
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await deleteProxyHost(id, userId);
|
||||
revalidatePath("/proxy-hosts");
|
||||
return actionSuccess("Proxy host deleted.");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireUser } from "@/src/lib/auth";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
import { createRedirectHost, deleteRedirectHost, updateRedirectHost } from "@/src/lib/models/redirect-hosts";
|
||||
import { actionSuccess, actionError, type ActionState } from "@/src/lib/actions";
|
||||
|
||||
@@ -18,9 +18,8 @@ function parseList(value: FormDataEntryValue | null): string[] {
|
||||
|
||||
export async function createRedirectAction(_prevState: ActionState, formData: FormData): Promise<ActionState> {
|
||||
try {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await createRedirectHost(
|
||||
{
|
||||
name: String(formData.get("name") ?? "Redirect"),
|
||||
@@ -41,9 +40,8 @@ export async function createRedirectAction(_prevState: ActionState, formData: Fo
|
||||
|
||||
export async function updateRedirectAction(id: number, _prevState: ActionState, formData: FormData): Promise<ActionState> {
|
||||
try {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await updateRedirectHost(
|
||||
id,
|
||||
{
|
||||
@@ -65,9 +63,8 @@ export async function updateRedirectAction(id: number, _prevState: ActionState,
|
||||
|
||||
export async function deleteRedirectAction(id: number, _prevState: ActionState): Promise<ActionState> {
|
||||
try {
|
||||
const session = await requireUser();
|
||||
const user = session.user;
|
||||
const userId = Number(user.id);
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await deleteRedirectHost(id, userId);
|
||||
revalidatePath("/redirects");
|
||||
return actionSuccess("Redirect deleted successfully");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useFormState } from "react-dom";
|
||||
import { Alert, Box, Button, Card, CardContent, Stack, TextField, Typography } from "@mui/material";
|
||||
import type { CloudflareSettings, GeneralSettings } from "@/src/lib/settings";
|
||||
import { Alert, Box, Button, Card, CardContent, Checkbox, FormControlLabel, Stack, TextField, Typography } from "@mui/material";
|
||||
import type { GeneralSettings } from "@/src/lib/settings";
|
||||
import {
|
||||
updateCloudflareSettingsAction,
|
||||
updateGeneralSettingsAction
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
|
||||
type Props = {
|
||||
general: GeneralSettings | null;
|
||||
cloudflare: CloudflareSettings | null;
|
||||
cloudflare: {
|
||||
hasToken: boolean;
|
||||
zoneId?: string;
|
||||
accountId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SettingsClient({ general, cloudflare }: Props) {
|
||||
@@ -68,15 +72,32 @@ export default function SettingsClient({ general, cloudflare }: Props) {
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
||||
Configure a Cloudflare API token with Zone.DNS Edit permissions to enable DNS-01 challenges for wildcard certificates.
|
||||
</Typography>
|
||||
{cloudflare.hasToken && (
|
||||
<Alert severity="info">
|
||||
A Cloudflare API token is already configured. Leave the token field blank to keep it, or select “Remove existing token” to delete it.
|
||||
</Alert>
|
||||
)}
|
||||
<Stack component="form" action={cloudflareFormAction} spacing={2}>
|
||||
{cloudflareState?.message && (
|
||||
<Alert severity={cloudflareState.success ? "success" : "warning"}>
|
||||
{cloudflareState.message}
|
||||
</Alert>
|
||||
)}
|
||||
<TextField name="apiToken" label="API token" defaultValue={cloudflare?.apiToken ?? ""} fullWidth />
|
||||
<TextField name="zoneId" label="Zone ID" defaultValue={cloudflare?.zoneId ?? ""} fullWidth />
|
||||
<TextField name="accountId" label="Account ID" defaultValue={cloudflare?.accountId ?? ""} fullWidth />
|
||||
<TextField
|
||||
name="apiToken"
|
||||
label="API token"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="Enter new token"
|
||||
fullWidth
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox name="clearToken" />}
|
||||
label="Remove existing token"
|
||||
disabled={!cloudflare.hasToken}
|
||||
/>
|
||||
<TextField name="zoneId" label="Zone ID" defaultValue={cloudflare.zoneId ?? ""} fullWidth />
|
||||
<TextField name="accountId" label="Account ID" defaultValue={cloudflare.accountId ?? ""} fullWidth />
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button type="submit" variant="contained">
|
||||
Save Cloudflare settings
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
import { applyCaddyConfig } from "@/src/lib/caddy";
|
||||
import { saveCloudflareSettings, saveGeneralSettings } from "@/src/lib/settings";
|
||||
import { getCloudflareSettings, saveCloudflareSettings, saveGeneralSettings } from "@/src/lib/settings";
|
||||
|
||||
type ActionResult = {
|
||||
success: boolean;
|
||||
@@ -13,7 +13,7 @@ type ActionResult = {
|
||||
export async function updateGeneralSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
|
||||
try {
|
||||
await requireAdmin();
|
||||
saveGeneralSettings({
|
||||
await saveGeneralSettings({
|
||||
primaryDomain: String(formData.get("primaryDomain") ?? ""),
|
||||
acmeEmail: formData.get("acmeEmail") ? String(formData.get("acmeEmail")) : undefined
|
||||
});
|
||||
@@ -28,16 +28,19 @@ export async function updateGeneralSettingsAction(_prevState: ActionResult | nul
|
||||
export async function updateCloudflareSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const apiToken = String(formData.get("apiToken") ?? "");
|
||||
if (!apiToken) {
|
||||
saveCloudflareSettings({ apiToken: "", zoneId: undefined, accountId: undefined });
|
||||
} else {
|
||||
saveCloudflareSettings({
|
||||
apiToken,
|
||||
zoneId: formData.get("zoneId") ? String(formData.get("zoneId")) : undefined,
|
||||
accountId: formData.get("accountId") ? String(formData.get("accountId")) : undefined
|
||||
});
|
||||
}
|
||||
const rawToken = formData.get("apiToken") ? String(formData.get("apiToken")).trim() : "";
|
||||
const clearToken = formData.get("clearToken") === "on";
|
||||
const current = await getCloudflareSettings();
|
||||
|
||||
const apiToken = clearToken ? "" : rawToken || current?.apiToken || "";
|
||||
const zoneId = formData.get("zoneId") ? String(formData.get("zoneId")) : undefined;
|
||||
const accountId = formData.get("accountId") ? String(formData.get("accountId")) : undefined;
|
||||
|
||||
await saveCloudflareSettings({
|
||||
apiToken,
|
||||
zoneId: zoneId && zoneId.length > 0 ? zoneId : undefined,
|
||||
accountId: accountId && accountId.length > 0 ? accountId : undefined
|
||||
});
|
||||
|
||||
// Try to apply the config, but don't fail if Caddy is unreachable
|
||||
try {
|
||||
|
||||
@@ -13,7 +13,11 @@ export default async function SettingsPage() {
|
||||
return (
|
||||
<SettingsClient
|
||||
general={general}
|
||||
cloudflare={cloudflare}
|
||||
cloudflare={{
|
||||
hasToken: Boolean(cloudflare?.apiToken),
|
||||
zoneId: cloudflare?.zoneId,
|
||||
accountId: cloudflare?.accountId
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { handlers } from "@/src/lib/auth";
|
||||
import { isRateLimited, registerFailedAttempt, resetAttempts } from "@/src/lib/rate-limit";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
export const { GET } = handlers;
|
||||
|
||||
function getClientIp(request: NextRequest): string {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
return forwarded.split(",")[0]?.trim() || "unknown";
|
||||
}
|
||||
const real = request.headers.get("x-real-ip");
|
||||
if (real) {
|
||||
return real.trim();
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function buildRateLimitKey(ip: string, username: string) {
|
||||
const normalizedUsername = username.trim().toLowerCase() || "unknown";
|
||||
return `login:${ip}:${normalizedUsername}`;
|
||||
}
|
||||
|
||||
function buildBlockedResponse(retryAfterMs?: number) {
|
||||
const retryAfterSeconds = retryAfterMs ? Math.ceil(retryAfterMs / 1000) : 60;
|
||||
const retryAfterMinutes = Math.max(1, Math.ceil(retryAfterSeconds / 60));
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Too many login attempts. Try again in about ${retryAfterMinutes} minute${retryAfterMinutes === 1 ? "" : "s"}.`
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": retryAfterSeconds.toString()
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const formData = await request.clone().formData();
|
||||
const username = String(formData.get("username") ?? "");
|
||||
const ip = getClientIp(request);
|
||||
const rateLimitKey = buildRateLimitKey(ip, username);
|
||||
|
||||
const limitation = isRateLimited(rateLimitKey);
|
||||
if (limitation.blocked) {
|
||||
return buildBlockedResponse(limitation.retryAfterMs);
|
||||
}
|
||||
|
||||
const response = await handlers.POST(request);
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
resetAttempts(rateLimitKey);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
const result = registerFailedAttempt(rateLimitKey);
|
||||
if (result.blocked) {
|
||||
return buildBlockedResponse(result.retryAfterMs);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
83
src/lib/rate-limit.ts
Normal file
83
src/lib/rate-limit.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
type RateLimitEntry = {
|
||||
attempts: number;
|
||||
firstAttemptTimestamp: number;
|
||||
blockedUntil?: number;
|
||||
};
|
||||
|
||||
type RateLimitOutcome = {
|
||||
blocked: boolean;
|
||||
retryAfterMs?: number;
|
||||
};
|
||||
|
||||
const ATTEMPTS = new Map<string, RateLimitEntry>();
|
||||
const MAX_ATTEMPTS = Number(process.env.LOGIN_MAX_ATTEMPTS ?? 5);
|
||||
const WINDOW_MS = Number(process.env.LOGIN_WINDOW_MS ?? 5 * 60 * 1000);
|
||||
const BLOCK_DURATION_MS = Number(process.env.LOGIN_BLOCK_MS ?? 15 * 60 * 1000);
|
||||
|
||||
function getEntry(key: string, now: number): RateLimitEntry | undefined {
|
||||
const entry = ATTEMPTS.get(key);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Unblock if the penalty period has elapsed.
|
||||
if (entry.blockedUntil && entry.blockedUntil <= now) {
|
||||
ATTEMPTS.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Reset the window once the observation window expires.
|
||||
if (!entry.blockedUntil && entry.firstAttemptTimestamp + WINDOW_MS <= now) {
|
||||
ATTEMPTS.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function isRateLimited(key: string): RateLimitOutcome {
|
||||
const now = Date.now();
|
||||
const entry = getEntry(key, now);
|
||||
if (!entry) {
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
if (entry.blockedUntil && entry.blockedUntil > now) {
|
||||
return { blocked: true, retryAfterMs: entry.blockedUntil - now };
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
export function registerFailedAttempt(key: string): RateLimitOutcome {
|
||||
const now = Date.now();
|
||||
const existing = getEntry(key, now);
|
||||
|
||||
if (!existing) {
|
||||
ATTEMPTS.set(key, {
|
||||
attempts: 1,
|
||||
firstAttemptTimestamp: now
|
||||
});
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
if (existing.blockedUntil && existing.blockedUntil > now) {
|
||||
return { blocked: true, retryAfterMs: existing.blockedUntil - now };
|
||||
}
|
||||
|
||||
existing.attempts += 1;
|
||||
|
||||
if (existing.attempts >= MAX_ATTEMPTS) {
|
||||
existing.attempts = 0;
|
||||
existing.firstAttemptTimestamp = now;
|
||||
existing.blockedUntil = now + BLOCK_DURATION_MS;
|
||||
return { blocked: true, retryAfterMs: BLOCK_DURATION_MS };
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
export function resetAttempts(key: string): void {
|
||||
ATTEMPTS.delete(key);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user