The first two pieces of the installer, and deliberately the two that change nothing: what this machine is, and what would happen to it. The survey is the floor under every later choice -- a component can only offer a container shape if Docker answers for this user, or a host shape on a systemd machine with what that shape needs. It reports what it could not establish as unknown rather than guessing: an unprivileged probe of port 25 means "I may not bind this", not "something is listening", and reporting the first as the second is a lie an operator would act on. The plan is one list, and it will have three readers: --dry-run prints it, the interface will show it before anything happens, and apply will walk it. What you are shown is what runs. Tested on a fresh Debian 13 in qemu, which has neither Docker nor Node and so exercises every unavailable shape: 23 checks, including that nothing was created by any of it. The lab that machine runs in is in e2e/vm.
414 lines
11 KiB
Go
414 lines
11 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
// Package plan turns what the operator chose, and what the machine is, into
|
|
// the list of things that would happen -- before any of them do.
|
|
//
|
|
// Everything the installer does goes through a plan. The interface shows it
|
|
// on its fifth screen, `--dry-run` prints it and stops, and apply walks it.
|
|
// One list, three readers: what you are shown is what runs.
|
|
package plan
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host"
|
|
)
|
|
|
|
// Component is one of the three programs.
|
|
type Component string
|
|
|
|
const (
|
|
Server Component = "server"
|
|
Console Component = "console"
|
|
Webmail Component = "webmail"
|
|
)
|
|
|
|
// Shape is how a component is installed here.
|
|
type Shape string
|
|
|
|
const (
|
|
Skip Shape = "skip"
|
|
Container Shape = "container"
|
|
Host Shape = "host"
|
|
)
|
|
|
|
// Names as they are shown to a person. The programs have names; the
|
|
// components have jobs.
|
|
var names = map[Component]string{
|
|
Server: "inbuxa-server (the mail server)",
|
|
Console: "inbuxa Admin (the console)",
|
|
Webmail: "inbuxa webmail",
|
|
}
|
|
|
|
// Choice is one row of the matrix.
|
|
type Choice struct {
|
|
Component Component
|
|
Shape Shape
|
|
}
|
|
|
|
// Options is everything the six screens collect.
|
|
type Options struct {
|
|
Domain string
|
|
MailHost string
|
|
ConsoleHost string
|
|
WebmailHost string
|
|
ACMEEmail string
|
|
Local bool // loopback evaluation: no public ports, no certificates
|
|
Dir string
|
|
Proxy string // "caddy", "snippets", "none"
|
|
Choices []Choice
|
|
}
|
|
|
|
// Shape returns what was chosen for a component.
|
|
func (o Options) Shape(c Component) Shape {
|
|
for _, ch := range o.Choices {
|
|
if ch.Component == c {
|
|
return ch.Shape
|
|
}
|
|
}
|
|
return Skip
|
|
}
|
|
|
|
// Defaults fills in the names that follow from the domain, leaving anything
|
|
// the operator set alone.
|
|
func (o *Options) Defaults() {
|
|
if o.Domain == "" {
|
|
return
|
|
}
|
|
if o.MailHost == "" {
|
|
o.MailHost = "mail." + o.Domain
|
|
}
|
|
if o.ConsoleHost == "" {
|
|
o.ConsoleHost = "admin." + o.Domain
|
|
}
|
|
if o.WebmailHost == "" {
|
|
o.WebmailHost = "webmail." + o.Domain
|
|
}
|
|
if o.ACMEEmail == "" {
|
|
o.ACMEEmail = "postmaster@" + o.Domain
|
|
}
|
|
if o.Dir == "" {
|
|
o.Dir = "/var/lib/inbuxa"
|
|
}
|
|
if o.Proxy == "" {
|
|
o.Proxy = "caddy"
|
|
}
|
|
}
|
|
|
|
// Availability says whether a shape can be chosen for a component on this
|
|
// machine, and why not when it cannot. The interface dims what it cannot
|
|
// offer and shows the reason beside it; a flag that asks for it anyway gets
|
|
// the same sentence as an error.
|
|
type Availability struct {
|
|
OK bool
|
|
Why string
|
|
}
|
|
|
|
// Available answers for one cell of the matrix.
|
|
func Available(f host.Facts, c Component, s Shape) Availability {
|
|
switch s {
|
|
case Skip:
|
|
return Availability{OK: true}
|
|
case Container:
|
|
if !f.Docker.Present {
|
|
return Availability{Why: "docker is not installed"}
|
|
}
|
|
if !f.Docker.Usable {
|
|
return Availability{Why: f.Docker.Why}
|
|
}
|
|
return Availability{OK: true}
|
|
case Host:
|
|
if !f.Systemd {
|
|
return Availability{Why: "a host install needs systemd, which is not running this machine"}
|
|
}
|
|
switch c {
|
|
case Server:
|
|
return Availability{OK: true}
|
|
case Console:
|
|
return Availability{OK: true}
|
|
case Webmail:
|
|
if !f.Node.Present {
|
|
return Availability{Why: "a host install of the webmail needs Node 22 or newer; none is installed"}
|
|
}
|
|
if f.Node.Major < 22 {
|
|
return Availability{Why: fmt.Sprintf("a host install of the webmail needs Node 22 or newer; found %s", f.Node.Version)}
|
|
}
|
|
return Availability{OK: true}
|
|
}
|
|
}
|
|
return Availability{Why: "unknown shape"}
|
|
}
|
|
|
|
// Step is one thing the installer will do, in the order it will do it.
|
|
type Step struct {
|
|
Title string // shown as a line in the plan and ticked off during apply
|
|
Detail []string // the files, units, containers and ports it touches
|
|
}
|
|
|
|
// Plan is the whole of it.
|
|
type Plan struct {
|
|
Options Options
|
|
Steps []Step
|
|
Ports []int // what will be bound, once, across every component
|
|
DNS []string // records the domain needs for this shape
|
|
Warnings []string // things that are not refusals but should be read
|
|
}
|
|
|
|
// Build works out what would happen. It does not touch the machine: every
|
|
// fact it needs is in the survey it was given.
|
|
func Build(f host.Facts, o Options) (Plan, error) {
|
|
o.Defaults()
|
|
p := Plan{Options: o}
|
|
|
|
if o.Domain == "" && !o.Local {
|
|
return p, fmt.Errorf("a domain is needed (or --local for a loopback evaluation)")
|
|
}
|
|
chosen := 0
|
|
for _, c := range []Component{Server, Console, Webmail} {
|
|
s := o.Shape(c)
|
|
if s == Skip {
|
|
continue
|
|
}
|
|
chosen++
|
|
if a := Available(f, c, s); !a.OK {
|
|
return p, fmt.Errorf("%s as a %s install: %s", names[c], s, a.Why)
|
|
}
|
|
}
|
|
if chosen == 0 {
|
|
return p, fmt.Errorf("nothing chosen: pick at least one component")
|
|
}
|
|
|
|
if !f.Root {
|
|
p.Warnings = append(p.Warnings, "not running as root: installing units, users and files under /etc will fail")
|
|
}
|
|
if f.MemoryMB > 0 && f.MemoryMB < 2048 {
|
|
p.Warnings = append(p.Warnings, fmt.Sprintf("%d MB of memory: a mail server with a full text index wants 2 GB or more", f.MemoryMB))
|
|
}
|
|
if f.DiskFreeGB > 0 && f.DiskFreeGB < 10 {
|
|
p.Warnings = append(p.Warnings, fmt.Sprintf("%d GB free: mail, indexes and images need room to grow", f.DiskFreeGB))
|
|
}
|
|
if f.Existing != "" {
|
|
p.Warnings = append(p.Warnings, "an install is already recorded in "+f.Existing+": this run will converge it, not duplicate it")
|
|
}
|
|
|
|
p.Steps = append(p.Steps, Step{
|
|
Title: "Write the deployment directory",
|
|
Detail: []string{o.Dir + "/ (configuration, state and the credentials file)"},
|
|
})
|
|
|
|
if s := o.Shape(Server); s != Skip {
|
|
p.Ports = append(p.Ports, 25, 465, 993, 995, 4190)
|
|
switch s {
|
|
case Container:
|
|
p.Steps = append(p.Steps, Step{
|
|
Title: "Start the mail server as a container",
|
|
Detail: []string{
|
|
"image: registry.coffeylabs.org/inbuxa/inbuxa-server (pinned by digest)",
|
|
"volumes: inbuxa-data, inbuxa-etc",
|
|
"ports: 25, 465, 993, 995, 4190, and 8081 on loopback for the proxy",
|
|
},
|
|
})
|
|
case Host:
|
|
p.Steps = append(p.Steps, Step{
|
|
Title: "Install the mail server on this machine",
|
|
Detail: []string{
|
|
"binary: /usr/local/bin/inbuxa-server, from the release on git.coffeylabs.org",
|
|
"user: inbuxa (system, no login), data in " + o.Dir + "/server",
|
|
"unit: /etc/systemd/system/inbuxa-server.service",
|
|
},
|
|
})
|
|
}
|
|
p.Steps = append(p.Steps, Step{
|
|
Title: "First boot",
|
|
Detail: []string{
|
|
"bootstrap over JMAP, then set the front-end URLs, which registers both first-party OAuth clients",
|
|
"turn on ACME, trust the proxy, create the first account",
|
|
"write " + o.Dir + "/credentials and " + o.Dir + "/dns.zone",
|
|
},
|
|
})
|
|
}
|
|
|
|
if s := o.Shape(Console); s != Skip {
|
|
switch s {
|
|
case Container:
|
|
p.Steps = append(p.Steps, Step{
|
|
Title: "Start the console as a container",
|
|
Detail: []string{
|
|
"image: registry.coffeylabs.org/inbuxa/inbuxa-admin (pinned by digest)",
|
|
"port: 8082 on loopback, behind the proxy",
|
|
},
|
|
})
|
|
case Host:
|
|
p.Steps = append(p.Steps, Step{
|
|
Title: "Install the console on this machine",
|
|
Detail: []string{
|
|
"files: " + o.Dir + "/console (static, served by the proxy)",
|
|
"no service: it is a page, not a program",
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
if s := o.Shape(Webmail); s != Skip {
|
|
switch s {
|
|
case Container:
|
|
p.Steps = append(p.Steps, Step{
|
|
Title: "Start the webmail as a container",
|
|
Detail: []string{
|
|
"image: registry.coffeylabs.org/inbuxa/ihasmail-inbuxa (pinned by digest)",
|
|
"port: 8080 on loopback, behind the proxy",
|
|
"secret: the OAuth client secret from first boot, written once",
|
|
},
|
|
})
|
|
case Host:
|
|
p.Steps = append(p.Steps, Step{
|
|
Title: "Install the webmail on this machine",
|
|
Detail: []string{
|
|
"files: " + o.Dir + "/webmail, from the release tarball",
|
|
"user: inbuxa-webmail (system, no login)",
|
|
"unit: /etc/systemd/system/inbuxa-webmail.service, listening on 127.0.0.1:8080",
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
if !o.Local {
|
|
switch o.Proxy {
|
|
case "caddy":
|
|
p.Ports = append(p.Ports, 80, 443)
|
|
p.Steps = append(p.Steps, Step{
|
|
Title: "Put a proxy in front, with certificates",
|
|
Detail: []string{
|
|
"caddy, managed by the installer",
|
|
"names: " + strings.Join(o.hostnames(), ", "),
|
|
"ports: 80 and 443",
|
|
},
|
|
})
|
|
case "snippets":
|
|
p.Steps = append(p.Steps, Step{
|
|
Title: "Write proxy configuration for the web server already here",
|
|
Detail: []string{
|
|
o.Dir + "/proxy/nginx-inbuxa.conf and " + o.Dir + "/proxy/Caddyfile.fragment",
|
|
"nothing is loaded or restarted: that stays yours",
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
p.Steps = append(p.Steps, Step{
|
|
Title: "Check it works",
|
|
Detail: []string{
|
|
"the server answers JMAP and reports its version",
|
|
"each front end answers, and points at the server it was configured with",
|
|
"the ports that should be open are, and the ones that should not are not",
|
|
},
|
|
})
|
|
|
|
p.DNS = o.dnsRecords()
|
|
|
|
for _, held := range f.PortsHeld(p.Ports...) {
|
|
who := held.Holder
|
|
if who == "" {
|
|
who = "something this user cannot see"
|
|
}
|
|
p.Warnings = append(p.Warnings, fmt.Sprintf("port %d (%s) is held by %s", held.Number, held.For, who))
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (o Options) hostnames() []string {
|
|
var names []string
|
|
if o.Shape(Server) != Skip {
|
|
names = append(names, o.MailHost)
|
|
}
|
|
if o.Shape(Console) != Skip {
|
|
names = append(names, o.ConsoleHost)
|
|
}
|
|
if o.Shape(Webmail) != Skip {
|
|
names = append(names, o.WebmailHost)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func (o Options) dnsRecords() []string {
|
|
if o.Local || o.Domain == "" {
|
|
return nil
|
|
}
|
|
var r []string
|
|
if o.Shape(Server) != Skip {
|
|
r = append(r,
|
|
fmt.Sprintf("%-28s MX 10 %s.", o.Domain+".", o.MailHost),
|
|
fmt.Sprintf("%-28s A this machine", o.MailHost+"."),
|
|
fmt.Sprintf("%-28s TXT \"v=spf1 mx -all\"", o.Domain+"."),
|
|
fmt.Sprintf("%-28s TXT the DKIM key first boot generates", "*._domainkey."+o.Domain+"."),
|
|
fmt.Sprintf("%-28s TXT \"v=DMARC1; p=reject; rua=mailto:postmaster@%s\"", "_dmarc."+o.Domain+".", o.Domain),
|
|
)
|
|
}
|
|
if o.Shape(Console) != Skip {
|
|
r = append(r, fmt.Sprintf("%-28s A this machine", o.ConsoleHost+"."))
|
|
}
|
|
if o.Shape(Webmail) != Skip {
|
|
r = append(r, fmt.Sprintf("%-28s A this machine", o.WebmailHost+"."))
|
|
}
|
|
r = append(r, "(first boot writes the exact records, including the keys, to dns.zone)")
|
|
return r
|
|
}
|
|
|
|
// String renders the plan the way the fifth screen and --dry-run show it.
|
|
func (p Plan) String() string {
|
|
var b strings.Builder
|
|
o := p.Options
|
|
fmt.Fprintf(&b, "Plan for %s\n\n", describe(o))
|
|
for i, s := range p.Steps {
|
|
fmt.Fprintf(&b, "%2d. %s\n", i+1, s.Title)
|
|
for _, d := range s.Detail {
|
|
fmt.Fprintf(&b, " %s\n", d)
|
|
}
|
|
}
|
|
if len(p.Ports) > 0 {
|
|
fmt.Fprintf(&b, "\nPorts it will bind: %s\n", joinInts(p.Ports))
|
|
}
|
|
if len(p.DNS) > 0 {
|
|
b.WriteString("\nThe domain will need:\n")
|
|
for _, r := range p.DNS {
|
|
fmt.Fprintf(&b, " %s\n", r)
|
|
}
|
|
}
|
|
if len(p.Warnings) > 0 {
|
|
b.WriteString("\nWorth reading first:\n")
|
|
for _, w := range p.Warnings {
|
|
fmt.Fprintf(&b, " - %s\n", w)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func describe(o Options) string {
|
|
var parts []string
|
|
for _, c := range []Component{Server, Console, Webmail} {
|
|
if s := o.Shape(c); s != Skip {
|
|
parts = append(parts, fmt.Sprintf("%s as a %s install", names[c], s))
|
|
}
|
|
}
|
|
where := o.Domain
|
|
if o.Local {
|
|
where = "a loopback evaluation"
|
|
}
|
|
return strings.Join(parts, ", ") + " -- " + where
|
|
}
|
|
|
|
func joinInts(n []int) string {
|
|
seen := map[int]bool{}
|
|
var out []string
|
|
for _, i := range n {
|
|
if seen[i] {
|
|
continue
|
|
}
|
|
seen[i] = true
|
|
out = append(out, fmt.Sprint(i))
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|