The certificates page has been completely redesigned

This commit is contained in:
fuomag9
2025-11-08 11:11:36 +01:00
parent 3be4e1bf7d
commit ee8813ac95
2 changed files with 245 additions and 88 deletions

View File

@@ -116,7 +116,7 @@ Visit `http://localhost:3000/login` and sign in with your credentials.
| **Redirects** | Set up 301/302 redirects with optional query string preservation |
| **Dead Hosts** | Display branded maintenance pages with custom status codes |
| **Access Lists** | Create HTTP basic-auth user lists and assign them to proxy hosts |
| **Certificates** | Request ACME-managed certificates or import custom PEM files |
| **Certificates** | Import custom SSL/TLS certificates (internal CA, wildcards, etc.) - Caddy auto-manages public certs |
| **Settings** | Configure primary domain, ACME email, and Cloudflare DNS automation |
| **Audit Log** | Review chronological feed of all administrative actions |
@@ -253,6 +253,40 @@ npm run dev
---
## Certificate Management
### Automatic HTTPS (Default)
Caddy automatically handles SSL/TLS certificates for all proxy hosts:
- **Zero Configuration**: Just add a domain to a proxy host - certificates are obtained automatically
- **Auto-Renewal**: Certificates renew automatically before expiration
- **Multiple Domains**: Each proxy host can have multiple domains with automatic cert management
- **Wildcard Support**: Use Cloudflare DNS-01 challenge for wildcard certificates
**No action required** - this works out of the box!
### Custom Certificates (Optional)
Import your own certificates when you need to:
- **Internal CA**: Use certificates from your organization's Certificate Authority
- **Pre-existing Certs**: Reuse certificates you already have
- **Special Requirements**: Compliance, security policies, or specific certificate features
- **Wildcard from DNS Provider**: Import wildcard certificates from your DNS provider
**How to import:**
1. Navigate to **Certificates** page
2. Click **Import Custom Certificate**
3. Provide certificate name and domains
4. Paste certificate PEM (full chain recommended)
5. Paste private key PEM
6. Save and assign to proxy hosts as needed
**Security Note**: Imported private keys are stored in the database. Ensure your `.env` file and database have restricted permissions (`chmod 600`).
---
## Cloudflare DNS Automation
To enable automatic SSL certificates with Cloudflare DNS-01 challenges:

View File

@@ -1,21 +1,20 @@
"use client";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box,
Button,
Card,
CardContent,
Chip,
FormControlLabel,
MenuItem,
Stack,
TextField,
Typography,
Checkbox
Typography
} from "@mui/material";
import type { Certificate } from "@/src/lib/models/certificates";
import { createCertificateAction, deleteCertificateAction, updateCertificateAction } from "./actions";
@@ -25,139 +24,263 @@ type Props = {
};
export default function CertificatesClient({ certificates }: Props) {
const importedCerts = certificates.filter(c => c.type === "imported");
const managedCerts = certificates.filter(c => c.type === "managed");
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Certificates
SSL/TLS Certificates
</Typography>
<Typography color="text.secondary">
Manage ACME-managed certificates or import your own PEM files for custom deployments.
Caddy automatically handles HTTPS certificates for all proxy hosts using Let's Encrypt.
Import custom certificates only when needed (internal CA, special requirements, etc.).
</Typography>
</Stack>
<Stack spacing={3}>
{certificates.map((cert) => (
<Card key={cert.id}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Box>
<Typography variant="h6" fontWeight={600}>
{cert.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{cert.domain_names.join(", ")}
</Typography>
</Box>
<Chip label={cert.type === "managed" ? "Managed" : "Imported"} color="primary" variant="outlined" />
</Box>
<Alert severity="info" icon={<InfoOutlinedIcon />}>
<Typography variant="body2" fontWeight={600} gutterBottom>
How Caddy handles certificates:
</Typography>
<Typography variant="body2" component="div">
• <strong>Automatic HTTPS:</strong> Caddy automatically obtains and renews certificates for all domains
<br />
• <strong>No configuration needed:</strong> Just add a proxy host with a domain, and Caddy handles the rest
<br />
• <strong>Custom certificates:</strong> Import your own certificates only when you have specific requirements
</Typography>
</Alert>
<Accordion elevation={0} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
<Typography fontWeight={600}>Edit</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<Stack component="form" action={(formData) => updateCertificateAction(cert.id, formData)} spacing={2}>
<TextField name="name" label="Name" defaultValue={cert.name} fullWidth />
<TextField
name="domain_names"
label="Domains"
defaultValue={cert.domain_names.join("\n")}
multiline
minRows={3}
fullWidth
/>
<TextField select name="type" label="Type" defaultValue={cert.type} fullWidth>
<MenuItem value="managed">Managed (ACME)</MenuItem>
<MenuItem value="imported">Imported</MenuItem>
</TextField>
{cert.type === "managed" ? (
<Box>
{managedCerts.length > 0 && (
<Stack spacing={2}>
<Alert severity="warning">
<Typography variant="body2">
<strong>Legacy "Managed" certificates detected:</strong> These entries are redundant since Caddy automatically manages HTTPS.
Consider deleting them unless you need to explicitly track certificate usage.
</Typography>
</Alert>
<Typography variant="h6" fontWeight={600}>
Managed Certificates (Legacy)
</Typography>
<Stack spacing={2}>
{managedCerts.map((cert) => (
<Card key={cert.id} sx={{ bgcolor: 'action.hover' }}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Box>
<Typography variant="h6" fontWeight={600}>
{cert.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{cert.domain_names.join(", ")}
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Chip label="Managed by Caddy" color="info" size="small" />
<Chip label="Auto-Renew" color="success" size="small" variant="outlined" />
</Stack>
</Box>
<Accordion elevation={0} disableGutters sx={{ bgcolor: 'transparent' }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
<Typography fontWeight={600}>Edit / Delete</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<Stack component="form" action={(formData) => updateCertificateAction(cert.id, formData)} spacing={2}>
<TextField name="name" label="Name" defaultValue={cert.name} fullWidth />
<TextField
name="domain_names"
label="Domains (one per line)"
defaultValue={cert.domain_names.join("\n")}
multiline
minRows={3}
fullWidth
helperText="These domains will be automatically managed by Caddy's ACME"
/>
<input type="hidden" name="type" value="managed" />
<input type="hidden" name="auto_renew" value="on" />
<input type="hidden" name="auto_renew_present" value="1" />
<FormControlLabel control={<Checkbox name="auto_renew" defaultChecked={cert.auto_renew} />} label="Auto renew" />
</Box>
) : (
<>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
<Button type="submit" variant="contained">
Save
</Button>
<Button
type="submit"
formAction={deleteCertificateAction.bind(null, cert.id)}
variant="outlined"
color="error"
>
Delete
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
</CardContent>
</Card>
))}
</Stack>
</Stack>
)}
{importedCerts.length > 0 && (
<Stack spacing={2}>
<Typography variant="h6" fontWeight={600}>
Imported Certificates
</Typography>
<Stack spacing={2}>
{importedCerts.map((cert) => (
<Card key={cert.id}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Box>
<Typography variant="h6" fontWeight={600}>
{cert.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{cert.domain_names.join(", ")}
</Typography>
</Box>
<Chip label="Custom Certificate" color="secondary" />
</Box>
<Accordion elevation={0} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
<Typography fontWeight={600}>Edit / Delete</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<Stack component="form" action={(formData) => updateCertificateAction(cert.id, formData)} spacing={2}>
<TextField name="name" label="Name" defaultValue={cert.name} fullWidth />
<TextField
name="domain_names"
label="Domains (one per line)"
defaultValue={cert.domain_names.join("\n")}
multiline
minRows={3}
fullWidth
helperText="Domains covered by this certificate"
/>
<input type="hidden" name="type" value="imported" />
<TextField
name="certificate_pem"
label="Certificate PEM"
placeholder="-----BEGIN CERTIFICATE-----"
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
multiline
minRows={6}
fullWidth
helperText="Full chain recommended (cert + intermediates)"
/>
<TextField
name="private_key_pem"
label="Private key PEM"
placeholder="-----BEGIN PRIVATE KEY-----"
label="Private Key PEM"
placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"
multiline
minRows={6}
fullWidth
helperText="Keep this secure! Never share your private key"
type="password"
/>
</>
)}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
<Button type="submit" variant="contained">
Save certificate
</Button>
<Button
type="submit"
formAction={deleteCertificateAction.bind(null, cert.id)}
variant="outlined"
color="error"
>
Delete
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
</CardContent>
</Card>
))}
</Stack>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
<Button type="submit" variant="contained">
Update Certificate
</Button>
<Button
type="submit"
formAction={deleteCertificateAction.bind(null, cert.id)}
variant="outlined"
color="error"
>
Delete
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
</CardContent>
</Card>
))}
</Stack>
</Stack>
)}
<Stack spacing={2} component="section">
<Typography variant="h6" fontWeight={600}>
Create certificate
Import Custom Certificate
</Typography>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
<strong>When to import certificates:</strong>
</Typography>
<Typography variant="body2" component="ul" sx={{ mt: 1, mb: 0, pl: 2 }}>
<li>Using an internal Certificate Authority (CA)</li>
<li>Wildcard certificates from your DNS provider</li>
<li>Pre-existing certificates you want to reuse</li>
<li>Special compliance or security requirements</li>
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
<strong>Otherwise:</strong> Just create a proxy host with your domain - Caddy will handle everything automatically!
</Typography>
</Alert>
<Card>
<CardContent>
<Stack component="form" action={createCertificateAction} spacing={2}>
<TextField name="name" label="Name" placeholder="Wildcard certificate" required fullWidth />
<TextField
name="name"
label="Certificate Name"
placeholder="Internal CA Certificate"
required
fullWidth
helperText="Descriptive name to identify this certificate"
/>
<TextField
name="domain_names"
label="Domains"
placeholder="example.com"
label="Domains (one per line)"
placeholder="*.example.com&#10;example.com"
multiline
minRows={3}
required
fullWidth
helperText="List all domains/subdomains covered by this certificate"
/>
<TextField select name="type" label="Type" defaultValue="managed" fullWidth>
<MenuItem value="managed">Managed (ACME)</MenuItem>
<MenuItem value="imported">Imported</MenuItem>
</TextField>
<FormControlLabel control={<Checkbox name="auto_renew" defaultChecked />} label="Auto renew (managed only)" />
<input type="hidden" name="type" value="imported" />
<TextField
name="certificate_pem"
label="Certificate PEM"
placeholder="Paste PEM content for imported certificates"
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
multiline
minRows={5}
minRows={8}
required
fullWidth
helperText="Paste the full certificate chain (certificate + intermediate certificates)"
/>
<TextField
name="private_key_pem"
label="Private key PEM"
placeholder="Paste PEM key for imported certificates"
label="Private Key PEM"
placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"
multiline
minRows={5}
minRows={8}
required
fullWidth
helperText="Private key for this certificate. Stored securely."
type="password"
/>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Create certificate
<Button type="submit" variant="contained" size="large">
Import Certificate
</Button>
</Box>
</Stack>