// Package incus is the Virtualization tab's engine: LXC containers and KVM virtual machines // through Incus, driven the way the Docker tab drives Docker — the CLI, `--format json`, as // this user first and as root if the socket says no. It also decides, per machine, whether // Incus is running, installable or out of reach, and owns the install: what to apt/dnf in, // where the pool's backing file goes (always Ham Lab's own folder), which subnet the bridge // takes, and the two iptables rules that keep Docker from dropping the bridge's traffic. package incus import ( "context" "encoding/json" "errors" "fmt" "net/netip" "os" "os/exec" "path/filepath" "regexp" "runtime" "sort" "strconv" "strings" "sync" "syscall" "time" "hamlab/internal/priv" ) const ( PoolName = "local" // plain files on the machine's own disk, in Ham Lab's folder BridgeName = "incusbr0" ZPoolName = "hamlab-virt" // the ZFS pool made on the backing file; Incus is handed it by name AdminGroup = "incus-admin" HamlabUnit = "hamlab.service" NetUnit = "hamlab-virt-net.service" // the Docker FORWARD fix, re-applied after every boot remoteTTL = time.Hour ) // Layout is what Ham Lab chose at install, kept as virt.json beside the data so the page can // say where everything is and a reinstall can put the pool back from the same file. type Layout struct { Driver string `json:"driver"` // zfs | btrfs | dir PoolSource string `json:"pool_source"` PoolSizeGiB int `json:"pool_size_gib,omitempty"` Bridge string `json:"bridge"` Subnet string `json:"subnet"` DockerRules bool `json:"docker_rules"` ARCMaxGiB int `json:"arc_max_gib,omitempty"` Attempted time.Time `json:"attempted"` Version string `json:"hamlab_version,omitempty"` } type Manager struct { dir string // /virt user string // the account the daemon runs as; joins incus-admin at install version string mu sync.Mutex cpuPrev map[string]map[string]cpuMark // caller → instance → last reading, so two readers do not spoil each other's deltas vga map[string]*vgaSession // instance → the console operation its browser channels share remote struct { at time.Time list []Image } pm struct { at time.Time name string candidate bool } } type cpuMark struct { ns int64 at time.Time } func New(dataDir, user, version string) *Manager { return &Manager{dir: filepath.Join(dataDir, "virt"), user: user, version: version, cpuPrev: map[string]map[string]cpuMark{}} } func (m *Manager) Dir() string { return m.dir } func (m *Manager) ImagesDir() string { return filepath.Join(m.dir, "images") } func (m *Manager) ISOsDir() string { return filepath.Join(m.dir, "isos") } func (m *Manager) ExportsDir() string { return filepath.Join(m.dir, "exports") } // EnsureDirs makes the tree, as this user, before root ever writes into it. func (m *Manager) EnsureDirs() error { for _, d := range []string{m.dir, m.ImagesDir(), m.ISOsDir(), m.ExportsDir()} { if err := os.MkdirAll(d, 0o755); err != nil { return err } } return nil } func (m *Manager) layoutPath() string { return filepath.Join(m.dir, "virt.json") } func (m *Manager) Layout() *Layout { b, err := os.ReadFile(m.layoutPath()) if err != nil { return nil } var l Layout if json.Unmarshal(b, &l) != nil { return nil } return &l } func (m *Manager) SaveLayout(l Layout) error { if err := m.EnsureDirs(); err != nil { return err } b, _ := json.MarshalIndent(l, "", " ") return os.WriteFile(m.layoutPath(), b, 0o644) } // ---- detection ---- // Status is what the page shows first: can this machine do it, and what is in the way. type Status struct { State string `json:"state"` // ready | repair | installable | unavailable Reason string `json:"reason,omitempty"` KVM bool `json:"kvm"` Arch string `json:"arch"` // Incus's name for it: x86_64, aarch64… Version string `json:"version,omitempty"` PM string `json:"pm,omitempty"` Drivers []string `json:"drivers"` // storage drivers this machine can back a pool with, best first Docker bool `json:"docker"` ZFSHost bool `json:"zfs_host"` ARCMaxGiB int `json:"arc_max_gib,omitempty"` MemTotal uint64 `json:"mem_total"` MemAvail uint64 `json:"mem_avail"` DiskFree uint64 `json:"disk_free"` Dir string `json:"dir"` Subnet string `json:"subnet"` // proposed when installable, in use when ready PoolSize int `json:"pool_size_gib,omitempty"` // proposed Layout *Layout `json:"layout,omitempty"` User string `json:"user"` Root bool `json:"root"` Unit string `json:"unit,omitempty"` BtrfsTools bool `json:"btrfs_tools"` } // IncusArch maps Go's architecture names to the ones Incus and its image server use. func IncusArch() string { switch runtime.GOARCH { case "amd64": return "x86_64" case "arm64": return "aarch64" case "arm": return "armv7l" case "386": return "i686" case "riscv64": return "riscv64" case "ppc64le": return "ppc64le" case "s390x": return "s390x" } return runtime.GOARCH } func have(bin string) bool { if _, err := exec.LookPath(bin); err == nil { return true } for _, d := range []string{"/usr/sbin", "/sbin", "/usr/local/sbin", "/usr/bin"} { // a daemon's PATH often lacks sbin if st, err := os.Stat(filepath.Join(d, bin)); err == nil && !st.IsDir() { return true } } return false } func meminfo() (total, avail uint64) { b, err := os.ReadFile("/proc/meminfo") if err != nil { return 0, 0 } for _, l := range strings.Split(string(b), "\n") { f := strings.Fields(l) if len(f) < 2 { continue } v, _ := strconv.ParseUint(f[1], 10, 64) switch f[0] { case "MemTotal:": total = v << 10 case "MemAvailable:": avail = v << 10 } } return } func diskFree(path string) uint64 { var st syscall.Statfs_t for p := path; ; p = filepath.Dir(p) { if err := syscall.Statfs(p, &st); err == nil { return st.Bavail * uint64(st.Bsize) } if p == "/" || p == "." { return 0 } } } // packageManager finds one Ham Lab can drive and whether it offers incus. The candidate // check is an exec — dnf's can take seconds — so the answer is kept for an hour. func (m *Manager) packageManager(ctx context.Context) (string, bool) { m.mu.Lock() if time.Since(m.pm.at) < time.Hour { n, c := m.pm.name, m.pm.candidate m.mu.Unlock() return n, c } m.mu.Unlock() name, cand := "", false c, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() switch { case have("apt-get"): name = "apt" if out, err := exec.CommandContext(c, "apt-cache", "policy", "incus").Output(); err == nil { cand = strings.Contains(string(out), "Candidate:") && !strings.Contains(string(out), "Candidate: (none)") } case have("dnf"): name = "dnf" cand = exec.CommandContext(c, "dnf", "-q", "info", "incus").Run() == nil case have("pacman"): name = "pacman" cand = exec.CommandContext(c, "pacman", "-Si", "incus").Run() == nil case have("apk"): name = "apk" if out, err := exec.CommandContext(c, "apk", "search", "-e", "incus").Output(); err == nil { cand = strings.TrimSpace(string(out)) != "" } case have("opkg"): name = "opkg" } m.mu.Lock() m.pm.at, m.pm.name, m.pm.candidate = time.Now(), name, cand m.mu.Unlock() return name, cand } func kernelHas(fs string) bool { if b, err := os.ReadFile("/proc/filesystems"); err == nil && strings.Contains(string(b), fs) { return true } if exec.Command("modinfo", "-n", fs).Run() == nil { return true } matches, _ := filepath.Glob(fmt.Sprintf("/lib/modules/*/kernel/fs/%s/%s.ko*", fs, fs)) return len(matches) > 0 } func arcMaxGiB() int { b, err := os.ReadFile("/sys/module/zfs/parameters/zfs_arc_max") if err != nil { return 0 } v, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64) if v <= 0 { // 0 means the module default: half of RAM, or the whole of it on 2.x total, _ := meminfo() return int(total >> 31) } return int(v >> 30) } // Detect works out the page's state. Cheap enough to run on every load: a few stats, one // `incus query` when the binary is there, and a cached package-manager answer otherwise. func (m *Manager) Detect(ctx context.Context) Status { total, avail := meminfo() st := Status{Arch: IncusArch(), Dir: m.dir, User: m.user, Root: priv.Available(), Unit: HamlabUnit, MemTotal: total, MemAvail: avail, DiskFree: diskFree(m.dir), Layout: m.Layout(), Docker: have("docker") || have("podman")} if _, err := os.Stat("/dev/kvm"); err == nil { st.KVM = true } if _, err := os.Stat("/sys/module/zfs"); err == nil { st.ZFSHost = true st.ARCMaxGiB = arcMaxGiB() if have("zpool") && have("zfs") { st.Drivers = append(st.Drivers, "zfs") } } if kernelHas("btrfs") { st.Drivers = append(st.Drivers, "btrfs") st.BtrfsTools = have("mkfs.btrfs") } st.Drivers = append(st.Drivers, "dir") // a quarter of what is free, between 10 GiB and 300 GiB, rounded down to whole GiB st.PoolSize = int(st.DiskFree >> 30 / 4) if st.PoolSize > 300 { st.PoolSize = 300 } if st.PoolSize < 10 { st.PoolSize = 10 } if have("incus") { c, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() out, err := m.run(c, "query", "/1.0") if err == nil { var info struct { Environment struct { ServerVersion string `json:"server_version"` } `json:"environment"` } _ = json.Unmarshal(out, &info) st.State, st.Version = "ready", info.Environment.ServerVersion if st.Layout != nil { st.Subnet = st.Layout.Subnet } else if b, err := m.run(c, "query", "/1.0/networks/"+BridgeName); err == nil { var n struct { Config map[string]string `json:"config"` } if json.Unmarshal(b, &n) == nil { st.Subnet = n.Config["ipv4.address"] } } return st } low := strings.ToLower(err.Error()) st.State = "repair" switch { case strings.Contains(low, "permission denied"): st.Reason = fmt.Sprintf("Incus is installed, but Ham Lab's account (%s) isn't in the %s group yet.", m.user, AdminGroup) case strings.Contains(low, "connection refused"), strings.Contains(low, "no such file"), strings.Contains(low, "not running"): st.Reason = "Incus is installed but its daemon isn't running." default: st.Reason = "Incus is installed but didn't answer: " + err.Error() } if !st.Root { st.State, st.Reason = "unavailable", st.Reason+" Ham Lab has no root here to fix that." } return st } pm, cand := m.packageManager(ctx) st.PM = pm switch { case pm == "opkg": st.State, st.Reason = "unavailable", "Incus isn't packaged for OpenWrt." case pm == "": st.State, st.Reason = "unavailable", "No package manager Ham Lab knows how to drive here (apt, dnf, pacman, apk)." case !cand: st.State, st.Reason = "unavailable", fmt.Sprintf("This machine's package manager (%s) offers no incus package.", pm) case !st.Root: st.State, st.Reason = "unavailable", "Incus can be installed with "+pm+", but Ham Lab has no root on this machine to do it." default: st.State = "installable" st.Subnet = Gateway(PickSubnet(TakenPrefixes(ctx))) } if st.State == "unavailable" && !st.KVM { st.Reason += " This kernel has no KVM either." } return st } // ---- running the CLI ---- func lastLine(out []byte, err error) error { msg := strings.TrimSpace(string(out)) if i := strings.LastIndex(msg, "\n"); i >= 0 { msg = msg[i+1:] } msg = strings.TrimPrefix(msg, "Error: ") if msg == "" { msg = err.Error() } return errors.New(msg) } // run executes incus as this user, and again as root when the socket refuses — the state a // fresh install is in until the daemon restarts with its new group. func (m *Manager) run(ctx context.Context, args ...string) ([]byte, error) { out, err := exec.CommandContext(ctx, "incus", args...).CombinedOutput() if err != nil && priv.Available() && strings.Contains(strings.ToLower(string(out)), "permission denied") { full := priv.Wrap(append([]string{"incus"}, args...)...) out, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput() } if err != nil { return out, lastLine(out, err) } return out, nil } // Reachable reports whether incus answers this user without help; a terminal's argv is // wrapped in sudo when it does not. func (m *Manager) Reachable(ctx context.Context) bool { c, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() return exec.CommandContext(c, "incus", "query", "/1.0").Run() == nil } // Argv is a command line for incus, wrapped for root when this user cannot reach it. func (m *Manager) Argv(ctx context.Context, args ...string) []string { argv := append([]string{"incus"}, args...) if !m.Reachable(ctx) && priv.Available() { return priv.Wrap(argv...) } return argv } // Cmd is the same as Argv, as an exec.Cmd for the task runner. func (m *Manager) Cmd(ctx context.Context, args ...string) *exec.Cmd { argv := m.Argv(ctx, args...) return exec.Command(argv[0], argv[1:]...) } // ShellCmd runs a small script with positional parameters, as root when needed. Values // travel as $1, $2… rather than being spliced into the script, so nothing needs quoting. func (m *Manager) ShellCmd(ctx context.Context, script string, params ...string) *exec.Cmd { argv := append([]string{"sh", "-c", script, "hamlab"}, params...) if !m.Reachable(ctx) && priv.Available() { argv = priv.Wrap(argv...) } return exec.Command(argv[0], argv[1:]...) } // ---- instances ---- type Snapshot struct { Name string `json:"name"` Created time.Time `json:"created"` Expires *time.Time `json:"expires,omitempty"` Size int64 `json:"size,omitempty"` } type Instance struct { Name string `json:"name"` Type string `json:"type"` // container | virtual-machine Status string `json:"status"` Arch string `json:"arch"` Description string `json:"description,omitempty"` Image string `json:"image"` OS string `json:"os,omitempty"` Release string `json:"release,omitempty"` CPU string `json:"cpu,omitempty"` Memory string `json:"memory,omitempty"` Created time.Time `json:"created"` LastUsed time.Time `json:"last_used"` IPv4 []string `json:"ipv4"` Snapshots []Snapshot `json:"snapshots"` Profiles []string `json:"profiles"` Ephemeral bool `json:"ephemeral"` CPUPct float64 `json:"cpu_pct"` MemUsage uint64 `json:"mem_usage"` MemPeak uint64 `json:"mem_peak"` DiskUsage uint64 `json:"disk_usage"` DiskTotal uint64 `json:"disk_total"` PID int `json:"pid"` Processes int `json:"processes"` Config map[string]string `json:"config"` Devices map[string]map[string]string `json:"devices"` Schedule string `json:"schedule,omitempty"` Expiry string `json:"expiry,omitempty"` } type rawInstance struct { Name string `json:"name"` Type string `json:"type"` Status string `json:"status"` Arch string `json:"architecture"` Description string `json:"description"` Config map[string]string `json:"expanded_config"` Devices map[string]map[string]string `json:"expanded_devices"` Profiles []string `json:"profiles"` Ephemeral bool `json:"ephemeral"` CreatedAt time.Time `json:"created_at"` LastUsedAt time.Time `json:"last_used_at"` State *struct { CPU struct { Usage int64 `json:"usage"` } `json:"cpu"` Memory struct { Usage uint64 `json:"usage"` UsagePeak uint64 `json:"usage_peak"` } `json:"memory"` Disk map[string]struct { Usage uint64 `json:"usage"` Total uint64 `json:"total"` } `json:"disk"` Network map[string]struct { Addresses []struct { Family string `json:"family"` Address string `json:"address"` Scope string `json:"scope"` } `json:"addresses"` } `json:"network"` PID int `json:"pid"` Processes int `json:"processes"` } `json:"state"` Snapshots []struct { Name string `json:"name"` CreatedAt time.Time `json:"created_at"` ExpiresAt *time.Time `json:"expires_at"` Size int64 `json:"size"` } `json:"snapshots"` } // secretKeys are config values never sent to the browser whole: cloud-init carries passwords. func secretKey(k string) bool { return strings.Contains(k, "user-data") || strings.Contains(k, "vendor-data") || strings.Contains(k, "network-config") || strings.Contains(k, "user.") } func (m *Manager) Instances(ctx context.Context) ([]Instance, error) { return m.InstancesFor(ctx, "api") } // InstancesFor is Instances with its own CPU history per caller: the page and the sensors // both read, at different rates, and a shared "previous reading" would give each the other's delta. func (m *Manager) InstancesFor(ctx context.Context, caller string) ([]Instance, error) { c, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() out, err := m.run(c, "list", "--format", "json") if err != nil { return nil, err } var raw []rawInstance if err := json.Unmarshal(out, &raw); err != nil { return nil, errors.New("unreadable answer from incus") } now := time.Now() m.mu.Lock() defer m.mu.Unlock() prevs := m.cpuPrev[caller] if prevs == nil { prevs = map[string]cpuMark{} m.cpuPrev[caller] = prevs } seen := map[string]bool{} var list []Instance for _, r := range raw { in := Instance{Name: r.Name, Type: r.Type, Status: r.Status, Arch: r.Arch, Description: r.Description, Profiles: r.Profiles, Ephemeral: r.Ephemeral, Created: r.CreatedAt, LastUsed: r.LastUsedAt, IPv4: []string{}, Snapshots: []Snapshot{}, Config: map[string]string{}, Devices: r.Devices} if in.Devices == nil { in.Devices = map[string]map[string]string{} } for k, v := range r.Config { if strings.HasPrefix(k, "volatile.") { continue } if secretKey(k) { v = fmt.Sprintf("(set · %d bytes)", len(v)) } in.Config[k] = v } in.Image = r.Config["image.description"] if in.Image == "" { in.Image = r.Config["volatile.base_image"] if len(in.Image) > 12 { in.Image = in.Image[:12] } } in.OS, in.Release = r.Config["image.os"], r.Config["image.release"] in.CPU, in.Memory = r.Config["limits.cpu"], r.Config["limits.memory"] in.Schedule, in.Expiry = r.Config["snapshots.schedule"], r.Config["snapshots.expiry"] for _, s := range r.Snapshots { in.Snapshots = append(in.Snapshots, Snapshot{Name: s.Name, Created: s.CreatedAt, Expires: s.ExpiresAt, Size: s.Size}) } if r.State != nil { in.MemUsage, in.MemPeak = r.State.Memory.Usage, r.State.Memory.UsagePeak in.PID, in.Processes = r.State.PID, r.State.Processes if d, ok := r.State.Disk["root"]; ok { in.DiskUsage, in.DiskTotal = d.Usage, d.Total } names := make([]string, 0, len(r.State.Network)) for n := range r.State.Network { names = append(names, n) } sort.Strings(names) for _, n := range names { if n == "lo" { continue } for _, a := range r.State.Network[n].Addresses { if a.Family == "inet" && a.Scope == "global" { in.IPv4 = append(in.IPv4, a.Address) } } } if r.Status == "Running" { if prev, ok := prevs[r.Name]; ok && now.After(prev.at) && r.State.CPU.Usage >= prev.ns { in.CPUPct = float64(r.State.CPU.Usage-prev.ns) / float64(now.Sub(prev.at).Nanoseconds()) * 100 } prevs[r.Name] = cpuMark{ns: r.State.CPU.Usage, at: now} seen[r.Name] = true } } list = append(list, in) } for n := range prevs { if !seen[n] { delete(prevs, n) } } if list == nil { list = []Instance{} } return list, nil } // ---- pools and networks ---- type Pool struct { Name string `json:"name"` Driver string `json:"driver"` Source string `json:"source"` Where string `json:"where"` // in words: what disk this is, so a pool is picked by place, not by driver Used uint64 `json:"used"` Total uint64 `json:"total"` UsedBy int `json:"used_by"` } // poolWhere describes a pool by where its bytes land. func (m *Manager) poolWhere(driver, source string) string { inHam := strings.HasPrefix(source, m.dir+"/") || source == m.dir switch driver { case "dir": if inHam { return "this machine's own disk — plain files, each disk sized on its own" } return "plain files at " + source case "zfs": if strings.HasSuffix(source, ".img") || source == ZPoolName { return "a ZFS pool inside one file on this machine's disk" } if strings.Contains(source, "/") { return "ZFS dataset " + source } return "ZFS pool " + source case "btrfs": if inHam { return "btrfs inside one file on this machine's disk" } return "btrfs at " + source } return driver + " at " + source } type Network struct { Name string `json:"name"` Type string `json:"type"` Managed bool `json:"managed"` Address string `json:"address,omitempty"` NAT bool `json:"nat"` UsedBy int `json:"used_by"` } func (m *Manager) Pools(ctx context.Context) []Pool { c, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() out, err := m.run(c, "storage", "list", "--format", "json") if err != nil { return nil } var raw []struct { Name string `json:"name"` Driver string `json:"driver"` Config map[string]string `json:"config"` UsedBy []string `json:"used_by"` } if json.Unmarshal(out, &raw) != nil { return nil } var out2 []Pool for _, p := range raw { pool := Pool{Name: p.Name, Driver: p.Driver, Source: p.Config["source"], Where: m.poolWhere(p.Driver, p.Config["source"]), UsedBy: len(p.UsedBy)} if b, err := m.run(c, "query", "/1.0/storage-pools/"+p.Name+"/resources"); err == nil { var res struct { Space struct { Used uint64 `json:"used"` Total uint64 `json:"total"` } `json:"space"` } if json.Unmarshal(b, &res) == nil { pool.Used, pool.Total = res.Space.Used, res.Space.Total } } out2 = append(out2, pool) } return out2 } // DefaultPool is where a new instance's disk goes unless the form says otherwise: the pool // on the default profile's root device. func (m *Manager) DefaultPool(ctx context.Context) string { c, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() out, err := m.run(c, "query", "/1.0/profiles/default") if err != nil { return "" } var p struct { Devices map[string]map[string]string `json:"devices"` } if json.Unmarshal(out, &p) != nil { return "" } return p.Devices["root"]["pool"] } func (m *Manager) Networks(ctx context.Context) []Network { c, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() out, err := m.run(c, "network", "list", "--format", "json") if err != nil { return nil } var raw []struct { Name string `json:"name"` Type string `json:"type"` Managed bool `json:"managed"` Config map[string]string `json:"config"` UsedBy []string `json:"used_by"` } if json.Unmarshal(out, &raw) != nil { return nil } var list []Network for _, n := range raw { if n.Type == "loopback" || strings.HasPrefix(n.Name, "veth") || strings.HasPrefix(n.Name, "tap") { continue } list = append(list, Network{Name: n.Name, Type: n.Type, Managed: n.Managed, Address: n.Config["ipv4.address"], NAT: n.Config["ipv4.nat"] == "true", UsedBy: len(n.UsedBy)}) } return list } // ---- images ---- type Image struct { Fingerprint string `json:"fingerprint"` Aliases []string `json:"aliases"` Arch string `json:"arch"` Type string `json:"type"` // container | virtual-machine Size uint64 `json:"size"` Created time.Time `json:"created"` LastUsed time.Time `json:"last_used"` Description string `json:"description"` OS string `json:"os,omitempty"` Release string `json:"release,omitempty"` Variant string `json:"variant,omitempty"` Cached bool `json:"cached"` Source string `json:"source,omitempty"` Public bool `json:"public"` } type rawImage struct { Fingerprint string `json:"fingerprint"` Aliases []struct { Name string `json:"name"` } `json:"aliases"` Architecture string `json:"architecture"` Type string `json:"type"` Size uint64 `json:"size"` CreatedAt time.Time `json:"created_at"` LastUsedAt time.Time `json:"last_used_at"` Properties map[string]string `json:"properties"` Cached bool `json:"cached"` Public bool `json:"public"` UpdateSource *struct { Alias string `json:"alias"` Server string `json:"server"` } `json:"update_source"` } func toImage(r rawImage) Image { im := Image{Fingerprint: r.Fingerprint, Arch: r.Architecture, Type: r.Type, Size: r.Size, Created: r.CreatedAt, LastUsed: r.LastUsedAt, Cached: r.Cached, Public: r.Public, Description: r.Properties["description"], OS: r.Properties["os"], Release: r.Properties["release"], Variant: r.Properties["variant"], Aliases: []string{}} if im.Type == "" { im.Type = "container" } for _, a := range r.Aliases { im.Aliases = append(im.Aliases, a.Name) } if r.UpdateSource != nil { im.Source = r.UpdateSource.Alias if im.Source == "" { im.Source = r.UpdateSource.Server } } return im } func (m *Manager) Images(ctx context.Context) ([]Image, error) { c, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() out, err := m.run(c, "image", "list", "--format", "json") if err != nil { return nil, err } var raw []rawImage if err := json.Unmarshal(out, &raw); err != nil { return nil, errors.New("unreadable image list") } list := make([]Image, 0, len(raw)) for _, r := range raw { list = append(list, toImage(r)) } sort.Slice(list, func(i, j int) bool { return list[i].Description < list[j].Description }) return list, nil } // Remote is the public catalogue (the `images:` remote Incus ships pointed at // images.linuxcontainers.org), narrowed to what this machine can run. A few megabytes of // JSON, so it is kept for an hour. func (m *Manager) Remote(ctx context.Context, refresh bool) ([]Image, error) { m.mu.Lock() if !refresh && time.Since(m.remote.at) < remoteTTL && m.remote.list != nil { l := m.remote.list m.mu.Unlock() return l, nil } m.mu.Unlock() c, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() out, err := m.run(c, "image", "list", "images:", "--format", "json", "architecture="+IncusArch()) if err != nil { return nil, err } var raw []rawImage if err := json.Unmarshal(out, &raw); err != nil { return nil, errors.New("unreadable catalogue") } arch := IncusArch() var list []Image for _, r := range raw { if r.Architecture != arch || len(r.Aliases) == 0 { continue } list = append(list, toImage(r)) } sort.Slice(list, func(i, j int) bool { if list[i].OS != list[j].OS { return list[i].OS < list[j].OS } return list[i].Description < list[j].Description }) m.mu.Lock() m.remote.at, m.remote.list = time.Now(), list m.mu.Unlock() return list, nil } // LibraryFile is one file in virt/images or virt/isos. type LibraryFile struct { Name string `json:"name"` Size int64 `json:"size"` Modified time.Time `json:"modified"` AttachedTo []string `json:"attached_to,omitempty"` } // Installed reports whether the incus binary is on this machine at all; the cheap gate the // sensors use before asking it anything. func Installed() bool { return have("incus") } func (m *Manager) Library(dir string) []LibraryFile { ents, err := os.ReadDir(dir) if err != nil { return []LibraryFile{} } out := []LibraryFile{} for _, e := range ents { if e.IsDir() || strings.HasPrefix(e.Name(), ".") { continue } if info, err := e.Info(); err == nil { out = append(out, LibraryFile{Name: e.Name(), Size: info.Size(), Modified: info.ModTime()}) } } return out } // ---- validation ---- var ( nameRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]{0,62}$`) imageRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,127}$`) sizeRe = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?(B|[KMGT]i?B)?$`) cpuRe = regexp.MustCompile(`^[0-9]{1,3}(-[0-9]{1,3})?(,[0-9]{1,3}(-[0-9]{1,3})?)*$`) snapRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$`) cronRe = regexp.MustCompile(`^(@(hourly|daily|weekly|monthly|yearly|annually|startup|never)|[0-9*/,-]+( [0-9*/,-]+){4})$`) expRe = regexp.MustCompile(`^[0-9]+[mHdwMy]( [0-9]+[mHdwMy])*$|^$`) deviceRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{0,30}$`) isoRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._ +-]{0,200}\.(?i:iso|img)$`) ) func ValidName(s string) bool { return nameRe.MatchString(s) } func ValidDevice(s string) bool { return deviceRe.MatchString(s) } func ValidISOName(s string) bool { return isoRe.MatchString(s) && s == filepath.Base(s) } func ValidImage(s string) bool { return imageRe.MatchString(s) } func ValidSize(s string) bool { return s == "" || sizeRe.MatchString(s) } func ValidCPU(s string) bool { return s == "" || cpuRe.MatchString(s) } func ValidSnap(s string) bool { return snapRe.MatchString(s) } func ValidCron(s string) bool { return s == "" || cronRe.MatchString(s) } func ValidExpiry(s string) bool { return expRe.MatchString(s) } func ValidNetwork(s string) bool { return s == "" || nameRe.MatchString(s) } // CreateSpec is the New instance form. type CreateSpec struct { Name string `json:"name"` Image string `json:"image"` // "images:debian/13" or a local alias VM bool `json:"vm"` CPU string `json:"cpu"` Memory string `json:"memory"` Disk string `json:"disk"` Pool string `json:"pool"` // where the root disk goes; blank is the default profile's Network string `json:"network"` CloudInit string `json:"cloud_init"` Profiles []string `json:"profiles"` Description string `json:"description"` Start bool `json:"start"` } // CreateArgs turns the form into the incus command line, or says what is wrong with it. func CreateArgs(sp CreateSpec, kvm bool) ([]string, error) { switch { case !ValidName(sp.Name): return nil, errors.New("name: letters, digits and dashes, starting with a letter, up to 63") case !ValidImage(sp.Image): return nil, errors.New("image: an alias like images:debian/13") case sp.VM && !kvm: return nil, errors.New("this machine has no KVM, so it can run containers only") case !ValidCPU(sp.CPU): return nil, errors.New("cpu: a count, like 2") case !ValidSize(sp.Memory): return nil, errors.New("memory: a size, like 2GiB") case !ValidSize(sp.Disk): return nil, errors.New("disk: a size, like 20GiB") case sp.Pool != "" && !ValidName(sp.Pool): return nil, errors.New("pool: a storage pool's name") case !ValidNetwork(sp.Network): return nil, errors.New("network: a bridge name") case len(sp.CloudInit) > 256<<10: return nil, errors.New("cloud-init: too long") case len(sp.Description) > 200 || strings.ContainsAny(sp.Description, "\n\r"): return nil, errors.New("description: one short line") } verb := "init" if sp.Start { verb = "launch" } args := []string{verb, sp.Image, sp.Name} if sp.VM { args = append(args, "--vm") } if sp.CPU != "" { args = append(args, "-c", "limits.cpu="+sp.CPU) } if sp.Memory != "" { args = append(args, "-c", "limits.memory="+sp.Memory) } // the root disk: the profile's, with the pool and size overridden where given var root []string if sp.Pool != "" { root = append(root, "pool="+sp.Pool) } if sp.Disk != "" { root = append(root, "size="+sp.Disk) } if len(root) > 0 { args = append(args, "-d", "root,"+strings.Join(root, ",")) } if sp.Network != "" { args = append(args, "-n", sp.Network) } if strings.TrimSpace(sp.CloudInit) != "" { args = append(args, "-c", "cloud-init.user-data="+sp.CloudInit) } for _, p := range sp.Profiles { if !ValidName(p) { return nil, errors.New("profile: " + p) } args = append(args, "--profile", p) } return args, nil } // ---- install ---- // InstallOpts is what the Install dialog lets him change. Where things go is not among them. type InstallOpts struct { Driver string `json:"driver"` PoolSizeGiB int `json:"pool_size_gib"` Subnet string `json:"subnet"` // the bridge's address, CIDR: 172.16.100.1/24 DockerRules bool `json:"docker_rules"` ARCMaxGiB int `json:"arc_max_gib"` // 0: leave it } func sq(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } // InstallScript is the whole install as one shell script, run as root as one followed task. // Every step checks before it acts, so running it again repairs rather than duplicates. func (m *Manager) InstallScript(st Status, o InstallOpts) (string, Layout, error) { var lay Layout ok := false for _, d := range st.Drivers { if d == o.Driver { ok = true } } if !ok { return "", lay, fmt.Errorf("driver %q isn't available on this machine", o.Driver) } if o.PoolSizeGiB < 5 && o.Driver != "dir" { return "", lay, errors.New("pool: at least 5 GiB") } if uint64(o.PoolSizeGiB)<<30 > st.DiskFree && o.Driver != "dir" { return "", lay, fmt.Errorf("pool: only %d GiB free where Ham Lab's data lives", st.DiskFree>>30) } gw, err := netip.ParsePrefix(o.Subnet) if err != nil || !gw.Addr().Is4() || gw.Bits() < 16 || gw.Bits() > 29 || gw.Addr() == gw.Masked().Addr() { return "", lay, errors.New("subnet: the bridge's own address with its prefix, like 172.16.100.1/24") } if o.ARCMaxGiB < 0 || (o.ARCMaxGiB > 0 && uint64(o.ARCMaxGiB)<<30 > st.MemTotal) { return "", lay, errors.New("arc: more than this machine has") } dir := m.dir lay = Layout{Driver: o.Driver, Bridge: BridgeName, Subnet: o.Subnet, DockerRules: o.DockerRules && st.Docker, ARCMaxGiB: o.ARCMaxGiB, Attempted: time.Now(), Version: m.version} var b strings.Builder w := func(f string, a ...any) { fmt.Fprintf(&b, f+"\n", a...) } w("#!/bin/sh") w("# Ham Lab: install Incus and lay out %s — generated %s", dir, time.Now().Format(time.RFC3339)) w("set -eu") w("export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin DEBIAN_FRONTEND=noninteractive") w("say() { printf '\\n== %%s\\n' \"$*\"; }") w("DIR=%s", sq(dir)) w("mkdir -p \"$DIR/images\" \"$DIR/isos\" \"$DIR/exports\"") w("say packages") pkgs := []string{"incus"} switch st.PM { case "apt": if o.Driver == "btrfs" && !st.BtrfsTools { pkgs = append(pkgs, "btrfs-progs") } w("if ! command -v incus >/dev/null 2>&1 || [ -n %s ]; then apt-get -q update; apt-get -q -y -o Dpkg::Options::=--force-confold install %s; else echo 'incus already installed'; fi", sq(strings.Join(pkgs[1:], " ")), strings.Join(pkgs, " ")) case "dnf": switch st.Arch { case "x86_64": pkgs = append(pkgs, "qemu-system-x86-core", "edk2-ovmf") case "aarch64": pkgs = append(pkgs, "qemu-system-aarch64-core", "edk2-aarch64") } if o.Driver == "btrfs" && !st.BtrfsTools { pkgs = append(pkgs, "btrfs-progs") } w("dnf -y install %s", strings.Join(pkgs, " ")) case "pacman": pkgs = append(pkgs, "qemu-base", "edk2-ovmf") if o.Driver == "btrfs" && !st.BtrfsTools { pkgs = append(pkgs, "btrfs-progs") } w("pacman -S --noconfirm --needed %s", strings.Join(pkgs, " ")) case "apk": pkgs = append(pkgs, "incus-client", "incus-openrc") if o.Driver == "btrfs" && !st.BtrfsTools { pkgs = append(pkgs, "btrfs-progs") } w("apk add %s", strings.Join(pkgs, " ")) default: if st.State != "repair" { return "", lay, errors.New("no package manager to install with") } w("echo 'incus is already installed; repairing the setup around it'") } w("if command -v systemctl >/dev/null 2>&1; then systemctl enable --now incus.socket 2>/dev/null || true; systemctl enable --now incus.service 2>/dev/null || systemctl start incus 2>/dev/null || true; fi") w("for i in 1 2 3 4 5 6 7 8 9 10; do incus query /1.0 >/dev/null 2>&1 && break; sleep 1; done") w("incus query /1.0 >/dev/null || { echo 'incus is not answering'; exit 1; }") w("say storage") switch o.Driver { case "zfs": lay.PoolSource, lay.PoolSizeGiB = ZPoolName, o.PoolSizeGiB w("if ! zpool list %s >/dev/null 2>&1; then", ZPoolName) w(" if [ ! -e \"$DIR/pool.img\" ]; then truncate -s %dG \"$DIR/pool.img\"; echo \"made $DIR/pool.img (%d GiB, sparse)\"; fi", o.PoolSizeGiB, o.PoolSizeGiB) w(" zpool import -d \"$DIR\" %s 2>/dev/null || zpool create -m none -o ashift=12 -O compression=lz4 -O atime=off -O xattr=sa %s \"$DIR/pool.img\"", ZPoolName, ZPoolName) w(" echo 'zfs pool %s on the backing file'", ZPoolName) w("else echo 'zfs pool %s already there'; fi", ZPoolName) w("if ! incus storage show %s >/dev/null 2>&1; then incus storage create %s zfs source=%s; fi", PoolName, PoolName, ZPoolName) // Incus must not start before the pool is imported, or it will try to import it itself // by scanning /dev, where a file-backed vdev is not. w("mkdir -p /etc/systemd/system/incus.service.d && printf '[Unit]\\nAfter=zfs-import.target zfs-mount.service\\n' > /etc/systemd/system/incus.service.d/hamlab-zfs.conf && systemctl daemon-reload || true") case "btrfs": lay.PoolSource, lay.PoolSizeGiB = filepath.Join(dir, "pool"), o.PoolSizeGiB w("if [ ! -e \"$DIR/pool.img\" ]; then truncate -s %dG \"$DIR/pool.img\"; mkfs.btrfs -q \"$DIR/pool.img\"; echo \"made $DIR/pool.img (%d GiB, sparse, btrfs)\"; fi", o.PoolSizeGiB, o.PoolSizeGiB) w("mkdir -p \"$DIR/pool\"") w("grep -qsF \"$DIR/pool.img\" /etc/fstab || printf '%%s %%s btrfs loop,nofail,noatime 0 0\\n' \"$DIR/pool.img\" \"$DIR/pool\" >> /etc/fstab") w("systemctl daemon-reload 2>/dev/null || true") w("mountpoint -q \"$DIR/pool\" || mount \"$DIR/pool\"") w("if ! incus storage show %s >/dev/null 2>&1; then incus storage create %s btrfs source=\"$DIR/pool\"; fi", PoolName, PoolName) default: lay.PoolSource = filepath.Join(dir, "pool") w("mkdir -p \"$DIR/pool\"") w("if ! incus storage show %s >/dev/null 2>&1; then incus storage create %s dir source=\"$DIR/pool\"; fi", PoolName, PoolName) } w("say network") w("if ! incus network show %s >/dev/null 2>&1; then incus network create %s ipv4.address=%s ipv4.nat=true ipv6.address=none; else echo '%s already there'; fi", BridgeName, BridgeName, sq(o.Subnet), BridgeName) w("say profile") w("incus profile device show default | grep -q '^root:' || incus profile device add default root disk path=/ pool=%s", PoolName) w("incus profile device show default | grep -q '^eth0:' || incus profile device add default eth0 nic network=%s name=eth0", BridgeName) if lay.DockerRules { w("say docker") // Docker sets FORWARD to DROP; without these the bridge's traffic goes nowhere. A unit // re-applies them after every boot, since Docker rebuilds its chains when it starts. w("IPT=$(command -v iptables || echo /usr/sbin/iptables)") w("cat > /etc/systemd/system/%s </dev/null || $IPT -I DOCKER-USER -i %s -j ACCEPT; $IPT -C DOCKER-USER -o %s -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || $IPT -I DOCKER-USER -o %s -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT'", BridgeName, BridgeName, BridgeName, BridgeName) w("RemainAfterExit=yes") w("") w("[Install]") w("WantedBy=multi-user.target docker.service") w("UNIT") w("systemctl daemon-reload && systemctl enable --now %s && echo 'DOCKER-USER rules in place'", NetUnit) } if o.ARCMaxGiB > 0 && st.ZFSHost { w("say arc") w("echo %d > /sys/module/zfs/parameters/zfs_arc_max && echo 'options zfs zfs_arc_max=%d' > /etc/modprobe.d/hamlab-zfs-arc.conf && echo 'ARC capped at %d GiB'", uint64(o.ARCMaxGiB)<<30, uint64(o.ARCMaxGiB)<<30, o.ARCMaxGiB) } w("say access") if m.user != "" && m.user != "root" { w("getent group %s >/dev/null && usermod -aG %s %s && echo '%s is in %s'", AdminGroup, AdminGroup, sq(m.user), m.user, AdminGroup) w("if [ -f /etc/systemd/system/%s ]; then mkdir -p /etc/systemd/system/%s.d && printf '[Service]\\nSupplementaryGroups=%s\\n' > /etc/systemd/system/%s.d/virt.conf && systemctl daemon-reload; fi", HamlabUnit, HamlabUnit, AdminGroup, HamlabUnit) } w("chown %s \"$DIR\" \"$DIR/images\" \"$DIR/isos\" \"$DIR/exports\" 2>/dev/null || true", sq(m.user)) w("say done") w("incus version || true") w("echo 'Ham Lab restarts in 3 seconds to pick up its new group; the page reconnects by itself.'") w("if systemctl is-active --quiet %s; then systemd-run --quiet --on-active=3 --unit=hamlab-virt-restart systemctl restart %s; fi", HamlabUnit, HamlabUnit) return b.String(), lay, nil } // FromISOSpec is the New VM from ISO form: an empty VM with the ISO as its first boot device. type FromISOSpec struct { Name string `json:"name"` ISO string `json:"iso"` // a file name in virt/isos CPU string `json:"cpu"` Memory string `json:"memory"` Disk string `json:"disk"` Pool string `json:"pool"` Network string `json:"network"` SecureBoot bool `json:"secureboot"` Start bool `json:"start"` } // FromISOScript makes the VM in one shell script run through ShellCmd; the parameters carry // the values so nothing is spliced into the text. func FromISOScript(sp FromISOSpec, kvm bool) (script string, params []string, err error) { switch { case !kvm: return "", nil, errors.New("this machine has no KVM") case !ValidName(sp.Name): return "", nil, errors.New("name: letters, digits and dashes, starting with a letter, up to 63") case !ValidISOName(sp.ISO): return "", nil, errors.New("iso: pick one from the library") case !ValidCPU(sp.CPU) || !ValidSize(sp.Memory) || !ValidSize(sp.Disk) || !ValidNetwork(sp.Network): return "", nil, errors.New("cpu, memory, disk: a count and two sizes, like 2, 2GiB, 20GiB") case sp.Pool != "" && !ValidName(sp.Pool): return "", nil, errors.New("pool: a storage pool's name") } cpu, mem, disk := sp.CPU, sp.Memory, sp.Disk if cpu == "" { cpu = "2" } if mem == "" { mem = "2GiB" } if disk == "" { disk = "20GiB" } sb := "true" if !sp.SecureBoot { sb = "false" } start := "0" if sp.Start { start = "1" } // $1 name $2 iso path $3 cpu $4 memory $5 disk $6 network $7 secureboot $8 start $9 pool script = `set -e NET=""; [ -n "$6" ] && NET="-n $6" ROOT="root,size=$5"; [ -n "$9" ] && ROOT="root,pool=$9,size=$5" incus init --empty --vm "$1" -c limits.cpu="$3" -c limits.memory="$4" -c security.secureboot="$7" -d "$ROOT" $NET incus config device add "$1" iso0 disk source="$2" boot.priority=10 echo "made $1 with $2 as its first boot device" [ "$8" = 1 ] && incus start "$1" && echo "started; open the VGA console to run the installer" exit 0` return script, []string{sp.Name, sp.ISO, cpu, mem, disk, sp.Network, sb, start, sp.Pool}, nil }