Compare commits

..

23 Commits

Author SHA1 Message Date
henrygd
6107f52d07 Fix system path in notification urls 2025-05-08 19:06:19 -04:00
henrygd
f4fb7a89e5 Add tests for GetSSHKey and handle read errors on key file 2025-05-08 18:54:14 -04:00
henrygd
5439066f4d hub.MakeLink method to assure URLs are formatted properly (#805)
- Updated AlertManager to replace direct app references with a hub interface.
- Changed AlertManager.app to AlertManager.hub
- Add tests for MakeLink
2025-05-08 17:47:15 -04:00
henrygd
7c18f3d8b4 Add mipsle agent build (#802) 2025-05-07 20:11:48 -04:00
henrygd
63af81666b Refactor SSH configuration and key management
- Restrict to specific key exchanges / MACs / ciphers.
- Refactored GetSSHKey method to return an ssh.Signer instead of byte array.
- Added common package.

Co-authored-by: nhas <jordanatararimu@gmail.com>
2025-05-07 20:03:21 -04:00
henrygd
c0a6153a43 Update goreleaser configuration for beszel-agent to include restart delay and process type 2025-05-05 20:22:15 -04:00
henrygd
df334caca6 update install-agent.ps1 to support installing as admin (#797) 2025-05-05 20:21:37 -04:00
henrygd
ffb3ec0477 Fix broken link to notifications when using base path 2025-05-02 20:02:23 -04:00
henrygd
3a97edd0d5 add winget support to windows install script 2025-05-01 17:40:20 -04:00
henrygd
ab1d1c1273 Remove PrivateTmp setting from Systemd rules in install-agent.sh
Allows sharing socket in /tmp
2025-05-01 17:00:08 -04:00
henrygd
0fb39edae4 rename ssh imports in server.go 2025-04-30 18:09:25 -04:00
henrygd
3a977a8e1f goreleaser: update deprecated format field 2025-04-30 16:15:37 -04:00
henrygd
081979de24 Add winget configuration for beszel-agent 2025-04-30 15:00:26 -04:00
henrygd
23fe189797 Add SELinux context management to install-agent.sh (#788)
- Introduced functions to set and clean up SELinux contexts.
- Added SELinux context checks during the update process (systemd only).
- Updated the service execution command to use a dedicated update script.
2025-04-29 18:59:36 -04:00
henrygd
e9d429b9b8 Enhance service start check in install-agent.ps1
- Added logic to handle service start failures and check status with retries.
- Improved user feedback for service status during startup process.
2025-04-28 21:47:02 -04:00
henrygd
99202c85b6 re-enable docker image creation workflow 2025-04-28 21:16:32 -04:00
henrygd
d5c3d8f84e release 0.11.1 with new line so goreleaser doesn't fail :)
- Temp disable docker workflow bc image was already build
2025-04-28 20:57:27 -04:00
Alexander Mnich
8f442992e6 New German translations 2025-04-28 20:48:38 -04:00
henrygd
39820c8ac1 release 0.11.1 2025-04-28 20:38:24 -04:00
henrygd
0c8b10af99 Escape backslashes in windows agent install command (#785) 2025-04-28 20:37:25 -04:00
henrygd
8e072492b7 Skip checking Docker if DOCKER_HOST is set to an empty string 2025-04-28 20:23:54 -04:00
henrygd
88d6307ce0 Add FreeBSD icon 2025-04-28 19:37:00 -04:00
henrygd
2cc516f9e5 update readme and notifications link 2025-04-27 20:05:07 -04:00
25 changed files with 854 additions and 208 deletions

View File

@@ -37,6 +37,7 @@ builds:
- arm - arm
- mips64 - mips64
- riscv64 - riscv64
- mipsle
ignore: ignore:
- goos: freebsd - goos: freebsd
goarch: arm goarch: arm
@@ -51,7 +52,7 @@ builds:
archives: archives:
- id: beszel-agent - id: beszel-agent
format: tar.gz formats: [tar.gz]
builds: builds:
- beszel-agent - beszel-agent
name_template: >- name_template: >-
@@ -60,10 +61,10 @@ archives:
{{- .Arch }} {{- .Arch }}
format_overrides: format_overrides:
- goos: windows - goos: windows
format: zip formats: [zip]
- id: beszel - id: beszel
format: tar.gz formats: [tar.gz]
builds: builds:
- beszel - beszel
name_template: >- name_template: >-
@@ -87,9 +88,6 @@ nfpms:
- beszel-agent - beszel-agent
formats: formats:
- deb - deb
# don't think this is needed with CGO_ENABLED=0
# dependencies:
# - libc6
contents: contents:
- src: ../supplemental/debian/beszel-agent.service - src: ../supplemental/debian/beszel-agent.service
dst: lib/systemd/system/beszel-agent.service dst: lib/systemd/system/beszel-agent.service
@@ -172,6 +170,44 @@ brews:
log_path "#{Dir.home}/.cache/beszel/beszel-agent.log" log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log" error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
keep_alive true keep_alive true
restart_delay 5
name beszel-agent
process_type :background
winget:
- ids: [beszel-agent]
name: beszel-agent
package_identifier: henrygd.beszel-agent
publisher: henrygd
license: MIT
license_url: 'https://github.com/henrygd/beszel/blob/main/LICENSE'
copyright: '2025 henrygd'
homepage: 'https://beszel.dev'
release_notes_url: 'https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}'
publisher_support_url: 'https://github.com/henrygd/beszel/issues'
short_description: 'Agent for Beszel, a lightweight server monitoring platform.'
skip_upload: auto
description: |
Beszel is a lightweight server monitoring platform that includes Docker
statistics, historical data, and alert functions. It has a friendly web
interface, simple configuration, and is ready to use out of the box.
It supports automatic backup, multi-user, OAuth authentication, and
API access.
tags:
- homelab
- monitoring
- self-hosted
repository:
owner: henrygd
name: beszel-winget
branch: henrygd.beszel-agent-{{ .Version }}
pull_request:
enabled: false
draft: false
base:
owner: microsoft
name: winget-pkgs
branch: master
release: release:
draft: true draft: true

View File

@@ -95,11 +95,13 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
} }
slog.Debug("System stats", "data", cachedData) slog.Debug("System stats", "data", cachedData)
if containerStats, err := a.dockerManager.getDockerStats(); err == nil { if a.dockerManager != nil {
cachedData.Containers = containerStats if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
slog.Debug("Docker stats", "data", cachedData.Containers) cachedData.Containers = containerStats
} else { slog.Debug("Docker stats", "data", cachedData.Containers)
slog.Debug("Docker stats", "err", err) } else {
slog.Debug("Docker stats", "err", err)
}
} }
cachedData.Stats.ExtraFs = make(map[string]*system.FsStats) cachedData.Stats.ExtraFs = make(map[string]*system.FsStats)

View File

@@ -232,6 +232,10 @@ func newDockerManager(a *Agent) *dockerManager {
dockerHost, exists := GetEnv("DOCKER_HOST") dockerHost, exists := GetEnv("DOCKER_HOST")
if exists { if exists {
slog.Info("DOCKER_HOST", "host", dockerHost) slog.Info("DOCKER_HOST", "host", dockerHost)
// return nil if set to empty string
if dockerHost == "" {
return nil
}
} else { } else {
dockerHost = getDockerHost() dockerHost = getDockerHost()
} }

View File

@@ -1,6 +1,7 @@
package agent package agent
import ( import (
"beszel/internal/common"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -8,19 +9,17 @@ import (
"os" "os"
"strings" "strings"
sshServer "github.com/gliderlabs/ssh" "github.com/gliderlabs/ssh"
"golang.org/x/crypto/ssh" gossh "golang.org/x/crypto/ssh"
) )
type ServerOptions struct { type ServerOptions struct {
Addr string Addr string
Network string Network string
Keys []ssh.PublicKey Keys []gossh.PublicKey
} }
func (a *Agent) StartServer(opts ServerOptions) error { func (a *Agent) StartServer(opts ServerOptions) error {
sshServer.Handle(a.handleSession)
slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network) slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network)
if opts.Network == "unix" { if opts.Network == "unix" {
@@ -37,33 +36,57 @@ func (a *Agent) StartServer(opts ServerOptions) error {
} }
defer ln.Close() defer ln.Close()
// Start SSH server on the listener // base config (limit to allowed algorithms)
return sshServer.Serve(ln, nil, sshServer.NoPty(), config := &gossh.ServerConfig{}
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool { config.KeyExchanges = common.DefaultKeyExchanges
config.MACs = common.DefaultMACs
config.Ciphers = common.DefaultCiphers
// set default handler
ssh.Handle(a.handleSession)
server := ssh.Server{
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
return config
},
// check public key(s)
PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
for _, pubKey := range opts.Keys { for _, pubKey := range opts.Keys {
if sshServer.KeysEqual(key, pubKey) { if ssh.KeysEqual(key, pubKey) {
return true return true
} }
} }
return false return false
}), },
) // disable pty
PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool {
return false
},
// log failed connections
ConnectionFailedCallback: func(conn net.Conn, err error) {
slog.Warn("Failed connection attempt", "addr", conn.RemoteAddr().String(), "err", err)
},
}
// Start SSH server on the listener
return server.Serve(ln)
} }
func (a *Agent) handleSession(s sshServer.Session) { func (a *Agent) handleSession(s ssh.Session) {
slog.Debug("New session", "client", s.RemoteAddr()) slog.Debug("New session", "client", s.RemoteAddr())
stats := a.gatherStats(s.Context().SessionID()) stats := a.gatherStats(s.Context().SessionID())
if err := json.NewEncoder(s).Encode(stats); err != nil { if err := json.NewEncoder(s).Encode(stats); err != nil {
slog.Error("Error encoding stats", "err", err, "stats", stats) slog.Error("Error encoding stats", "err", err, "stats", stats)
s.Exit(1) s.Exit(1)
return
} }
s.Exit(0) s.Exit(0)
} }
// ParseKeys parses a string containing SSH public keys in authorized_keys format. // ParseKeys parses a string containing SSH public keys in authorized_keys format.
// It returns a slice of ssh.PublicKey and an error if any key fails to parse. // It returns a slice of ssh.PublicKey and an error if any key fails to parse.
func ParseKeys(input string) ([]ssh.PublicKey, error) { func ParseKeys(input string) ([]gossh.PublicKey, error) {
var parsedKeys []ssh.PublicKey var parsedKeys []gossh.PublicKey
for line := range strings.Lines(input) { for line := range strings.Lines(input) {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
// Skip empty lines or comments // Skip empty lines or comments
@@ -71,7 +94,7 @@ func ParseKeys(input string) ([]ssh.PublicKey, error) {
continue continue
} }
// Parse the key // Parse the key
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) parsedKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(line))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err) return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err)
} }

View File

@@ -31,6 +31,9 @@ func (a *Agent) initializeSystemInfo() {
} else if strings.Contains(platform, "indows") { } else if strings.Contains(platform, "indows") {
a.systemInfo.KernelVersion = strings.Replace(platform, "Microsoft ", "", 1) + " " + version a.systemInfo.KernelVersion = strings.Replace(platform, "Microsoft ", "", 1) + " " + version
a.systemInfo.Os = system.Windows a.systemInfo.Os = system.Windows
} else if platform == "freebsd" {
a.systemInfo.Os = system.Freebsd
a.systemInfo.KernelVersion = version
} else { } else {
a.systemInfo.Os = system.Linux a.systemInfo.Os = system.Linux
} }

View File

@@ -15,8 +15,13 @@ import (
"github.com/pocketbase/pocketbase/tools/mailer" "github.com/pocketbase/pocketbase/tools/mailer"
) )
type hubLike interface {
core.App
MakeLink(parts ...string) string
}
type AlertManager struct { type AlertManager struct {
app core.App hub hubLike
alertQueue chan alertTask alertQueue chan alertTask
stopChan chan struct{} stopChan chan struct{}
pendingAlerts sync.Map pendingAlerts sync.Map
@@ -79,9 +84,9 @@ var supportsTitle = map[string]struct{}{
} }
// NewAlertManager creates a new AlertManager instance. // NewAlertManager creates a new AlertManager instance.
func NewAlertManager(app core.App) *AlertManager { func NewAlertManager(app hubLike) *AlertManager {
am := &AlertManager{ am := &AlertManager{
app: app, hub: app,
alertQueue: make(chan alertTask), alertQueue: make(chan alertTask),
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
} }
@@ -91,7 +96,7 @@ func NewAlertManager(app core.App) *AlertManager {
func (am *AlertManager) SendAlert(data AlertMessageData) error { func (am *AlertManager) SendAlert(data AlertMessageData) error {
// get user settings // get user settings
record, err := am.app.FindFirstRecordByFilter( record, err := am.hub.FindFirstRecordByFilter(
"user_settings", "user={:user}", "user_settings", "user={:user}",
dbx.Params{"user": data.UserID}, dbx.Params{"user": data.UserID},
) )
@@ -104,12 +109,12 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
Webhooks: []string{}, Webhooks: []string{},
} }
if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil { if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil {
am.app.Logger().Error("Failed to unmarshal user settings", "err", err.Error()) am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
} }
// send alerts via webhooks // send alerts via webhooks
for _, webhook := range userAlertSettings.Webhooks { for _, webhook := range userAlertSettings.Webhooks {
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil { if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
am.app.Logger().Error("Failed to send shoutrrr alert", "err", err.Error()) am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
} }
} }
// send alerts via email // send alerts via email
@@ -125,15 +130,15 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
Subject: data.Title, Subject: data.Title,
Text: data.Message + fmt.Sprintf("\n\n%s", data.Link), Text: data.Message + fmt.Sprintf("\n\n%s", data.Link),
From: mail.Address{ From: mail.Address{
Address: am.app.Settings().Meta.SenderAddress, Address: am.hub.Settings().Meta.SenderAddress,
Name: am.app.Settings().Meta.SenderName, Name: am.hub.Settings().Meta.SenderName,
}, },
} }
err = am.app.NewMailClient().Send(&message) err = am.hub.NewMailClient().Send(&message)
if err != nil { if err != nil {
return err return err
} }
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject) am.hub.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
return nil return nil
} }
@@ -183,9 +188,9 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
err = shoutrrr.Send(parsedURL.String(), message) err = shoutrrr.Send(parsedURL.String(), message)
if err == nil { if err == nil {
am.app.Logger().Info("Sent shoutrrr alert", "title", title) am.hub.Logger().Info("Sent shoutrrr alert", "title", title)
} else { } else {
am.app.Logger().Error("Error sending shoutrrr alert", "err", err.Error()) am.hub.Logger().Error("Error sending shoutrrr alert", "err", err)
return err return err
} }
return nil return nil
@@ -201,7 +206,7 @@ func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
if url == "" { if url == "" {
return e.JSON(200, map[string]string{"err": "URL is required"}) return e.JSON(200, map[string]string{"err": "URL is required"})
} }
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppURL, "View Beszel") err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
if err != nil { if err != nil {
return e.JSON(200, map[string]string{"err": err.Error()}) return e.JSON(200, map[string]string{"err": err.Error()})
} }

View File

@@ -2,7 +2,6 @@ package alerts
import ( import (
"fmt" "fmt"
"net/url"
"strings" "strings"
"time" "time"
@@ -87,7 +86,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, systemRecord *core.
// getSystemStatusAlerts retrieves all "Status" alert records for a given system ID. // getSystemStatusAlerts retrieves all "Status" alert records for a given system ID.
func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) { func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) {
alertRecords, err := am.app.FindAllRecords("alerts", dbx.HashExp{ alertRecords, err := am.hub.FindAllRecords("alerts", dbx.HashExp{
"system": systemID, "system": systemID,
"name": "Status", "name": "Status",
}) })
@@ -130,7 +129,7 @@ func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.R
} }
// No alert scheduled for this record, send "up" alert // No alert scheduled for this record, send "up" alert
if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil { if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil {
am.app.Logger().Error("Failed to send alert", "err", err.Error()) am.hub.Logger().Error("Failed to send alert", "err", err)
} }
} }
} }
@@ -147,7 +146,7 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji) title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
message := strings.TrimSuffix(title, emoji) message := strings.TrimSuffix(title, emoji)
if errs := am.app.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 { if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
return errs["user"] return errs["user"]
} }
user := alertRecord.ExpandedOne("user") user := alertRecord.ExpandedOne("user")
@@ -159,7 +158,7 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
UserID: user.Id, UserID: user.Id,
Title: title, Title: title,
Message: message, Message: message,
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName), Link: am.hub.MakeLink("system", systemName),
LinkText: "View " + systemName, LinkText: "View " + systemName,
}) })
} }

View File

@@ -3,7 +3,6 @@ package alerts
import ( import (
"beszel/internal/entities/system" "beszel/internal/entities/system"
"fmt" "fmt"
"net/url"
"strings" "strings"
"time" "time"
@@ -15,7 +14,7 @@ import (
) )
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error { func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
alertRecords, err := am.app.FindAllRecords("alerts", alertRecords, err := am.hub.FindAllRecords("alerts",
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}), dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
) )
if err != nil || len(alertRecords) == 0 { if err != nil || len(alertRecords) == 0 {
@@ -101,7 +100,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
Created types.DateTime `db:"created"` Created types.DateTime `db:"created"`
}{} }{}
err = am.app.DB(). err = am.hub.DB().
Select("stats", "created"). Select("stats", "created").
From("system_stats"). From("system_stats").
Where(dbx.NewExp( Where(dbx.NewExp(
@@ -271,12 +270,12 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel) body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
alert.alertRecord.Set("triggered", alert.triggered) alert.alertRecord.Set("triggered", alert.triggered)
if err := am.app.Save(alert.alertRecord); err != nil { if err := am.hub.Save(alert.alertRecord); err != nil {
// app.Logger().Error("failed to save alert record", "err", err.Error()) // app.Logger().Error("failed to save alert record", "err", err)
return return
} }
// expand the user relation and send the alert // expand the user relation and send the alert
if errs := am.app.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 { if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs) // app.Logger().Error("failed to expand user relation", "errs", errs)
return return
} }
@@ -285,7 +284,7 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
UserID: user.Id, UserID: user.Id,
Title: subject, Title: subject,
Message: body, Message: body,
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName), Link: am.hub.MakeLink("system", systemName),
LinkText: "View " + systemName, LinkText: "View " + systemName,
}) })
} }

View File

@@ -0,0 +1,7 @@
package common
var (
DefaultKeyExchanges = []string{"curve25519-sha256"}
DefaultMACs = []string{"hmac-sha2-256-etm@openssh.com"}
DefaultCiphers = []string{"chacha20-poly1305@openssh.com"}
)

View File

@@ -70,6 +70,7 @@ const (
Linux Os = iota Linux Os = iota
Darwin Darwin
Windows Windows
Freebsd
) )
type Info struct { type Info struct {

View File

@@ -10,11 +10,13 @@ import (
"beszel/site" "beszel/site"
"crypto/ed25519" "crypto/ed25519"
"encoding/pem" "encoding/pem"
"fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"os" "os"
"path"
"strings" "strings"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
@@ -56,7 +58,6 @@ func GetEnv(key string) (value string, exists bool) {
} }
func (h *Hub) StartHub() error { func (h *Hub) StartHub() error {
h.App.OnServe().BindFunc(func(e *core.ServeEvent) error { h.App.OnServe().BindFunc(func(e *core.ServeEvent) error {
// initialize settings / collections // initialize settings / collections
if err := h.initialize(e); err != nil { if err := h.initialize(e); err != nil {
@@ -156,7 +157,7 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
return nil return nil
} }
// startServer starts the server for the Beszel (not PocketBase) // startServer sets up the server for Beszel
func (h *Hub) startServer(se *core.ServeEvent) error { func (h *Hub) startServer(se *core.ServeEvent) error {
// TODO: exclude dev server from production binary // TODO: exclude dev server from production binary
switch h.IsDev() { switch h.IsDev() {
@@ -239,73 +240,63 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
return nil return nil
} }
// generates key pair if it doesn't exist and returns private key bytes // generates key pair if it doesn't exist and returns signer
func (h *Hub) GetSSHKey() ([]byte, error) { func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
dataDir := h.DataDir() privateKeyPath := path.Join(dataDir, "id_ed25519")
// check if the key pair already exists // check if the key pair already exists
existingKey, err := os.ReadFile(dataDir + "/id_ed25519") existingKey, err := os.ReadFile(privateKeyPath)
if err == nil { if err == nil {
if pubKey, err := os.ReadFile(h.DataDir() + "/id_ed25519.pub"); err == nil { private, err := ssh.ParsePrivateKey(existingKey)
h.pubKey = strings.TrimSuffix(string(pubKey), "\n") if err != nil {
return nil, fmt.Errorf("failed to parse private key: %s", err)
} }
// return existing private key pubKeyBytes := ssh.MarshalAuthorizedKey(private.PublicKey())
return existingKey, nil 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 // Generate the Ed25519 key pair
pubKey, privKey, err := ed25519.GenerateKey(nil) pubKey, privKey, err := ed25519.GenerateKey(nil)
if err != nil { if err != nil {
// h.Logger().Error("Error generating key pair:", "err", err.Error())
return nil, err return nil, err
} }
// Get the private key in OpenSSH format // Get the private key in OpenSSH format
privKeyBytes, err := ssh.MarshalPrivateKey(privKey, "") privKeyPem, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
// h.Logger().Error("Error marshaling private key:", "err", err.Error())
return nil, err
}
// Save the private key to a file
privateFile, err := os.Create(dataDir + "/id_ed25519")
if err != nil {
// h.Logger().Error("Error creating private key file:", "err", err.Error())
return nil, err
}
defer privateFile.Close()
if err := pem.Encode(privateFile, privKeyBytes); err != nil {
// h.Logger().Error("Error writing private key to file:", "err", err.Error())
return nil, err
}
// Generate the public key in OpenSSH format
publicKey, err := ssh.NewPublicKey(pubKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
pubKeyBytes := ssh.MarshalAuthorizedKey(publicKey) 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
sshPubKey, _ := ssh.NewPublicKey(pubKey)
sshPrivate, _ := ssh.NewSignerFromSigner(privKey)
pubKeyBytes := ssh.MarshalAuthorizedKey(sshPubKey)
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n") h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
// Save the public key to a file
publicFile, err := os.Create(dataDir + "/id_ed25519.pub")
if err != nil {
return nil, err
}
defer publicFile.Close()
if _, err := publicFile.Write(pubKeyBytes); err != nil {
return nil, err
}
h.Logger().Info("ed25519 SSH key pair generated successfully.") h.Logger().Info("ed25519 SSH key pair generated successfully.")
h.Logger().Info("Private key saved to: " + dataDir + "/id_ed25519") h.Logger().Info("Saved to: " + privateKeyPath)
h.Logger().Info("Public key saved to: " + dataDir + "/id_ed25519.pub")
existingKey, err = os.ReadFile(dataDir + "/id_ed25519") return sshPrivate, err
if err == nil { }
return existingKey, nil
} // MakeLink formats a link with the app URL and path segments.
return nil, err // 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
} }

View File

@@ -0,0 +1,257 @@
//go:build testing
// +build testing
package hub
import (
"testing"
"crypto/ed25519"
"encoding/pem"
"os"
"path/filepath"
"strings"
"github.com/pocketbase/pocketbase"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
func getTestHub() *Hub {
app := pocketbase.New()
return NewHub(app)
}
func TestMakeLink(t *testing.T) {
hub := getTestHub()
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 := getTestHub()
// 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.pubKey = ""
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.pubKey, "h.pubKey should be set after key generation")
assert.True(t, strings.HasPrefix(hub.pubKey, "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.pubKey = ""
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.pubKey, "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.pubKey = ""
// 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.pubKey, "h.pubKey should not be set if there was an error")
})
}
})
}

View File

@@ -1,6 +1,7 @@
package systems package systems
import ( import (
"beszel/internal/common"
"beszel/internal/entities/system" "beszel/internal/entities/system"
"context" "context"
"fmt" "fmt"
@@ -45,7 +46,7 @@ type System struct {
type hubLike interface { type hubLike interface {
core.App core.App
GetSSHKey() ([]byte, error) GetSSHKey(dataDir string) (ssh.Signer, error)
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleStatusAlerts(status string, systemRecord *core.Record) error HandleStatusAlerts(status string, systemRecord *core.Record) error
} }
@@ -62,13 +63,10 @@ func NewSystemManager(hub hubLike) *SystemManager {
func (sm *SystemManager) Initialize() error { func (sm *SystemManager) Initialize() error {
sm.bindEventHooks() sm.bindEventHooks()
// ssh setup // ssh setup
key, err := sm.hub.GetSSHKey() err := sm.createSSHClientConfig()
if err != nil { if err != nil {
return err return err
} }
if err := sm.createSSHClientConfig(key); err != nil {
return err
}
// start updating existing systems // start updating existing systems
var systems []*System var systems []*System
err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems) err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems)
@@ -362,15 +360,21 @@ func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
return nil, fmt.Errorf("failed to fetch data") return nil, fmt.Errorf("failed to fetch data")
} }
func (sm *SystemManager) createSSHClientConfig(key []byte) error { // createSSHClientConfig initializes the ssh config for the system manager
signer, err := ssh.ParsePrivateKey(key) func (sm *SystemManager) createSSHClientConfig() error {
privateKey, err := sm.hub.GetSSHKey(sm.hub.DataDir())
if err != nil { if err != nil {
return err return err
} }
sm.sshConfig = &ssh.ClientConfig{ sm.sshConfig = &ssh.ClientConfig{
User: "u", User: "u",
Auth: []ssh.AuthMethod{ Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer), ssh.PublicKeys(privateKey),
},
Config: ssh.Config{
Ciphers: common.DefaultCiphers,
KeyExchanges: common.DefaultKeyExchanges,
MACs: common.DefaultMACs,
}, },
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sessionTimeout, Timeout: sessionTimeout,

View File

@@ -1,7 +1,7 @@
{ {
"name": "beszel", "name": "beszel",
"private": true, "private": true,
"version": "0.11.0", "version": "0.11.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -86,7 +86,7 @@ function copyLinuxCommand(port = "45876", publicKey: string, brew = false) {
function copyWindowsCommand(port = "45876", publicKey: string) { function copyWindowsCommand(port = "45876", publicKey: string) {
copyToClipboard( copyToClipboard(
`Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser; & iwr -useb https://get.beszel.dev -OutFile "$env:TEMP\install-agent.ps1"; & "$env:TEMP\install-agent.ps1" -Key "${publicKey}" -Port ${port}` `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser; & iwr -useb https://get.beszel.dev -OutFile "$env:TEMP\\install-agent.ps1"; & "$env:TEMP\\install-agent.ps1" -Key "${publicKey}" -Port ${port}`
) )
} }

View File

@@ -1,5 +1,5 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro"
import { memo, useMemo, useState } from "react" import { memo, useMemo, useState } from "react"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { $alerts } from "@/lib/stores" import { $alerts } from "@/lib/stores"
@@ -15,10 +15,11 @@ import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react"
import { alertInfo, cn } from "@/lib/utils" import { alertInfo, cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { AlertRecord, SystemRecord } from "@/types" import { AlertRecord, SystemRecord } from "@/types"
import { Link } from "../router" import { $router, Link } from "../router"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checkbox } from "../ui/checkbox" import { Checkbox } from "../ui/checkbox"
import { SystemAlert, SystemAlertGlobal } from "./alerts-system" import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
import { getPagePath } from "@nanostores/router"
export default memo(function AlertsButton({ system }: { system: SystemRecord }) { export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts) const alerts = useStore($alerts)
@@ -81,7 +82,7 @@ function AlertDialogContent({ system }: { system: SystemRecord }) {
<DialogDescription> <DialogDescription>
<Trans> <Trans>
See{" "} See{" "}
<Link href="/settings/notifications" className="link"> <Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
notification settings notification settings
</Link>{" "} </Link>{" "}
to configure how you receive alerts. to configure how you receive alerts.

View File

@@ -1,5 +1,5 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@@ -128,7 +128,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
<Trans> <Trans>
Beszel uses{" "} Beszel uses{" "}
<a href="https://containrrr.dev/shoutrrr/services/overview/" target="_blank" className="link"> <a href="https://beszel.dev/guide/notifications" target="_blank" className="link">
Shoutrrr Shoutrrr
</a>{" "} </a>{" "}
to integrate with popular notification services. to integrate with popular notification services.

View File

@@ -32,7 +32,7 @@ import { Separator } from "../ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import { Button } from "../ui/button" import { Button } from "../ui/button"
import { Input } from "../ui/input" import { Input } from "../ui/input"
import { ChartAverage, ChartMax, Rows, TuxIcon, WindowsIcon, AppleIcon } from "../ui/icons" import { ChartAverage, ChartMax, Rows, TuxIcon, WindowsIcon, AppleIcon, FreeBsdIcon } from "../ui/icons"
import { useIntersectionObserver } from "@/lib/use-intersection-observer" import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { timeTicks } from "d3-time" import { timeTicks } from "d3-time"
@@ -276,6 +276,10 @@ export default function SystemDetail({ name }: { name: string }) {
Icon: WindowsIcon, Icon: WindowsIcon,
value: system.info.k, value: system.info.k,
}, },
[Os.FreeBSD]: {
Icon: FreeBsdIcon,
value: system.info.k,
},
} }
let uptime: React.ReactNode let uptime: React.ReactNode

View File

@@ -38,6 +38,18 @@ export function AppleIcon(props: SVGProps<SVGSVGElement>) {
) )
} }
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
export function FreeBsdIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M2.7 2C3.5 2 6 3.2 6 3.2 4.8 4 3.7 5 3 6.4 2.1 4.8 1.3 2.9 2 2.2l.7-.2m18.1.1c.4 0 .8 0 1 .2 1 1.1-2 5.8-2.4 6.4-.5.5-1.8 0-2.9-1-1-1.2-1.5-2.4-1-3 .4-.4 3.6-2.4 5.3-2.6m-8.8.5c1.3 0 2.5.2 3.7.7l-1 .7c-1 1-.6 2.8 1 4.4 1 1 2.1 1.6 3 1.6a2 2 0 0 0 1.5-.6l.7-1a9.7 9.7 0 1 1-18.6 3.8A9.7 9.7 0 0 1 12 2.7"
/>
</svg>
)
}
// ion icons (MIT) https://github.com/ionic-team/ionicons/blob/main/LICENSE // ion icons (MIT) https://github.com/ionic-team/ionicons/blob/main/LICENSE
export function DockerIcon(props: SVGProps<SVGSVGElement>) { export function DockerIcon(props: SVGProps<SVGSVGElement>) {
return ( return (

View File

@@ -2,7 +2,7 @@ export enum Os {
Linux = 0, Linux = 0,
Darwin, Darwin,
Windows, Windows,
// FreeBSD, FreeBSD,
} }
export enum ChartType { export enum ChartType {

View File

@@ -305,7 +305,7 @@ msgstr "Dokumentation"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Down" msgid "Down"
msgstr "" msgstr "Offline"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -461,7 +461,7 @@ msgstr "Anzeige- und Benachrichtigungseinstellungen verwalten."
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Manual setup instructions" msgid "Manual setup instructions"
msgstr "" msgstr "Anleitung zur manuellen Einrichtung"
#. Chart select field. Please try to keep this short. #. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
@@ -727,7 +727,7 @@ msgstr "Tabelle"
#. Temperature label in systems table #. Temperature label in systems table
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Temp" msgid "Temp"
msgstr "" msgstr "Temperatur"
#: src/lib/utils.ts #: src/lib/utils.ts
#: src/components/routes/system.tsx #: src/components/routes/system.tsx

View File

@@ -1,6 +1,6 @@
package beszel package beszel
const ( const (
Version = "0.11.0" Version = "0.11.1"
AppName = "beszel" AppName = "beszel"
) )

View File

@@ -4,12 +4,12 @@ Beszel is a lightweight server monitoring platform that includes Docker statisti
It has a friendly web interface, simple configuration, and is ready to use out of the box. It supports automatic backup, multi-user, OAuth authentication, and API access. It has a friendly web interface, simple configuration, and is ready to use out of the box. It supports automatic backup, multi-user, OAuth authentication, and API access.
[![agent Docker Image Size](https://img.shields.io/docker/image-size/henrygd/beszel-agent/0.4.0?logo=docker&label=agent%20image%20size)](https://hub.docker.com/r/henrygd/beszel-agent) [![agent Docker Image Size](https://img.shields.io/docker/image-size/henrygd/beszel-agent/latest?logo=docker&label=agent%20image%20size)](https://hub.docker.com/r/henrygd/beszel-agent)
[![hub Docker Image Size](https://img.shields.io/docker/image-size/henrygd/beszel/0.4.0?logo=docker&label=hub%20image%20size)](https://hub.docker.com/r/henrygd/beszel) [![hub Docker Image Size](https://img.shields.io/docker/image-size/henrygd/beszel/latest?logo=docker&label=hub%20image%20size)](https://hub.docker.com/r/henrygd/beszel)
[![MIT license](https://img.shields.io/github/license/henrygd/beszel?color=%239944ee)](https://github.com/henrygd/beszel/blob/main/LICENSE) [![MIT license](https://img.shields.io/github/license/henrygd/beszel?color=%239944ee)](https://github.com/henrygd/beszel/blob/main/LICENSE)
[![Crowdin](https://badges.crowdin.net/beszel/localized.svg)](https://crowdin.com/project/beszel) [![Crowdin](https://badges.crowdin.net/beszel/localized.svg)](https://crowdin.com/project/beszel)
![Screenshot of beszel dashboard and system page](https://henrygd-assets.b-cdn.net/beszel/screenshot-new.png) ![Screenshot of Beszel dashboard and system page, side by side. The dashboard shows metrics from multiple connected systems, while the system page shows detailed metrics for a single system.](https://henrygd-assets.b-cdn.net/beszel/screenshot-new.png)
## Features ## Features

View File

@@ -2,7 +2,8 @@ param (
[switch]$Elevated, [switch]$Elevated,
[Parameter(Mandatory=$true)] [Parameter(Mandatory=$true)]
[string]$Key, [string]$Key,
[int]$Port = 45876 [int]$Port = 45876,
[string]$AgentPath = ""
) )
# Check if key is provided or empty # Check if key is provided or empty
@@ -15,60 +16,147 @@ if ([string]::IsNullOrWhiteSpace($Key)) {
# Stop on first error # Stop on first error
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
#region Utility Functions
# Function to check if running as admin # Function to check if running as admin
function Test-Admin { function Test-Admin {
return ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) return ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
} }
# Non-admin tasks - install Scoop and Scoop apps - Only run if we're not in elevated mode # Function to check if a command exists
if (-not $Elevated) { function Test-CommandExists {
param (
[Parameter(Mandatory=$true)]
[string]$Command
)
return (Get-Command $Command -ErrorAction SilentlyContinue)
}
#endregion
#region Installation Methods
# Function to install Scoop
function Install-Scoop {
Write-Host "Installing Scoop..."
try { try {
# Check if Scoop is already installed Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
if (Get-Command scoop -ErrorAction SilentlyContinue) {
if (-not (Test-CommandExists "scoop")) {
throw "Failed to install Scoop"
}
Write-Host "Scoop installed successfully."
}
catch {
throw "Failed to install Scoop: $($_.Exception.Message)"
}
}
# Function to install Git via Scoop
function Install-Git {
if (Test-CommandExists "git") {
Write-Host "Git is already installed."
return
}
Write-Host "Installing Git..."
scoop install git
if (-not (Test-CommandExists "git")) {
throw "Failed to install Git"
}
}
# Function to install NSSM
function Install-NSSM {
param (
[string]$Method = "Scoop" # Default to Scoop method
)
if (Test-CommandExists "nssm") {
Write-Host "NSSM is already installed."
return
}
Write-Host "Installing NSSM..."
if ($Method -eq "Scoop") {
scoop install nssm
}
elseif ($Method -eq "WinGet") {
winget install -e --id NSSM.NSSM --accept-source-agreements --accept-package-agreements
# Refresh PATH environment variable to make NSSM available in current session
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
}
else {
throw "Unsupported installation method: $Method"
}
if (-not (Test-CommandExists "nssm")) {
throw "Failed to install NSSM"
}
}
# Function to install beszel-agent with Scoop
function Install-BeszelAgentWithScoop {
Write-Host "Adding beszel bucket..."
scoop bucket add beszel https://github.com/henrygd/beszel-scoops | Out-Null
Write-Host "Installing beszel-agent..."
scoop install beszel-agent | Out-Null
if (-not (Test-CommandExists "beszel-agent")) {
throw "Failed to install beszel-agent"
}
return $(Join-Path -Path $(scoop prefix beszel-agent) -ChildPath "beszel-agent.exe")
}
# Function to install beszel-agent with WinGet
function Install-BeszelAgentWithWinGet {
Write-Host "Installing beszel-agent..."
winget install --exact --id henrygd.beszel-agent --accept-source-agreements --accept-package-agreements | Out-Null
# Refresh PATH environment variable to make beszel-agent available in current session
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
# Find the path to the beszel-agent executable
$agentPath = (Get-Command beszel-agent -ErrorAction SilentlyContinue).Source
if (-not $agentPath) {
throw "Could not find beszel-agent executable path after installation"
}
return $agentPath
}
# Function to install using Scoop
function Install-WithScoop {
param (
[string]$Key,
[int]$Port
)
try {
# Ensure Scoop is installed
if (-not (Test-CommandExists "scoop")) {
Install-Scoop | Out-Null
}
else {
Write-Host "Scoop is already installed." Write-Host "Scoop is already installed."
} else {
Write-Host "Installing Scoop..."
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) {
throw "Failed to install Scoop"
}
}
# Check if git is already installed
if (Get-Command git -ErrorAction SilentlyContinue) {
Write-Host "Git is already installed."
} else {
Write-Host "Installing Git..."
scoop install git
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
throw "Failed to install Git"
}
}
# Check if nssm is already installed
if (Get-Command nssm -ErrorAction SilentlyContinue) {
Write-Host "NSSM is already installed."
} else {
Write-Host "Installing NSSM..."
scoop install nssm
if (-not (Get-Command nssm -ErrorAction SilentlyContinue)) {
throw "Failed to install NSSM"
}
} }
# Add bucket and install agent # Install Git (required for Scoop buckets)
Write-Host "Adding beszel bucket..." Install-Git | Out-Null
scoop bucket add beszel https://github.com/henrygd/beszel-scoops
Write-Host "Installing beszel-agent..." # Install NSSM
scoop install beszel-agent Install-NSSM -Method "Scoop" | Out-Null
if (-not (Get-Command beszel-agent -ErrorAction SilentlyContinue)) { # Install beszel-agent
throw "Failed to install beszel-agent" $agentPath = Install-BeszelAgentWithScoop
}
return $agentPath
} }
catch { catch {
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
@@ -77,27 +165,48 @@ if (-not $Elevated) {
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
exit 1 exit 1
} }
}
# Check if we need admin privileges for the NSSM part # Function to install using WinGet
if (-not (Test-Admin)) { function Install-WithWinGet {
Write-Host "Admin privileges required for NSSM. Relaunching as admin..." -ForegroundColor Yellow param (
Write-Host "Check service status with 'nssm status beszel-agent'" [string]$Key,
Write-Host "Edit service configuration with 'nssm edit beszel-agent'" [int]$Port
)
try {
# Install NSSM
Install-NSSM -Method "WinGet" | Out-Null
# Relaunch the script with the -Elevated switch and pass parameters # Install beszel-agent
Start-Process powershell.exe -Verb RunAs -ArgumentList "-File `"$PSCommandPath`" -Elevated -Key `"$Key`" -Port $Port" $agentPath = Install-BeszelAgentWithWinGet
exit
return $agentPath
}
catch {
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red
Write-Host "Press any key to exit..." -ForegroundColor Red
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
exit 1
} }
} }
# Admin tasks - service installation and firewall rules #endregion
try {
$agentPath = Join-Path -Path $(scoop prefix beszel-agent) -ChildPath "beszel-agent.exe" #region Service Configuration
if (-not $agentPath) {
throw "Could not find beszel-agent executable. Make sure it was properly installed." # Function to install and configure the NSSM service
} function Install-NSSMService {
param (
[Parameter(Mandatory=$true)]
[string]$AgentPath,
[Parameter(Mandatory=$true)]
[string]$Key,
[Parameter(Mandatory=$true)]
[int]$Port
)
# Install and configure the service
Write-Host "Installing beszel-agent service..." Write-Host "Installing beszel-agent service..."
# Check if service already exists # Check if service already exists
@@ -112,7 +221,7 @@ try {
} }
} }
nssm install beszel-agent $agentPath nssm install beszel-agent $AgentPath
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Failed to install beszel-agent service" throw "Failed to install beszel-agent service"
} }
@@ -129,6 +238,14 @@ try {
$logFile = "$logDir\beszel-agent.log" $logFile = "$logDir\beszel-agent.log"
nssm set beszel-agent AppStdout $logFile nssm set beszel-agent AppStdout $logFile
nssm set beszel-agent AppStderr $logFile nssm set beszel-agent AppStderr $logFile
}
# Function to configure firewall rules
function Configure-Firewall {
param (
[Parameter(Mandatory=$true)]
[int]$Port
)
# Create a firewall rule if it doesn't exist # Create a firewall rule if it doesn't exist
$ruleName = "Allow beszel-agent" $ruleName = "Allow beszel-agent"
@@ -154,31 +271,130 @@ try {
Write-Host "Warning: Failed to create firewall rule: $($_.Exception.Message)" -ForegroundColor Yellow Write-Host "Warning: Failed to create firewall rule: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host "You may need to manually create a firewall rule for port $Port." -ForegroundColor Yellow Write-Host "You may need to manually create a firewall rule for port $Port." -ForegroundColor Yellow
} }
}
# Function to start and monitor the service
function Start-BeszelAgentService {
Write-Host "Starting beszel-agent service..." Write-Host "Starting beszel-agent service..."
nssm start beszel-agent nssm start beszel-agent
if ($LASTEXITCODE -ne 0) { $startResult = $LASTEXITCODE
throw "Failed to start beszel-agent service"
# Only enter the status check loop if the NSSM start command failed
if ($startResult -ne 0) {
Write-Host "NSSM start command returned error code: $startResult" -ForegroundColor Yellow
Write-Host "This could be due to 'SERVICE_START_PENDING' state. Checking service status..."
# Allow up to 10 seconds for the service to start, checking every second
$maxWaitTime = 10 # seconds
$elapsedTime = 0
$serviceStarted = $false
while (-not $serviceStarted -and $elapsedTime -lt $maxWaitTime) {
Start-Sleep -Seconds 1
$elapsedTime += 1
$serviceStatus = nssm status beszel-agent
if ($serviceStatus -eq "SERVICE_RUNNING") {
$serviceStarted = $true
Write-Host "Success! The beszel-agent service is now running." -ForegroundColor Green
}
elseif ($serviceStatus -like "*PENDING*") {
Write-Host "Service is still starting (status: $serviceStatus)... waiting" -ForegroundColor Yellow
}
else {
Write-Host "Warning: The service status is '$serviceStatus' instead of 'SERVICE_RUNNING'." -ForegroundColor Yellow
Write-Host "You may need to troubleshoot the service installation." -ForegroundColor Yellow
break
}
}
if (-not $serviceStarted) {
Write-Host "Service did not reach running state." -ForegroundColor Yellow
Write-Host "You can check status manually with 'nssm status beszel-agent'" -ForegroundColor Yellow
}
} else {
# NSSM start command was successful
Write-Host "Success! The beszel-agent service is running properly." -ForegroundColor Green
}
}
#endregion
#region Main Script Execution
# Check if we're running as admin
$isAdmin = Test-Admin
try {
# First: Install the agent (doesn't require admin)
if (-not $AgentPath) {
# Check for problematic case: running as admin and need Scoop
if ($isAdmin -and -not (Test-CommandExists "scoop") -and -not (Test-CommandExists "winget")) {
Write-Host "ERROR: You're running as administrator but need to install Scoop." -ForegroundColor Red
Write-Host "Scoop should be installed without admin privileges." -ForegroundColor Red
Write-Host "Please run this script again without administrator privileges." -ForegroundColor Red
exit 1
}
Write-Host "Installing beszel-agent..."
if (Test-CommandExists "scoop") {
Write-Host "Using Scoop for installation..."
$AgentPath = Install-WithScoop -Key $Key -Port $Port
}
elseif (Test-CommandExists "winget") {
Write-Host "Using WinGet for installation..."
$AgentPath = Install-WithWinGet -Key $Key -Port $Port
}
else {
Write-Host "Neither Scoop nor WinGet is installed. Installing Scoop..."
$AgentPath = Install-WithScoop -Key $Key -Port $Port
}
}
if (-not $AgentPath) {
throw "Could not find beszel-agent executable. Make sure it was properly installed."
} }
Write-Host "Checking beszel-agent service status..." # Second: If we need admin rights for service installation and we don't have them, relaunch
Start-Sleep -Seconds 5 # Allow time to start before checking status if (-not $isAdmin -and -not $Elevated) {
$serviceStatus = nssm status beszel-agent Write-Host "Admin privileges required for service installation. Relaunching as admin..." -ForegroundColor Yellow
Write-Host "Check service status with 'nssm status beszel-agent'"
Write-Host "Edit service configuration with 'nssm edit beszel-agent'"
# Relaunch the script with the -Elevated switch and pass parameters
Start-Process powershell.exe -Verb RunAs -ArgumentList "-File `"$PSCommandPath`" -Elevated -Key `"$Key`" -Port $Port -AgentPath `"$AgentPath`""
exit
}
if ($serviceStatus -eq "SERVICE_RUNNING") { # Third: If we have admin rights, install service and configure firewall
Write-Host "Success! The beszel-agent service is running properly." -ForegroundColor Green if ($isAdmin -or $Elevated) {
} else { # Install the service
Write-Host "Warning: The service status is '$serviceStatus' instead of 'SERVICE_RUNNING'." -ForegroundColor Yellow Install-NSSMService -AgentPath $AgentPath -Key $Key -Port $Port
Write-Host "You may need to troubleshoot the service installation." -ForegroundColor Yellow
# Configure firewall
Configure-Firewall -Port $Port
# Start the service
Start-BeszelAgentService
# Pause to see results if this is an elevated window
if ($Elevated) {
Write-Host "Press any key to exit..." -ForegroundColor Cyan
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
}
} }
} }
catch { catch {
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red
# Pause if this is likely a new window
if ($Elevated -or (-not $isAdmin)) {
Write-Host "Press any key to exit..." -ForegroundColor Red
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
}
exit 1
} }
# Pause to see results before exit if this is an elevated window #endregion
if ($Elevated) {
Write-Host "Press any key to exit..." -ForegroundColor Cyan
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
}

View File

@@ -8,7 +8,53 @@ is_openwrt() {
cat /etc/os-release | grep -q "OpenWrt" cat /etc/os-release | grep -q "OpenWrt"
} }
# Function to ensure the proxy URL ends with a / # If SELinux is enabled, set the context of the binary
set_selinux_context() {
# Check if SELinux is enabled and in enforcing or permissive mode
if command -v getenforce >/dev/null 2>&1; then
SELINUX_MODE=$(getenforce)
if [ "$SELINUX_MODE" != "Disabled" ]; then
echo "SELinux is enabled (${SELINUX_MODE} mode). Setting appropriate context..."
# First try to set persistent context if semanage is available
if command -v semanage >/dev/null 2>&1; then
echo "Attempting to set persistent SELinux context..."
if semanage fcontext -a -t bin_t "/opt/beszel-agent/beszel-agent" >/dev/null 2>&1; then
restorecon -v /opt/beszel-agent/beszel-agent >/dev/null 2>&1
else
echo "Warning: Failed to set persistent context, falling back to temporary context."
fi
fi
# Fall back to chcon if semanage failed or isn't available
if command -v chcon >/dev/null 2>&1; then
# Set context for both the directory and binary
chcon -t bin_t /opt/beszel-agent/beszel-agent || echo "Warning: Failed to set SELinux context for binary."
chcon -R -t bin_t /opt/beszel-agent || echo "Warning: Failed to set SELinux context for directory."
else
if [ "$SELINUX_MODE" = "Enforcing" ]; then
echo "Warning: SELinux is in enforcing mode but chcon command not found. The service may fail to start."
echo "Consider installing the policycoreutils package or temporarily setting SELinux to permissive mode."
else
echo "Warning: SELinux is in permissive mode but chcon command not found."
fi
fi
fi
fi
}
# Clean up SELinux contexts if they were set
cleanup_selinux_context() {
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "Cleaning up SELinux contexts..."
# Remove persistent context if semanage is available
if command -v semanage >/dev/null 2>&1; then
semanage fcontext -d "/opt/beszel-agent/beszel-agent" 2>/dev/null || true
fi
fi
}
# Ensure the proxy URL ends with a /
ensure_trailing_slash() { ensure_trailing_slash() {
if [ -n "$1" ]; then if [ -n "$1" ]; then
case "$1" in case "$1" in
@@ -20,7 +66,7 @@ ensure_trailing_slash() {
fi fi
} }
# Define default values # Default values
PORT=45876 PORT=45876
UNINSTALL=false UNINSTALL=false
GITHUB_URL="https://github.com" GITHUB_URL="https://github.com"
@@ -141,6 +187,9 @@ done
# Uninstall process # Uninstall process
if [ "$UNINSTALL" = true ]; then if [ "$UNINSTALL" = true ]; then
# Clean up SELinux contexts before removing files
cleanup_selinux_context
if is_alpine; then if is_alpine; then
echo "Stopping and disabling the agent service..." echo "Stopping and disabling the agent service..."
rc-service beszel-agent stop rc-service beszel-agent stop
@@ -334,6 +383,9 @@ mv beszel-agent /opt/beszel-agent/beszel-agent
chown beszel:beszel /opt/beszel-agent/beszel-agent chown beszel:beszel /opt/beszel-agent/beszel-agent
chmod 755 /opt/beszel-agent/beszel-agent chmod 755 /opt/beszel-agent/beszel-agent
# Set SELinux context if needed
set_selinux_context
# Cleanup # Cleanup
rm -rf "$TEMP_DIR" rm -rf "$TEMP_DIR"
@@ -528,7 +580,6 @@ StateDirectory=beszel-agent
KeyringMode=private KeyringMode=private
LockPersonality=yes LockPersonality=yes
NoNewPrivileges=yes NoNewPrivileges=yes
PrivateTmp=yes
ProtectClock=yes ProtectClock=yes
ProtectHome=read-only ProtectHome=read-only
ProtectHostname=yes ProtectHostname=yes
@@ -548,6 +599,37 @@ EOF
systemctl enable beszel-agent.service systemctl enable beszel-agent.service
systemctl start beszel-agent.service systemctl start beszel-agent.service
# Create the update script
echo "Creating the update script..."
cat >/opt/beszel-agent/run-update.sh <<'EOF'
#!/bin/sh
set -e
if /opt/beszel-agent/beszel-agent update | grep -q "Successfully updated"; then
echo "Update found, checking SELinux context."
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "SELinux enabled, applying context..."
if command -v chcon >/dev/null 2>&1; then
chcon -t bin_t /opt/beszel-agent/beszel-agent || echo "Warning: chcon command failed to apply context."
fi
if command -v restorecon >/dev/null 2>&1; then
restorecon -v /opt/beszel-agent/beszel-agent >/dev/null 2>&1 || echo "Warning: restorecon command failed to apply context."
fi
fi
echo "Restarting beszel-agent service..."
systemctl restart beszel-agent
echo "Update process finished."
else
echo "No updates found or applied."
fi
exit 0
EOF
chown root:root /opt/beszel-agent/run-update.sh
chmod +x /opt/beszel-agent/run-update.sh
# Prompt for auto-update setup # Prompt for auto-update setup
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
AUTO_UPDATE="y" AUTO_UPDATE="y"
@@ -571,7 +653,7 @@ Wants=beszel-agent.service
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/bin/sh -c '/opt/beszel-agent/beszel-agent update | grep -q "Successfully updated" && (echo "Update found, restarting beszel-agent" && systemctl restart beszel-agent) || echo "No updates found"' ExecStart=/opt/beszel-agent/run-update.sh
EOF EOF
# Create systemd timer for the daily update # Create systemd timer for the daily update