ae42450ec1
Full server backup (spec 7.5.A): internal/backup produces a tar.gz of all of /data — a consistent SQLite snapshot via VACUUM INTO, DKIM keys, sasldb2 and a version manifest; TLS certs (tls/) and the Postfix queue are excluded. Two equal paths: the panel button (POST /backup, no-store) and the selfpost-backup CLI via docker exec (spec 11.6). CheckRestore runs before store.Open: a manifest version mismatch refuses to boot with the image tag to use; a match consumes the manifest so it only guards the first post-restore boot. Restore is not a separate branch — Postfix/OpenDKIM regenerate from the restored SQLite as on any start. Domain export/import (spec 7.5.B): DomainExport carries the DKIM private key and each application's working password. SASL secrets are read from sasldb2 via db_dump (the userPassword property is plaintext) and, on import, re-keyed under the local realm with saslpasswd2 — so credentials keep working on an instance with a different hostname, with no DKIM DNS change. Import validates and rolls back atomically on any failure. db-util (db_dump) is now an explicit image dep. Verified on the server (selfpost:p9): gofmt/vet/test green; container e2e for cross-realm domain export/import (SMTP AUTH 235 under the new realm), CLI and panel backups, same-version restore, and version-mismatch refusal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
200 lines
8.3 KiB
HTML
200 lines
8.3 KiB
HTML
{{define "content"}}
|
|
<div class="topbar">
|
|
<h1>{{.Domain.Name}}</h1>
|
|
<div class="actions muted">
|
|
<span>{{.User}}</span>
|
|
<form class="inline" method="post" action="/logout">
|
|
<button type="submit">Sign out</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<a class="back" href="/">← All domains</a>
|
|
|
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
|
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
|
|
|
|
{{if .NewCred}}
|
|
<div class="card credential">
|
|
<h2>New application password</h2>
|
|
<p class="muted">This password is shown <strong>once only</strong> and is not
|
|
stored. Copy it now — if it is lost, regenerate a new one.</p>
|
|
<label>Login</label>
|
|
<span class="code">{{.NewCred.Login}}</span>
|
|
<label>Password</label>
|
|
<span class="code">{{.NewCred.Password}}</span>
|
|
</div>
|
|
{{end}}
|
|
|
|
<div class="card">
|
|
<h2>DKIM DNS record</h2>
|
|
<p class="muted">Publish this TXT record in the DNS for <strong>{{.Domain.Name}}</strong>.
|
|
It is not a secret and can be viewed at any time.</p>
|
|
|
|
<label>Host / name</label>
|
|
<span class="code">{{.Record.Name}}</span>
|
|
|
|
<label>Type</label>
|
|
<span class="code">TXT</span>
|
|
|
|
<label>Value</label>
|
|
<span class="code">{{.Record.Value}}</span>
|
|
|
|
<p class="muted">Also configure SPF and DMARC for the domain (see the
|
|
documentation). Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Applications</h2>
|
|
<p class="muted">Each application is a SASL login/password an app or script
|
|
uses to send mail as this domain. A login may send from any address of the
|
|
domain (<em>wildcard</em>) or only from a fixed list of addresses.</p>
|
|
|
|
{{if .Apps}}
|
|
<table>
|
|
<thead>
|
|
<tr><th>Login</th><th>Mode</th><th>Addresses</th><th></th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .Apps}}
|
|
<tr>
|
|
<td class="code">{{.Login}}</td>
|
|
<td>{{if eq .AddressMode $.Wildcard}}Any address (@{{$.Domain.Name}}){{else}}List{{end}}</td>
|
|
<td class="muted">
|
|
{{if eq .AddressMode $.Wildcard}}*@{{$.Domain.Name}}{{else}}
|
|
{{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}
|
|
{{end}}
|
|
</td>
|
|
<td class="actions">
|
|
<details>
|
|
<summary>Edit mode</summary>
|
|
<form method="post" action="/applications/{{.ID}}/mode">
|
|
<label>Address mode</label>
|
|
<select name="mode">
|
|
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
|
|
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
|
|
</select>
|
|
<label>Addresses (for list mode; one per line or comma-separated)</label>
|
|
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
|
|
{{end}}{{$a}}{{end}}</textarea>
|
|
<button type="submit">Save mode</button>
|
|
</form>
|
|
</details>
|
|
<details>
|
|
<summary>Rate limit{{if .HasLimit}} (active){{end}}</summary>
|
|
<form method="post" action="/applications/{{.ID}}/ratelimit">
|
|
<label>Expected client IPs (one per line or comma-separated)</label>
|
|
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
|
|
<label>Message limit</label>
|
|
<input name="max_messages" type="number" min="1" value="{{.MaxText}}" placeholder="500">
|
|
<label>Window (seconds)</label>
|
|
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
|
|
<button type="submit">Save limit</button>
|
|
</form>
|
|
{{if .HasLimit}}
|
|
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
|
|
onsubmit="return confirm('Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.')">
|
|
<input type="hidden" name="clear" value="1">
|
|
<button type="submit" class="danger">Remove limit</button>
|
|
</form>
|
|
{{end}}
|
|
</details>
|
|
<form class="inline" method="post" action="/applications/{{.ID}}/password"
|
|
onsubmit="return confirm('Regenerate the password for {{.Login}}? The current password stops working immediately.')">
|
|
<button type="submit">New password</button>
|
|
</form>
|
|
<form class="inline" method="post" action="/applications/{{.ID}}/delete"
|
|
onsubmit="return confirm('Delete application {{.Login}}? Its credentials stop working immediately.')">
|
|
<button type="submit" class="danger">Delete</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{else}}
|
|
<p class="muted">No applications yet. Create one below.</p>
|
|
{{end}}
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Sending rate limit (domain)</h2>
|
|
<p class="muted">Optional level-2 limit (spec 7.4): cap how many messages this
|
|
domain may send from its expected client IP(s) within a time window, summed
|
|
across all its applications. It counts messages — one message to many
|
|
recipients counts once. Leave the IP list empty to disable it and rely only on
|
|
the global level-1 limit. Applications that send from changing IPs should be
|
|
left unbound here.</p>
|
|
|
|
<p class="muted">Status:
|
|
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level-1 only){{end}}.</p>
|
|
|
|
<form method="post" action="/domains/{{.Domain.ID}}/ratelimit">
|
|
<label for="d_ips">Expected client IPs (one per line or comma-separated)</label>
|
|
<textarea id="d_ips" name="allowed_ips" rows="2"
|
|
placeholder="203.0.113.10">{{.DomainRLIPs}}</textarea>
|
|
|
|
<label for="d_max">Message limit</label>
|
|
<input id="d_max" name="max_messages" type="number" min="1"
|
|
value="{{.DomainRLMax}}" placeholder="1000">
|
|
|
|
<label for="d_win">Window (seconds)</label>
|
|
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
|
|
|
|
<button type="submit">Save limit</button>
|
|
</form>
|
|
{{if .DomainHasRL}}
|
|
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
|
|
onsubmit="return confirm('Remove the domain rate limit? Only the global level-1 limit will apply.')">
|
|
<input type="hidden" name="clear" value="1">
|
|
<button type="submit" class="danger">Remove limit</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Add an application</h2>
|
|
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
|
<label for="login">Login</label>
|
|
<input id="login" name="login" type="text" placeholder="prod-server"
|
|
autocomplete="off" autocapitalize="none" spellcheck="false"
|
|
value="{{.FormLogin}}" required>
|
|
|
|
<label for="mode">Address mode</label>
|
|
<select id="mode" name="mode">
|
|
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
|
|
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
|
|
</select>
|
|
|
|
<label for="addresses">Addresses (for list mode; one per line or comma-separated)</label>
|
|
<textarea id="addresses" name="addresses" rows="3"
|
|
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
|
|
|
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
<button type="submit">Create application</button>
|
|
</form>
|
|
<p class="muted">A strong password is generated and shown once. The login must
|
|
be unique across all domains and may contain letters, digits, '.', '-' and '_'.</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Export domain</h2>
|
|
<p class="muted">Download this domain to move it to another SelfPost instance:
|
|
its DKIM key, selector and every application with its working password. On
|
|
import the DNS record stays the same, so no DNS change is needed.</p>
|
|
<p class="muted"><strong>The export file is a secret</strong> — it contains the
|
|
private DKIM key and application passwords. Transfer it securely and delete it
|
|
after the import.</p>
|
|
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/export">
|
|
<button type="submit">Export domain</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Danger zone</h2>
|
|
<p class="muted">Deleting this domain also deletes its DKIM key and every
|
|
application bound to it.</p>
|
|
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
|
|
</div>
|
|
{{end}}
|