fix: remove ACME cert scanning to eliminate caddy-data permission issue (#88)

Caddy's certmagic creates storage dirs with hardcoded 0700 permissions,
making the web container's supplementary group membership ineffective.
Rather than working around this with ACLs or chmod hacks, remove the
feature entirely — it was cosmetic (issuer/expiry display) for certs
that Caddy auto-manages anyway.

Also bump access list dropdown timeout from 5s to 10s to fix flaky E2E test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-04-03 12:34:18 +02:00
parent 49b869f0ca
commit b9a88c4330
8 changed files with 9 additions and 144 deletions

View File

@@ -101,10 +101,6 @@ OAUTH_ALLOW_AUTO_LINKING=false # Auto-link OAuth to accounts without pas
# Certificate storage directory (usually no need to change)
# CERTS_DIRECTORY=./data/certs
# Caddy certificate directory for ACME metadata scanning in the Certificates page
# (Only needed for custom/non-standard deployments)
# CADDY_CERTS_DIR=/caddy-data/caddy/certificates
# Login rate limiting (optional, for custom rate limit settings)
# LOGIN_MAX_ATTEMPTS=5
# LOGIN_WINDOW_MS=300000

View File

@@ -62,7 +62,6 @@ Data persists in Docker volumes (caddy-manager-data, caddy-data, caddy-config, c
| `CADDY_API_URL` | Caddy Admin API endpoint | `http://caddy:2019` (prod)<br/>`http://localhost:2019` (dev) | No |
| `DATABASE_URL` | SQLite database URL | `file:/app/data/caddy-proxy-manager.db` | No |
| `CERTS_DIRECTORY` | Certificate storage directory | `./data/certs` | No |
| `CADDY_CERTS_DIR` | Caddy cert storage path used for ACME metadata scanning (non-default deployments) | `/caddy-data/caddy/certificates` | No |
| `LOGIN_MAX_ATTEMPTS` | Max login attempts before rate limit | `5` | No |
| `LOGIN_WINDOW_MS` | Rate limit window in milliseconds | `300000` (5 min) | No |
| `LOGIN_BLOCK_MS` | Rate limit block duration in milliseconds | `900000` (15 min) | No |

View File

@@ -44,7 +44,6 @@ export default function CertificatesClient({
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const allStatuses: (CertExpiryStatus | null)[] = [
...acmeHosts.map((h) => h.certExpiryStatus),
...importedCerts.map((c) => c.expiryStatus),
];
const { expired, expiringSoon, healthy } = countExpiry(allStatuses);

View File

@@ -5,7 +5,6 @@ import { Card, CardContent } from "@/components/ui/card";
import { DataTable } from "@/components/ui/DataTable";
import { StatusChip } from "@/components/ui/StatusChip";
import type { AcmeHost } from "../page";
import { RelativeTime } from "./RelativeTime";
type Props = {
acmeHosts: AcmeHost[];
@@ -40,18 +39,6 @@ const columns = [
</div>
),
},
{
id: "issuer",
label: "Issuer",
render: (r: AcmeHost) => (
<span className="text-xs text-muted-foreground font-mono">{r.certIssuer ?? "—"}</span>
),
},
{
id: "expiry",
label: "Expiry",
render: (r: AcmeHost) => <RelativeTime validTo={r.certValidTo} status={r.certExpiryStatus} />,
},
{
id: "status",
label: "Status",
@@ -71,7 +58,6 @@ function acmeMobileCard(r: AcmeHost) {
{r.domains[0]}{r.domains.length > 1 ? ` +${r.domains.length - 1}` : ""}
</p>
<div className="flex items-center gap-2 flex-wrap mt-1">
<RelativeTime validTo={r.certValidTo} status={r.certExpiryStatus} />
<StatusChip status={r.enabled ? "active" : "inactive"} />
</div>
</CardContent>
@@ -81,7 +67,7 @@ function acmeMobileCard(r: AcmeHost) {
export function AcmeTab({ acmeHosts, acmePagination, search, statusFilter }: Props) {
const filtered = acmeHosts.filter((h) => {
if (statusFilter && h.certExpiryStatus !== statusFilter) return false;
if (statusFilter) return false; // ACME hosts have no expiry status
if (search) {
const q = search.toLowerCase();
return (

View File

@@ -4,7 +4,6 @@ import { proxyHosts, certificates } from '@/src/lib/db/schema';
import { isNull, isNotNull, count } from 'drizzle-orm';
import { requireAdmin } from '@/src/lib/auth';
import CertificatesClient from './CertificatesClient';
import { scanAcmeCerts } from '@/src/lib/acme-certs';
import { listCaCertificates, type CaCertificate } from '@/src/lib/models/ca-certificates';
import { listIssuedClientCertificates, type IssuedClientCertificate } from '@/src/lib/models/issued-client-certificates';
@@ -23,10 +22,6 @@ export type AcmeHost = {
domains: string[];
ssl_forced: boolean;
enabled: boolean;
certValidTo: string | null;
certValidFrom: string | null;
certIssuer: string | null;
certExpiryStatus: CertExpiryStatus | null;
};
export type ImportedCertView = {
@@ -86,8 +81,6 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PER_PAGE;
const acmeCertMap = scanAcmeCerts();
const [caCerts, issuedClientCerts] = await Promise.all([
listCaCertificates(),
listIssuedClientCertificates()
@@ -124,25 +117,13 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
.where(isNotNull(proxyHosts.certificateId)),
]);
const acmeHosts: AcmeHost[] = acmeRows.map(r => {
const domains = JSON.parse(r.domains) as string[];
let certInfo = null;
for (const domain of domains) {
const info = acmeCertMap.get(domain.toLowerCase());
if (info) { certInfo = info; break; }
}
return {
id: r.id,
name: r.name,
domains,
ssl_forced: r.sslForced,
enabled: r.enabled,
certValidTo: certInfo?.validTo ?? null,
certValidFrom: certInfo?.validFrom ?? null,
certIssuer: certInfo?.issuer ?? null,
certExpiryStatus: certInfo?.validTo ? getExpiryStatus(certInfo.validTo) : null,
};
});
const acmeHosts: AcmeHost[] = acmeRows.map(r => ({
id: r.id,
name: r.name,
domains: JSON.parse(r.domains) as string[],
ssl_forced: r.sslForced,
enabled: r.enabled,
}));
const usageMap = new Map<number, { id: number; name: string; domains: string[] }[]>();
for (const u of usageRows) {

View File

@@ -58,7 +58,6 @@ services:
- caddy-manager-data:/app/data
- geoip-data:/usr/share/GeoIP:ro,z
- caddy-logs:/logs:ro
- caddy-data:/caddy-data:ro
depends_on:
caddy:
condition: service_healthy

View File

@@ -1,95 +0,0 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { X509Certificate } from 'node:crypto';
export type AcmeCertInfo = {
validTo: string;
validFrom: string;
issuer: string;
domains: string[];
};
/**
* Walks Caddy's certificate storage directory and parses every .crt file.
* Returns a map from lowercase domain → cert info (most recent cert wins for
* a given domain if multiple exist).
*
* Caddy stores certs under:
* <CADDY_CERTS_DIR>/acme-v02.api.letsencrypt.org-directory/<domain>/<domain>.crt
* <CADDY_CERTS_DIR>/acme.zerossl.com-v2-DV90/<domain>/<domain>.crt
* ...etc
*
* The directory is mounted at /caddy-data in the web container, so:
* CADDY_CERTS_DIR defaults to /caddy-data/caddy/certificates
*/
const CADDY_CERTS_DIR =
process.env.CADDY_CERTS_DIR ?? '/caddy-data/caddy/certificates';
function walkCrtFiles(dir: string): string[] {
const results: string[] = [];
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return results; // directory doesn't exist yet (e.g. no certs issued)
}
for (const entry of entries) {
const full = join(dir, entry);
try {
const stat = statSync(full);
if (stat.isDirectory()) {
results.push(...walkCrtFiles(full));
} else if (entry.endsWith('.crt')) {
results.push(full);
}
} catch {
// skip unreadable entries
}
}
return results;
}
export function scanAcmeCerts(): Map<string, AcmeCertInfo> {
const map = new Map<string, AcmeCertInfo>();
const crtFiles = walkCrtFiles(CADDY_CERTS_DIR);
for (const file of crtFiles) {
try {
const pem = readFileSync(file, 'utf-8');
const cert = new X509Certificate(pem);
const sanDomains =
cert.subjectAltName
?.split(',')
.map(s => s.trim())
.filter(s => s.startsWith('DNS:'))
.map(s => s.slice(4).toLowerCase()) ?? [];
const issuerLine = cert.issuer ?? '';
const issuer = (
issuerLine.match(/O=([^\n,]+)/)?.[1] ??
issuerLine.match(/CN=([^\n,]+)/)?.[1] ??
issuerLine
).trim();
const info: AcmeCertInfo = {
validTo: new Date(cert.validTo).toISOString(),
validFrom: new Date(cert.validFrom).toISOString(),
issuer,
domains: sanDomains,
};
for (const domain of sanDomains) {
// Keep the cert with the latest validTo for each domain
const existing = map.get(domain);
if (!existing || new Date(info.validTo).getTime() > new Date(existing.validTo).getTime()) {
map.set(domain, info);
}
}
} catch {
// skip unreadable / malformed certs
}
}
return map;
}

View File

@@ -90,7 +90,7 @@ export async function createProxyHost(page: Page, config: ProxyHostConfig): Prom
await accessListTrigger.scrollIntoViewIfNeeded();
await accessListTrigger.click();
const option = page.getByRole('option', { name: config.accessListName });
await expect(option).toBeVisible({ timeout: 5_000 });
await expect(option).toBeVisible({ timeout: 10_000 });
await option.click();
}