Files
selfpost/internal/web/view/server.go
T
mixandClaude Opus 5.5 faa3c11ae7
test / test (push) Waiting to run
panel-dkim-signing stage 2: OpenDKIM removed
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>
2026-10-16 01:05:39 +03:00

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
}