| package agent
|
|
|
| import (
|
| "crypto/tls"
|
| "errors"
|
| "fmt"
|
| "log/slog"
|
| "net"
|
| "net/http"
|
| "net/url"
|
| "os"
|
| "path"
|
| "strings"
|
| "time"
|
|
|
| "github.com/henrygd/beszel"
|
| "github.com/henrygd/beszel/agent/utils"
|
| "github.com/henrygd/beszel/internal/common"
|
|
|
| "github.com/fxamacker/cbor/v2"
|
| "github.com/lxzan/gws"
|
| "golang.org/x/crypto/ssh"
|
| "golang.org/x/net/proxy"
|
| )
|
|
|
| const (
|
| wsDeadline = 70 * time.Second
|
| )
|
|
|
|
|
|
|
| type WebSocketClient struct {
|
| gws.BuiltinEventHandler
|
| options *gws.ClientOption
|
| agent *Agent
|
| Conn *gws.Conn
|
| hubURL *url.URL
|
| token string
|
| fingerprint string
|
| hubRequest *common.HubRequest[cbor.RawMessage]
|
| lastConnectAttempt time.Time
|
| hubVerified bool
|
| }
|
|
|
|
|
|
|
| func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
| hubURLStr, exists := utils.GetEnv("HUB_URL")
|
| if !exists {
|
| return nil, errors.New("HUB_URL environment variable not set")
|
| }
|
|
|
| client = &WebSocketClient{}
|
|
|
| client.hubURL, err = url.Parse(hubURLStr)
|
| if err != nil {
|
| return nil, errors.New("invalid hub URL")
|
| }
|
|
|
| client.token, err = getToken()
|
| if err != nil {
|
| return nil, err
|
| }
|
|
|
| client.agent = agent
|
| client.hubRequest = &common.HubRequest[cbor.RawMessage]{}
|
| client.fingerprint = agent.getFingerprint()
|
|
|
| return client, nil
|
| }
|
|
|
|
|
|
|
|
|
| func getToken() (string, error) {
|
|
|
| token, _ := utils.GetEnv("TOKEN")
|
| if token != "" {
|
| return token, nil
|
| }
|
|
|
| tokenFile, _ := utils.GetEnv("TOKEN_FILE")
|
| if tokenFile == "" {
|
| return "", errors.New("must set TOKEN or TOKEN_FILE")
|
| }
|
| tokenBytes, err := os.ReadFile(tokenFile)
|
| if err != nil {
|
| return "", err
|
| }
|
| return strings.TrimSpace(string(tokenBytes)), nil
|
| }
|
|
|
|
|
|
|
| func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
| if client.options != nil {
|
| return client.options
|
| }
|
|
|
|
|
| if client.hubURL.Scheme == "https" {
|
| client.hubURL.Scheme = "wss"
|
| } else {
|
| client.hubURL.Scheme = "ws"
|
| }
|
| client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
|
|
|
|
|
| if val := os.Getenv("BESZEL_AGENT_ALL_PROXY"); val != "" {
|
| os.Setenv("ALL_PROXY", val)
|
| }
|
|
|
| client.options = &gws.ClientOption{
|
| Addr: client.hubURL.String(),
|
| TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
| RequestHeader: http.Header{
|
| "User-Agent": []string{getUserAgent()},
|
| "X-Token": []string{client.token},
|
| "X-Beszel": []string{beszel.Version},
|
| },
|
| NewDialer: func() (gws.Dialer, error) {
|
| return proxy.FromEnvironment(), nil
|
| },
|
| }
|
| return client.options
|
| }
|
|
|
|
|
|
|
| func (client *WebSocketClient) Connect() (err error) {
|
| client.lastConnectAttempt = time.Now()
|
|
|
|
|
| client.Close()
|
|
|
| client.Conn, _, err = gws.NewClient(client, client.getOptions())
|
| if err != nil {
|
| return err
|
| }
|
|
|
| go client.Conn.ReadLoop()
|
|
|
| return nil
|
| }
|
|
|
|
|
|
|
| func (client *WebSocketClient) OnOpen(conn *gws.Conn) {
|
| conn.SetDeadline(time.Now().Add(wsDeadline))
|
| }
|
|
|
|
|
|
|
| func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) {
|
| if err != nil {
|
| slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: "))
|
| }
|
| client.agent.connectionManager.eventChan <- WebSocketDisconnect
|
| }
|
|
|
|
|
|
|
| func (client *WebSocketClient) OnMessage(conn *gws.Conn, message *gws.Message) {
|
| defer message.Close()
|
| conn.SetDeadline(time.Now().Add(wsDeadline))
|
|
|
| if message.Opcode != gws.OpcodeBinary {
|
| return
|
| }
|
|
|
| var HubRequest common.HubRequest[cbor.RawMessage]
|
|
|
| err := cbor.Unmarshal(message.Data.Bytes(), &HubRequest)
|
| if err != nil {
|
| slog.Error("Error parsing message", "err", err)
|
| return
|
| }
|
|
|
| if err := client.handleHubRequest(&HubRequest, HubRequest.Id); err != nil {
|
| slog.Error("Error handling message", "err", err)
|
| }
|
| }
|
|
|
|
|
|
|
| func (client *WebSocketClient) OnPing(conn *gws.Conn, message []byte) {
|
| conn.SetDeadline(time.Now().Add(wsDeadline))
|
| conn.WritePong(message)
|
| }
|
|
|
|
|
| func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage], requestID *uint32) (err error) {
|
| var authRequest common.FingerprintRequest
|
| if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil {
|
| return err
|
| }
|
|
|
| if err := client.verifySignature(authRequest.Signature); err != nil {
|
| return err
|
| }
|
|
|
| client.hubVerified = true
|
| client.agent.connectionManager.eventChan <- WebSocketConnect
|
|
|
| response := &common.FingerprintResponse{
|
| Fingerprint: client.fingerprint,
|
| }
|
|
|
| if authRequest.NeedSysInfo {
|
| response.Name, _ = utils.GetEnv("SYSTEM_NAME")
|
| response.Hostname = client.agent.systemDetails.Hostname
|
| serverAddr := client.agent.connectionManager.serverOptions.Addr
|
| _, response.Port, _ = net.SplitHostPort(serverAddr)
|
| }
|
|
|
| return client.sendResponse(response, requestID)
|
| }
|
|
|
|
|
| func (client *WebSocketClient) verifySignature(signature []byte) (err error) {
|
| for _, pubKey := range client.agent.keys {
|
| sig := ssh.Signature{
|
| Format: pubKey.Type(),
|
| Blob: signature,
|
| }
|
| if err = pubKey.Verify([]byte(client.token), &sig); err == nil {
|
| return nil
|
| }
|
| }
|
| return errors.New("invalid signature - check KEY value")
|
| }
|
|
|
|
|
|
|
| func (client *WebSocketClient) Close() {
|
| if client.Conn != nil {
|
| _ = client.Conn.WriteClose(1000, nil)
|
| }
|
| }
|
|
|
|
|
| func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage], requestID *uint32) error {
|
| ctx := &HandlerContext{
|
| Client: client,
|
| Agent: client.agent,
|
| Request: msg,
|
| RequestID: requestID,
|
| HubVerified: client.hubVerified,
|
| SendResponse: client.sendResponse,
|
| }
|
| return client.agent.handlerRegistry.Handle(ctx)
|
| }
|
|
|
|
|
| func (client *WebSocketClient) sendMessage(data any) error {
|
| bytes, err := cbor.Marshal(data)
|
| if err != nil {
|
| return err
|
| }
|
| err = client.Conn.WriteMessage(gws.OpcodeBinary, bytes)
|
| if err != nil {
|
|
|
|
|
| client.Close()
|
| }
|
| return err
|
| }
|
|
|
|
|
|
|
|
|
| func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
| if requestID != nil {
|
| response := newAgentResponse(data, requestID)
|
| return client.sendMessage(response)
|
| }
|
|
|
| return client.sendMessage(data)
|
| }
|
|
|
|
|
|
|
| func getUserAgent() string {
|
| const (
|
| uaBase = "Mozilla/5.0 (%s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
| uaWindows = "Windows NT 11.0; Win64; x64"
|
| uaMac = "Macintosh; Intel Mac OS X 14_0_0"
|
| )
|
| if time.Now().UnixNano()%2 == 0 {
|
| return fmt.Sprintf(uaBase, uaWindows)
|
| }
|
| return fmt.Sprintf(uaBase, uaMac)
|
| }
|
|
|