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:
@@ -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
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
Reference in New Issue
Block a user