The tab reports one of four states for the selected machine (ready, repair, installable, unavailable) from what it finds — /dev/kvm, the package manager's incus candidate, the pool drivers the kernel has — and acts on that machine only. Install is one followed task: packages, a pool whose backing file sits in Ham Lab's own virt/ folder (zfs pool on a file, btrfs on a loop mount, or a plain dir), a bridge on a subnet picked clear of the machine's routes and Docker's pools (never 10.x on a /8 LAN), the DOCKER-USER rules that keep Docker's FORWARD drop off the bridge, the incus-admin group for Ham Lab's account, and a restart. Instances list with live CPU and memory, create from the images: catalogue or a local image (container or VM, limits, cloud-init), start/stop/restart/force-stop/rename/ delete, console and shell as terminal tabs, snapshots with Incus's own schedule and expiry, limits editing. Images: local store, catalogue search and download (exported into the library too), import a tarball or a VM disk from a path, publish an instance. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
131 lines
3.9 KiB
Go
131 lines
3.9 KiB
Go
package incus
|
||
|
||
// The bridge subnet. Incus's own `auto` picks a random 10.x.y.0/24, and on a LAN that is
|
||
// itself 10.0.0.0/8 (this one) every such pick is on-link and collides. So Ham Lab chooses:
|
||
// the first /24 that nothing on the machine already routes, is not an address it holds, and
|
||
// is outside Docker's configured pools — tried in an order that also stays clear of Docker's
|
||
// *defaults* (172.17–172.31 and 192.168.0.0/16 carved into /20s), which a Docker that has no
|
||
// daemon.json will hand out later without asking anyone.
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/netip"
|
||
"os"
|
||
"os/exec"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// candidates in order of preference: 172.16.0.0/16 is the one RFC 1918 block Docker never
|
||
// takes by default; then the top of 192.168/16, which Docker reaches last; then the rest.
|
||
func candidates() []netip.Prefix {
|
||
var out []netip.Prefix
|
||
for i := 100; i <= 254; i++ {
|
||
out = append(out, netip.MustParsePrefix(fmt.Sprintf("172.16.%d.0/24", i)))
|
||
}
|
||
for i := 254; i >= 200; i-- {
|
||
out = append(out, netip.MustParsePrefix(fmt.Sprintf("192.168.%d.0/24", i)))
|
||
}
|
||
for i := 1; i < 100; i++ {
|
||
out = append(out, netip.MustParsePrefix(fmt.Sprintf("172.16.%d.0/24", i)))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func overlaps(a, b netip.Prefix) bool {
|
||
return a.Contains(b.Addr()) || b.Contains(a.Addr())
|
||
}
|
||
|
||
// PickSubnet returns the first candidate /24 clear of every prefix in taken.
|
||
func PickSubnet(taken []netip.Prefix) netip.Prefix {
|
||
for _, c := range candidates() {
|
||
clear := true
|
||
for _, t := range taken {
|
||
if overlaps(c, t) {
|
||
clear = false
|
||
break
|
||
}
|
||
}
|
||
if clear {
|
||
return c
|
||
}
|
||
}
|
||
return netip.MustParsePrefix("172.16.100.0/24") // every candidate taken: not a machine this will run on
|
||
}
|
||
|
||
// Gateway is the bridge's own address in a subnet: its first host, as CIDR, the way
|
||
// Incus's ipv4.address wants it.
|
||
func Gateway(p netip.Prefix) string {
|
||
return fmt.Sprintf("%s/%d", p.Masked().Addr().Next(), p.Bits())
|
||
}
|
||
|
||
// TakenPrefixes reads what the machine already uses: its routes, its addresses, and the
|
||
// pools Docker is configured to carve networks from.
|
||
func TakenPrefixes(ctx context.Context) []netip.Prefix {
|
||
var out []netip.Prefix
|
||
add := func(s string) {
|
||
if p, err := netip.ParsePrefix(s); err == nil && p.Addr().Is4() {
|
||
out = append(out, p.Masked())
|
||
} else if a, err := netip.ParseAddr(s); err == nil && a.Is4() {
|
||
out = append(out, netip.PrefixFrom(a, 32))
|
||
}
|
||
}
|
||
c, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||
defer cancel()
|
||
if b, err := exec.CommandContext(c, "ip", "-j", "-4", "route", "show").Output(); err == nil {
|
||
var routes []struct{ Dst string }
|
||
if json.Unmarshal(b, &routes) == nil {
|
||
for _, r := range routes {
|
||
if r.Dst != "" && r.Dst != "default" {
|
||
add(r.Dst)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if b, err := exec.CommandContext(c, "ip", "-j", "-4", "addr", "show").Output(); err == nil {
|
||
var links []struct {
|
||
AddrInfo []struct {
|
||
Local string `json:"local"`
|
||
Prefixlen int `json:"prefixlen"`
|
||
} `json:"addr_info"`
|
||
}
|
||
if json.Unmarshal(b, &links) == nil {
|
||
for _, l := range links {
|
||
for _, a := range l.AddrInfo {
|
||
add(fmt.Sprintf("%s/%d", a.Local, a.Prefixlen))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for _, p := range dockerPools() {
|
||
add(p)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// dockerPools reads default-address-pools from Docker's daemon.json, and falls back to
|
||
// Docker's built-in defaults when the file names none.
|
||
func dockerPools() []string {
|
||
var cfg struct {
|
||
Pools []struct {
|
||
Base string `json:"base"`
|
||
} `json:"default-address-pools"`
|
||
}
|
||
if b, err := os.ReadFile("/etc/docker/daemon.json"); err == nil && json.Unmarshal(b, &cfg) == nil && len(cfg.Pools) > 0 {
|
||
var out []string
|
||
for _, p := range cfg.Pools {
|
||
out = append(out, p.Base)
|
||
}
|
||
return out
|
||
}
|
||
if _, err := exec.LookPath("docker"); err != nil {
|
||
return nil
|
||
}
|
||
return []string{"172.17.0.0/12", "192.168.0.0/16"}
|
||
}
|
||
|
||
// String form used in the layout file and on the page.
|
||
func prefixString(p netip.Prefix) string { return strings.TrimSpace(p.Masked().String()) }
|