|
|
|
|
| package agent
|
|
|
| import (
|
| "crypto/ed25519"
|
| "net/url"
|
| "os"
|
| "strings"
|
| "testing"
|
| "time"
|
|
|
| "github.com/henrygd/beszel"
|
|
|
| "github.com/henrygd/beszel/internal/common"
|
|
|
| "github.com/fxamacker/cbor/v2"
|
| "github.com/stretchr/testify/assert"
|
| "github.com/stretchr/testify/require"
|
| "golang.org/x/crypto/ssh"
|
| )
|
|
|
|
|
| func TestNewWebSocketClient(t *testing.T) {
|
| agent := createTestAgent(t)
|
|
|
| testCases := []struct {
|
| name string
|
| hubURL string
|
| token string
|
| expectError bool
|
| errorMsg string
|
| }{
|
| {
|
| name: "valid configuration",
|
| hubURL: "http://localhost:8080",
|
| token: "test-token-123",
|
| expectError: false,
|
| },
|
| {
|
| name: "valid https URL",
|
| hubURL: "https://hub.example.com",
|
| token: "secure-token",
|
| expectError: false,
|
| },
|
| {
|
| name: "missing hub URL",
|
| hubURL: "",
|
| token: "test-token",
|
| expectError: true,
|
| errorMsg: "HUB_URL environment variable not set",
|
| },
|
| {
|
| name: "invalid URL",
|
| hubURL: "ht\ttp://invalid",
|
| token: "test-token",
|
| expectError: true,
|
| errorMsg: "invalid hub URL",
|
| },
|
| {
|
| name: "missing token",
|
| hubURL: "http://localhost:8080",
|
| token: "",
|
| expectError: true,
|
| errorMsg: "must set TOKEN or TOKEN_FILE",
|
| },
|
| }
|
|
|
| for _, tc := range testCases {
|
| t.Run(tc.name, func(t *testing.T) {
|
|
|
| if tc.hubURL != "" {
|
| t.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
|
| }
|
| if tc.token != "" {
|
| t.Setenv("BESZEL_AGENT_TOKEN", tc.token)
|
| }
|
|
|
| client, err := newWebSocketClient(agent)
|
|
|
| if tc.expectError {
|
| assert.Error(t, err)
|
| if err != nil && tc.errorMsg != "" {
|
| assert.Contains(t, err.Error(), tc.errorMsg)
|
| }
|
| assert.Nil(t, client)
|
| } else {
|
| require.NoError(t, err)
|
| assert.NotNil(t, client)
|
| assert.Equal(t, agent, client.agent)
|
| assert.Equal(t, tc.token, client.token)
|
| assert.Equal(t, tc.hubURL, client.hubURL.String())
|
| assert.NotEmpty(t, client.fingerprint)
|
| assert.NotNil(t, client.hubRequest)
|
| }
|
| })
|
| }
|
| }
|
|
|
|
|
| func TestWebSocketClient_GetOptions(t *testing.T) {
|
| agent := createTestAgent(t)
|
|
|
| testCases := []struct {
|
| name string
|
| inputURL string
|
| expectedScheme string
|
| expectedPath string
|
| }{
|
| {
|
| name: "http to ws conversion",
|
| inputURL: "http://localhost:8080",
|
| expectedScheme: "ws",
|
| expectedPath: "/api/beszel/agent-connect",
|
| },
|
| {
|
| name: "https to wss conversion",
|
| inputURL: "https://hub.example.com",
|
| expectedScheme: "wss",
|
| expectedPath: "/api/beszel/agent-connect",
|
| },
|
| {
|
| name: "existing path preservation",
|
| inputURL: "http://localhost:8080/custom/path",
|
| expectedScheme: "ws",
|
| expectedPath: "/custom/path/api/beszel/agent-connect",
|
| },
|
| }
|
|
|
| for _, tc := range testCases {
|
| t.Run(tc.name, func(t *testing.T) {
|
|
|
| t.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
|
| t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
|
|
| client, err := newWebSocketClient(agent)
|
| require.NoError(t, err)
|
|
|
| options := client.getOptions()
|
|
|
|
|
| wsURL, err := url.Parse(options.Addr)
|
| require.NoError(t, err)
|
|
|
| assert.Equal(t, tc.expectedScheme, wsURL.Scheme)
|
| assert.Equal(t, tc.expectedPath, wsURL.Path)
|
|
|
|
|
| assert.Equal(t, "test-token", options.RequestHeader.Get("X-Token"))
|
| assert.Equal(t, beszel.Version, options.RequestHeader.Get("X-Beszel"))
|
| assert.Contains(t, options.RequestHeader.Get("User-Agent"), "Mozilla/5.0")
|
|
|
|
|
| options2 := client.getOptions()
|
| assert.Same(t, options, options2, "Options should be cached")
|
| })
|
| }
|
| }
|
|
|
|
|
| func TestWebSocketClient_VerifySignature(t *testing.T) {
|
| agent := createTestAgent(t)
|
|
|
|
|
| _, goodPrivKey, err := ed25519.GenerateKey(nil)
|
| require.NoError(t, err)
|
| goodPubKey, err := ssh.NewPublicKey(goodPrivKey.Public().(ed25519.PublicKey))
|
| require.NoError(t, err)
|
|
|
| _, badPrivKey, err := ed25519.GenerateKey(nil)
|
| require.NoError(t, err)
|
| badPubKey, err := ssh.NewPublicKey(badPrivKey.Public().(ed25519.PublicKey))
|
| require.NoError(t, err)
|
|
|
|
|
| t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
| t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
|
|
| client, err := newWebSocketClient(agent)
|
| require.NoError(t, err)
|
|
|
| testCases := []struct {
|
| name string
|
| keys []ssh.PublicKey
|
| token string
|
| signWith ed25519.PrivateKey
|
| expectError bool
|
| }{
|
| {
|
| name: "valid signature with correct key",
|
| keys: []ssh.PublicKey{goodPubKey},
|
| token: "test-token",
|
| signWith: goodPrivKey,
|
| expectError: false,
|
| },
|
| {
|
| name: "invalid signature with wrong key",
|
| keys: []ssh.PublicKey{goodPubKey},
|
| token: "test-token",
|
| signWith: badPrivKey,
|
| expectError: true,
|
| },
|
| {
|
| name: "valid signature with multiple keys",
|
| keys: []ssh.PublicKey{badPubKey, goodPubKey},
|
| token: "test-token",
|
| signWith: goodPrivKey,
|
| expectError: false,
|
| },
|
| {
|
| name: "no valid keys",
|
| keys: []ssh.PublicKey{badPubKey},
|
| token: "test-token",
|
| signWith: goodPrivKey,
|
| expectError: true,
|
| },
|
| }
|
|
|
| for _, tc := range testCases {
|
| t.Run(tc.name, func(t *testing.T) {
|
|
|
| agent.keys = tc.keys
|
| client.token = tc.token
|
|
|
|
|
| signature := ed25519.Sign(tc.signWith, []byte(tc.token))
|
|
|
| err := client.verifySignature(signature)
|
|
|
| if tc.expectError {
|
| assert.Error(t, err)
|
| assert.Contains(t, err.Error(), "invalid signature")
|
| } else {
|
| assert.NoError(t, err)
|
| }
|
| })
|
| }
|
| }
|
|
|
|
|
| func TestWebSocketClient_HandleHubRequest(t *testing.T) {
|
| agent := createTestAgent(t)
|
|
|
|
|
| t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
| t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
|
|
| client, err := newWebSocketClient(agent)
|
| require.NoError(t, err)
|
|
|
| testCases := []struct {
|
| name string
|
| action common.WebSocketAction
|
| hubVerified bool
|
| expectError bool
|
| errorMsg string
|
| }{
|
| {
|
| name: "CheckFingerprint without verification",
|
| action: common.CheckFingerprint,
|
| hubVerified: false,
|
| expectError: false,
|
| },
|
| {
|
| name: "GetData without verification",
|
| action: common.GetData,
|
| hubVerified: false,
|
| expectError: true,
|
| errorMsg: "hub not verified",
|
| },
|
| }
|
|
|
| for _, tc := range testCases {
|
| t.Run(tc.name, func(t *testing.T) {
|
| client.hubVerified = tc.hubVerified
|
|
|
|
|
| hubRequest := &common.HubRequest[cbor.RawMessage]{
|
| Action: tc.action,
|
| Data: cbor.RawMessage{},
|
| }
|
|
|
| err := client.handleHubRequest(hubRequest, nil)
|
|
|
| if tc.expectError {
|
| assert.Error(t, err)
|
| if tc.errorMsg != "" {
|
| assert.Contains(t, err.Error(), tc.errorMsg)
|
| }
|
| } else {
|
|
|
|
|
| if err != nil && tc.errorMsg != "" {
|
| assert.NotContains(t, err.Error(), tc.errorMsg)
|
| }
|
| }
|
| })
|
| }
|
| }
|
|
|
|
|
| func TestGetUserAgent(t *testing.T) {
|
|
|
| userAgents := make(map[string]bool)
|
|
|
| for range 20 {
|
| ua := getUserAgent()
|
| userAgents[ua] = true
|
|
|
|
|
| assert.Contains(t, ua, "Mozilla/5.0")
|
| assert.Contains(t, ua, "AppleWebKit/537.36")
|
| assert.Contains(t, ua, "Chrome/124.0.0.0")
|
| assert.Contains(t, ua, "Safari/537.36")
|
|
|
|
|
| isWindows := strings.Contains(ua, "Windows NT 11.0")
|
| isMac := strings.Contains(ua, "Macintosh; Intel Mac OS X 14_0_0")
|
| assert.True(t, isWindows || isMac, "User agent should contain either Windows or Mac identifier")
|
| }
|
|
|
|
|
|
|
| if len(userAgents) == 1 {
|
| t.Log("Note: Only one user agent variant was generated in this test run")
|
| }
|
| }
|
|
|
|
|
| func TestWebSocketClient_Close(t *testing.T) {
|
| agent := createTestAgent(t)
|
|
|
| t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
| t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
|
|
| client, err := newWebSocketClient(agent)
|
| require.NoError(t, err)
|
|
|
|
|
| assert.NotPanics(t, func() {
|
| client.Close()
|
| })
|
| }
|
|
|
|
|
| func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
| agent := createTestAgent(t)
|
|
|
| t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
| t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
|
|
| client, err := newWebSocketClient(agent)
|
| require.NoError(t, err)
|
|
|
|
|
| client.lastConnectAttempt = time.Now()
|
|
|
|
|
|
|
| err = client.Connect()
|
| assert.Error(t, err, "Connection should fail but not hang")
|
| }
|
|
|
|
|
| func TestGetToken(t *testing.T) {
|
| t.Run("token from TOKEN environment variable", func(t *testing.T) {
|
|
|
| expectedToken := "test-token-from-env"
|
| t.Setenv("TOKEN", expectedToken)
|
|
|
| token, err := getToken()
|
| assert.NoError(t, err)
|
| assert.Equal(t, expectedToken, token)
|
| })
|
|
|
| t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) {
|
|
|
| expectedToken := "test-token-from-beszel-env"
|
| t.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
|
|
|
| token, err := getToken()
|
| assert.NoError(t, err)
|
| assert.Equal(t, expectedToken, token)
|
| })
|
|
|
| t.Run("token from TOKEN_FILE", func(t *testing.T) {
|
|
|
| expectedToken := "test-token-from-file"
|
| tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
| require.NoError(t, err)
|
| defer os.Remove(tokenFile.Name())
|
|
|
| _, err = tokenFile.WriteString(expectedToken)
|
| require.NoError(t, err)
|
| tokenFile.Close()
|
|
|
|
|
| t.Setenv("TOKEN_FILE", tokenFile.Name())
|
|
|
| token, err := getToken()
|
| assert.NoError(t, err)
|
| assert.Equal(t, expectedToken, token)
|
| })
|
|
|
| t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
|
|
| expectedToken := "test-token-from-beszel-file"
|
| tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
| require.NoError(t, err)
|
| defer os.Remove(tokenFile.Name())
|
|
|
| _, err = tokenFile.WriteString(expectedToken)
|
| require.NoError(t, err)
|
| tokenFile.Close()
|
|
|
|
|
| t.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
|
|
|
| token, err := getToken()
|
| assert.NoError(t, err)
|
| assert.Equal(t, expectedToken, token)
|
| })
|
|
|
| t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) {
|
|
|
| fileToken := "token-from-file"
|
| tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
| require.NoError(t, err)
|
| defer os.Remove(tokenFile.Name())
|
|
|
| _, err = tokenFile.WriteString(fileToken)
|
| require.NoError(t, err)
|
| tokenFile.Close()
|
|
|
|
|
| envToken := "token-from-env"
|
| t.Setenv("TOKEN", envToken)
|
| t.Setenv("TOKEN_FILE", tokenFile.Name())
|
|
|
| token, err := getToken()
|
| assert.NoError(t, err)
|
| assert.Equal(t, envToken, token, "TOKEN should take precedence over TOKEN_FILE")
|
| })
|
|
|
| t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) {
|
| t.Setenv("BESZEL_AGENT_TOKEN", "")
|
| t.Setenv("TOKEN", "")
|
| t.Setenv("BESZEL_AGENT_TOKEN_FILE", "")
|
| t.Setenv("TOKEN_FILE", "")
|
|
|
| token, err := getToken()
|
| assert.Error(t, err)
|
| assert.Equal(t, "", token)
|
| assert.Contains(t, err.Error(), "must set TOKEN or TOKEN_FILE")
|
| })
|
|
|
| t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
|
|
|
| t.Setenv("TOKEN_FILE", "/non/existent/file.txt")
|
|
|
| token, err := getToken()
|
| assert.Error(t, err)
|
| assert.Equal(t, "", token)
|
| assert.Contains(t, err.Error(), "no such file or directory")
|
| })
|
|
|
| t.Run("handles empty token file", func(t *testing.T) {
|
|
|
| tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
| require.NoError(t, err)
|
| defer os.Remove(tokenFile.Name())
|
| tokenFile.Close()
|
|
|
|
|
| t.Setenv("TOKEN_FILE", tokenFile.Name())
|
|
|
| token, err := getToken()
|
| assert.NoError(t, err)
|
| assert.Equal(t, "", token, "Empty file should return empty string")
|
| })
|
|
|
| t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
|
| tokenWithWhitespace := " test-token-with-whitespace \n\t"
|
| expectedToken := "test-token-with-whitespace"
|
| tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
| require.NoError(t, err)
|
| defer os.Remove(tokenFile.Name())
|
|
|
| _, err = tokenFile.WriteString(tokenWithWhitespace)
|
| require.NoError(t, err)
|
| tokenFile.Close()
|
|
|
| t.Setenv("TOKEN_FILE", tokenFile.Name())
|
|
|
| token, err := getToken()
|
| assert.NoError(t, err)
|
| assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
|
| })
|
| }
|
|
|