Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c27f4e40a3 | |||
| f861b95c9b | |||
| d6bb1871dd | |||
| 12da316ace | |||
| 3fb643f41e | |||
| 25cd4669b2 | |||
| 47af056a7c | |||
| 7a91843c79 | |||
| 99819b70ff | |||
|
|
4c5ad53370 | ||
|
|
d710ad1247 | ||
|
|
6515da666f | ||
|
|
96bac86934 | ||
|
|
dbfc340ea4 | ||
|
|
521a059414 | ||
|
|
1be8fc2629 | ||
|
|
6d2827a132 | ||
|
|
eb11856994 | ||
|
|
7d61528dad | ||
|
|
92fa1cb9d8 | ||
|
|
ef62ef232f | ||
|
|
0e47ec4d7d | ||
|
|
5c78a8e8f6 | ||
|
|
2c70f2859a | ||
|
|
60633bf6c3 | ||
|
|
a520717aab | ||
|
|
8f4c24119e | ||
|
|
390840dbd9 | ||
|
|
3a4807b5cd | ||
|
|
0c632811b4 | ||
|
|
81be14e95e |
0
.dockerignore
Normal file → Executable file
0
.dockerignore
Normal file → Executable file
0
.env.example
Normal file → Executable file
0
.env.example
Normal file → Executable file
0
.github/FUNDING.yml
vendored
Normal file → Executable file
0
.github/FUNDING.yml
vendored
Normal file → Executable file
0
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file → Executable file
0
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file → Executable file
0
.github/ISSUE_TEMPLATE/dns_challenge_request.md
vendored
Normal file → Executable file
0
.github/ISSUE_TEMPLATE/dns_challenge_request.md
vendored
Normal file → Executable file
0
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file → Executable file
0
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file → Executable file
4
.github/dependabot.yml
vendored
Normal file → Executable file
4
.github/dependabot.yml
vendored
Normal file → Executable file
@@ -17,8 +17,8 @@ updates:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
|
||||
# NPM dependencies updates
|
||||
- package-ecosystem: "npm"
|
||||
# Bun dependencies updates
|
||||
- package-ecosystem: "bun"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
32
.github/workflows/dependabot-automerge.yml
vendored
Executable file
32
.github/workflows/dependabot-automerge.yml
vendored
Executable 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
0
.github/workflows/docker-build-pr.yml
vendored
Normal file → Executable file
1
.github/workflows/docker-build-trusted.yml
vendored
Normal file → Executable file
1
.github/workflows/docker-build-trusted.yml
vendored
Normal file → Executable file
@@ -57,6 +57,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=match,pattern=v(.*),group=1
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
|
||||
0
.github/workflows/stale.yml
vendored
Normal file → Executable file
0
.github/workflows/stale.yml
vendored
Normal file → Executable file
0
.github/workflows/test.yml
vendored
Normal file → Executable file
0
.github/workflows/test.yml
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
9
README.md
Normal file → Executable file
9
README.md
Normal file → Executable 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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
@@ -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
|
||||
- **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
|
||||
- **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
|
||||
- **Search & Pagination** - Server-side search and pagination on all data tables
|
||||
- **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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -325,8 +326,6 @@ Each forward-auth-protected host has its own access list of allowed users and/or
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Additional DNS providers (Route53, Namecheap, etc.)
|
||||
|
||||
[Open an issue](https://github.com/fuomag9/caddy-proxy-manager/issues) for feature requests.
|
||||
|
||||
---
|
||||
|
||||
0
SECURITY.md
Normal file → Executable file
0
SECURITY.md
Normal file → Executable file
0
app/(auth)/link-account/LinkAccountClient.tsx
Normal file → Executable file
0
app/(auth)/link-account/LinkAccountClient.tsx
Normal file → Executable file
0
app/(auth)/link-account/page.tsx
Normal file → Executable file
0
app/(auth)/link-account/page.tsx
Normal file → Executable file
0
app/(auth)/login/LoginClient.tsx
Normal file → Executable file
0
app/(auth)/login/LoginClient.tsx
Normal file → Executable file
0
app/(auth)/login/page.tsx
Normal file → Executable file
0
app/(auth)/login/page.tsx
Normal file → Executable file
0
app/(auth)/portal/PortalLoginForm.tsx
Normal file → Executable file
0
app/(auth)/portal/PortalLoginForm.tsx
Normal file → Executable file
0
app/(auth)/portal/page.tsx
Normal file → Executable file
0
app/(auth)/portal/page.tsx
Normal file → Executable file
0
app/(dashboard)/DashboardLayoutClient.tsx
Normal file → Executable file
0
app/(dashboard)/DashboardLayoutClient.tsx
Normal file → Executable file
0
app/(dashboard)/OverviewClient.tsx
Normal file → Executable file
0
app/(dashboard)/OverviewClient.tsx
Normal file → Executable file
0
app/(dashboard)/access-lists/AccessListsClient.tsx
Normal file → Executable file
0
app/(dashboard)/access-lists/AccessListsClient.tsx
Normal file → Executable file
0
app/(dashboard)/access-lists/actions.ts
Normal file → Executable file
0
app/(dashboard)/access-lists/actions.ts
Normal file → Executable file
0
app/(dashboard)/access-lists/page.tsx
Normal file → Executable file
0
app/(dashboard)/access-lists/page.tsx
Normal file → Executable file
0
app/(dashboard)/analytics/AnalyticsClient.tsx
Normal file → Executable file
0
app/(dashboard)/analytics/AnalyticsClient.tsx
Normal file → Executable file
0
app/(dashboard)/analytics/WorldMapInner.tsx
Normal file → Executable file
0
app/(dashboard)/analytics/WorldMapInner.tsx
Normal file → Executable file
0
app/(dashboard)/analytics/page.tsx
Normal file → Executable file
0
app/(dashboard)/analytics/page.tsx
Normal file → Executable file
0
app/(dashboard)/api-docs/ApiDocsClient.tsx
Normal file → Executable file
0
app/(dashboard)/api-docs/ApiDocsClient.tsx
Normal file → Executable file
0
app/(dashboard)/api-docs/page.tsx
Normal file → Executable file
0
app/(dashboard)/api-docs/page.tsx
Normal file → Executable file
0
app/(dashboard)/api-tokens/actions.ts
Normal file → Executable file
0
app/(dashboard)/api-tokens/actions.ts
Normal file → Executable file
0
app/(dashboard)/audit-log/AuditLogClient.tsx
Normal file → Executable file
0
app/(dashboard)/audit-log/AuditLogClient.tsx
Normal file → Executable file
0
app/(dashboard)/audit-log/page.tsx
Normal file → Executable file
0
app/(dashboard)/audit-log/page.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/CertificatesClient.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/CertificatesClient.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/actions.ts
Normal file → Executable file
0
app/(dashboard)/certificates/actions.ts
Normal file → Executable file
0
app/(dashboard)/certificates/ca-actions.ts
Normal file → Executable file
0
app/(dashboard)/certificates/ca-actions.ts
Normal file → Executable file
0
app/(dashboard)/certificates/components/AcmeTab.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/components/AcmeTab.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/components/CaCertDrawer.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/components/CaCertDrawer.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/components/CaTab.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/components/CaTab.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/components/ImportCertDrawer.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/components/ImportCertDrawer.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/components/ImportedTab.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/components/ImportedTab.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/components/RelativeTime.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/components/RelativeTime.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/components/StatusSummaryBar.tsx
Normal file → Executable file
0
app/(dashboard)/certificates/components/StatusSummaryBar.tsx
Normal file → Executable file
79
app/(dashboard)/certificates/page.tsx
Normal file → Executable file
79
app/(dashboard)/certificates/page.tsx
Normal file → Executable file
@@ -7,6 +7,7 @@ import CertificatesClient from './CertificatesClient';
|
||||
import { listCaCertificates, type CaCertificate } from '@/src/lib/models/ca-certificates';
|
||||
import { listIssuedClientCertificates, type IssuedClientCertificate } from '@/src/lib/models/issued-client-certificates';
|
||||
import { listMtlsRoles, type MtlsRole } from '@/src/lib/models/mtls-roles';
|
||||
import { isDomainCoveredByCert } from '@/src/lib/cert-domain-match';
|
||||
|
||||
export type { CaCertificate };
|
||||
export type { IssuedClientCertificate };
|
||||
@@ -78,6 +79,7 @@ function getExpiryStatus(validToIso: string): CertExpiryStatus {
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
|
||||
export default async function CertificatesPage({ searchParams }: PageProps) {
|
||||
await requireAdmin();
|
||||
const { page: pageParam } = await searchParams;
|
||||
@@ -89,7 +91,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
|
||||
]);
|
||||
const mtlsRoles = await listMtlsRoles().catch(() => []);
|
||||
|
||||
const [acmeRows, acmeTotal, certRows, usageRows] = await Promise.all([
|
||||
const [allAcmeRows, certRows, usageRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: proxyHosts.id,
|
||||
@@ -100,14 +102,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
|
||||
})
|
||||
.from(proxyHosts)
|
||||
.where(isNull(proxyHosts.certificateId))
|
||||
.orderBy(proxyHosts.name)
|
||||
.limit(PER_PAGE)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(proxyHosts)
|
||||
.where(isNull(proxyHosts.certificateId))
|
||||
.then(([r]) => r?.value ?? 0),
|
||||
.orderBy(proxyHosts.name),
|
||||
db.select().from(certificates),
|
||||
db
|
||||
.select({
|
||||
@@ -120,7 +115,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
|
||||
.where(isNotNull(proxyHosts.certificateId)),
|
||||
]);
|
||||
|
||||
const acmeHosts: AcmeHost[] = acmeRows.map(r => ({
|
||||
const allAcmeHosts: AcmeHost[] = allAcmeRows.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
domains: JSON.parse(r.domains) as string[],
|
||||
@@ -140,6 +135,66 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
|
||||
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 managedCerts: ManagedCertView[] = [];
|
||||
const issuedByCa = issuedClientCerts.reduce<Map<number, IssuedClientCertificate[]>>((map, cert) => {
|
||||
@@ -174,11 +229,11 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
|
||||
|
||||
return (
|
||||
<CertificatesClient
|
||||
acmeHosts={acmeHosts}
|
||||
acmeHosts={paginatedAcmeHosts}
|
||||
importedCerts={importedCerts}
|
||||
managedCerts={managedCerts}
|
||||
caCertificates={caCertificateViews}
|
||||
acmePagination={{ total: acmeTotal, page, perPage: PER_PAGE }}
|
||||
acmePagination={{ total: adjustedAcmeTotal, page, perPage: PER_PAGE }}
|
||||
mtlsRoles={mtlsRoles}
|
||||
issuedClientCerts={issuedClientCerts}
|
||||
/>
|
||||
|
||||
0
app/(dashboard)/groups/GroupsClient.tsx
Normal file → Executable file
0
app/(dashboard)/groups/GroupsClient.tsx
Normal file → Executable file
0
app/(dashboard)/groups/actions.ts
Normal file → Executable file
0
app/(dashboard)/groups/actions.ts
Normal file → Executable file
0
app/(dashboard)/groups/page.tsx
Normal file → Executable file
0
app/(dashboard)/groups/page.tsx
Normal file → Executable file
0
app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx
Normal file → Executable file
0
app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx
Normal file → Executable file
0
app/(dashboard)/l4-proxy-hosts/actions.ts
Normal file → Executable file
0
app/(dashboard)/l4-proxy-hosts/actions.ts
Normal file → Executable file
0
app/(dashboard)/l4-proxy-hosts/page.tsx
Normal file → Executable file
0
app/(dashboard)/l4-proxy-hosts/page.tsx
Normal file → Executable file
0
app/(dashboard)/layout.tsx
Normal file → Executable file
0
app/(dashboard)/layout.tsx
Normal file → Executable file
41
app/(dashboard)/page.tsx
Normal file → Executable file
41
app/(dashboard)/page.tsx
Normal file → Executable file
@@ -11,6 +11,7 @@ import { count, desc, isNull, sql } from "drizzle-orm";
|
||||
import { ArrowLeftRight, ShieldCheck, KeyRound } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
import { getAnalyticsSummary } from "@/src/lib/analytics-db";
|
||||
import { isDomainCoveredByCert } from "@/src/lib/cert-domain-match";
|
||||
|
||||
type StatCard = {
|
||||
label: string;
|
||||
@@ -20,19 +21,51 @@ type StatCard = {
|
||||
};
|
||||
|
||||
async function loadStats(): Promise<StatCard[]> {
|
||||
const [proxyHostCountResult, acmeCertCountResult, importedCertCountResult, accessListCountResult] =
|
||||
const [proxyHostCountResult, acmeRows, certRows, importedCertCountResult, accessListCountResult] =
|
||||
await Promise.all([
|
||||
db.select({ value: count() }).from(proxyHosts),
|
||||
// Proxy hosts with no explicit cert → Caddy auto-issues one ACME cert per host
|
||||
db.select({ value: count() }).from(proxyHosts).where(isNull(proxyHosts.certificateId)),
|
||||
// All proxy hosts with no explicit cert (for ACME deduplication)
|
||||
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)
|
||||
db.select({ value: count() }).from(certificates).where(
|
||||
sql`${certificates.type} = 'imported' AND ${certificates.certificatePem} IS NOT NULL`
|
||||
),
|
||||
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 certificatesCount = (acmeCertCountResult[0]?.value ?? 0) + (importedCertCountResult[0]?.value ?? 0);
|
||||
const certificatesCount = acmeCount + (importedCertCountResult[0]?.value ?? 0);
|
||||
const accessListsCount = accessListCountResult[0]?.value ?? 0;
|
||||
|
||||
return [
|
||||
|
||||
0
app/(dashboard)/profile/ProfileClient.tsx
Normal file → Executable file
0
app/(dashboard)/profile/ProfileClient.tsx
Normal file → Executable file
0
app/(dashboard)/profile/page.tsx
Normal file → Executable file
0
app/(dashboard)/profile/page.tsx
Normal file → Executable file
0
app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx
Normal file → Executable file
0
app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx
Normal file → Executable file
8
app/(dashboard)/proxy-hosts/actions.ts
Normal file → Executable file
8
app/(dashboard)/proxy-hosts/actions.ts
Normal file → Executable file
@@ -77,6 +77,7 @@ function parseAuthentikConfig(formData: FormData): ProxyHostAuthentikInput | und
|
||||
const copyHeaders = parseCsv(formData.get("authentik_copy_headers"));
|
||||
const trustedProxies = parseCsv(formData.get("authentik_trusted_proxies"));
|
||||
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")
|
||||
? parseCheckbox(formData.get("authentik_set_host_header"))
|
||||
: undefined;
|
||||
@@ -103,6 +104,9 @@ function parseAuthentikConfig(formData: FormData): ProxyHostAuthentikInput | und
|
||||
if (protectedPaths.length > 0 || formData.has("authentik_protected_paths")) {
|
||||
result.protectedPaths = protectedPaths;
|
||||
}
|
||||
if (excludedPaths.length > 0 || formData.has("authentik_excluded_paths")) {
|
||||
result.excludedPaths = excludedPaths;
|
||||
}
|
||||
if (setHostHeader !== undefined) {
|
||||
result.setOutpostHostHeader = setHostHeader;
|
||||
}
|
||||
@@ -122,6 +126,7 @@ function parseCpmForwardAuthConfig(formData: FormData): CpmForwardAuthInput | un
|
||||
: false
|
||||
: undefined;
|
||||
const protectedPaths = parseCsv(formData.get("cpm_forward_auth_protected_paths"));
|
||||
const excludedPaths = parseCsv(formData.get("cpm_forward_auth_excluded_paths"));
|
||||
|
||||
const result: CpmForwardAuthInput = {};
|
||||
if (enabledValue !== undefined) {
|
||||
@@ -130,6 +135,9 @@ function parseCpmForwardAuthConfig(formData: FormData): CpmForwardAuthInput | un
|
||||
if (protectedPaths.length > 0 || formData.has("cpm_forward_auth_protected_paths")) {
|
||||
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;
|
||||
}
|
||||
|
||||
0
app/(dashboard)/proxy-hosts/page.tsx
Normal file → Executable file
0
app/(dashboard)/proxy-hosts/page.tsx
Normal file → Executable file
0
app/(dashboard)/settings/OAuthProvidersSection.tsx
Normal file → Executable file
0
app/(dashboard)/settings/OAuthProvidersSection.tsx
Normal file → Executable file
200
app/(dashboard)/settings/SettingsClient.tsx
Normal file → Executable file
200
app/(dashboard)/settings/SettingsClient.tsx
Normal file → Executable file
@@ -20,14 +20,16 @@ import type {
|
||||
MetricsSettings,
|
||||
LoggingSettings,
|
||||
DnsSettings,
|
||||
DnsProviderSettings,
|
||||
UpstreamDnsResolutionSettings,
|
||||
GeoBlockSettings,
|
||||
} from "@/lib/settings";
|
||||
import type { DnsProviderDefinition } from "@/src/lib/dns-providers";
|
||||
import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields";
|
||||
import OAuthProvidersSection from "./OAuthProvidersSection";
|
||||
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
|
||||
import {
|
||||
updateCloudflareSettingsAction,
|
||||
updateDnsProviderSettingsAction,
|
||||
updateGeneralSettingsAction,
|
||||
updateAuthentikSettingsAction,
|
||||
updateMetricsSettingsAction,
|
||||
@@ -112,7 +114,7 @@ function SettingSection({
|
||||
const A: Record<string, AccentConfig> = {
|
||||
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" },
|
||||
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" },
|
||||
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" },
|
||||
@@ -126,11 +128,8 @@ const A: Record<string, AccentConfig> = {
|
||||
|
||||
type Props = {
|
||||
general: GeneralSettings | null;
|
||||
cloudflare: {
|
||||
hasToken: boolean;
|
||||
zoneId?: string;
|
||||
accountId?: string;
|
||||
};
|
||||
dnsProvider: DnsProviderSettings | null;
|
||||
dnsProviderDefinitions: DnsProviderDefinition[];
|
||||
authentik: AuthentikSettings | null;
|
||||
metrics: MetricsSettings | null;
|
||||
logging: LoggingSettings | null;
|
||||
@@ -145,7 +144,7 @@ type Props = {
|
||||
tokenFromEnv: boolean;
|
||||
overrides: {
|
||||
general: boolean;
|
||||
cloudflare: boolean;
|
||||
dnsProvider: boolean;
|
||||
authentik: boolean;
|
||||
metrics: boolean;
|
||||
logging: boolean;
|
||||
@@ -178,7 +177,8 @@ type Props = {
|
||||
|
||||
export default function SettingsClient({
|
||||
general,
|
||||
cloudflare,
|
||||
dnsProvider,
|
||||
dnsProviderDefinitions,
|
||||
authentik,
|
||||
metrics,
|
||||
logging,
|
||||
@@ -190,7 +190,9 @@ export default function SettingsClient({
|
||||
instanceSync
|
||||
}: Props) {
|
||||
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 [metricsState, metricsFormAction] = useFormState(updateMetricsSettingsAction, null);
|
||||
const [loggingState, loggingFormAction] = useFormState(updateLoggingSettingsAction, null);
|
||||
@@ -207,7 +209,7 @@ export default function SettingsClient({
|
||||
const isSlave = instanceSync.mode === "slave";
|
||||
const isMaster = instanceSync.mode === "master";
|
||||
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 [metricsOverride, setMetricsOverride] = useState(instanceSync.overrides.metrics);
|
||||
const [loggingOverride, setLoggingOverride] = useState(instanceSync.overrides.logging);
|
||||
@@ -463,65 +465,159 @@ export default function SettingsClient({
|
||||
</form>
|
||||
</SettingSection>
|
||||
|
||||
{/* ── Cloudflare DNS ── */}
|
||||
{/* ── DNS Providers ── */}
|
||||
<SettingSection
|
||||
icon={<Cloud className="h-4 w-4" />}
|
||||
title="Cloudflare DNS"
|
||||
description="Configure a Cloudflare API token with Zone.DNS Edit permissions to enable DNS-01 challenges for wildcard certificates."
|
||||
accent={A.cloudflare}
|
||||
title="DNS Providers"
|
||||
description="Configure DNS providers for ACME DNS-01 challenges (required for wildcard certificates). You can add multiple providers and select a default."
|
||||
accent={A.dnsProvider}
|
||||
>
|
||||
{cloudflare.hasToken && (
|
||||
<InfoAlert>
|
||||
A Cloudflare API token is already configured. Leave the token field blank to keep it, or select “Remove existing token” to delete it.
|
||||
</InfoAlert>
|
||||
)}
|
||||
<form action={cloudflareFormAction} className="flex flex-col gap-3">
|
||||
{cloudflareState?.message && (
|
||||
<StatusAlert message={cloudflareState.message} success={cloudflareState.success} />
|
||||
{dnsProviderState?.message && (
|
||||
<StatusAlert message={dnsProviderState.message} success={dnsProviderState.success} />
|
||||
)}
|
||||
{isSlave && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="cloudflare-override"
|
||||
id="dnsprovider-override"
|
||||
name="overrideEnabled"
|
||||
checked={cloudflareOverride}
|
||||
onCheckedChange={(v) => setCloudflareOverride(!!v)}
|
||||
form="dnsp-add-form"
|
||||
checked={dnsProviderOverride}
|
||||
onCheckedChange={(v) => setDnsProviderOverride(!!v)}
|
||||
/>
|
||||
<Label htmlFor="cloudflare-override">Override master settings</Label>
|
||||
<Label htmlFor="dnsprovider-override">Override master settings</Label>
|
||||
</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">
|
||||
<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
|
||||
id="cf-apiToken"
|
||||
name="apiToken"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="Enter new token"
|
||||
disabled={isSlave && !cloudflareOverride}
|
||||
id={`dnsp-${field.key}`}
|
||||
name={`credential_${field.key}`}
|
||||
type={field.type === "password" ? "password" : "text"}
|
||||
autoComplete={field.type === "password" ? "new-password" : "off"}
|
||||
placeholder={field.placeholder ?? ""}
|
||||
disabled={isSlave && !dnsProviderOverride}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="cf-clearToken"
|
||||
name="clearToken"
|
||||
disabled={!cloudflare.hasToken || (isSlave && !cloudflareOverride)}
|
||||
/>
|
||||
<Label htmlFor="cf-clearToken">Remove existing token</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cf-zoneId">Zone ID</Label>
|
||||
<Input id="cf-zoneId" name="zoneId" defaultValue={cloudflare.zoneId ?? ""} disabled={isSlave && !cloudflareOverride} className="h-8 text-sm font-mono" />
|
||||
</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>
|
||||
))}
|
||||
{isUpdate && (
|
||||
<InfoAlert>
|
||||
Credentials are already configured. Leave fields blank to keep existing values.
|
||||
</InfoAlert>
|
||||
)}
|
||||
{providerDef.docsUrl && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<a href={providerDef.docsUrl} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
Provider documentation
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{isSlave && <input type="hidden" name="overrideEnabled" value={dnsProviderOverride ? "on" : ""} />}
|
||||
<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>
|
||||
</form>
|
||||
</SettingSection>
|
||||
|
||||
121
app/(dashboard)/settings/actions.ts
Normal file → Executable file
121
app/(dashboard)/settings/actions.ts
Normal file → Executable file
@@ -5,10 +5,11 @@ import { requireAdmin } from "@/src/lib/auth";
|
||||
import { applyCaddyConfig } from "@/src/lib/caddy";
|
||||
import { getInstanceMode, getSlaveMasterToken, setInstanceMode, setSlaveMasterToken, syncInstances } from "@/src/lib/instance-sync";
|
||||
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 { 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 = {
|
||||
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> {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
20
app/(dashboard)/settings/page.tsx
Normal file → Executable file
20
app/(dashboard)/settings/page.tsx
Normal file → Executable file
@@ -1,8 +1,9 @@
|
||||
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 { listInstances } from "@/src/lib/models/instances";
|
||||
import { listOAuthProviders } from "@/src/lib/models/oauth-providers";
|
||||
import { DNS_PROVIDERS } from "@/src/lib/dns-providers";
|
||||
import { config } from "@/src/lib/config";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
|
||||
@@ -13,9 +14,9 @@ export default async function SettingsPage() {
|
||||
const modeFromEnv = isInstanceModeFromEnv();
|
||||
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(),
|
||||
getCloudflareSettings(),
|
||||
getDnsProviderSettings(),
|
||||
getAuthentikSettings(),
|
||||
getMetricsSettings(),
|
||||
getLoggingSettings(),
|
||||
@@ -26,11 +27,11 @@ export default async function SettingsPage() {
|
||||
listOAuthProviders(),
|
||||
]);
|
||||
|
||||
const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] =
|
||||
const [overrideGeneral, overrideDnsProvider, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] =
|
||||
instanceMode === "slave"
|
||||
? await Promise.all([
|
||||
getSetting("general"),
|
||||
getSetting("cloudflare"),
|
||||
getSetting("dns_provider"),
|
||||
getSetting("authentik"),
|
||||
getSetting("metrics"),
|
||||
getSetting("logging"),
|
||||
@@ -49,11 +50,8 @@ export default async function SettingsPage() {
|
||||
return (
|
||||
<SettingsClient
|
||||
general={general}
|
||||
cloudflare={{
|
||||
hasToken: Boolean(cloudflare?.apiToken),
|
||||
zoneId: cloudflare?.zoneId,
|
||||
accountId: cloudflare?.accountId
|
||||
}}
|
||||
dnsProvider={dnsProvider}
|
||||
dnsProviderDefinitions={DNS_PROVIDERS}
|
||||
authentik={authentik}
|
||||
metrics={metrics}
|
||||
logging={logging}
|
||||
@@ -68,7 +66,7 @@ export default async function SettingsPage() {
|
||||
tokenFromEnv,
|
||||
overrides: {
|
||||
general: overrideGeneral !== null,
|
||||
cloudflare: overrideCloudflare !== null,
|
||||
dnsProvider: overrideDnsProvider !== null,
|
||||
authentik: overrideAuthentik !== null,
|
||||
metrics: overrideMetrics !== null,
|
||||
logging: overrideLogging !== null,
|
||||
|
||||
0
app/(dashboard)/users/UsersClient.tsx
Normal file → Executable file
0
app/(dashboard)/users/UsersClient.tsx
Normal file → Executable file
0
app/(dashboard)/users/actions.ts
Normal file → Executable file
0
app/(dashboard)/users/actions.ts
Normal file → Executable file
0
app/(dashboard)/users/page.tsx
Normal file → Executable file
0
app/(dashboard)/users/page.tsx
Normal file → Executable file
0
app/(dashboard)/waf/WafEventsClient.tsx
Normal file → Executable file
0
app/(dashboard)/waf/WafEventsClient.tsx
Normal file → Executable file
0
app/(dashboard)/waf/page.tsx
Normal file → Executable file
0
app/(dashboard)/waf/page.tsx
Normal file → Executable file
0
app/api/analytics/blocked/route.ts
Normal file → Executable file
0
app/api/analytics/blocked/route.ts
Normal file → Executable file
0
app/api/analytics/countries/route.ts
Normal file → Executable file
0
app/api/analytics/countries/route.ts
Normal file → Executable file
0
app/api/analytics/hosts/route.ts
Normal file → Executable file
0
app/api/analytics/hosts/route.ts
Normal file → Executable file
0
app/api/analytics/protocols/route.ts
Normal file → Executable file
0
app/api/analytics/protocols/route.ts
Normal file → Executable file
0
app/api/analytics/summary/route.ts
Normal file → Executable file
0
app/api/analytics/summary/route.ts
Normal file → Executable file
0
app/api/analytics/timeline/route.ts
Normal file → Executable file
0
app/api/analytics/timeline/route.ts
Normal file → Executable file
0
app/api/analytics/user-agents/route.ts
Normal file → Executable file
0
app/api/analytics/user-agents/route.ts
Normal file → Executable file
0
app/api/analytics/waf-stats/route.ts
Normal file → Executable file
0
app/api/analytics/waf-stats/route.ts
Normal file → Executable file
0
app/api/auth/[...all]/route.ts
Normal file → Executable file
0
app/api/auth/[...all]/route.ts
Normal file → Executable file
0
app/api/auth/link-account/route.ts
Normal file → Executable file
0
app/api/auth/link-account/route.ts
Normal file → Executable file
3
app/api/auth/logout/route.ts
Normal file → Executable file
3
app/api/auth/logout/route.ts
Normal file → Executable file
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAuth } from "@/src/lib/auth-server";
|
||||
import { checkSameOrigin } from "@/src/lib/auth";
|
||||
import { config } from "@/src/lib/config";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -10,5 +11,5 @@ export async function POST(request: NextRequest) {
|
||||
if (originCheck) return originCheck;
|
||||
|
||||
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
0
app/api/forward-auth/callback/route.ts
Normal file → Executable file
0
app/api/forward-auth/login/route.ts
Normal file → Executable file
0
app/api/forward-auth/login/route.ts
Normal file → Executable file
0
app/api/forward-auth/session-login/route.ts
Normal file → Executable file
0
app/api/forward-auth/session-login/route.ts
Normal file → Executable file
0
app/api/forward-auth/verify/route.ts
Normal file → Executable file
0
app/api/forward-auth/verify/route.ts
Normal file → Executable file
0
app/api/geoip-status/route.ts
Normal file → Executable file
0
app/api/geoip-status/route.ts
Normal file → Executable file
0
app/api/health/route.ts
Normal file → Executable file
0
app/api/health/route.ts
Normal file → Executable file
0
app/api/instances/sync/route.ts
Normal file → Executable file
0
app/api/instances/sync/route.ts
Normal file → Executable file
0
app/api/l4-ports/route.ts
Normal file → Executable file
0
app/api/l4-ports/route.ts
Normal file → Executable file
0
app/api/user/change-password/route.ts
Normal file → Executable file
0
app/api/user/change-password/route.ts
Normal file → Executable file
0
app/api/user/link-oauth-start/route.ts
Normal file → Executable file
0
app/api/user/link-oauth-start/route.ts
Normal file → Executable file
0
app/api/user/unlink-oauth/route.ts
Normal file → Executable file
0
app/api/user/unlink-oauth/route.ts
Normal file → Executable file
0
app/api/user/update-avatar/route.ts
Normal file → Executable file
0
app/api/user/update-avatar/route.ts
Normal file → Executable file
0
app/api/v1/access-lists/[id]/entries/[entryId]/route.ts
Normal file → Executable file
0
app/api/v1/access-lists/[id]/entries/[entryId]/route.ts
Normal file → Executable file
0
app/api/v1/access-lists/[id]/entries/route.ts
Normal file → Executable file
0
app/api/v1/access-lists/[id]/entries/route.ts
Normal file → Executable file
0
app/api/v1/access-lists/[id]/route.ts
Normal file → Executable file
0
app/api/v1/access-lists/[id]/route.ts
Normal file → Executable file
0
app/api/v1/access-lists/route.ts
Normal file → Executable file
0
app/api/v1/access-lists/route.ts
Normal file → Executable file
0
app/api/v1/audit-log/route.ts
Normal file → Executable file
0
app/api/v1/audit-log/route.ts
Normal file → Executable file
0
app/api/v1/ca-certificates/[id]/route.ts
Normal file → Executable file
0
app/api/v1/ca-certificates/[id]/route.ts
Normal file → Executable file
0
app/api/v1/ca-certificates/route.ts
Normal file → Executable file
0
app/api/v1/ca-certificates/route.ts
Normal file → Executable file
0
app/api/v1/caddy/apply/route.ts
Normal file → Executable file
0
app/api/v1/caddy/apply/route.ts
Normal file → Executable file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user