The login is a user name and a password. The old single password becomes the account named after the OS user on first boot, along with its sessions and the machine-wide look. Settings gains Users (add, remove, reset a password — your own password confirms) and Sessions (device, IP, last seen; revoke one or sign out everywhere else). Theme, accents and the dashboard layout follow the account; the refresh rate, graph history and alerts stay the machine's. Pairing a machine takes a user on it. Log lines name the actor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
695 lines
20 KiB
Go
695 lines
20 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
|
|
|
|
const (
|
|
unitPath = "/etc/systemd/system/hamlab.service"
|
|
openrcPath = "/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/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 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 {
|
|
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() }
|