Refusing because Docker is absent is not help. It is a chore handed back to the operator, who will then install it however the first search result says to -- which is how machines end up with a third-party apt repository and a signing key nobody chose. So the installer offers. It says what it would do, in the same words the plan uses, and does it only when told: --install-deps during a run, or "inbuxa deps --install" on its own for someone preparing a machine before they have a domain to give it. What it installs, and from where, is the part worth arguing about: - the Docker daemon: the distribution's own package. One package from an archive the machine already trusts beats a new source. - the Compose plugin: Debian's docker.io ships no compose v2 at all, so this is Docker's official static build, pinned by version with its checksum in the source beside it. - Node: the official tarball into /opt/inbuxa/node, deliberately off PATH so it runs the webmail's unit and nothing else. Every download is checked before it is put in place; a checksum that does not match stops the step rather than warning and carrying on. Tested from a bare Debian 13 in the lab: 25 checks, ending with docker and compose answering, node 22 where the unit will look for it, and the installer agreeing that nothing is missing any more. The last of those failed the first time -- the survey looked for node on PATH only, and so could not see the one it had just installed.
389 lines
10 KiB
Go
389 lines
10 KiB
Go
// 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
|
|
Path string // where it is; OwnPath when the installer put it there
|
|
Why string
|
|
}
|
|
|
|
// OwnPath is where the installer puts a Node of its own, deliberately outside
|
|
// PATH so it runs the webmail's unit and nothing else on the machine.
|
|
const OwnPath = "/opt/inbuxa/node/bin/node"
|
|
|
|
// 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
|
|
// The installer's own Node first: it is deliberately not on PATH, so
|
|
// looking only there would mean never seeing what we installed ourselves
|
|
// and offering to install it again.
|
|
bin := OwnPath
|
|
if _, err := os.Stat(bin); err != nil {
|
|
var err error
|
|
bin, err = exec.LookPath("node")
|
|
if err != nil {
|
|
n.Why = "node is not installed"
|
|
return n
|
|
}
|
|
}
|
|
n.Present = true
|
|
n.Path = bin
|
|
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
|
|
}
|