test / test (push) Waiting to run
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>
423 lines
12 KiB
Go
423 lines
12 KiB
Go
package view
|
|
|
|
// The data of the Server pages that are not a health table: the system log
|
|
// (templates/system_log.html, mockup system-log.html), the backup and the
|
|
// domain import (backup.html, backup.html), the users list (users.html,
|
|
// users.html), the user form (user.html, user.html) and the confirmation before
|
|
// a user is deleted (user_delete.html, user-delete.html). Everything under
|
|
// /server/ is for the global role.
|
|
//
|
|
// A page is a typed struct that embeds Meta, built by a constructor and filled
|
|
// by With… methods; the handler reads the stores, the view decides what is said
|
|
// and how it is worded.
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
serverLog = "/server/log"
|
|
serverBackup = "/server/backup"
|
|
serverUsers = "/server/users"
|
|
)
|
|
|
|
// UserHref is the address of a panel user's form.
|
|
func UserHref(id int64) string { return serverUsers + "/" + strconv.FormatInt(id, 10) }
|
|
|
|
// ---- System log
|
|
|
|
// SystemLog is the data of the system log: the tail of mail.log, newest line
|
|
// first, in the one box that polls. The page and its fragment (GET
|
|
// /server/log/fragment) render the same box (templates/system_log_body.html).
|
|
type SystemLog struct {
|
|
Meta
|
|
Head Head
|
|
|
|
Log Box
|
|
Lines []LogLine
|
|
// Empty is shown in place of the lines when there are none: the log has
|
|
// nothing in it yet, or it could not be read.
|
|
Empty EmptyState
|
|
}
|
|
|
|
// NewSystemLog builds the page. lines are the log's tail as it was read, oldest
|
|
// first; the page shows them newest first. errText is why the log could not be
|
|
// read, and empty when it could.
|
|
func NewSystemLog(m Meta, lines []LogLine, errText string) *SystemLog {
|
|
m.Title, m.Section, m.Page = "System log", "server", "log"
|
|
p := &SystemLog{
|
|
Meta: m,
|
|
Head: Head{
|
|
Kicker: "Server",
|
|
Title: "System log",
|
|
Lead: Rich("The tail of ", Code("mail.log"), ", the log Postfix writes. For one message, open it from the ", Link(outboundLog, "Outbound log"), " instead."),
|
|
},
|
|
Log: Box{No: "01", Title: "Recent lines", ID: "system-log", Poll: serverLog + "/fragment",
|
|
End: Plain("Last 200 lines · refreshes on its own")},
|
|
Empty: EmptyState{Icon: "ti-file-text", Text: Plain("No log lines yet.")},
|
|
}
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
p.Lines = append(p.Lines, lines[i])
|
|
}
|
|
if errText != "" {
|
|
p.Empty = EmptyState{Icon: "ti-alert-circle", Text: Plain(errText)}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// ---- Backup
|
|
|
|
// Backup is the data of the backup page: the whole instance in one archive, and
|
|
// the import of one domain from another SelfPost. Both files are secrets and
|
|
// both can be sealed with a password, so the page carries the password fields of
|
|
// the full backup; the import asks for one only when the file needs it.
|
|
type Backup struct {
|
|
Meta
|
|
Head Head
|
|
Flash *Flash
|
|
|
|
Full Box
|
|
FullAction string
|
|
Encrypt bool
|
|
PasswordMin int
|
|
Import Box
|
|
ImportAction string
|
|
}
|
|
|
|
// NewBackup builds the page. The encryption box is ticked, as on the domain
|
|
// export: a secret file is sealed unless the operator chooses otherwise, and
|
|
// after a refusal the page comes back the same way. refusal is the message of
|
|
// whichever form was turned down, empty on a first visit.
|
|
func NewBackup(m Meta, passwordMin int, refusal string) *Backup {
|
|
m.Title, m.Section, m.Page = "Backup", "server", "backup"
|
|
p := &Backup{
|
|
Meta: m,
|
|
Head: Head{
|
|
Kicker: "Server",
|
|
Title: "Backup",
|
|
Lead: Plain("Take the whole instance with you, or bring one domain in from another SelfPost. Both files are secrets."),
|
|
},
|
|
Full: Box{No: "01", Title: "Full backup", Help: topicLink(HelpBackup, "Backups, encryption and restore")},
|
|
FullAction: serverBackup,
|
|
Encrypt: true,
|
|
PasswordMin: passwordMin,
|
|
Import: Box{No: "02", Title: "Import a domain", ID: "import", Help: topicLink(HelpBackup, "Moving a domain to another instance")},
|
|
ImportAction: serverBackup + "/import",
|
|
}
|
|
if refusal != "" {
|
|
p.Flash = &Flash{Error: true, Text: Plain(refusal)}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// ---- Users
|
|
|
|
// Users is the data of the list of panel users.
|
|
type Users struct {
|
|
Meta
|
|
Head Head
|
|
Flash *Flash
|
|
|
|
List Box
|
|
Rows []UserRow
|
|
ShowInbound bool
|
|
}
|
|
|
|
// UserRow is one panel user as the list shows them: the name, with a mark on
|
|
// the signed-in user's own row, the address, the role as a tag, the domains of
|
|
// each direction as words and the way to the form.
|
|
type UserRow struct {
|
|
Name string
|
|
You bool
|
|
Email string
|
|
Role Tag
|
|
Outbound string
|
|
Inbound string
|
|
Href string
|
|
}
|
|
|
|
// UserRowInput is what the list needs to know of one user. A global user
|
|
// reaches every domain, so the lists of names are only read for the domain role.
|
|
type UserRowInput struct {
|
|
ID int64
|
|
Username string
|
|
Email string
|
|
Global bool
|
|
You bool
|
|
AllOutbound bool
|
|
AllInbound bool
|
|
Outbound []string
|
|
Inbound []string
|
|
}
|
|
|
|
// NewUserRow words one user of the list.
|
|
func NewUserRow(in UserRowInput) UserRow {
|
|
role := Tag{Status: "domain"}
|
|
if in.Global {
|
|
role = Tag{Status: "global"}
|
|
}
|
|
return UserRow{
|
|
Name: in.Username, You: in.You, Email: orDash(in.Email), Role: role,
|
|
Outbound: reachWords(in.Global || in.AllOutbound, in.Outbound),
|
|
Inbound: reachWords(in.Global || in.AllInbound, in.Inbound),
|
|
Href: UserHref(in.ID),
|
|
}
|
|
}
|
|
|
|
// YouTag is the mark on the signed-in user's own row.
|
|
func (r UserRow) YouTag() Tag { return Tag{Label: "you"} }
|
|
|
|
// reachWords is what the list says of one direction of a user's reach: All, the
|
|
// names assigned, or a dash where nothing is.
|
|
func reachWords(all bool, names []string) string {
|
|
switch {
|
|
case all:
|
|
return "All"
|
|
case len(names) > 0:
|
|
return strings.Join(names, ", ")
|
|
}
|
|
return dash
|
|
}
|
|
|
|
// NewUsers builds the page. inbound is whether the inbound feature is on, which
|
|
// decides whether the list has the column and the lead speaks of it; flash is
|
|
// the result of the last action.
|
|
func NewUsers(m Meta, inbound bool, flash string) *Users {
|
|
m.Title, m.Section, m.Page = "Users", "server", "users"
|
|
reach := Rich("the domains assigned", Inline{Text: "."})
|
|
if inbound {
|
|
reach = Rich("the domains assigned, outbound and inbound separately", Inline{Text: "."})
|
|
}
|
|
p := &Users{
|
|
Meta: m,
|
|
Head: Head{
|
|
Kicker: "Server",
|
|
Title: "Users",
|
|
Lead: Rich("Who may sign in to this panel. Everyone here is an administrator; the role is the reach — ",
|
|
Em("global"), " is the whole server, ", Em("domain"), " is ", reach),
|
|
Actions: []Action{{Label: "Create user", Icon: "ti-user-plus", Href: serverUsers + "/new", Primary: true}},
|
|
},
|
|
List: Box{No: "01", Title: "Panel users", Help: topicLink(HelpUsers, "Roles are a reach, not a rank")},
|
|
ShowInbound: inbound,
|
|
}
|
|
if flash != "" {
|
|
p.Flash = &Flash{Text: Plain(flash)}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// WithRows sets the users and counts them in the box head.
|
|
func (p *Users) WithRows(rows []UserRow) *Users {
|
|
p.Rows = rows
|
|
p.List.End = Plain(plural(int64(len(rows)), "user", "users"))
|
|
return p
|
|
}
|
|
|
|
// ---- The user form
|
|
|
|
// UserForm is the data of the form that creates a panel user and edits one: the
|
|
// account in one box, and the domains the user reaches, outbound and inbound
|
|
// separately, in the other column. The same form serves both; a blank password
|
|
// on an existing user means keep the current one.
|
|
type UserForm struct {
|
|
Meta
|
|
Head Head
|
|
Flash *Flash
|
|
|
|
// Action is where the form is sent, Submit what its button says and
|
|
// CancelHref where Cancel goes.
|
|
Action string
|
|
Submit string
|
|
CancelHref string
|
|
|
|
Account Box
|
|
Username string
|
|
Email string
|
|
NewUser bool
|
|
PasswordMin int
|
|
Roles []Option
|
|
Role string
|
|
// RoleLocked is the only global user: the role cannot be changed, so the
|
|
// select is read-only and the role travels in a hidden field.
|
|
RoleLocked bool
|
|
|
|
Outbound Box
|
|
AllOut bool
|
|
OutDomains []DomainChoice
|
|
|
|
ShowInbound bool
|
|
Inbound Box
|
|
AllIn bool
|
|
InDomains []DomainChoice
|
|
}
|
|
|
|
// DomainChoice is one row of a list of domains on the user form.
|
|
type DomainChoice struct {
|
|
ID int64
|
|
Name string
|
|
Checked bool
|
|
}
|
|
|
|
// UserFormInput is what a user form shows: a new user's blanks or an existing
|
|
// user's values, or what was typed into a form that was refused.
|
|
type UserFormInput struct {
|
|
// ID is the user being edited, 0 for a new one; Name is that user's stored
|
|
// name, which the head shows whatever was typed into the form.
|
|
ID int64
|
|
Name string
|
|
|
|
Username string
|
|
Email string
|
|
Role string
|
|
RoleLocked bool
|
|
// CanDelete says whether the head offers Delete user.
|
|
CanDelete bool
|
|
|
|
PasswordMin int
|
|
ShowInbound bool
|
|
|
|
AllOut bool
|
|
OutDomains []DomainChoice
|
|
AllIn bool
|
|
InDomains []DomainChoice
|
|
|
|
Error string
|
|
}
|
|
|
|
// NewUserForm builds the page.
|
|
func NewUserForm(m Meta, in UserFormInput) *UserForm {
|
|
crumbs := []Crumb{{Href: serverUsers, Label: "Users"}}
|
|
title := "Create user"
|
|
if in.ID != 0 {
|
|
title = in.Name
|
|
}
|
|
m.Title, m.Section, m.Page = title+" · Users", "server", "users"
|
|
p := &UserForm{
|
|
Meta: m,
|
|
Head: Head{Crumbs: crumbs, Title: title},
|
|
CancelHref: serverUsers,
|
|
|
|
Account: Box{No: "01", Title: "Account", Help: topicLink(HelpUsers, "Roles and domain assignments")},
|
|
Username: in.Username,
|
|
Email: in.Email,
|
|
NewUser: in.ID == 0,
|
|
PasswordMin: in.PasswordMin,
|
|
Roles: []Option{
|
|
{Value: "domain", Label: "Domain — the domains assigned"},
|
|
{Value: "global", Label: "Global — the whole server"},
|
|
},
|
|
Role: in.Role,
|
|
RoleLocked: in.RoleLocked,
|
|
|
|
Outbound: Box{No: "02", Title: "Outbound domains", End: Plain("DNS records, applications, log"), DomainPick: true},
|
|
AllOut: in.AllOut,
|
|
OutDomains: in.OutDomains,
|
|
ShowInbound: in.ShowInbound,
|
|
Inbound: Box{No: "03", Title: "Inbound domains", End: Plain("Upstream, recipients, log"), DomainPick: true},
|
|
AllIn: in.AllIn,
|
|
InDomains: in.InDomains,
|
|
}
|
|
if p.NewUser {
|
|
p.Action, p.Submit = serverUsers+"/new", "Create user"
|
|
} else {
|
|
p.Action, p.Submit = UserHref(in.ID), "Save user"
|
|
if in.CanDelete {
|
|
p.Head.Actions = []Action{{Label: "Delete user", Icon: "ti-trash", Href: UserHref(in.ID) + "/delete", Danger: true}}
|
|
}
|
|
}
|
|
if in.Error != "" {
|
|
p.Flash = &Flash{Error: true, Text: Plain(in.Error)}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// ---- Delete a user
|
|
|
|
// UserDelete is the data of the confirmation before a panel user is deleted:
|
|
// what goes with them, the one button that does it, and the way out.
|
|
type UserDelete struct {
|
|
Meta
|
|
Head Head
|
|
|
|
Danger Box
|
|
Intro Text
|
|
Consequences []Text
|
|
Note Text
|
|
Action string
|
|
Confirm string
|
|
KeepHref string
|
|
}
|
|
|
|
// UserDeleteInput is what the confirmation needs to know of the user.
|
|
type UserDeleteInput struct {
|
|
ID int64
|
|
Username string
|
|
Global bool
|
|
// What the user is assigned: All of a direction, or the names of its domains.
|
|
AllOutbound bool
|
|
AllInbound bool
|
|
Outbound []string
|
|
Inbound []string
|
|
}
|
|
|
|
// NewUserDelete builds the page.
|
|
func NewUserDelete(m Meta, in UserDeleteInput) *UserDelete {
|
|
m.Title, m.Section, m.Page = "Delete "+in.Username, "server", "users"
|
|
href := UserHref(in.ID)
|
|
p := &UserDelete{
|
|
Meta: m,
|
|
Head: Head{
|
|
Crumbs: []Crumb{{Href: serverUsers, Label: "Users"}, {Href: href, Label: in.Username}},
|
|
Title: "Delete " + in.Username,
|
|
},
|
|
Danger: Box{Icon: "ti-alert-triangle", Title: "This cannot be undone", Variant: BoxDanger},
|
|
Intro: Rich("Deleting the panel user ", Strong(in.Username), " will:"),
|
|
Consequences: []Text{
|
|
Plain("end any signed-in session of this user at once;"),
|
|
},
|
|
Note: Plain("Applications and their passwords, upstreams and recipients are not affected."),
|
|
Action: href + "/delete",
|
|
Confirm: "Delete " + in.Username,
|
|
KeepHref: href,
|
|
}
|
|
if in.Global {
|
|
p.Consequences = append(p.Consequences, Plain("remove their global access to the server and to every domain."))
|
|
return p
|
|
}
|
|
var out, inb Text
|
|
switch {
|
|
case in.AllOutbound:
|
|
out = Text{Strong("all outbound domains")}
|
|
default:
|
|
out = names(in.Outbound)
|
|
}
|
|
switch {
|
|
case in.AllInbound:
|
|
inb = Text{Strong("all inbound domains")}
|
|
default:
|
|
inb = names(in.Inbound)
|
|
}
|
|
switch {
|
|
case len(out) > 0 && len(inb) > 0:
|
|
p.Consequences = append(p.Consequences, Rich("remove the assignments to ", out, " (outbound) and to ", inb, " (inbound)."))
|
|
case len(out) > 0:
|
|
p.Consequences = append(p.Consequences, Rich("remove the assignments to ", out, " (outbound)."))
|
|
case len(inb) > 0:
|
|
p.Consequences = append(p.Consequences, Rich("remove the assignments to ", inb, " (inbound)."))
|
|
}
|
|
return p
|
|
}
|
|
|
|
// names is a run of domain names, each strong, separated by commas.
|
|
func names(list []string) Text {
|
|
var out Text
|
|
for i, n := range list {
|
|
if i > 0 {
|
|
out = append(out, Inline{Text: ", "})
|
|
}
|
|
out = append(out, Strong(n))
|
|
}
|
|
return out
|
|
}
|