The binary was already static and musl-clean; the only thing standing between Ham Lab and an OpenWrt box was that setup.go knew systemd and OpenRC and nothing else, so an install there ended at "nothing starts it at boot." Adds an embedded procd init script, detected on /sbin/procd + /etc/rc.common ahead of the OpenRC branch. Both backends write /etc/init.d/hamlab, so uninstall tells them apart by USE_PROCD=1 rather than by path. Verified on the new router: procd supervises the instance, S95/K10 symlinks land, and the hub drives its Docker and stacks like any other node. The Updates tab needed nothing — apk-tools 3 answers the same query Alpine's does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
719 lines
21 KiB
Go
719 lines
21 KiB
Go
// Package setup is `hamlab install` and `hamlab uninstall`: the binary installs itself, so
|
|
// a new machine needs nothing but this one file. It copies itself into place, writes the
|
|
// config, sets the login password and registers a service with whichever init the host
|
|
// runs (systemd or OpenRC), as root or through sudo/doas.
|
|
package setup
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
_ "embed"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"golang.org/x/term"
|
|
|
|
"hamlab/internal/auth"
|
|
"hamlab/internal/config"
|
|
)
|
|
|
|
//go:embed hamlab.service
|
|
var systemdUnit string
|
|
|
|
//go:embed hamlab.openrc
|
|
var openrcScript string
|
|
|
|
//go:embed hamlab.procd
|
|
var procdScript string
|
|
|
|
const (
|
|
unitPath = "/etc/systemd/system/hamlab.service"
|
|
openrcPath = "/etc/init.d/hamlab"
|
|
// procd (OpenWrt) uses the same path as OpenRC; the two are told apart by content.
|
|
procdPath = "/etc/init.d/hamlab"
|
|
sudoersPath = "/etc/sudoers.d/hamlab-quiet"
|
|
)
|
|
|
|
type options struct {
|
|
user, listen, tls, prefix, data string
|
|
passwordStdin, openFirewall bool
|
|
noStart bool
|
|
}
|
|
|
|
func say(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
|
|
|
// elevate re-runs this command as root through sudo or doas (which may prompt), naming
|
|
// the invoking user so the service still runs as them.
|
|
func elevate(sub string, args []string, keepUser bool) error {
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, tool := range []string{"sudo", "doas"} {
|
|
path, err := exec.LookPath(tool)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
full := []string{tool, self, sub}
|
|
if u, err := user.Current(); err == nil && keepUser && !hasFlag(args, "user") {
|
|
full = append(full, "--user", u.Username)
|
|
}
|
|
full = append(full, args...)
|
|
return syscall.Exec(path, full, os.Environ())
|
|
}
|
|
return errors.New("this needs root, and neither sudo nor doas is installed: run it as root")
|
|
}
|
|
|
|
func hasFlag(args []string, name string) bool {
|
|
for _, a := range args {
|
|
a = strings.TrimLeft(a, "-")
|
|
if a == name || strings.HasPrefix(a, name+"=") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Install is `hamlab install [flags]`.
|
|
func Install(args []string, version string) error {
|
|
var o options
|
|
fs := flag.NewFlagSet("install", flag.ContinueOnError)
|
|
fs.StringVar(&o.user, "user", "", "user the console runs as; terminals are this user's shell (default: you)")
|
|
fs.StringVar(&o.listen, "listen", "", "listen address, e.g. 0.0.0.0:8100 (default: keep, else 127.0.0.1:8100)")
|
|
fs.StringVar(&o.tls, "tls", "", `"auto" serves HTTPS with a self-signed certificate, "off" plain HTTP (default: keep)`)
|
|
fs.StringVar(&o.prefix, "prefix", "/usr/local/bin", "directory the binary is installed into")
|
|
fs.StringVar(&o.data, "data", "", "config and state directory (default: ~/.config/hamlab of the user)")
|
|
fs.BoolVar(&o.passwordStdin, "password-stdin", false, "read the login password from stdin instead of prompting")
|
|
fs.BoolVar(&o.openFirewall, "open-firewall", false, "open the listen port in firewalld/ufw when one is active")
|
|
fs.BoolVar(&o.noStart, "no-start", false, "install and enable, but don't start the service")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if os.Geteuid() != 0 {
|
|
return elevate("install", args, true)
|
|
}
|
|
sbinPath()
|
|
|
|
// who
|
|
name := o.user
|
|
for _, env := range []string{"SUDO_USER", "DOAS_USER"} {
|
|
if name == "" {
|
|
name = os.Getenv(env)
|
|
}
|
|
}
|
|
if name == "" {
|
|
name = "root"
|
|
}
|
|
acct, err := lookupUser(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// where
|
|
dataDir := o.data
|
|
if dataDir == "" {
|
|
dataDir = filepath.Dir(existingConfig(acct.home))
|
|
}
|
|
cfgPath := filepath.Join(dataDir, "config.json")
|
|
|
|
// binary
|
|
dest := filepath.Join(o.prefix, "hamlab")
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
self, _ = filepath.EvalSymlinks(self)
|
|
if err := os.MkdirAll(o.prefix, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if self != dest {
|
|
if err := CopyAtomic(self, dest); err != nil {
|
|
return fmt.Errorf("install binary: %w", err)
|
|
}
|
|
// builds for other architectures sit beside it so a hub can push them to matching machines
|
|
sibs, _ := filepath.Glob(filepath.Join(filepath.Dir(self), "hamlab-linux-*"))
|
|
for _, sib := range sibs {
|
|
arch := strings.TrimPrefix(filepath.Base(sib), "hamlab-linux-")
|
|
if sib == self || strings.Contains(arch, ".") {
|
|
continue
|
|
}
|
|
if err := CopyAtomic(sib, dest+"-"+arch); err != nil {
|
|
say("warning: %s: %v", sib, err)
|
|
}
|
|
}
|
|
}
|
|
restorecon(dest)
|
|
say("binary %s (%s)", dest, version)
|
|
|
|
// config + password
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if o.listen != "" {
|
|
if _, _, err := net.SplitHostPort(o.listen); err != nil {
|
|
return fmt.Errorf("--listen %q: want host:port", o.listen)
|
|
}
|
|
cfg.Listen = o.listen
|
|
}
|
|
switch o.tls {
|
|
case "":
|
|
case "auto":
|
|
cfg.TLS = "auto"
|
|
case "off", "none":
|
|
cfg.TLS = ""
|
|
default:
|
|
return fmt.Errorf("--tls %q: want auto or off", o.tls)
|
|
}
|
|
generated := ""
|
|
cfg.MigrateUsers(acct.name)
|
|
if len(cfg.Users) == 0 || o.passwordStdin {
|
|
pw, gen, err := choosePassword(o.passwordStdin)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h, err := auth.HashPassword(pw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// the first account is named after the OS user the service runs as; --password-stdin
|
|
// on an existing install resets that first account
|
|
if len(cfg.Users) == 0 {
|
|
cfg.Users = []config.User{{Name: acct.name, Hash: h, Created: time.Now()}}
|
|
} else {
|
|
cfg.Users[0].Hash = h
|
|
}
|
|
if gen {
|
|
generated = pw
|
|
}
|
|
}
|
|
made := firstMissing(dataDir) // e.g. ~/.config, when this is the first thing to use it
|
|
if err := cfg.Save(); err != nil {
|
|
return err
|
|
}
|
|
if err := chownData(dataDir, made, acct); err != nil {
|
|
return err
|
|
}
|
|
say("config %s", cfgPath)
|
|
|
|
// service
|
|
vars := map[string]string{"@USER@": acct.name, "@GROUP@": acct.group, "@HOME@": acct.home, "@BIN@": dest, "@CONFIG@": cfgPath,
|
|
"@GROUPS@": journalGroups()}
|
|
initSys := ""
|
|
switch {
|
|
case isDir("/run/systemd/system"):
|
|
initSys = "systemd"
|
|
unit := fill(systemdUnit, vars)
|
|
if vars["@GROUPS@"] == "" {
|
|
unit = dropLines(unit, "SupplementaryGroups=")
|
|
}
|
|
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
|
|
return err
|
|
}
|
|
restorecon(unitPath)
|
|
if err := runAll([][]string{{"systemctl", "daemon-reload"}, {"systemctl", "enable", "hamlab.service"}}); err != nil {
|
|
return err
|
|
}
|
|
if !o.noStart {
|
|
if err := run("systemctl", "restart", "hamlab.service"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case exists("/sbin/procd") && exists("/etc/rc.common"):
|
|
initSys = "procd"
|
|
if err := os.WriteFile(procdPath, []byte(fill(procdScript, vars)), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := run(procdPath, "enable"); err != nil {
|
|
say("warning: %s enable: %v", procdPath, err)
|
|
}
|
|
if !o.noStart {
|
|
if err := run(procdPath, "restart"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case exists("/sbin/openrc-run") || exists("/usr/sbin/openrc-run") || have("rc-service"):
|
|
initSys = "OpenRC"
|
|
if err := os.WriteFile(openrcPath, []byte(fill(openrcScript, vars)), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := run("rc-update", "add", "hamlab", "default"); err != nil {
|
|
say("warning: rc-update: %v", err)
|
|
}
|
|
if !o.noStart {
|
|
if err := run("rc-service", "hamlab", "restart"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if initSys != "" {
|
|
say("service hamlab (%s), runs as %s, starts at boot", initSys, acct.name)
|
|
}
|
|
if acct.name != "root" {
|
|
quietSudoers()
|
|
}
|
|
|
|
port := firewall(cfg.Listen, o.openFirewall)
|
|
|
|
scheme := "http"
|
|
if cfg.TLS == "auto" {
|
|
scheme = "https"
|
|
}
|
|
url := scheme + "://" + browseAddr(cfg.Listen)
|
|
switch {
|
|
case initSys == "":
|
|
say("\nNo systemd, procd or OpenRC here, so nothing starts it at boot.\nRun it under your own supervisor, as %s:\n HAMLAB_CONFIG=%s %s serve", acct.name, cfgPath, dest)
|
|
case o.noStart:
|
|
case healthy(cfg.Listen, cfg.TLS == "auto"):
|
|
say("\nHam Lab is up: %s", url)
|
|
default:
|
|
say("\nInstalled, but %s isn't answering yet. Check: %s", url, map[string]string{"systemd": "journalctl -u hamlab -n 30", "OpenRC": "tail /var/log/hamlab.log"}[initSys])
|
|
}
|
|
if generated != "" {
|
|
say("Login: user %s, password (shown once; change it in Settings): %s", cfg.Users[0].Name, generated)
|
|
}
|
|
if host, _, _ := net.SplitHostPort(cfg.Listen); isLoopback(host) {
|
|
say("It only listens on this machine. For LAN access: hamlab install --listen 0.0.0.0:%s", port)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Uninstall is `hamlab uninstall [--purge]`.
|
|
func Uninstall(args []string) error {
|
|
fs := flag.NewFlagSet("uninstall", flag.ContinueOnError)
|
|
purge := fs.Bool("purge", false, "also delete the config and state directory")
|
|
prefix := fs.String("prefix", "/usr/local/bin", "directory the binary was installed into")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if os.Geteuid() != 0 {
|
|
return elevate("uninstall", args, false)
|
|
}
|
|
sbinPath()
|
|
dataDir := ""
|
|
if b, err := os.ReadFile(unitPath); err == nil {
|
|
dataDir = configDirFrom(string(b), "Environment=HAMLAB_CONFIG=")
|
|
_ = run("systemctl", "disable", "--now", "hamlab.service")
|
|
os.Remove(unitPath)
|
|
_ = run("systemctl", "daemon-reload")
|
|
}
|
|
if b, err := os.ReadFile(openrcPath); err == nil {
|
|
if strings.Contains(string(b), "USE_PROCD=1") {
|
|
dataDir = configDirFrom(string(b), "HAMLAB_CONFIG=")
|
|
_ = run(procdPath, "stop")
|
|
_ = run(procdPath, "disable")
|
|
} else {
|
|
dataDir = configDirFrom(string(b), "export HAMLAB_CONFIG=")
|
|
_ = run("rc-service", "hamlab", "stop")
|
|
_ = run("rc-update", "del", "hamlab", "default")
|
|
}
|
|
os.Remove(openrcPath)
|
|
}
|
|
os.Remove(sudoersPath)
|
|
bins, _ := filepath.Glob(filepath.Join(*prefix, "hamlab-*"))
|
|
for _, b := range append(bins, filepath.Join(*prefix, "hamlab")) {
|
|
os.Remove(b)
|
|
}
|
|
say("Ham Lab removed.")
|
|
switch {
|
|
case dataDir == "":
|
|
case *purge:
|
|
if err := purgeData(dataDir); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
say("Config and state kept in %s (uninstall --purge deletes them).", dataDir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sbinPath makes sure the admin tools resolve: a root shell reached with plain `su` keeps
|
|
// the user's PATH, which on Debian has no sbin in it.
|
|
func sbinPath() {
|
|
os.Setenv("PATH", os.Getenv("PATH")+":/usr/local/sbin:/usr/sbin:/sbin:/usr/local/bin:/usr/bin:/bin")
|
|
}
|
|
|
|
// ownFiles is everything Ham Lab writes into its data directory.
|
|
var ownFiles = []string{"config.json", "config.json.tmp", "state.json", "state.json.tmp", "state.json.corrupt", "cert.pem", "key.pem", "hamlab.prev", "hamlab.new"}
|
|
|
|
// purgeData deletes Ham Lab's data. The whole directory goes only when it is unmistakably
|
|
// ours (named "hamlab"); a --data pointed at a shared directory such as a home loses
|
|
// just the files Ham Lab wrote.
|
|
func purgeData(dir string) error {
|
|
if filepath.Base(dir) == "hamlab" {
|
|
say("Deleted %s.", dir)
|
|
return os.RemoveAll(dir)
|
|
}
|
|
for _, f := range ownFiles {
|
|
os.Remove(filepath.Join(dir, f))
|
|
}
|
|
say("Deleted Ham Lab's files in %s (the directory isn't Ham Lab's alone, so it stays).", dir)
|
|
return nil
|
|
}
|
|
|
|
func configDirFrom(text, key string) string {
|
|
for _, line := range strings.Split(text, "\n") {
|
|
if v, ok := strings.CutPrefix(strings.TrimSpace(line), key); ok {
|
|
if v = strings.Trim(v, `"'`); strings.HasSuffix(v, "/config.json") && filepath.Dir(v) != "/" {
|
|
return filepath.Dir(v)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ---- pieces ----
|
|
|
|
type account struct {
|
|
name, group, home string
|
|
uid, gid int
|
|
}
|
|
|
|
func lookupUser(name string) (account, error) {
|
|
a := account{name: name}
|
|
if u, err := user.Lookup(name); err == nil {
|
|
a.uid, _ = strconv.Atoi(u.Uid)
|
|
a.gid, _ = strconv.Atoi(u.Gid)
|
|
a.home = u.HomeDir
|
|
} else if out, err2 := exec.Command("getent", "passwd", name).Output(); err2 == nil { // LDAP/SSSD accounts
|
|
f := strings.Split(strings.TrimSpace(string(out)), ":")
|
|
if len(f) < 6 {
|
|
return a, fmt.Errorf("no such user: %s", name)
|
|
}
|
|
a.uid, _ = strconv.Atoi(f[2])
|
|
a.gid, _ = strconv.Atoi(f[3])
|
|
a.home = f[5]
|
|
} else {
|
|
return a, fmt.Errorf("no such user: %s", name)
|
|
}
|
|
if a.home == "" || !isDir(a.home) {
|
|
return a, fmt.Errorf("user %s has no home directory (%q); pick another with --user", name, a.home)
|
|
}
|
|
a.group = strconv.Itoa(a.gid)
|
|
if g, err := user.LookupGroupId(a.group); err == nil {
|
|
a.group = g.Name
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// existingConfig returns the config path for a home directory: whichever already exists,
|
|
// else the default for a new install.
|
|
func existingConfig(home string) string {
|
|
for _, p := range config.Candidates(home) {
|
|
if exists(p) {
|
|
return p
|
|
}
|
|
}
|
|
return config.Candidates(home)[0]
|
|
}
|
|
|
|
// journalGroups lists the groups that can read the system journal and exist here. A unit
|
|
// naming a group the host doesn't have fails to start, and "adm" is not universal.
|
|
func journalGroups() string {
|
|
var have []string
|
|
for _, g := range []string{"systemd-journal", "adm"} {
|
|
if _, err := user.LookupGroup(g); err == nil {
|
|
have = append(have, g)
|
|
}
|
|
}
|
|
return strings.Join(have, " ")
|
|
}
|
|
|
|
func choosePassword(fromStdin bool) (pw string, generated bool, err error) {
|
|
switch {
|
|
case fromStdin:
|
|
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
|
pw = strings.TrimRight(line, "\r\n")
|
|
case term.IsTerminal(int(os.Stdin.Fd())):
|
|
fmt.Fprint(os.Stderr, "Login password for the web console (12+ characters, empty to generate one): ")
|
|
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
fmt.Fprintln(os.Stderr)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if len(p1) > 0 {
|
|
fmt.Fprint(os.Stderr, "Again: ")
|
|
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
fmt.Fprintln(os.Stderr)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if !bytes.Equal(p1, p2) {
|
|
return "", false, errors.New("passwords do not match")
|
|
}
|
|
}
|
|
pw = string(p1)
|
|
}
|
|
if pw == "" && !fromStdin {
|
|
return randomPassword(), true, nil
|
|
}
|
|
if len(pw) < 12 {
|
|
return "", false, errors.New("password must be at least 12 characters")
|
|
}
|
|
return pw, false, nil
|
|
}
|
|
|
|
func randomPassword() string {
|
|
const alphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no look-alikes
|
|
b := make([]byte, 20)
|
|
_, _ = rand.Read(b)
|
|
for i := range b {
|
|
b[i] = alphabet[int(b[i])%len(alphabet)]
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// CopyAtomic puts src at dst with mode 0755 by writing beside it and renaming, so dst is
|
|
// always either the old file or the whole new one, and a running dst is never written to.
|
|
func CopyAtomic(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
tmp := filepath.Join(filepath.Dir(dst), "."+filepath.Base(dst)+".new")
|
|
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = io.Copy(out, in)
|
|
if err == nil {
|
|
err = out.Sync()
|
|
}
|
|
if cerr := out.Close(); err == nil {
|
|
err = cerr
|
|
}
|
|
if err == nil {
|
|
err = os.Chmod(tmp, 0o755)
|
|
}
|
|
if err == nil {
|
|
err = os.Rename(tmp, dst)
|
|
}
|
|
if err != nil {
|
|
os.Remove(tmp)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// firstMissing returns the topmost directory that creating dir would have to make, or "".
|
|
func firstMissing(dir string) string {
|
|
missing := ""
|
|
for d := filepath.Clean(dir); d != "/" && d != "." && !exists(d); d = filepath.Dir(d) {
|
|
missing = d
|
|
}
|
|
return missing
|
|
}
|
|
|
|
// chownData hands Ham Lab's data to the account that runs it. Directories this install
|
|
// created are ours to hand over whole. In one that already existed (a --data pointed at
|
|
// something shared), only the files Ham Lab writes change owner: a recursive chown of
|
|
// somebody else's tree can't be undone.
|
|
func chownData(dataDir, made string, a account) error {
|
|
if made != "" {
|
|
return chownTree(made, a)
|
|
}
|
|
if filepath.Base(dataDir) == "hamlab" {
|
|
return chownTree(dataDir, a)
|
|
}
|
|
for _, f := range ownFiles {
|
|
if p := filepath.Join(dataDir, f); exists(p) {
|
|
if err := os.Lchown(p, a.uid, a.gid); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func chownTree(dir string, a account) error {
|
|
return filepath.Walk(dir, func(p string, _ os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Lchown(p, a.uid, a.gid)
|
|
})
|
|
}
|
|
|
|
func fill(tmpl string, vars map[string]string) string {
|
|
for k, v := range vars {
|
|
tmpl = strings.ReplaceAll(tmpl, k, v)
|
|
}
|
|
return tmpl
|
|
}
|
|
|
|
func dropLines(text, prefix string) string {
|
|
var keep []string
|
|
for _, l := range strings.Split(text, "\n") {
|
|
if !strings.HasPrefix(l, prefix) {
|
|
keep = append(keep, l)
|
|
}
|
|
}
|
|
return strings.Join(keep, "\n")
|
|
}
|
|
|
|
// quietSudoers keeps the daemon's read-only polling (SMART, UPS, journal) out of the auth
|
|
// log: at 250 ms refresh rates the sudo session lines would otherwise dominate it.
|
|
func quietSudoers() {
|
|
if !isDir("/etc/sudoers.d") || !have("visudo") {
|
|
return
|
|
}
|
|
var cmds []string
|
|
for _, bin := range []string{"smartctl", "pwrstat", "journalctl", "tail", "cat", "ls", "crontab"} {
|
|
for _, dir := range []string{"/usr/sbin", "/usr/bin", "/sbin", "/bin"} {
|
|
if exists(dir + "/" + bin) {
|
|
cmds = append(cmds, dir+"/"+bin)
|
|
}
|
|
}
|
|
}
|
|
for _, bin := range []string{"zpool", "zfs"} { // listing only: scrubs and snapshots still log
|
|
for _, dir := range []string{"/usr/sbin", "/sbin", "/usr/bin", "/bin"} {
|
|
if exists(dir + "/" + bin) {
|
|
cmds = append(cmds, dir+"/"+bin+" list *", dir+"/"+bin+" status *")
|
|
}
|
|
}
|
|
}
|
|
if len(cmds) == 0 {
|
|
return
|
|
}
|
|
text := "# Ham Lab: don't log the daemon's read-only polling commands (written by hamlab install)\n" +
|
|
"Cmnd_Alias HAMLAB_QUIET = " + strings.Join(cmds, ", ") + "\n" +
|
|
"Defaults!HAMLAB_QUIET !syslog, !pam_session, !pam_setcred\n"
|
|
tmp := sudoersPath + ".new"
|
|
if os.WriteFile(tmp, []byte(text), 0o440) != nil {
|
|
return
|
|
}
|
|
if exec.Command("visudo", "-cf", tmp).Run() != nil {
|
|
os.Remove(tmp)
|
|
say("warning: sudoers snippet failed validation; skipped")
|
|
return
|
|
}
|
|
_ = os.Rename(tmp, sudoersPath)
|
|
}
|
|
|
|
// firewall reports (and with open, fixes) an active host firewall in front of a
|
|
// non-loopback listen address. It returns the port.
|
|
func firewall(listen string, open bool) string {
|
|
host, port, _ := net.SplitHostPort(listen)
|
|
if isLoopback(host) {
|
|
return port
|
|
}
|
|
switch {
|
|
case have("firewall-cmd") && exec.Command("firewall-cmd", "--state").Run() == nil:
|
|
if open {
|
|
if err := runAll([][]string{{"firewall-cmd", "--quiet", "--permanent", "--add-port=" + port + "/tcp"}, {"firewall-cmd", "--quiet", "--reload"}}); err != nil {
|
|
say("warning: firewalld: %v", err)
|
|
} else {
|
|
say("firewall firewalld: opened %s/tcp", port)
|
|
}
|
|
} else {
|
|
say("firewall firewalld is active and will block port %s. Open it: hamlab install --open-firewall", port)
|
|
}
|
|
case have("ufw"):
|
|
out, _ := exec.Command("ufw", "status").Output()
|
|
if !bytes.Contains(out, []byte("Status: active")) {
|
|
break
|
|
}
|
|
if open {
|
|
if err := run("ufw", "allow", port+"/tcp"); err != nil {
|
|
say("warning: ufw: %v", err)
|
|
} else {
|
|
say("firewall ufw: opened %s/tcp", port)
|
|
}
|
|
} else {
|
|
say("firewall ufw is active and will block port %s. Open it: hamlab install --open-firewall", port)
|
|
}
|
|
}
|
|
return port
|
|
}
|
|
|
|
func isLoopback(host string) bool {
|
|
ip := net.ParseIP(host)
|
|
return host == "localhost" || (ip != nil && ip.IsLoopback())
|
|
}
|
|
|
|
// browseAddr turns a listen address into one a browser can open.
|
|
func browseAddr(listen string) string {
|
|
host, port, err := net.SplitHostPort(listen)
|
|
if err != nil {
|
|
return listen
|
|
}
|
|
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
|
|
host = "localhost"
|
|
if addrs, err := net.InterfaceAddrs(); err == nil {
|
|
for _, a := range addrs {
|
|
if n, ok := a.(*net.IPNet); ok && n.IP.To4() != nil && !n.IP.IsLoopback() {
|
|
host = n.IP.String()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return net.JoinHostPort(host, port)
|
|
}
|
|
|
|
// healthy polls /healthz for up to 10 s.
|
|
func healthy(listen string, https bool) bool {
|
|
host, port, _ := net.SplitHostPort(listen)
|
|
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
|
|
host = "127.0.0.1"
|
|
}
|
|
scheme := "http"
|
|
if https {
|
|
scheme = "https"
|
|
}
|
|
c := &http.Client{Timeout: 2 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} // self-signed; reachability only
|
|
for i := 0; i < 20; i++ {
|
|
if resp, err := c.Get(scheme + "://" + net.JoinHostPort(host, port) + "/healthz"); err == nil {
|
|
resp.Body.Close()
|
|
return resp.StatusCode < 500
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// restorecon fixes the SELinux label of a file we created, where SELinux exists.
|
|
func restorecon(path string) {
|
|
if have("restorecon") {
|
|
_ = exec.Command("restorecon", "-F", path).Run()
|
|
}
|
|
}
|
|
|
|
func run(args ...string) error {
|
|
out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
|
|
if err != nil {
|
|
if msg := strings.TrimSpace(string(out)); msg != "" {
|
|
return fmt.Errorf("%s: %s", strings.Join(args, " "), msg)
|
|
}
|
|
return fmt.Errorf("%s: %w", strings.Join(args, " "), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runAll(cmds [][]string) error {
|
|
for _, c := range cmds {
|
|
if err := run(c...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func have(bin string) bool { _, err := exec.LookPath(bin); return err == nil }
|
|
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
|
|
func isDir(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() }
|