diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 2f76718aa..3ced7e448 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -10,42 +10,43 @@ jobs: runs-on: ubuntu-latest strategy: fail-fast: false - max-parallel: 5 + max-parallel: 3 matrix: include: - # henrygd/beszel - - image: henrygd/beszel + # beszel + - image: beszel dockerfile: ./internal/dockerfile_hub - registry: docker.io - username_secret: DOCKERHUB_USERNAME - password_secret: DOCKERHUB_TOKEN tags: | type=raw,value=edge type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} - - # henrygd/beszel-agent:alpine - - image: henrygd/beszel-agent + + # beszel-agent + - image: beszel-agent + dockerfile: ./internal/dockerfile_agent + tags: | + type=raw,value=edge + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} + + # beszel-agent:alpine + - image: beszel-agent dockerfile: ./internal/dockerfile_agent_alpine flavor: latest=false - registry: docker.io - username_secret: DOCKERHUB_USERNAME - password_secret: DOCKERHUB_TOKEN tags: | type=raw,value=alpine type=semver,pattern={{version}}-alpine type=semver,pattern={{major}}.{{minor}}-alpine type=semver,pattern={{major}}-alpine - # henrygd/beszel-agent-nvidia - - image: henrygd/beszel-agent-nvidia + # beszel-agent-nvidia + - image: beszel-agent-nvidia dockerfile: ./internal/dockerfile_agent_nvidia platforms: linux/amd64,linux/arm64 - registry: docker.io - username_secret: DOCKERHUB_USERNAME - password_secret: DOCKERHUB_TOKEN tags: | type=raw,value=edge type=semver,pattern={{version}} @@ -53,122 +54,21 @@ jobs: type=semver,pattern={{major}} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} - # henrygd/beszel-agent-nvidia:slim - - image: henrygd/beszel-agent-nvidia + # beszel-agent-nvidia:slim + - image: beszel-agent-nvidia dockerfile: ./internal/dockerfile_agent_nvidia_slim flavor: latest=false platforms: linux/amd64,linux/arm64 - registry: docker.io - username_secret: DOCKERHUB_USERNAME - password_secret: DOCKERHUB_TOKEN tags: | type=raw,value=slim type=semver,pattern={{version}}-slim type=semver,pattern={{major}}.{{minor}}-slim type=semver,pattern={{major}}-slim - # henrygd/beszel-agent-intel - - image: henrygd/beszel-agent-intel + # beszel-agent-intel + - image: beszel-agent-intel dockerfile: ./internal/dockerfile_agent_intel platforms: linux/amd64 - registry: docker.io - username_secret: DOCKERHUB_USERNAME - password_secret: DOCKERHUB_TOKEN - tags: | - type=raw,value=edge - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} - - # ghcr.io/henrygd/beszel - - image: ghcr.io/${{ github.repository }}/beszel - dockerfile: ./internal/dockerfile_hub - registry: ghcr.io - username: ${{ github.actor }} - password_secret: GITHUB_TOKEN - tags: | - type=raw,value=edge - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} - - # ghcr.io/henrygd/beszel-agent - - image: ghcr.io/${{ github.repository }}/beszel-agent - dockerfile: ./internal/dockerfile_agent - registry: ghcr.io - username: ${{ github.actor }} - password_secret: GITHUB_TOKEN - tags: | - type=raw,value=edge - type=raw,value=latest - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} - - # ghcr.io/henrygd/beszel-agent-nvidia - - image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia - dockerfile: ./internal/dockerfile_agent_nvidia - platforms: linux/amd64,linux/arm64 - registry: ghcr.io - username: ${{ github.actor }} - password_secret: GITHUB_TOKEN - tags: | - type=raw,value=edge - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} - - # ghcr.io/henrygd/beszel-agent-nvidia:slim - - image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia - dockerfile: ./internal/dockerfile_agent_nvidia_slim - flavor: latest=false - platforms: linux/amd64,linux/arm64 - registry: ghcr.io - username: ${{ github.actor }} - password_secret: GITHUB_TOKEN - tags: | - type=raw,value=slim - type=semver,pattern={{version}}-slim - type=semver,pattern={{major}}.{{minor}}-slim - type=semver,pattern={{major}}-slim - - # ghcr.io/henrygd/beszel-agent-intel - - image: ghcr.io/${{ github.repository }}/beszel-agent-intel - dockerfile: ./internal/dockerfile_agent_intel - platforms: linux/amd64 - registry: ghcr.io - username: ${{ github.actor }} - password_secret: GITHUB_TOKEN - tags: | - type=raw,value=edge - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} - - # ghcr.io/henrygd/beszel-agent:alpine - - image: ghcr.io/${{ github.repository }}/beszel-agent - dockerfile: ./internal/dockerfile_agent_alpine - flavor: latest=false - registry: ghcr.io - username: ${{ github.actor }} - password_secret: GITHUB_TOKEN - tags: | - type=raw,value=alpine - type=semver,pattern={{version}}-alpine - type=semver,pattern={{major}}.{{minor}}-alpine - type=semver,pattern={{major}}-alpine - - # henrygd/beszel-agent - - image: henrygd/beszel-agent - dockerfile: ./internal/dockerfile_agent - registry: docker.io - username_secret: DOCKERHUB_USERNAME - password_secret: DOCKERHUB_TOKEN tags: | type=raw,value=edge type=semver,pattern={{version}} @@ -203,7 +103,11 @@ jobs: id: metadata uses: docker/metadata-action@v6 with: - images: ${{ matrix.image }} + # Each image is built once and pushed to both registries. + # Docker Hub is skipped when its token is missing (e.g. forks). + images: | + name=henrygd/${{ matrix.image }},enable=${{ secrets.DOCKERHUB_TOKEN != '' }} + name=ghcr.io/${{ github.repository }}/${{ matrix.image }} # Variant images must not overwrite the standard image's latest tag. flavor: ${{ matrix.flavor || 'latest=auto' }} tags: ${{ matrix.tags }} @@ -211,13 +115,20 @@ jobs: # https://github.com/docker/login-action - name: Login to Docker Hub env: - password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }} + password_secret_exists: ${{ secrets.DOCKERHUB_TOKEN != '' && 'true' || 'false' }} if: github.event_name != 'pull_request' && env.password_secret_exists == 'true' uses: docker/login-action@v4 with: - username: ${{ matrix.username || secrets[matrix.username_secret] }} - password: ${{ secrets[matrix.password_secret] }} - registry: ${{ matrix.registry }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} # Build and push Docker image with Buildx (don't push on PR) # https://github.com/docker/build-push-action @@ -227,7 +138,7 @@ jobs: context: ./ file: ${{ matrix.dockerfile }} platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7' }} - push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }} + push: ${{ github.ref_type == 'tag' }} provenance: mode=max sbom: true tags: ${{ steps.metadata.outputs.tags }} diff --git a/agent/agent.go b/agent/agent.go index 6e876b577..0ba042a0a 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -27,6 +27,7 @@ type Agent struct { debug bool // true if LOG_LEVEL is set to debug zfs bool // true if system has arcstats memCalc string // Memory calculation formula + forceUseCgroup bool // Explicitly use the cgroup mount root for CPU accounting fsNames []string // List of filesystem device names being monitored fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval @@ -79,6 +80,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) { } agent.memCalc, _ = utils.GetEnv("MEM_CALC") + + if useCgroup, exists := utils.GetEnv("USE_CGROUP"); exists { + agent.forceUseCgroup = useCgroup == "true" + } + agent.initializeCpu() agent.sensorConfig = agent.newSensorConfig() // Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks @@ -106,6 +112,8 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) { slog.Debug(beszel.Version) + agent.warnIfRootCgroup() + // initialize docker manager agent.dockerManager = newDockerManager(agent) diff --git a/agent/cpu.go b/agent/cpu.go index ce7fb39f4..f99f8ca40 100644 --- a/agent/cpu.go +++ b/agent/cpu.go @@ -30,17 +30,17 @@ type CpuMetrics struct { Iowait float64 Steal float64 Idle float64 - // fromCgroup is set when Total comes from cgroup accounting (LXC) rather - // than /proc/stat, so per-core /proc/stat usage would not match it. + // fromCgroup is set when Total comes from cgroup accounting rather than + // /proc/stat, so per-core /proc/stat usage would not match it. fromCgroup bool } // getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements. // It returns percentages for total, user, system, iowait, and steal time. -func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) { +func getCpuMetrics(cacheTimeMs uint16, forceUseCgroup bool) (CpuMetrics, error) { // Inside LXC, lxcfs serves /proc/stat with the host cores' counters, not - // the guest's own usage. Prefer the cgroup's CPU accounting there. (#2332) - if metrics, ok := containerCpuMetrics(cacheTimeMs); ok { + // the guest's own usage. Prefer cgroup accounting there or when requested. (#2332) + if metrics, ok := containerCpuMetrics(cacheTimeMs, forceUseCgroup); ok { metrics.fromCgroup = true return metrics, nil } diff --git a/agent/cpu_linux.go b/agent/cpu_linux.go index a50d5059b..f19170615 100644 --- a/agent/cpu_linux.go +++ b/agent/cpu_linux.go @@ -3,6 +3,7 @@ package agent import ( + "log/slog" "os" "path/filepath" "runtime" @@ -23,15 +24,15 @@ import ( // reflects only the guest's processes, so inside LXC we derive CPU% from that // instead. // -// Other runtimes (Docker, Podman, k8s) are deliberately left alone: the agent -// is normally deployed there to monitor the host, and the host's /proc/stat is -// exactly what it should report. +// Other runtimes (Docker, Podman, k8s) use /proc/stat by default: the agent +// is normally deployed there to monitor the host. BESZEL_AGENT_USE_CGROUP=true +// explicitly opts into cgroup-root CPU accounting outside LXC. // File paths and hooks are variables so tests can point them at fixtures. var ( - cpuCgroupRoot = "/sys/fs/cgroup" // default cgroup v2 mount point - cpuCgroupMountinfo = "/proc/self/mountinfo" - cpuProcSelfCgroup = "/proc/self/cgroup" + cgroupRoot = "/sys/fs/cgroup" // default cgroup v2 mount point + cgroupMountinfo = "/proc/self/mountinfo" + procSelfCgroup = "/proc/self/cgroup" cpuSystemdContPath = "/run/systemd/container" cpuNumCPU = runtime.NumCPU cpuNow = time.Now @@ -50,7 +51,7 @@ func detectLxc() bool { // lxcfs mounted over /proc/stat is the direct cause of the host-core // counters. Only match that mount point: an LXC host also has lxcfs // mounted, but at /var/lib/lxcfs. - if data, err := os.ReadFile(cpuCgroupMountinfo); err == nil && procStatFromLxcfs(data) { + if data, err := os.ReadFile(cgroupMountinfo); err == nil && procStatFromLxcfs(data) { return true } // set by liblxc for the container init and inherited on non-systemd guests @@ -65,6 +66,12 @@ func detectLxc() bool { return false } +// useCgroup enables cgroup-root accounting automatically in LXC or +// explicitly when requested by the agent configuration. +func useCgroup(forceUse bool) bool { + return forceUse || inLxc() +} + // procStatFromLxcfs reports whether mountinfo shows lxcfs mounted on /proc/stat. func procStatFromLxcfs(mountinfo []byte) bool { for line := range strings.SplitSeq(string(mountinfo), "\n") { @@ -91,10 +98,10 @@ type cgroupCpuSample struct { var lastCgroupCpuSamples = make(map[uint16]cgroupCpuSample) -// init seeds the LXC CPU baseline so the first reported value is a real -// delta since startup rather than zero. -func init() { - if !inLxc() { +// initializeCpu seeds the cgroup baseline after Agent configuration is read, +// so the first reported CPU value is a delta instead of zero. +func (a *Agent) initializeCpu() { + if !useCgroup(a.forceUseCgroup) { return } if s, ok := readContainerCpuSample(); ok { @@ -103,12 +110,47 @@ func init() { } } -// containerCpuMetrics derives CPU metrics from the guest's own cgroup -// accounting when running inside LXC. It returns ok=false everywhere else and -// whenever cgroup accounting is unreadable, so callers keep the /proc/stat -// path. -func containerCpuMetrics(cacheTimeMs uint16) (CpuMetrics, bool) { - if !inLxc() { +// warnIfRootCgroup logs at startup when USE_CGROUP would read the host's root +// cgroup (e.g. a container run with --cgroupns=host), whose accounting covers +// the whole machine rather than the container. +func (a *Agent) warnIfRootCgroup() { + if a.forceUseCgroup && inRootCgroupV2() { + slog.Warn("USE_CGROUP is reading the host's root cgroup; use --cgroupns=private to monitor the container") + } +} + +// cgroupV2Dir returns the cgroup v2 mount point (the mount root, not the +// agent's own leaf), or false if the process is not in the v2 hierarchy. +func cgroupV2Dir() (string, bool) { + if !utils.InCgroupV2(procSelfCgroup) { + return "", false + } + if mount := utils.CgroupMountPoint(cgroupMountinfo, "cgroup2", ""); mount != "" { + return mount, true + } + return cgroupRoot, true +} + +// inRootCgroupV2 reports whether the visible cgroup v2 mount is the root +// cgroup. Every cgroup except the root has a cgroup.type file. +func inRootCgroupV2() bool { + dir, ok := cgroupV2Dir() + if !ok { + return false + } + if _, err := os.Stat(filepath.Join(dir, "cgroup.controllers")); err != nil { + return false // no cgroup v2 mount here + } + _, err := os.Stat(filepath.Join(dir, "cgroup.type")) + return os.IsNotExist(err) +} + +// containerCpuMetrics derives CPU metrics from the cgroup mount root's +// accounting when running inside LXC or explicitly enabled. It returns +// ok=false when disabled or whenever cgroup accounting is unreadable, so +// callers keep the /proc/stat path. +func containerCpuMetrics(cacheTimeMs uint16, forceUseCgroup bool) (CpuMetrics, bool) { + if !useCgroup(forceUseCgroup) { return CpuMetrics{}, false } cur, ok := readContainerCpuSample() @@ -164,21 +206,21 @@ func readContainerCpuSample() (cgroupCpuSample, bool) { // in /proc/self/cgroup (its service cgroup, or the ".lxc" leaf when started // from an attached shell) only covers a subset and must not be descended into. func readCgroupV2CpuSample() (cgroupCpuSample, bool) { - if !inCgroupV2() { + dir, ok := cgroupV2Dir() + if !ok { return cgroupCpuSample{}, false // no v2 membership; try v1 } - dir := cpuCgroupRoot - if mount := cgroupMountPoint("cgroup2", ""); mount != "" { - dir = mount + stat, err := utils.ReadCgroupStat(filepath.Join(dir, "cpu.stat")) + if err != nil { + return cgroupCpuSample{}, false } - stat := filepath.Join(dir, "cpu.stat") - usage, ok := cgroupStatValue(stat, "usage_usec") + usage, ok := stat["usage_usec"] if !ok { return cgroupCpuSample{}, false } s := cgroupCpuSample{usageUsec: usage, cores: cpuCgroupCores(dir)} - s.userUsec, _ = cgroupStatValue(stat, "user_usec") - s.systemUsec, _ = cgroupStatValue(stat, "system_usec") + s.userUsec = stat["user_usec"] + s.systemUsec = stat["system_usec"] return s, true } @@ -187,7 +229,7 @@ func readCgroupV2CpuSample() (cgroupCpuSample, bool) { // accounting includes every child cgroup, so it is read directly rather than // the agent's own sub-cgroup. func readCgroupV1CpuSample() (cgroupCpuSample, bool) { - dir := cgroupMountPoint("cgroup", "cpuacct") + dir := utils.CgroupMountPoint(cgroupMountinfo, "cgroup", "cpuacct") if dir == "" { return cgroupCpuSample{}, false } @@ -197,78 +239,13 @@ func readCgroupV1CpuSample() (cgroupCpuSample, bool) { } s := cgroupCpuSample{usageUsec: usageNs / 1000, cores: cpuCgroupCores(dir)} // cpuacct.stat reports user/system in USER_HZ jiffies. - if v, ok := cgroupStatValue(filepath.Join(dir, "cpuacct.stat"), "user"); ok { - s.userUsec = v * 1e6 / cpuUserHZ - } - if v, ok := cgroupStatValue(filepath.Join(dir, "cpuacct.stat"), "system"); ok { - s.systemUsec = v * 1e6 / cpuUserHZ + if stat, err := utils.ReadCgroupStat(filepath.Join(dir, "cpuacct.stat")); err == nil { + s.userUsec = stat["user"] * 1e6 / cpuUserHZ + s.systemUsec = stat["system"] * 1e6 / cpuUserHZ } return s, true } -// inCgroupV2 reports whether /proc/self/cgroup lists the v2 unified hierarchy -// (a "0::" entry). -func inCgroupV2() bool { - data, err := os.ReadFile(cpuProcSelfCgroup) - if err != nil { - return false - } - for line := range strings.SplitSeq(string(data), "\n") { - if strings.HasPrefix(line, "0::") { - return true - } - } - return false -} - -// cgroupMountPoint returns the mount point of a cgroup hierarchy from -// /proc/self/mountinfo: the cgroup2 mount for v2, or the cgroup mount whose -// super options list the wanted v1 controller. -func cgroupMountPoint(fstype, v1ctrl string) string { - data, err := os.ReadFile(cpuCgroupMountinfo) - if err != nil { - return "" - } - for _, line := range strings.Split(string(data), "\n") { - left, right, found := strings.Cut(line, " - ") - if !found { - continue - } - post := strings.Fields(right) - if len(post) == 0 || post[0] != fstype { - continue - } - if v1ctrl != "" && !mountOptHas(post, v1ctrl) { - continue - } - fields := strings.Fields(left) - if len(fields) >= 5 { - return unescapeMountPoint(fields[4]) - } - } - return "" -} - -// mountOptHas reports whether the comma-separated super options (field 3 after -// the " - " separator) contain opt. -func mountOptHas(post []string, opt string) bool { - if len(post) < 3 { - return false - } - for _, o := range strings.Split(post[2], ",") { - if o == opt { - return true - } - } - return false -} - -// unescapeMountPoint decodes octal escapes (e.g. \040 for space) used in -// mountinfo paths. -func unescapeMountPoint(s string) string { - return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(s) -} - // cpuCgroupCores returns how many CPU cores the cgroup at dir may use: the // smallest of the process affinity mask, the cgroup cpuset, and the CPU quota. func cpuCgroupCores(dir string) float64 { @@ -335,38 +312,10 @@ func cpuQuotaCores(dir string) (float64, bool) { } } } - if quota, ok := readCgroupInt(filepath.Join(dir, "cpu.cfs_quota_us")); ok && quota > 0 { - if period, ok := readCgroupInt(filepath.Join(dir, "cpu.cfs_period_us")); ok && period > 0 { + if quota, ok := utils.ReadIntFile(filepath.Join(dir, "cpu.cfs_quota_us")); ok && quota > 0 { + if period, ok := utils.ReadIntFile(filepath.Join(dir, "cpu.cfs_period_us")); ok && period > 0 { return float64(quota) / float64(period), true } } return 0, false } - -// cgroupStatValue returns the value of key in a cgroup "key value" stat file. -func cgroupStatValue(path, key string) (uint64, bool) { - data, err := os.ReadFile(path) - if err != nil { - return 0, false - } - for line := range strings.SplitSeq(string(data), "\n") { - name, value, found := strings.Cut(line, " ") - if !found || name != key { - continue - } - v, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64) - return v, err == nil - } - return 0, false -} - -// readCgroupInt reads a file containing a single signed integer -// (cpu.cfs_quota_us is -1 when no quota is set). -func readCgroupInt(path string) (int64, bool) { - data, err := os.ReadFile(path) - if err != nil { - return 0, false - } - v, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) - return v, err == nil -} diff --git a/agent/cpu_linux_test.go b/agent/cpu_linux_test.go index 337ad76b1..e1c92f43d 100644 --- a/agent/cpu_linux_test.go +++ b/agent/cpu_linux_test.go @@ -22,13 +22,13 @@ func swapCpuContainerSeams(t *testing.T) { numCPU func() int now func() time.Time }{ - cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuSystemdContPath, cpuNumCPU, cpuNow, + cgroupRoot, cgroupMountinfo, procSelfCgroup, cpuSystemdContPath, cpuNumCPU, cpuNow, } origInLxc := inLxc samples := lastCgroupCpuSamples env, hadEnv := os.LookupEnv("container") t.Cleanup(func() { - cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuSystemdContPath = backup.root, backup.mountinfo, backup.selfCgroup, backup.systemdCont + cgroupRoot, cgroupMountinfo, procSelfCgroup, cpuSystemdContPath = backup.root, backup.mountinfo, backup.selfCgroup, backup.systemdCont cpuNumCPU, cpuNow = backup.numCPU, backup.now inLxc = origInLxc lastCgroupCpuSamples = samples @@ -40,11 +40,13 @@ func swapCpuContainerSeams(t *testing.T) { inLxc = sync.OnceValue(detectLxc) lastCgroupCpuSamples = make(map[uint16]cgroupCpuSample) os.Unsetenv("container") + t.Setenv("BESZEL_AGENT_USE_CGROUP", "") + t.Setenv("USE_CGROUP", "") tmp := t.TempDir() - cpuCgroupRoot = filepath.Join(tmp, "cgroup") - cpuCgroupMountinfo = filepath.Join(tmp, "mountinfo") - cpuProcSelfCgroup = filepath.Join(tmp, "self-cgroup") + cgroupRoot = filepath.Join(tmp, "cgroup") + cgroupMountinfo = filepath.Join(tmp, "mountinfo") + procSelfCgroup = filepath.Join(tmp, "self-cgroup") cpuSystemdContPath = filepath.Join(tmp, "systemd-container") } @@ -80,16 +82,16 @@ func TestDetectLxc(t *testing.T) { {"systemd container lxc", func(t *testing.T) { writeCpuFixture(t, cpuSystemdContPath, "lxc\n") }, true}, {"systemd container nspawn", func(t *testing.T) { writeCpuFixture(t, cpuSystemdContPath, "systemd-nspawn\n") }, false}, {"lxcfs serving /proc/stat", func(t *testing.T) { - writeCpuFixture(t, cpuCgroupMountinfo, + writeCpuFixture(t, cgroupMountinfo, "31 25 0:28 / /proc/stat rw,nosuid,nodev,relatime - fuse.lxcfs lxcfs rw,user_id=0,group_id=0\n") }, true}, // an LXC host (e.g. Proxmox) mounts lxcfs too, but not over its own /proc {"lxcfs mounted on host", func(t *testing.T) { - writeCpuFixture(t, cpuCgroupMountinfo, + writeCpuFixture(t, cgroupMountinfo, "45 25 0:40 / /var/lib/lxcfs rw,nosuid,nodev,relatime - fuse.lxcfs lxcfs rw,user_id=0,group_id=0\n") }, false}, {"cgroup-only mountinfo", func(t *testing.T) { - writeCpuFixture(t, cpuCgroupMountinfo, + writeCpuFixture(t, cgroupMountinfo, "36 25 0:32 / /sys/fs/cgroup rw - cgroup2 cgroup2 rw,nsdelegate\n") }, false}, } @@ -104,12 +106,12 @@ func TestDetectLxc(t *testing.T) { func TestReadCgroupV2CpuSample(t *testing.T) { swapCpuContainerSeams(t) - writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n") - writeCpuFixture(t, cpuCgroupMountinfo, "") - require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755)) - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), + writeCpuFixture(t, procSelfCgroup, "0::/\n") + writeCpuFixture(t, cgroupMountinfo, "") + require.NoError(t, os.MkdirAll(cgroupRoot, 0o755)) + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 3000000\nuser_usec 2000000\nsystem_usec 1000000\nnr_throttled 7\n") - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "2,5-7\n") + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpuset.cpus.effective"), "2,5-7\n") cpuNumCPU = func() int { return 8 } s, ok := readCgroupV2CpuSample() @@ -127,10 +129,10 @@ func TestReadCgroupV2PrefersContainerRoot(t *testing.T) { for _, rel := range []string{"system.slice/beszel-agent.service", ".lxc"} { t.Run(rel, func(t *testing.T) { swapCpuContainerSeams(t) - writeCpuFixture(t, cpuProcSelfCgroup, "0::/"+rel+"\n") - writeCpuFixture(t, cpuCgroupMountinfo, "") - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 9000\n") - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, rel, "cpu.stat"), "usage_usec 5\n") + writeCpuFixture(t, procSelfCgroup, "0::/"+rel+"\n") + writeCpuFixture(t, cgroupMountinfo, "") + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 9000\n") + writeCpuFixture(t, filepath.Join(cgroupRoot, rel, "cpu.stat"), "usage_usec 5\n") s, ok := readCgroupV2CpuSample() require.True(t, ok) @@ -142,9 +144,9 @@ func TestReadCgroupV2PrefersContainerRoot(t *testing.T) { // Same for v1: the cpuacct mount root covers the agent's sibling services. func TestReadCgroupV1PrefersContainerRoot(t *testing.T) { swapCpuContainerSeams(t) - writeCpuFixture(t, cpuProcSelfCgroup, "3:cpu,cpuacct:/system.slice/beszel-agent.service\n") + writeCpuFixture(t, procSelfCgroup, "3:cpu,cpuacct:/system.slice/beszel-agent.service\n") v1 := filepath.Join(t.TempDir(), "cpu,cpuacct") - writeCpuFixture(t, cpuCgroupMountinfo, + writeCpuFixture(t, cgroupMountinfo, "30 25 0:26 / "+v1+" rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,cpu,cpuacct\n") writeCpuFixture(t, filepath.Join(v1, "cpuacct.usage"), "9000000\n") writeCpuFixture(t, filepath.Join(v1, "system.slice/beszel-agent.service/cpuacct.usage"), "5000\n") @@ -156,9 +158,9 @@ func TestReadCgroupV1PrefersContainerRoot(t *testing.T) { func TestReadCgroupV1CpuSample(t *testing.T) { swapCpuContainerSeams(t) - writeCpuFixture(t, cpuProcSelfCgroup, "3:cpuacct:/\n2:memory:/\n") + writeCpuFixture(t, procSelfCgroup, "3:cpuacct:/\n2:memory:/\n") v1 := filepath.Join(t.TempDir(), "cpuacct") - writeCpuFixture(t, cpuCgroupMountinfo, + writeCpuFixture(t, cgroupMountinfo, "30 25 0:26 / "+v1+" rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,cpuacct\n") writeCpuFixture(t, filepath.Join(v1, "cpuacct.usage"), "2000000000\n") writeCpuFixture(t, filepath.Join(v1, "cpuacct.stat"), "user 100\nsystem 50\n") @@ -175,24 +177,24 @@ func TestReadCgroupV1CpuSample(t *testing.T) { func TestContainerCpuMetricsMath(t *testing.T) { swapCpuContainerSeams(t) markLxc(t) - writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n") - writeCpuFixture(t, cpuCgroupMountinfo, "") - require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755)) - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "0-3\n") + writeCpuFixture(t, procSelfCgroup, "0::/\n") + writeCpuFixture(t, cgroupMountinfo, "") + require.NoError(t, os.MkdirAll(cgroupRoot, 0o755)) + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpuset.cpus.effective"), "0-3\n") cpuNumCPU = func() int { return 8 } advance := fakeNow(t) - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1000000\nuser_usec 600000\nsystem_usec 400000\n") - m, ok := containerCpuMetrics(60000) + m, ok := containerCpuMetrics(60000, false) require.True(t, ok) assert.Zero(t, m.Total) // first call only seeds the baseline // 1s elapsed, container burned 2 core-seconds on 4 usable cores advance(time.Second) - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 3000000\nuser_usec 1600000\nsystem_usec 900000\n") - m, ok = containerCpuMetrics(60000) + m, ok = containerCpuMetrics(60000, false) require.True(t, ok) assert.InDelta(t, 50, m.Total, 0.01) assert.InDelta(t, 25, m.User, 0.01) @@ -205,18 +207,18 @@ func TestContainerCpuMetricsMath(t *testing.T) { func TestContainerCpuMetricsHonorsQuota(t *testing.T) { swapCpuContainerSeams(t) markLxc(t) - writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n") - writeCpuFixture(t, cpuCgroupMountinfo, "") - require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755)) - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.max"), "200000 100000\n") // 2 cores + writeCpuFixture(t, procSelfCgroup, "0::/\n") + writeCpuFixture(t, cgroupMountinfo, "") + require.NoError(t, os.MkdirAll(cgroupRoot, 0o755)) + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.max"), "200000 100000\n") // 2 cores cpuNumCPU = func() int { return 8 } advance := fakeNow(t) - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1000000\n") - containerCpuMetrics(60000) + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1000000\n") + containerCpuMetrics(60000, false) advance(time.Second) - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n") - m, ok := containerCpuMetrics(60000) + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 2000000\n") + m, ok := containerCpuMetrics(60000, false) require.True(t, ok) assert.InDelta(t, 50, m.Total, 0.01) // 1 core-second against a 2-core quota } @@ -224,30 +226,30 @@ func TestContainerCpuMetricsHonorsQuota(t *testing.T) { func TestContainerCpuMetricsZeroAndBackwardDelta(t *testing.T) { swapCpuContainerSeams(t) markLxc(t) - writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n") - writeCpuFixture(t, cpuCgroupMountinfo, "") - require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755)) - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 5000000\n") + writeCpuFixture(t, procSelfCgroup, "0::/\n") + writeCpuFixture(t, cgroupMountinfo, "") + require.NoError(t, os.MkdirAll(cgroupRoot, 0o755)) + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 5000000\n") cpuNumCPU = func() int { return 4 } advance := fakeNow(t) // seed the baseline, then do not advance the clock: elapsed <= 0 - containerCpuMetrics(60000) - m, ok := containerCpuMetrics(60000) + containerCpuMetrics(60000, false) + m, ok := containerCpuMetrics(60000, false) require.True(t, ok) assert.Zero(t, m.Total) // counter goes backwards (cgroup recreated): report zero and re-baseline advance(time.Second) - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 100000\n") - m, ok = containerCpuMetrics(60000) + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 100000\n") + m, ok = containerCpuMetrics(60000, false) require.True(t, ok) assert.Zero(t, m.Total) // next tick measures from the new baseline, not the stale one advance(time.Second) - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1100000\n") - m, ok = containerCpuMetrics(60000) + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1100000\n") + m, ok = containerCpuMetrics(60000, false) require.True(t, ok) assert.InDelta(t, 25, m.Total, 0.01) // 1e6 usec / (1s * 4 cores) } @@ -255,34 +257,118 @@ func TestContainerCpuMetricsZeroAndBackwardDelta(t *testing.T) { func TestContainerCpuMetricsFallbacks(t *testing.T) { t.Run("not in lxc", func(t *testing.T) { swapCpuContainerSeams(t) - _, ok := containerCpuMetrics(60000) + _, ok := containerCpuMetrics(60000, false) assert.False(t, ok) }) // Docker agents monitor the host, so cgroup accounting must not kick in // even when it is readable and no LXC marker is present. t.Run("docker container", func(t *testing.T) { swapCpuContainerSeams(t) - writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n") - writeCpuFixture(t, cpuCgroupMountinfo, "") - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 5000000\n") - _, ok := containerCpuMetrics(60000) + writeCpuFixture(t, procSelfCgroup, "0::/\n") + writeCpuFixture(t, cgroupMountinfo, "") + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 5000000\n") + _, ok := containerCpuMetrics(60000, false) assert.False(t, ok) }) t.Run("in lxc without cgroup accounting", func(t *testing.T) { swapCpuContainerSeams(t) markLxc(t) - writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n") - writeCpuFixture(t, cpuCgroupMountinfo, "") - // cpuCgroupRoot has no cpu.stat - _, ok := containerCpuMetrics(60000) + writeCpuFixture(t, procSelfCgroup, "0::/\n") + writeCpuFixture(t, cgroupMountinfo, "") + // cgroupRoot has no cpu.stat + _, ok := containerCpuMetrics(60000, false) assert.False(t, ok) }) } +func TestContainerCpuMetricsExplicitOptIn(t *testing.T) { + swapCpuContainerSeams(t) + writeCpuFixture(t, procSelfCgroup, "0::/system.slice/beszel-agent.service\n") + writeCpuFixture(t, cgroupMountinfo, "") + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1000000\n") + cpuNumCPU = func() int { return 4 } + advance := fakeNow(t) + + a := &Agent{forceUseCgroup: true} + a.initializeCpu() + require.False(t, lastCgroupCpuSamples[60000].at.IsZero()) + advance(time.Second) + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 3000000\n") + m, err := getCpuMetrics(60000, a.forceUseCgroup) + require.NoError(t, err) + assert.InDelta(t, 50, m.Total, 0.01) + assert.True(t, m.fromCgroup) +} + +func TestContainerCpuMetricsExplicitOptInUnreadable(t *testing.T) { + swapCpuContainerSeams(t) + writeCpuFixture(t, procSelfCgroup, "0::/\n") + _, ok := containerCpuMetrics(60000, true) + assert.False(t, ok) +} + +func TestInRootCgroupV2(t *testing.T) { + swapCpuContainerSeams(t) + writeCpuFixture(t, cgroupMountinfo, "") + + // no cgroup v2 membership + writeCpuFixture(t, procSelfCgroup, "4:cpu,cpuacct:/docker/abc\n") + writeCpuFixture(t, filepath.Join(cgroupRoot, "cgroup.controllers"), "cpu memory\n") + assert.False(t, inRootCgroupV2()) + + // --cgroupns=host: the mount is the host root, which has no cgroup.type + writeCpuFixture(t, procSelfCgroup, "0::/system.slice/docker-abc.scope\n") + assert.True(t, inRootCgroupV2()) + + // --cgroupns=private: the mount is the container's own (non-root) cgroup + writeCpuFixture(t, filepath.Join(cgroupRoot, "cgroup.type"), "domain\n") + assert.False(t, inRootCgroupV2()) + + // no cgroup v2 mount at all + require.NoError(t, os.RemoveAll(cgroupRoot)) + assert.False(t, inRootCgroupV2()) +} + +func TestInitializeCpuDetectsLxc(t *testing.T) { + swapCpuContainerSeams(t) + markLxc(t) + writeCpuFixture(t, procSelfCgroup, "0::/\n") + writeCpuFixture(t, cgroupMountinfo, "") + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1000000\n") + + a := &Agent{} + a.initializeCpu() + assert.EqualValues(t, 1000000, lastCgroupCpuSamples[60000].usageUsec) +} + +func TestNewAgentInitializesCgroupCpuFromEnv(t *testing.T) { + swapCpuContainerSeams(t) + t.Setenv("BESZEL_AGENT_USE_CGROUP", "true") + writeCpuFixture(t, procSelfCgroup, "0::/system.slice/beszel-agent.service\n") + writeCpuFixture(t, cgroupMountinfo, "") + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1000000\n") + cpuNumCPU = func() int { return 4 } + advance := fakeNow(t) + + a, err := NewAgent(t.TempDir()) + require.NoError(t, err) + require.True(t, a.forceUseCgroup) + assert.EqualValues(t, 1000000, lastCgroupCpuSamples[60000].usageUsec) + + // Changing the environment later must not change this agent's CPU source. + t.Setenv("BESZEL_AGENT_USE_CGROUP", "false") + advance(time.Second) + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 3000000\n") + m, err := getCpuMetrics(60000, a.forceUseCgroup) + require.NoError(t, err) + assert.InDelta(t, 50, m.Total, 0.01) + assert.True(t, m.fromCgroup) +} + // The host path must keep reporting through gopsutil untouched. func TestGetCpuMetricsHostFallback(t *testing.T) { swapCpuContainerSeams(t) - m, err := getCpuMetrics(60000) + m, err := getCpuMetrics(60000, false) require.NoError(t, err) assert.False(t, m.fromCgroup) assert.GreaterOrEqual(t, m.Total, 0.0) @@ -294,20 +380,20 @@ func TestGetCpuMetricsHostFallback(t *testing.T) { func TestGetCpuMetricsPrefersCgroup(t *testing.T) { swapCpuContainerSeams(t) markLxc(t) - writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n") - writeCpuFixture(t, cpuCgroupMountinfo, "") - require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755)) + writeCpuFixture(t, procSelfCgroup, "0::/\n") + writeCpuFixture(t, cgroupMountinfo, "") + require.NoError(t, os.MkdirAll(cgroupRoot, 0o755)) cpuNumCPU = func() int { return 4 } advance := fakeNow(t) - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 0\n") - m, err := getCpuMetrics(60000) + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 0\n") + m, err := getCpuMetrics(60000, false) require.NoError(t, err) assert.Zero(t, m.Total) advance(time.Second) - writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n") - m, err = getCpuMetrics(60000) + writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 2000000\n") + m, err = getCpuMetrics(60000, false) require.NoError(t, err) assert.InDelta(t, 50, m.Total, 0.01) assert.True(t, m.fromCgroup) // per-core usage is skipped for this source diff --git a/agent/cpu_unsupported.go b/agent/cpu_unsupported.go index 886941f82..24cc50872 100644 --- a/agent/cpu_unsupported.go +++ b/agent/cpu_unsupported.go @@ -4,6 +4,10 @@ package agent // containerCpuMetrics is Linux-only (cgroup accounting). Other platforms keep // the gopsutil /proc path. -func containerCpuMetrics(uint16) (CpuMetrics, bool) { +func containerCpuMetrics(uint16, bool) (CpuMetrics, bool) { return CpuMetrics{}, false } + +func (a *Agent) initializeCpu() {} + +func (a *Agent) warnIfRootCgroup() {} diff --git a/agent/memory.go b/agent/memory.go new file mode 100644 index 000000000..4d21b7041 --- /dev/null +++ b/agent/memory.go @@ -0,0 +1,55 @@ +package agent + +import ( + "github.com/henrygd/beszel/agent/utils" + "github.com/henrygd/beszel/agent/zfs" + "github.com/henrygd/beszel/internal/entities/system" + "github.com/shirou/gopsutil/v4/mem" +) + +// memoryMetrics holds byte counters. For cgroups, Used excludes file cache, +// while retaining shared memory/tmpfs. BuffCache is the subtracted file cache. +type memoryMetrics struct { + Total, Used, BuffCache uint64 +} + +var memoryVirtualMemory = mem.VirtualMemory + +func (a *Agent) updateMemoryStats(stats *system.Stats) { + v, err := memoryVirtualMemory() + var hostTotal uint64 + if err == nil { + hostTotal = v.Total + // Swap retains its existing /proc/meminfo source in both memory paths. + stats.Swap = utils.BytesToGigabytes(v.SwapTotal) + stats.SwapUsed = utils.BytesToGigabytes(saturatingSub(v.SwapTotal, v.SwapFree)) + } + + metrics, fromCgroup := containerMemoryMetrics(hostTotal, a.forceUseCgroup) + if !fromCgroup { + if err != nil { + return + } + used, cacheBuff, _ := calculateHostMemoryUsage(v, a.memCalc == "htop") + metrics = memoryMetrics{Total: v.Total, Used: used, BuffCache: cacheBuff} + // Host ARC must not be subtracted from container-scoped accounting. + if a.zfs { + if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < metrics.Used { + metrics.Used -= arcSize + stats.MemZfsArc = utils.BytesToGigabytes(arcSize) + } + } + } + + stats.Mem = utils.BytesToGigabytes(metrics.Total) + stats.MemUsed = utils.BytesToGigabytes(metrics.Used) + stats.MemBuffCache = utils.BytesToGigabytes(metrics.BuffCache) + if metrics.Total > 0 { + stats.MemPct = utils.TwoDecimals(float64(metrics.Used) / float64(metrics.Total) * 100) + } + if a.systemDetails.MemoryTotal != metrics.Total { + a.updateSystemDetails(func(details *system.Details) { + details.MemoryTotal = metrics.Total + }) + } +} diff --git a/agent/memory_linux.go b/agent/memory_linux.go new file mode 100644 index 000000000..1df672f17 --- /dev/null +++ b/agent/memory_linux.go @@ -0,0 +1,82 @@ +//go:build linux + +package agent + +import ( + "path/filepath" + "strconv" + + "github.com/henrygd/beszel/agent/utils" +) + +// containerMemoryMetrics is only used when USE_CGROUP is explicitly enabled. +// Unlike /proc/stat, lxcfs already serves a container-scoped /proc/meminfo, +// so LXC keeps the existing memory path by default. +func containerMemoryMetrics(hostTotal uint64, forceUseCgroup bool) (memoryMetrics, bool) { + if !forceUseCgroup { + return memoryMetrics{}, false + } + if dir, ok := cgroupV2Dir(); ok { + if metrics, ok := readCgroupMemoryMetrics(dir, hostTotal, true); ok { + return metrics, true + } + } + if dir := utils.CgroupMountPoint(cgroupMountinfo, "cgroup", "memory"); dir != "" { + return readCgroupMemoryMetrics(dir, hostTotal, false) + } + return memoryMetrics{}, false +} + +// Read the hierarchy mount root to include sibling services and descendants, +// rather than the agent's own service cgroup. A v1 unlimited limit is a +// page-aligned LONG_MAX sentinel; the threshold also covers 32-bit kernels. +func readCgroupMemoryMetrics(dir string, hostTotal uint64, v2 bool) (memoryMetrics, bool) { + usageFile, limitFile, cacheKey, shmemKey := "memory.usage_in_bytes", "memory.limit_in_bytes", "total_cache", "total_shmem" + if v2 { + usageFile, limitFile, cacheKey, shmemKey = "memory.current", "memory.max", "file", "shmem" + } + usage, ok := utils.ReadUintFile(filepath.Join(dir, usageFile)) + if !ok { + return memoryMetrics{}, false + } + rawLimit, ok := utils.ReadStringFileOK(filepath.Join(dir, limitFile)) + if !ok { + return memoryMetrics{}, false + } + var limit uint64 + if !(v2 && rawLimit == "max") { + var err error + limit, err = strconv.ParseUint(rawLimit, 10, 64) + if err != nil { + return memoryMetrics{}, false + } + if !v2 && (limit >= (uint64(1)<<63)-(1<<20) || limit == 2147479552) { + limit = 0 // v1 unlimited sentinel + } else if limit == 0 { + return memoryMetrics{}, false + } + } + total := limit + if hostTotal > 0 && (total == 0 || hostTotal < total) { + total = hostTotal + } + if total == 0 { + return memoryMetrics{}, false // unlimited and no usable host total + } + stat, err := utils.ReadCgroupStat(filepath.Join(dir, "memory.stat")) + if err != nil { + return memoryMetrics{}, false + } + cache, ok := stat[cacheKey] + if !ok { + return memoryMetrics{}, false + } + shmem, ok := stat[shmemKey] + if !ok { + return memoryMetrics{}, false + } + // Both cache (v1) and file (v2) include shmem/tmpfs, which must remain + // charged as used memory rather than being classified as file cache. + cache = min(saturatingSub(cache, shmem), usage) + return memoryMetrics{Total: total, Used: usage - cache, BuffCache: cache}, true +} diff --git a/agent/memory_linux_test.go b/agent/memory_linux_test.go new file mode 100644 index 000000000..40ec3e4d9 --- /dev/null +++ b/agent/memory_linux_test.go @@ -0,0 +1,221 @@ +//go:build testing && linux + +package agent + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + + "github.com/henrygd/beszel/internal/entities/system" + "github.com/shirou/gopsutil/v4/mem" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupMemoryCgroup(t *testing.T, v2 bool) string { + t.Helper() + swapCpuContainerSeams(t) + if v2 { + writeCpuFixture(t, procSelfCgroup, "0::/system.slice/agent.service\n") + writeCpuFixture(t, cgroupMountinfo, "") + return cgroupRoot + } + dir := filepath.Join(filepath.Dir(cgroupRoot), "memory") + writeCpuFixture(t, procSelfCgroup, "2:memory:/system.slice/agent.service\n") + writeCpuFixture(t, cgroupMountinfo, + "30 25 0:26 / "+dir+" rw - cgroup cgroup rw,memory\n") + return dir +} + +func writeMemoryFixture(t *testing.T, dir string, v2 bool, usage, limit, stat string) { + t.Helper() + usageName, limitName := "memory.usage_in_bytes", "memory.limit_in_bytes" + if v2 { + usageName, limitName = "memory.current", "memory.max" + } + writeCpuFixture(t, filepath.Join(dir, usageName), usage) + writeCpuFixture(t, filepath.Join(dir, limitName), limit) + writeCpuFixture(t, filepath.Join(dir, "memory.stat"), stat) +} + +func TestContainerMemoryMetrics(t *testing.T) { + for _, v2 := range []bool{true, false} { + t.Run(strconv.FormatBool(v2), func(t *testing.T) { + dir := setupMemoryCgroup(t, v2) + key, shmemKey := "total_cache", "total_shmem" + if v2 { + key, shmemKey = "file", "shmem" + } + stat := func(cache, shmem string) string { + return key + " " + cache + "\n" + shmemKey + " " + shmem + "\n" + } + for _, tt := range []struct { + name, usage, limit, stat string + host uint64 + want memoryMetrics + ok bool + }{ + {"file cache excluded", "600", "1000", stat("200", "0"), 2000, memoryMetrics{1000, 400, 200}, true}, + {"shared memory retained", "600", "1000", stat("200", "50"), 2000, memoryMetrics{1000, 450, 150}, true}, + {"shared memory larger than cache", "600", "1000", stat("200", "300"), 2000, memoryMetrics{1000, 600, 0}, true}, + {"zero usage", "0", "1000", stat("0", "0"), 2000, memoryMetrics{1000, 0, 0}, true}, + {"cache larger than charge", "100", "1000", stat("200", "0"), 2000, memoryMetrics{1000, 0, 100}, true}, + {"host caps limit", "600", "3000", stat("200", "0"), 2000, memoryMetrics{2000, 400, 200}, true}, + {"finite without host", "600", "1000", stat("200", "0"), 0, memoryMetrics{1000, 400, 200}, true}, + {"missing cache key", "600", "1000", shmemKey + " 0\n", 2000, memoryMetrics{}, false}, + {"missing shared memory key", "600", "1000", key + " 200\n", 2000, memoryMetrics{}, false}, + {"invalid cache", "600", "1000", stat("-1", "0"), 2000, memoryMetrics{}, false}, + {"invalid shared memory", "600", "1000", stat("200", "-1"), 2000, memoryMetrics{}, false}, + {"invalid usage", "bad", "1000", stat("200", "0"), 2000, memoryMetrics{}, false}, + {"invalid limit", "600", "bad", stat("200", "0"), 2000, memoryMetrics{}, false}, + {"zero limit", "600", "0", stat("200", "0"), 2000, memoryMetrics{}, false}, + } { + t.Run(tt.name, func(t *testing.T) { + writeMemoryFixture(t, dir, v2, tt.usage, tt.limit, tt.stat) + metrics, ok := containerMemoryMetrics(tt.host, true) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, metrics) + }) + } + limit := "9223372036854771712" + if v2 { + limit = "max" + } + writeMemoryFixture(t, dir, v2, "600", limit, stat("200", "0")) + writeMemoryFixture(t, filepath.Join(dir, "system.slice/agent.service"), v2, "1", "100", stat("0", "0")) + metrics, ok := containerMemoryMetrics(2000, true) + require.True(t, ok) + assert.Equal(t, memoryMetrics{2000, 400, 200}, metrics) + _, ok = containerMemoryMetrics(0, true) + assert.False(t, ok) + if !v2 { + writeMemoryFixture(t, dir, false, "600", "2147479552", stat("200", "0")) + metrics, ok = containerMemoryMetrics(2000, true) + require.True(t, ok) + assert.EqualValues(t, 2000, metrics.Total) + } + }) + } +} + +func TestContainerMemoryScopeAndEnablement(t *testing.T) { + dir := setupMemoryCgroup(t, true) + // A discovered non-default mount must win over the default root. + mount := filepath.Join(t.TempDir(), "unified") + writeCpuFixture(t, cgroupMountinfo, + "30 25 0:26 /guest "+mount+" rw - cgroup2 cgroup2 rw\n") + writeMemoryFixture(t, dir, true, "1", "1000", "file 0\nshmem 0\n") + writeMemoryFixture(t, mount, true, "600", "1000", "file 200\nshmem 0\n") + writeMemoryFixture(t, filepath.Join(mount, "system.slice/agent.service"), true, "10", "100", "file 0\nshmem 0\n") + _, ok := containerMemoryMetrics(2000, false) + assert.False(t, ok) // Docker/host agents keep the existing path by default. + markLxc(t) + inLxc = sync.OnceValue(detectLxc) // Simulate a fresh process in LXC. + _, ok = containerMemoryMetrics(2000, false) + assert.False(t, ok) // lxcfs already serves container-scoped /proc/meminfo. + metrics, ok := containerMemoryMetrics(2000, true) + require.True(t, ok) + assert.Equal(t, memoryMetrics{1000, 400, 200}, metrics) + require.NoError(t, os.Remove(filepath.Join(mount, "memory.stat"))) + _, ok = containerMemoryMetrics(2000, true) + assert.False(t, ok) +} + +func TestUpdateMemoryStatsCgroupAndFallback(t *testing.T) { + dir := setupMemoryCgroup(t, true) + const gib = uint64(1 << 30) + writeMemoryFixture(t, dir, true, strconv.FormatUint(6*gib, 10), strconv.FormatUint(10*gib, 10), + "file "+strconv.FormatUint(2*gib, 10)+"\nshmem 0\n") + original := memoryVirtualMemory + t.Cleanup(func() { memoryVirtualMemory = original }) + memoryVirtualMemory = func() (*mem.VirtualMemoryStat, error) { + return &mem.VirtualMemoryStat{Total: 20 * gib, Used: 12 * gib, Free: 4 * gib, + Cached: 4 * gib, SwapTotal: 2 * gib, SwapFree: gib}, nil + } + a := &Agent{forceUseCgroup: true, memCalc: "htop", zfs: true} + var stats system.Stats + a.updateMemoryStats(&stats) + assert.Equal(t, float64(10), stats.Mem) + assert.Equal(t, float64(4), stats.MemUsed) + assert.Equal(t, float64(2), stats.MemBuffCache) + assert.Equal(t, float64(40), stats.MemPct) + assert.Zero(t, stats.MemZfsArc) + assert.Equal(t, float64(2), stats.Swap) + assert.Equal(t, float64(1), stats.SwapUsed) + assert.Equal(t, 10*gib, a.systemDetails.MemoryTotal) + assert.True(t, a.detailsDirty) + + a.detailsDirty = false + a.updateMemoryStats(&system.Stats{}) + assert.False(t, a.detailsDirty) + writeCpuFixture(t, filepath.Join(dir, "memory.max"), strconv.FormatUint(8*gib, 10)) + a.updateMemoryStats(&system.Stats{}) + assert.Equal(t, 8*gib, a.systemDetails.MemoryTotal) + assert.True(t, a.detailsDirty) + + // A missing required field falls back to the original htop calculation. + writeCpuFixture(t, filepath.Join(dir, "memory.stat"), "file 123\n") + a.zfs = false + stats = system.Stats{} + a.updateMemoryStats(&stats) + assert.Equal(t, float64(20), stats.Mem) + assert.Equal(t, float64(12), stats.MemUsed) + assert.Equal(t, float64(4), stats.MemBuffCache) + assert.Equal(t, float64(60), stats.MemPct) + assert.Equal(t, 20*gib, a.systemDetails.MemoryTotal) + + // Finite cgroup limits still work if /proc/meminfo cannot be read. + memoryVirtualMemory = func() (*mem.VirtualMemoryStat, error) { return nil, errors.New("unreadable") } + writeCpuFixture(t, filepath.Join(dir, "memory.stat"), "file 0\nshmem 0\n") + stats = system.Stats{} + a.updateMemoryStats(&stats) + assert.Equal(t, float64(8), stats.Mem) + assert.Equal(t, float64(6), stats.MemUsed) + assert.Equal(t, float64(75), stats.MemPct) + assert.Zero(t, stats.Swap) +} + +func TestMemoryDetailsInitiallyUseCgroupLimit(t *testing.T) { + dir := setupMemoryCgroup(t, true) + writeMemoryFixture(t, dir, true, "600", "1000", "file 200\nshmem 0\n") + original := memoryVirtualMemory + t.Cleanup(func() { memoryVirtualMemory = original }) + memoryVirtualMemory = func() (*mem.VirtualMemoryStat, error) { + return &mem.VirtualMemoryStat{Total: 2000}, nil + } + a := &Agent{forceUseCgroup: true} + a.refreshSystemDetails() + assert.EqualValues(t, 1000, a.systemDetails.MemoryTotal) +} + +// Captured from this container: active file cache made the working-set +// calculation report ~80 GiB despite only ~17 GiB of anonymous RSS. +func TestMemoryStatsExcludeActiveFileCache(t *testing.T) { + dir := setupMemoryCgroup(t, false) + const usage = uint64(135881601024) + const cache = uint64(116823166976) + const shmem = uint64(77180928) + writeMemoryFixture(t, dir, false, strconv.FormatUint(usage, 10), "137438953472", + "total_cache 116823166976\ntotal_shmem 77180928\n"+ + "total_inactive_file 49902608384\ntotal_active_file 67684671488\n") + original := memoryVirtualMemory + t.Cleanup(func() { memoryVirtualMemory = original }) + memoryVirtualMemory = func() (*mem.VirtualMemoryStat, error) { + return &mem.VirtualMemoryStat{Total: 1580621720 * 1024}, nil + } + metrics, ok := containerMemoryMetrics(1580621720*1024, true) + require.True(t, ok) + assert.Equal(t, usage-(cache-shmem), metrics.Used) + assert.Equal(t, cache-shmem, metrics.BuffCache) + assert.Equal(t, usage, metrics.Used+metrics.BuffCache) + var stats system.Stats + a := &Agent{forceUseCgroup: true} + a.updateMemoryStats(&stats) + assert.Equal(t, float64(17.82), stats.MemUsed) + assert.Equal(t, float64(108.73), stats.MemBuffCache) + assert.Equal(t, float64(13.92), stats.MemPct) +} diff --git a/agent/memory_unsupported.go b/agent/memory_unsupported.go new file mode 100644 index 000000000..21e320d68 --- /dev/null +++ b/agent/memory_unsupported.go @@ -0,0 +1,7 @@ +//go:build !linux + +package agent + +func containerMemoryMetrics(uint64, bool) (memoryMetrics, bool) { + return memoryMetrics{}, false +} diff --git a/agent/network_monitor.go b/agent/network_monitor.go index 5fa908396..b0486181c 100644 --- a/agent/network_monitor.go +++ b/agent/network_monitor.go @@ -1,6 +1,7 @@ package agent import ( + "crypto/tls" "errors" "fmt" "net/http" @@ -20,7 +21,11 @@ type MonitorManager struct { } func newMonitorManager() *MonitorManager { - return newMonitorManagerWithProbe(networkMonitorProbe(&http.Client{Timeout: monitor.MaxProbeTimeout})) + client := &http.Client{Timeout: monitor.MaxProbeTimeout} + insecureTransport := http.DefaultTransport.(*http.Transport).Clone() + insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + insecureClient := &http.Client{Timeout: monitor.MaxProbeTimeout, Transport: insecureTransport} + return newMonitorManagerWithProbe(networkMonitorProbe(client, insecureClient)) } func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager { diff --git a/agent/network_monitor_probe.go b/agent/network_monitor_probe.go index 7d7b6ae30..e1ac7c427 100644 --- a/agent/network_monitor_probe.go +++ b/agent/network_monitor_probe.go @@ -18,7 +18,9 @@ const networkMonitorUserAgent = "Beszel-Agent/" + beszel.Version + " (+https://b // Implementations must honor cancellation and bound their execution time. type monitorProbe func(context.Context, monitor.Config) (int64, error) -func networkMonitorProbe(client *http.Client) monitorProbe { +// networkMonitorProbe returns a probe that uses client for HTTP monitors, or +// insecureClient for HTTP monitors that skip TLS certificate verification. +func networkMonitorProbe(client, insecureClient *http.Client) monitorProbe { return func(ctx context.Context, config monitor.Config) (int64, error) { switch config.Protocol { case "icmp": @@ -26,6 +28,9 @@ func networkMonitorProbe(client *http.Client) monitorProbe { case "tcp": return monitorTCP(ctx, config.Target, config.Port) case "http": + if config.SkipTLSVerify { + return monitorHTTP(ctx, insecureClient, config.Target) + } return monitorHTTP(ctx, client, config.Target) case "dns": return monitorDNS(ctx, config.Target, config.Server) diff --git a/agent/network_monitor_test.go b/agent/network_monitor_test.go index 1116ce3ce..d1e37e591 100644 --- a/agent/network_monitor_test.go +++ b/agent/network_monitor_test.go @@ -145,7 +145,7 @@ func TestMonitorManagerApplySyncUpsertRunsImmediatelyAndReturnsResult(t *testing pm := &MonitorManager{ monitors: make(map[string]*monitorTask), - probe: networkMonitorProbe(server.Client()), + probe: networkMonitorProbe(server.Client(), nil), } resp, err := pm.HandleSyncRequest(monitor.SyncRequest{ @@ -261,6 +261,24 @@ func TestMonitorHTTP(t *testing.T) { assert.Equal(t, int64(-1), responseUs) require.Error(t, err) }) + + t.Run("self-signed certificate", func(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + probe := newMonitorManager().probe + config := monitor.Config{Protocol: "http", Target: server.URL} + + responseUs, err := probe(context.Background(), config) + assert.Equal(t, int64(-1), responseUs) + require.Error(t, err) + + config.SkipTLSVerify = true + responseUs, err = probe(context.Background(), config) + require.NoError(t, err) + assert.GreaterOrEqual(t, responseUs, int64(0)) + }) } func TestMonitorTCP(t *testing.T) { @@ -576,7 +594,7 @@ func TestMonitorProbeTimeoutRecordsLoss(t *testing.T) { defer server.Close() defer close(release) pm := newMonitorManager() - pm.probe = networkMonitorProbe(&http.Client{Timeout: 20 * time.Millisecond}) + pm.probe = networkMonitorProbe(&http.Client{Timeout: 20 * time.Millisecond}, nil) task := newMonitorTask(monitor.Config{ID: "timeout", Protocol: "http", Target: server.URL}) defer task.cancel() diff --git a/agent/package_updates.go b/agent/package_updates.go index b3cc8e8f6..b9afc1a99 100644 --- a/agent/package_updates.go +++ b/agent/package_updates.go @@ -141,7 +141,10 @@ func runningInContainer() bool { func detectPackageManager(dataDir string) (string, packageUpdatesCheck) { switch { - case commandExists("apt-get"): + // openSUSE's zypper-aptitude provides an apt-get wrapper around zypper, so + // apt also requires the apt package in dpkg's database. dpkg alone is not + // enough because openSUSE packages it too. + case commandExists("apt-get") && exec.Command("dpkg-query", "-W", "apt").Run() == nil: return "apt", checkApt case commandExists("dnf"): return "dnf", newDnfCheck(dataDir) diff --git a/agent/package_updates_test.go b/agent/package_updates_test.go index 8103ee9a5..ead5b80fa 100644 --- a/agent/package_updates_test.go +++ b/agent/package_updates_test.go @@ -268,6 +268,29 @@ exit 1`, }) } +func TestDetectPackageManager(t *testing.T) { + tests := []struct { + name string + commands map[string]string + manager string + }{ + {"apt", map[string]string{"apt-get": "", "dpkg-query": "exit 0"}, "apt"}, + {"zypper-aptitude", map[string]string{"apt-get": "", "zypper": ""}, "zypper"}, + {"zypper-aptitude with dpkg", map[string]string{"apt-get": "", "dpkg-query": "exit 1", "zypper": ""}, "zypper"}, + {"dnf", map[string]string{"dnf": "", "rpm": ""}, "dnf"}, + {"none", nil, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeCommands(t, tt.commands) + // only the fake commands, so the host's package managers are not found + t.Setenv("PATH", strings.SplitN(os.Getenv("PATH"), string(os.PathListSeparator), 2)[0]) + manager, _ := detectPackageManager(t.TempDir()) + assert.Equal(t, tt.manager, manager) + }) + } +} + func TestParseZypperTable(t *testing.T) { tests := []struct { file string diff --git a/agent/server.go b/agent/server.go index 054163c09..344063cf2 100644 --- a/agent/server.go +++ b/agent/server.go @@ -151,11 +151,16 @@ func (a *Agent) handleSession(s ssh.Session) { s.Exit(1) return } + // A legacy hub never sends a request, so the session is done once the payload is out. + _ = s.Exit(0) + return } var req common.HubRequest[cbor.RawMessage] if err := cbor.NewDecoder(s).Decode(&req); err != nil { - // Fallback to legacy one-shot if the first decode fails + // Only reachable for hubs that report >= MinVersionAgentResponse: a + // malformed or truncated request falls back to the one-shot payload + // so the peer still receives something parseable. if err2 := a.handleLegacyStats(s, hubVersion); err2 != nil { slog.Error("Error encoding stats (fallback)", "err", err2) s.Exit(1) diff --git a/agent/server_test.go b/agent/server_test.go index 9cd4fbdd8..b003dd11e 100644 --- a/agent/server_test.go +++ b/agent/server_test.go @@ -3,6 +3,7 @@ package agent import ( + "bytes" "context" "crypto/ed25519" "encoding/json" @@ -19,6 +20,7 @@ import ( "testing" "time" + "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/system" @@ -972,3 +974,184 @@ func TestGetHubVersionConcurrent(t *testing.T) { } wg.Wait() } + +///////////////////////////////////////////////////////////////// +/////////////// Session Protocol Tests ////////////////////////// +///////////////////////////////////////////////////////////////// + +// dialTestAgentClient starts an agent SSH server on a private unix socket and +// connects a real SSH client whose ClientVersion mimics a hub of the given +// version, so session-protocol behavior can be exercised end to end. +func dialTestAgentClient(t *testing.T, clientVersion string) *gossh.Client { + t.Helper() + + pubKey, privKey, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + signer, err := gossh.NewSignerFromKey(privKey) + require.NoError(t, err) + sshPubKey, err := gossh.NewPublicKey(pubKey) + require.NoError(t, err) + + socketFile := filepath.Join(t.TempDir(), "beszel-test.sock") + agent, err := NewAgent("") + require.NoError(t, err) + + errChan := make(chan error, 1) + go func() { + errChan <- agent.StartServer(ServerOptions{ + Network: "unix", + Addr: socketFile, + Keys: []gossh.PublicKey{sshPubKey}, + }) + }() + t.Cleanup(func() { + _ = agent.StopServer() + select { + case <-errChan: + default: + } + }) + + // Wait for the listener instead of a fixed sleep. + require.Eventually(t, func() bool { + conn, err := net.Dial("unix", socketFile) + if err != nil { + return false + } + _ = conn.Close() + return true + }, 5*time.Second, 25*time.Millisecond) + + client, err := gossh.Dial("unix", socketFile, &gossh.ClientConfig{ + User: "u", + Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)}, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + Timeout: 4 * time.Second, + ClientVersion: clientVersion, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + return client +} + +// waitSessionOutput runs readFn on a session stream and fails the test if it +// does not finish quickly. A healthy session ends in milliseconds; the guard +// exists so a handler that blocks (the bug this suite guards against) fails +// the test instead of hanging it until the 70s idle timeout. +func waitSessionOutput(t *testing.T, readFn func() ([]byte, error)) []byte { + t.Helper() + type result struct { + output []byte + err error + } + resCh := make(chan result, 1) + go func() { + output, err := readFn() + resCh <- result{output, err} + }() + select { + case res := <-resCh: + require.NoError(t, res.err) + return res.output + case <-time.After(10 * time.Second): + t.Fatal("session did not complete within 10s; handler appears to be blocked") + return nil + } +} + +// TestLegacyHubGetsSinglePayload pins the legacy one-shot contract: a hub +// older than MinVersionAgentResponse never sends a request, so the agent must +// write exactly one stats payload, exit with status 0, and end the session +// promptly. Regression target: the handler used to fall through into the +// request decode after the legacy write, read an immediate EOF from the hub's +// empty stdin, and send a second payload via the decode-failure fallback. +// Covers all three routes into the legacy path: a pre-0.13 CBOR hub, a +// pre-0.12 JSON hub, and a client whose version string cannot be parsed. +func TestLegacyHubGetsSinglePayload(t *testing.T) { + tests := []struct { + name string + clientVersion string + cborFormat bool + }{ + {name: "legacy cbor hub 0.12.0", clientVersion: "SSH-2.0-beszel_0.12.0", cborFormat: true}, + {name: "legacy json hub 0.11.1", clientVersion: "SSH-2.0-beszel_0.11.1", cborFormat: false}, + {name: "unparsable client version", clientVersion: "SSH-2.0-OpenSSH_8.0", cborFormat: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := dialTestAgentClient(t, tt.clientVersion) + + session, err := client.NewSession() + require.NoError(t, err) + defer func() { _ = session.Close() }() + + output := waitSessionOutput(t, func() ([]byte, error) { + return session.Output("") + }) + require.NotEmpty(t, output, "legacy hub must receive a stats payload") + + // Exactly one payload: decode the first object, then require the + // stream to be exhausted. A second write would leave bytes here. + var data system.CombinedData + if tt.cborFormat { + dec := cbor.NewDecoder(bytes.NewReader(output)) + require.NoError(t, dec.Decode(&data), "payload must be valid CBOR") + var extra any + require.ErrorIs(t, dec.Decode(&extra), io.EOF, + "legacy hub must receive exactly one payload, got trailing bytes") + } else { + dec := json.NewDecoder(bytes.NewReader(output)) + require.NoError(t, dec.Decode(&data), "payload must be valid JSON") + var extra map[string]any + require.ErrorIs(t, dec.Decode(&extra), io.EOF, + "legacy hub must receive exactly one payload, got trailing bytes") + } + // ConnectionType is stamped by the SSH path itself, so it is a + // deterministic (environment-independent) proof that the payload + // came from the SSH handler. + assert.Equal(t, system.ConnectionTypeSSH, data.Info.ConnectionType, + "payload must be stamped as SSH-sourced") + + // Output() returns an *ssh.ExitError when the handler exits + // non-zero; reaching this point with no error means exit status 0. + // (An s.Exit(1) from a failed legacy write would surface here.) + }) + } +} + +// TestModernHubRequestResponse guards the new-protocol path against +// regressions from the legacy-path changes: a hub at or above +// MinVersionAgentResponse sends a CBOR request and must get exactly one +// AgentResponse with populated system data and a clean exit. +func TestModernHubRequestResponse(t *testing.T) { + client := dialTestAgentClient(t, "SSH-2.0-beszel_0.21.0") + + session, err := client.NewSession() + require.NoError(t, err) + defer func() { _ = session.Close() }() + + stdout, err := session.StdoutPipe() + require.NoError(t, err) + stdin, err := session.StdinPipe() + require.NoError(t, err) + require.NoError(t, session.Shell()) + + // Same request shape the hub's SSH transport sends. + require.NoError(t, cbor.NewEncoder(stdin).Encode(common.HubRequest[any]{Action: common.GetData})) + require.NoError(t, stdin.Close()) + + output := waitSessionOutput(t, func() ([]byte, error) { + return io.ReadAll(stdout) + }) + + var resp common.AgentResponse + require.NoError(t, cbor.Unmarshal(output, &resp), "response must be a single valid CBOR AgentResponse") + assert.Empty(t, resp.Error) + require.NotNil(t, resp.SystemData, "GetData response must carry system data") + assert.Equal(t, system.ConnectionTypeSSH, resp.SystemData.Info.ConnectionType, + "GetData response must be stamped as SSH-sourced") + + // Wait() returns an *ssh.ExitError on non-zero exit; nil means status 0. + require.NoError(t, session.Wait()) +} diff --git a/agent/system.go b/agent/system.go index f567585f9..2aa302770 100644 --- a/agent/system.go +++ b/agent/system.go @@ -106,11 +106,16 @@ func (a *Agent) refreshSystemDetails() { // total memory a.systemDetails.MemoryTotal = hostInfo.MemTotal - if a.systemDetails.MemoryTotal == 0 { - if v, err := mem.VirtualMemory(); err == nil { + hostMemoryTotal := hostInfo.MemTotal + if v, err := memoryVirtualMemory(); err == nil { + hostMemoryTotal = v.Total + if a.systemDetails.MemoryTotal == 0 { a.systemDetails.MemoryTotal = v.Total } } + if metrics, ok := containerMemoryMetrics(hostMemoryTotal, a.forceUseCgroup); ok { + a.systemDetails.MemoryTotal = metrics.Total + } // zfs if _, err := zfs.ARCSize(); err != nil { @@ -156,7 +161,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { } // cpu metrics - cpuMetrics, err := getCpuMetrics(cacheTimeMs) + cpuMetrics, err := getCpuMetrics(cacheTimeMs, a.forceUseCgroup) if err == nil { systemStats.Cpu = utils.TwoDecimals(cpuMetrics.Total) systemStats.CpuBreakdown = []float64{ @@ -190,34 +195,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { } // memory - if v, err := mem.VirtualMemory(); err == nil { - used, cacheBuff, swapUsed := calculateHostMemoryUsage(v, a.memCalc == "htop") - // swap - systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal) - systemStats.SwapUsed = utils.BytesToGigabytes(swapUsed) - v.Used = used - // if a.memCalc == "legacy" { - // v.Used = v.Total - v.Free - v.Buffers - v.Cached - // cacheBuff = v.Total - v.Free - v.Used - // v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 - // } - // subtract ZFS ARC size from used memory and add as its own category - if a.zfs { - if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used { - v.Used = v.Used - arcSize - systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize) - } - } - if v.Total > 0 { - v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 - } else { - v.UsedPercent = 0 - } - systemStats.Mem = utils.BytesToGigabytes(v.Total) - systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff) - systemStats.MemUsed = utils.BytesToGigabytes(v.Used) - systemStats.MemPct = utils.TwoDecimals(v.UsedPercent) - } + a.updateMemoryStats(&systemStats) // disk usage a.updateDiskUsage(&systemStats) diff --git a/agent/utils/cgroup_linux.go b/agent/utils/cgroup_linux.go new file mode 100644 index 000000000..eb3920638 --- /dev/null +++ b/agent/utils/cgroup_linux.go @@ -0,0 +1,100 @@ +//go:build linux + +package utils + +import ( + "os" + "strconv" + "strings" +) + +// InCgroupV2 reports whether the given /proc/self/cgroup file lists the +// unified hierarchy (a "0::" entry). +func InCgroupV2(path string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + for line := range strings.SplitSeq(string(data), "\n") { + if strings.HasPrefix(line, "0::") { + return true + } + } + return false +} + +// CgroupMountPoint finds a hierarchy's mount point in the given mountinfo file. +// Use fstype "cgroup2" and an empty controller for v2, or "cgroup" and the +// desired controller for v1. It returns the mount root, not a process's leaf. +func CgroupMountPoint(path, fstype, controller string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + for line := range strings.SplitSeq(string(data), "\n") { + left, right, found := strings.Cut(line, " - ") + if !found { + continue + } + post := strings.Fields(right) + if len(post) == 0 || post[0] != fstype { + continue + } + if controller != "" && !mountOptHas(post, controller) { + continue + } + fields := strings.Fields(left) + if len(fields) >= 5 { + return unescapeMountPoint(fields[4]) + } + } + return "" +} + +// mountOptHas checks the comma-separated super options after the separator. +func mountOptHas(post []string, opt string) bool { + if len(post) < 3 { + return false + } + for o := range strings.SplitSeq(post[2], ",") { + if o == opt { + return true + } + } + return false +} + +func unescapeMountPoint(s string) string { + return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(s) +} + +// ReadCgroupStat reads all unsigned counters from a cgroup key/value stat file +// in one read. Malformed entries are omitted; callers must check required keys. +func ReadCgroupStat(path string) (map[string]uint64, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + values := make(map[string]uint64) + for line := range strings.SplitSeq(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + value, err := strconv.ParseUint(fields[1], 10, 64) + if err == nil { + values[fields[0]] = value + } + } + return values, nil +} + +// ReadIntFile parses a signed decimal integer (e.g. a v1 CPU quota of -1). +func ReadIntFile(path string) (int64, bool) { + raw, ok := ReadStringFileOK(path) + if !ok { + return 0, false + } + value, err := strconv.ParseInt(raw, 10, 64) + return value, err == nil +} diff --git a/agent/utils/cgroup_linux_test.go b/agent/utils/cgroup_linux_test.go new file mode 100644 index 000000000..22664e233 --- /dev/null +++ b/agent/utils/cgroup_linux_test.go @@ -0,0 +1,88 @@ +//go:build linux + +package utils + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInCgroupV2(t *testing.T) { + path := filepath.Join(t.TempDir(), "cgroup") + assert.False(t, InCgroupV2(path)) + for _, tt := range []struct { + name, contents string + want bool + }{ + {"v1", "3:cpu,cpuacct:/system.slice/agent.service\n2:memory:/\n", false}, + {"v2 service", "0::/system.slice/agent.service\n", true}, + {"hybrid", "2:memory:/\n0::/\n", true}, + {"empty", "", false}, + } { + t.Run(tt.name, func(t *testing.T) { + require.NoError(t, os.WriteFile(path, []byte(tt.contents), 0o644)) + assert.Equal(t, tt.want, InCgroupV2(path)) + }) + } +} + +func TestCgroupMountPoint(t *testing.T) { + path := filepath.Join(t.TempDir(), "mountinfo") + assert.Empty(t, CgroupMountPoint(path, "cgroup2", "")) + contents := "malformed\n" + + "30 25 0:26 / /sys/fs/cgroup/cpu rw - cgroup cgroup rw,cpu,cpuacct\n" + + "31 25 0:27 / /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n" + + `32 25 0:28 /guest /sys/fs/cgroup/unified\040root\134name rw - cgroup2 cgroup2 rw` + "\n" + require.NoError(t, os.WriteFile(path, []byte(contents), 0o644)) + assert.Equal(t, "/sys/fs/cgroup/cpu", CgroupMountPoint(path, "cgroup", "cpuacct")) + assert.Equal(t, "/sys/fs/cgroup/memory", CgroupMountPoint(path, "cgroup", "memory")) + assert.Equal(t, `/sys/fs/cgroup/unified root\name`, CgroupMountPoint(path, "cgroup2", "")) + assert.Empty(t, CgroupMountPoint(path, "cgroup", "mem")) + assert.Empty(t, CgroupMountPoint(path, "cgroup", "cpuset")) +} + +func TestReadCgroupStat(t *testing.T) { + path := filepath.Join(t.TempDir(), "memory.stat") + _, err := ReadCgroupStat(path) + require.Error(t, err) + contents := "anon 1024\nfile\t2048\ninactive_file 0\n" + + "negative -1\noverflow 18446744073709551616\ninvalid abc\nmissing\nextra 1 2\n" + require.NoError(t, os.WriteFile(path, []byte(contents), 0o644)) + stat, err := ReadCgroupStat(path) + require.NoError(t, err) + assert.Equal(t, map[string]uint64{"anon": 1024, "file": 2048, "inactive_file": 0}, stat) + _, ok := stat["inactive_file"] + assert.True(t, ok) + _, ok = stat["usage_usec"] + assert.False(t, ok) +} + +func TestReadIntFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "quota") + _, ok := ReadIntFile(path) + assert.False(t, ok) + for _, tt := range []struct { + contents string + want int64 + ok bool + }{ + {" -1\n", -1, true}, + {"100000\n", 100000, true}, + {"0", 0, true}, + {"max", 0, false}, + {"9223372036854775808", 9223372036854775807, false}, + } { + t.Run(tt.contents, func(t *testing.T) { + require.NoError(t, os.WriteFile(path, []byte(tt.contents), 0o644)) + value, ok := ReadIntFile(path) + assert.Equal(t, tt.ok, ok) + if ok { + assert.Equal(t, tt.want, value) + } + }) + } +} diff --git a/internal/entities/monitor/monitor.go b/internal/entities/monitor/monitor.go index 4d41cd6d7..530010cf5 100644 --- a/internal/entities/monitor/monitor.go +++ b/internal/entities/monitor/monitor.go @@ -28,6 +28,9 @@ type Config struct { // Server is the DNS server to query (host or host:port, default port 53). // Only used when Protocol is "dns"; empty means use the system resolver. Server string `cbor:"5,keyasint,omitempty"` + // SkipTLSVerify disables certificate verification for HTTPS targets, such as + // those with self-signed certificates. Only used when Protocol is "http". + SkipTLSVerify bool `cbor:"6,keyasint,omitempty" db:"skipTlsVerify"` } // CertInfo holds details of the leaf TLS certificate presented by a target. diff --git a/internal/hub/api_systemd_test.go b/internal/hub/api_systemd_test.go index 93ab194ea..adb858726 100644 --- a/internal/hub/api_systemd_test.go +++ b/internal/hub/api_systemd_test.go @@ -1,3 +1,5 @@ +//go:build testing + package hub_test import ( diff --git a/internal/hub/api_test.go b/internal/hub/api_test.go index 639ca284e..f3e869a88 100644 --- a/internal/hub/api_test.go +++ b/internal/hub/api_test.go @@ -1,3 +1,5 @@ +//go:build testing + package hub_test import ( diff --git a/internal/hub/network_monitors.go b/internal/hub/network_monitors.go index 5529aa584..b6da50e8e 100644 --- a/internal/hub/network_monitors.go +++ b/internal/hub/network_monitors.go @@ -68,6 +68,10 @@ func bindNetworkMonitorsEvents(hub *Hub) { if protocol != "dns" { e.Record.Set("server", "") } + // only http uses TLS verification - clear it for other protocols + if protocol != "http" { + e.Record.Set("skipTlsVerify", false) + } ID := generateMonitorID(systemID, *monitorConfigFromRecord(e.Record)) if ID != e.Record.Id { newRecord := copyMonitorToNewRecord(e.Record, ID) @@ -115,6 +119,8 @@ func monitorConfigFromRecord(record *core.Record) *monitor.Config { Port: uint16(record.GetInt("port")), Interval: uint16(record.GetInt("interval")), Server: record.GetString("server"), + // SkipTLSVerify is a probe option, not part of the monitor's identity, so it does not affect the ID. + SkipTLSVerify: record.GetBool("skipTlsVerify"), } } @@ -139,7 +145,7 @@ func copyMonitorToNewRecord(oldRecord *core.Record, newID string) *core.Record { collection := oldRecord.Collection() newRecord := core.NewRecord(collection) newRecord.Id = newID - fields := []string{"system", "target", "protocol", "port", "server", "interval", "enabled"} + fields := []string{"system", "target", "protocol", "port", "server", "interval", "enabled", "skipTlsVerify"} for _, field := range fields { newRecord.Set(field, oldRecord.Get(field)) } diff --git a/internal/hub/network_monitors_test.go b/internal/hub/network_monitors_test.go index 71dd4b82a..28b7a0842 100644 --- a/internal/hub/network_monitors_test.go +++ b/internal/hub/network_monitors_test.go @@ -1,3 +1,5 @@ +//go:build testing + package hub import ( @@ -293,20 +295,21 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) { oldRecord := core.NewRecord(collection) oldRecord.Load(map[string]any{ - "system": "sys123", - "target": "https://example.com", - "protocol": "http", - "port": 443, - "server": "1.1.1.1", - "interval": 60, - "enabled": true, - "res": 1200, - "resAvg1h": 1300, - "resMin1h": 900, - "resMax1h": 1600, - "loss1h": 5, - "certInfo": map[string]any{"expires": 1800000000000}, - "updated": "2026-04-29 12:00:00.000Z", + "system": "sys123", + "target": "https://example.com", + "protocol": "http", + "port": 443, + "server": "1.1.1.1", + "interval": 60, + "enabled": true, + "skipTlsVerify": true, + "res": 1200, + "resAvg1h": 1300, + "resMin1h": 900, + "resMax1h": 1600, + "loss1h": 5, + "certInfo": map[string]any{"expires": 1800000000000}, + "updated": "2026-04-29 12:00:00.000Z", }) newRecord := copyMonitorToNewRecord(oldRecord, "next12345") @@ -317,6 +320,7 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) { assert.Equal(t, 443, newRecord.GetInt("port")) assert.Equal(t, "1.1.1.1", newRecord.GetString("server")) assert.True(t, newRecord.GetBool("enabled")) + assert.True(t, newRecord.GetBool("skipTlsVerify")) assert.Contains(t, []string{"", "null"}, newRecord.GetString("certInfo")) assert.Zero(t, newRecord.GetFloat("res")) assert.Zero(t, newRecord.GetFloat("resAvg1h")) diff --git a/internal/hub/systems/network_monitor_sync_test.go b/internal/hub/systems/network_monitor_sync_test.go index a89077afc..b09817af1 100644 --- a/internal/hub/systems/network_monitor_sync_test.go +++ b/internal/hub/systems/network_monitor_sync_test.go @@ -177,6 +177,23 @@ func TestNetworkMonitorReconnectSync(t *testing.T) { } } +func TestGetMonitorConfigsForSystemIncludesSkipTLSVerify(t *testing.T) { + sys, app := newTestSystemWithHub(t) + collection, err := app.FindCachedCollectionByNameOrId("network_monitors") + require.NoError(t, err) + record := core.NewRecord(collection) + record.Load(map[string]any{ + "system": sys.Id, "target": "https://self-signed.example", "protocol": "http", + "interval": 60, "enabled": true, "skipTlsVerify": true, + }) + require.NoError(t, app.SaveNoValidate(record)) + + configs, err := sys.manager.GetMonitorConfigsForSystem(sys.Id) + require.NoError(t, err) + require.Len(t, configs, 1) + require.True(t, configs[0].SkipTLSVerify, "full sync must keep the skip TLS verify option") +} + func TestGetMonitorConfigsForSystemQueryError(t *testing.T) { sys, app := newTestSystemWithHub(t) _, err := app.DB().NewQuery("DROP TABLE network_monitors").Execute() diff --git a/internal/hub/systems/system_manager.go b/internal/hub/systems/system_manager.go index e00c3924f..a01897aa5 100644 --- a/internal/hub/systems/system_manager.go +++ b/internal/hub/systems/system_manager.go @@ -380,7 +380,7 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) { func (sm *SystemManager) GetMonitorConfigsForSystem(systemID string) ([]monitor.Config, error) { var configs []monitor.Config err := sm.hub.DB(). - NewQuery("SELECT id, target, protocol, port, interval, server FROM network_monitors WHERE system = {:system} AND enabled = true"). + NewQuery("SELECT id, target, protocol, port, interval, server, skipTlsVerify FROM network_monitors WHERE system = {:system} AND enabled = true"). Bind(dbx.Params{"system": systemID}). All(&configs) return configs, err diff --git a/internal/migrations/1790964708_network_monitor_skip_tls_verify.go b/internal/migrations/1790964708_network_monitor_skip_tls_verify.go new file mode 100644 index 000000000..8327d3912 --- /dev/null +++ b/internal/migrations/1790964708_network_monitor_skip_tls_verify.go @@ -0,0 +1,24 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("network_monitors") + if err != nil { + return err + } + collection.Fields.Add(&core.BoolField{Id: "nm_skip_tls_verify", Name: "skipTlsVerify"}) + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("network_monitors") + if err != nil { + return err + } + collection.Fields.RemoveByName("skipTlsVerify") + return app.Save(collection) + }) +} diff --git a/internal/site/src/components/charts/area-chart.tsx b/internal/site/src/components/charts/area-chart.tsx index 3daf10928..fd19217a3 100644 --- a/internal/site/src/components/charts/area-chart.tsx +++ b/internal/site/src/components/charts/area-chart.tsx @@ -11,7 +11,8 @@ import { } from "@/components/ui/chart" import { chartMargin, cn, formatShortDate } from "@/lib/utils" import type { ChartData, SystemStatsRecord } from "@/types" -import { useYAxisWidth } from "./hooks" +import { hasChartValues, useYAxisWidth } from "./hooks" +import { ChartNoValues } from "./chart-no-values" import type { AxisDomain } from "recharts/types/util/types" import { useIntersectionObserver } from "@/lib/use-intersection-observer" @@ -118,6 +119,9 @@ export default function AreaChartDefault({ if (displayData.length === 0) { return null } + if (!hasChartValues(displayData, dataPoints)) { + return + } // if (logRender) { // console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date()) // } diff --git a/internal/site/src/components/charts/chart-no-values.tsx b/internal/site/src/components/charts/chart-no-values.tsx new file mode 100644 index 000000000..f9f683abe --- /dev/null +++ b/internal/site/src/components/charts/chart-no-values.tsx @@ -0,0 +1,16 @@ +import { t } from "@lingui/core/macro" +import type { Ref } from "react" +import Spinner from "@/components/spinner" + +/** + * Shown in place of a chart whose records contain no values (e.g. every probe failed). + * The opacity-100 class hides ChartCard's loading spinner, same as a rendered chart. + * Takes the chart's intersection observer ref so the chart keeps updating once values arrive. + */ +export function ChartNoValues({ ref }: { ref?: Ref }) { + return ( +
+ +
+ ) +} diff --git a/internal/site/src/components/charts/hooks.ts b/internal/site/src/components/charts/hooks.ts index 95c962952..0a99eaa23 100644 --- a/internal/site/src/components/charts/hooks.ts +++ b/internal/site/src/components/charts/hooks.ts @@ -85,6 +85,14 @@ export function useContainerChartConfigs(containerData: ChartData["containerData }, [containerData]) } +/** + * Whether any series has a numeric value in the data. Without one, recharts renders no y-axis + * ticks, so the y-axis width is never measured and the chart would stay hidden. + */ +export function hasChartValues(data: T[], dataPoints?: { dataKey: (data: T) => unknown }[]) { + return !!dataPoints?.some(({ dataKey }) => data.some((record) => typeof dataKey(record) === "number")) +} + /** Sets the correct width of the y axis in recharts based on the longest label */ export function useYAxisWidth() { const [yAxisWidth, setYAxisWidth] = useState(0) diff --git a/internal/site/src/components/charts/line-chart.tsx b/internal/site/src/components/charts/line-chart.tsx index 94c0a200c..15faffb36 100644 --- a/internal/site/src/components/charts/line-chart.tsx +++ b/internal/site/src/components/charts/line-chart.tsx @@ -11,7 +11,8 @@ import { } from "@/components/ui/chart" import { chartMargin, cn, formatShortDate } from "@/lib/utils" import type { ChartOptions, SystemStatsRecord } from "@/types" -import { useYAxisWidth } from "./hooks" +import { hasChartValues, useYAxisWidth } from "./hooks" +import { ChartNoValues } from "./chart-no-values" import type { AxisDomain } from "recharts/types/util/types" import { useIntersectionObserver } from "@/lib/use-intersection-observer" @@ -141,7 +142,7 @@ export default function LineChartDefault({ dataKey={dataPoint.dataKey} name={dataPoint.label} type="monotoneX" - dot={dataPoint.dot || false} + dot={dataPoint.dot ?? (connectNulls ? false : isolatedDot)} strokeWidth={1.5} stroke={color} strokeOpacity={dataPoint.strokeOpacity} @@ -160,6 +161,9 @@ export default function LineChartDefault({ if (displayData.length === 0) { return null } + if (!hasChartValues(displayData, dataPoints)) { + return + } // if (logRender) { // console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date()) // } diff --git a/internal/site/src/components/network-monitors-table/monitor-dialog.tsx b/internal/site/src/components/network-monitors-table/monitor-dialog.tsx index dfe56422d..6a0b91166 100644 --- a/internal/site/src/components/network-monitors-table/monitor-dialog.tsx +++ b/internal/site/src/components/network-monitors-table/monitor-dialog.tsx @@ -19,6 +19,7 @@ import { } from "@/components/ui/dropdown-menu" import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -40,6 +41,7 @@ type MonitorValues = { port: number server: string interval: string + skipTlsVerify: boolean } type NormalizedMonitorValues = Omit & { @@ -63,6 +65,7 @@ const NormalizedMonitorValuesSchema = v.pipe( port: v.number(), server: v.pipe(v.string(), v.trim()), interval: MonitorIntervalSchema, + skipTlsVerify: v.boolean(), }), v.transform((input): NormalizedMonitorValues => { let { protocol, port } = input @@ -84,6 +87,8 @@ const NormalizedMonitorValuesSchema = v.pipe( // Only DNS monitors use a custom server; clear it for other protocols. server: protocol === "dns" ? input.server : "", interval: input.interval, + // Only HTTPS targets use TLS; clear it for everything else. + skipTlsVerify: protocol === "http" && isHttpsTarget(httpTarget) && input.skipTlsVerify, } }), v.forward( @@ -121,6 +126,10 @@ const BulkMonitorSchema = v.object({ server: v.optional(v.pipe(v.string(), v.trim())), }) +function isHttpsTarget(target: string) { + return /^https:\/\//i.test(target) +} + function normalizeHttpTarget(target: string, port = 0) { const useExplicitPort = port > 0 && port !== 80 && port !== 443 const hasOriginOnlyTarget = /^https?:\/\/[^/?#]+$/i.test(target) @@ -198,6 +207,7 @@ function parseBulkMonitorLine(line: string, lineNumber: number, system: string) port: parsed.output.port ? Number(parsed.output.port) : 0, server: parsed.output.server || "", interval: parsed.output.interval || `${defaultInterval}`, + skipTlsVerify: false, }) } @@ -720,6 +730,7 @@ function MonitorDialogContent({ const [port, setPort] = useState(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "") const [server, setServer] = useState(monitor?.protocol === "dns" ? (monitor.server ?? "") : "") const [monitorInterval, setMonitorInterval] = useState(String(monitor?.interval ?? defaultInterval)) + const [skipTlsVerify, setSkipTlsVerify] = useState(monitor?.skipTlsVerify ?? false) const [loading, setLoading] = useState(false) const [selectedSystemId, setSelectedSystemId] = useState(monitor?.system ?? "") const [selectedSystemIds, setSelectedSystemIds] = useState>(new Set()) @@ -728,6 +739,8 @@ function MonitorDialogContent({ const { t } = useLingui() const isEditing = !!monitor const dnsTargetIsIp = protocol === "dns" && isIpAddress(target.trim()) + // Bare hostnames are normalized to https, so they can also skip verification. + const usesTls = protocol === "http" && !/^http:\/\//i.test(target.trim()) // When the dialog is opened, initialize form fields with monitor values (if editing) or defaults (if adding). useEffect(() => { @@ -740,6 +753,7 @@ function MonitorDialogContent({ setPort(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "") setServer(monitor?.protocol === "dns" ? (monitor.server ?? "") : "") setMonitorInterval(String(monitor?.interval ?? defaultInterval)) + setSkipTlsVerify(monitor?.skipTlsVerify ?? false) setSelectedSystemId(monitor?.system ?? "") setSelectedSystemIds(new Set()) setLoading(false) @@ -761,6 +775,7 @@ function MonitorDialogContent({ port: protocol === "tcp" ? Number(port) : 0, server: protocol === "dns" ? server.trim() : "", interval: monitorInterval, + skipTlsVerify, }, monitor ? monitor.enabled : true ) @@ -910,6 +925,22 @@ function MonitorDialogContent({ required /> + {usesTls && ( + + )} {!isEditing && onOpenBulkAdd && (