panel-dkim-signing stage 2: OpenDKIM removed
test / test (push) Canceled after 0s

The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.

Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.

Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.

Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.

KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.

e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-16 01:05:39 +03:00
co-authored by Claude Opus 5.5
parent 005922bc7d
commit faa3c11ae7
96 changed files with 971 additions and 2247 deletions
+2 -1
View File
@@ -29,7 +29,8 @@ type Config struct {
TLSCertFile string
TLSServedCertFile string
TLSReloadInterval time.Duration
OpenDKIMSocket string
SignSocket string
SignLocalSocket string
JournalSocket string
SenderSocket string
// Level-1 Postfix anvil backstop (env RATE_LIMIT_*), shown in the panel
+3 -3
View File
@@ -56,11 +56,11 @@ func newAppStandAt(t *testing.T, dbPath string) appStand {
// The domain page is rendered with the domain's DKIM record and its DNS
// state: a real key in a temporary directory, and a resolver nobody
// listens on, so every check answers at once without leaving the machine.
odk := domain.NewOpenDKIM(t.TempDir())
if _, err := odk.EnsureKey("example.org", "mail"); err != nil {
keys := domain.NewKeys(t.TempDir())
if _, err := keys.EnsureKey("example.org", "mail"); err != nil {
t.Fatalf("EnsureKey: %v", err)
}
h.domains = domain.NewService(h.store, odk, h.apps, "mail")
h.domains = domain.NewService(h.store, keys, h.apps, "mail")
h.dns = offlineDNS
h.cfg.RateLimitMessagesPerIP, h.cfg.RateLimitWindowSeconds = 600, 3600
var err error
+18 -14
View File
@@ -5,6 +5,8 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -81,13 +83,11 @@ func contains(list []string, want string) bool {
// and its DMARC record says so.
func TestNewDomainHasNoReports(t *testing.T) {
s, _ := hostedStand(t)
// A second OpenDKIM that reloads nothing: the stand's has no supervisor.
odk := domain.NewOpenDKIM(t.TempDir())
odk.SetReloadHook(func() error { return nil })
if _, err := odk.EnsureKey("example.org", "mail"); err != nil {
keys := domain.NewKeys(t.TempDir())
if _, err := keys.EnsureKey("example.org", "mail"); err != nil {
t.Fatal(err)
}
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
s.h.domains = domain.NewService(s.h.store, keys, s.h.apps, "mail")
rec := postFormAs(s.h.HandleAddDomain, globalPrincipal, "/outbound/domains", nil, url.Values{"name": {"new.example"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("adding a domain = %d:\n%s", rec.Code, rec.Body.String())
@@ -309,11 +309,9 @@ func (failingDMARCMaps) RebuildDMARCMaps(postfix.DMARCMapsConfig) error {
// not a plain-text page (audit 2026-10-12, L13).
func TestReloadWritesTheDMARCMapsAndSaysWhereItFailed(t *testing.T) {
s, maps := hostedStand(t)
// An OpenDKIM that reloads nothing - the stand has no supervisor - and
// a sampler for the Health page a failure is shown on.
odk := domain.NewOpenDKIM(t.TempDir())
odk.SetReloadHook(func() error { return nil })
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
// Keys of its own, and a sampler for the Health page a failure is shown
// on.
s.h.domains = domain.NewService(s.h.store, domain.NewKeys(t.TempDir()), s.h.apps, "mail")
s.h.machine = &health.MachineSampler{}
rec := postFormAs(s.h.HandleReload, globalPrincipal, "/server/health/reload", nil, url.Values{})
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/server/health?reloaded=1" {
@@ -335,14 +333,20 @@ func TestReloadWritesTheDMARCMapsAndSaysWhereItFailed(t *testing.T) {
// the way to finish, not "internal error" (audit 2026-10-12, L17).
func TestDeleteDomainSaysItIsGoneWhenARebuildFails(t *testing.T) {
s, _ := hostedStand(t)
odk := domain.NewOpenDKIM(t.TempDir())
odk.SetReloadHook(func() error { return nil })
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
dir := t.TempDir()
s.h.domains = domain.NewService(s.h.store, domain.NewKeys(dir), s.h.apps, "mail")
d, err := s.h.domains.Add("gone.example")
if err != nil {
t.Fatal(err)
}
odk.SetReloadHook(func() error { return errors.New("supervisor away") })
// A directory where the selector table goes: it cannot be rewritten.
table := filepath.Join(dir, "selectors")
if err := os.Remove(table); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(table, "x"), 0o700); err != nil {
t.Fatal(err)
}
rec := postFormAs(s.h.HandleDeleteDomain, globalPrincipal, "/outbound/domains/x/delete", map[string]string{"id": strconv.FormatInt(d.ID, 10)}, url.Values{})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("delete with a failing rebuild = %d", rec.Code)
+6 -5
View File
@@ -85,7 +85,7 @@ func dashboardFlash(r *http.Request) string {
}
// HandleAddDomain validates the submitted name, creates the domain (DKIM key +
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
// selector table), and redirects to the domain's page so the DNS record to
// publish is shown (product.md).
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
_, ok := h.requireGlobal(w, r)
@@ -171,9 +171,10 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
}
// HandleReload writes again, from the database, every file the panel keeps
// for the mail path and has OpenDKIM and Postfix re-read them - what the
// first start after a restore does (cmd/panel mailStack.Resync): OpenDKIM's
// tables, the sender map, with the inbound relay on its maps and the filter
// for the mail path and has Postfix re-read them - what the first start after
// a restore does (cmd/panel mailStack.Resync): the DKIM selector table (and
// the keys of deleted domains removed), the sender map, with the inbound
// relay on its maps and the filter
// lists, with DMARC reports on their maps (architecture.md § Panel HTTP
// surface). Before 2.5.4 it left the last two out, and said "regenerated"
// over them (audit 2026-10-12, L13). A failure is a message on Health that
@@ -187,7 +188,7 @@ func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
run func() error
}
steps := []step{
{"OpenDKIM's tables", h.domains.Resync},
{"the DKIM selector table", h.domains.Resync},
{"the sender map", h.apps.Resync},
}
if h.cfg.InboundEnabled && h.inbound != nil {
@@ -16,17 +16,18 @@ import (
// The inbound spam filter's socket is one of the milter sockets on Health and
// counts in the Milter sockets card of Overview — and only when the filter is on.
// socketsServer is a server whose two Postfix milter sockets answer, so that
// socketsServer is a server whose Postfix milter sockets answer, so that
// every difference in the Milter sockets card comes from the filter.
func socketsServer(t *testing.T) *Handlers {
t.Helper()
h, _ := settingsServer(t)
h.machine = &health.MachineSampler{}
dir := t.TempDir()
h.cfg.OpenDKIMSocket = filepath.Join(dir, "opendkim.sock")
h.cfg.SignSocket = filepath.Join(dir, "sign.sock")
h.cfg.SignLocalSocket = filepath.Join(dir, "sign-local.sock")
h.cfg.JournalSocket = filepath.Join(dir, "journal.sock")
h.cfg.SenderSocket = filepath.Join(dir, "sender.sock")
for _, path := range []string{h.cfg.OpenDKIMSocket, h.cfg.SenderSocket, h.cfg.JournalSocket} {
for _, path := range []string{h.cfg.SignSocket, h.cfg.SignLocalSocket, h.cfg.SenderSocket, h.cfg.JournalSocket} {
l, err := net.Listen("unix", path)
if err != nil {
t.Skipf("unix sockets unavailable here: %v", err)
@@ -56,24 +57,24 @@ func TestFilterOffAddsNoSocket(t *testing.T) {
h := socketsServer(t)
h.cfg.InboundEnabled = true
h.cfg.InboundAntispamMilter = "" // no filter attached
if got := socketsOf(t, h); len(got) != 3 {
t.Fatalf("%d sockets with the filter off, want 3: %+v", len(got), got)
if got := socketsOf(t, h); len(got) != 4 {
t.Fatalf("%d sockets with the filter off, want 4: %+v", len(got), got)
}
// A milter address with the inbound relay off is not a filter either.
h.cfg.InboundEnabled = false
h.cfg.InboundAntispamMilter = "inet:127.0.0.1:1"
got := socketsOf(t, h)
if len(got) != 3 {
t.Fatalf("%d sockets with the inbound relay off, want 3: %+v", len(got), got)
if len(got) != 4 {
t.Fatalf("%d sockets with the inbound relay off, want 4: %+v", len(got), got)
}
card := socketCard(t, h)
if card.Value != "3 of 3 answering" || card.Sub != "OpenDKIM, sender-check, send-log" || card.Level != "" {
if card.Value != "4 of 4 answering" || card.Sub != "dkim-signer, sender-check, send-log" || card.Level != "" {
t.Errorf("card = %+v", card)
}
}
func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
func TestFilterOnAnsweringIsTheLastSocket(t *testing.T) {
h := socketsServer(t)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ -84,7 +85,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = "inet:"+l.Addr().String(), "tempfail"
got := socketsOf(t, h)
if len(got) != 4 || got[3].Name != "Spam filter" || got[3].Note != "inbound" || got[3].Path != "inet:"+l.Addr().String() {
if len(got) != 5 || got[4].Name != "Spam filter" || got[4].Note != "inbound" || got[4].Path != "inet:"+l.Addr().String() {
t.Fatalf("sockets = %+v", got)
}
for _, s := range got {
@@ -93,7 +94,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
}
}
card := socketCard(t, h)
if card.Value != "4 of 4 answering" || card.Sub != "incl. the spam filter" || card.Level != "" {
if card.Value != "5 of 5 answering" || card.Sub != "incl. the spam filter" || card.Level != "" {
t.Errorf("card = %+v", card)
}
@@ -111,7 +112,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
}
// A filter that does not answer is a socket that is missing: an error when
// Postfix defers inbound mail while it is down (as for OpenDKIM), a warning when
// Postfix defers inbound mail while it is down (as for the signer), a warning when
// it accepts the mail unfiltered (as for the send log).
func TestFilterDownIsAMissingSocket(t *testing.T) {
h := socketsServer(t)
@@ -133,10 +134,10 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
} {
h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = dead, tt.action
got := socketsOf(t, h)
if len(got) != 4 {
t.Fatalf("action %q: %d sockets, want 4", tt.action, len(got))
if len(got) != 5 {
t.Fatalf("action %q: %d sockets, want 5", tt.action, len(got))
}
f := got[3]
f := got[4]
refused := "Connection to " + l.Addr().String() + " refused. " + tt.detail
if f.Present || f.Status != tt.status || f.Detail != refused {
t.Errorf("action %q: filter = %+v, want status %q, detail %q", tt.action, f, tt.status, refused)
@@ -147,7 +148,7 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
t.Errorf("action %q: SocketStatus = %q, want %q", tt.action, c.SocketStatus, tt.status)
}
card := socketCard(t, h)
if card.Value != "3 of 4 answering" || card.Level != tt.level || !strings.HasSuffix(card.Sub, "Spam filter") {
if card.Value != "4 of 5 answering" || card.Level != tt.level || !strings.HasSuffix(card.Sub, "Spam filter") {
t.Errorf("action %q: card = %+v", tt.action, card)
}
@@ -161,7 +162,7 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
rec := httptest.NewRecorder()
h.HandleOverviewFragment(rec, reqAs("/overview/fragment", globalPrincipal))
page := rec.Body.String()
for _, want := range []string{`>` + tt.stamp + `<`, `Milter sockets</a> (3 of 4 answering)`} {
for _, want := range []string{`>` + tt.stamp + `<`, `Milter sockets</a> (4 of 5 answering)`} {
if !strings.Contains(page, want) {
t.Errorf("action %q: Overview is missing %q", tt.action, want)
}
@@ -201,3 +202,19 @@ func TestFilterAddressIsGlobalOnly(t *testing.T) {
func reqAs(path string, p auth.Principal) *http.Request {
return auth.RequestWithPrincipal(httptest.NewRequest("GET", path, nil), p)
}
// The signer's two rows are one name on the card, and a row that does not
// answer says which of the two it is.
func TestSignerSocketsOnTheCard(t *testing.T) {
h := socketsServer(t)
h.cfg.SignLocalSocket = filepath.Join(t.TempDir(), "gone.sock")
got := socketsOf(t, h)
if got[0].Name != "dkim-signer" || got[0].Note != "applications" || !got[0].Present ||
got[1].Name != "dkim-signer" || got[1].Note != "notifications" || got[1].Present || got[1].Status != health.StatusError {
t.Fatalf("sockets = %+v", got)
}
card := socketCard(t, h)
if card.Value != "3 of 4 answering" || card.Sub != "dkim-signer · notifications" || card.Level != "fail" {
t.Errorf("card = %+v", card)
}
}
@@ -19,7 +19,7 @@ import (
func healthReading() healthReadings {
c := checksFixture()
c.Processes = []health.Process{
{Name: "opendkim", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK},
{Name: "panel", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK},
{Name: "postfix", State: "FATAL", Detail: "exited too quickly", Status: health.StatusError},
}
c.Cert = health.Certificate{
@@ -44,7 +44,7 @@ func healthReading() healthReadings {
Status: health.StatusOK,
}
c.Sockets = []health.Socket{
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening"},
{Name: "dkim-signer", Note: "applications", Path: "/run/selfpost/sign.sock", Present: true, Status: health.StatusOK, Detail: "Listening"},
{Name: "send-log", Path: "/run/selfpost/journal.sock", Status: health.StatusWarn, Detail: "send-log socket missing: the log stops being written."},
}
c.SocketStatus = health.StatusWarn
@@ -73,7 +73,7 @@ func TestHealthPageRendersEveryCheck(t *testing.T) {
h, _ := settingsServer(t)
out := renderHealth(t, h, healthReading(), "")
for _, want := range []string{
"opendkim", "fatal", "exited too quickly",
"panel", "fatal", "exited too quickly",
"mail.example.com", "203.0.113.10 → no PTR record", "2026-11-08 09:14 UTC",
`action="/server/health/reload"`, `action="/server/health/recheck"`,
`hx-get="/server/health/fragment"`,
@@ -208,7 +208,7 @@ func TestHealthFragmentCarriesTheSameRows(t *testing.T) {
func TestOverviewPageShowsVerdictsAndDomains(t *testing.T) {
h, _ := settingsServer(t)
h.machine = &health.MachineSampler{}
h.domains = domain.NewService(h.store, domain.NewOpenDKIM(t.TempDir()), nil, "selfpost")
h.domains = domain.NewService(h.store, domain.NewKeys(t.TempDir()), nil, "selfpost")
h.cfg.Hostname = "mail.example.org"
d, err := h.store.AddDomain("example.org", "mail")
if err != nil {
@@ -153,7 +153,7 @@ func serverMailStand(t *testing.T) (*Handlers, *fakeZone, domain.ServerMail) {
h.dns = dnscheck.New([]string{addr})
h.cfg.Hostname = notifyHost
h.machine = &health.MachineSampler{}
h.domains = domain.NewService(h.store, domain.NewOpenDKIM(t.TempDir()), nil, "sp2026")
h.domains = domain.NewService(h.store, domain.NewKeys(t.TempDir()), nil, "sp2026")
h.domains.SetServerName(notifyHost)
mail, err := h.domains.ServerMail()
if err != nil {
@@ -124,7 +124,7 @@ func settingsServerAt(t *testing.T, dbPath string) (*Handlers, string) {
// A domain service that does not know the server's own name, so that
// the records of Settings and Health come out as "cannot be read";
// the tests of those records use serverMailStand.
domains: domain.NewService(st, domain.NewOpenDKIM(t.TempDir()), nil, "selfpost"),
domains: domain.NewService(st, domain.NewKeys(t.TempDir()), nil, "selfpost"),
cfg: Config{SendLogRetentionEnvDefault: 90},
}, password
}
+27 -8
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"sync"
@@ -346,19 +347,24 @@ func healthCards(c healthReadings) []view.HealthCard {
}
// The line under the count has to fit a card: the sockets that do not
// answer when some do not; otherwise their names, or - with the inbound spam
// filter among them, when three names no longer fit - that it is included.
// answer when some do not; otherwise their names - the signer's two once -
// or, with the inbound spam filter among them, when three names no longer
// fit, that it is included.
answering, socketNames, silent, withFilter := 0, make([]string, 0, len(sockets)), []string{}, false
for _, s := range sockets {
if s.Present {
answering++
} else if s.Note != "" && s.Name != filterName {
silent = append(silent, s.Name+" · "+s.Note)
} else {
silent = append(silent, s.Name)
}
if s.Note != "" {
if s.Name == filterName {
withFilter = true
}
socketNames = append(socketNames, s.Name)
if !slices.Contains(socketNames, s.Name) {
socketNames = append(socketNames, s.Name)
}
}
socketSub := strings.Join(socketNames, ", ")
switch {
@@ -455,7 +461,8 @@ func (h *Handlers) healthChecks() healthReadings {
cert := health.CheckServedCertificate(h.cfg.TLSServedCertFile, h.cfg.TLSCertFile, h.cfg.TLSReloadInterval, time.Now())
sockets := []health.Socket{
health.CheckSocket("OpenDKIM", h.cfg.OpenDKIMSocket, true),
signerSocket(h.cfg.SignSocket, "applications"),
signerSocket(h.cfg.SignLocalSocket, "notifications"),
health.CheckSocket("sender-check", h.cfg.SenderSocket, true),
health.CheckSocket("send-log", h.cfg.JournalSocket, false),
}
@@ -486,15 +493,27 @@ func (h *Handlers) healthChecks() healthReadings {
}
}
// filterName is the inbound spam filter's row among the milter sockets.
const filterName = "Spam filter"
// signerSocket is a row of the DKIM signer's: one socket for what
// applications send, one for the panel's own notifications. Postfix attaches
// both strictly, so either one down defers the mail that goes through it.
func signerSocket(path, note string) health.Socket {
s := health.CheckSocket("dkim-signer", path, true)
s.Note = note
return s
}
// filterSocket is the inbound spam filter's row among the milter sockets. A
// filter that is down costs what the server's setting says: with the action
// tempfail Postfix defers inbound mail (an error, like OpenDKIM), otherwise it
// tempfail Postfix defers inbound mail (an error, like the signer), otherwise it
// accepts the mail unfiltered (a warning, like the send log). The address is the
// one in INBOUND_ANTISPAM_MILTER as configured; Health and Overview, the only
// pages that show it, are for the global administrator.
func filterSocket(milter, action string) health.Socket {
tempfail := action == "tempfail"
s := health.CheckMilter("Spam filter", milter, tempfail)
s := health.CheckMilter(filterName, milter, tempfail)
s.Note = "inbound"
switch {
case s.Status != health.StatusError && s.Status != health.StatusWarn:
@@ -519,7 +538,7 @@ func queueSummary(out string) string {
func statusFlash(r *http.Request) string {
switch {
case r.URL.Query().Get("reloaded") != "":
return "Configuration regenerated from the database - OpenDKIM's tables, every map the panel writes for Postfix, the filter lists - and OpenDKIM and Postfix have re-read it."
return "Configuration regenerated from the database - the DKIM selector table, every map the panel writes for Postfix, the filter lists - and Postfix has re-read it."
case r.URL.Query().Get("rechecked") != "":
return "DNS re-checked."
default:
@@ -13,7 +13,7 @@ import (
func checksFixture() healthReadings {
return healthReadings{
Processes: []health.Process{
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
{Name: "panel", State: "RUNNING", Status: health.StatusOK},
{Name: "postfix", State: "FATAL", Status: health.StatusError},
},
ProcessError: false,
@@ -24,7 +24,7 @@ func checksFixture() healthReadings {
Machine: health.Machine{Status: health.StatusUnknown},
Cert: health.Certificate{Subject: "mail.example.org", DaysLeft: 12, Status: health.StatusWarn, Detail: "Expires soon."},
Sockets: []health.Socket{
{Name: "OpenDKIM", Present: true, Status: health.StatusOK},
{Name: "dkim-signer", Present: true, Status: health.StatusOK},
{Name: "send-log", Present: false, Status: health.StatusWarn},
},
SocketStatus: health.StatusWarn,
@@ -87,7 +87,7 @@ func TestQueueCardValue(t *testing.T) {
func TestProcessesCardLeavesOutTheIdleOneShot(t *testing.T) {
c := checksFixture()
c.Processes = []health.Process{
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
{Name: "panel", State: "RUNNING", Status: health.StatusOK},
{Name: "postfix", State: "RUNNING", Status: health.StatusOK},
{Name: "postfix-reload", State: "STOPPED", Status: health.StatusOK, OneShot: true},
}
@@ -98,7 +98,7 @@ func TestProcessesCardLeavesOutTheIdleOneShot(t *testing.T) {
}
c.Processes = []health.Process{
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
{Name: "panel", State: "RUNNING", Status: health.StatusOK},
{Name: "postfix-reload", State: "FATAL", Status: health.StatusError, OneShot: true},
}
card = healthCards(c)[1]