31 Commits

Author SHA1 Message Date
c27f4e40a3 changed port 3001 to 3000
Some checks are pending
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Waiting to run
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Waiting to run
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Waiting to run
Tests / test (push) Waiting to run
2026-04-23 06:52:54 +00:00
f861b95c9b set primary domain
Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-22 10:39:37 +00:00
d6bb1871dd changed bind mounts back to volumes
Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-22 10:38:45 +00:00
12da316ace testing
Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-22 10:35:36 +00:00
3fb643f41e changed volumes to bind mounts
Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-22 10:28:30 +00:00
25cd4669b2 changed docker-compose to compose
Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-22 10:26:00 +00:00
47af056a7c changed port
Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-22 10:23:33 +00:00
7a91843c79 updated config
Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
2026-04-21 22:54:55 +00:00
99819b70ff added caddy-proxy-manager for testing
Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-21 22:49:08 +00:00
fuomag9
4c5ad53370 Fix inflated certificate counts in dashboard and certificates page
The dashboard overview counted all proxy hosts without a cert as ACME
certs, ignoring wildcard deduplication. The certificates page only
deduplicated the current page of results (25 rows) but used a full
count(*) for the total, so hosts on other pages covered by wildcards
were never subtracted.

Now both pages fetch all ACME hosts, apply full deduplication (cert
wildcard coverage + ACME wildcard collapsing), then paginate/count
from the deduplicated result.

Also fixes a strict-mode violation in the E2E test where DataTable's
dual mobile/desktop rendering caused getByText to match two elements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:01:39 +02:00
fuomag9
d710ad1247 Remove unnecessary IONOS field name migration
The wrong field name only existed for one commit before the fix, and
the only affected user already re-entered their credentials.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 18:09:33 +02:00
fuomag9
6515da666f Fix duplicate ACME entries for subdomains covered by wildcard ACME host
The previous fix (92fa1cb) only collapsed subdomains when an explicit
managed/imported certificate had a wildcard. When all hosts use Caddy
Auto (no certificate assigned), the wildcard ACME host was not checked
against sibling subdomain hosts, so each showed as a separate entry.

Also adds a startup migration to rename the stored IONOS DNS credential
key from api_token to auth_api_token for users who configured IONOS
before ef62ef2.

Closes #110

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 18:04:38 +02:00
fuomag9
96bac86934 Fix L4 port manager failing to recreate caddy after Docker restart
The sidecar's `docker compose up` command lacked `--pull never`, so
Docker Compose would attempt to pull the caddy image from ghcr.io when
the local image was missing or stale. Since the sidecar has no registry
credentials this failed with 403 Forbidden.

Closes #117

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:35:12 +02:00
fuomag9
dbfc340ea4 Fix logout redirect to 0.0.0.0 instead of configured BASE_URL
Closes #113

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 09:51:51 +02:00
dependabot[bot]
521a059414 deps(deps): Bump better-auth in the production-dependencies group (#116)
Bumps the production-dependencies group with 1 update: [better-auth](https://github.com/better-auth/better-auth/tree/HEAD/packages/better-auth).


Updates `better-auth` from 1.6.4 to 1.6.5
- [Release notes](https://github.com/better-auth/better-auth/releases)
- [Changelog](https://github.com/better-auth/better-auth/blob/main/packages/better-auth/CHANGELOG.md)
- [Commits](https://github.com/better-auth/better-auth/commits/better-auth@1.6.5/packages/better-auth)

---
updated-dependencies:
- dependency-name: better-auth
  dependency-version: 1.6.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 05:07:02 +00:00
dependabot[bot]
1be8fc2629 deps(deps-dev): Bump the development-dependencies group with 3 updates (#115)
Bumps the development-dependencies group with 3 updates: [eslint](https://github.com/eslint/eslint), [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) and [typescript](https://github.com/microsoft/TypeScript).


Updates `eslint` from 10.2.0 to 10.2.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)

Updates `shadcn` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.3.0/packages/shadcn)

Updates `typescript` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.2...v6.0.3)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: shadcn
  dependency-version: 4.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 05:06:24 +00:00
dependabot[bot]
6d2827a132 ci(deps): Bump dependabot/fetch-metadata from 2 to 3 (#114)
Bumps [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) from 2 to 3.
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/v2...v3)

---
updated-dependencies:
- dependency-name: dependabot/fetch-metadata
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 05:03:28 +00:00
fuomag9
eb11856994 Update README for multi-provider DNS, forward auth excluded paths
- Add DNS Providers feature listing all 12 supported providers
- Update Certificate Management section for multi-provider DNS-01
- Mention excluded paths in Forward Auth Portal feature
- Remove completed roadmap item (additional DNS providers)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 22:24:04 +02:00
fuomag9
7d61528dad Fix login rejection for usernames containing hyphens
better-auth's default username validator only allows [a-zA-Z0-9_.],
rejecting hyphens with a generic "invalid username or password" error.
Added a custom validator that also permits hyphens.

Closes #112

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 12:15:18 +02:00
fuomag9
92fa1cb9d8 Fix duplicate certificate display for wildcard-covered subdomains
When a wildcard cert (e.g. *.domain.de) existed and a proxy host was created
for a subdomain (e.g. sub.domain.de) without explicitly linking it, the
certificates page showed it as a separate ACME entry. Now hosts covered by
an existing wildcard cert are attributed to that cert's "Used by" list instead.

Closes #110

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 12:10:17 +02:00
fuomag9
ef62ef232f Fix IONOS DNS provider field name (api_token -> auth_api_token)
The IONOS libdns provider uses auth_api_token as the JSON field name,
not api_token. This caused Caddy to reject the config with
"unknown field api_token".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 00:57:36 +02:00
fuomag9
0e47ec4d7d Fix Docker image tagging for pre-release versions
The semver patterns in docker/metadata-action don't match pre-release
tags like v1.0-rc2. Add a type=match rule that extracts the version
from any v* tag so RC and other pre-release builds get proper tags.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 19:43:07 +02:00
fuomag9
5c78a8e8f6 Add IONOS DNS provider
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 18:09:12 +02:00
fuomag9
2c70f2859a Add multi-provider DNS registry for ACME DNS-01 challenges
Replace hardcoded Cloudflare DNS-01 with a data-driven provider registry
supporting 11 providers (Cloudflare, Route 53, DigitalOcean, Duck DNS,
Hetzner, Vultr, Porkbun, GoDaddy, Namecheap, OVH, Linode). Users can
configure multiple providers with encrypted credentials and select a
default. Per-certificate provider override is supported via providerOptions.

- Add src/lib/dns-providers.ts with provider definitions, credential
  encrypt/decrypt, and Caddy config builder
- Change DnsProviderSettings to multi-provider format with default selection
- Auto-migrate legacy Cloudflare settings on startup (db.ts)
- Normalize old single-provider format on read (getDnsProviderSettings)
- Refactor buildTlsAutomation() to use provider registry
- Add GET /api/v1/dns-providers endpoint for provider discovery
- Add dns-provider settings group to REST API and instance sync
- Replace Cloudflare settings card with multi-provider UI (add/remove
  providers, set default, dynamic credential forms)
- Add 10 DNS provider modules to Caddy Dockerfile
- Update OpenAPI spec, E2E tests, and unit test mocks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 18:01:16 +02:00
fuomag9
60633bf6c3 Fix unused variable lint error in api-security test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 10:28:48 +02:00
fuomag9
a520717aab Fix excluded_paths dropped by sanitize functions during creation
The sanitizeAuthentikMeta and sanitizeCpmForwardAuthMeta functions
did not process excluded_paths, causing the field to be silently
stripped when creating a proxy host.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 10:21:18 +02:00
fuomag9
8f4c24119e Add excluded paths support for forward auth (fixes #108)
Allow users to exclude specific paths from Authentik/CPM forward auth
protection. When excluded_paths is set, all paths require authentication
EXCEPT the excluded ones — useful for apps like Navidrome that need
/share/* and /rest/* to bypass auth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 10:11:35 +02:00
Copilot
390840dbd9 Add dependabot automerge workflow (#106)
Agent-Logs-Url: https://github.com/fuomag9/caddy-proxy-manager/sessions/d7981099-949d-4dc6-95c7-7c14383964e4

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: fuomag9 <1580624+fuomag9@users.noreply.github.com>
2026-04-17 01:29:29 +02:00
dependabot[bot]
3a4807b5cd deps(deps-dev): Bump the development-dependencies group with 3 updates (#104)
Bumps the development-dependencies group with 3 updates: [@next/eslint-plugin-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-plugin-next), [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `@next/eslint-plugin-next` from 16.2.3 to 16.2.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.4/packages/eslint-plugin-next)

Updates `better-sqlite3` from 12.8.0 to 12.9.0
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.8.0...v12.9.0)

Updates `typescript-eslint` from 8.58.1 to 8.58.2
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.58.2/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@next/eslint-plugin-next"
  dependency-version: 16.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: better-sqlite3
  dependency-version: 12.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.58.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 01:17:30 +02:00
dependabot[bot]
0c632811b4 deps(deps): Bump the production-dependencies group with 5 updates (#105)
Bumps the production-dependencies group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.4.27` | `10.5.0` |
| [better-auth](https://github.com/better-auth/better-auth/tree/HEAD/packages/better-auth) | `1.6.2` | `1.6.4` |
| [maplibre-gl](https://github.com/maplibre/maplibre-gl-js) | `5.22.0` | `5.23.0` |
| [next](https://github.com/vercel/next.js) | `16.2.3` | `16.2.4` |
| [postcss](https://github.com/postcss/postcss) | `8.5.9` | `8.5.10` |


Updates `autoprefixer` from 10.4.27 to 10.5.0
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.4.27...10.5.0)

Updates `better-auth` from 1.6.2 to 1.6.4
- [Release notes](https://github.com/better-auth/better-auth/releases)
- [Changelog](https://github.com/better-auth/better-auth/blob/main/packages/better-auth/CHANGELOG.md)
- [Commits](https://github.com/better-auth/better-auth/commits/better-auth@1.6.4/packages/better-auth)

Updates `maplibre-gl` from 5.22.0 to 5.23.0
- [Release notes](https://github.com/maplibre/maplibre-gl-js/releases)
- [Changelog](https://github.com/maplibre/maplibre-gl-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-gl-js/compare/v5.22.0...v5.23.0)

Updates `next` from 16.2.3 to 16.2.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.3...v16.2.4)

Updates `postcss` from 8.5.9 to 8.5.10
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.9...8.5.10)

---
updated-dependencies:
- dependency-name: autoprefixer
  dependency-version: 10.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: better-auth
  dependency-version: 1.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: maplibre-gl
  dependency-version: 5.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: next
  dependency-version: 16.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 01:17:13 +02:00
Copilot
81be14e95e configure dependabot for bun ecosystem (#103)
Agent-Logs-Url: https://github.com/fuomag9/caddy-proxy-manager/sessions/169dafb8-7aec-48bd-b02d-f0cea1d14bf8

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: fuomag9 <1580624+fuomag9@users.noreply.github.com>
2026-04-16 01:12:33 +02:00
406 changed files with 1558 additions and 211 deletions

0
.dockerignore Normal file → Executable file
View File

0
.env.example Normal file → Executable file
View File

0
.github/FUNDING.yml vendored Normal file → Executable file
View File

0
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file → Executable file
View File

0
.github/ISSUE_TEMPLATE/dns_challenge_request.md vendored Normal file → Executable file
View File

0
.github/ISSUE_TEMPLATE/feature_request.md vendored Normal file → Executable file
View File

4
.github/dependabot.yml vendored Normal file → Executable file
View File

@@ -17,8 +17,8 @@ updates:
prefix: "ci" prefix: "ci"
include: "scope" include: "scope"
# NPM dependencies updates # Bun dependencies updates
- package-ecosystem: "npm" - package-ecosystem: "bun"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"

32
.github/workflows/dependabot-automerge.yml vendored Executable file
View File

@@ -0,0 +1,32 @@
name: Dependabot auto-merge
on:
pull_request:
branches:
- main
- develop
permissions:
contents: write
pull-requests: write
jobs:
automerge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Fetch Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v3
- name: Auto-approve the PR
run: gh pr review --approve "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

0
.github/workflows/docker-build-pr.yml vendored Normal file → Executable file
View File

1
.github/workflows/docker-build-trusted.yml vendored Normal file → Executable file
View File

@@ -57,6 +57,7 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=match,pattern=v(.*),group=1
type=sha type=sha
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}

0
.github/workflows/stale.yml vendored Normal file → Executable file
View File

0
.github/workflows/test.yml vendored Normal file → Executable file
View File

0
.gitignore vendored Normal file → Executable file
View File

0
.version Normal file → Executable file
View File

0
LICENSE Normal file → Executable file
View File

9
README.md Normal file → Executable file
View File

@@ -38,7 +38,7 @@ Data persists in Docker volumes (caddy-manager-data, caddy-data, caddy-config, c
- **L4 Proxy Hosts** - TCP/UDP stream proxying with TLS SNI matching, proxy protocol (v1/v2), load balancing, health checks, and per-host geo blocking. Automatic Docker Compose port management via sidecar - **L4 Proxy Hosts** - TCP/UDP stream proxying with TLS SNI matching, proxy protocol (v1/v2), load balancing, health checks, and per-host geo blocking. Automatic Docker Compose port management via sidecar
- **Location Rules** - Path-based routing to different upstreams per proxy host (e.g. `/api/*` to one backend, `/ws/*` to another) - **Location Rules** - Path-based routing to different upstreams per proxy host (e.g. `/api/*` to one backend, `/ws/*` to another)
- **Redirect & Rewrite** - Per-host redirect rules (301/302/307/308) and path prefix rewriting - **Redirect & Rewrite** - Per-host redirect rules (301/302/307/308) and path prefix rewriting
- **Forward Auth Portal** - Built-in identity provider for protecting proxy hosts without an external IdP. Credential and OAuth login portal, user groups with membership management, and per-host access control by user or group - **Forward Auth Portal** - Built-in identity provider for protecting proxy hosts without an external IdP. Credential and OAuth login portal, user groups with membership management, per-host access control by user or group, and excluded paths that bypass authentication
- **WAF** - Web Application Firewall powered by Coraza with optional OWASP Core Rule Set (SQLi, XSS, LFI, RCE). Per-host enable/disable, global and per-host rule suppression, custom SecLang directives, and a searchable event log with severity and blocked/detected classification - **WAF** - Web Application Firewall powered by Coraza with optional OWASP Core Rule Set (SQLi, XSS, LFI, RCE). Per-host enable/disable, global and per-host rule suppression, custom SecLang directives, and a searchable event log with severity and blocked/detected classification
- **Analytics** - Live traffic charts, protocol breakdown, country map, top user agents, and blocked request log with configurable time ranges - **Analytics** - Live traffic charts, protocol breakdown, country map, top user agents, and blocked request log with configurable time ranges
- **Geo Blocking** - Block or allow traffic by country, continent, ASN, CIDR range, or exact IP per proxy host. Allow rules override block rules. Fail-closed mode, custom response codes/bodies, and trusted proxy support - **Geo Blocking** - Block or allow traffic by country, continent, ASN, CIDR range, or exact IP per proxy host. Allow rules override block rules. Fail-closed mode, custom response codes/bodies, and trusted proxy support
@@ -55,7 +55,8 @@ Data persists in Docker volumes (caddy-manager-data, caddy-data, caddy-config, c
- **API Tokens** - Create and manage API tokens with optional expiration for programmatic access - **API Tokens** - Create and manage API tokens with optional expiration for programmatic access
- **Instance Sync** - Master/slave configuration sync for multi-instance deployments. The master pushes proxy hosts, certificates, access lists, and settings to slaves on every change - **Instance Sync** - Master/slave configuration sync for multi-instance deployments. The master pushes proxy hosts, certificates, access lists, and settings to slaves on every change
- **OAuth / SSO** - OAuth2/OIDC authentication with any compliant provider (Authentik, Keycloak, Auth0, etc.). Account linking from the Profile page - **OAuth / SSO** - OAuth2/OIDC authentication with any compliant provider (Authentik, Keycloak, Auth0, etc.). Account linking from the Profile page
- **Settings** - ACME email, Cloudflare DNS-01, upstream DNS pinning defaults, Authentik outpost, Prometheus metrics, logging format - **DNS Providers** - Multi-provider DNS-01 challenge support for ACME certificates: Cloudflare, Route 53, DigitalOcean, Duck DNS, Hetzner, Vultr, Porkbun, GoDaddy, Namecheap, OVH, IONOS, and Linode. Credentials encrypted at rest. Per-certificate provider override supported
- **Settings** - ACME email, DNS provider configuration, upstream DNS pinning defaults, Authentik outpost, Prometheus metrics, logging format
- **Audit Log** - Searchable configuration change history with user attribution and pagination - **Audit Log** - Searchable configuration change history with user attribution and pagination
- **Search & Pagination** - Server-side search and pagination on all data tables - **Search & Pagination** - Server-side search and pagination on all data tables
- **Dark Mode** - Full dark/light theme support with system preference detection - **Dark Mode** - Full dark/light theme support with system preference detection
@@ -158,7 +159,7 @@ New users default to the **user** role. The initial admin account is created fro
Caddy automatically obtains Let's Encrypt certificates for all proxy hosts. Caddy automatically obtains Let's Encrypt certificates for all proxy hosts.
**Cloudflare DNS-01** (optional): Configure in Settings with a Cloudflare API token (`Zone.DNS:Edit` permissions). **DNS-01 Challenge** (optional): Configure a DNS provider in **Settings → DNS Providers** for wildcard certificates and environments where ports 80/443 are not public. Supported providers: Cloudflare, Route 53, DigitalOcean, Duck DNS, Hetzner, Vultr, Porkbun, GoDaddy, Namecheap, OVH, IONOS, and Linode. Credentials are encrypted at rest with AES-256-GCM. You can override the DNS provider per certificate.
**Custom Certificates** (optional): Import your own certificates via the Certificates page. Private keys are stored unencrypted in SQLite. **Custom Certificates** (optional): Import your own certificates via the Certificates page. Private keys are stored unencrypted in SQLite.
@@ -325,8 +326,6 @@ Each forward-auth-protected host has its own access list of allowed users and/or
## Roadmap ## Roadmap
- [ ] Additional DNS providers (Route53, Namecheap, etc.)
[Open an issue](https://github.com/fuomag9/caddy-proxy-manager/issues) for feature requests. [Open an issue](https://github.com/fuomag9/caddy-proxy-manager/issues) for feature requests.
--- ---

0
SECURITY.md Normal file → Executable file
View File

0
app/(auth)/link-account/LinkAccountClient.tsx Normal file → Executable file
View File

0
app/(auth)/link-account/page.tsx Normal file → Executable file
View File

0
app/(auth)/login/LoginClient.tsx Normal file → Executable file
View File

0
app/(auth)/login/page.tsx Normal file → Executable file
View File

0
app/(auth)/portal/PortalLoginForm.tsx Normal file → Executable file
View File

0
app/(auth)/portal/page.tsx Normal file → Executable file
View File

0
app/(dashboard)/DashboardLayoutClient.tsx Normal file → Executable file
View File

0
app/(dashboard)/OverviewClient.tsx Normal file → Executable file
View File

0
app/(dashboard)/access-lists/AccessListsClient.tsx Normal file → Executable file
View File

0
app/(dashboard)/access-lists/actions.ts Normal file → Executable file
View File

0
app/(dashboard)/access-lists/page.tsx Normal file → Executable file
View File

0
app/(dashboard)/analytics/AnalyticsClient.tsx Normal file → Executable file
View File

0
app/(dashboard)/analytics/WorldMapInner.tsx Normal file → Executable file
View File

0
app/(dashboard)/analytics/page.tsx Normal file → Executable file
View File

0
app/(dashboard)/api-docs/ApiDocsClient.tsx Normal file → Executable file
View File

0
app/(dashboard)/api-docs/page.tsx Normal file → Executable file
View File

0
app/(dashboard)/api-tokens/actions.ts Normal file → Executable file
View File

0
app/(dashboard)/audit-log/AuditLogClient.tsx Normal file → Executable file
View File

0
app/(dashboard)/audit-log/page.tsx Normal file → Executable file
View File

0
app/(dashboard)/certificates/CertificatesClient.tsx Normal file → Executable file
View File

0
app/(dashboard)/certificates/actions.ts Normal file → Executable file
View File

0
app/(dashboard)/certificates/ca-actions.ts Normal file → Executable file
View File

0
app/(dashboard)/certificates/components/AcmeTab.tsx Normal file → Executable file
View File

View File

0
app/(dashboard)/certificates/components/CaTab.tsx Normal file → Executable file
View File

View File

View File

View File

View File

79
app/(dashboard)/certificates/page.tsx Normal file → Executable file
View File

@@ -7,6 +7,7 @@ import CertificatesClient from './CertificatesClient';
import { listCaCertificates, type CaCertificate } from '@/src/lib/models/ca-certificates'; import { listCaCertificates, type CaCertificate } from '@/src/lib/models/ca-certificates';
import { listIssuedClientCertificates, type IssuedClientCertificate } from '@/src/lib/models/issued-client-certificates'; import { listIssuedClientCertificates, type IssuedClientCertificate } from '@/src/lib/models/issued-client-certificates';
import { listMtlsRoles, type MtlsRole } from '@/src/lib/models/mtls-roles'; import { listMtlsRoles, type MtlsRole } from '@/src/lib/models/mtls-roles';
import { isDomainCoveredByCert } from '@/src/lib/cert-domain-match';
export type { CaCertificate }; export type { CaCertificate };
export type { IssuedClientCertificate }; export type { IssuedClientCertificate };
@@ -78,6 +79,7 @@ function getExpiryStatus(validToIso: string): CertExpiryStatus {
return 'ok'; return 'ok';
} }
export default async function CertificatesPage({ searchParams }: PageProps) { export default async function CertificatesPage({ searchParams }: PageProps) {
await requireAdmin(); await requireAdmin();
const { page: pageParam } = await searchParams; const { page: pageParam } = await searchParams;
@@ -89,7 +91,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
]); ]);
const mtlsRoles = await listMtlsRoles().catch(() => []); const mtlsRoles = await listMtlsRoles().catch(() => []);
const [acmeRows, acmeTotal, certRows, usageRows] = await Promise.all([ const [allAcmeRows, certRows, usageRows] = await Promise.all([
db db
.select({ .select({
id: proxyHosts.id, id: proxyHosts.id,
@@ -100,14 +102,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
}) })
.from(proxyHosts) .from(proxyHosts)
.where(isNull(proxyHosts.certificateId)) .where(isNull(proxyHosts.certificateId))
.orderBy(proxyHosts.name) .orderBy(proxyHosts.name),
.limit(PER_PAGE)
.offset(offset),
db
.select({ value: count() })
.from(proxyHosts)
.where(isNull(proxyHosts.certificateId))
.then(([r]) => r?.value ?? 0),
db.select().from(certificates), db.select().from(certificates),
db db
.select({ .select({
@@ -120,7 +115,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
.where(isNotNull(proxyHosts.certificateId)), .where(isNotNull(proxyHosts.certificateId)),
]); ]);
const acmeHosts: AcmeHost[] = acmeRows.map(r => ({ const allAcmeHosts: AcmeHost[] = allAcmeRows.map(r => ({
id: r.id, id: r.id,
name: r.name, name: r.name,
domains: JSON.parse(r.domains) as string[], domains: JSON.parse(r.domains) as string[],
@@ -140,6 +135,66 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
usageMap.set(u.certId, hosts); usageMap.set(u.certId, hosts);
} }
// Build a map of cert ID -> its domain list (including wildcard entries)
const certDomainMap = new Map<number, string[]>();
for (const cert of certRows) {
const domainNames = JSON.parse(cert.domainNames) as string[];
// For imported certs, also check PEM SANs which may include wildcards
if (cert.type === 'imported' && cert.certificatePem) {
const pemInfo = parsePemInfo(cert.certificatePem);
if (pemInfo?.sanDomains.length) {
certDomainMap.set(cert.id, pemInfo.sanDomains);
continue;
}
}
certDomainMap.set(cert.id, domainNames);
}
// Filter out ACME hosts whose domains are fully covered by an existing certificate's wildcard,
// and attribute them to that certificate's usedBy list instead.
const filteredAcmeHosts: AcmeHost[] = [];
for (const host of allAcmeHosts) {
let coveredByCertId: number | null = null;
for (const [certId, certDomains] of certDomainMap) {
if (host.domains.every(d => isDomainCoveredByCert(d, certDomains))) {
coveredByCertId = certId;
break;
}
}
if (coveredByCertId !== null) {
// Move this host to the cert's usedBy list
const hosts = usageMap.get(coveredByCertId) ?? [];
hosts.push({ id: host.id, name: host.name, domains: host.domains });
usageMap.set(coveredByCertId, hosts);
} else {
filteredAcmeHosts.push(host);
}
}
// Among ACME auto-managed hosts, collapse subdomain hosts under wildcard hosts.
// e.g. if *.domain.de is an ACME host, sub.domain.de should not appear separately.
const wildcardAcmeHosts = filteredAcmeHosts.filter(h => h.domains.some(d => d.startsWith('*.')));
const wildcardDomainSets = wildcardAcmeHosts.map(h => h.domains);
const deduplicatedAcmeHosts: AcmeHost[] = [];
for (const host of filteredAcmeHosts) {
// Never collapse a host that itself has a wildcard domain
if (host.domains.some(d => d.startsWith('*.'))) {
deduplicatedAcmeHosts.push(host);
continue;
}
// Check if all of this host's domains are covered by any wildcard ACME host
const coveredByWildcard = wildcardDomainSets.some(wcDomains =>
host.domains.every(d => isDomainCoveredByCert(d, wcDomains))
);
if (!coveredByWildcard) {
deduplicatedAcmeHosts.push(host);
}
}
// Paginate the deduplicated ACME hosts
const adjustedAcmeTotal = deduplicatedAcmeHosts.length;
const paginatedAcmeHosts = deduplicatedAcmeHosts.slice(offset, offset + PER_PAGE);
const importedCerts: ImportedCertView[] = []; const importedCerts: ImportedCertView[] = [];
const managedCerts: ManagedCertView[] = []; const managedCerts: ManagedCertView[] = [];
const issuedByCa = issuedClientCerts.reduce<Map<number, IssuedClientCertificate[]>>((map, cert) => { const issuedByCa = issuedClientCerts.reduce<Map<number, IssuedClientCertificate[]>>((map, cert) => {
@@ -174,11 +229,11 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
return ( return (
<CertificatesClient <CertificatesClient
acmeHosts={acmeHosts} acmeHosts={paginatedAcmeHosts}
importedCerts={importedCerts} importedCerts={importedCerts}
managedCerts={managedCerts} managedCerts={managedCerts}
caCertificates={caCertificateViews} caCertificates={caCertificateViews}
acmePagination={{ total: acmeTotal, page, perPage: PER_PAGE }} acmePagination={{ total: adjustedAcmeTotal, page, perPage: PER_PAGE }}
mtlsRoles={mtlsRoles} mtlsRoles={mtlsRoles}
issuedClientCerts={issuedClientCerts} issuedClientCerts={issuedClientCerts}
/> />

0
app/(dashboard)/groups/GroupsClient.tsx Normal file → Executable file
View File

0
app/(dashboard)/groups/actions.ts Normal file → Executable file
View File

0
app/(dashboard)/groups/page.tsx Normal file → Executable file
View File

0
app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx Normal file → Executable file
View File

0
app/(dashboard)/l4-proxy-hosts/actions.ts Normal file → Executable file
View File

0
app/(dashboard)/l4-proxy-hosts/page.tsx Normal file → Executable file
View File

0
app/(dashboard)/layout.tsx Normal file → Executable file
View File

41
app/(dashboard)/page.tsx Normal file → Executable file
View File

@@ -11,6 +11,7 @@ import { count, desc, isNull, sql } from "drizzle-orm";
import { ArrowLeftRight, ShieldCheck, KeyRound } from "lucide-react"; import { ArrowLeftRight, ShieldCheck, KeyRound } from "lucide-react";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { getAnalyticsSummary } from "@/src/lib/analytics-db"; import { getAnalyticsSummary } from "@/src/lib/analytics-db";
import { isDomainCoveredByCert } from "@/src/lib/cert-domain-match";
type StatCard = { type StatCard = {
label: string; label: string;
@@ -20,19 +21,51 @@ type StatCard = {
}; };
async function loadStats(): Promise<StatCard[]> { async function loadStats(): Promise<StatCard[]> {
const [proxyHostCountResult, acmeCertCountResult, importedCertCountResult, accessListCountResult] = const [proxyHostCountResult, acmeRows, certRows, importedCertCountResult, accessListCountResult] =
await Promise.all([ await Promise.all([
db.select({ value: count() }).from(proxyHosts), db.select({ value: count() }).from(proxyHosts),
// Proxy hosts with no explicit cert → Caddy auto-issues one ACME cert per host // All proxy hosts with no explicit cert (for ACME deduplication)
db.select({ value: count() }).from(proxyHosts).where(isNull(proxyHosts.certificateId)), db.select({ domains: proxyHosts.domains }).from(proxyHosts).where(isNull(proxyHosts.certificateId)),
// All certs (for wildcard coverage check)
db.select({ id: certificates.id, type: certificates.type, domainNames: certificates.domainNames, certificatePem: certificates.certificatePem }).from(certificates),
// Imported certs with actual PEM data (valid, user-managed) // Imported certs with actual PEM data (valid, user-managed)
db.select({ value: count() }).from(certificates).where( db.select({ value: count() }).from(certificates).where(
sql`${certificates.type} = 'imported' AND ${certificates.certificatePem} IS NOT NULL` sql`${certificates.type} = 'imported' AND ${certificates.certificatePem} IS NOT NULL`
), ),
db.select({ value: count() }).from(accessLists) db.select({ value: count() }).from(accessLists)
]); ]);
// Build cert domain map for wildcard coverage checks
const certDomainMap = new Map<number, string[]>();
for (const cert of certRows) {
certDomainMap.set(cert.id, JSON.parse(cert.domainNames) as string[]);
}
// Deduplicate ACME hosts: remove those covered by a cert's wildcard or another ACME wildcard
const acmeHostDomains = acmeRows.map(r => JSON.parse(r.domains) as string[]);
const wildcardAcmeDomainSets = acmeHostDomains.filter(domains => domains.some((d: string) => d.startsWith('*.')));
let acmeCount = 0;
for (const domains of acmeHostDomains) {
// Check if covered by an existing certificate's wildcard
let covered = false;
for (const [, certDomains] of certDomainMap) {
if (domains.every((d: string) => isDomainCoveredByCert(d, certDomains))) {
covered = true;
break;
}
}
// Check if this non-wildcard host is covered by a wildcard ACME host
if (!covered && !domains.some((d: string) => d.startsWith('*.'))) {
covered = wildcardAcmeDomainSets.some(wcDomains =>
domains.every((d: string) => isDomainCoveredByCert(d, wcDomains))
);
}
if (!covered) acmeCount++;
}
const proxyHostsCount = proxyHostCountResult[0]?.value ?? 0; const proxyHostsCount = proxyHostCountResult[0]?.value ?? 0;
const certificatesCount = (acmeCertCountResult[0]?.value ?? 0) + (importedCertCountResult[0]?.value ?? 0); const certificatesCount = acmeCount + (importedCertCountResult[0]?.value ?? 0);
const accessListsCount = accessListCountResult[0]?.value ?? 0; const accessListsCount = accessListCountResult[0]?.value ?? 0;
return [ return [

0
app/(dashboard)/profile/ProfileClient.tsx Normal file → Executable file
View File

0
app/(dashboard)/profile/page.tsx Normal file → Executable file
View File

0
app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx Normal file → Executable file
View File

8
app/(dashboard)/proxy-hosts/actions.ts Normal file → Executable file
View File

@@ -77,6 +77,7 @@ function parseAuthentikConfig(formData: FormData): ProxyHostAuthentikInput | und
const copyHeaders = parseCsv(formData.get("authentik_copy_headers")); const copyHeaders = parseCsv(formData.get("authentik_copy_headers"));
const trustedProxies = parseCsv(formData.get("authentik_trusted_proxies")); const trustedProxies = parseCsv(formData.get("authentik_trusted_proxies"));
const protectedPaths = parseCsv(formData.get("authentik_protected_paths")); const protectedPaths = parseCsv(formData.get("authentik_protected_paths"));
const excludedPaths = parseCsv(formData.get("authentik_excluded_paths"));
const setHostHeader = formData.has("authentik_set_host_header_present") const setHostHeader = formData.has("authentik_set_host_header_present")
? parseCheckbox(formData.get("authentik_set_host_header")) ? parseCheckbox(formData.get("authentik_set_host_header"))
: undefined; : undefined;
@@ -103,6 +104,9 @@ function parseAuthentikConfig(formData: FormData): ProxyHostAuthentikInput | und
if (protectedPaths.length > 0 || formData.has("authentik_protected_paths")) { if (protectedPaths.length > 0 || formData.has("authentik_protected_paths")) {
result.protectedPaths = protectedPaths; result.protectedPaths = protectedPaths;
} }
if (excludedPaths.length > 0 || formData.has("authentik_excluded_paths")) {
result.excludedPaths = excludedPaths;
}
if (setHostHeader !== undefined) { if (setHostHeader !== undefined) {
result.setOutpostHostHeader = setHostHeader; result.setOutpostHostHeader = setHostHeader;
} }
@@ -122,6 +126,7 @@ function parseCpmForwardAuthConfig(formData: FormData): CpmForwardAuthInput | un
: false : false
: undefined; : undefined;
const protectedPaths = parseCsv(formData.get("cpm_forward_auth_protected_paths")); const protectedPaths = parseCsv(formData.get("cpm_forward_auth_protected_paths"));
const excludedPaths = parseCsv(formData.get("cpm_forward_auth_excluded_paths"));
const result: CpmForwardAuthInput = {}; const result: CpmForwardAuthInput = {};
if (enabledValue !== undefined) { if (enabledValue !== undefined) {
@@ -130,6 +135,9 @@ function parseCpmForwardAuthConfig(formData: FormData): CpmForwardAuthInput | un
if (protectedPaths.length > 0 || formData.has("cpm_forward_auth_protected_paths")) { if (protectedPaths.length > 0 || formData.has("cpm_forward_auth_protected_paths")) {
result.protected_paths = protectedPaths.length > 0 ? protectedPaths : null; result.protected_paths = protectedPaths.length > 0 ? protectedPaths : null;
} }
if (excludedPaths.length > 0 || formData.has("cpm_forward_auth_excluded_paths")) {
result.excluded_paths = excludedPaths.length > 0 ? excludedPaths : null;
}
return Object.keys(result).length > 0 ? result : undefined; return Object.keys(result).length > 0 ? result : undefined;
} }

0
app/(dashboard)/proxy-hosts/page.tsx Normal file → Executable file
View File

0
app/(dashboard)/settings/OAuthProvidersSection.tsx Normal file → Executable file
View File

200
app/(dashboard)/settings/SettingsClient.tsx Normal file → Executable file
View File

@@ -20,14 +20,16 @@ import type {
MetricsSettings, MetricsSettings,
LoggingSettings, LoggingSettings,
DnsSettings, DnsSettings,
DnsProviderSettings,
UpstreamDnsResolutionSettings, UpstreamDnsResolutionSettings,
GeoBlockSettings, GeoBlockSettings,
} from "@/lib/settings"; } from "@/lib/settings";
import type { DnsProviderDefinition } from "@/src/lib/dns-providers";
import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields"; import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields";
import OAuthProvidersSection from "./OAuthProvidersSection"; import OAuthProvidersSection from "./OAuthProvidersSection";
import type { OAuthProvider } from "@/src/lib/models/oauth-providers"; import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
import { import {
updateCloudflareSettingsAction, updateDnsProviderSettingsAction,
updateGeneralSettingsAction, updateGeneralSettingsAction,
updateAuthentikSettingsAction, updateAuthentikSettingsAction,
updateMetricsSettingsAction, updateMetricsSettingsAction,
@@ -112,7 +114,7 @@ function SettingSection({
const A: Record<string, AccentConfig> = { const A: Record<string, AccentConfig> = {
sync: { border: "border-l-violet-500", icon: "border-violet-500/30 bg-violet-500/10 text-violet-500" }, sync: { border: "border-l-violet-500", icon: "border-violet-500/30 bg-violet-500/10 text-violet-500" },
general: { border: "border-l-zinc-400", icon: "border-zinc-500/30 bg-zinc-500/10 text-zinc-500" }, general: { border: "border-l-zinc-400", icon: "border-zinc-500/30 bg-zinc-500/10 text-zinc-500" },
cloudflare: { border: "border-l-orange-500", icon: "border-orange-500/30 bg-orange-500/10 text-orange-500" }, dnsProvider:{ border: "border-l-orange-500", icon: "border-orange-500/30 bg-orange-500/10 text-orange-500" },
dns: { border: "border-l-cyan-500", icon: "border-cyan-500/30 bg-cyan-500/10 text-cyan-500" }, dns: { border: "border-l-cyan-500", icon: "border-cyan-500/30 bg-cyan-500/10 text-cyan-500" },
upstreamDns:{ border: "border-l-emerald-500", icon: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500" }, upstreamDns:{ border: "border-l-emerald-500", icon: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500" },
authentik: { border: "border-l-purple-500", icon: "border-purple-500/30 bg-purple-500/10 text-purple-500" }, authentik: { border: "border-l-purple-500", icon: "border-purple-500/30 bg-purple-500/10 text-purple-500" },
@@ -126,11 +128,8 @@ const A: Record<string, AccentConfig> = {
type Props = { type Props = {
general: GeneralSettings | null; general: GeneralSettings | null;
cloudflare: { dnsProvider: DnsProviderSettings | null;
hasToken: boolean; dnsProviderDefinitions: DnsProviderDefinition[];
zoneId?: string;
accountId?: string;
};
authentik: AuthentikSettings | null; authentik: AuthentikSettings | null;
metrics: MetricsSettings | null; metrics: MetricsSettings | null;
logging: LoggingSettings | null; logging: LoggingSettings | null;
@@ -145,7 +144,7 @@ type Props = {
tokenFromEnv: boolean; tokenFromEnv: boolean;
overrides: { overrides: {
general: boolean; general: boolean;
cloudflare: boolean; dnsProvider: boolean;
authentik: boolean; authentik: boolean;
metrics: boolean; metrics: boolean;
logging: boolean; logging: boolean;
@@ -178,7 +177,8 @@ type Props = {
export default function SettingsClient({ export default function SettingsClient({
general, general,
cloudflare, dnsProvider,
dnsProviderDefinitions,
authentik, authentik,
metrics, metrics,
logging, logging,
@@ -190,7 +190,9 @@ export default function SettingsClient({
instanceSync instanceSync
}: Props) { }: Props) {
const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null); const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null);
const [cloudflareState, cloudflareFormAction] = useFormState(updateCloudflareSettingsAction, null); const [dnsProviderState, dnsProviderFormAction] = useFormState(updateDnsProviderSettingsAction, null);
const [selectedProvider, setSelectedProvider] = useState("none");
const configuredProviders = dnsProvider?.providers ? Object.keys(dnsProvider.providers) : [];
const [authentikState, authentikFormAction] = useFormState(updateAuthentikSettingsAction, null); const [authentikState, authentikFormAction] = useFormState(updateAuthentikSettingsAction, null);
const [metricsState, metricsFormAction] = useFormState(updateMetricsSettingsAction, null); const [metricsState, metricsFormAction] = useFormState(updateMetricsSettingsAction, null);
const [loggingState, loggingFormAction] = useFormState(updateLoggingSettingsAction, null); const [loggingState, loggingFormAction] = useFormState(updateLoggingSettingsAction, null);
@@ -207,7 +209,7 @@ export default function SettingsClient({
const isSlave = instanceSync.mode === "slave"; const isSlave = instanceSync.mode === "slave";
const isMaster = instanceSync.mode === "master"; const isMaster = instanceSync.mode === "master";
const [generalOverride, setGeneralOverride] = useState(instanceSync.overrides.general); const [generalOverride, setGeneralOverride] = useState(instanceSync.overrides.general);
const [cloudflareOverride, setCloudflareOverride] = useState(instanceSync.overrides.cloudflare); const [dnsProviderOverride, setDnsProviderOverride] = useState(instanceSync.overrides.dnsProvider);
const [authentikOverride, setAuthentikOverride] = useState(instanceSync.overrides.authentik); const [authentikOverride, setAuthentikOverride] = useState(instanceSync.overrides.authentik);
const [metricsOverride, setMetricsOverride] = useState(instanceSync.overrides.metrics); const [metricsOverride, setMetricsOverride] = useState(instanceSync.overrides.metrics);
const [loggingOverride, setLoggingOverride] = useState(instanceSync.overrides.logging); const [loggingOverride, setLoggingOverride] = useState(instanceSync.overrides.logging);
@@ -463,65 +465,159 @@ export default function SettingsClient({
</form> </form>
</SettingSection> </SettingSection>
{/* ── Cloudflare DNS ── */} {/* ── DNS Providers ── */}
<SettingSection <SettingSection
icon={<Cloud className="h-4 w-4" />} icon={<Cloud className="h-4 w-4" />}
title="Cloudflare DNS" title="DNS Providers"
description="Configure a Cloudflare API token with Zone.DNS Edit permissions to enable DNS-01 challenges for wildcard certificates." description="Configure DNS providers for ACME DNS-01 challenges (required for wildcard certificates). You can add multiple providers and select a default."
accent={A.cloudflare} accent={A.dnsProvider}
> >
{cloudflare.hasToken && ( {dnsProviderState?.message && (
<InfoAlert> <StatusAlert message={dnsProviderState.message} success={dnsProviderState.success} />
A Cloudflare API token is already configured. Leave the token field blank to keep it, or select &ldquo;Remove existing token&rdquo; to delete it.
</InfoAlert>
)}
<form action={cloudflareFormAction} className="flex flex-col gap-3">
{cloudflareState?.message && (
<StatusAlert message={cloudflareState.message} success={cloudflareState.success} />
)} )}
{isSlave && ( {isSlave && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
id="cloudflare-override" id="dnsprovider-override"
name="overrideEnabled" name="overrideEnabled"
checked={cloudflareOverride} form="dnsp-add-form"
onCheckedChange={(v) => setCloudflareOverride(!!v)} checked={dnsProviderOverride}
onCheckedChange={(v) => setDnsProviderOverride(!!v)}
/> />
<Label htmlFor="cloudflare-override">Override master settings</Label> <Label htmlFor="dnsprovider-override">Override master settings</Label>
</div> </div>
)} )}
{/* Configured providers list */}
{configuredProviders.length > 0 && (
<div className="flex flex-col gap-2">
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Configured providers</Label>
{configuredProviders.map((name) => {
const def = dnsProviderDefinitions.find((p) => p.name === name);
const isDefault = dnsProvider?.default === name;
return (
<div
key={name}
className="flex items-center justify-between gap-3 rounded-md border px-4 py-2.5"
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{def?.displayName ?? name}</span>
{isDefault && <StatusChip status="active" label="Default" />}
</div>
<div className="flex gap-2">
{!isDefault && (
<form action={dnsProviderFormAction}>
<input type="hidden" name="action" value="set-default" />
<input type="hidden" name="provider" value={name} />
{isSlave && <input type="hidden" name="overrideEnabled" value={dnsProviderOverride ? "on" : ""} />}
<Button type="submit" variant="outline" size="sm" className="text-emerald-600 border-emerald-500/50">
Set default
</Button>
</form>
)}
<form action={dnsProviderFormAction}>
<input type="hidden" name="action" value="remove" />
<input type="hidden" name="provider" value={name} />
{isSlave && <input type="hidden" name="overrideEnabled" value={dnsProviderOverride ? "on" : ""} />}
<Button type="submit" variant="outline" size="sm" className="text-destructive border-destructive/50">
Remove
</Button>
</form>
</div>
</div>
);
})}
{dnsProvider?.default && (
<form action={dnsProviderFormAction}>
<input type="hidden" name="action" value="set-default" />
<input type="hidden" name="provider" value="none" />
{isSlave && <input type="hidden" name="overrideEnabled" value={dnsProviderOverride ? "on" : ""} />}
<Button type="submit" variant="ghost" size="sm" className="text-xs text-muted-foreground">
Clear default (HTTP-01 only)
</Button>
</form>
)}
</div>
)}
{/* Add / update provider form */}
<form id="dnsp-add-form" action={dnsProviderFormAction} className="flex flex-col gap-3">
<input type="hidden" name="action" value="save" />
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{configuredProviders.length > 0 ? "Add or update provider" : "Add a provider"}
</Label>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="cf-apiToken">API token</Label> <Label htmlFor="dns-provider-select">Provider</Label>
<Select
name="provider"
value={selectedProvider}
onValueChange={setSelectedProvider}
disabled={isSlave && !dnsProviderOverride}
>
<SelectTrigger id="dns-provider-select">
<SelectValue placeholder="Select a DNS provider..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Select...</SelectItem>
{dnsProviderDefinitions.map((p) => (
<SelectItem key={p.name} value={p.name}>
{p.displayName}{configuredProviders.includes(p.name) ? " (update)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Dynamic credential fields */}
{selectedProvider && selectedProvider !== "none" && (() => {
const providerDef = dnsProviderDefinitions.find((p) => p.name === selectedProvider);
if (!providerDef) return null;
const isUpdate = configuredProviders.includes(selectedProvider);
return (
<div className="flex flex-col gap-3">
{providerDef.description && (
<p className="text-xs text-muted-foreground">{providerDef.description}</p>
)}
{providerDef.fields.map((field) => (
<div key={field.key} className="flex flex-col gap-1.5">
<Label htmlFor={`dnsp-${field.key}`} className="text-xs">
{field.label}{field.required ? "" : " (optional)"}
</Label>
<Input <Input
id="cf-apiToken" id={`dnsp-${field.key}`}
name="apiToken" name={`credential_${field.key}`}
type="password" type={field.type === "password" ? "password" : "text"}
autoComplete="new-password" autoComplete={field.type === "password" ? "new-password" : "off"}
placeholder="Enter new token" placeholder={field.placeholder ?? ""}
disabled={isSlave && !cloudflareOverride} disabled={isSlave && !dnsProviderOverride}
className="h-8 text-sm" className="h-8 text-sm"
/> />
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div> </div>
<div className="flex items-center gap-2"> ))}
<Checkbox {isUpdate && (
id="cf-clearToken" <InfoAlert>
name="clearToken" Credentials are already configured. Leave fields blank to keep existing values.
disabled={!cloudflare.hasToken || (isSlave && !cloudflareOverride)} </InfoAlert>
/> )}
<Label htmlFor="cf-clearToken">Remove existing token</Label> {providerDef.docsUrl && (
</div> <p className="text-xs text-muted-foreground">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <a href={providerDef.docsUrl} target="_blank" rel="noopener noreferrer" className="underline">
<div className="flex flex-col gap-1.5"> Provider documentation
<Label htmlFor="cf-zoneId">Zone ID</Label> </a>
<Input id="cf-zoneId" name="zoneId" defaultValue={cloudflare.zoneId ?? ""} disabled={isSlave && !cloudflareOverride} className="h-8 text-sm font-mono" /> </p>
</div> )}
<div className="flex flex-col gap-1.5">
<Label htmlFor="cf-accountId">Account ID</Label>
<Input id="cf-accountId" name="accountId" defaultValue={cloudflare.accountId ?? ""} disabled={isSlave && !cloudflareOverride} className="h-8 text-sm font-mono" />
</div>
</div> </div>
);
})()}
{isSlave && <input type="hidden" name="overrideEnabled" value={dnsProviderOverride ? "on" : ""} />}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" size="sm">Save Cloudflare settings</Button> <Button type="submit" size="sm" disabled={!selectedProvider || selectedProvider === "none"}>
{selectedProvider && selectedProvider !== "none" && configuredProviders.includes(selectedProvider) ? "Update provider" : "Add provider"}
</Button>
</div> </div>
</form> </form>
</SettingSection> </SettingSection>

121
app/(dashboard)/settings/actions.ts Normal file → Executable file
View File

@@ -5,10 +5,11 @@ import { requireAdmin } from "@/src/lib/auth";
import { applyCaddyConfig } from "@/src/lib/caddy"; import { applyCaddyConfig } from "@/src/lib/caddy";
import { getInstanceMode, getSlaveMasterToken, setInstanceMode, setSlaveMasterToken, syncInstances } from "@/src/lib/instance-sync"; import { getInstanceMode, getSlaveMasterToken, setInstanceMode, setSlaveMasterToken, syncInstances } from "@/src/lib/instance-sync";
import { createInstance, deleteInstance, updateInstance } from "@/src/lib/models/instances"; import { createInstance, deleteInstance, updateInstance } from "@/src/lib/models/instances";
import { clearSetting, getSetting, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings, saveUpstreamDnsResolutionSettings, saveGeoBlockSettings, saveWafSettings, getWafSettings } from "@/src/lib/settings"; import { clearSetting, getSetting, saveCloudflareSettings, getDnsProviderSettings, saveDnsProviderSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings, saveUpstreamDnsResolutionSettings, saveGeoBlockSettings, saveWafSettings, getWafSettings } from "@/src/lib/settings";
import { listProxyHosts, updateProxyHost } from "@/src/lib/models/proxy-hosts"; import { listProxyHosts, updateProxyHost } from "@/src/lib/models/proxy-hosts";
import { getWafRuleMessages } from "@/src/lib/models/waf-events"; import { getWafRuleMessages } from "@/src/lib/models/waf-events";
import type { CloudflareSettings, GeoBlockSettings, WafSettings } from "@/src/lib/settings"; import type { CloudflareSettings, DnsProviderSettings, GeoBlockSettings, WafSettings } from "@/src/lib/settings";
import { getProviderDefinition, encryptProviderCredentials } from "@/src/lib/dns-providers";
type ActionResult = { type ActionResult = {
success: boolean; success: boolean;
@@ -113,6 +114,122 @@ export async function updateCloudflareSettingsAction(_prevState: ActionResult |
} }
} }
export async function updateDnsProviderSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
try {
await requireAdmin();
const mode = await getInstanceMode();
const overrideEnabled = formData.get("overrideEnabled") === "on";
if (mode === "slave" && !overrideEnabled) {
await clearSetting("dns_provider");
try {
await applyCaddyConfig();
revalidatePath("/settings");
return { success: true, message: "DNS provider settings reset to master defaults" };
} catch (error) {
console.error("Failed to apply Caddy config:", error);
revalidatePath("/settings");
const errorMsg = error instanceof Error ? error.message : "Unknown error";
await syncInstances();
return { success: true, message: `Settings reset, but could not apply to Caddy: ${errorMsg}` };
}
}
const action = String(formData.get("action") ?? "save").trim();
const providerName = String(formData.get("provider") ?? "").trim();
const current = await getDnsProviderSettings();
const settings: DnsProviderSettings = current ?? { providers: {}, default: null };
if (action === "remove") {
if (!providerName || !settings.providers[providerName]) {
return { success: false, message: "No provider to remove" };
}
const def = getProviderDefinition(providerName);
delete settings.providers[providerName];
if (settings.default === providerName) {
// Pick next configured provider, or null
const remaining = Object.keys(settings.providers);
settings.default = remaining.length > 0 ? remaining[0] : null;
}
await saveDnsProviderSettings(settings);
await syncInstances();
try { await applyCaddyConfig(); } catch { /* non-fatal */ }
revalidatePath("/settings");
return { success: true, message: `${def?.displayName ?? providerName} removed${settings.default ? `. Default is now ${settings.default}.` : "."}` };
}
if (action === "set-default") {
const newDefault = providerName === "none" ? null : providerName;
if (newDefault && !settings.providers[newDefault]) {
return { success: false, message: `Cannot set default: ${providerName} is not configured` };
}
settings.default = newDefault;
await saveDnsProviderSettings(settings);
await syncInstances();
try { await applyCaddyConfig(); } catch { /* non-fatal */ }
revalidatePath("/settings");
const label = newDefault ? (getProviderDefinition(newDefault)?.displayName ?? newDefault) : "None";
return { success: true, message: `Default DNS provider set to ${label}` };
}
// action === "save": add or update a provider's credentials
if (!providerName || providerName === "none") {
return { success: false, message: "Select a provider to configure" };
}
const def = getProviderDefinition(providerName);
if (!def) {
return { success: false, message: `Unknown DNS provider: ${providerName}` };
}
const existingCreds = settings.providers[providerName];
// Collect credentials from form
const credentials: Record<string, string> = {};
for (const field of def.fields) {
const rawValue = formData.get(`credential_${field.key}`);
const value = rawValue ? String(rawValue).trim() : "";
if (value) {
credentials[field.key] = value;
} else if (existingCreds?.[field.key]) {
credentials[field.key] = existingCreds[field.key];
}
}
// Validate required fields
for (const field of def.fields) {
if (field.required && !credentials[field.key]) {
return { success: false, message: `${field.label} is required for ${def.displayName}` };
}
}
// Encrypt password fields before storing
settings.providers[providerName] = encryptProviderCredentials(providerName, credentials);
// If this is the first provider, make it the default
if (!settings.default) {
settings.default = providerName;
}
await saveDnsProviderSettings(settings);
await syncInstances();
try {
await applyCaddyConfig();
revalidatePath("/settings");
const isDefault = settings.default === providerName;
return { success: true, message: `${def.displayName} saved${isDefault ? " (default)" : ""}` };
} catch (error) {
console.error("Failed to apply Caddy config:", error);
revalidatePath("/settings");
const errorMsg = error instanceof Error ? error.message : "Unknown error";
return { success: true, message: `Settings saved, but could not apply to Caddy: ${errorMsg}` };
}
} catch (error) {
console.error("Failed to save DNS provider settings:", error);
return { success: false, message: error instanceof Error ? error.message : "Failed to save DNS provider settings" };
}
}
export async function updateAuthentikSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> { export async function updateAuthentikSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
try { try {
await requireAdmin(); await requireAdmin();

20
app/(dashboard)/settings/page.tsx Normal file → Executable file
View File

@@ -1,8 +1,9 @@
import SettingsClient from "./SettingsClient"; import SettingsClient from "./SettingsClient";
import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getSetting, getUpstreamDnsResolutionSettings, getGeoBlockSettings } from "@/src/lib/settings"; import { getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getDnsProviderSettings, getSetting, getUpstreamDnsResolutionSettings, getGeoBlockSettings } from "@/src/lib/settings";
import { getInstanceMode, getSlaveLastSync, getSlaveMasterToken, isInstanceModeFromEnv, isSyncTokenFromEnv, getEnvSlaveInstances } from "@/src/lib/instance-sync"; import { getInstanceMode, getSlaveLastSync, getSlaveMasterToken, isInstanceModeFromEnv, isSyncTokenFromEnv, getEnvSlaveInstances } from "@/src/lib/instance-sync";
import { listInstances } from "@/src/lib/models/instances"; import { listInstances } from "@/src/lib/models/instances";
import { listOAuthProviders } from "@/src/lib/models/oauth-providers"; import { listOAuthProviders } from "@/src/lib/models/oauth-providers";
import { DNS_PROVIDERS } from "@/src/lib/dns-providers";
import { config } from "@/src/lib/config"; import { config } from "@/src/lib/config";
import { requireAdmin } from "@/src/lib/auth"; import { requireAdmin } from "@/src/lib/auth";
@@ -13,9 +14,9 @@ export default async function SettingsPage() {
const modeFromEnv = isInstanceModeFromEnv(); const modeFromEnv = isInstanceModeFromEnv();
const tokenFromEnv = isSyncTokenFromEnv(); const tokenFromEnv = isSyncTokenFromEnv();
const [general, cloudflare, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock, oauthProviders] = await Promise.all([ const [general, dnsProvider, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock, oauthProviders] = await Promise.all([
getGeneralSettings(), getGeneralSettings(),
getCloudflareSettings(), getDnsProviderSettings(),
getAuthentikSettings(), getAuthentikSettings(),
getMetricsSettings(), getMetricsSettings(),
getLoggingSettings(), getLoggingSettings(),
@@ -26,11 +27,11 @@ export default async function SettingsPage() {
listOAuthProviders(), listOAuthProviders(),
]); ]);
const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] = const [overrideGeneral, overrideDnsProvider, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] =
instanceMode === "slave" instanceMode === "slave"
? await Promise.all([ ? await Promise.all([
getSetting("general"), getSetting("general"),
getSetting("cloudflare"), getSetting("dns_provider"),
getSetting("authentik"), getSetting("authentik"),
getSetting("metrics"), getSetting("metrics"),
getSetting("logging"), getSetting("logging"),
@@ -49,11 +50,8 @@ export default async function SettingsPage() {
return ( return (
<SettingsClient <SettingsClient
general={general} general={general}
cloudflare={{ dnsProvider={dnsProvider}
hasToken: Boolean(cloudflare?.apiToken), dnsProviderDefinitions={DNS_PROVIDERS}
zoneId: cloudflare?.zoneId,
accountId: cloudflare?.accountId
}}
authentik={authentik} authentik={authentik}
metrics={metrics} metrics={metrics}
logging={logging} logging={logging}
@@ -68,7 +66,7 @@ export default async function SettingsPage() {
tokenFromEnv, tokenFromEnv,
overrides: { overrides: {
general: overrideGeneral !== null, general: overrideGeneral !== null,
cloudflare: overrideCloudflare !== null, dnsProvider: overrideDnsProvider !== null,
authentik: overrideAuthentik !== null, authentik: overrideAuthentik !== null,
metrics: overrideMetrics !== null, metrics: overrideMetrics !== null,
logging: overrideLogging !== null, logging: overrideLogging !== null,

0
app/(dashboard)/users/UsersClient.tsx Normal file → Executable file
View File

0
app/(dashboard)/users/actions.ts Normal file → Executable file
View File

0
app/(dashboard)/users/page.tsx Normal file → Executable file
View File

0
app/(dashboard)/waf/WafEventsClient.tsx Normal file → Executable file
View File

0
app/(dashboard)/waf/page.tsx Normal file → Executable file
View File

0
app/api/analytics/blocked/route.ts Normal file → Executable file
View File

0
app/api/analytics/countries/route.ts Normal file → Executable file
View File

0
app/api/analytics/hosts/route.ts Normal file → Executable file
View File

0
app/api/analytics/protocols/route.ts Normal file → Executable file
View File

0
app/api/analytics/summary/route.ts Normal file → Executable file
View File

0
app/api/analytics/timeline/route.ts Normal file → Executable file
View File

0
app/api/analytics/user-agents/route.ts Normal file → Executable file
View File

0
app/api/analytics/waf-stats/route.ts Normal file → Executable file
View File

0
app/api/auth/[...all]/route.ts Normal file → Executable file
View File

0
app/api/auth/link-account/route.ts Normal file → Executable file
View File

3
app/api/auth/logout/route.ts Normal file → Executable file
View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getAuth } from "@/src/lib/auth-server"; import { getAuth } from "@/src/lib/auth-server";
import { checkSameOrigin } from "@/src/lib/auth"; import { checkSameOrigin } from "@/src/lib/auth";
import { config } from "@/src/lib/config";
import { headers } from "next/headers"; import { headers } from "next/headers";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -10,5 +11,5 @@ export async function POST(request: NextRequest) {
if (originCheck) return originCheck; if (originCheck) return originCheck;
await getAuth().api.signOut({ headers: await headers() }); await getAuth().api.signOut({ headers: await headers() });
return NextResponse.redirect(new URL("/login", request.url)); return NextResponse.redirect(new URL("/login", config.baseUrl));
} }

0
app/api/forward-auth/callback/route.ts Normal file → Executable file
View File

0
app/api/forward-auth/login/route.ts Normal file → Executable file
View File

0
app/api/forward-auth/session-login/route.ts Normal file → Executable file
View File

0
app/api/forward-auth/verify/route.ts Normal file → Executable file
View File

0
app/api/geoip-status/route.ts Normal file → Executable file
View File

0
app/api/health/route.ts Normal file → Executable file
View File

0
app/api/instances/sync/route.ts Normal file → Executable file
View File

0
app/api/l4-ports/route.ts Normal file → Executable file
View File

0
app/api/user/change-password/route.ts Normal file → Executable file
View File

0
app/api/user/link-oauth-start/route.ts Normal file → Executable file
View File

0
app/api/user/unlink-oauth/route.ts Normal file → Executable file
View File

0
app/api/user/update-avatar/route.ts Normal file → Executable file
View File

View File

0
app/api/v1/access-lists/[id]/entries/route.ts Normal file → Executable file
View File

0
app/api/v1/access-lists/[id]/route.ts Normal file → Executable file
View File

0
app/api/v1/access-lists/route.ts Normal file → Executable file
View File

0
app/api/v1/audit-log/route.ts Normal file → Executable file
View File

0
app/api/v1/ca-certificates/[id]/route.ts Normal file → Executable file
View File

0
app/api/v1/ca-certificates/route.ts Normal file → Executable file
View File

0
app/api/v1/caddy/apply/route.ts Normal file → Executable file
View File

Some files were not shown because too many files have changed in this diff Show More