mirror of
https://github.com/fankes/beszel.git
synced 2025-10-18 17:29:28 +08:00
rename /src to /internal (sorry i'll fix the prs)
This commit is contained in:
321
internal/hub/agent_connect.go
Normal file
321
internal/hub/agent_connect.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/src/common"
|
||||
"github.com/henrygd/beszel/src/hub/expirymap"
|
||||
"github.com/henrygd/beszel/src/hub/ws"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/lxzan/gws"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// agentConnectRequest holds information related to an agent's connection attempt.
|
||||
type agentConnectRequest struct {
|
||||
hub *Hub
|
||||
req *http.Request
|
||||
res http.ResponseWriter
|
||||
token string
|
||||
agentSemVer semver.Version
|
||||
// isUniversalToken is true if the token is a universal token.
|
||||
isUniversalToken bool
|
||||
// userId is the user ID associated with the universal token.
|
||||
userId string
|
||||
}
|
||||
|
||||
// universalTokenMap stores active universal tokens and their associated user IDs.
|
||||
var universalTokenMap tokenMap
|
||||
|
||||
type tokenMap struct {
|
||||
store *expirymap.ExpiryMap[string]
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// getMap returns the expirymap, creating it if necessary.
|
||||
func (tm *tokenMap) GetMap() *expirymap.ExpiryMap[string] {
|
||||
tm.once.Do(func() {
|
||||
tm.store = expirymap.New[string](time.Hour)
|
||||
})
|
||||
return tm.store
|
||||
}
|
||||
|
||||
// handleAgentConnect is the HTTP handler for an agent's connection request.
|
||||
func (h *Hub) handleAgentConnect(e *core.RequestEvent) error {
|
||||
agentRequest := agentConnectRequest{req: e.Request, res: e.Response, hub: h}
|
||||
_ = agentRequest.agentConnect()
|
||||
return nil
|
||||
}
|
||||
|
||||
// agentConnect validates agent credentials and upgrades the connection to a WebSocket.
|
||||
func (acr *agentConnectRequest) agentConnect() (err error) {
|
||||
var agentVersion string
|
||||
|
||||
acr.token, agentVersion, err = acr.validateAgentHeaders(acr.req.Header)
|
||||
if err != nil {
|
||||
return acr.sendResponseError(acr.res, http.StatusBadRequest, "")
|
||||
}
|
||||
|
||||
// Check if token is an active universal token
|
||||
acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token)
|
||||
|
||||
// Find matching fingerprint records for this token
|
||||
fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub)
|
||||
if len(fpRecords) == 0 && !acr.isUniversalToken {
|
||||
// Invalid token - no records found and not a universal token
|
||||
return acr.sendResponseError(acr.res, http.StatusUnauthorized, "Invalid token")
|
||||
}
|
||||
|
||||
// Validate agent version
|
||||
acr.agentSemVer, err = semver.Parse(agentVersion)
|
||||
if err != nil {
|
||||
return acr.sendResponseError(acr.res, http.StatusUnauthorized, "Invalid agent version")
|
||||
}
|
||||
|
||||
// Upgrade connection to WebSocket
|
||||
conn, err := ws.GetUpgrader().Upgrade(acr.res, acr.req)
|
||||
if err != nil {
|
||||
return acr.sendResponseError(acr.res, http.StatusInternalServerError, "WebSocket upgrade failed")
|
||||
}
|
||||
|
||||
go acr.verifyWsConn(conn, fpRecords)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyWsConn verifies the WebSocket connection using the agent's fingerprint and
|
||||
// SSH key signature, then adds the system to the system manager.
|
||||
func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.FingerprintRecord) (err error) {
|
||||
wsConn := ws.NewWsConnection(conn)
|
||||
|
||||
// must set wsConn in connection store before the read loop
|
||||
conn.Session().Store("wsConn", wsConn)
|
||||
|
||||
// make sure connection is closed if there is an error
|
||||
defer func() {
|
||||
if err != nil {
|
||||
wsConn.Close([]byte(err.Error()))
|
||||
}
|
||||
}()
|
||||
|
||||
go conn.ReadLoop()
|
||||
|
||||
signer, err := acr.hub.GetSSHKey("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentFingerprint, err := wsConn.GetFingerprint(acr.token, signer, acr.isUniversalToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Find or create the appropriate system for this token and fingerprint
|
||||
fpRecord, err := acr.findOrCreateSystemForToken(fpRecords, agentFingerprint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return acr.hub.sm.AddWebSocketSystem(fpRecord.SystemId, acr.agentSemVer, wsConn)
|
||||
}
|
||||
|
||||
// validateAgentHeaders extracts and validates the token and agent version from HTTP headers.
|
||||
func (acr *agentConnectRequest) validateAgentHeaders(headers http.Header) (string, string, error) {
|
||||
token := headers.Get("X-Token")
|
||||
agentVersion := headers.Get("X-Beszel")
|
||||
|
||||
if agentVersion == "" || token == "" || len(token) > 64 {
|
||||
return "", "", errors.New("")
|
||||
}
|
||||
return token, agentVersion, nil
|
||||
}
|
||||
|
||||
// sendResponseError writes an HTTP error response.
|
||||
func (acr *agentConnectRequest) sendResponseError(res http.ResponseWriter, code int, message string) error {
|
||||
res.WriteHeader(code)
|
||||
if message != "" {
|
||||
res.Write([]byte(message))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getFingerprintRecordsByToken retrieves all fingerprint records associated with a given token.
|
||||
func getFingerprintRecordsByToken(token string, h *Hub) []ws.FingerprintRecord {
|
||||
var records []ws.FingerprintRecord
|
||||
// All will populate empty slice even on error
|
||||
_ = h.DB().NewQuery("SELECT id, system, fingerprint, token FROM fingerprints WHERE token = {:token}").
|
||||
Bind(dbx.Params{
|
||||
"token": token,
|
||||
}).
|
||||
All(&records)
|
||||
return records
|
||||
}
|
||||
|
||||
// findOrCreateSystemForToken finds an existing system matching the token and fingerprint,
|
||||
// or creates a new one for a universal token.
|
||||
func (acr *agentConnectRequest) findOrCreateSystemForToken(fpRecords []ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
|
||||
// No records - only valid for active universal tokens
|
||||
if len(fpRecords) == 0 {
|
||||
return acr.handleNoRecords(agentFingerprint)
|
||||
}
|
||||
|
||||
// Single record - handle as regular token
|
||||
if len(fpRecords) == 1 && !acr.isUniversalToken {
|
||||
return acr.handleSingleRecord(fpRecords[0], agentFingerprint)
|
||||
}
|
||||
|
||||
// Multiple records or universal token - look for matching fingerprint
|
||||
return acr.handleMultipleRecordsOrUniversalToken(fpRecords, agentFingerprint)
|
||||
}
|
||||
|
||||
// handleNoRecords handles the case where no fingerprint records are found for a token.
|
||||
// A new system is created if the token is a valid universal token.
|
||||
func (acr *agentConnectRequest) handleNoRecords(agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
|
||||
var fpRecord ws.FingerprintRecord
|
||||
|
||||
if !acr.isUniversalToken || acr.userId == "" {
|
||||
return fpRecord, errors.New("no matching fingerprints")
|
||||
}
|
||||
|
||||
return acr.createNewSystemForUniversalToken(agentFingerprint)
|
||||
}
|
||||
|
||||
// handleSingleRecord handles the case with a single fingerprint record. It validates
|
||||
// the agent's fingerprint against the stored one, or sets it on first connect.
|
||||
func (acr *agentConnectRequest) handleSingleRecord(fpRecord ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
|
||||
// If no current fingerprint, update with new fingerprint (first time connecting)
|
||||
if fpRecord.Fingerprint == "" {
|
||||
if err := acr.hub.SetFingerprint(&fpRecord, agentFingerprint.Fingerprint); err != nil {
|
||||
return fpRecord, err
|
||||
}
|
||||
// Update the record with the fingerprint that was set
|
||||
fpRecord.Fingerprint = agentFingerprint.Fingerprint
|
||||
return fpRecord, nil
|
||||
}
|
||||
|
||||
// Abort if fingerprint exists but doesn't match (different machine)
|
||||
if fpRecord.Fingerprint != agentFingerprint.Fingerprint {
|
||||
return fpRecord, errors.New("fingerprint mismatch")
|
||||
}
|
||||
|
||||
return fpRecord, nil
|
||||
}
|
||||
|
||||
// handleMultipleRecordsOrUniversalToken finds a matching fingerprint from multiple records.
|
||||
// If no match is found and the token is a universal token, a new system is created.
|
||||
func (acr *agentConnectRequest) handleMultipleRecordsOrUniversalToken(fpRecords []ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
|
||||
// Return existing record with matching fingerprint if found
|
||||
for i := range fpRecords {
|
||||
if fpRecords[i].Fingerprint == agentFingerprint.Fingerprint {
|
||||
return fpRecords[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
// No matching fingerprint record found, but it's
|
||||
// an active universal token so create a new system
|
||||
if acr.isUniversalToken {
|
||||
return acr.createNewSystemForUniversalToken(agentFingerprint)
|
||||
}
|
||||
|
||||
return ws.FingerprintRecord{}, errors.New("fingerprint mismatch")
|
||||
}
|
||||
|
||||
// createNewSystemForUniversalToken creates a new system and fingerprint record for a universal token.
|
||||
func (acr *agentConnectRequest) createNewSystemForUniversalToken(agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
|
||||
var fpRecord ws.FingerprintRecord
|
||||
if !acr.isUniversalToken || acr.userId == "" {
|
||||
return fpRecord, errors.New("invalid token")
|
||||
}
|
||||
|
||||
fpRecord.Token = acr.token
|
||||
|
||||
systemId, err := acr.createSystem(agentFingerprint)
|
||||
if err != nil {
|
||||
return fpRecord, err
|
||||
}
|
||||
fpRecord.SystemId = systemId
|
||||
|
||||
// Set the fingerprint for the new system
|
||||
if err := acr.hub.SetFingerprint(&fpRecord, agentFingerprint.Fingerprint); err != nil {
|
||||
return fpRecord, err
|
||||
}
|
||||
|
||||
// Update the record with the fingerprint that was set
|
||||
fpRecord.Fingerprint = agentFingerprint.Fingerprint
|
||||
|
||||
return fpRecord, nil
|
||||
}
|
||||
|
||||
// createSystem creates a new system record in the database using details from the agent.
|
||||
func (acr *agentConnectRequest) createSystem(agentFingerprint common.FingerprintResponse) (recordId string, err error) {
|
||||
systemsCollection, err := acr.hub.FindCachedCollectionByNameOrId("systems")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
remoteAddr := getRealIP(acr.req)
|
||||
// separate port from address
|
||||
if agentFingerprint.Hostname == "" {
|
||||
agentFingerprint.Hostname = remoteAddr
|
||||
}
|
||||
if agentFingerprint.Port == "" {
|
||||
agentFingerprint.Port = "45876"
|
||||
}
|
||||
// create new record
|
||||
systemRecord := core.NewRecord(systemsCollection)
|
||||
systemRecord.Set("name", agentFingerprint.Hostname)
|
||||
systemRecord.Set("host", remoteAddr)
|
||||
systemRecord.Set("port", agentFingerprint.Port)
|
||||
systemRecord.Set("users", []string{acr.userId})
|
||||
|
||||
return systemRecord.Id, acr.hub.Save(systemRecord)
|
||||
}
|
||||
|
||||
// SetFingerprint creates or updates a fingerprint record in the database.
|
||||
func (h *Hub) SetFingerprint(fpRecord *ws.FingerprintRecord, fingerprint string) (err error) {
|
||||
// // can't use raw query here because it doesn't trigger SSE
|
||||
var record *core.Record
|
||||
switch fpRecord.Id {
|
||||
case "":
|
||||
// create new record for universal token
|
||||
collection, _ := h.FindCachedCollectionByNameOrId("fingerprints")
|
||||
record = core.NewRecord(collection)
|
||||
record.Set("system", fpRecord.SystemId)
|
||||
default:
|
||||
record, err = h.FindRecordById("fingerprints", fpRecord.Id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record.Set("token", fpRecord.Token)
|
||||
record.Set("fingerprint", fingerprint)
|
||||
return h.SaveNoValidate(record)
|
||||
}
|
||||
|
||||
// getRealIP extracts the client's real IP address from request headers,
|
||||
// checking common proxy headers before falling back to the remote address.
|
||||
func getRealIP(r *http.Request) string {
|
||||
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||
// X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2"
|
||||
// Take the first one
|
||||
ips := strings.Split(ip, ",")
|
||||
if len(ips) > 0 {
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
}
|
||||
// Fallback to RemoteAddr
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
1701
internal/hub/agent_connect_test.go
Normal file
1701
internal/hub/agent_connect_test.go
Normal file
File diff suppressed because it is too large
Load Diff
290
internal/hub/config/config.go
Normal file
290
internal/hub/config/config.go
Normal file
@@ -0,0 +1,290 @@
|
||||
// Package config provides functions for syncing systems with the config.yml file
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/henrygd/beszel/src/entities/system"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/spf13/cast"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
Systems []systemConfig `yaml:"systems"`
|
||||
}
|
||||
|
||||
type systemConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Host string `yaml:"host"`
|
||||
Port uint16 `yaml:"port,omitempty"`
|
||||
Token string `yaml:"token,omitempty"`
|
||||
Users []string `yaml:"users"`
|
||||
}
|
||||
|
||||
// Syncs systems with the config.yml file
|
||||
func SyncSystems(e *core.ServeEvent) error {
|
||||
h := e.App
|
||||
configPath := filepath.Join(h.DataDir(), "config.yml")
|
||||
configData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var config config
|
||||
err = yaml.Unmarshal(configData, &config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse config.yml: %v", err)
|
||||
}
|
||||
|
||||
if len(config.Systems) == 0 {
|
||||
log.Println("No systems defined in config.yml.")
|
||||
return nil
|
||||
}
|
||||
|
||||
var firstUser *core.Record
|
||||
|
||||
// Create a map of email to user ID
|
||||
userEmailToID := make(map[string]string)
|
||||
users, err := h.FindAllRecords("users", dbx.NewExp("id != ''"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(users) > 0 {
|
||||
firstUser = users[0]
|
||||
for _, user := range users {
|
||||
userEmailToID[user.GetString("email")] = user.Id
|
||||
}
|
||||
}
|
||||
|
||||
// add default settings for systems if not defined in config
|
||||
for i := range config.Systems {
|
||||
system := &config.Systems[i]
|
||||
if system.Port == 0 {
|
||||
system.Port = 45876
|
||||
}
|
||||
if len(users) > 0 && len(system.Users) == 0 {
|
||||
// default to first user if none are defined
|
||||
system.Users = []string{firstUser.Id}
|
||||
} else {
|
||||
// Convert email addresses to user IDs
|
||||
userIDs := make([]string, 0, len(system.Users))
|
||||
for _, email := range system.Users {
|
||||
if id, ok := userEmailToID[email]; ok {
|
||||
userIDs = append(userIDs, id)
|
||||
} else {
|
||||
log.Printf("User %s not found", email)
|
||||
}
|
||||
}
|
||||
system.Users = userIDs
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing systems
|
||||
existingSystems, err := h.FindAllRecords("systems", dbx.NewExp("id != ''"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a map of existing systems
|
||||
existingSystemsMap := make(map[string]*core.Record)
|
||||
for _, system := range existingSystems {
|
||||
key := system.GetString("name") + system.GetString("host") + system.GetString("port")
|
||||
existingSystemsMap[key] = system
|
||||
}
|
||||
|
||||
// Process systems from config
|
||||
for _, sysConfig := range config.Systems {
|
||||
key := sysConfig.Name + sysConfig.Host + cast.ToString(sysConfig.Port)
|
||||
if existingSystem, ok := existingSystemsMap[key]; ok {
|
||||
// Update existing system
|
||||
existingSystem.Set("name", sysConfig.Name)
|
||||
existingSystem.Set("users", sysConfig.Users)
|
||||
existingSystem.Set("port", sysConfig.Port)
|
||||
if err := h.Save(existingSystem); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only update token if one is specified in config, otherwise preserve existing token
|
||||
if sysConfig.Token != "" {
|
||||
if err := updateFingerprintToken(h, existingSystem.Id, sysConfig.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
delete(existingSystemsMap, key)
|
||||
} else {
|
||||
// Create new system
|
||||
systemsCollection, err := h.FindCollectionByNameOrId("systems")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find systems collection: %v", err)
|
||||
}
|
||||
newSystem := core.NewRecord(systemsCollection)
|
||||
newSystem.Set("name", sysConfig.Name)
|
||||
newSystem.Set("host", sysConfig.Host)
|
||||
newSystem.Set("port", sysConfig.Port)
|
||||
newSystem.Set("users", sysConfig.Users)
|
||||
newSystem.Set("info", system.Info{})
|
||||
newSystem.Set("status", "pending")
|
||||
if err := h.Save(newSystem); err != nil {
|
||||
return fmt.Errorf("failed to create new system: %v", err)
|
||||
}
|
||||
|
||||
// For new systems, generate token if not provided
|
||||
token := sysConfig.Token
|
||||
if token == "" {
|
||||
token = uuid.New().String()
|
||||
}
|
||||
|
||||
// Create fingerprint record for new system
|
||||
if err := createFingerprintRecord(h, newSystem.Id, token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete systems not in config (and their fingerprint records will cascade delete)
|
||||
for _, system := range existingSystemsMap {
|
||||
if err := h.Delete(system); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Systems synced with config.yml")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generates content for the config.yml file as a YAML string
|
||||
func generateYAML(h core.App) (string, error) {
|
||||
// Fetch all systems from the database
|
||||
systems, err := h.FindRecordsByFilter("systems", "id != ''", "name", -1, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create a Config struct to hold the data
|
||||
config := config{
|
||||
Systems: make([]systemConfig, 0, len(systems)),
|
||||
}
|
||||
|
||||
// Fetch all users at once
|
||||
allUserIDs := make([]string, 0)
|
||||
for _, system := range systems {
|
||||
allUserIDs = append(allUserIDs, system.GetStringSlice("users")...)
|
||||
}
|
||||
userEmailMap, err := getUserEmailMap(h, allUserIDs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Fetch all fingerprint records to get tokens
|
||||
type fingerprintData struct {
|
||||
ID string `db:"id"`
|
||||
System string `db:"system"`
|
||||
Token string `db:"token"`
|
||||
}
|
||||
var fingerprints []fingerprintData
|
||||
err = h.DB().NewQuery("SELECT id, system, token FROM fingerprints").All(&fingerprints)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create a map of system ID to token
|
||||
systemTokenMap := make(map[string]string)
|
||||
for _, fingerprint := range fingerprints {
|
||||
systemTokenMap[fingerprint.System] = fingerprint.Token
|
||||
}
|
||||
|
||||
// Populate the Config struct with system data
|
||||
for _, system := range systems {
|
||||
userIDs := system.GetStringSlice("users")
|
||||
userEmails := make([]string, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if email, ok := userEmailMap[userID]; ok {
|
||||
userEmails = append(userEmails, email)
|
||||
}
|
||||
}
|
||||
|
||||
sysConfig := systemConfig{
|
||||
Name: system.GetString("name"),
|
||||
Host: system.GetString("host"),
|
||||
Port: cast.ToUint16(system.Get("port")),
|
||||
Users: userEmails,
|
||||
Token: systemTokenMap[system.Id],
|
||||
}
|
||||
config.Systems = append(config.Systems, sysConfig)
|
||||
}
|
||||
|
||||
// Marshal the Config struct to YAML
|
||||
yamlData, err := yaml.Marshal(&config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Add a header to the YAML
|
||||
yamlData = append([]byte("# Values for port, users, and token are optional.\n# Defaults are port 45876, the first created user, and a generated UUID token.\n\n"), yamlData...)
|
||||
|
||||
return string(yamlData), nil
|
||||
}
|
||||
|
||||
// New helper function to get a map of user IDs to emails
|
||||
func getUserEmailMap(h core.App, userIDs []string) (map[string]string, error) {
|
||||
users, err := h.FindRecordsByIds("users", userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userEmailMap := make(map[string]string, len(users))
|
||||
for _, user := range users {
|
||||
userEmailMap[user.Id] = user.GetString("email")
|
||||
}
|
||||
|
||||
return userEmailMap, nil
|
||||
}
|
||||
|
||||
// Helper function to update or create fingerprint token for an existing system
|
||||
func updateFingerprintToken(app core.App, systemID, token string) error {
|
||||
// Try to find existing fingerprint record
|
||||
fingerprint, err := app.FindFirstRecordByFilter("fingerprints", "system = {:system}", dbx.Params{"system": systemID})
|
||||
if err != nil {
|
||||
// If no fingerprint record exists, create one
|
||||
return createFingerprintRecord(app, systemID, token)
|
||||
}
|
||||
|
||||
// Update existing fingerprint record with new token (keep existing fingerprint)
|
||||
fingerprint.Set("token", token)
|
||||
return app.Save(fingerprint)
|
||||
}
|
||||
|
||||
// Helper function to create a new fingerprint record for a system
|
||||
func createFingerprintRecord(app core.App, systemID, token string) error {
|
||||
fingerprintsCollection, err := app.FindCollectionByNameOrId("fingerprints")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find fingerprints collection: %v", err)
|
||||
}
|
||||
|
||||
newFingerprint := core.NewRecord(fingerprintsCollection)
|
||||
newFingerprint.Set("system", systemID)
|
||||
newFingerprint.Set("token", token)
|
||||
newFingerprint.Set("fingerprint", "") // Empty fingerprint, will be set on first connection
|
||||
|
||||
return app.Save(newFingerprint)
|
||||
}
|
||||
|
||||
// Returns the current config.yml file as a JSON object
|
||||
func GetYamlConfig(e *core.RequestEvent) error {
|
||||
if e.Auth.GetString("role") != "admin" {
|
||||
return e.ForbiddenError("Requires admin role", nil)
|
||||
}
|
||||
configContent, err := generateYAML(e.App)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.JSON(200, map[string]string{"config": configContent})
|
||||
}
|
247
internal/hub/config/config_test.go
Normal file
247
internal/hub/config/config_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/src/tests"
|
||||
|
||||
"github.com/henrygd/beszel/src/hub/config"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config struct for testing (copied from config package since it's not exported)
|
||||
type testConfig struct {
|
||||
Systems []testSystemConfig `yaml:"systems"`
|
||||
}
|
||||
|
||||
type testSystemConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Host string `yaml:"host"`
|
||||
Port uint16 `yaml:"port,omitempty"`
|
||||
Users []string `yaml:"users"`
|
||||
Token string `yaml:"token,omitempty"`
|
||||
}
|
||||
|
||||
// Helper function to create a test system for config tests
|
||||
// func createConfigTestSystem(app core.App, name, host string, port uint16, userIDs []string) (*core.Record, error) {
|
||||
// systemCollection, err := app.FindCollectionByNameOrId("systems")
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// system := core.NewRecord(systemCollection)
|
||||
// system.Set("name", name)
|
||||
// system.Set("host", host)
|
||||
// system.Set("port", port)
|
||||
// system.Set("users", userIDs)
|
||||
// system.Set("status", "pending")
|
||||
|
||||
// return system, app.Save(system)
|
||||
// }
|
||||
|
||||
// Helper function to create a fingerprint record
|
||||
func createConfigTestFingerprint(app core.App, systemID, token, fingerprint string) (*core.Record, error) {
|
||||
fingerprintCollection, err := app.FindCollectionByNameOrId("fingerprints")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fp := core.NewRecord(fingerprintCollection)
|
||||
fp.Set("system", systemID)
|
||||
fp.Set("token", token)
|
||||
fp.Set("fingerprint", fingerprint)
|
||||
|
||||
return fp, app.Save(fp)
|
||||
}
|
||||
|
||||
// TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios
|
||||
func TestConfigSyncWithTokens(t *testing.T) {
|
||||
testHub, err := tests.NewTestHub()
|
||||
require.NoError(t, err)
|
||||
defer testHub.Cleanup()
|
||||
|
||||
// Create test user
|
||||
user, err := tests.CreateUser(testHub.App, "admin@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
setupFunc func() (string, *core.Record, *core.Record) // Returns: existing token, system record, fingerprint record
|
||||
configYAML string
|
||||
expectToken string // Expected token after sync
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "new system with token in config",
|
||||
setupFunc: func() (string, *core.Record, *core.Record) {
|
||||
return "", nil, nil // No existing system
|
||||
},
|
||||
configYAML: `systems:
|
||||
- name: "new-server"
|
||||
host: "new.example.com"
|
||||
port: 45876
|
||||
users:
|
||||
- "admin@example.com"
|
||||
token: "explicit-token-123"`,
|
||||
expectToken: "explicit-token-123",
|
||||
description: "New system should use token from config",
|
||||
},
|
||||
{
|
||||
name: "existing system without token in config (preserve existing)",
|
||||
setupFunc: func() (string, *core.Record, *core.Record) {
|
||||
// Create existing system and fingerprint
|
||||
system, err := tests.CreateRecord(testHub.App, "systems", map[string]any{
|
||||
"name": "preserve-server",
|
||||
"host": "preserve.example.com",
|
||||
"port": 45876,
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
fingerprint, err := createConfigTestFingerprint(testHub.App, system.Id, "preserve-token-999", "preserve-fingerprint")
|
||||
require.NoError(t, err)
|
||||
|
||||
return "preserve-token-999", system, fingerprint
|
||||
},
|
||||
configYAML: `systems:
|
||||
- name: "preserve-server"
|
||||
host: "preserve.example.com"
|
||||
port: 45876
|
||||
users:
|
||||
- "admin@example.com"`,
|
||||
expectToken: "preserve-token-999",
|
||||
description: "Existing system should preserve original token when config doesn't specify one",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Setup test data
|
||||
_, existingSystem, existingFingerprint := tc.setupFunc()
|
||||
|
||||
// Write config file
|
||||
configPath := filepath.Join(testHub.DataDir(), "config.yml")
|
||||
err := os.WriteFile(configPath, []byte(tc.configYAML), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create serve event and sync
|
||||
event := &core.ServeEvent{App: testHub.App}
|
||||
err = config.SyncSystems(event)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the config to get the system name for verification
|
||||
var configData testConfig
|
||||
err = yaml.Unmarshal([]byte(tc.configYAML), &configData)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, configData.Systems, 1)
|
||||
systemName := configData.Systems[0].Name
|
||||
|
||||
// Find the system after sync
|
||||
systems, err := testHub.FindRecordsByFilter("systems", "name = {:name}", "", -1, 0, map[string]any{"name": systemName})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, systems, 1)
|
||||
system := systems[0]
|
||||
|
||||
// Find the fingerprint record
|
||||
fingerprints, err := testHub.FindRecordsByFilter("fingerprints", "system = {:system}", "", -1, 0, map[string]any{"system": system.Id})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fingerprints, 1)
|
||||
fingerprint := fingerprints[0]
|
||||
|
||||
// Verify token
|
||||
actualToken := fingerprint.GetString("token")
|
||||
if tc.expectToken == "" {
|
||||
// For generated tokens, just verify it's not empty and is a valid UUID format
|
||||
assert.NotEmpty(t, actualToken, tc.description)
|
||||
assert.Len(t, actualToken, 36, "Generated token should be UUID format") // UUID length
|
||||
} else {
|
||||
assert.Equal(t, tc.expectToken, actualToken, tc.description)
|
||||
}
|
||||
|
||||
// For existing systems, verify fingerprint is preserved
|
||||
if existingFingerprint != nil {
|
||||
actualFingerprint := fingerprint.GetString("fingerprint")
|
||||
expectedFingerprint := existingFingerprint.GetString("fingerprint")
|
||||
assert.Equal(t, expectedFingerprint, actualFingerprint, "Fingerprint should be preserved")
|
||||
}
|
||||
|
||||
// Cleanup for next test
|
||||
if existingSystem != nil {
|
||||
testHub.Delete(existingSystem)
|
||||
}
|
||||
if existingFingerprint != nil {
|
||||
testHub.Delete(existingFingerprint)
|
||||
}
|
||||
// Clean up the new records
|
||||
testHub.Delete(system)
|
||||
testHub.Delete(fingerprint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigMigrationScenario tests the specific migration scenario mentioned in the discussion
|
||||
func TestConfigMigrationScenario(t *testing.T) {
|
||||
testHub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer testHub.Cleanup()
|
||||
|
||||
// Create test user
|
||||
user, err := tests.CreateUser(testHub.App, "admin@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate migration scenario: system exists with token from migration
|
||||
existingSystem, err := tests.CreateRecord(testHub.App, "systems", map[string]any{
|
||||
"name": "migrated-server",
|
||||
"host": "migrated.example.com",
|
||||
"port": 45876,
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
migrationToken := "migration-generated-token-123"
|
||||
existingFingerprint, err := createConfigTestFingerprint(testHub.App, existingSystem.Id, migrationToken, "existing-fingerprint-from-agent")
|
||||
require.NoError(t, err)
|
||||
|
||||
// User exports config BEFORE this update (so no token field in YAML)
|
||||
oldConfigYAML := `systems:
|
||||
- name: "migrated-server"
|
||||
host: "migrated.example.com"
|
||||
port: 45876
|
||||
users:
|
||||
- "admin@example.com"`
|
||||
|
||||
// Write old config file and import
|
||||
configPath := filepath.Join(testHub.DataDir(), "config.yml")
|
||||
err = os.WriteFile(configPath, []byte(oldConfigYAML), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := &core.ServeEvent{App: testHub.App}
|
||||
err = config.SyncSystems(event)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the original token is preserved
|
||||
updatedFingerprint, err := testHub.FindRecordById("fingerprints", existingFingerprint.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
actualToken := updatedFingerprint.GetString("token")
|
||||
assert.Equal(t, migrationToken, actualToken, "Migration token should be preserved when config doesn't specify a token")
|
||||
|
||||
// Verify fingerprint is also preserved
|
||||
actualFingerprint := updatedFingerprint.GetString("fingerprint")
|
||||
assert.Equal(t, "existing-fingerprint-from-agent", actualFingerprint, "Existing fingerprint should be preserved")
|
||||
|
||||
// Verify system still exists and is updated correctly
|
||||
updatedSystem, err := testHub.FindRecordById("systems", existingSystem.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "migrated-server", updatedSystem.GetString("name"))
|
||||
assert.Equal(t, "migrated.example.com", updatedSystem.GetString("host"))
|
||||
}
|
104
internal/hub/expirymap/expirymap.go
Normal file
104
internal/hub/expirymap/expirymap.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package expirymap
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
)
|
||||
|
||||
type val[T any] struct {
|
||||
value T
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type ExpiryMap[T any] struct {
|
||||
store *store.Store[string, *val[T]]
|
||||
cleanupInterval time.Duration
|
||||
}
|
||||
|
||||
// New creates a new expiry map with custom cleanup interval
|
||||
func New[T any](cleanupInterval time.Duration) *ExpiryMap[T] {
|
||||
m := &ExpiryMap[T]{
|
||||
store: store.New(map[string]*val[T]{}),
|
||||
cleanupInterval: cleanupInterval,
|
||||
}
|
||||
m.startCleaner()
|
||||
return m
|
||||
}
|
||||
|
||||
// Set stores a value with the given TTL
|
||||
func (m *ExpiryMap[T]) Set(key string, value T, ttl time.Duration) {
|
||||
m.store.Set(key, &val[T]{
|
||||
value: value,
|
||||
expires: time.Now().Add(ttl),
|
||||
})
|
||||
}
|
||||
|
||||
// GetOk retrieves a value and checks if it exists and hasn't expired
|
||||
// Performs lazy cleanup of expired entries on access
|
||||
func (m *ExpiryMap[T]) GetOk(key string) (T, bool) {
|
||||
value, ok := m.store.GetOk(key)
|
||||
if !ok {
|
||||
return *new(T), false
|
||||
}
|
||||
|
||||
// Check if expired and perform lazy cleanup
|
||||
if value.expires.Before(time.Now()) {
|
||||
m.store.Remove(key)
|
||||
return *new(T), false
|
||||
}
|
||||
|
||||
return value.value, true
|
||||
}
|
||||
|
||||
// GetByValue retrieves a value by value
|
||||
func (m *ExpiryMap[T]) GetByValue(val T) (key string, value T, ok bool) {
|
||||
for key, v := range m.store.GetAll() {
|
||||
if reflect.DeepEqual(v.value, val) {
|
||||
// check if expired
|
||||
if v.expires.Before(time.Now()) {
|
||||
m.store.Remove(key)
|
||||
break
|
||||
}
|
||||
return key, v.value, true
|
||||
}
|
||||
}
|
||||
return "", *new(T), false
|
||||
}
|
||||
|
||||
// Remove explicitly removes a key
|
||||
func (m *ExpiryMap[T]) Remove(key string) {
|
||||
m.store.Remove(key)
|
||||
}
|
||||
|
||||
// RemovebyValue removes a value by value
|
||||
func (m *ExpiryMap[T]) RemovebyValue(value T) (T, bool) {
|
||||
for key, val := range m.store.GetAll() {
|
||||
if reflect.DeepEqual(val.value, value) {
|
||||
m.store.Remove(key)
|
||||
return val.value, true
|
||||
}
|
||||
}
|
||||
return *new(T), false
|
||||
}
|
||||
|
||||
// startCleaner runs the background cleanup process
|
||||
func (m *ExpiryMap[T]) startCleaner() {
|
||||
go func() {
|
||||
tick := time.Tick(m.cleanupInterval)
|
||||
for range tick {
|
||||
m.cleanup()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// cleanup removes all expired entries
|
||||
func (m *ExpiryMap[T]) cleanup() {
|
||||
now := time.Now()
|
||||
for key, val := range m.store.GetAll() {
|
||||
if val.expires.Before(now) {
|
||||
m.store.Remove(key)
|
||||
}
|
||||
}
|
||||
}
|
477
internal/hub/expirymap/expirymap_test.go
Normal file
477
internal/hub/expirymap/expirymap_test.go
Normal file
@@ -0,0 +1,477 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package expirymap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Not using the following methods but are useful for testing
|
||||
|
||||
// TESTING: Has checks if a key exists and hasn't expired
|
||||
func (m *ExpiryMap[T]) Has(key string) bool {
|
||||
_, ok := m.GetOk(key)
|
||||
return ok
|
||||
}
|
||||
|
||||
// TESTING: Get retrieves a value, returns zero value if not found or expired
|
||||
func (m *ExpiryMap[T]) Get(key string) T {
|
||||
value, _ := m.GetOk(key)
|
||||
return value
|
||||
}
|
||||
|
||||
// TESTING: Len returns the number of non-expired entries
|
||||
func (m *ExpiryMap[T]) Len() int {
|
||||
count := 0
|
||||
now := time.Now()
|
||||
for _, val := range m.store.Values() {
|
||||
if val.expires.After(now) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func TestExpiryMap_BasicOperations(t *testing.T) {
|
||||
em := New[string](time.Hour)
|
||||
|
||||
// Test Set and GetOk
|
||||
em.Set("key1", "value1", time.Hour)
|
||||
value, ok := em.GetOk("key1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "value1", value)
|
||||
|
||||
// Test Get
|
||||
value = em.Get("key1")
|
||||
assert.Equal(t, "value1", value)
|
||||
|
||||
// Test Has
|
||||
assert.True(t, em.Has("key1"))
|
||||
assert.False(t, em.Has("nonexistent"))
|
||||
|
||||
// Test Remove
|
||||
em.Remove("key1")
|
||||
assert.False(t, em.Has("key1"))
|
||||
}
|
||||
|
||||
func TestExpiryMap_Expiration(t *testing.T) {
|
||||
em := New[string](time.Hour)
|
||||
|
||||
// Set a value with very short TTL
|
||||
em.Set("shortlived", "value", time.Millisecond*10)
|
||||
|
||||
// Should exist immediately
|
||||
assert.True(t, em.Has("shortlived"))
|
||||
|
||||
// Wait for expiration
|
||||
time.Sleep(time.Millisecond * 20)
|
||||
|
||||
// Should be expired and automatically cleaned up on access
|
||||
assert.False(t, em.Has("shortlived"))
|
||||
value, ok := em.GetOk("shortlived")
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, "", value) // zero value for string
|
||||
}
|
||||
|
||||
func TestExpiryMap_LazyCleanup(t *testing.T) {
|
||||
em := New[int](time.Hour)
|
||||
|
||||
// Set multiple values with short TTL
|
||||
em.Set("key1", 1, time.Millisecond*10)
|
||||
em.Set("key2", 2, time.Millisecond*10)
|
||||
em.Set("key3", 3, time.Hour) // This one won't expire
|
||||
|
||||
// Wait for expiration
|
||||
time.Sleep(time.Millisecond * 20)
|
||||
|
||||
// Access expired keys should trigger lazy cleanup
|
||||
_, ok := em.GetOk("key1")
|
||||
assert.False(t, ok)
|
||||
|
||||
// Non-expired key should still exist
|
||||
value, ok := em.GetOk("key3")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 3, value)
|
||||
}
|
||||
|
||||
func TestExpiryMap_Len(t *testing.T) {
|
||||
em := New[string](time.Hour)
|
||||
|
||||
// Initially empty
|
||||
assert.Equal(t, 0, em.Len())
|
||||
|
||||
// Add some values
|
||||
em.Set("key1", "value1", time.Hour)
|
||||
em.Set("key2", "value2", time.Hour)
|
||||
em.Set("key3", "value3", time.Millisecond*10) // Will expire soon
|
||||
|
||||
// Should count all initially
|
||||
assert.Equal(t, 3, em.Len())
|
||||
|
||||
// Wait for one to expire
|
||||
time.Sleep(time.Millisecond * 20)
|
||||
|
||||
// Len should reflect only non-expired entries
|
||||
assert.Equal(t, 2, em.Len())
|
||||
}
|
||||
|
||||
func TestExpiryMap_CustomInterval(t *testing.T) {
|
||||
// Create with very short cleanup interval for testing
|
||||
em := New[string](time.Millisecond * 50)
|
||||
|
||||
// Set a value that expires quickly
|
||||
em.Set("test", "value", time.Millisecond*10)
|
||||
|
||||
// Should exist initially
|
||||
assert.True(t, em.Has("test"))
|
||||
|
||||
// Wait for expiration + cleanup cycle
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
|
||||
// Should be cleaned up by background process
|
||||
// Note: This test might be flaky due to timing, but demonstrates the concept
|
||||
assert.False(t, em.Has("test"))
|
||||
}
|
||||
|
||||
func TestExpiryMap_GenericTypes(t *testing.T) {
|
||||
// Test with different types
|
||||
t.Run("Int", func(t *testing.T) {
|
||||
em := New[int](time.Hour)
|
||||
|
||||
em.Set("num", 42, time.Hour)
|
||||
value, ok := em.GetOk("num")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 42, value)
|
||||
})
|
||||
|
||||
t.Run("Struct", func(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
Name string
|
||||
Age int
|
||||
}
|
||||
|
||||
em := New[TestStruct](time.Hour)
|
||||
|
||||
expected := TestStruct{Name: "John", Age: 30}
|
||||
em.Set("person", expected, time.Hour)
|
||||
|
||||
value, ok := em.GetOk("person")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, expected, value)
|
||||
})
|
||||
|
||||
t.Run("Pointer", func(t *testing.T) {
|
||||
em := New[*string](time.Hour)
|
||||
|
||||
str := "hello"
|
||||
em.Set("ptr", &str, time.Hour)
|
||||
|
||||
value, ok := em.GetOk("ptr")
|
||||
assert.True(t, ok)
|
||||
require.NotNil(t, value)
|
||||
assert.Equal(t, "hello", *value)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExpiryMap_ZeroValues(t *testing.T) {
|
||||
em := New[string](time.Hour)
|
||||
|
||||
// Test getting non-existent key returns zero value
|
||||
value := em.Get("nonexistent")
|
||||
assert.Equal(t, "", value)
|
||||
|
||||
// Test getting expired key returns zero value
|
||||
em.Set("expired", "value", time.Millisecond*10)
|
||||
time.Sleep(time.Millisecond * 20)
|
||||
|
||||
value = em.Get("expired")
|
||||
assert.Equal(t, "", value)
|
||||
}
|
||||
|
||||
func TestExpiryMap_Concurrent(t *testing.T) {
|
||||
em := New[int](time.Hour)
|
||||
|
||||
// Simple concurrent access test
|
||||
done := make(chan bool, 2)
|
||||
|
||||
// Writer goroutine
|
||||
go func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
em.Set("key", i, time.Hour)
|
||||
time.Sleep(time.Microsecond)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Reader goroutine
|
||||
go func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
_ = em.Get("key")
|
||||
time.Sleep(time.Microsecond)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Wait for both to complete
|
||||
<-done
|
||||
<-done
|
||||
|
||||
// Should not panic and should have some value
|
||||
assert.True(t, em.Has("key"))
|
||||
}
|
||||
|
||||
func TestExpiryMap_GetByValue(t *testing.T) {
|
||||
em := New[string](time.Hour)
|
||||
|
||||
// Test getting by value when value exists
|
||||
em.Set("key1", "value1", time.Hour)
|
||||
em.Set("key2", "value2", time.Hour)
|
||||
em.Set("key3", "value1", time.Hour) // Duplicate value - should return first match
|
||||
|
||||
// Test successful retrieval
|
||||
key, value, ok := em.GetByValue("value1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "value1", value)
|
||||
assert.Contains(t, []string{"key1", "key3"}, key) // Should be one of the keys with this value
|
||||
|
||||
// Test retrieval of unique value
|
||||
key, value, ok = em.GetByValue("value2")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "value2", value)
|
||||
assert.Equal(t, "key2", key)
|
||||
|
||||
// Test getting non-existent value
|
||||
key, value, ok = em.GetByValue("nonexistent")
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, "", value) // zero value for string
|
||||
assert.Equal(t, "", key) // zero value for string
|
||||
}
|
||||
|
||||
func TestExpiryMap_GetByValue_Expiration(t *testing.T) {
|
||||
em := New[string](time.Hour)
|
||||
|
||||
// Set a value with short TTL
|
||||
em.Set("shortkey", "shortvalue", time.Millisecond*10)
|
||||
em.Set("longkey", "longvalue", time.Hour)
|
||||
|
||||
// Should find the short-lived value initially
|
||||
key, value, ok := em.GetByValue("shortvalue")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "shortvalue", value)
|
||||
assert.Equal(t, "shortkey", key)
|
||||
|
||||
// Wait for expiration
|
||||
time.Sleep(time.Millisecond * 20)
|
||||
|
||||
// Should not find expired value and should trigger lazy cleanup
|
||||
key, value, ok = em.GetByValue("shortvalue")
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, "", value)
|
||||
assert.Equal(t, "", key)
|
||||
|
||||
// Should still find non-expired value
|
||||
key, value, ok = em.GetByValue("longvalue")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "longvalue", value)
|
||||
assert.Equal(t, "longkey", key)
|
||||
}
|
||||
|
||||
func TestExpiryMap_GetByValue_GenericTypes(t *testing.T) {
|
||||
t.Run("Int", func(t *testing.T) {
|
||||
em := New[int](time.Hour)
|
||||
|
||||
em.Set("num1", 42, time.Hour)
|
||||
em.Set("num2", 84, time.Hour)
|
||||
|
||||
key, value, ok := em.GetByValue(42)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 42, value)
|
||||
assert.Equal(t, "num1", key)
|
||||
|
||||
key, value, ok = em.GetByValue(99)
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, 0, value)
|
||||
assert.Equal(t, "", key)
|
||||
})
|
||||
|
||||
t.Run("Struct", func(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
Name string
|
||||
Age int
|
||||
}
|
||||
|
||||
em := New[TestStruct](time.Hour)
|
||||
|
||||
person1 := TestStruct{Name: "John", Age: 30}
|
||||
person2 := TestStruct{Name: "Jane", Age: 25}
|
||||
|
||||
em.Set("person1", person1, time.Hour)
|
||||
em.Set("person2", person2, time.Hour)
|
||||
|
||||
key, value, ok := em.GetByValue(person1)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, person1, value)
|
||||
assert.Equal(t, "person1", key)
|
||||
|
||||
nonexistent := TestStruct{Name: "Bob", Age: 40}
|
||||
key, value, ok = em.GetByValue(nonexistent)
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, TestStruct{}, value)
|
||||
assert.Equal(t, "", key)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExpiryMap_RemoveValue(t *testing.T) {
|
||||
em := New[string](time.Hour)
|
||||
|
||||
// Test removing existing value
|
||||
em.Set("key1", "value1", time.Hour)
|
||||
em.Set("key2", "value2", time.Hour)
|
||||
em.Set("key3", "value1", time.Hour) // Duplicate value
|
||||
|
||||
// Remove by value should remove one instance
|
||||
removedValue, ok := em.RemovebyValue("value1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "value1", removedValue)
|
||||
|
||||
// Should still have the other instance or value2
|
||||
assert.True(t, em.Has("key2")) // value2 should still exist
|
||||
|
||||
// Check if one of the duplicate values was removed
|
||||
// At least one key with "value1" should be gone
|
||||
key1Exists := em.Has("key1")
|
||||
key3Exists := em.Has("key3")
|
||||
assert.False(t, key1Exists && key3Exists) // Both shouldn't exist
|
||||
assert.True(t, key1Exists || key3Exists) // At least one should be gone
|
||||
|
||||
// Test removing non-existent value
|
||||
removedValue, ok = em.RemovebyValue("nonexistent")
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, "", removedValue) // zero value for string
|
||||
}
|
||||
|
||||
func TestExpiryMap_RemoveValue_GenericTypes(t *testing.T) {
|
||||
t.Run("Int", func(t *testing.T) {
|
||||
em := New[int](time.Hour)
|
||||
|
||||
em.Set("num1", 42, time.Hour)
|
||||
em.Set("num2", 84, time.Hour)
|
||||
|
||||
// Remove existing value
|
||||
removedValue, ok := em.RemovebyValue(42)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 42, removedValue)
|
||||
assert.False(t, em.Has("num1"))
|
||||
assert.True(t, em.Has("num2"))
|
||||
|
||||
// Remove non-existent value
|
||||
removedValue, ok = em.RemovebyValue(99)
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, 0, removedValue)
|
||||
})
|
||||
|
||||
t.Run("Struct", func(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
Name string
|
||||
Age int
|
||||
}
|
||||
|
||||
em := New[TestStruct](time.Hour)
|
||||
|
||||
person1 := TestStruct{Name: "John", Age: 30}
|
||||
person2 := TestStruct{Name: "Jane", Age: 25}
|
||||
|
||||
em.Set("person1", person1, time.Hour)
|
||||
em.Set("person2", person2, time.Hour)
|
||||
|
||||
// Remove existing struct
|
||||
removedValue, ok := em.RemovebyValue(person1)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, person1, removedValue)
|
||||
assert.False(t, em.Has("person1"))
|
||||
assert.True(t, em.Has("person2"))
|
||||
|
||||
// Remove non-existent struct
|
||||
nonexistent := TestStruct{Name: "Bob", Age: 40}
|
||||
removedValue, ok = em.RemovebyValue(nonexistent)
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, TestStruct{}, removedValue)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
|
||||
em := New[string](time.Hour)
|
||||
|
||||
// Set values with different TTLs
|
||||
em.Set("key1", "value1", time.Millisecond*10) // Will expire
|
||||
em.Set("key2", "value2", time.Hour) // Won't expire
|
||||
em.Set("key3", "value1", time.Hour) // Won't expire, duplicate value
|
||||
|
||||
// Wait for first value to expire
|
||||
time.Sleep(time.Millisecond * 20)
|
||||
|
||||
// Try to remove the expired value - should remove one of the "value1" entries
|
||||
removedValue, ok := em.RemovebyValue("value1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "value1", removedValue)
|
||||
|
||||
// Should still have key2 (different value)
|
||||
assert.True(t, em.Has("key2"))
|
||||
|
||||
// Should have removed one of the "value1" entries (either key1 or key3)
|
||||
// But we can't predict which one due to map iteration order
|
||||
key1Exists := em.Has("key1")
|
||||
key3Exists := em.Has("key3")
|
||||
|
||||
// Exactly one of key1 or key3 should be gone
|
||||
assert.False(t, key1Exists && key3Exists) // Both shouldn't exist
|
||||
assert.True(t, key1Exists || key3Exists) // At least one should still exist
|
||||
}
|
||||
|
||||
func TestExpiryMap_ValueOperations_Integration(t *testing.T) {
|
||||
em := New[string](time.Hour)
|
||||
|
||||
// Test integration of GetByValue and RemoveValue
|
||||
em.Set("key1", "shared", time.Hour)
|
||||
em.Set("key2", "unique", time.Hour)
|
||||
em.Set("key3", "shared", time.Hour)
|
||||
|
||||
// Find shared value
|
||||
key, value, ok := em.GetByValue("shared")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "shared", value)
|
||||
assert.Contains(t, []string{"key1", "key3"}, key)
|
||||
|
||||
// Remove shared value
|
||||
removedValue, ok := em.RemovebyValue("shared")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "shared", removedValue)
|
||||
|
||||
// Should still be able to find the other shared value
|
||||
key, value, ok = em.GetByValue("shared")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "shared", value)
|
||||
assert.Contains(t, []string{"key1", "key3"}, key)
|
||||
|
||||
// Remove the other shared value
|
||||
removedValue, ok = em.RemovebyValue("shared")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "shared", removedValue)
|
||||
|
||||
// Should not find shared value anymore
|
||||
key, value, ok = em.GetByValue("shared")
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, "", value)
|
||||
assert.Equal(t, "", key)
|
||||
|
||||
// Unique value should still exist
|
||||
key, value, ok = em.GetByValue("unique")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "unique", value)
|
||||
assert.Equal(t, "key2", key)
|
||||
}
|
300
internal/hub/hub.go
Normal file
300
internal/hub/hub.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Package hub handles updating systems and serving the web UI.
|
||||
package hub
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/src/alerts"
|
||||
"github.com/henrygd/beszel/src/hub/config"
|
||||
"github.com/henrygd/beszel/src/hub/systems"
|
||||
"github.com/henrygd/beszel/src/records"
|
||||
"github.com/henrygd/beszel/src/users"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type Hub struct {
|
||||
core.App
|
||||
*alerts.AlertManager
|
||||
um *users.UserManager
|
||||
rm *records.RecordManager
|
||||
sm *systems.SystemManager
|
||||
pubKey string
|
||||
signer ssh.Signer
|
||||
appURL string
|
||||
}
|
||||
|
||||
// NewHub creates a new Hub instance with default configuration
|
||||
func NewHub(app core.App) *Hub {
|
||||
hub := &Hub{}
|
||||
hub.App = app
|
||||
|
||||
hub.AlertManager = alerts.NewAlertManager(hub)
|
||||
hub.um = users.NewUserManager(hub)
|
||||
hub.rm = records.NewRecordManager(hub)
|
||||
hub.sm = systems.NewSystemManager(hub)
|
||||
hub.appURL, _ = GetEnv("APP_URL")
|
||||
return hub
|
||||
}
|
||||
|
||||
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
||||
func GetEnv(key string) (value string, exists bool) {
|
||||
if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists {
|
||||
return value, exists
|
||||
}
|
||||
// Fallback to the old unprefixed key
|
||||
return os.LookupEnv(key)
|
||||
}
|
||||
|
||||
func (h *Hub) StartHub() error {
|
||||
h.App.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
||||
// initialize settings / collections
|
||||
if err := h.initialize(e); err != nil {
|
||||
return err
|
||||
}
|
||||
// sync systems with config
|
||||
if err := config.SyncSystems(e); err != nil {
|
||||
return err
|
||||
}
|
||||
// register api routes
|
||||
if err := h.registerApiRoutes(e); err != nil {
|
||||
return err
|
||||
}
|
||||
// register cron jobs
|
||||
if err := h.registerCronJobs(e); err != nil {
|
||||
return err
|
||||
}
|
||||
// start server
|
||||
if err := h.startServer(e); err != nil {
|
||||
return err
|
||||
}
|
||||
// start system updates
|
||||
if err := h.sm.Initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
// TODO: move to users package
|
||||
// handle default values for user / user_settings creation
|
||||
h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
|
||||
h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
|
||||
|
||||
if pb, ok := h.App.(*pocketbase.PocketBase); ok {
|
||||
// log.Println("Starting pocketbase")
|
||||
err := pb.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initialize sets up initial configuration (collections, settings, etc.)
|
||||
func (h *Hub) initialize(e *core.ServeEvent) error {
|
||||
// set general settings
|
||||
settings := e.App.Settings()
|
||||
// batch requests (for global alerts)
|
||||
settings.Batch.Enabled = true
|
||||
// set URL if BASE_URL env is set
|
||||
if h.appURL != "" {
|
||||
settings.Meta.AppURL = h.appURL
|
||||
}
|
||||
if err := e.App.Save(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
// set auth settings
|
||||
usersCollection, err := e.App.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
|
||||
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
|
||||
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
|
||||
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
|
||||
// disable oauth if no providers are configured (todo: remove this in post 0.9.0 release)
|
||||
if usersCollection.OAuth2.Enabled {
|
||||
usersCollection.OAuth2.Enabled = len(usersCollection.OAuth2.Providers) > 0
|
||||
}
|
||||
// allow oauth user creation if USER_CREATION is set
|
||||
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
|
||||
cr := "@request.context = 'oauth2'"
|
||||
usersCollection.CreateRule = &cr
|
||||
} else {
|
||||
usersCollection.CreateRule = nil
|
||||
}
|
||||
if err := e.App.Save(usersCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
// allow all users to access systems if SHARE_ALL_SYSTEMS is set
|
||||
systemsCollection, err := e.App.FindCachedCollectionByNameOrId("systems")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
|
||||
systemsReadRule := "@request.auth.id != \"\""
|
||||
if shareAllSystems != "true" {
|
||||
// default is to only show systems that the user id is assigned to
|
||||
systemsReadRule += " && users.id ?= @request.auth.id"
|
||||
}
|
||||
updateDeleteRule := systemsReadRule + " && @request.auth.role != \"readonly\""
|
||||
systemsCollection.ListRule = &systemsReadRule
|
||||
systemsCollection.ViewRule = &systemsReadRule
|
||||
systemsCollection.UpdateRule = &updateDeleteRule
|
||||
systemsCollection.DeleteRule = &updateDeleteRule
|
||||
if err := e.App.Save(systemsCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerCronJobs sets up scheduled tasks
|
||||
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
||||
// delete old system_stats and alerts_history records once every hour
|
||||
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
||||
// create longer records every 10 minutes
|
||||
h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
|
||||
return nil
|
||||
}
|
||||
|
||||
// custom api routes
|
||||
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||
// auth protected routes
|
||||
apiAuth := se.Router.Group("/api/beszel")
|
||||
apiAuth.Bind(apis.RequireAuth())
|
||||
// auth optional routes
|
||||
apiNoAuth := se.Router.Group("/api/beszel")
|
||||
|
||||
// create first user endpoint only needed if no users exist
|
||||
if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 {
|
||||
apiNoAuth.POST("/create-user", h.um.CreateFirstUser)
|
||||
}
|
||||
// check if first time setup on login page
|
||||
apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error {
|
||||
total, err := e.App.CountRecords("users")
|
||||
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
|
||||
})
|
||||
// get public key and version
|
||||
apiAuth.GET("/getkey", func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
||||
})
|
||||
// send test notification
|
||||
apiAuth.POST("/test-notification", h.SendTestNotification)
|
||||
// get config.yml content
|
||||
apiAuth.GET("/config-yaml", config.GetYamlConfig)
|
||||
// handle agent websocket connection
|
||||
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
||||
// get or create universal tokens
|
||||
apiAuth.GET("/universal-token", h.getUniversalToken)
|
||||
// update / delete user alerts
|
||||
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handler for universal token API endpoint (create, read, delete)
|
||||
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
||||
tokenMap := universalTokenMap.GetMap()
|
||||
userID := e.Auth.Id
|
||||
query := e.Request.URL.Query()
|
||||
token := query.Get("token")
|
||||
|
||||
if token == "" {
|
||||
// return existing token if it exists
|
||||
if token, _, ok := tokenMap.GetByValue(userID); ok {
|
||||
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
|
||||
}
|
||||
// if no token is provided, generate a new one
|
||||
token = uuid.New().String()
|
||||
}
|
||||
response := map[string]any{"token": token}
|
||||
|
||||
switch query.Get("enable") {
|
||||
case "1":
|
||||
tokenMap.Set(token, userID, time.Hour)
|
||||
case "0":
|
||||
tokenMap.RemovebyValue(userID)
|
||||
}
|
||||
_, response["active"] = tokenMap.GetOk(token)
|
||||
return e.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// generates key pair if it doesn't exist and returns signer
|
||||
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
|
||||
if h.signer != nil {
|
||||
return h.signer, nil
|
||||
}
|
||||
|
||||
if dataDir == "" {
|
||||
dataDir = h.DataDir()
|
||||
}
|
||||
|
||||
privateKeyPath := path.Join(dataDir, "id_ed25519")
|
||||
|
||||
// check if the key pair already exists
|
||||
existingKey, err := os.ReadFile(privateKeyPath)
|
||||
if err == nil {
|
||||
private, err := ssh.ParsePrivateKey(existingKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %s", err)
|
||||
}
|
||||
pubKeyBytes := ssh.MarshalAuthorizedKey(private.PublicKey())
|
||||
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
|
||||
return private, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
// File exists but couldn't be read for some other reason
|
||||
return nil, fmt.Errorf("failed to read %s: %w", privateKeyPath, err)
|
||||
}
|
||||
|
||||
// Generate the Ed25519 key pair
|
||||
_, privKey, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
privKeyPem, err := ssh.MarshalPrivateKey(privKey, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(privateKeyPath, pem.EncodeToMemory(privKeyPem), 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to write private key to %q: err: %w", privateKeyPath, err)
|
||||
}
|
||||
|
||||
// These are fine to ignore the errors on, as we've literally just created a crypto.PublicKey | crypto.Signer
|
||||
sshPrivate, _ := ssh.NewSignerFromSigner(privKey)
|
||||
pubKeyBytes := ssh.MarshalAuthorizedKey(sshPrivate.PublicKey())
|
||||
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
|
||||
|
||||
h.Logger().Info("ed25519 key pair generated successfully.")
|
||||
h.Logger().Info("Saved to: " + privateKeyPath)
|
||||
|
||||
return sshPrivate, err
|
||||
}
|
||||
|
||||
// MakeLink formats a link with the app URL and path segments.
|
||||
// Only path segments should be provided.
|
||||
func (h *Hub) MakeLink(parts ...string) string {
|
||||
base := strings.TrimSuffix(h.Settings().Meta.AppURL, "/")
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
base = fmt.Sprintf("%s/%s", base, url.PathEscape(part))
|
||||
}
|
||||
return base
|
||||
}
|
713
internal/hub/hub_test.go
Normal file
713
internal/hub/hub_test.go
Normal file
@@ -0,0 +1,713 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package hub_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/src/migrations"
|
||||
beszelTests "github.com/henrygd/beszel/src/tests"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||
func jsonReader(v any) io.Reader {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return bytes.NewReader(data)
|
||||
}
|
||||
|
||||
func TestMakeLink(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
appURL string
|
||||
parts []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no parts, no trailing slash in AppURL",
|
||||
appURL: "http://localhost:8090",
|
||||
parts: []string{},
|
||||
expected: "http://localhost:8090",
|
||||
},
|
||||
{
|
||||
name: "no parts, with trailing slash in AppURL",
|
||||
appURL: "http://localhost:8090/",
|
||||
parts: []string{},
|
||||
expected: "http://localhost:8090", // TrimSuffix should handle the trailing slash
|
||||
},
|
||||
{
|
||||
name: "one part",
|
||||
appURL: "http://example.com",
|
||||
parts: []string{"one"},
|
||||
expected: "http://example.com/one",
|
||||
},
|
||||
{
|
||||
name: "multiple parts",
|
||||
appURL: "http://example.com",
|
||||
parts: []string{"alpha", "beta", "gamma"},
|
||||
expected: "http://example.com/alpha/beta/gamma",
|
||||
},
|
||||
{
|
||||
name: "parts with spaces needing escaping",
|
||||
appURL: "http://example.com",
|
||||
parts: []string{"path with spaces", "another part"},
|
||||
expected: "http://example.com/path%20with%20spaces/another%20part",
|
||||
},
|
||||
{
|
||||
name: "parts with slashes needing escaping",
|
||||
appURL: "http://example.com",
|
||||
parts: []string{"a/b", "c"},
|
||||
expected: "http://example.com/a%2Fb/c", // url.PathEscape escapes '/'
|
||||
},
|
||||
{
|
||||
name: "AppURL with subpath, no trailing slash",
|
||||
appURL: "http://localhost/sub",
|
||||
parts: []string{"resource"},
|
||||
expected: "http://localhost/sub/resource",
|
||||
},
|
||||
{
|
||||
name: "AppURL with subpath, with trailing slash",
|
||||
appURL: "http://localhost/sub/",
|
||||
parts: []string{"item"},
|
||||
expected: "http://localhost/sub/item",
|
||||
},
|
||||
{
|
||||
name: "empty parts in the middle",
|
||||
appURL: "http://localhost",
|
||||
parts: []string{"first", "", "third"},
|
||||
expected: "http://localhost/first/third",
|
||||
},
|
||||
{
|
||||
name: "leading and trailing empty parts",
|
||||
appURL: "http://localhost",
|
||||
parts: []string{"", "path", ""},
|
||||
expected: "http://localhost/path",
|
||||
},
|
||||
{
|
||||
name: "parts with various special characters",
|
||||
appURL: "https://test.dev/",
|
||||
parts: []string{"p@th?", "key=value&"},
|
||||
expected: "https://test.dev/p@th%3F/key=value&",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Store original app URL and restore it after the test
|
||||
originalAppURL := hub.Settings().Meta.AppURL
|
||||
hub.Settings().Meta.AppURL = tt.appURL
|
||||
defer func() { hub.Settings().Meta.AppURL = originalAppURL }()
|
||||
|
||||
got := hub.MakeLink(tt.parts...)
|
||||
assert.Equal(t, tt.expected, got, "MakeLink generated URL does not match expected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSSHKey(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
|
||||
// Test Case 1: Key generation (no existing key)
|
||||
t.Run("KeyGeneration", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Ensure pubKey is initially empty or different to ensure GetSSHKey sets it
|
||||
hub.SetPubkey("")
|
||||
|
||||
signer, err := hub.GetSSHKey(tempDir)
|
||||
assert.NoError(t, err, "GetSSHKey should not error when generating a new key")
|
||||
assert.NotNil(t, signer, "GetSSHKey should return a non-nil signer")
|
||||
|
||||
// Check if private key file was created
|
||||
privateKeyPath := filepath.Join(tempDir, "id_ed25519")
|
||||
info, err := os.Stat(privateKeyPath)
|
||||
assert.NoError(t, err, "Private key file should be created")
|
||||
assert.False(t, info.IsDir(), "Private key path should be a file, not a directory")
|
||||
|
||||
// Check if h.pubKey was set
|
||||
assert.NotEmpty(t, hub.GetPubkey(), "h.pubKey should be set after key generation")
|
||||
assert.True(t, strings.HasPrefix(hub.GetPubkey(), "ssh-ed25519 "), "h.pubKey should start with 'ssh-ed25519 '")
|
||||
|
||||
// Verify the generated private key is parsable
|
||||
keyData, err := os.ReadFile(privateKeyPath)
|
||||
require.NoError(t, err)
|
||||
_, err = ssh.ParsePrivateKey(keyData)
|
||||
assert.NoError(t, err, "Generated private key should be parsable by ssh.ParsePrivateKey")
|
||||
})
|
||||
|
||||
// Test Case 2: Existing key
|
||||
t.Run("ExistingKey", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Manually create a valid key pair for the test
|
||||
rawPubKey, rawPrivKey, err := ed25519.GenerateKey(nil)
|
||||
require.NoError(t, err, "Failed to generate raw ed25519 key pair for pre-existing key test")
|
||||
|
||||
// Marshal the private key into OpenSSH PEM format
|
||||
pemBlock, err := ssh.MarshalPrivateKey(rawPrivKey, "")
|
||||
require.NoError(t, err, "Failed to marshal private key to PEM block for pre-existing key test")
|
||||
|
||||
privateKeyBytes := pem.EncodeToMemory(pemBlock)
|
||||
require.NotNil(t, privateKeyBytes, "PEM encoded private key bytes should not be nil")
|
||||
|
||||
privateKeyPath := filepath.Join(tempDir, "id_ed25519")
|
||||
err = os.WriteFile(privateKeyPath, privateKeyBytes, 0600)
|
||||
require.NoError(t, err, "Failed to write pre-existing private key")
|
||||
|
||||
// Determine the expected public key string
|
||||
sshPubKey, err := ssh.NewPublicKey(rawPubKey)
|
||||
require.NoError(t, err)
|
||||
expectedPubKeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPubKey)))
|
||||
|
||||
// Reset h.pubKey to ensure it's set by GetSSHKey from the file
|
||||
hub.SetPubkey("")
|
||||
|
||||
signer, err := hub.GetSSHKey(tempDir)
|
||||
assert.NoError(t, err, "GetSSHKey should not error when reading an existing key")
|
||||
assert.NotNil(t, signer, "GetSSHKey should return a non-nil signer for an existing key")
|
||||
|
||||
// Check if h.pubKey was set correctly to the public key from the file
|
||||
assert.Equal(t, expectedPubKeyStr, hub.GetPubkey(), "h.pubKey should match the existing public key")
|
||||
|
||||
// Verify the signer's public key matches the original public key
|
||||
signerPubKey := signer.PublicKey()
|
||||
marshaledSignerPubKey := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signerPubKey)))
|
||||
assert.Equal(t, expectedPubKeyStr, marshaledSignerPubKey, "Signer's public key should match the existing public key")
|
||||
})
|
||||
|
||||
// Test Case 3: Error cases
|
||||
t.Run("ErrorCases", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(dir string) error
|
||||
errorCheck func(t *testing.T, err error)
|
||||
}{
|
||||
{
|
||||
name: "CorruptedKey",
|
||||
setupFunc: func(dir string) error {
|
||||
return os.WriteFile(filepath.Join(dir, "id_ed25519"), []byte("this is not a valid SSH key"), 0600)
|
||||
},
|
||||
errorCheck: func(t *testing.T, err error) {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "ssh: no key found")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PermissionDenied",
|
||||
setupFunc: func(dir string) error {
|
||||
// Create the key file
|
||||
keyPath := filepath.Join(dir, "id_ed25519")
|
||||
if err := os.WriteFile(keyPath, []byte("dummy content"), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Make it read-only (can't be opened for writing in case a new key needs to be written)
|
||||
return os.Chmod(keyPath, 0400)
|
||||
},
|
||||
errorCheck: func(t *testing.T, err error) {
|
||||
// On read-only key, the parser will attempt to parse it and fail with "ssh: no key found"
|
||||
assert.Error(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyFile",
|
||||
setupFunc: func(dir string) error {
|
||||
// Create an empty file
|
||||
return os.WriteFile(filepath.Join(dir, "id_ed25519"), []byte{}, 0600)
|
||||
},
|
||||
errorCheck: func(t *testing.T, err error) {
|
||||
assert.Error(t, err)
|
||||
// The error from attempting to parse an empty file
|
||||
assert.Contains(t, err.Error(), "ssh: no key found")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Setup the test case
|
||||
err := tc.setupFunc(tempDir)
|
||||
require.NoError(t, err, "Setup failed")
|
||||
|
||||
// Reset h.pubKey before each test case
|
||||
hub.SetPubkey("")
|
||||
|
||||
// Attempt to get SSH key
|
||||
_, err = hub.GetSSHKey(tempDir)
|
||||
|
||||
// Verify the error
|
||||
tc.errorCheck(t, err)
|
||||
|
||||
// Check that pubKey was not set in error cases
|
||||
assert.Empty(t, hub.GetPubkey(), "h.pubKey should not be set if there was an error")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApiRoutesAuthentication(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
defer hub.Cleanup()
|
||||
|
||||
hub.StartHub()
|
||||
|
||||
// Create test user and get auth token
|
||||
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
|
||||
require.NoError(t, err, "Failed to create test user")
|
||||
|
||||
adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{
|
||||
"email": "admin@example.com",
|
||||
"password": "password123",
|
||||
"role": "admin",
|
||||
})
|
||||
require.NoError(t, err, "Failed to create admin user")
|
||||
adminUserToken, err := adminUser.NewAuthToken()
|
||||
|
||||
// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{
|
||||
// "email": "superuser@example.com",
|
||||
// "password": "password123",
|
||||
// })
|
||||
// require.NoError(t, err, "Failed to create superuser")
|
||||
|
||||
userToken, err := user.NewAuthToken()
|
||||
require.NoError(t, err, "Failed to create auth token")
|
||||
|
||||
// Create test system for user-alerts endpoints
|
||||
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"users": []string{user.Id},
|
||||
"host": "127.0.0.1",
|
||||
})
|
||||
require.NoError(t, err, "Failed to create test system")
|
||||
|
||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||
return hub.TestApp
|
||||
}
|
||||
|
||||
scenarios := []beszelTests.ApiScenario{
|
||||
// Auth Protected Routes - Should require authentication
|
||||
{
|
||||
Name: "POST /test-notification - no auth should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://127.0.0.1",
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - with auth should succeed",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://127.0.0.1",
|
||||
}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"sending message"},
|
||||
},
|
||||
{
|
||||
Name: "GET /config-yaml - no auth should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/config-yaml",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /config-yaml - with user auth should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/config-yaml",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Requires admin"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /config-yaml - with admin auth should succeed",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/config-yaml",
|
||||
Headers: map[string]string{
|
||||
"Authorization": adminUserToken,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test-system"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /universal-token - no auth should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/universal-token",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /universal-token - with auth should succeed",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/universal-token",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"active", "token"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST /user-alerts - no auth should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
"systems": []string{system.Id},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "POST /user-alerts - with auth should succeed",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
"systems": []string{system.Id},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "DELETE /user-alerts - no auth should fail",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system.Id},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "DELETE /user-alerts - with auth should succeed",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
// Create an alert to delete
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system.Id,
|
||||
"user": user.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
// Auth Optional Routes - Should work without authentication
|
||||
{
|
||||
Name: "GET /getkey - no auth should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/getkey",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /getkey - with auth should also succeed",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/getkey",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"key\":", "\"v\":"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /first-run - no auth should succeed",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/first-run",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"firstRun\":false"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /first-run - with auth should also succeed",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/first-run",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"firstRun\":false"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /agent-connect - no auth should succeed (websocket upgrade fails but route is accessible)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/agent-connect",
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - invalid auth token should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://127.0.0.1",
|
||||
}),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "invalid-token",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST /user-alerts - invalid auth token should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "invalid-token",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
"systems": []string{system.Id},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstUserCreation(t *testing.T) {
|
||||
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
defer hub.Cleanup()
|
||||
|
||||
hub.StartHub()
|
||||
|
||||
testAppFactoryExisting := func(t testing.TB) *pbTests.TestApp {
|
||||
return hub.TestApp
|
||||
}
|
||||
|
||||
scenarios := []beszelTests.ApiScenario{
|
||||
{
|
||||
Name: "POST /create-user - should be available when no users exist",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/create-user",
|
||||
Body: jsonReader(map[string]any{
|
||||
"email": "firstuser@example.com",
|
||||
"password": "password123",
|
||||
}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"User created"},
|
||||
TestAppFactory: testAppFactoryExisting,
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
userCount, err := hub.CountRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, userCount, "Should start with no users")
|
||||
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, len(superusers), "Should start with one temporary superuser")
|
||||
require.EqualValues(t, migrations.TempAdminEmail, superusers[0].GetString("email"), "Should have created one temporary superuser")
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
userCount, err := hub.CountRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, userCount, "Should have created one user")
|
||||
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, len(superusers), "Should have created one superuser")
|
||||
require.EqualValues(t, "firstuser@example.com", superusers[0].GetString("email"), "Should have created one superuser")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "POST /create-user - should not be available when users exist",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/create-user",
|
||||
Body: jsonReader(map[string]any{
|
||||
"email": "firstuser@example.com",
|
||||
"password": "password123",
|
||||
}),
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"wasn't found"},
|
||||
TestAppFactory: testAppFactoryExisting,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreateUserEndpoint not available when USER_EMAIL, USER_PASSWORD are set", func(t *testing.T) {
|
||||
os.Setenv("BESZEL_HUB_USER_EMAIL", "me@example.com")
|
||||
os.Setenv("BESZEL_HUB_USER_PASSWORD", "password123")
|
||||
defer os.Unsetenv("BESZEL_HUB_USER_EMAIL")
|
||||
defer os.Unsetenv("BESZEL_HUB_USER_PASSWORD")
|
||||
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
defer hub.Cleanup()
|
||||
|
||||
hub.StartHub()
|
||||
|
||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||
return hub.TestApp
|
||||
}
|
||||
|
||||
scenario := beszelTests.ApiScenario{
|
||||
Name: "POST /create-user - should not be available when USER_EMAIL, USER_PASSWORD are set",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/create-user",
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"wasn't found"},
|
||||
TestAppFactory: testAppFactory,
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
users, err := hub.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, len(users), "Should start with one user")
|
||||
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
|
||||
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, len(superusers), "Should start with one superuser")
|
||||
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
users, err := hub.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, len(users), "Should still have one user")
|
||||
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
|
||||
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, len(superusers), "Should still have one superuser")
|
||||
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
|
||||
},
|
||||
}
|
||||
|
||||
scenario.Test(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateUserEndpointAvailability(t *testing.T) {
|
||||
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Ensure no users exist
|
||||
userCount, err := hub.CountRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, userCount, "Should start with no users")
|
||||
|
||||
hub.StartHub()
|
||||
|
||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||
return hub.TestApp
|
||||
}
|
||||
|
||||
scenario := beszelTests.ApiScenario{
|
||||
Name: "POST /create-user - should be available when no users exist",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/create-user",
|
||||
Body: jsonReader(map[string]any{
|
||||
"email": "firstuser@example.com",
|
||||
"password": "password123",
|
||||
}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"User created"},
|
||||
TestAppFactory: testAppFactory,
|
||||
}
|
||||
|
||||
scenario.Test(t)
|
||||
|
||||
// Verify user was created
|
||||
userCount, err = hub.CountRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, userCount, "Should have created one user")
|
||||
})
|
||||
|
||||
t.Run("CreateUserEndpoint not available when users exist", func(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create a user first
|
||||
_, err := beszelTests.CreateUser(hub, "existing@example.com", "password")
|
||||
require.NoError(t, err)
|
||||
|
||||
hub.StartHub()
|
||||
|
||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||
return hub.TestApp
|
||||
}
|
||||
|
||||
scenario := beszelTests.ApiScenario{
|
||||
Name: "POST /create-user - should not be available when users exist",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/create-user",
|
||||
Body: jsonReader(map[string]any{
|
||||
"email": "another@example.com",
|
||||
"password": "password123",
|
||||
}),
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"wasn't found"},
|
||||
TestAppFactory: testAppFactory,
|
||||
}
|
||||
|
||||
scenario.Test(t)
|
||||
})
|
||||
}
|
21
internal/hub/hub_test_helpers.go
Normal file
21
internal/hub/hub_test_helpers.go
Normal file
@@ -0,0 +1,21 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package hub
|
||||
|
||||
import "github.com/henrygd/beszel/src/hub/systems"
|
||||
|
||||
// TESTING ONLY: GetSystemManager returns the system manager
|
||||
func (h *Hub) GetSystemManager() *systems.SystemManager {
|
||||
return h.sm
|
||||
}
|
||||
|
||||
// TESTING ONLY: GetPubkey returns the public key
|
||||
func (h *Hub) GetPubkey() string {
|
||||
return h.pubKey
|
||||
}
|
||||
|
||||
// TESTING ONLY: SetPubkey sets the public key
|
||||
func (h *Hub) SetPubkey(pubkey string) {
|
||||
h.pubKey = pubkey
|
||||
}
|
82
internal/hub/server_development.go
Normal file
82
internal/hub/server_development.go
Normal file
@@ -0,0 +1,82 @@
|
||||
//go:build development
|
||||
|
||||
package hub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
)
|
||||
|
||||
// Wraps http.RoundTripper to modify dev proxy HTML responses
|
||||
type responseModifier struct {
|
||||
transport http.RoundTripper
|
||||
hub *Hub
|
||||
}
|
||||
|
||||
func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := rm.transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
// Only modify HTML responses
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/html") {
|
||||
return resp, nil
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
resp.Body.Close()
|
||||
// Create a new response with the modified body
|
||||
modifiedBody := rm.modifyHTML(string(body))
|
||||
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
||||
resp.ContentLength = int64(len(modifiedBody))
|
||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (rm *responseModifier) modifyHTML(html string) string {
|
||||
parsedURL, err := url.Parse(rm.hub.appURL)
|
||||
if err != nil {
|
||||
return html
|
||||
}
|
||||
// fix base paths in html if using subpath
|
||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||
html = strings.ReplaceAll(html, "./", basePath)
|
||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
|
||||
return html
|
||||
}
|
||||
|
||||
// startServer sets up the development server for Beszel
|
||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
slog.Info("starting server", "appURL", h.appURL)
|
||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||
Scheme: "http",
|
||||
Host: "localhost:5173",
|
||||
})
|
||||
|
||||
proxy.Transport = &responseModifier{
|
||||
transport: http.DefaultTransport,
|
||||
hub: h,
|
||||
}
|
||||
|
||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||
proxy.ServeHTTP(e.Response, e.Request)
|
||||
return nil
|
||||
})
|
||||
_ = osutils.LaunchURL(h.appURL)
|
||||
return nil
|
||||
}
|
52
internal/hub/server_production.go
Normal file
52
internal/hub/server_production.go
Normal file
@@ -0,0 +1,52 @@
|
||||
//go:build !development
|
||||
|
||||
package hub
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/src/site"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// startServer sets up the production server for Beszel
|
||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
// parse app url
|
||||
parsedURL, err := url.Parse(h.appURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// fix base paths in html if using subpath
|
||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
||||
html := strings.ReplaceAll(string(indexFile), "./", basePath)
|
||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||
html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1)
|
||||
// set up static asset serving
|
||||
staticPaths := [2]string{"/static/", "/assets/"}
|
||||
serveStatic := apis.Static(site.DistDirFS, false)
|
||||
// get CSP configuration
|
||||
csp, cspExists := GetEnv("CSP")
|
||||
// add route
|
||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||
// serve static assets if path is in staticPaths
|
||||
for i := range staticPaths {
|
||||
if strings.Contains(e.Request.URL.Path, staticPaths[i]) {
|
||||
e.Response.Header().Set("Cache-Control", "public, max-age=2592000")
|
||||
return serveStatic(e)
|
||||
}
|
||||
}
|
||||
if cspExists {
|
||||
e.Response.Header().Del("X-Frame-Options")
|
||||
e.Response.Header().Set("Content-Security-Policy", csp)
|
||||
}
|
||||
return e.HTML(http.StatusOK, html)
|
||||
})
|
||||
return nil
|
||||
}
|
390
internal/hub/systems/system.go
Normal file
390
internal/hub/systems/system.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package systems
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/src/hub/ws"
|
||||
|
||||
"github.com/henrygd/beszel/src/entities/system"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type System struct {
|
||||
Id string `db:"id"`
|
||||
Host string `db:"host"`
|
||||
Port string `db:"port"`
|
||||
Status string `db:"status"`
|
||||
manager *SystemManager // Manager that this system belongs to
|
||||
client *ssh.Client // SSH client for fetching data
|
||||
data *system.CombinedData // system data from agent
|
||||
ctx context.Context // Context for stopping the updater
|
||||
cancel context.CancelFunc // Stops and removes system from updater
|
||||
WsConn *ws.WsConn // Handler for agent WebSocket connection
|
||||
agentVersion semver.Version // Agent version
|
||||
updateTicker *time.Ticker // Ticker for updating the system
|
||||
}
|
||||
|
||||
func (sm *SystemManager) NewSystem(systemId string) *System {
|
||||
system := &System{
|
||||
Id: systemId,
|
||||
data: &system.CombinedData{},
|
||||
}
|
||||
system.ctx, system.cancel = system.getContext()
|
||||
return system
|
||||
}
|
||||
|
||||
// StartUpdater starts the system updater.
|
||||
// It first fetches the data from the agent then updates the records.
|
||||
// If the data is not found or the system is down, it sets the system down.
|
||||
func (sys *System) StartUpdater() {
|
||||
// Channel that can be used to set the system down. Currently only used to
|
||||
// allow a short delay for reconnection after websocket connection is closed.
|
||||
var downChan chan struct{}
|
||||
|
||||
// Add random jitter to first WebSocket connection to prevent
|
||||
// clustering if all agents are started at the same time.
|
||||
// SSH connections during hub startup are already staggered.
|
||||
var jitter <-chan time.Time
|
||||
if sys.WsConn != nil {
|
||||
jitter = getJitter()
|
||||
// use the websocket connection's down channel to set the system down
|
||||
downChan = sys.WsConn.DownChan
|
||||
} else {
|
||||
// if the system does not have a websocket connection, wait before updating
|
||||
// to allow the agent to connect via websocket (makes sure fingerprint is set).
|
||||
time.Sleep(11 * time.Second)
|
||||
}
|
||||
|
||||
// update immediately if system is not paused (only for ws connections)
|
||||
// we'll wait a minute before connecting via SSH to prioritize ws connections
|
||||
if sys.Status != paused && sys.ctx.Err() == nil {
|
||||
if err := sys.update(); err != nil {
|
||||
_ = sys.setDown(err)
|
||||
}
|
||||
}
|
||||
|
||||
sys.updateTicker = time.NewTicker(time.Duration(interval) * time.Millisecond)
|
||||
// Go 1.23+ will automatically stop the ticker when the system is garbage collected, however we seem to need this or testing/synctest will block even if calling runtime.GC()
|
||||
defer sys.updateTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sys.ctx.Done():
|
||||
return
|
||||
case <-sys.updateTicker.C:
|
||||
if err := sys.update(); err != nil {
|
||||
_ = sys.setDown(err)
|
||||
}
|
||||
case <-downChan:
|
||||
sys.WsConn = nil
|
||||
downChan = nil
|
||||
_ = sys.setDown(nil)
|
||||
case <-jitter:
|
||||
sys.updateTicker.Reset(time.Duration(interval) * time.Millisecond)
|
||||
if err := sys.update(); err != nil {
|
||||
_ = sys.setDown(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update updates the system data and records.
|
||||
func (sys *System) update() error {
|
||||
if sys.Status == paused {
|
||||
sys.handlePaused()
|
||||
return nil
|
||||
}
|
||||
data, err := sys.fetchDataFromAgent()
|
||||
if err == nil {
|
||||
_, err = sys.createRecords(data)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (sys *System) handlePaused() {
|
||||
if sys.WsConn == nil {
|
||||
// if the system is paused and there's no websocket connection, remove the system
|
||||
_ = sys.manager.RemoveSystem(sys.Id)
|
||||
} else {
|
||||
// Send a ping to the agent to keep the connection alive if the system is paused
|
||||
if err := sys.WsConn.Ping(); err != nil {
|
||||
sys.manager.hub.Logger().Warn("Failed to ping agent", "system", sys.Id, "err", err)
|
||||
_ = sys.manager.RemoveSystem(sys.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createRecords updates the system record and adds system_stats and container_stats records
|
||||
func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) {
|
||||
systemRecord, err := sys.getRecord()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hub := sys.manager.hub
|
||||
// add system_stats and container_stats records
|
||||
systemStatsCollection, err := hub.FindCachedCollectionByNameOrId("system_stats")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
systemStatsRecord := core.NewRecord(systemStatsCollection)
|
||||
systemStatsRecord.Set("system", systemRecord.Id)
|
||||
systemStatsRecord.Set("stats", data.Stats)
|
||||
systemStatsRecord.Set("type", "1m")
|
||||
if err := hub.SaveNoValidate(systemStatsRecord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// add new container_stats record
|
||||
if len(data.Containers) > 0 {
|
||||
containerStatsCollection, err := hub.FindCachedCollectionByNameOrId("container_stats")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
containerStatsRecord := core.NewRecord(containerStatsCollection)
|
||||
containerStatsRecord.Set("system", systemRecord.Id)
|
||||
containerStatsRecord.Set("stats", data.Containers)
|
||||
containerStatsRecord.Set("type", "1m")
|
||||
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||
systemRecord.Set("status", up)
|
||||
|
||||
systemRecord.Set("info", data.Info)
|
||||
if err := hub.SaveNoValidate(systemRecord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return systemRecord, nil
|
||||
}
|
||||
|
||||
// getRecord retrieves the system record from the database.
|
||||
// If the record is not found, it removes the system from the manager.
|
||||
func (sys *System) getRecord() (*core.Record, error) {
|
||||
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
|
||||
if err != nil || record == nil {
|
||||
_ = sys.manager.RemoveSystem(sys.Id)
|
||||
return nil, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// setDown marks a system as down in the database.
|
||||
// It takes the original error that caused the system to go down and returns any error
|
||||
// encountered during the process of updating the system status.
|
||||
func (sys *System) setDown(originalError error) error {
|
||||
if sys.Status == down || sys.Status == paused {
|
||||
return nil
|
||||
}
|
||||
record, err := sys.getRecord()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if originalError != nil {
|
||||
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError)
|
||||
}
|
||||
record.Set("status", down)
|
||||
return sys.manager.hub.SaveNoValidate(record)
|
||||
}
|
||||
|
||||
func (sys *System) getContext() (context.Context, context.CancelFunc) {
|
||||
if sys.ctx == nil {
|
||||
sys.ctx, sys.cancel = context.WithCancel(context.Background())
|
||||
}
|
||||
return sys.ctx, sys.cancel
|
||||
}
|
||||
|
||||
// fetchDataFromAgent attempts to fetch data from the agent,
|
||||
// prioritizing WebSocket if available.
|
||||
func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
|
||||
if sys.data == nil {
|
||||
sys.data = &system.CombinedData{}
|
||||
}
|
||||
|
||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
||||
wsData, err := sys.fetchDataViaWebSocket()
|
||||
if err == nil {
|
||||
return wsData, nil
|
||||
}
|
||||
// close the WebSocket connection if error and try SSH
|
||||
sys.closeWebSocketConnection()
|
||||
}
|
||||
|
||||
sshData, err := sys.fetchDataViaSSH()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sshData, nil
|
||||
}
|
||||
|
||||
func (sys *System) fetchDataViaWebSocket() (*system.CombinedData, error) {
|
||||
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
|
||||
return nil, errors.New("no websocket connection")
|
||||
}
|
||||
err := sys.WsConn.RequestSystemData(sys.data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sys.data, nil
|
||||
}
|
||||
|
||||
// fetchDataViaSSH handles fetching data using SSH.
|
||||
// This function encapsulates the original SSH logic.
|
||||
// It updates sys.data directly upon successful fetch.
|
||||
func (sys *System) fetchDataViaSSH() (*system.CombinedData, error) {
|
||||
maxRetries := 1
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if sys.client == nil || sys.Status == down {
|
||||
if err := sys.createSSHClient(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
session, err := sys.createSessionWithTimeout(4 * time.Second)
|
||||
if err != nil {
|
||||
if attempt >= maxRetries {
|
||||
return nil, err
|
||||
}
|
||||
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
|
||||
sys.closeSSHConnection()
|
||||
// Reset format detection on connection failure - agent might have been upgraded
|
||||
continue
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := session.Shell(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
*sys.data = system.CombinedData{}
|
||||
|
||||
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
|
||||
err = cbor.NewDecoder(stdout).Decode(sys.data)
|
||||
} else {
|
||||
err = json.NewDecoder(stdout).Decode(sys.data)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
sys.closeSSHConnection()
|
||||
if attempt < maxRetries {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// wait for the session to complete
|
||||
if err := session.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sys.data, nil
|
||||
}
|
||||
|
||||
// this should never be reached due to the return in the loop
|
||||
return nil, fmt.Errorf("failed to fetch data")
|
||||
}
|
||||
|
||||
// createSSHClient creates a new SSH client for the system
|
||||
func (s *System) createSSHClient() error {
|
||||
if s.manager.sshConfig == nil {
|
||||
if err := s.manager.createSSHClientConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
network := "tcp"
|
||||
host := s.Host
|
||||
if strings.HasPrefix(host, "/") {
|
||||
network = "unix"
|
||||
} else {
|
||||
host = net.JoinHostPort(host, s.Port)
|
||||
}
|
||||
var err error
|
||||
s.client, err = ssh.Dial(network, host, s.manager.sshConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
|
||||
// in case of network issues
|
||||
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
|
||||
if sys.client == nil {
|
||||
return nil, fmt.Errorf("client not initialized")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(sys.ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
sessionChan := make(chan *ssh.Session, 1)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
if session, err := sys.client.NewSession(); err != nil {
|
||||
errChan <- err
|
||||
} else {
|
||||
sessionChan <- session
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case session := <-sessionChan:
|
||||
return session, nil
|
||||
case err := <-errChan:
|
||||
return nil, err
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// closeSSHConnection closes the SSH connection but keeps the system in the manager
|
||||
func (sys *System) closeSSHConnection() {
|
||||
if sys.client != nil {
|
||||
sys.client.Close()
|
||||
sys.client = nil
|
||||
}
|
||||
}
|
||||
|
||||
// closeWebSocketConnection closes the WebSocket connection but keeps the system in the manager
|
||||
// to allow updating via SSH. It will be removed if the WS connection is re-established.
|
||||
// The system will be set as down a few seconds later if the connection is not re-established.
|
||||
func (sys *System) closeWebSocketConnection() {
|
||||
if sys.WsConn != nil {
|
||||
sys.WsConn.Close(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// extractAgentVersion extracts the beszel version from SSH server version string
|
||||
func extractAgentVersion(versionString string) (semver.Version, error) {
|
||||
_, after, _ := strings.Cut(versionString, "_")
|
||||
return semver.Parse(after)
|
||||
}
|
||||
|
||||
// getJitter returns a channel that will be triggered after a random delay
|
||||
// between 40% and 90% of the interval.
|
||||
// This is used to stagger the initial WebSocket connections to prevent clustering.
|
||||
func getJitter() <-chan time.Time {
|
||||
minPercent := 40
|
||||
maxPercent := 90
|
||||
jitterRange := maxPercent - minPercent
|
||||
msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100)
|
||||
return time.After(time.Duration(msDelay) * time.Millisecond)
|
||||
}
|
348
internal/hub/systems/system_manager.go
Normal file
348
internal/hub/systems/system_manager.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package systems
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/src/hub/ws"
|
||||
|
||||
"github.com/henrygd/beszel/src/entities/system"
|
||||
|
||||
"github.com/henrygd/beszel/src/common"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// System status constants
|
||||
const (
|
||||
up string = "up" // System is online and responding
|
||||
down string = "down" // System is offline or not responding
|
||||
paused string = "paused" // System monitoring is paused
|
||||
pending string = "pending" // System is waiting on initial connection result
|
||||
|
||||
// interval is the default update interval in milliseconds (60 seconds)
|
||||
interval int = 60_000
|
||||
// interval int = 10_000 // Debug interval for faster updates
|
||||
|
||||
// sessionTimeout is the maximum time to wait for SSH connections
|
||||
sessionTimeout = 4 * time.Second
|
||||
)
|
||||
|
||||
// errSystemExists is returned when attempting to add a system that already exists
|
||||
var errSystemExists = errors.New("system exists")
|
||||
|
||||
// SystemManager manages a collection of monitored systems and their connections.
|
||||
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
||||
type SystemManager struct {
|
||||
hub hubLike // Hub interface for database and alert operations
|
||||
systems *store.Store[string, *System] // Thread-safe store of active systems
|
||||
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
||||
}
|
||||
|
||||
// hubLike defines the interface requirements for the hub dependency.
|
||||
// It extends core.App with system-specific functionality.
|
||||
type hubLike interface {
|
||||
core.App
|
||||
GetSSHKey(dataDir string) (ssh.Signer, error)
|
||||
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
|
||||
HandleStatusAlerts(status string, systemRecord *core.Record) error
|
||||
}
|
||||
|
||||
// NewSystemManager creates a new SystemManager instance with the provided hub.
|
||||
// The hub must implement the hubLike interface to provide database and alert functionality.
|
||||
func NewSystemManager(hub hubLike) *SystemManager {
|
||||
return &SystemManager{
|
||||
systems: store.New(map[string]*System{}),
|
||||
hub: hub,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize sets up the system manager by binding event hooks and starting existing systems.
|
||||
// It configures SSH client settings and begins monitoring all non-paused systems from the database.
|
||||
// Systems are started with staggered delays to prevent overwhelming the hub during startup.
|
||||
func (sm *SystemManager) Initialize() error {
|
||||
sm.bindEventHooks()
|
||||
|
||||
// Initialize SSH client configuration
|
||||
err := sm.createSSHClientConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load existing systems from database (excluding paused ones)
|
||||
var systems []*System
|
||||
err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems)
|
||||
if err != nil || len(systems) == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start systems in background with staggered timing
|
||||
go func() {
|
||||
// Calculate staggered delay between system starts (max 2 seconds per system)
|
||||
delta := interval / max(1, len(systems))
|
||||
delta = min(delta, 2_000)
|
||||
sleepTime := time.Duration(delta) * time.Millisecond
|
||||
|
||||
for _, system := range systems {
|
||||
time.Sleep(sleepTime)
|
||||
_ = sm.AddSystem(system)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindEventHooks registers event handlers for system and fingerprint record changes.
|
||||
// These hooks ensure the system manager stays synchronized with database changes.
|
||||
func (sm *SystemManager) bindEventHooks() {
|
||||
sm.hub.OnRecordCreate("systems").BindFunc(sm.onRecordCreate)
|
||||
sm.hub.OnRecordAfterCreateSuccess("systems").BindFunc(sm.onRecordAfterCreateSuccess)
|
||||
sm.hub.OnRecordUpdate("systems").BindFunc(sm.onRecordUpdate)
|
||||
sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess)
|
||||
sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess)
|
||||
sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated)
|
||||
}
|
||||
|
||||
// onTokenRotated handles fingerprint token rotation events.
|
||||
// When a system's authentication token is rotated, any existing WebSocket connection
|
||||
// must be closed to force re-authentication with the new token.
|
||||
func (sm *SystemManager) onTokenRotated(e *core.RecordEvent) error {
|
||||
systemID := e.Record.GetString("system")
|
||||
system, ok := sm.systems.GetOk(systemID)
|
||||
if !ok {
|
||||
return e.Next()
|
||||
}
|
||||
// No need to close connection if not connected via websocket
|
||||
if system.WsConn == nil {
|
||||
return e.Next()
|
||||
}
|
||||
system.setDown(nil)
|
||||
sm.RemoveSystem(systemID)
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// onRecordCreate is called before a new system record is committed to the database.
|
||||
// It initializes the record with default values: empty info and pending status.
|
||||
func (sm *SystemManager) onRecordCreate(e *core.RecordEvent) error {
|
||||
e.Record.Set("info", system.Info{})
|
||||
e.Record.Set("status", pending)
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// onRecordAfterCreateSuccess is called after a new system record is successfully created.
|
||||
// It adds the new system to the manager to begin monitoring.
|
||||
func (sm *SystemManager) onRecordAfterCreateSuccess(e *core.RecordEvent) error {
|
||||
if err := sm.AddRecord(e.Record, nil); err != nil {
|
||||
e.App.Logger().Error("Error adding record", "err", err)
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// onRecordUpdate is called before a system record is updated in the database.
|
||||
// It clears system info when the status is changed to paused.
|
||||
func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
|
||||
if e.Record.GetString("status") == paused {
|
||||
e.Record.Set("info", system.Info{})
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// onRecordAfterUpdateSuccess handles system record updates after they're committed to the database.
|
||||
// It manages system lifecycle based on status changes and triggers appropriate alerts.
|
||||
// Status transitions are handled as follows:
|
||||
// - paused: Closes SSH connection and deactivates alerts
|
||||
// - pending: Starts monitoring (reuses WebSocket if available)
|
||||
// - up: Triggers system alerts
|
||||
// - down: Triggers status change alerts
|
||||
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
||||
newStatus := e.Record.GetString("status")
|
||||
prevStatus := pending
|
||||
system, ok := sm.systems.GetOk(e.Record.Id)
|
||||
if ok {
|
||||
prevStatus = system.Status
|
||||
system.Status = newStatus
|
||||
}
|
||||
|
||||
switch newStatus {
|
||||
case paused:
|
||||
if ok {
|
||||
// Pause monitoring but keep system in manager for potential resume
|
||||
system.closeSSHConnection()
|
||||
}
|
||||
_ = deactivateAlerts(e.App, e.Record.Id)
|
||||
return e.Next()
|
||||
case pending:
|
||||
// Resume monitoring, preferring existing WebSocket connection
|
||||
if ok && system.WsConn != nil {
|
||||
go system.update()
|
||||
return e.Next()
|
||||
}
|
||||
// Start new monitoring session
|
||||
if err := sm.AddRecord(e.Record, nil); err != nil {
|
||||
e.App.Logger().Error("Error adding record", "err", err)
|
||||
}
|
||||
_ = deactivateAlerts(e.App, e.Record.Id)
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// Handle systems not in manager
|
||||
if !ok {
|
||||
return sm.AddRecord(e.Record, nil)
|
||||
}
|
||||
|
||||
// Trigger system alerts when system comes online
|
||||
if newStatus == up {
|
||||
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
|
||||
e.App.Logger().Error("Error handling system alerts", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger status change alerts for up/down transitions
|
||||
if (newStatus == down && prevStatus == up) || (newStatus == up && prevStatus == down) {
|
||||
if err := sm.hub.HandleStatusAlerts(newStatus, e.Record); err != nil {
|
||||
e.App.Logger().Error("Error handling status alerts", "err", err)
|
||||
}
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// onRecordAfterDeleteSuccess is called after a system record is successfully deleted.
|
||||
// It removes the system from the manager and cleans up all associated resources.
|
||||
func (sm *SystemManager) onRecordAfterDeleteSuccess(e *core.RecordEvent) error {
|
||||
sm.RemoveSystem(e.Record.Id)
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// AddSystem adds a system to the manager and starts monitoring it.
|
||||
// It validates required fields, initializes the system context, and starts the update goroutine.
|
||||
// Returns error if a system with the same ID already exists.
|
||||
func (sm *SystemManager) AddSystem(sys *System) error {
|
||||
if sm.systems.Has(sys.Id) {
|
||||
return errSystemExists
|
||||
}
|
||||
if sys.Id == "" || sys.Host == "" {
|
||||
return errors.New("system missing required fields")
|
||||
}
|
||||
|
||||
// Initialize system for monitoring
|
||||
sys.manager = sm
|
||||
sys.ctx, sys.cancel = sys.getContext()
|
||||
sys.data = &system.CombinedData{}
|
||||
sm.systems.Set(sys.Id, sys)
|
||||
|
||||
// Start monitoring in background
|
||||
go sys.StartUpdater()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveSystem removes a system from the manager and cleans up all associated resources.
|
||||
// It cancels the system's context, closes all connections, and removes it from the store.
|
||||
// Returns an error if the system is not found.
|
||||
func (sm *SystemManager) RemoveSystem(systemID string) error {
|
||||
system, ok := sm.systems.GetOk(systemID)
|
||||
if !ok {
|
||||
return errors.New("system not found")
|
||||
}
|
||||
|
||||
// Stop the update goroutine
|
||||
if system.cancel != nil {
|
||||
system.cancel()
|
||||
}
|
||||
|
||||
// Clean up all connections
|
||||
system.closeSSHConnection()
|
||||
system.closeWebSocketConnection()
|
||||
sm.systems.Remove(systemID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRecord creates a System instance from a database record and adds it to the manager.
|
||||
// If a system with the same ID already exists, it's removed first to ensure clean state.
|
||||
// If no system instance is provided, a new one is created.
|
||||
// This method is typically called when systems are created or their status changes to pending.
|
||||
func (sm *SystemManager) AddRecord(record *core.Record, system *System) (err error) {
|
||||
// Remove existing system to ensure clean state
|
||||
if sm.systems.Has(record.Id) {
|
||||
_ = sm.RemoveSystem(record.Id)
|
||||
}
|
||||
|
||||
// Create new system if none provided
|
||||
if system == nil {
|
||||
system = sm.NewSystem(record.Id)
|
||||
}
|
||||
|
||||
// Populate system from record
|
||||
system.Status = record.GetString("status")
|
||||
system.Host = record.GetString("host")
|
||||
system.Port = record.GetString("port")
|
||||
|
||||
return sm.AddSystem(system)
|
||||
}
|
||||
|
||||
// AddWebSocketSystem creates and adds a system with an established WebSocket connection.
|
||||
// This method is called when an agent connects via WebSocket with valid authentication.
|
||||
// The system is immediately added to monitoring with the provided connection and version info.
|
||||
func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver.Version, wsConn *ws.WsConn) error {
|
||||
systemRecord, err := sm.hub.FindRecordById("systems", systemId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
system := sm.NewSystem(systemId)
|
||||
system.WsConn = wsConn
|
||||
system.agentVersion = agentVersion
|
||||
|
||||
if err := sm.AddRecord(systemRecord, system); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
||||
func (sm *SystemManager) createSSHClientConfig() error {
|
||||
privateKey, err := sm.hub.GetSSHKey("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sm.sshConfig = &ssh.ClientConfig{
|
||||
User: "u",
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(privateKey),
|
||||
},
|
||||
Config: ssh.Config{
|
||||
Ciphers: common.DefaultCiphers,
|
||||
KeyExchanges: common.DefaultKeyExchanges,
|
||||
MACs: common.DefaultMACs,
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
ClientVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version),
|
||||
Timeout: sessionTimeout,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deactivateAlerts finds all triggered alerts for a system and sets them to inactive.
|
||||
// This is called when a system is paused or goes offline to prevent continued alerts.
|
||||
func deactivateAlerts(app core.App, systemID string) error {
|
||||
// Note: Direct SQL updates don't trigger SSE, so we use the PocketBase API
|
||||
// _, err := app.DB().NewQuery(fmt.Sprintf("UPDATE alerts SET triggered = false WHERE system = '%s'", systemID)).Execute()
|
||||
|
||||
alerts, err := app.FindRecordsByFilter("alerts", fmt.Sprintf("system = '%s' && triggered = 1", systemID), "", -1, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, alert := range alerts {
|
||||
alert.Set("triggered", false)
|
||||
if err := app.SaveNoValidate(alert); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
422
internal/hub/systems/systems_test.go
Normal file
422
internal/hub/systems/systems_test.go
Normal file
@@ -0,0 +1,422 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package systems_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/src/entities/container"
|
||||
"github.com/henrygd/beszel/src/entities/system"
|
||||
"github.com/henrygd/beszel/src/hub/systems"
|
||||
"github.com/henrygd/beszel/src/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSystemManagerNew(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer hub.Cleanup()
|
||||
sm := hub.GetSystemManager()
|
||||
|
||||
user, err := tests.CreateUser(hub, "test@test.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
sm.Initialize()
|
||||
|
||||
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "it-was-coney-island",
|
||||
"host": "the-playground-of-the-world",
|
||||
"port": "33914",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "pending", record.GetString("status"), "System status should be 'pending'")
|
||||
assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'")
|
||||
|
||||
// Verify the system host and port
|
||||
host, port := sm.GetSystemHostPort(record.Id)
|
||||
assert.Equal(t, record.GetString("host"), host, "System host should match")
|
||||
assert.Equal(t, record.GetString("port"), port, "System port should match")
|
||||
|
||||
time.Sleep(13 * time.Second)
|
||||
synctest.Wait()
|
||||
|
||||
assert.Equal(t, "pending", record.Fresh().GetString("status"), "System status should be 'pending'")
|
||||
// Verify the system was added by checking if it exists
|
||||
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
|
||||
|
||||
time.Sleep(10 * time.Second)
|
||||
synctest.Wait()
|
||||
|
||||
// system should be set to down after 15 seconds (no websocket connection)
|
||||
assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'")
|
||||
// make sure the system is down in the db
|
||||
record, err = hub.FindRecordById("systems", record.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "down", record.GetString("status"), "System status should be 'down'")
|
||||
|
||||
assert.Equal(t, 1, sm.GetSystemCount(), "System count should be 1")
|
||||
|
||||
err = sm.RemoveSystem(record.Id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 0, sm.GetSystemCount(), "System count should be 0")
|
||||
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after removal")
|
||||
|
||||
// let's also make sure a system is removed from the store when the record is deleted
|
||||
record, err = tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "there-was-no-place-like-it",
|
||||
"host": "in-the-whole-world",
|
||||
"port": "33914",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store after creation")
|
||||
|
||||
time.Sleep(8 * time.Second)
|
||||
synctest.Wait()
|
||||
assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'")
|
||||
|
||||
sm.SetSystemStatusInDB(record.Id, "up")
|
||||
time.Sleep(time.Second)
|
||||
synctest.Wait()
|
||||
assert.Equal(t, "up", sm.GetSystemStatusFromStore(record.Id), "System status should be 'up'")
|
||||
|
||||
// make sure the system switches to down after 11 seconds
|
||||
sm.RemoveSystem(record.Id)
|
||||
sm.AddRecord(record, nil)
|
||||
assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'")
|
||||
time.Sleep(12 * time.Second)
|
||||
synctest.Wait()
|
||||
assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'")
|
||||
|
||||
// sm.SetSystemStatusInDB(record.Id, "paused")
|
||||
// time.Sleep(time.Second)
|
||||
// synctest.Wait()
|
||||
// assert.Equal(t, "paused", sm.GetSystemStatusFromStore(record.Id), "System status should be 'paused'")
|
||||
|
||||
// delete the record
|
||||
err = hub.Delete(record)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
|
||||
})
|
||||
|
||||
testOld(t, hub)
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
time.Sleep(time.Second)
|
||||
synctest.Wait()
|
||||
|
||||
for _, systemId := range sm.GetAllSystemIDs() {
|
||||
err = sm.RemoveSystem(systemId)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, sm.HasSystem(systemId), "System should not exist in the store after deletion")
|
||||
}
|
||||
|
||||
assert.Equal(t, 0, sm.GetSystemCount(), "System count should be 0")
|
||||
|
||||
// TODO: test with websocket client
|
||||
})
|
||||
}
|
||||
|
||||
func testOld(t *testing.T, hub *tests.TestHub) {
|
||||
user, err := tests.CreateUser(hub, "test@testy.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
sm := hub.GetSystemManager()
|
||||
assert.NotNil(t, sm)
|
||||
|
||||
// error expected when creating a user with a duplicate email
|
||||
_, err = tests.CreateUser(hub, "test@test.com", "testtesttest")
|
||||
require.Error(t, err)
|
||||
|
||||
// Test collection existence. todo: move to hub package tests
|
||||
t.Run("CollectionExistence", func(t *testing.T) {
|
||||
// Verify that required collections exist
|
||||
systems, err := hub.FindCachedCollectionByNameOrId("systems")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, systems)
|
||||
|
||||
systemStats, err := hub.FindCachedCollectionByNameOrId("system_stats")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, systemStats)
|
||||
|
||||
containerStats, err := hub.FindCachedCollectionByNameOrId("container_stats")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, containerStats)
|
||||
})
|
||||
|
||||
t.Run("RemoveSystem", func(t *testing.T) {
|
||||
// Get the count before adding the system
|
||||
countBefore := sm.GetSystemCount()
|
||||
|
||||
// Create a test system record
|
||||
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "i-even-got-lost-at-coney-island",
|
||||
"host": "but-they-found-me",
|
||||
"port": "33914",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the system count increased
|
||||
countAfterAdd := sm.GetSystemCount()
|
||||
assert.Equal(t, countBefore+1, countAfterAdd, "System count should increase after adding a system via event hook")
|
||||
|
||||
// Verify the system exists
|
||||
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
|
||||
|
||||
// Remove the system
|
||||
err = sm.RemoveSystem(record.Id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check that the system count decreased
|
||||
countAfterRemove := sm.GetSystemCount()
|
||||
assert.Equal(t, countAfterAdd-1, countAfterRemove, "System count should decrease after removing a system")
|
||||
|
||||
// Verify the system no longer exists
|
||||
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after removal")
|
||||
|
||||
// Verify the system is not in the list of all system IDs
|
||||
ids := sm.GetAllSystemIDs()
|
||||
assert.NotContains(t, ids, record.Id, "System ID should not be in the list of all system IDs after removal")
|
||||
|
||||
// Verify the system status is empty
|
||||
status := sm.GetSystemStatusFromStore(record.Id)
|
||||
assert.Equal(t, "", status, "System status should be empty after removal")
|
||||
|
||||
// Try to remove it again - should return an error since it's already removed
|
||||
err = sm.RemoveSystem(record.Id)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("NewRecordPending", func(t *testing.T) {
|
||||
// Create a test system
|
||||
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "and-you-know",
|
||||
"host": "i-feel-very-bad",
|
||||
"port": "33914",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add the record to the system manager
|
||||
err = sm.AddRecord(record, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test filtering records by status - should be "pending" now
|
||||
filter := "status = 'pending'"
|
||||
pendingSystems, err := hub.FindRecordsByFilter("systems", filter, "-created", 0, 0, nil)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(pendingSystems), 1)
|
||||
})
|
||||
|
||||
t.Run("SystemStatusUpdate", func(t *testing.T) {
|
||||
// Create a test system record
|
||||
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "we-used-to-sleep-on-the-beach",
|
||||
"host": "sleep-overnight-here",
|
||||
"port": "33914",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add the record to the system manager
|
||||
err = sm.AddRecord(record, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test status changes
|
||||
initialStatus := sm.GetSystemStatusFromStore(record.Id)
|
||||
|
||||
// Set a new status
|
||||
sm.SetSystemStatusInDB(record.Id, "up")
|
||||
|
||||
// Verify status was updated
|
||||
newStatus := sm.GetSystemStatusFromStore(record.Id)
|
||||
assert.Equal(t, "up", newStatus, "System status should be updated to 'up'")
|
||||
assert.NotEqual(t, initialStatus, newStatus, "Status should have changed")
|
||||
|
||||
// Verify the database was updated
|
||||
updatedRecord, err := hub.FindRecordById("systems", record.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "up", updatedRecord.Get("status"), "Database status should match")
|
||||
})
|
||||
|
||||
t.Run("HandleSystemData", func(t *testing.T) {
|
||||
// Create a test system record
|
||||
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "things-changed-you-know",
|
||||
"host": "they-dont-sleep-anymore-on-the-beach",
|
||||
"port": "33914",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create test system data
|
||||
testData := &system.CombinedData{
|
||||
Info: system.Info{
|
||||
Hostname: "data-test.example.com",
|
||||
KernelVersion: "5.15.0-generic",
|
||||
Cores: 4,
|
||||
Threads: 8,
|
||||
CpuModel: "Test CPU",
|
||||
Uptime: 3600,
|
||||
Cpu: 25.5,
|
||||
MemPct: 40.2,
|
||||
DiskPct: 60.0,
|
||||
Bandwidth: 100.0,
|
||||
AgentVersion: "1.0.0",
|
||||
},
|
||||
Stats: system.Stats{
|
||||
Cpu: 25.5,
|
||||
Mem: 16384.0,
|
||||
MemUsed: 6553.6,
|
||||
MemPct: 40.0,
|
||||
DiskTotal: 1024000.0,
|
||||
DiskUsed: 614400.0,
|
||||
DiskPct: 60.0,
|
||||
NetworkSent: 1024.0,
|
||||
NetworkRecv: 2048.0,
|
||||
},
|
||||
Containers: []*container.Stats{},
|
||||
}
|
||||
|
||||
// Test handling system data. todo: move to hub/alerts package tests
|
||||
err = hub.HandleSystemAlerts(record, testData)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("ErrorHandling", func(t *testing.T) {
|
||||
// Try to add a non-existent record
|
||||
nonExistentId := "non_existent_id"
|
||||
err := sm.RemoveSystem(nonExistentId)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Try to add a system with invalid host
|
||||
system := &systems.System{
|
||||
Host: "",
|
||||
}
|
||||
err = sm.AddSystem(system)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("ConcurrentOperations", func(t *testing.T) {
|
||||
// Create a test system
|
||||
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "jfkjahkfajs",
|
||||
"host": "localhost",
|
||||
"port": "33914",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run concurrent operations
|
||||
const goroutines = 5
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
|
||||
for i := range goroutines {
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
|
||||
// Alternate between different operations
|
||||
switch i % 3 {
|
||||
case 0:
|
||||
status := fmt.Sprintf("status-%d", i)
|
||||
sm.SetSystemStatusInDB(record.Id, status)
|
||||
case 1:
|
||||
_ = sm.GetSystemStatusFromStore(record.Id)
|
||||
case 2:
|
||||
_, _ = sm.GetSystemHostPort(record.Id)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify system still exists and is in a valid state
|
||||
assert.True(t, sm.HasSystem(record.Id), "System should still exist after concurrent operations")
|
||||
status := sm.GetSystemStatusFromStore(record.Id)
|
||||
assert.NotEmpty(t, status, "System should have a status after concurrent operations")
|
||||
})
|
||||
|
||||
t.Run("ContextCancellation", func(t *testing.T) {
|
||||
// Create a test system record
|
||||
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "lkhsdfsjf",
|
||||
"host": "localhost",
|
||||
"port": "33914",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the system exists in the store
|
||||
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
|
||||
|
||||
// Store the original context and cancel function
|
||||
originalCtx, originalCancel, err := sm.GetSystemContextFromStore(record.Id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Ensure the context is not nil
|
||||
assert.NotNil(t, originalCtx, "System context should not be nil")
|
||||
assert.NotNil(t, originalCancel, "System cancel function should not be nil")
|
||||
|
||||
// Cancel the context
|
||||
originalCancel()
|
||||
|
||||
// Wait a short time for cancellation to propagate
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Verify the context is done
|
||||
select {
|
||||
case <-originalCtx.Done():
|
||||
// Context was properly cancelled
|
||||
default:
|
||||
t.Fatal("Context was not cancelled")
|
||||
}
|
||||
|
||||
// Verify the system is still in the store (cancellation shouldn't remove it)
|
||||
assert.True(t, sm.HasSystem(record.Id), "System should still exist after context cancellation")
|
||||
|
||||
// Explicitly remove the system
|
||||
err = sm.RemoveSystem(record.Id)
|
||||
assert.NoError(t, err, "RemoveSystem should succeed")
|
||||
|
||||
// Verify the system is removed
|
||||
assert.False(t, sm.HasSystem(record.Id), "System should be removed after RemoveSystem")
|
||||
|
||||
// Try to remove it again - should return an error
|
||||
err = sm.RemoveSystem(record.Id)
|
||||
assert.Error(t, err, "RemoveSystem should fail for non-existent system")
|
||||
|
||||
// Add the system back
|
||||
err = sm.AddRecord(record, nil)
|
||||
require.NoError(t, err, "AddRecord should succeed")
|
||||
|
||||
// Verify the system is back in the store
|
||||
assert.True(t, sm.HasSystem(record.Id), "System should exist after re-adding")
|
||||
|
||||
// Verify a new context was created
|
||||
newCtx, newCancel, err := sm.GetSystemContextFromStore(record.Id)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, newCtx, "New system context should not be nil")
|
||||
assert.NotNil(t, newCancel, "New system cancel function should not be nil")
|
||||
assert.NotEqual(t, originalCtx, newCtx, "New context should be different from original")
|
||||
|
||||
// Clean up
|
||||
err = sm.RemoveSystem(record.Id)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
110
internal/hub/systems/systems_test_helpers.go
Normal file
110
internal/hub/systems/systems_test_helpers.go
Normal file
@@ -0,0 +1,110 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package systems
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
entities "github.com/henrygd/beszel/src/entities/system"
|
||||
)
|
||||
|
||||
// TESTING ONLY: GetSystemCount returns the number of systems in the store
|
||||
func (sm *SystemManager) GetSystemCount() int {
|
||||
return sm.systems.Length()
|
||||
}
|
||||
|
||||
// TESTING ONLY: HasSystem checks if a system with the given ID exists in the store
|
||||
func (sm *SystemManager) HasSystem(systemID string) bool {
|
||||
return sm.systems.Has(systemID)
|
||||
}
|
||||
|
||||
// TESTING ONLY: GetSystemStatusFromStore returns the status of a system with the given ID
|
||||
// Returns an empty string if the system doesn't exist
|
||||
func (sm *SystemManager) GetSystemStatusFromStore(systemID string) string {
|
||||
sys, ok := sm.systems.GetOk(systemID)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return sys.Status
|
||||
}
|
||||
|
||||
// TESTING ONLY: GetSystemContextFromStore returns the context and cancel function for a system
|
||||
func (sm *SystemManager) GetSystemContextFromStore(systemID string) (context.Context, context.CancelFunc, error) {
|
||||
sys, ok := sm.systems.GetOk(systemID)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("no system")
|
||||
}
|
||||
return sys.ctx, sys.cancel, nil
|
||||
}
|
||||
|
||||
// TESTING ONLY: GetSystemFromStore returns a store from the system
|
||||
func (sm *SystemManager) GetSystemFromStore(systemID string) (*System, error) {
|
||||
sys, ok := sm.systems.GetOk(systemID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no system")
|
||||
}
|
||||
return sys, nil
|
||||
}
|
||||
|
||||
// TESTING ONLY: GetAllSystemIDs returns a slice of all system IDs in the store
|
||||
func (sm *SystemManager) GetAllSystemIDs() []string {
|
||||
data := sm.systems.GetAll()
|
||||
ids := make([]string, 0, len(data))
|
||||
for id := range data {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// TESTING ONLY: GetSystemData returns the combined data for a system with the given ID
|
||||
// Returns nil if the system doesn't exist
|
||||
// This method is intended for testing
|
||||
func (sm *SystemManager) GetSystemData(systemID string) *entities.CombinedData {
|
||||
sys, ok := sm.systems.GetOk(systemID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return sys.data
|
||||
}
|
||||
|
||||
// TESTING ONLY: GetSystemHostPort returns the host and port for a system with the given ID
|
||||
// Returns empty strings if the system doesn't exist
|
||||
func (sm *SystemManager) GetSystemHostPort(systemID string) (string, string) {
|
||||
sys, ok := sm.systems.GetOk(systemID)
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
return sys.Host, sys.Port
|
||||
}
|
||||
|
||||
// TESTING ONLY: SetSystemStatusInDB sets the status of a system directly and updates the database record
|
||||
// This is intended for testing
|
||||
// Returns false if the system doesn't exist
|
||||
func (sm *SystemManager) SetSystemStatusInDB(systemID string, status string) bool {
|
||||
if !sm.HasSystem(systemID) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Update the database record
|
||||
record, err := sm.hub.FindRecordById("systems", systemID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
record.Set("status", status)
|
||||
err = sm.hub.Save(record)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// TESTING ONLY: RemoveAllSystems removes all systems from the store
|
||||
func (sm *SystemManager) RemoveAllSystems() {
|
||||
for _, system := range sm.systems.GetAll() {
|
||||
sm.RemoveSystem(system.Id)
|
||||
}
|
||||
}
|
85
internal/hub/update.go
Normal file
85
internal/hub/update.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/henrygd/beszel/src/ghupdate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Update updates beszel to the latest version
|
||||
func Update(cmd *cobra.Command, _ []string) {
|
||||
dataDir := os.TempDir()
|
||||
|
||||
// set dataDir to ./beszel_data if it exists
|
||||
if _, err := os.Stat("./beszel_data"); err == nil {
|
||||
dataDir = "./beszel_data"
|
||||
}
|
||||
|
||||
// Check if china-mirrors flag is set
|
||||
useMirror, _ := cmd.Flags().GetBool("china-mirrors")
|
||||
|
||||
updated, err := ghupdate.Update(ghupdate.Config{
|
||||
ArchiveExecutable: "beszel",
|
||||
DataDir: dataDir,
|
||||
UseMirror: useMirror,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if !updated {
|
||||
return
|
||||
}
|
||||
|
||||
// make sure the file is executable
|
||||
exePath, err := os.Executable()
|
||||
if err == nil {
|
||||
if err := os.Chmod(exePath, 0755); err != nil {
|
||||
fmt.Printf("Warning: failed to set executable permissions: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Try to restart the service if it's running
|
||||
restartService()
|
||||
}
|
||||
|
||||
// restartService attempts to restart the beszel service
|
||||
func restartService() {
|
||||
// Check if we're running as a service by looking for systemd
|
||||
if _, err := exec.LookPath("systemctl"); err == nil {
|
||||
// Check if beszel service exists and is active
|
||||
cmd := exec.Command("systemctl", "is-active", "beszel.service")
|
||||
if err := cmd.Run(); err == nil {
|
||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
|
||||
restartCmd := exec.Command("systemctl", "restart", "beszel.service")
|
||||
if err := restartCmd.Run(); err != nil {
|
||||
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
|
||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo systemctl restart beszel")
|
||||
} else {
|
||||
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check for OpenRC (Alpine Linux)
|
||||
if _, err := exec.LookPath("rc-service"); err == nil {
|
||||
cmd := exec.Command("rc-service", "beszel", "status")
|
||||
if err := cmd.Run(); err == nil {
|
||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
|
||||
restartCmd := exec.Command("rc-service", "beszel", "restart")
|
||||
if err := restartCmd.Run(); err != nil {
|
||||
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
|
||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo rc-service beszel restart")
|
||||
} else {
|
||||
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Service restart not attempted. If running as a service, restart manually.")
|
||||
}
|
182
internal/hub/ws/ws.go
Normal file
182
internal/hub/ws/ws.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
"weak"
|
||||
|
||||
"github.com/henrygd/beszel/src/entities/system"
|
||||
|
||||
"github.com/henrygd/beszel/src/common"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/lxzan/gws"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
deadline = 70 * time.Second
|
||||
)
|
||||
|
||||
// Handler implements the WebSocket event handler for agent connections.
|
||||
type Handler struct {
|
||||
gws.BuiltinEventHandler
|
||||
}
|
||||
|
||||
// WsConn represents a WebSocket connection to an agent.
|
||||
type WsConn struct {
|
||||
conn *gws.Conn
|
||||
responseChan chan *gws.Message
|
||||
DownChan chan struct{}
|
||||
}
|
||||
|
||||
// FingerprintRecord is fingerprints collection record data in the hub
|
||||
type FingerprintRecord struct {
|
||||
Id string `db:"id"`
|
||||
SystemId string `db:"system"`
|
||||
Fingerprint string `db:"fingerprint"`
|
||||
Token string `db:"token"`
|
||||
}
|
||||
|
||||
var upgrader *gws.Upgrader
|
||||
|
||||
// GetUpgrader returns a singleton WebSocket upgrader instance.
|
||||
func GetUpgrader() *gws.Upgrader {
|
||||
if upgrader != nil {
|
||||
return upgrader
|
||||
}
|
||||
handler := &Handler{}
|
||||
upgrader = gws.NewUpgrader(handler, &gws.ServerOption{})
|
||||
return upgrader
|
||||
}
|
||||
|
||||
// NewWsConnection creates a new WebSocket connection wrapper.
|
||||
func NewWsConnection(conn *gws.Conn) *WsConn {
|
||||
return &WsConn{
|
||||
conn: conn,
|
||||
responseChan: make(chan *gws.Message, 1),
|
||||
DownChan: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// OnOpen sets a deadline for the WebSocket connection.
|
||||
func (h *Handler) OnOpen(conn *gws.Conn) {
|
||||
conn.SetDeadline(time.Now().Add(deadline))
|
||||
}
|
||||
|
||||
// OnMessage routes incoming WebSocket messages to the response channel.
|
||||
func (h *Handler) OnMessage(conn *gws.Conn, message *gws.Message) {
|
||||
conn.SetDeadline(time.Now().Add(deadline))
|
||||
if message.Opcode != gws.OpcodeBinary || message.Data.Len() == 0 {
|
||||
return
|
||||
}
|
||||
wsConn, ok := conn.Session().Load("wsConn")
|
||||
if !ok {
|
||||
_ = conn.WriteClose(1000, nil)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case wsConn.(*WsConn).responseChan <- message:
|
||||
default:
|
||||
// close if the connection is not expecting a response
|
||||
wsConn.(*WsConn).Close(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// OnClose handles WebSocket connection closures and triggers system down status after delay.
|
||||
func (h *Handler) OnClose(conn *gws.Conn, err error) {
|
||||
wsConn, ok := conn.Session().Load("wsConn")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wsConn.(*WsConn).conn = nil
|
||||
// wait 5 seconds to allow reconnection before setting system down
|
||||
// use a weak pointer to avoid keeping references if the system is removed
|
||||
go func(downChan weak.Pointer[chan struct{}]) {
|
||||
time.Sleep(5 * time.Second)
|
||||
downChanValue := downChan.Value()
|
||||
if downChanValue != nil {
|
||||
*downChanValue <- struct{}{}
|
||||
}
|
||||
}(weak.Make(&wsConn.(*WsConn).DownChan))
|
||||
}
|
||||
|
||||
// Close terminates the WebSocket connection gracefully.
|
||||
func (ws *WsConn) Close(msg []byte) {
|
||||
if ws.IsConnected() {
|
||||
ws.conn.WriteClose(1000, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Ping sends a ping frame to keep the connection alive.
|
||||
func (ws *WsConn) Ping() error {
|
||||
ws.conn.SetDeadline(time.Now().Add(deadline))
|
||||
return ws.conn.WritePing(nil)
|
||||
}
|
||||
|
||||
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
|
||||
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
|
||||
if ws.conn == nil {
|
||||
return gws.ErrConnClosed
|
||||
}
|
||||
bytes, err := cbor.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ws.conn.WriteMessage(gws.OpcodeBinary, bytes)
|
||||
}
|
||||
|
||||
// RequestSystemData requests system metrics from the agent and unmarshals the response.
|
||||
func (ws *WsConn) RequestSystemData(data *system.CombinedData) error {
|
||||
var message *gws.Message
|
||||
|
||||
ws.sendMessage(common.HubRequest[any]{
|
||||
Action: common.GetData,
|
||||
})
|
||||
select {
|
||||
case <-time.After(10 * time.Second):
|
||||
ws.Close(nil)
|
||||
return gws.ErrConnClosed
|
||||
case message = <-ws.responseChan:
|
||||
}
|
||||
defer message.Close()
|
||||
return cbor.Unmarshal(message.Data.Bytes(), data)
|
||||
}
|
||||
|
||||
// GetFingerprint authenticates with the agent using SSH signature and returns the agent's fingerprint.
|
||||
func (ws *WsConn) GetFingerprint(token string, signer ssh.Signer, needSysInfo bool) (common.FingerprintResponse, error) {
|
||||
var clientFingerprint common.FingerprintResponse
|
||||
challenge := []byte(token)
|
||||
|
||||
signature, err := signer.Sign(nil, challenge)
|
||||
if err != nil {
|
||||
return clientFingerprint, err
|
||||
}
|
||||
|
||||
err = ws.sendMessage(common.HubRequest[any]{
|
||||
Action: common.CheckFingerprint,
|
||||
Data: common.FingerprintRequest{
|
||||
Signature: signature.Blob,
|
||||
NeedSysInfo: needSysInfo,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return clientFingerprint, err
|
||||
}
|
||||
|
||||
var message *gws.Message
|
||||
select {
|
||||
case message = <-ws.responseChan:
|
||||
case <-time.After(10 * time.Second):
|
||||
return clientFingerprint, errors.New("request expired")
|
||||
}
|
||||
defer message.Close()
|
||||
|
||||
err = cbor.Unmarshal(message.Data.Bytes(), &clientFingerprint)
|
||||
return clientFingerprint, err
|
||||
}
|
||||
|
||||
// IsConnected returns true if the WebSocket connection is active.
|
||||
func (ws *WsConn) IsConnected() bool {
|
||||
return ws.conn != nil
|
||||
}
|
222
internal/hub/ws/ws_test.go
Normal file
222
internal/hub/ws/ws_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package ws
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/src/common"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// TestGetUpgrader tests the singleton upgrader
|
||||
func TestGetUpgrader(t *testing.T) {
|
||||
// Reset the global upgrader to test singleton behavior
|
||||
upgrader = nil
|
||||
|
||||
// First call should create the upgrader
|
||||
upgrader1 := GetUpgrader()
|
||||
assert.NotNil(t, upgrader1, "Upgrader should not be nil")
|
||||
|
||||
// Second call should return the same instance
|
||||
upgrader2 := GetUpgrader()
|
||||
assert.Same(t, upgrader1, upgrader2, "Should return the same upgrader instance")
|
||||
|
||||
// Verify it's properly configured
|
||||
assert.NotNil(t, upgrader1, "Upgrader should be configured")
|
||||
}
|
||||
|
||||
// TestNewWsConnection tests WebSocket connection creation
|
||||
func TestNewWsConnection(t *testing.T) {
|
||||
// We can't easily mock gws.Conn, so we'll pass nil and test the structure
|
||||
wsConn := NewWsConnection(nil)
|
||||
|
||||
assert.NotNil(t, wsConn, "WebSocket connection should not be nil")
|
||||
assert.Nil(t, wsConn.conn, "Connection should be nil as passed")
|
||||
assert.NotNil(t, wsConn.responseChan, "Response channel should be initialized")
|
||||
assert.NotNil(t, wsConn.DownChan, "Down channel should be initialized")
|
||||
assert.Equal(t, 1, cap(wsConn.responseChan), "Response channel should have capacity of 1")
|
||||
assert.Equal(t, 1, cap(wsConn.DownChan), "Down channel should have capacity of 1")
|
||||
}
|
||||
|
||||
// TestWsConn_IsConnected tests the connection status check
|
||||
func TestWsConn_IsConnected(t *testing.T) {
|
||||
// Test with nil connection
|
||||
wsConn := NewWsConnection(nil)
|
||||
assert.False(t, wsConn.IsConnected(), "Should not be connected when conn is nil")
|
||||
}
|
||||
|
||||
// TestWsConn_Close tests the connection closing with nil connection
|
||||
func TestWsConn_Close(t *testing.T) {
|
||||
wsConn := NewWsConnection(nil)
|
||||
|
||||
// Should handle nil connection gracefully
|
||||
assert.NotPanics(t, func() {
|
||||
wsConn.Close([]byte("test message"))
|
||||
}, "Should not panic when closing nil connection")
|
||||
}
|
||||
|
||||
// TestWsConn_SendMessage_CBOR tests CBOR encoding in sendMessage
|
||||
func TestWsConn_SendMessage_CBOR(t *testing.T) {
|
||||
wsConn := NewWsConnection(nil)
|
||||
|
||||
testData := common.HubRequest[any]{
|
||||
Action: common.GetData,
|
||||
Data: "test data",
|
||||
}
|
||||
|
||||
// This will fail because conn is nil, but we can test the CBOR encoding logic
|
||||
// by checking that the function properly encodes to CBOR before failing
|
||||
err := wsConn.sendMessage(testData)
|
||||
assert.Error(t, err, "Should error with nil connection")
|
||||
|
||||
// Test CBOR encoding separately
|
||||
bytes, err := cbor.Marshal(testData)
|
||||
assert.NoError(t, err, "Should encode to CBOR successfully")
|
||||
|
||||
// Verify we can decode it back
|
||||
var decodedData common.HubRequest[any]
|
||||
err = cbor.Unmarshal(bytes, &decodedData)
|
||||
assert.NoError(t, err, "Should decode from CBOR successfully")
|
||||
assert.Equal(t, testData.Action, decodedData.Action, "Action should match")
|
||||
}
|
||||
|
||||
// TestWsConn_GetFingerprint_SignatureGeneration tests signature creation logic
|
||||
func TestWsConn_GetFingerprint_SignatureGeneration(t *testing.T) {
|
||||
// Generate test key pair
|
||||
_, privKey, err := ed25519.GenerateKey(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
signer, err := ssh.NewSignerFromKey(privKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
token := "test-token"
|
||||
|
||||
// This will timeout since conn is nil, but we can verify the signature logic
|
||||
// We can't test the full flow, but we can test that the signature is created properly
|
||||
challenge := []byte(token)
|
||||
signature, err := signer.Sign(nil, challenge)
|
||||
assert.NoError(t, err, "Should create signature successfully")
|
||||
assert.NotEmpty(t, signature.Blob, "Signature blob should not be empty")
|
||||
assert.Equal(t, signer.PublicKey().Type(), signature.Format, "Signature format should match key type")
|
||||
|
||||
// Test the fingerprint request structure
|
||||
fpRequest := common.FingerprintRequest{
|
||||
Signature: signature.Blob,
|
||||
NeedSysInfo: true,
|
||||
}
|
||||
|
||||
// Test CBOR encoding of fingerprint request
|
||||
fpData, err := cbor.Marshal(fpRequest)
|
||||
assert.NoError(t, err, "Should encode fingerprint request to CBOR")
|
||||
|
||||
var decodedFpRequest common.FingerprintRequest
|
||||
err = cbor.Unmarshal(fpData, &decodedFpRequest)
|
||||
assert.NoError(t, err, "Should decode fingerprint request from CBOR")
|
||||
assert.Equal(t, fpRequest.Signature, decodedFpRequest.Signature, "Signature should match")
|
||||
assert.Equal(t, fpRequest.NeedSysInfo, decodedFpRequest.NeedSysInfo, "NeedSysInfo should match")
|
||||
|
||||
// Test the full hub request structure
|
||||
hubRequest := common.HubRequest[any]{
|
||||
Action: common.CheckFingerprint,
|
||||
Data: fpRequest,
|
||||
}
|
||||
|
||||
hubData, err := cbor.Marshal(hubRequest)
|
||||
assert.NoError(t, err, "Should encode hub request to CBOR")
|
||||
|
||||
var decodedHubRequest common.HubRequest[cbor.RawMessage]
|
||||
err = cbor.Unmarshal(hubData, &decodedHubRequest)
|
||||
assert.NoError(t, err, "Should decode hub request from CBOR")
|
||||
assert.Equal(t, common.CheckFingerprint, decodedHubRequest.Action, "Action should be CheckFingerprint")
|
||||
}
|
||||
|
||||
// TestWsConn_RequestSystemData_RequestFormat tests system data request format
|
||||
func TestWsConn_RequestSystemData_RequestFormat(t *testing.T) {
|
||||
// Test the request format that would be sent
|
||||
request := common.HubRequest[any]{
|
||||
Action: common.GetData,
|
||||
}
|
||||
|
||||
// Test CBOR encoding
|
||||
data, err := cbor.Marshal(request)
|
||||
assert.NoError(t, err, "Should encode request to CBOR")
|
||||
|
||||
// Test decoding
|
||||
var decodedRequest common.HubRequest[any]
|
||||
err = cbor.Unmarshal(data, &decodedRequest)
|
||||
assert.NoError(t, err, "Should decode request from CBOR")
|
||||
assert.Equal(t, common.GetData, decodedRequest.Action, "Should have GetData action")
|
||||
}
|
||||
|
||||
// TestFingerprintRecord tests the FingerprintRecord struct
|
||||
func TestFingerprintRecord(t *testing.T) {
|
||||
record := FingerprintRecord{
|
||||
Id: "test-id",
|
||||
SystemId: "system-123",
|
||||
Fingerprint: "test-fingerprint",
|
||||
Token: "test-token",
|
||||
}
|
||||
|
||||
assert.Equal(t, "test-id", record.Id)
|
||||
assert.Equal(t, "system-123", record.SystemId)
|
||||
assert.Equal(t, "test-fingerprint", record.Fingerprint)
|
||||
assert.Equal(t, "test-token", record.Token)
|
||||
}
|
||||
|
||||
// TestDeadlineConstant tests that the deadline constant is reasonable
|
||||
func TestDeadlineConstant(t *testing.T) {
|
||||
assert.Equal(t, 70*time.Second, deadline, "Deadline should be 70 seconds")
|
||||
}
|
||||
|
||||
// TestCommonActions tests that the common actions are properly defined
|
||||
func TestCommonActions(t *testing.T) {
|
||||
// Test that the actions we use exist and have expected values
|
||||
assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0")
|
||||
assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1")
|
||||
}
|
||||
|
||||
// TestHandler tests that we can create a Handler
|
||||
func TestHandler(t *testing.T) {
|
||||
handler := &Handler{}
|
||||
assert.NotNil(t, handler, "Handler should be created successfully")
|
||||
|
||||
// The Handler embeds gws.BuiltinEventHandler, so it should have the embedded type
|
||||
assert.NotNil(t, handler.BuiltinEventHandler, "Should have embedded BuiltinEventHandler")
|
||||
}
|
||||
|
||||
// TestWsConnChannelBehavior tests channel behavior without WebSocket connections
|
||||
func TestWsConnChannelBehavior(t *testing.T) {
|
||||
wsConn := NewWsConnection(nil)
|
||||
|
||||
// Test that channels are properly initialized and can be used
|
||||
select {
|
||||
case wsConn.DownChan <- struct{}{}:
|
||||
// Should be able to write to channel
|
||||
default:
|
||||
t.Error("Should be able to write to DownChan")
|
||||
}
|
||||
|
||||
// Test reading from DownChan
|
||||
select {
|
||||
case <-wsConn.DownChan:
|
||||
// Should be able to read from channel
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
t.Error("Should be able to read from DownChan")
|
||||
}
|
||||
|
||||
// Response channel should be empty initially
|
||||
select {
|
||||
case <-wsConn.responseChan:
|
||||
t.Error("Response channel should be empty initially")
|
||||
default:
|
||||
// Expected - channel should be empty
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user