Survey the machine, and say what an install would do to it

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.
This commit is contained in:
2026-09-22 17:56:20 -07:00
commit f97fd12556
13 changed files with 2046 additions and 0 deletions
+375
View File
@@ -0,0 +1,375 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package host looks at the machine the installer is running on and reports
// what it found. Nothing here changes anything.
//
// This is the first screen of the interface and the floor under every choice
// after it: a component can only offer "container" if there is a usable
// Docker, or "host" if this is a systemd machine with the pieces that shape
// needs. The survey is taken once, so the plan and the interface are arguing
// from the same facts.
package host
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"os"
"os/exec"
"os/user"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
)
// Facts is everything the installer knows about this machine before it asks
// anybody anything.
type Facts struct {
OS OSInfo
Root bool // running as uid 0
Systemd bool // systemd is pid 1
Docker Docker //
Node Node //
Ports []Port // the ports the suite wants, and who holds them
DiskFreeGB int // on the filesystem that would hold the install
MemoryMB int
Existing string // path of a previous install's state file, or ""
}
// OSInfo is the distribution, as os-release describes it.
type OSInfo struct {
ID string // "debian", "ubuntu", "fedora"…
VersionID string // "13", "24.04"
Pretty string
Family string // "debian", "rhel", "arch", "suse", "" when unknown
}
// Docker is whether containers are a real option for this user.
type Docker struct {
Present bool
Usable bool // the daemon answers *as this user*, which is the part that matters
Version string //
Compose string // "v2" when `docker compose` works, "" otherwise
Why string // why it is unusable, in a sentence fit to show someone
}
// Node is what a host install of the webmail would run on.
type Node struct {
Present bool
Version string // "22.14.0"
Major int
Why string
}
// Port is one of the ports the suite would like, and what holds it now.
type Port struct {
Number int
For string // what wants it
Free bool
Unknown bool // could not be tested: binding it needs privileges we lack
Holder string // best guess at the process, when we can see one
}
// wanted is every port the suite can ask for, with what asks. The survey
// reports all of them regardless of the shape chosen, because the interface
// needs to gray out a choice before the operator makes it.
var wanted = []struct {
n int
for_ string
}{
{25, "SMTP, mail from other servers"},
{80, "HTTP, for certificates and the redirect"},
{443, "HTTPS, the front ends and JMAP"},
{465, "submissions, mail apps sending"},
{993, "IMAPS, mail apps reading"},
{995, "POP3S, mail apps collecting"},
{4190, "ManageSieve, filters from mail apps"},
{8080, "the webmail, behind the proxy"},
{8081, "the server's plain HTTP, behind the proxy"},
}
// Survey takes the whole survey. It never fails: a fact it cannot establish
// is reported as absent with a reason, because "we could not tell" is itself
// something the operator should see.
func Survey(ctx context.Context) Facts {
f := Facts{
OS: readOSRelease("/etc/os-release"),
Systemd: isSystemd(),
Docker: surveyDocker(ctx),
Node: surveyNode(ctx),
MemoryMB: memoryMB(),
}
if u, err := user.Current(); err == nil {
f.Root = u.Uid == "0"
}
for _, w := range wanted {
f.Ports = append(f.Ports, surveyPort(w.n, w.for_))
}
f.DiskFreeGB = diskFreeGB("/var/lib")
for _, p := range []string{"/etc/inbuxa/install.json"} {
if _, err := os.Stat(p); err == nil {
f.Existing = p
}
}
return f
}
func readOSRelease(path string) OSInfo {
var o OSInfo
file, err := os.Open(path)
if err != nil {
return o
}
defer file.Close()
fields := map[string]string{}
s := bufio.NewScanner(file)
for s.Scan() {
line := strings.TrimSpace(s.Text())
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
fields[k] = strings.Trim(v, `"'`)
}
o.ID = fields["ID"]
o.VersionID = fields["VERSION_ID"]
o.Pretty = fields["PRETTY_NAME"]
like := fields["ID_LIKE"] + " " + o.ID
switch {
case strings.Contains(like, "debian"), strings.Contains(like, "ubuntu"):
o.Family = "debian"
case strings.Contains(like, "rhel"), strings.Contains(like, "fedora"), strings.Contains(like, "centos"):
o.Family = "rhel"
case strings.Contains(like, "arch"):
o.Family = "arch"
case strings.Contains(like, "suse"):
o.Family = "suse"
}
return o
}
func isSystemd() bool {
// /run/systemd/system exists exactly when systemd is running the machine,
// which is what systemd's own documentation says to test.
st, err := os.Stat("/run/systemd/system")
return err == nil && st.IsDir()
}
func surveyDocker(ctx context.Context) Docker {
var d Docker
bin, err := exec.LookPath("docker")
if err != nil {
d.Why = "docker is not installed"
return d
}
d.Present = true
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// `docker version` talks to the daemon, unlike `docker --version`, so it
// answers the question that matters: can *this user* run containers?
out, err := exec.CommandContext(ctx, bin, "version", "--format", "{{.Server.Version}}").CombinedOutput()
if err != nil {
d.Why = firstLine(string(out))
if d.Why == "" {
d.Why = "the docker daemon did not answer"
}
return d
}
d.Usable = true
d.Version = strings.TrimSpace(string(out))
if err := exec.CommandContext(ctx, bin, "compose", "version").Run(); err == nil {
d.Compose = "v2"
} else {
d.Usable = false
d.Why = "docker compose (v2) is not available"
}
return d
}
var nodeVersion = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)`)
func surveyNode(ctx context.Context) Node {
var n Node
bin, err := exec.LookPath("node")
if err != nil {
n.Why = "node is not installed"
return n
}
n.Present = true
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, bin, "--version").Output()
if err != nil {
n.Why = "node is installed but would not run"
return n
}
m := nodeVersion.FindStringSubmatch(strings.TrimSpace(string(out)))
if m == nil {
n.Why = "node's version could not be read"
return n
}
n.Version = strings.TrimPrefix(strings.TrimSpace(string(out)), "v")
n.Major, _ = strconv.Atoi(m[1])
return n
}
// surveyPort reports whether a port is free, and who has it when it is not.
// Binding is the only honest test -- something listening on 0.0.0.0 and
// something listening on one address are different answers, and /proc tells
// us the second only after the first has failed.
func surveyPort(n int, for_ string) Port {
p := Port{Number: n, For: for_}
l, err := net.Listen("tcp", fmt.Sprintf(":%d", n))
if err == nil {
l.Close()
p.Free = true
return p
}
// "I am not allowed to bind this" is not "something is listening here".
// Reporting the first as the second told an unprivileged run that every
// port below 1024 was taken, which is a lie an operator would act on.
if errors.Is(err, syscall.EACCES) || errors.Is(err, syscall.EPERM) {
p.Unknown = true
if h := holderOf(n); h != "" {
p.Holder = h
p.Unknown = false
}
return p
}
p.Holder = holderOf(n)
return p
}
// holderOf is a best effort at naming the process on a port, for the sake of
// a message like "443 is held by nginx" instead of "443 is busy". It reads
// /proc, which means it only sees the whole truth as root; a guess is better
// than nothing and the caller treats it as one.
func holderOf(port int) string {
inodes := map[string]bool{}
for _, tab := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
file, err := os.Open(tab)
if err != nil {
continue
}
s := bufio.NewScanner(file)
s.Scan() // header
for s.Scan() {
fields := strings.Fields(s.Text())
if len(fields) < 10 {
continue
}
_, portHex, ok := strings.Cut(fields[1], ":")
if !ok {
continue
}
n, err := strconv.ParseInt(portHex, 16, 32)
if err != nil || int(n) != port {
continue
}
if fields[3] != "0A" { // 0A = LISTEN
continue
}
inodes[fields[9]] = true
}
file.Close()
}
if len(inodes) == 0 {
return ""
}
procs, err := filepath.Glob("/proc/[0-9]*/fd/*")
if err != nil {
return ""
}
for _, fd := range procs {
link, err := os.Readlink(fd)
if err != nil || !strings.HasPrefix(link, "socket:[") {
continue
}
inode := strings.TrimSuffix(strings.TrimPrefix(link, "socket:["), "]")
if !inodes[inode] {
continue
}
pid := strings.Split(fd, "/")[2]
if comm, err := os.ReadFile("/proc/" + pid + "/comm"); err == nil {
return strings.TrimSpace(string(comm)) + " (pid " + pid + ")"
}
}
return ""
}
func memoryMB() int {
file, err := os.Open("/proc/meminfo")
if err != nil {
return 0
}
defer file.Close()
s := bufio.NewScanner(file)
for s.Scan() {
if !strings.HasPrefix(s.Text(), "MemTotal:") {
continue
}
fields := strings.Fields(s.Text())
if len(fields) < 2 {
return 0
}
kb, _ := strconv.Atoi(fields[1])
return kb / 1024
}
return 0
}
func diskFreeGB(path string) int {
out, err := exec.Command("df", "-BG", "--output=avail", path).Output()
if err != nil {
return 0
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return 0
}
n, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimSpace(lines[1]), "G"))
return n
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
return s
}
// PortsHeld returns the wanted ports that are not free, for the shapes that
// need them. An empty result is what lets an install proceed unasked.
func (f Facts) PortsHeld(numbers ...int) []Port {
want := map[int]bool{}
for _, n := range numbers {
want[n] = true
}
var held []Port
for _, p := range f.Ports {
if want[p.Number] && !p.Free && !p.Unknown {
held = append(held, p)
}
}
return held
}
// Port returns one surveyed port by number.
func (f Facts) Port(n int) (Port, bool) {
for _, p := range f.Ports {
if p.Number == n {
return p, true
}
}
return Port{}, false
}
+413
View File
@@ -0,0 +1,413 @@
// 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, ", ")
}