Files
inbuxa-installer/internal/host/host.go
T
jcoffey-dev f97fd12556 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.
2026-09-22 17:56:20 -07:00

376 lines
9.9 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
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
}