From 4a40d1772c2c0fcb1aec1fefecbbc56bb6622cbe Mon Sep 17 00:00:00 2001 From: hank Date: Mon, 5 Oct 2026 10:51:53 -0400 Subject: [PATCH 01/10] chore(helm): update app version to 0.21.0 (#2498) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- supplemental/helm/beszel-agent/Chart.yaml | 4 ++-- supplemental/helm/beszel-agent/README.md | 6 +++--- supplemental/helm/beszel-hub/Chart.yaml | 4 ++-- supplemental/helm/beszel-hub/README.md | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/supplemental/helm/beszel-agent/Chart.yaml b/supplemental/helm/beszel-agent/Chart.yaml index 9c404dd4c..eba41d44b 100644 --- a/supplemental/helm/beszel-agent/Chart.yaml +++ b/supplemental/helm/beszel-agent/Chart.yaml @@ -2,9 +2,9 @@ apiVersion: v1 description: Installs beszel-agent in kubernetes home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent name: beszel-agent -appVersion: "0.20.0" +appVersion: "0.21.0" # Bump this version when publishing chart changes. -version: 0.1.7 +version: 0.1.8 sources: - https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent - https://www.beszel.dev/ diff --git a/supplemental/helm/beszel-agent/README.md b/supplemental/helm/beszel-agent/README.md index dd9cd2445..0f82125b0 100644 --- a/supplemental/helm/beszel-agent/README.md +++ b/supplemental/helm/beszel-agent/README.md @@ -80,7 +80,7 @@ Essential parameters to configure: | `secret.sshKey` | `ssh-key` | Key name in the secret for the SSH public key | | `secret.tokenKey` | `token` | Key name in the secret for the authentication token | | `image.repository` | `henrygd/beszel-agent` | Container image | -| `image.tag` | Chart AppVersion (0.20.0) | Image version | +| `image.tag` | Chart AppVersion (0.21.0) | Image version | | `hostNetwork` | `false` | Use host network for network monitoring | | `tolerations` | Allows all taints | Tolerations for running on tainted nodes | @@ -385,7 +385,7 @@ helm upgrade beszel-agent ./beszel-agent \ # Change image version helm upgrade beszel-agent ./beszel-agent \ - --set image.tag="0.20.0" + --set image.tag="0.21.0" ``` ### Restart All Agents @@ -522,7 +522,7 @@ kubectl get secret beszel-agent -o jsonpath='{.data.ssh-key}' | base64 -d ## Chart Information - **Chart Version**: 0.1.0 -- **App Version**: 0.20.0 +- **App Version**: 0.21.0 - **Kubernetes Version**: 1.19+ - **Maintainer**: cloudwithdan (nikoloskid@pm.me) diff --git a/supplemental/helm/beszel-hub/Chart.yaml b/supplemental/helm/beszel-hub/Chart.yaml index 6883cb7fe..70f519e12 100644 --- a/supplemental/helm/beszel-hub/Chart.yaml +++ b/supplemental/helm/beszel-hub/Chart.yaml @@ -2,9 +2,9 @@ apiVersion: v1 description: Installs beszel-hub in kubernetes home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub name: beszel-hub -appVersion: "0.20.0" +appVersion: "0.21.0" # Bump this version when publishing chart changes. -version: 0.1.7 +version: 0.1.8 sources: - https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub - https://www.beszel.dev/ diff --git a/supplemental/helm/beszel-hub/README.md b/supplemental/helm/beszel-hub/README.md index ecc682798..70537d06a 100644 --- a/supplemental/helm/beszel-hub/README.md +++ b/supplemental/helm/beszel-hub/README.md @@ -47,7 +47,7 @@ Key configuration options in `values.yaml`: |-----------|---------|-------------| | `replicaCount` | `1` | Number of Beszel Hub replicas | | `image.repository` | `henrygd/beszel` | Container image repository | -| `image.tag` | Chart AppVersion (0.20.0) | Container image tag | +| `image.tag` | Chart AppVersion (0.21.0) | Container image tag | | `image.pullPolicy` | `IfNotPresent` | Image pull policy | | `service.port` | `8090` | Service port | | `persistentVolumeClaim.enabled` | `true` | Enable persistent volume | @@ -169,7 +169,7 @@ tolerations: ```yaml replicaCount: 3 image: - tag: "0.20.0" + tag: "0.21.0" service: type: LoadBalancer ingress: @@ -330,7 +330,7 @@ By default, Beszel Hub uses a PersistentVolumeClaim for data storage. Ensure you ## Chart Information - **Chart Version**: 0.1.0 -- **App Version**: 0.20.0 +- **App Version**: 0.21.0 - **Kubernetes Version**: 1.19+ - **Maintainer**: cloudwithdan (nikoloskid@pm.me) From 7748651f5cb3ee0ccd3d3e6141f315ee5d20454e Mon Sep 17 00:00:00 2001 From: henrygd Date: Mon, 5 Oct 2026 10:53:02 -0400 Subject: [PATCH 02/10] ci: build each docker image once and push to both registries Previously the matrix had separate entries for Docker Hub and GHCR, so every image was built twice. Each entry now pushes to both registries from a single build. Docker Hub is skipped when DOCKERHUB_TOKEN is unset (e.g. forks). Also drops the explicit latest tag on the GHCR agent image so latest comes only from latest=auto, and lowers max-parallel to 3. --- .github/workflows/docker-images.yml | 167 +++++++--------------------- 1 file changed, 39 insertions(+), 128 deletions(-) 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 }} From dcae7682cddf5e52ba3bd7b3aebfa0f472d8c890 Mon Sep 17 00:00:00 2001 From: Julio Lins Date: Mon, 5 Oct 2026 07:54:21 -0700 Subject: [PATCH 03/10] fix(hub): add missing testing build tag to hub tests (#2525) --- internal/hub/api_systemd_test.go | 2 ++ internal/hub/api_test.go | 2 ++ internal/hub/network_monitors_test.go | 2 ++ 3 files changed, 6 insertions(+) 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_test.go b/internal/hub/network_monitors_test.go index 71dd4b82a..3c82bcb58 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 ( From 36c14e903d73b226a2df5bbf7ab4fbf1943c2906 Mon Sep 17 00:00:00 2001 From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:38:33 +0800 Subject: [PATCH 04/10] fix(agent): stop sending legacy hubs a duplicate stats payload (#2529) --- agent/server.go | 7 +- agent/server_test.go | 183 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) 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()) +} From 74c952b87907a36d84d404ad17a0695be4ac55df Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Mon, 5 Oct 2026 17:49:05 +0200 Subject: [PATCH 05/10] fix(agent): don't detect zypper-aptitude's apt-get wrapper as apt (#2523) --- agent/package_updates.go | 5 ++++- agent/package_updates_test.go | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/agent/package_updates.go b/agent/package_updates.go index da00419c5..9697f6392 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", checkDnf diff --git a/agent/package_updates_test.go b/agent/package_updates_test.go index bb49f39d3..68d67134b 100644 --- a/agent/package_updates_test.go +++ b/agent/package_updates_test.go @@ -245,6 +245,29 @@ exit 100`, }) } +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 From d89f844a2a0abc7474555f0635344245456f82d6 Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Mon, 5 Oct 2026 18:01:51 +0200 Subject: [PATCH 06/10] feat(monitors): add option to ignore TLS errors for HTTPS monitors (#2500) --- agent/network_monitor.go | 7 ++++- agent/network_monitor_probe.go | 7 ++++- agent/network_monitor_test.go | 22 +++++++++++-- internal/entities/monitor/monitor.go | 3 ++ internal/hub/network_monitors.go | 8 ++++- internal/hub/network_monitors_test.go | 30 +++++++++--------- .../hub/systems/network_monitor_sync_test.go | 17 ++++++++++ internal/hub/systems/system_manager.go | 2 +- ...0964708_network_monitor_skip_tls_verify.go | 24 ++++++++++++++ .../network-monitors-table/monitor-dialog.tsx | 31 +++++++++++++++++++ .../network-monitors-columns.tsx | 13 +++++++- internal/site/src/lib/use-network-monitors.ts | 2 +- internal/site/src/types.d.ts | 2 ++ 13 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 internal/migrations/1790964708_network_monitor_skip_tls_verify.go 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/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/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 3c82bcb58..28b7a0842 100644 --- a/internal/hub/network_monitors_test.go +++ b/internal/hub/network_monitors_test.go @@ -295,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") @@ -319,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/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 && (