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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user