Files
mailgoserver/internal/webui/templates/letsencrypt.html
T

265 lines
16 KiB
HTML

{{define "title"}}Let's Encrypt - Email Server Management{{end}}
{{define "page_title"}}Let's Encrypt{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-patch-check me-2"></i>Let's Encrypt</h2>
</div>
<p class="text-muted">
Two independent certificates can be obtained here — DNS-01 (needs a supported DNS provider) and
HTTP-01 (needs nothing but port 80 reachable from the internet). Enable either, both, or
neither. Which listener (SMTP-TLS, IMAP-TLS, or the admin/webmail HTTPS) actually uses which
certificate is chosen on the <a href="/pymta-manager/settings">Settings</a> page's TLS/SSL
section — e.g. run the HTTP-01 cert on mail while the dashboard keeps a DNS-01 or custom cert.
</p>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-globe me-2"></i>DNS-01</h5></div>
<div class="card-body">
<dl class="row mb-3">
<dt class="col-sm-3">Mode</dt>
<dd class="col-sm-9">
{{if .status.Enabled}}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Enabled {{if .status.Staging}}(staging){{end}}</span>
{{else}}
<span class="badge bg-secondary"><i class="bi bi-dash-circle me-1"></i>Disabled</span>
{{end}}
</dd>
<dt class="col-sm-3">Domains</dt>
<dd class="col-sm-9">{{if .status.Domains}}{{range .status.Domains}}<code>{{.}}</code> {{end}}{{else}}<span class="text-muted">none configured</span>{{end}}</dd>
<dt class="col-sm-3">DNS Provider</dt>
<dd class="col-sm-9">{{if .status.Provider}}{{.status.Provider}}{{else}}<span class="text-muted">none selected</span>{{end}}</dd>
<dt class="col-sm-3">Certificate expires</dt>
<dd class="col-sm-9">{{if .status.NotAfter.IsZero}}<span class="text-muted">unknown</span>{{else}}{{strftime "%Y-%m-%d %H:%M" .status.NotAfter}}{{end}}</dd>
<dt class="col-sm-3">Last attempt</dt>
<dd class="col-sm-9">
{{if .status.LastAttempt.IsZero}}
<span class="text-muted">none yet this run</span>
{{else if .status.LastError}}
<span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .status.LastAttempt}} — {{.status.LastError}}</span>
{{else}}
<span class="text-success"><i class="bi bi-check-circle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .status.LastAttempt}} — success</span>
{{end}}
</dd>
</dl>
<form method="post" action="/pymta-manager/letsencrypt/obtain">
<button type="submit" class="btn btn-primary" data-confirm="Obtain or renew the DNS-01 certificate now using the saved configuration?"><i class="bi bi-arrow-repeat me-1"></i>Obtain / Renew Now</button>
</form>
<hr>
<form method="POST" action="/pymta-manager/letsencrypt/save">
<div class="mb-3">
<label class="form-label">Enable DNS-01</label>
<select class="form-select" name="enabled">
<option value="false" {{if ne .le.enabled "true"}}selected{{end}}>No</option>
<option value="true" {{if eq .le.enabled "true"}}selected{{end}}>Yes</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Staging mode</label>
<select class="form-select" name="staging">
<option value="false" {{if ne .le.staging "true"}}selected{{end}}>No — request a real, trusted certificate</option>
<option value="true" {{if eq .le.staging "true"}}selected{{end}}>Yes — untrusted test certificate, no rate limits</option>
</select>
<div class="form-text">Recommended while testing a new configuration.</div>
</div>
<div class="mb-3">
<label class="form-label">Contact Email</label>
<input type="email" class="form-control" name="contact_email" value="{{.le.contact_email}}">
</div>
<div class="mb-3">
<label class="form-label">Domains</label>
<input type="text" class="form-control font-monospace" name="domains" value="{{.le.domains}}" placeholder="mail.example.com,*.mail.example.com">
<div class="form-text">Comma-separated. Include a wildcard entry (e.g. <code>*.mail.example.com</code>) alongside its bare domain to cover both with one certificate.</div>
</div>
<div class="mb-3">
<label class="form-label">DNS Provider</label>
<select class="form-select" name="dns_provider" id="le_provider">
<option value="">Select a provider...</option>
<option value="cloudflare" {{if eq .le.dns_provider "cloudflare"}}selected{{end}}>Cloudflare</option>
<option value="route53" {{if eq .le.dns_provider "route53"}}selected{{end}}>AWS Route53</option>
<option value="digitalocean" {{if eq .le.dns_provider "digitalocean"}}selected{{end}}>DigitalOcean</option>
<option value="gcloud" {{if eq .le.dns_provider "gcloud"}}selected{{end}}>Google Cloud DNS</option>
</select>
</div>
<div class="provider-fields" id="fields-cloudflare">
<div class="setting-section mb-3">
<h6>Cloudflare</h6>
<div class="mb-3">
<label class="form-label">API Token</label>
<input type="password" class="form-control" name="cloudflare_api_token" placeholder="Leave blank to keep the current value">
</div>
</div>
</div>
<div class="provider-fields" id="fields-route53">
<div class="setting-section mb-3">
<h6>AWS Route53</h6>
<div class="mb-3">
<label class="form-label">Access Key ID</label>
<input type="password" class="form-control" name="route53_access_key_id" placeholder="Leave blank to keep the current value, or blank both keys to use the host's AWS credential chain">
</div>
<div class="mb-3">
<label class="form-label">Secret Access Key</label>
<input type="password" class="form-control" name="route53_secret_access_key" placeholder="Leave blank to keep the current value">
</div>
<div class="mb-3">
<label class="form-label">Region</label>
<input type="text" class="form-control" name="route53_region" value="{{.le.route53_region}}" placeholder="us-east-1">
</div>
<div class="mb-3">
<label class="form-label">Hosted Zone ID (optional)</label>
<input type="text" class="form-control" name="route53_hosted_zone_id" value="{{.le.route53_hosted_zone_id}}" placeholder="Leave blank to auto-discover">
</div>
</div>
</div>
<div class="provider-fields" id="fields-digitalocean">
<div class="setting-section mb-3">
<h6>DigitalOcean</h6>
<div class="mb-3">
<label class="form-label">API Token</label>
<input type="password" class="form-control" name="digitalocean_api_token" placeholder="Leave blank to keep the current value">
</div>
</div>
</div>
<div class="provider-fields" id="fields-gcloud">
<div class="setting-section mb-3">
<h6>Google Cloud DNS</h6>
<div class="mb-3">
<label class="form-label">Project ID</label>
<input type="text" class="form-control" name="gcloud_project" value="{{.le.gcloud_project}}">
</div>
<div class="mb-3">
<label class="form-label">Service Account Key (optional)</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" name="gcloud_service_account_json_path" id="gcloud_sa_path" placeholder="Leave blank to use Application Default Credentials">
<input type="file" class="d-none" id="gcloudKeyUpload" accept=".json">
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('gcloudKeyUpload').click()"><i class="bi bi-upload"></i></button>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-success"><i class="bi bi-check-lg me-1"></i>Save DNS-01 Configuration</button>
</form>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-hdd-network me-2"></i>HTTP-01</h5></div>
<div class="card-body">
<dl class="row mb-3">
<dt class="col-sm-3">Mode</dt>
<dd class="col-sm-9">
{{if .statusHTTP.Enabled}}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Enabled</span>
{{else}}
<span class="badge bg-secondary"><i class="bi bi-dash-circle me-1"></i>Disabled</span>
{{end}}
</dd>
<dt class="col-sm-3">Domains</dt>
<dd class="col-sm-9">{{if .statusHTTP.Domains}}{{range .statusHTTP.Domains}}<code>{{.}}</code> {{end}}{{else}}<span class="text-muted">none configured</span>{{end}}{{if .statusHTTP.IncludeIP}} <span class="badge bg-info">+ server IP</span>{{end}}</dd>
<dt class="col-sm-3">Certificate expires</dt>
<dd class="col-sm-9">{{if .statusHTTP.NotAfter.IsZero}}<span class="text-muted">unknown</span>{{else}}{{strftime "%Y-%m-%d %H:%M" .statusHTTP.NotAfter}}{{end}}</dd>
<dt class="col-sm-3">Last attempt</dt>
<dd class="col-sm-9">
{{if .statusHTTP.LastAttempt.IsZero}}
<span class="text-muted">none yet this run</span>
{{else if .statusHTTP.LastError}}
<span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .statusHTTP.LastAttempt}} — {{.statusHTTP.LastError}}</span>
{{else}}
<span class="text-success"><i class="bi bi-check-circle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .statusHTTP.LastAttempt}} — success</span>
{{end}}
</dd>
</dl>
<form method="post" action="/pymta-manager/letsencrypt/http/obtain">
<button type="submit" class="btn btn-primary" data-confirm="Obtain or renew the HTTP-01 certificate now using the saved configuration?"><i class="bi bi-arrow-repeat me-1"></i>Obtain / Renew Now</button>
</form>
<hr>
<form method="POST" action="/pymta-manager/letsencrypt/http/save">
<div class="mb-3">
<label class="form-label">Enable HTTP-01</label>
<select class="form-select" name="enabled">
<option value="false" {{if ne .leHTTP.enabled "true"}}selected{{end}}>No</option>
<option value="true" {{if eq .leHTTP.enabled "true"}}selected{{end}}>Yes</option>
</select>
<div class="form-text">No DNS provider needed — Let's Encrypt verifies ownership by requesting a token over plain HTTP on this port. Once enabled and the server is restarted, this port stays bound for the life of the process (not just during an obtain) — so you can confirm your router/proxy port-forwarding actually reaches this host by browsing to it directly and expecting a plain "ok" response.</div>
</div>
<div class="mb-3">
<label class="form-label">Staging mode</label>
<select class="form-select" name="staging">
<option value="false" {{if ne .leHTTP.staging "true"}}selected{{end}}>No — request a real, trusted certificate</option>
<option value="true" {{if eq .leHTTP.staging "true"}}selected{{end}}>Yes — untrusted test certificate, no rate limits</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Contact Email</label>
<input type="email" class="form-control" name="contact_email" value="{{.leHTTP.contact_email}}">
</div>
<div class="mb-3">
<label class="form-label">Domains</label>
<input type="text" class="form-control font-monospace" name="domains" value="{{.leHTTP.domains}}" placeholder="mail.example.com">
<div class="form-text">Comma-separated.</div>
</div>
<div class="mb-3">
<label class="form-label">Also get this certificate for the server's IP address</label>
<select class="form-select" name="include_ip">
<option value="false" {{if ne .leHTTP.include_ip "true"}}selected{{end}}>No — domain only</option>
<option value="true" {{if eq .leHTTP.include_ip "true"}}selected{{end}}>Yes</option>
</select>
<div class="form-text">Adds the IP as a second identifier on the same certificate, so clients connecting by bare IP (no hostname) get a trusted cert too. This automatically switches to Let's Encrypt's "shortlived" certificate profile, the only one that currently allows IP identifiers — those certificates are valid for only about 6 days, so expect much more frequent renewals than the domain-only case (handled automatically by the existing renewal check).</div>
</div>
<div class="mb-3">
<label class="form-label">IP address override</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" name="ip_override" id="le_ip_override" value="{{.leHTTP.ip_override}}" placeholder="Leave blank to autodetect this host's WAN IP on every obtain/renew">
<button class="btn btn-outline-secondary" type="button" id="le_detect_ip_btn"><i class="bi bi-broadcast me-1"></i>Detect</button>
</div>
</div>
<button type="submit" class="btn btn-success"><i class="bi bi-check-lg me-1"></i>Save HTTP-01 Configuration</button>
</form>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
function updateProviderFields() {
const selected = document.getElementById('le_provider').value;
document.querySelectorAll('.provider-fields').forEach(function(el) {
el.style.display = (el.id === 'fields-' + selected) ? '' : 'none';
});
}
document.getElementById('le_provider').addEventListener('change', updateProviderFields);
updateProviderFields();
document.getElementById('le_detect_ip_btn').addEventListener('click', function() {
fetch('/pymta-manager/api/letsencrypt/detect_ip')
.then(r => r.json())
.then(data => {
if (data.status === 'success') { document.getElementById('le_ip_override').value = data.ip; showToast('Detected WAN IP: ' + data.ip, 'success'); }
else { showToast(data.message || 'Failed to detect IP', 'danger'); }
}).catch(() => showToast('Failed to detect IP', 'danger'));
});
document.getElementById('gcloudKeyUpload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('gcloud_key_file', file);
fetch('/pymta-manager/api/letsencrypt/upload_gcloud_key', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.status === 'success') { document.getElementById('gcloud_sa_path').value = data.filepath; showToast('Service account key uploaded', 'success'); }
else { showToast(data.message || 'Failed to upload key', 'danger'); }
}).catch(() => showToast('Failed to upload key', 'danger'));
});
</script>
{{end}}