test / test (push) Canceled after 0s
Audit 2026-10-12, stage 2c, "Health says what is served"; the pages of
ea8b6bf's mockup.
Sockets (A-38). A unix socket's file outlives a killed OpenDKIM or panel
and refuses every connect() (unix(7)); Health said "Listening" from a
stat. CheckSocket and CheckMilter's unix case now dial: refused is
"Nothing listens on the ... socket: the program that made it has
stopped", EACCES is unknown (the panel may not connect), anything else
"does not answer". The probe closes before a byte, which no milter logs:
libmilter takes the EOF as SMFIC_EOF without a line (sendmail
8.17.1.9-2+deb12u2, the image's libmilter1.0.1, libmilter/comm.c
mi_rd_cmd(), engine.c mi_engine() - its source is now in dev/upstream),
OpenDKIM's mlfi_close() has no connection to free, go-milter v0.4.1
returns quietly on io.EOF (session.go, HandleMilterCommands()). The page
polls every 5 s while in view, so this mattered.
Certificate (A-65). Postfix serves build/postfix-config.sh's copy,
refreshed by cert-reload every TLS_RELOAD_INTERVAL_SECONDS. The panel now
reads smtpd_tls_cert_file from postconf -h at its start (as the retry
policy) and TLS_RELOAD_INTERVAL_SECONDS; CheckServedCertificate checks the
served copy and, when the mounted file differs byte for byte (cert-reload's
own cmp -s), names it as Renewal: its expiry and mtime, two facts on the
card. Waiting is fine for the interval: a nearing expiry of the served one
is then no warning (an expiry still an error); past interval + 1 h it is a
warning pointing at cert-reload's lines. The notifier sends no expiry
notice while a renewal waits on time.
Wording (D-P1, D-P3, and the same for the spam filter): Sender check
note, help topics sender-check, upstream, filter, certificate - word for
word as in the mockup; the filter row's detail too.
Tests: stale socket file (Linux) refused for CheckSocket and CheckMilter;
CheckServedCertificate (same, waiting, late, expired + waiting, no served
path); intervalText; the card's facts; the notifier quiet while a renewal
waits, speaking once it is late. Stand (2026-10-15, make e2e, exit 0):
new health_shows_what_is_served - the three sockets dialled ok by the
panel's user; a certificate mounted without its key is refused by
cert-reload ("do not belong together") and the card shows the served
expiry, "Renewal expires" with the mounted one's, "Renewal mounted", and
"Postfix still serves the certificate above."; a matching pair is then
served again. go vet and go test ./... pass on the server (24 packages).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
208 lines
7.4 KiB
Go
208 lines
7.4 KiB
Go
package view
|
||
|
||
import (
|
||
"strconv"
|
||
"time"
|
||
)
|
||
|
||
// The data of Server › Health (templates/health.html, mockup health.html):
|
||
// every check behind the cards of Overview, as tables, and the home of the two
|
||
// server actions, Re-check DNS and Reload configuration.
|
||
//
|
||
// The page is two stacks of boxes numbered by importance (the `cards` layout),
|
||
// and all but the Configuration box are polled (templates/health_body.html):
|
||
// the handler builds the same Health for the page and for its fragment, and
|
||
// AsFragment marks what the fragment swaps in out of band.
|
||
|
||
// Health is the data of the Health page.
|
||
type Health struct {
|
||
Meta
|
||
Head Head
|
||
Flash *Flash
|
||
Processes Box
|
||
ProcessRows []ProcessRow
|
||
ProcessError bool
|
||
|
||
// SenderCheck is what the strict check of the From: line has cost: for how
|
||
// long the panel did not answer while Postfix was running, how many
|
||
// connections Postfix turned away and how many messages it deferred in
|
||
// the middle for it. SenderNote is the line under its table.
|
||
SenderCheck Box
|
||
SenderRows []SenderCheckRow
|
||
SenderNote Text
|
||
|
||
Machine Box
|
||
MachineRows []MachineRow
|
||
|
||
Certificate Box
|
||
CertFacts []Fact
|
||
CertDetail string
|
||
CertProblem bool
|
||
|
||
Sockets Box
|
||
SocketRows []SocketRow
|
||
Hostname Box
|
||
HostFacts []Fact
|
||
|
||
// NotifyMail is the card of the DNS records notifications are signed and
|
||
// authorised with (their values are under Server › Settings); NotifyNote
|
||
// is the line under its table.
|
||
NotifyMail Box
|
||
NotifyRows []MailRow
|
||
NotifyNote Text
|
||
|
||
Configuration Box
|
||
Reload Action
|
||
}
|
||
|
||
// SenderCheckRow is one period of the Sender check table.
|
||
type SenderCheckRow struct {
|
||
Period string
|
||
Down string // with "≈ " in front when the period holds an estimate
|
||
Deferred string // connections turned away
|
||
Messages string // messages deferred in the middle
|
||
}
|
||
|
||
// MachineRow is one line of the Machine table. A row that is a level (CPU,
|
||
// memory) carries a Gauge; one that is a rate (network) carries Usage text; a
|
||
// reading that could not be taken carries neither and shows a dash. Detail is
|
||
// the supporting text, one line each.
|
||
type MachineRow struct {
|
||
Resource string
|
||
Gauge *Gauge
|
||
Usage string
|
||
Detail []string
|
||
}
|
||
|
||
// Gauge is a reading shown as a bar and its figure: Percent fills the bar, Text
|
||
// is the figure printed beside it, and Level (ok, warn or fail) colours it.
|
||
type Gauge struct {
|
||
Percent int
|
||
Text string
|
||
Level string
|
||
}
|
||
|
||
// Bar is the Bulma class that colours the gauge's progress bar.
|
||
func (g Gauge) Bar() string {
|
||
switch g.Level {
|
||
case LevelFail:
|
||
return "is-danger"
|
||
case LevelWarn:
|
||
return "is-warning"
|
||
}
|
||
return "is-success"
|
||
}
|
||
|
||
// ProcessRow is one supervised program: its name, its state as a tag and what
|
||
// supervisord said about it.
|
||
type ProcessRow struct {
|
||
Name string
|
||
State Tag
|
||
Detail string
|
||
}
|
||
|
||
// SocketRow is one milter socket. Note qualifies the name ("inbound spam
|
||
// filter"); Detail is shown only when the socket is not fine, since "Listening"
|
||
// says nothing the tag does not.
|
||
type SocketRow struct {
|
||
Name string
|
||
Note string
|
||
Path string
|
||
State Tag
|
||
Detail string
|
||
}
|
||
|
||
// MailRow is one record of the Notification mail table: its name, the host it
|
||
// is published at and its state as a tag.
|
||
type MailRow struct {
|
||
Name string
|
||
Host string
|
||
State Tag
|
||
}
|
||
|
||
// NewMailRow is the row of one record of the server's own name, from what the
|
||
// DNS check found. A record that is not in place is a warning, as on Settings.
|
||
func NewMailRow(name, host string, c DNSCheck) MailRow {
|
||
return MailRow{Name: name, Host: host, State: mailTag(c)}
|
||
}
|
||
|
||
// NewHealth builds the page around its content. The handler fills the tables;
|
||
// the boxes, the head and the reload action are the page's own.
|
||
func NewHealth(m Meta, flash string) *Health {
|
||
m.Title, m.Section, m.Page = "Health", "server", "health"
|
||
h := &Health{
|
||
Meta: m,
|
||
Head: Head{
|
||
Kicker: "Server",
|
||
Title: "Health",
|
||
Lead: Plain("Everything behind the cards on Overview. Refreshes automatically."),
|
||
Actions: []Action{{Label: "Re-check DNS", Icon: "ti-refresh", Post: "/server/health/recheck"}},
|
||
},
|
||
// Numbered by importance; the odd ones are the left stack.
|
||
Processes: Box{No: "01", Title: "Processes", ID: "processes"},
|
||
Sockets: Box{No: "02", Title: "Milter sockets", ID: "sockets"},
|
||
SenderCheck: Box{No: "03", Title: "Sender check", ID: "sender-check",
|
||
Help: topicLink(HelpSenderCheck, "What is checked and what these numbers are for")},
|
||
Certificate: Box{No: "04", Title: "TLS certificate", ID: "certificate", Help: topicLink(HelpCertificate, "When it warns and what to do")},
|
||
Hostname: Box{No: "05", Title: "Hostname and reverse DNS", ID: "hostname", Help: topicLink(HelpRDNS, "Forward and reverse DNS")},
|
||
NotifyMail: Box{No: "06", Title: "Notification mail", ID: "notification-mail",
|
||
Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")},
|
||
Machine: Box{No: "07", Title: "Machine", ID: "machine", Help: topicLink(HelpChecks, "What these readings mean")},
|
||
Configuration: Box{No: "08", Title: "Configuration", ID: "configuration",
|
||
End: Plain("Safe to run at any time")},
|
||
Reload: Action{Label: "Reload configuration", Icon: "ti-reload", Post: "/server/health/reload"},
|
||
}
|
||
if flash != "" {
|
||
h.Flash = &Flash{Text: Plain(flash)}
|
||
}
|
||
return h
|
||
}
|
||
|
||
// WithNotificationMail fills the Notification mail card: from is the address
|
||
// notifications come from, rows the state of each record, and status the
|
||
// card's verdict for its head.
|
||
func (h *Health) WithNotificationMail(from string, rows []MailRow, status string) *Health {
|
||
h.NotifyMail.End = Verdict(status)
|
||
h.NotifyRows = rows
|
||
h.NotifyNote = Rich("Sent from ", Mono(from), ". What to publish is under ",
|
||
Link("/server/settings#notification-mail", "Settings"), ".")
|
||
return h
|
||
}
|
||
|
||
// WithSenderCheck fills the Sender check card: its three periods, the first
|
||
// day counted ("" before any was) and the card's verdict.
|
||
func (h *Health) WithSenderCheck(rows []SenderCheckRow, since, status string) *Health {
|
||
h.SenderCheck.End = Verdict(status)
|
||
h.SenderRows = rows
|
||
h.SenderNote = Rich("The panel holds each message's ", Code("From:"),
|
||
" against the application that sends it. While the panel does not answer, Postfix answers the sending application with a temporary error instead of accepting mail unchecked, and the application tries again later. Time is counted only while Postfix itself was running; ≈ marks a period with a start at which that could not be asked, and its time is at most what is shown.")
|
||
if since != "" {
|
||
h.SenderNote = append(h.SenderNote, Rich(" Counted since ", Mono(since), ".")...)
|
||
}
|
||
return h
|
||
}
|
||
|
||
// AsFragment makes the page its polled fragment: the left stack replaces the
|
||
// element that polls, and the three readings of the right stack are marked to
|
||
// be swapped into place by their ids.
|
||
func (h *Health) AsFragment() *Health {
|
||
h.Sockets.OOB, h.Certificate.OOB, h.NotifyMail.OOB = true, true, true
|
||
return h
|
||
}
|
||
|
||
// Downtime prints a length of time the way the Sender check table does: whole
|
||
// seconds while they say something, then minutes, then hours.
|
||
func Downtime(d time.Duration) string {
|
||
secs := int64(d / time.Second)
|
||
switch {
|
||
case secs < 60:
|
||
return strconv.FormatInt(secs, 10) + " s"
|
||
case secs < 3600:
|
||
return strconv.FormatInt(secs/60, 10) + " min " + strconv.FormatInt(secs%60, 10) + " s"
|
||
}
|
||
return strconv.FormatInt(secs/3600, 10) + " h " + strconv.FormatInt(secs%3600/60, 10) + " min"
|
||
}
|
||
|
||
// Verdict puts a check's status in the end slot of its box, as the mockup does.
|
||
func Verdict(status string) Text { return Text{TagOf(status, "")} }
|