mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 17:07:47 +02:00
Compare commits
11 Commits
2f861f11a4
...
alerts-dns
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d58f080a23 | ||
|
|
8628741a63 | ||
|
|
aefb917a08 | ||
|
|
266a74bab8 | ||
|
|
ad24484caa | ||
|
|
027d0c204d | ||
|
|
46d94a9804 | ||
|
|
82fc772882 | ||
|
|
c157c2026d | ||
|
|
e1d9ebc61d | ||
|
|
5af6b6b184 |
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
|
**PLEASE ONLY USE SECURITY ADVISORIES FOR REAL HIGH SEVERITY VULNERABILITIES.**
|
||||||
|
|
||||||
If it's low severity (use best judgement) you may open an issue instead of an advisory.
|
If you find a vulnerability in the latest version, and it is not high severity, open an issue instead of an advisory.
|
||||||
|
|
||||||
|
I am overwhelmed with advisories, often erroneous, which are clearly found and written by AI. I don't have the capacity to review all of them.
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
wsDeadline = 70 * time.Second
|
// Keep the connection alive long enough for a slow collection cycle to
|
||||||
|
// finish before the hub considers the agent disconnected.
|
||||||
|
wsDeadline = 120 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type caCertFileError struct {
|
type caCertFileError struct {
|
||||||
|
|||||||
@@ -700,3 +700,11 @@ func TestGetToken(t *testing.T) {
|
|||||||
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
|
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWebSocketDeadlineCoversSlowCollection(t *testing.T) {
|
||||||
|
const minimumDeadline = 120 * time.Second
|
||||||
|
|
||||||
|
if wsDeadline < minimumDeadline {
|
||||||
|
t.Fatalf("WebSocket deadline %s is shorter than the slow-collection window of %s", wsDeadline, minimumDeadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -10,7 +10,7 @@ require (
|
|||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lxzan/gws v1.10.1
|
github.com/lxzan/gws v1.10.1
|
||||||
github.com/nicholas-fedor/shoutrrr v0.19.0
|
github.com/nicholas-fedor/shoutrrr v0.20.0
|
||||||
github.com/pocketbase/dbx v1.12.0
|
github.com/pocketbase/dbx v1.12.0
|
||||||
github.com/pocketbase/pocketbase v0.40.2
|
github.com/pocketbase/pocketbase v0.40.2
|
||||||
github.com/shirou/gopsutil/v4 v4.26.8
|
github.com/shirou/gopsutil/v4 v4.26.8
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -54,8 +54,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
|||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20260902005441-ca85771921e4 h1:/6mPXfWmhv8eKck12I0YNIcIjwHtxP3YRIMKiEgTjWg=
|
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe h1:QAinXoAFJdGQYztXn3VpFey7KCwpedbZ/EkzbplQ0cY=
|
||||||
github.com/google/pprof v0.0.0-20260902005441-ca85771921e4/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
@@ -83,8 +83,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
|
|||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.19.0 h1:Rl6bpK3DXuR2Trtx2JV8t+wjUwkHdRHrc8nBKoEpHr0=
|
github.com/nicholas-fedor/shoutrrr v0.20.0 h1:hMAxIYlfAeZ1FcTDgU0kUOvVXUsOirWo8IWlnzGLkac=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.19.0/go.mod h1:Glfdi8AGTbnEn2k2+hW62n8oL0i9vqRVFtXaUIthNks=
|
github.com/nicholas-fedor/shoutrrr v0.20.0/go.mod h1:hgde37yNWCXh8+N6WemyDRMNYLOFTf326GsBx8Z7CFA=
|
||||||
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
|
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
|
||||||
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||||
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
|
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ type hubLike interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AlertManager struct {
|
type AlertManager struct {
|
||||||
hub hubLike
|
hub hubLike
|
||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
pendingAlerts sync.Map
|
pendingAlerts sync.Map
|
||||||
alertsCache *AlertsCache
|
alertsCache *AlertsCache
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertMessageData struct {
|
type AlertMessageData struct {
|
||||||
@@ -231,8 +231,20 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
|||||||
am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
|
am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
|
||||||
}
|
}
|
||||||
// send alerts via webhooks
|
// send alerts via webhooks
|
||||||
|
send := sendPublicNotification
|
||||||
|
if len(userAlertSettings.Webhooks) > 0 {
|
||||||
|
// Read the owner's current role at delivery time, including for URLs
|
||||||
|
// saved before an admin was demoted. Never fall back on lookup failure.
|
||||||
|
owner, err := am.hub.FindRecordById("users", data.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load notification owner: %w", err)
|
||||||
|
}
|
||||||
|
if owner.GetString("role") == "admin" {
|
||||||
|
send = shoutrrr.Send
|
||||||
|
}
|
||||||
|
}
|
||||||
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, send); err != nil {
|
||||||
am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
|
am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,6 +275,10 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
|||||||
|
|
||||||
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
|
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
|
||||||
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
|
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
|
||||||
|
return am.sendShoutrrrAlert(notificationUrl, title, message, link, linkText, shoutrrr.Send)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AlertManager) sendShoutrrrAlert(notificationUrl, title, message, link, linkText string, send func(string, string) error) error {
|
||||||
// Parse the URL
|
// Parse the URL
|
||||||
parsedURL, err := url.Parse(notificationUrl)
|
parsedURL, err := url.Parse(notificationUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -305,7 +321,7 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
parsedURL.RawQuery = queryParams.Encode()
|
parsedURL.RawQuery = queryParams.Encode()
|
||||||
// log.Println("URL after modification:", parsedURL.String())
|
// log.Println("URL after modification:", parsedURL.String())
|
||||||
|
|
||||||
err = shoutrrr.Send(parsedURL.String(), message)
|
err = send(parsedURL.String(), message)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
am.hub.Logger().Info("Sent shoutrrr alert", "title", title)
|
am.hub.Logger().Info("Sent shoutrrr alert", "title", title)
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ package alerts
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/hub/utils"
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
|
"github.com/nicholas-fedor/shoutrrr"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
@@ -147,72 +145,16 @@ func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
|||||||
if err != nil || data.URL == "" {
|
if err != nil || data.URL == "" {
|
||||||
return e.BadRequestError("URL is required", err)
|
return e.BadRequestError("URL is required", err)
|
||||||
}
|
}
|
||||||
// Only allow admins to send test notifications to internal URLs
|
send := shoutrrr.Send
|
||||||
if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" {
|
if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" {
|
||||||
internalURL, err := isInternalURL(data.URL)
|
send = sendPublicNotification
|
||||||
if err != nil {
|
}
|
||||||
return e.BadRequestError(err.Error(), nil)
|
err = am.sendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel", send)
|
||||||
}
|
if errors.Is(err, errInternalDestination) || errors.Is(err, errUnrestrictedService) {
|
||||||
if internalURL {
|
return e.ForbiddenError(err.Error(), nil)
|
||||||
return e.ForbiddenError("Only admins can send to internal destinations", nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
err = am.SendShoutrrrAlert(data.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()})
|
||||||
}
|
}
|
||||||
return e.JSON(200, map[string]bool{"err": false})
|
return e.JSON(200, map[string]bool{"err": false})
|
||||||
}
|
}
|
||||||
|
|
||||||
// isInternalURL checks if the given shoutrrr URL points to an internal destination (localhost or private IP)
|
|
||||||
func isInternalURL(rawURL string) (bool, error) {
|
|
||||||
parsedURL, err := url.Parse(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
host := parsedURL.Hostname()
|
|
||||||
if host == "" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.EqualFold(host, "localhost") {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if ip := net.ParseIP(host); ip != nil {
|
|
||||||
return isInternalIP(ip), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some Shoutrrr URLs use the host position for service identifiers rather than a
|
|
||||||
// network hostname (for example, discord://token@webhookid). Restrict DNS lookups
|
|
||||||
// to names that look like actual hostnames so valid service URLs keep working.
|
|
||||||
if !strings.Contains(host, ".") {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ips, err := net.LookupIP(host)
|
|
||||||
if err != nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.ContainsFunc(ips, isInternalIP) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var cgnatNetwork = &net.IPNet{
|
|
||||||
IP: net.IPv4(100, 64, 0, 0),
|
|
||||||
Mask: net.CIDRMask(10, 32),
|
|
||||||
}
|
|
||||||
|
|
||||||
func isInternalIP(ip net.IP) bool {
|
|
||||||
return ip.IsPrivate() ||
|
|
||||||
ip.IsLoopback() ||
|
|
||||||
ip.IsUnspecified() ||
|
|
||||||
ip.IsLinkLocalUnicast() ||
|
|
||||||
ip.IsMulticast() ||
|
|
||||||
cgnatNetwork.Contains(ip)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
|
|
||||||
@@ -29,43 +30,6 @@ func jsonReader(v any) io.Reader {
|
|||||||
return bytes.NewReader(data)
|
return bytes.NewReader(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsInternalURL(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
url string
|
|
||||||
internal bool
|
|
||||||
}{
|
|
||||||
{name: "loopback ipv4", url: "generic://127.0.0.1", internal: true},
|
|
||||||
{name: "private ipv4", url: "generic://10.0.0.1", internal: true},
|
|
||||||
{name: "localhost hostname", url: "generic://localhost", internal: true},
|
|
||||||
{name: "localhost with path", url: "generic+http://localhost/api/v1/postStuff", internal: true},
|
|
||||||
{name: "loopback with port and path", url: "generic+http://127.0.0.1:8080/api/v1/postStuff", internal: true},
|
|
||||||
{name: "public hostname", url: "generic+https://beszel.dev/api/v1/postStuff", internal: false},
|
|
||||||
{name: "cloud metadata ipv4", url: "generic://169.254.169.254", internal: true},
|
|
||||||
{name: "link-local ipv4", url: "generic://169.254.1.1", internal: true},
|
|
||||||
{name: "link-local ipv6", url: "generic://[fe80::1]", internal: true},
|
|
||||||
{name: "mapped link-local ipv4", url: "generic://[::ffff:169.254.169.254]", internal: true},
|
|
||||||
{name: "cgnat lower boundary", url: "generic://100.64.0.0", internal: true},
|
|
||||||
{name: "cgnat upper boundary", url: "generic://100.127.255.255", internal: true},
|
|
||||||
{name: "below cgnat", url: "generic://100.63.255.255", internal: false},
|
|
||||||
{name: "above cgnat", url: "generic://100.128.0.0", internal: false},
|
|
||||||
{name: "multicast ipv4", url: "generic://224.0.0.1", internal: true},
|
|
||||||
{name: "multicast ipv6", url: "generic://[ff02::1]", internal: true},
|
|
||||||
{name: "public ipv4", url: "generic://8.8.8.8", internal: false},
|
|
||||||
{name: "public ipv6", url: "generic://[2001:4860:4860::8888]", internal: false},
|
|
||||||
{name: "token style service url", url: "discord://abc123@123456789", internal: false},
|
|
||||||
{name: "single label service url", url: "slack://token@team/channel", internal: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, testCase := range testCases {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
internal, err := alerts.IsInternalURL(testCase.url)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, testCase.internal, internal)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserAlertsApi(t *testing.T) {
|
func TestUserAlertsApi(t *testing.T) {
|
||||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
@@ -457,6 +421,17 @@ func TestSendTestNotification(t *testing.T) {
|
|||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
var delivered atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
delivered.Add(1)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
localURL := "generic+" + server.URL
|
||||||
|
|
||||||
|
readonlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
readonlyToken, err := readonlyUser.NewAuthToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
userToken, err := user.NewAuthToken()
|
userToken, err := user.NewAuthToken()
|
||||||
|
|
||||||
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
||||||
@@ -481,11 +456,11 @@ func TestSendTestNotification(t *testing.T) {
|
|||||||
ExpectedContent: []string{"requires valid"},
|
ExpectedContent: []string{"requires valid"},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
Body: jsonReader(map[string]any{
|
Body: jsonReader(map[string]any{
|
||||||
"url": "generic://127.0.0.1",
|
"url": localURL,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "POST /test-notification - with external auth should succeed",
|
Name: "POST /test-notification - invalid service reports error",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
URL: "/api/beszel/test-notification",
|
URL: "/api/beszel/test-notification",
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
@@ -493,7 +468,7 @@ func TestSendTestNotification(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
Body: jsonReader(map[string]any{
|
Body: jsonReader(map[string]any{
|
||||||
"url": "generic://8.8.8.8",
|
"url": "unknown://example.com",
|
||||||
}),
|
}),
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{"\"err\":"},
|
ExpectedContent: []string{"\"err\":"},
|
||||||
@@ -535,10 +510,10 @@ func TestSendTestNotification(t *testing.T) {
|
|||||||
"Authorization": adminUserToken,
|
"Authorization": adminUserToken,
|
||||||
},
|
},
|
||||||
Body: jsonReader(map[string]any{
|
Body: jsonReader(map[string]any{
|
||||||
"url": "generic://127.0.0.1",
|
"url": localURL,
|
||||||
}),
|
}),
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{"\"err\":"},
|
ExpectedContent: []string{"\"err\":false"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "POST /test-notification - internal url with superuser auth should succeed",
|
Name: "POST /test-notification - internal url with superuser auth should succeed",
|
||||||
@@ -549,14 +524,28 @@ func TestSendTestNotification(t *testing.T) {
|
|||||||
"Authorization": superuserToken,
|
"Authorization": superuserToken,
|
||||||
},
|
},
|
||||||
Body: jsonReader(map[string]any{
|
Body: jsonReader(map[string]any{
|
||||||
"url": "generic://127.0.0.1",
|
"url": localURL,
|
||||||
}),
|
}),
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{"\"err\":"},
|
ExpectedContent: []string{"\"err\":"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, url := range []string{localURL, "smtp://user:pass@127.0.0.1/?fromAddress=sender@example.com&toAddresses=recipient@example.com", "mqtt://127.0.0.1/topic"} {
|
||||||
|
scenarios = append(scenarios, beszelTests.ApiScenario{
|
||||||
|
Name: "readonly cannot send to " + url,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Headers: map[string]string{"Authorization": readonlyToken},
|
||||||
|
Body: jsonReader(map[string]any{"url": url}),
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{"Only admins"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
scenario.Test(t)
|
scenario.Test(t)
|
||||||
}
|
}
|
||||||
|
assert.EqualValues(t, 2, delivered.Load(), "only admin and superuser requests should reach the server")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,10 +100,6 @@ func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool)
|
|||||||
return am.setAlertTriggered(alert, triggered)
|
return am.setAlertTriggered(alert, triggered)
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsInternalURL(rawURL string) (bool, error) {
|
|
||||||
return isInternalURL(rawURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildContainerLogExcerpt exposes buildContainerLogExcerpt for testing.
|
// BuildContainerLogExcerpt exposes buildContainerLogExcerpt for testing.
|
||||||
func BuildContainerLogExcerpt(raw string) string {
|
func BuildContainerLogExcerpt(raw string) string {
|
||||||
return buildContainerLogExcerpt(raw)
|
return buildContainerLogExcerpt(raw)
|
||||||
|
|||||||
66
internal/alerts/alerts_webhooks_test.go
Normal file
66
internal/alerts/alerts_webhooks_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPersistedWebhooksUseCurrentOwnerRole(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
|
||||||
|
var delivered atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
delivered.Add(1)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
settings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", dbx.Params{"user": user.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
settings.Set("settings", alerts.UserNotificationSettings{Webhooks: []string{"generic+" + server.URL}})
|
||||||
|
require.NoError(t, hub.Save(settings))
|
||||||
|
message := alerts.AlertMessageData{UserID: user.Id, Title: "Test", Message: "Persisted webhook"}
|
||||||
|
|
||||||
|
// Keep the same URL and manager while changing roles, so cached privileges
|
||||||
|
// or treating previously saved URLs as trusted would fail this test.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
role string
|
||||||
|
want int32
|
||||||
|
}{
|
||||||
|
{"regular user", "user", 0},
|
||||||
|
{"readonly user", "readonly", 0},
|
||||||
|
{"promoted admin", "admin", 1},
|
||||||
|
{"demoted admin", "user", 1},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
user.Set("role", tc.role)
|
||||||
|
require.NoError(t, hub.Save(user))
|
||||||
|
// Webhook errors are logged; SendAlert continues to email delivery.
|
||||||
|
require.NoError(t, am.SendAlert(message))
|
||||||
|
require.Equal(t, tc.want, delivered.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("missing owner fails closed", func(t *testing.T) {
|
||||||
|
// Model an orphaned settings record without deleting it through the
|
||||||
|
// normal user deletion cascade.
|
||||||
|
const missingOwner = "missingowner123"
|
||||||
|
settings.Set("user", missingOwner)
|
||||||
|
require.NoError(t, hub.SaveNoValidate(settings))
|
||||||
|
message.UserID = missingOwner
|
||||||
|
err := am.SendAlert(message)
|
||||||
|
require.ErrorContains(t, err, "load notification owner")
|
||||||
|
require.EqualValues(t, 1, delivered.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
150
internal/alerts/notification_client.go
Normal file
150
internal/alerts/notification_client.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nicholas-fedor/shoutrrr/pkg/router"
|
||||||
|
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInternalDestination = errors.New("Only admins can send to internal destinations")
|
||||||
|
errUnrestrictedService = errors.New("Only admins can use this notification service") // Restrict services w/o custom connection support
|
||||||
|
publicNotificationDialer = &net.Dialer{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
// Control checks each resolved address immediately before connecting.
|
||||||
|
Control: func(_, address string, _ syscall.RawConn) error { return checkNotificationAddress(address) },
|
||||||
|
}
|
||||||
|
publicNotificationClient = newPublicNotificationClient()
|
||||||
|
)
|
||||||
|
|
||||||
|
func newPublicNotificationClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
// Do not use proxies: they can resolve the target themselves and
|
||||||
|
// bypass the destination check on our socket.
|
||||||
|
DialContext: publicNotificationDialer.DialContext,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkNotificationAddress(address string) error {
|
||||||
|
addr, err := netip.ParseAddrPort(address)
|
||||||
|
if err != nil || addr.Addr().Zone() != "" {
|
||||||
|
return errInternalDestination
|
||||||
|
}
|
||||||
|
ip := net.IP(addr.Addr().AsSlice())
|
||||||
|
if !ip.IsGlobalUnicast() || isInternalIP(ip) {
|
||||||
|
return errInternalDestination
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPublicNotification(rawURL, message string) error {
|
||||||
|
client := ¬ificationClient{Client: publicNotificationClient}
|
||||||
|
service, err := newPublicNotificationService(rawURL, types.SenderOptions{HTTPClient: client, DialContext: client.dialContext})
|
||||||
|
if err == nil {
|
||||||
|
if closer, ok := service.(io.Closer); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
err = service.Send(message, &types.Params{})
|
||||||
|
}
|
||||||
|
// Some services format errors without preserving their error chain.
|
||||||
|
if client.blocked.Load() {
|
||||||
|
return errInternalDestination
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type notificationClient struct {
|
||||||
|
*http.Client
|
||||||
|
blocked atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *notificationClient) Do(req *http.Request) (*http.Response, error) {
|
||||||
|
response, err := c.Client.Do(req)
|
||||||
|
if errors.Is(err, errInternalDestination) {
|
||||||
|
c.blocked.Store(true)
|
||||||
|
}
|
||||||
|
return response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *notificationClient) dialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
conn, err := publicNotificationDialer.DialContext(ctx, network, address)
|
||||||
|
if errors.Is(err, errInternalDestination) {
|
||||||
|
c.blocked.Store(true)
|
||||||
|
}
|
||||||
|
return conn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPublicNotificationService(rawURL string, opts types.SenderOptions) (types.Service, error) {
|
||||||
|
r := &router.ServiceRouter{}
|
||||||
|
scheme, serviceURL, err := r.ExtractServiceName(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
service, err := r.NewService(scheme)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpSetter, httpOK := service.(types.HTTPClientSetter)
|
||||||
|
dialSetter, dialOK := service.(types.DialContextSetter)
|
||||||
|
if (!httpOK || opts.HTTPClient == nil) && (!dialOK || opts.DialContext == nil) {
|
||||||
|
return nil, errUnrestrictedService
|
||||||
|
}
|
||||||
|
if serviceURL.Scheme != scheme {
|
||||||
|
custom, ok := service.(types.CustomURLService)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %s", router.ErrCustomURLsNotSupported, scheme)
|
||||||
|
}
|
||||||
|
serviceURL, err = custom.GetServiceURLFromCustom(serviceURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shoutrrr v0.20.0 CreateSenderWithOptions injects only AFTER Initialize.
|
||||||
|
// Matrix can log in during Initialize, so inject before it as well.
|
||||||
|
if httpOK {
|
||||||
|
httpSetter.SetHTTPClient(opts.HTTPClient)
|
||||||
|
}
|
||||||
|
if dialOK {
|
||||||
|
dialSetter.SetDialContext(opts.DialContext)
|
||||||
|
}
|
||||||
|
if err := service.Initialize(serviceURL, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Some initializers replace their HTTP client with a default client.
|
||||||
|
if httpOK {
|
||||||
|
httpSetter.SetHTTPClient(opts.HTTPClient)
|
||||||
|
}
|
||||||
|
if dialOK {
|
||||||
|
dialSetter.SetDialContext(opts.DialContext)
|
||||||
|
}
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cgnatNetwork = &net.IPNet{
|
||||||
|
IP: net.IPv4(100, 64, 0, 0),
|
||||||
|
Mask: net.CIDRMask(10, 32),
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInternalIP(ip net.IP) bool {
|
||||||
|
return ip.IsPrivate() ||
|
||||||
|
ip.IsLoopback() ||
|
||||||
|
ip.IsUnspecified() ||
|
||||||
|
ip.IsLinkLocalUnicast() ||
|
||||||
|
ip.IsMulticast() ||
|
||||||
|
cgnatNetwork.Contains(ip)
|
||||||
|
}
|
||||||
217
internal/alerts/notification_client_test.go
Normal file
217
internal/alerts/notification_client_test.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||||
|
"golang.org/x/net/dns/dnsmessage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckNotificationAddress(t *testing.T) {
|
||||||
|
for _, host := range []string{"127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.0.1", "169.254.169.254", "100.64.0.0", "100.127.255.255", "0.0.0.0", "224.0.0.1", "255.255.255.255", "::1", "::", "fc00::1", "fe80::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:169.254.169.254", "fe80::1%lo", "localhost", "consul"} {
|
||||||
|
t.Run(host, func(t *testing.T) {
|
||||||
|
if err := checkNotificationAddress(net.JoinHostPort(host, "80")); !errors.Is(err, errInternalDestination) {
|
||||||
|
t.Fatalf("expected blocked address, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, host := range []string{"8.8.8.8", "100.63.255.255", "100.128.0.0", "2001:4860:4860::8888"} {
|
||||||
|
if err := checkNotificationAddress(net.JoinHostPort(host, "443")); err != nil {
|
||||||
|
t.Errorf("public address %s: %v", host, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicNotificationBlocksInternalRequests(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
host := strings.TrimPrefix(server.URL, "http://")
|
||||||
|
for _, rawURL := range []string{
|
||||||
|
"generic+http://" + host,
|
||||||
|
"generic+https://" + host,
|
||||||
|
"generic+http://localhost:" + strings.Split(host, ":")[1],
|
||||||
|
"matrix://user:password@" + host + "/room?disabletls=yes",
|
||||||
|
"mattermost://" + host + "/token?disabletls=yes",
|
||||||
|
} {
|
||||||
|
t.Run(rawURL, func(t *testing.T) {
|
||||||
|
if err := sendPublicNotification(rawURL, "test"); !errors.Is(err, errInternalDestination) {
|
||||||
|
t.Fatalf("expected internal destination error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if hits.Load() != 0 {
|
||||||
|
t.Fatal("internal server received a request")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type notificationRoundTripper func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f notificationRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||||
|
|
||||||
|
func TestPublicNotificationRedirect(t *testing.T) {
|
||||||
|
client := newPublicNotificationClient()
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
transport := client.Transport
|
||||||
|
client.Transport = notificationRoundTripper(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Host == "public.example" {
|
||||||
|
return &http.Response{StatusCode: 307, Header: http.Header{"Location": {"http://127.0.0.1/"}}, Body: io.NopCloser(strings.NewReader("")), Request: r}, nil
|
||||||
|
}
|
||||||
|
return transport.RoundTrip(r)
|
||||||
|
})
|
||||||
|
_, err := client.Get("http://public.example/")
|
||||||
|
if !errors.Is(err, errInternalDestination) {
|
||||||
|
t.Fatalf("expected redirect to be blocked, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicNotificationServiceClient(t *testing.T) {
|
||||||
|
for _, rawURL := range []string{"generic+http://public.example/path", "discord://token@123456789", "slack://hook:AAAAAAAAA-BBBBBBBBB-123456789123456789123456@webhook"} {
|
||||||
|
t.Run(rawURL, func(t *testing.T) {
|
||||||
|
var hits int
|
||||||
|
client := &http.Client{Transport: notificationRoundTripper(func(r *http.Request) (*http.Response, error) {
|
||||||
|
hits++
|
||||||
|
body := `{"ok":true}`
|
||||||
|
if strings.HasPrefix(rawURL, "slack:") {
|
||||||
|
body = "ok"
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil
|
||||||
|
})}
|
||||||
|
service, err := newPublicNotificationService(rawURL, types.SenderOptions{HTTPClient: client})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := service.Send("test", &types.Params{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits == 0 {
|
||||||
|
t.Fatal("injected client was not used")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicNotificationDNS(t *testing.T) {
|
||||||
|
// Supply deterministic DNS responses over an in-memory TCP connection.
|
||||||
|
// The first lookup sees a public IP; subsequent lookups see loopback.
|
||||||
|
var rebound atomic.Bool
|
||||||
|
resolver := &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
client, server := net.Pipe()
|
||||||
|
go func() {
|
||||||
|
defer server.Close()
|
||||||
|
var size [2]byte
|
||||||
|
if _, err := io.ReadFull(server, size[:]); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := make([]byte, binary.BigEndian.Uint16(size[:]))
|
||||||
|
if _, err := io.ReadFull(server, buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var msg dnsmessage.Message
|
||||||
|
if err := msg.Unpack(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg.Header.Response = true
|
||||||
|
msg.Header.RecursionAvailable = true
|
||||||
|
q := msg.Questions[0]
|
||||||
|
if q.Type == dnsmessage.TypeA {
|
||||||
|
ip := [4]byte{8, 8, 8, 8}
|
||||||
|
if rebound.Load() {
|
||||||
|
ip = [4]byte{127, 0, 0, 1}
|
||||||
|
}
|
||||||
|
msg.Answers = []dnsmessage.Resource{{Header: dnsmessage.ResourceHeader{Name: q.Name, Type: q.Type, Class: dnsmessage.ClassINET}, Body: &dnsmessage.AResource{A: ip}}}
|
||||||
|
}
|
||||||
|
buf, err := msg.Pack()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binary.BigEndian.PutUint16(size[:], uint16(len(buf)))
|
||||||
|
server.Write(append(size[:], buf...))
|
||||||
|
}()
|
||||||
|
return client, nil
|
||||||
|
}}
|
||||||
|
// These tests do not run in parallel; restore the process resolver afterward.
|
||||||
|
previous := net.DefaultResolver
|
||||||
|
net.DefaultResolver = resolver
|
||||||
|
t.Cleanup(func() { net.DefaultResolver = previous })
|
||||||
|
ips, err := resolver.LookupIP(context.Background(), "ip4", "rebind.example")
|
||||||
|
if err != nil || len(ips) != 1 || !ips[0].Equal(net.IPv4(8, 8, 8, 8)) {
|
||||||
|
t.Fatalf("initial DNS lookup: %v, %v", ips, err)
|
||||||
|
}
|
||||||
|
rebound.Store(true)
|
||||||
|
client := newPublicNotificationClient()
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
for _, host := range []string{"rebind.example", "consul"} {
|
||||||
|
guarded := ¬ificationClient{Client: client}
|
||||||
|
conn, dialErr := guarded.dialContext(context.Background(), "tcp", net.JoinHostPort(host, "25"))
|
||||||
|
if conn != nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
if !errors.Is(dialErr, errInternalDestination) || !guarded.blocked.Load() {
|
||||||
|
t.Errorf("expected TCP dial-time rejection for %s, got %v", host, dialErr)
|
||||||
|
}
|
||||||
|
_, err := client.Get("http://" + host + "/")
|
||||||
|
if !errors.Is(err, errInternalDestination) {
|
||||||
|
t.Errorf("expected dial-time rejection for %s, got %v", host, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicNotificationTCP(t *testing.T) {
|
||||||
|
for _, rawURL := range []string{
|
||||||
|
"smtp://user:pass@HOST:25/?fromAddress=sender@example.com&toAddresses=recipient@example.com",
|
||||||
|
"smtp://user:pass@HOST:465/?fromAddress=sender@example.com&toAddresses=recipient@example.com",
|
||||||
|
"mqtt://HOST:1883/topic",
|
||||||
|
"mqtts://HOST:8883/topic",
|
||||||
|
} {
|
||||||
|
t.Run(rawURL, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
t.Run("internal destination", func(t *testing.T) {
|
||||||
|
err := sendPublicNotification(strings.ReplaceAll(rawURL, "HOST", "127.0.0.1"), "test")
|
||||||
|
if !errors.Is(err, errInternalDestination) {
|
||||||
|
t.Fatalf("expected blocked destination, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("public destination uses injected dialer", func(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
stopped := errors.New("test dial stopped")
|
||||||
|
service, err := newPublicNotificationService(strings.ReplaceAll(rawURL, "HOST", "8.8.8.8"), types.SenderOptions{
|
||||||
|
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
if network != "tcp" || !strings.HasPrefix(address, "8.8.8.8:") {
|
||||||
|
t.Errorf("unexpected dial: %s %s", network, address)
|
||||||
|
}
|
||||||
|
if err := checkNotificationAddress(address); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
return nil, stopped
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if closer, ok := service.(io.Closer); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
if err := service.Send("test", &types.Params{}); err == nil {
|
||||||
|
t.Fatal("expected dial failure")
|
||||||
|
}
|
||||||
|
if calls.Load() == 0 {
|
||||||
|
t.Fatal("custom dialer was not used")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ RUN go mod download
|
|||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . ./
|
COPY . ./
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates && update-ca-certificates
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
@@ -19,6 +21,7 @@ RUN rm -rf /tmp/*
|
|||||||
# --------------------------
|
# --------------------------
|
||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
|
||||||
# this is so we don't need to create the /tmp directory in the scratch container
|
# this is so we don't need to create the /tmp directory in the scratch container
|
||||||
COPY --from=builder /tmp /tmp
|
COPY --from=builder /tmp /tmp
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ FROM alpine:3.23
|
|||||||
|
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools nvtop smartmontools
|
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools nvtop smartmontools zfs
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
# Ensure data persistence across container recreations
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|||||||
@@ -75,7 +75,12 @@ export const smartColumns: ColumnDef<SmartAttribute>[] = [
|
|||||||
header: "Name",
|
header: "Name",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: (row) => row.rs || row.rv?.toString(),
|
accessorFn: (row) => {
|
||||||
|
if (row.n === "DataUnitsWritten" || row.n === "DataUnitsRead") {
|
||||||
|
return formatDataUnits(Number(row.rv ?? 0))
|
||||||
|
}
|
||||||
|
return row.rs || row.rv?.toString()
|
||||||
|
},
|
||||||
header: "Value",
|
header: "Value",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -103,6 +108,12 @@ function formatCapacity(bytes: number): string {
|
|||||||
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to format NVMe data units
|
||||||
|
// (1 unit = 1000 * 512 bytes) as a human-readable size
|
||||||
|
function formatDataUnits(units: number): string {
|
||||||
|
return formatCapacity(units * 1000 * 512)
|
||||||
|
}
|
||||||
|
|
||||||
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
|
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
|
||||||
|
|
||||||
export const createColumns = (
|
export const createColumns = (
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ apiVersion: v1
|
|||||||
description: Installs beszel-agent in kubernetes
|
description: Installs beszel-agent in kubernetes
|
||||||
home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
|
home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
|
||||||
name: beszel-agent
|
name: beszel-agent
|
||||||
appVersion: "0.18.8"
|
appVersion: "0.19.0"
|
||||||
# Bump this version when publishing chart changes.
|
# Bump this version when publishing chart changes.
|
||||||
version: 0.1.5
|
version: 0.1.6
|
||||||
sources:
|
sources:
|
||||||
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
|
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
|
||||||
- https://www.beszel.dev/
|
- https://www.beszel.dev/
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ Essential parameters to configure:
|
|||||||
| `secret.sshKey` | `ssh-key` | Key name in the secret for the SSH public key |
|
| `secret.sshKey` | `ssh-key` | Key name in the secret for the SSH public key |
|
||||||
| `secret.tokenKey` | `token` | Key name in the secret for the authentication token |
|
| `secret.tokenKey` | `token` | Key name in the secret for the authentication token |
|
||||||
| `image.repository` | `henrygd/beszel-agent` | Container image |
|
| `image.repository` | `henrygd/beszel-agent` | Container image |
|
||||||
| `image.tag` | Chart AppVersion (0.18.8) | Image version |
|
| `image.tag` | Chart AppVersion (0.19.0) | Image version |
|
||||||
| `hostNetwork` | `false` | Use host network for network monitoring |
|
| `hostNetwork` | `false` | Use host network for network monitoring |
|
||||||
| `tolerations` | Allows all taints | Tolerations for running on tainted nodes |
|
| `tolerations` | Allows all taints | Tolerations for running on tainted nodes |
|
||||||
|
|
||||||
@@ -385,7 +385,7 @@ helm upgrade beszel-agent ./beszel-agent \
|
|||||||
|
|
||||||
# Change image version
|
# Change image version
|
||||||
helm upgrade beszel-agent ./beszel-agent \
|
helm upgrade beszel-agent ./beszel-agent \
|
||||||
--set image.tag="0.18.8"
|
--set image.tag="0.19.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Restart All Agents
|
### Restart All Agents
|
||||||
@@ -522,7 +522,7 @@ kubectl get secret beszel-agent -o jsonpath='{.data.ssh-key}' | base64 -d
|
|||||||
## Chart Information
|
## Chart Information
|
||||||
|
|
||||||
- **Chart Version**: 0.1.0
|
- **Chart Version**: 0.1.0
|
||||||
- **App Version**: 0.18.8
|
- **App Version**: 0.19.0
|
||||||
- **Kubernetes Version**: 1.19+
|
- **Kubernetes Version**: 1.19+
|
||||||
- **Maintainer**: cloudwithdan (nikoloskid@pm.me)
|
- **Maintainer**: cloudwithdan (nikoloskid@pm.me)
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ apiVersion: v1
|
|||||||
description: Installs beszel-hub in kubernetes
|
description: Installs beszel-hub in kubernetes
|
||||||
home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub
|
home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub
|
||||||
name: beszel-hub
|
name: beszel-hub
|
||||||
appVersion: "0.18.8"
|
appVersion: "0.19.0"
|
||||||
# Bump this version when publishing chart changes.
|
# Bump this version when publishing chart changes.
|
||||||
version: 0.1.5
|
version: 0.1.6
|
||||||
sources:
|
sources:
|
||||||
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub
|
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub
|
||||||
- https://www.beszel.dev/
|
- https://www.beszel.dev/
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Key configuration options in `values.yaml`:
|
|||||||
|-----------|---------|-------------|
|
|-----------|---------|-------------|
|
||||||
| `replicaCount` | `1` | Number of Beszel Hub replicas |
|
| `replicaCount` | `1` | Number of Beszel Hub replicas |
|
||||||
| `image.repository` | `henrygd/beszel` | Container image repository |
|
| `image.repository` | `henrygd/beszel` | Container image repository |
|
||||||
| `image.tag` | Chart AppVersion (0.18.8) | Container image tag |
|
| `image.tag` | Chart AppVersion (0.19.0) | Container image tag |
|
||||||
| `image.pullPolicy` | `IfNotPresent` | Image pull policy |
|
| `image.pullPolicy` | `IfNotPresent` | Image pull policy |
|
||||||
| `service.port` | `8090` | Service port |
|
| `service.port` | `8090` | Service port |
|
||||||
| `persistentVolumeClaim.enabled` | `true` | Enable persistent volume |
|
| `persistentVolumeClaim.enabled` | `true` | Enable persistent volume |
|
||||||
@@ -169,7 +169,7 @@ tolerations:
|
|||||||
```yaml
|
```yaml
|
||||||
replicaCount: 3
|
replicaCount: 3
|
||||||
image:
|
image:
|
||||||
tag: "0.18.8"
|
tag: "0.19.0"
|
||||||
service:
|
service:
|
||||||
type: LoadBalancer
|
type: LoadBalancer
|
||||||
ingress:
|
ingress:
|
||||||
@@ -330,7 +330,7 @@ By default, Beszel Hub uses a PersistentVolumeClaim for data storage. Ensure you
|
|||||||
## Chart Information
|
## Chart Information
|
||||||
|
|
||||||
- **Chart Version**: 0.1.0
|
- **Chart Version**: 0.1.0
|
||||||
- **App Version**: 0.18.8
|
- **App Version**: 0.19.0
|
||||||
- **Kubernetes Version**: 1.19+
|
- **Kubernetes Version**: 1.19+
|
||||||
- **Maintainer**: cloudwithdan (nikoloskid@pm.me)
|
- **Maintainer**: cloudwithdan (nikoloskid@pm.me)
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ is_glibc() {
|
|||||||
set_selinux_context() {
|
set_selinux_context() {
|
||||||
# Check if SELinux is enabled and in enforcing or permissive mode
|
# Check if SELinux is enabled and in enforcing or permissive mode
|
||||||
if command -v getenforce >/dev/null 2>&1; then
|
if command -v getenforce >/dev/null 2>&1; then
|
||||||
SELINUX_MODE=$(getenforce)
|
SELINUX_MODE=$(getenforce) || { warn "Could not query SELinux mode."; return 0; }
|
||||||
if [ "$SELINUX_MODE" != "Disabled" ]; then
|
if [ "$SELINUX_MODE" != "Disabled" ]; then
|
||||||
echo "SELinux is enabled (${SELINUX_MODE} mode). Setting appropriate context..."
|
echo "SELinux is enabled (${SELINUX_MODE} mode). Setting appropriate context..."
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ set_selinux_context() {
|
|||||||
if command -v semanage >/dev/null 2>&1; then
|
if command -v semanage >/dev/null 2>&1; then
|
||||||
echo "Attempting to set persistent SELinux context..."
|
echo "Attempting to set persistent SELinux context..."
|
||||||
if semanage fcontext -a -t bin_t "$BIN_PATH" >/dev/null 2>&1; then
|
if semanage fcontext -a -t bin_t "$BIN_PATH" >/dev/null 2>&1; then
|
||||||
restorecon -v "$BIN_PATH" >/dev/null 2>&1
|
restorecon -v "$BIN_PATH" >/dev/null 2>&1 || warn "Failed to restore persistent SELinux context; trying chcon."
|
||||||
else
|
else
|
||||||
echo "Warning: Failed to set persistent context, falling back to temporary context."
|
echo "Warning: Failed to set persistent context, falling back to temporary context."
|
||||||
fi
|
fi
|
||||||
@@ -271,7 +271,7 @@ detect_mips_endianness() {
|
|||||||
for bin_to_check in $bins; do
|
for bin_to_check in $bins; do
|
||||||
if [ -f "$bin_to_check" ]; then
|
if [ -f "$bin_to_check" ]; then
|
||||||
# The 6th byte in ELF header: 01 = little, 02 = big
|
# The 6th byte in ELF header: 01 = little, 02 = big
|
||||||
endian=$(hexdump -n 1 -s 5 -e '1/1 "%02x"' "$bin_to_check" 2>/dev/null)
|
endian=$(hexdump -n 1 -s 5 -e '1/1 "%02x"' "$bin_to_check" 2>/dev/null) || continue
|
||||||
if [ "$endian" = "01" ]; then
|
if [ "$endian" = "01" ]; then
|
||||||
echo "mipsle"
|
echo "mipsle"
|
||||||
return
|
return
|
||||||
@@ -286,6 +286,136 @@ detect_mips_endianness() {
|
|||||||
echo "mips"
|
echo "mips"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Expected failures must be handled explicitly; unexpected failures abort installation.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
warn() {
|
||||||
|
echo "Warning: $*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
require_value() {
|
||||||
|
[ "$#" -ge 2 ] && [ -n "$2" ] || fail "Option $1 requires a value."
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_platform() {
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Linux)
|
||||||
|
if is_alpine; then
|
||||||
|
command -v rc-service >/dev/null && command -v rc-update >/dev/null || fail "OpenRC is required."
|
||||||
|
elif is_openwrt; then
|
||||||
|
[ -f /etc/rc.common ] || fail "OpenWrt procd is required."
|
||||||
|
else
|
||||||
|
command -v systemctl >/dev/null && [ -d /run/systemd/system ] || fail "This Linux installer requires a running systemd, OpenRC (Alpine), or procd (OpenWrt)."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
FreeBSD)
|
||||||
|
command -v service >/dev/null && command -v sysrc >/dev/null || fail "FreeBSD service and sysrc commands are required."
|
||||||
|
;;
|
||||||
|
Darwin) fail "For macOS, use the Homebrew installer: https://github.com/henrygd/beszel/blob/main/supplemental/scripts/install-agent-brew.sh" ;;
|
||||||
|
*) fail "Unsupported operating system: $(uname -s)" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
agent_service() {
|
||||||
|
if is_alpine; then
|
||||||
|
rc-service beszel-agent "$1"
|
||||||
|
elif is_openwrt; then
|
||||||
|
/etc/init.d/beszel-agent "$1"
|
||||||
|
elif is_freebsd; then
|
||||||
|
service beszel-agent "$1"
|
||||||
|
else
|
||||||
|
systemctl "$1" beszel-agent.service
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Match the files preserved by the service setup below. A binary or rc script
|
||||||
|
# alone is not reusable configuration (FreeBSD stores its environment separately).
|
||||||
|
agent_configuration_exists() {
|
||||||
|
if is_alpine || is_openwrt; then
|
||||||
|
[ -f /etc/init.d/beszel-agent ]
|
||||||
|
elif is_freebsd; then
|
||||||
|
[ -f "$AGENT_DIR/env" ]
|
||||||
|
else
|
||||||
|
[ -f /etc/systemd/system/beszel-agent.service ]
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# An orphaned binary can remain after a failed install. It does not imply
|
||||||
|
# that the service manager knows about the agent yet.
|
||||||
|
agent_service_registered() {
|
||||||
|
if is_alpine || is_openwrt; then
|
||||||
|
[ -f /etc/init.d/beszel-agent ]
|
||||||
|
elif is_freebsd; then
|
||||||
|
[ -f /usr/local/etc/rc.d/beszel-agent ]
|
||||||
|
else
|
||||||
|
service_load_state=$(systemctl show --property=LoadState --value beszel-agent.service) || return 2
|
||||||
|
case "$service_load_state" in
|
||||||
|
not-found) return 1 ;;
|
||||||
|
"") return 2 ;;
|
||||||
|
*) return 0 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
TEMP_DIR=""
|
||||||
|
STAGED_BINARY=""
|
||||||
|
INSTALL_STEP="validating installation options"
|
||||||
|
UPGRADE_PENDING=false
|
||||||
|
cleanup() {
|
||||||
|
cleanup_status=$?
|
||||||
|
trap - 0 HUP INT TERM
|
||||||
|
if [ "$cleanup_status" -ne 0 ]; then
|
||||||
|
warn "Installer failed while $INSTALL_STEP (exit $cleanup_status)."
|
||||||
|
if [ "$UPGRADE_PENDING" = true ]; then
|
||||||
|
warn "Restoring the previous binary and restarting its service if registered."
|
||||||
|
if [ -n "$STAGED_BINARY" ]; then
|
||||||
|
rm -f "$STAGED_BINARY" || warn "Could not remove staged binary."
|
||||||
|
fi
|
||||||
|
if STAGED_BINARY=$(mktemp "$BIN_PATH.XXXXXX") && cp -p "$BIN_PATH.bak" "$STAGED_BINARY" && mv -f "$STAGED_BINARY" "$BIN_PATH"; then
|
||||||
|
# The temporary inode does not inherit the installed binary's SELinux label.
|
||||||
|
set_selinux_context || warn "Could not restore SELinux context on the previous agent."
|
||||||
|
if agent_service_registered; then
|
||||||
|
agent_service restart || warn "Could not restart the previous agent; check the service configuration and logs."
|
||||||
|
else
|
||||||
|
service_check_status=$?
|
||||||
|
[ "$service_check_status" -eq 1 ] || warn "Could not determine whether the previous agent service is registered; check it manually."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not restore $BIN_PATH.bak. Restore it manually before restarting the service."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "$STAGED_BINARY" ]; then
|
||||||
|
rm -f "$STAGED_BINARY" || warn "Could not remove staged binary."
|
||||||
|
fi
|
||||||
|
if [ -n "$TEMP_DIR" ]; then
|
||||||
|
rm -rf "$TEMP_DIR" || warn "Could not remove temporary directory $TEMP_DIR."
|
||||||
|
fi
|
||||||
|
exit "$cleanup_status"
|
||||||
|
}
|
||||||
|
trap cleanup 0
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
# A missing crontab is normal. Keep the producer alive so the new job is written.
|
||||||
|
read_root_crontab() {
|
||||||
|
crontab -u root -l 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_auto_update() {
|
||||||
|
printf "\nEnable automatic daily updates for beszel-agent? (y/n): "
|
||||||
|
if ! read -r AUTO_UPDATE; then
|
||||||
|
AUTO_UPDATE=n
|
||||||
|
echo "Skipping automatic updates (no input)."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
PORT=45876
|
PORT=45876
|
||||||
UNINSTALL=false
|
UNINSTALL=false
|
||||||
@@ -304,7 +434,7 @@ HUB_URL_PROVIDED=false
|
|||||||
VERSION="latest"
|
VERSION="latest"
|
||||||
|
|
||||||
# Check for help flag
|
# Check for help flag
|
||||||
case "$1" in
|
case "${1-}" in
|
||||||
-h | --help)
|
-h | --help)
|
||||||
printf "Beszel Agent installation script\n\n"
|
printf "Beszel Agent installation script\n\n"
|
||||||
printf "Usage: ./install-agent.sh [options]\n\n"
|
printf "Usage: ./install-agent.sh [options]\n\n"
|
||||||
@@ -324,6 +454,9 @@ case "$1" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# Reject unsupported hosts before sudo or any system changes.
|
||||||
|
validate_platform
|
||||||
|
|
||||||
# Build sudo args by properly quoting everything
|
# Build sudo args by properly quoting everything
|
||||||
build_sudo_args() {
|
build_sudo_args() {
|
||||||
QUOTED_ARGS=""
|
QUOTED_ARGS=""
|
||||||
@@ -354,26 +487,31 @@ fi
|
|||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
-k)
|
-k)
|
||||||
|
require_value "$@"
|
||||||
shift
|
shift
|
||||||
KEY="$1"
|
KEY="$1"
|
||||||
KEY_PROVIDED=true
|
KEY_PROVIDED=true
|
||||||
;;
|
;;
|
||||||
-p)
|
-p)
|
||||||
|
require_value "$@"
|
||||||
shift
|
shift
|
||||||
PORT="$1"
|
PORT="$1"
|
||||||
PORT_PROVIDED=true
|
PORT_PROVIDED=true
|
||||||
;;
|
;;
|
||||||
-t)
|
-t)
|
||||||
|
require_value "$@"
|
||||||
shift
|
shift
|
||||||
TOKEN="$1"
|
TOKEN="$1"
|
||||||
TOKEN_PROVIDED=true
|
TOKEN_PROVIDED=true
|
||||||
;;
|
;;
|
||||||
-url)
|
-url)
|
||||||
|
require_value "$@"
|
||||||
shift
|
shift
|
||||||
HUB_URL="$1"
|
HUB_URL="$1"
|
||||||
HUB_URL_PROVIDED=true
|
HUB_URL_PROVIDED=true
|
||||||
;;
|
;;
|
||||||
-v | --version)
|
-v | --version)
|
||||||
|
require_value "$@"
|
||||||
shift
|
shift
|
||||||
VERSION="$1"
|
VERSION="$1"
|
||||||
;;
|
;;
|
||||||
@@ -392,7 +530,7 @@ while [ $# -gt 0 ]; do
|
|||||||
GITHUB_PROXY_URL="https://gh.beszel.dev"
|
GITHUB_PROXY_URL="https://gh.beszel.dev"
|
||||||
GITHUB_URL="$GITHUB_PROXY_URL"
|
GITHUB_URL="$GITHUB_PROXY_URL"
|
||||||
fi
|
fi
|
||||||
elif [ "$2" != "" ] && ! echo "$2" | grep -q '^-'; then
|
elif [ "${2-}" != "" ] && ! echo "$2" | grep -q '^-'; then
|
||||||
# use custom proxy URL provided as next argument
|
# use custom proxy URL provided as next argument
|
||||||
GITHUB_PROXY_URL="$2"
|
GITHUB_PROXY_URL="$2"
|
||||||
GITHUB_URL="$(ensure_trailing_slash "$2")https://github.com"
|
GITHUB_URL="$(ensure_trailing_slash "$2")https://github.com"
|
||||||
@@ -415,7 +553,7 @@ while [ $# -gt 0 ]; do
|
|||||||
else
|
else
|
||||||
echo "Invalid value for --auto-update flag: $AUTO_UPDATE_VALUE. Using default (prompt)."
|
echo "Invalid value for --auto-update flag: $AUTO_UPDATE_VALUE. Using default (prompt)."
|
||||||
fi
|
fi
|
||||||
elif [ "$2" = "true" ] || [ "$2" = "false" ]; then
|
elif [ "${2-}" = "true" ] || [ "${2-}" = "false" ]; then
|
||||||
# Value provided as next argument
|
# Value provided as next argument
|
||||||
AUTO_UPDATE_FLAG="$2"
|
AUTO_UPDATE_FLAG="$2"
|
||||||
shift
|
shift
|
||||||
@@ -443,20 +581,7 @@ else
|
|||||||
BIN_PATH="/opt/beszel-agent/beszel-agent"
|
BIN_PATH="/opt/beszel-agent/beszel-agent"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Stop existing service if it exists (for upgrades)
|
INSTALL_STEP="uninstalling the agent"
|
||||||
if [ "$UNINSTALL" != true ] && [ -f "$BIN_PATH" ]; then
|
|
||||||
echo "Existing installation detected. Stopping service for upgrade..."
|
|
||||||
if is_alpine; then
|
|
||||||
rc-service beszel-agent stop 2>/dev/null || true
|
|
||||||
elif is_openwrt; then
|
|
||||||
/etc/init.d/beszel-agent stop 2>/dev/null || true
|
|
||||||
elif is_freebsd; then
|
|
||||||
service beszel-agent stop 2>/dev/null || true
|
|
||||||
else
|
|
||||||
systemctl stop beszel-agent.service 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Uninstall process
|
# Uninstall process
|
||||||
if [ "$UNINSTALL" = true ]; then
|
if [ "$UNINSTALL" = true ]; then
|
||||||
# Clean up SELinux contexts before removing files
|
# Clean up SELinux contexts before removing files
|
||||||
@@ -464,8 +589,8 @@ if [ "$UNINSTALL" = true ]; then
|
|||||||
|
|
||||||
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 || warn "Cleanup command failed: rc-service beszel-agent stop"
|
||||||
rc-update del beszel-agent default
|
rc-update del beszel-agent default || warn "Cleanup command failed: rc-update del beszel-agent default"
|
||||||
|
|
||||||
echo "Removing the OpenRC service files..."
|
echo "Removing the OpenRC service files..."
|
||||||
rm -f /etc/init.d/beszel-agent
|
rm -f /etc/init.d/beszel-agent
|
||||||
@@ -481,8 +606,8 @@ if [ "$UNINSTALL" = true ]; then
|
|||||||
rm -f /var/log/beszel-agent.log /var/log/beszel-agent.err
|
rm -f /var/log/beszel-agent.log /var/log/beszel-agent.err
|
||||||
elif is_openwrt; then
|
elif is_openwrt; then
|
||||||
echo "Stopping and disabling the agent service..."
|
echo "Stopping and disabling the agent service..."
|
||||||
/etc/init.d/beszel-agent stop
|
/etc/init.d/beszel-agent stop || warn "Cleanup command failed: /etc/init.d/beszel-agent stop"
|
||||||
/etc/init.d/beszel-agent disable
|
/etc/init.d/beszel-agent disable || warn "Cleanup command failed: /etc/init.d/beszel-agent disable"
|
||||||
|
|
||||||
echo "Removing the OpenWRT service files..."
|
echo "Removing the OpenWRT service files..."
|
||||||
rm -f /etc/init.d/beszel-agent
|
rm -f /etc/init.d/beszel-agent
|
||||||
@@ -498,7 +623,7 @@ if [ "$UNINSTALL" = true ]; then
|
|||||||
|
|
||||||
elif is_freebsd; then
|
elif is_freebsd; then
|
||||||
echo "Stopping and disabling the agent service..."
|
echo "Stopping and disabling the agent service..."
|
||||||
service beszel-agent stop
|
service beszel-agent stop || warn "Cleanup command failed: service beszel-agent stop"
|
||||||
sysrc beszel_agent_enable="NO"
|
sysrc beszel_agent_enable="NO"
|
||||||
|
|
||||||
echo "Removing the FreeBSD service files..."
|
echo "Removing the FreeBSD service files..."
|
||||||
@@ -525,16 +650,16 @@ if [ "$UNINSTALL" = true ]; then
|
|||||||
|
|
||||||
else
|
else
|
||||||
echo "Stopping and disabling the agent service..."
|
echo "Stopping and disabling the agent service..."
|
||||||
systemctl stop beszel-agent.service
|
systemctl stop beszel-agent.service || warn "Cleanup command failed: systemctl stop beszel-agent.service"
|
||||||
systemctl disable beszel-agent.service >/dev/null 2>&1
|
systemctl disable beszel-agent.service >/dev/null 2>&1 || warn "Cleanup command failed: systemctl disable beszel-agent.service"
|
||||||
|
|
||||||
echo "Removing the systemd service file..."
|
echo "Removing the systemd service file..."
|
||||||
rm /etc/systemd/system/beszel-agent.service
|
rm -f /etc/systemd/system/beszel-agent.service
|
||||||
|
|
||||||
# Remove the update timer and service if they exist
|
# Remove the update timer and service if they exist
|
||||||
echo "Removing the daily update service and timer..."
|
echo "Removing the daily update service and timer..."
|
||||||
systemctl stop beszel-agent-update.timer 2>/dev/null
|
systemctl stop beszel-agent-update.timer 2>/dev/null || warn "Cleanup command failed: systemctl stop beszel-agent-update.timer"
|
||||||
systemctl disable beszel-agent-update.timer >/dev/null 2>&1
|
systemctl disable beszel-agent-update.timer >/dev/null 2>&1 || warn "Cleanup command failed: systemctl disable beszel-agent-update.timer"
|
||||||
rm -f /etc/systemd/system/beszel-agent-update.service
|
rm -f /etc/systemd/system/beszel-agent-update.service
|
||||||
rm -f /etc/systemd/system/beszel-agent-update.timer
|
rm -f /etc/systemd/system/beszel-agent-update.timer
|
||||||
|
|
||||||
@@ -545,13 +670,15 @@ if [ "$UNINSTALL" = true ]; then
|
|||||||
rm -rf "$AGENT_DIR"
|
rm -rf "$AGENT_DIR"
|
||||||
|
|
||||||
echo "Removing the dedicated user for the agent service..."
|
echo "Removing the dedicated user for the agent service..."
|
||||||
killall beszel-agent 2>/dev/null
|
killall beszel-agent 2>/dev/null || true # Usually already stopped by the service manager.
|
||||||
if is_alpine || is_openwrt; then
|
if id -u beszel >/dev/null 2>&1; then
|
||||||
deluser beszel 2>/dev/null
|
if is_alpine || is_openwrt; then
|
||||||
elif is_freebsd; then
|
deluser beszel || fail "Could not remove the beszel user."
|
||||||
pw user del beszel 2>/dev/null
|
elif is_freebsd; then
|
||||||
else
|
pw user del beszel || fail "Could not remove the beszel user."
|
||||||
userdel beszel 2>/dev/null
|
else
|
||||||
|
userdel beszel || fail "Could not remove the beszel user."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Beszel Agent has been uninstalled successfully!"
|
echo "Beszel Agent has been uninstalled successfully!"
|
||||||
@@ -563,6 +690,7 @@ package_installed() {
|
|||||||
command -v "$1" >/dev/null 2>&1
|
command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
INSTALL_STEP="installing required packages"
|
||||||
# Check for package manager and install necessary packages if not installed
|
# Check for package manager and install necessary packages if not installed
|
||||||
if package_installed apk; then
|
if package_installed apk; then
|
||||||
if ! package_installed tar || ! package_installed curl || ! package_installed sha256sum; then
|
if ! package_installed tar || ! package_installed curl || ! package_installed sha256sum; then
|
||||||
@@ -596,13 +724,18 @@ else
|
|||||||
echo "Warning: Please ensure 'tar' and 'curl' and 'sha256sum (coreutils)' are installed."
|
echo "Warning: Please ensure 'tar' and 'curl' and 'sha256sum (coreutils)' are installed."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# If no SSH key is provided, ask for the SSH key interactively (skip if upgrading)
|
for required_command in tar curl; do
|
||||||
|
command -v "$required_command" >/dev/null || fail "Required command is missing: $required_command"
|
||||||
|
done
|
||||||
|
|
||||||
|
# If no SSH key is provided, prompt unless service setup will reuse configuration.
|
||||||
if [ -z "$KEY" ]; then
|
if [ -z "$KEY" ]; then
|
||||||
if [ -f "$BIN_PATH" ]; then
|
if agent_configuration_exists; then
|
||||||
echo "Upgrading existing installation. Using existing service configuration."
|
echo "Using existing service configuration."
|
||||||
else
|
else
|
||||||
printf "Enter your SSH key: "
|
printf "Enter your SSH key: "
|
||||||
read KEY
|
read -r KEY || fail "No SSH key received. Supply -k for noninteractive installation."
|
||||||
|
[ -n "$KEY" ] || fail "SSH key must not be empty."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -623,6 +756,7 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
INSTALL_STEP="configuring the service user"
|
||||||
# Create a dedicated user for the service if it doesn't exist
|
# Create a dedicated user for the service if it doesn't exist
|
||||||
AGENT_USER="beszel"
|
AGENT_USER="beszel"
|
||||||
echo "Configuring the dedicated user for the Beszel Agent service..."
|
echo "Configuring the dedicated user for the Beszel Agent service..."
|
||||||
@@ -696,6 +830,7 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
INSTALL_STEP="creating installation directories"
|
||||||
# Create the directory for the Beszel Agent
|
# Create the directory for the Beszel Agent
|
||||||
|
|
||||||
if [ ! -d "$AGENT_DIR" ]; then
|
if [ ! -d "$AGENT_DIR" ]; then
|
||||||
@@ -709,6 +844,7 @@ if [ ! -d "$BIN_DIR" ]; then
|
|||||||
mkdir -p "$BIN_DIR"
|
mkdir -p "$BIN_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
INSTALL_STEP="downloading and verifying the agent"
|
||||||
# Download and install the Beszel Agent
|
# Download and install the Beszel Agent
|
||||||
|
|
||||||
OS=$(uname -s | sed -e 'y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/')
|
OS=$(uname -s | sed -e 'y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/')
|
||||||
@@ -720,11 +856,12 @@ fi
|
|||||||
|
|
||||||
# Determine version to install
|
# Determine version to install
|
||||||
if [ "$VERSION" = "latest" ]; then
|
if [ "$VERSION" = "latest" ]; then
|
||||||
INSTALL_VERSION=$(curl -s "https://get.beszel.dev/latest-version")
|
INSTALL_VERSION=$(curl -fsS --connect-timeout 10 --max-time 30 "https://get.beszel.dev/latest-version") || INSTALL_VERSION=""
|
||||||
if [ -z "$INSTALL_VERSION" ]; then
|
if [ -z "$INSTALL_VERSION" ]; then
|
||||||
# Fallback to GitHub API
|
# Fallback to GitHub API
|
||||||
API_RELEASE_URL="https://api.github.com/repos/henrygd/beszel/releases/latest"
|
API_RELEASE_URL="https://api.github.com/repos/henrygd/beszel/releases/latest"
|
||||||
INSTALL_VERSION=$(curl -s "$API_RELEASE_URL" | grep -o '"tag_name": "v[^"]*"' | cut -d'"' -f4 | tr -d 'v')
|
RELEASE_JSON=$(curl -fsS --connect-timeout 10 --max-time 30 "$API_RELEASE_URL") || fail "Could not fetch the latest release from GitHub."
|
||||||
|
INSTALL_VERSION=$(printf '%s\n' "$RELEASE_JSON" | grep -o '"tag_name": "v[^"]*"' | cut -d'"' -f4 | tr -d 'v')
|
||||||
fi
|
fi
|
||||||
if [ -z "$INSTALL_VERSION" ]; then
|
if [ -z "$INSTALL_VERSION" ]; then
|
||||||
echo "Failed to get latest version"
|
echo "Failed to get latest version"
|
||||||
@@ -741,7 +878,8 @@ echo "Downloading beszel-agent v${INSTALL_VERSION}..."
|
|||||||
# Download checksums file
|
# Download checksums file
|
||||||
TEMP_DIR=$(mktemp -d)
|
TEMP_DIR=$(mktemp -d)
|
||||||
cd "$TEMP_DIR" || exit 1
|
cd "$TEMP_DIR" || exit 1
|
||||||
CHECKSUM=$(curl -fsSL "$GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/beszel_${INSTALL_VERSION}_checksums.txt" | grep "$FILE_NAME" | cut -d' ' -f1)
|
curl -fsSL --connect-timeout 10 --max-time 60 "$GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/beszel_${INSTALL_VERSION}_checksums.txt" -o checksums.txt || fail "Could not download checksums. Try --mirror if GitHub is unreachable."
|
||||||
|
CHECKSUM=$(awk -v name="$FILE_NAME" '$2 == name { print $1 }' checksums.txt)
|
||||||
if [ -z "$CHECKSUM" ] || ! echo "$CHECKSUM" | grep -qE "^[a-fA-F0-9]{64}$"; then
|
if [ -z "$CHECKSUM" ] || ! echo "$CHECKSUM" | grep -qE "^[a-fA-F0-9]{64}$"; then
|
||||||
echo "Failed to get checksum or invalid checksum format"
|
echo "Failed to get checksum or invalid checksum format"
|
||||||
echo "Try again with --mirror (or --mirror <url>) if GitHub is not reachable."
|
echo "Try again with --mirror (or --mirror <url>) if GitHub is not reachable."
|
||||||
@@ -763,10 +901,10 @@ if ! tar -tzf "$FILE_NAME" >/dev/null 2>&1; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$($CHECK_CMD "$FILE_NAME" | cut -d' ' -f1)" != "$CHECKSUM" ]; then
|
ACTUAL_CHECKSUM=$($CHECK_CMD "$FILE_NAME") || fail "Could not calculate archive checksum."
|
||||||
echo "Checksum verification failed: $($CHECK_CMD "$FILE_NAME" | cut -d' ' -f1) & $CHECKSUM"
|
ACTUAL_CHECKSUM=${ACTUAL_CHECKSUM%% *}
|
||||||
rm -rf "$TEMP_DIR"
|
if [ "$ACTUAL_CHECKSUM" != "$CHECKSUM" ]; then
|
||||||
exit 1
|
fail "Checksum verification failed: $ACTUAL_CHECKSUM != $CHECKSUM"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! tar -xzf "$FILE_NAME" beszel-agent; then
|
if ! tar -xzf "$FILE_NAME" beszel-agent; then
|
||||||
@@ -781,20 +919,34 @@ if [ ! -s "$TEMP_DIR/beszel-agent" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
INSTALL_STEP="replacing the agent binary"
|
||||||
|
# Stage on the destination filesystem so replacement and rollback use atomic renames.
|
||||||
|
STAGED_BINARY=$(mktemp "$BIN_PATH.XXXXXX") || fail "Could not create a staged binary."
|
||||||
|
cp beszel-agent "$STAGED_BINARY" || fail "Could not stage the agent binary."
|
||||||
|
chown "${AGENT_USER}:${AGENT_USER}" "$STAGED_BINARY" || fail "Could not set binary ownership."
|
||||||
|
chmod 755 "$STAGED_BINARY" || fail "Could not set binary permissions."
|
||||||
|
|
||||||
if [ -f "$BIN_PATH" ]; then
|
if [ -f "$BIN_PATH" ]; then
|
||||||
echo "Backing up existing binary..."
|
echo "Backing up existing binary..."
|
||||||
cp "$BIN_PATH" "$BIN_PATH.bak"
|
cp -p "$BIN_PATH" "$BIN_PATH.bak" || fail "Could not back up the existing binary."
|
||||||
|
UPGRADE_PENDING=true
|
||||||
|
if agent_service_registered; then
|
||||||
|
agent_service stop || fail "Could not stop the existing agent."
|
||||||
|
else
|
||||||
|
service_check_status=$?
|
||||||
|
[ "$service_check_status" -eq 1 ] || fail "Could not determine whether the existing agent service is registered."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mv beszel-agent "$BIN_PATH"
|
mv -f "$STAGED_BINARY" "$BIN_PATH" || fail "Could not install the agent binary."
|
||||||
chown "${AGENT_USER}:${AGENT_USER}" "$BIN_PATH"
|
STAGED_BINARY=""
|
||||||
chmod 755 "$BIN_PATH"
|
|
||||||
|
|
||||||
# Set SELinux context if needed
|
# Set SELinux context if needed
|
||||||
set_selinux_context
|
set_selinux_context
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
rm -rf "$TEMP_DIR"
|
rm -rf "$TEMP_DIR"
|
||||||
|
TEMP_DIR=""
|
||||||
|
|
||||||
# Make sure /etc/machine-id exists and is non-empty for persistent fingerprint
|
# Make sure /etc/machine-id exists and is non-empty for persistent fingerprint
|
||||||
if [ ! -s /etc/machine-id ]; then
|
if [ ! -s /etc/machine-id ]; then
|
||||||
@@ -819,6 +971,7 @@ detect_nvidia_devices() {
|
|||||||
echo "$devices"
|
echo "$devices"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
INSTALL_STEP="configuring and starting the service"
|
||||||
# Modify service installation part, add Alpine check before systemd service creation
|
# Modify service installation part, add Alpine check before systemd service creation
|
||||||
if is_alpine; then
|
if is_alpine; then
|
||||||
if [ ! -f /etc/init.d/beszel-agent ]; then
|
if [ ! -f /etc/init.d/beszel-agent ]; then
|
||||||
@@ -868,7 +1021,7 @@ EOF
|
|||||||
chown "${AGENT_USER}:${AGENT_USER}" /var/log/beszel-agent.log /var/log/beszel-agent.err
|
chown "${AGENT_USER}:${AGENT_USER}" /var/log/beszel-agent.log /var/log/beszel-agent.err
|
||||||
|
|
||||||
# Start the service
|
# Start the service
|
||||||
rc-service beszel-agent restart
|
rc-service beszel-agent restart || fail "Could not start the agent; check service logs."
|
||||||
|
|
||||||
# Check if service started successfully
|
# Check if service started successfully
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -884,8 +1037,7 @@ EOF
|
|||||||
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
|
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
|
||||||
AUTO_UPDATE="n"
|
AUTO_UPDATE="n"
|
||||||
else
|
else
|
||||||
printf "\nEnable automatic daily updates for beszel-agent? (y/n): "
|
prompt_auto_update
|
||||||
read AUTO_UPDATE
|
|
||||||
fi
|
fi
|
||||||
case "$AUTO_UPDATE" in
|
case "$AUTO_UPDATE" in
|
||||||
[Yy]*)
|
[Yy]*)
|
||||||
@@ -893,7 +1045,7 @@ EOF
|
|||||||
|
|
||||||
# Create cron job to run beszel-agent update command daily at midnight
|
# Create cron job to run beszel-agent update command daily at midnight
|
||||||
if ! crontab -u root -l 2>/dev/null | grep -q "beszel-agent.*update"; then
|
if ! crontab -u root -l 2>/dev/null | grep -q "beszel-agent.*update"; then
|
||||||
(crontab -u root -l 2>/dev/null; echo "12 0 * * * $BIN_PATH update >/dev/null 2>&1") | crontab -u root -
|
(read_root_crontab; echo "12 0 * * * $BIN_PATH update >/dev/null 2>&1") | crontab -u root -
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf "\nDaily updates have been enabled via cron job.\n"
|
printf "\nDaily updates have been enabled via cron job.\n"
|
||||||
@@ -963,7 +1115,7 @@ EOF
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Start the service
|
# Start the service
|
||||||
/etc/init.d/beszel-agent restart
|
/etc/init.d/beszel-agent restart || fail "Could not start the agent; check service logs."
|
||||||
|
|
||||||
# Auto-update service for OpenWRT using a crontab job
|
# Auto-update service for OpenWRT using a crontab job
|
||||||
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
|
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
|
||||||
@@ -973,15 +1125,14 @@ EOF
|
|||||||
AUTO_UPDATE="n"
|
AUTO_UPDATE="n"
|
||||||
sleep 1 # give time for the service to start
|
sleep 1 # give time for the service to start
|
||||||
else
|
else
|
||||||
printf "\nEnable automatic daily updates for beszel-agent? (y/n): "
|
prompt_auto_update
|
||||||
read AUTO_UPDATE
|
|
||||||
fi
|
fi
|
||||||
case "$AUTO_UPDATE" in
|
case "$AUTO_UPDATE" in
|
||||||
[Yy]*)
|
[Yy]*)
|
||||||
echo "Setting up daily automatic updates for beszel-agent..."
|
echo "Setting up daily automatic updates for beszel-agent..."
|
||||||
|
|
||||||
if ! crontab -u root -l 2>/dev/null | grep -q "beszel-agent.*update"; then
|
if ! crontab -u root -l 2>/dev/null | grep -q "beszel-agent.*update"; then
|
||||||
(crontab -u root -l 2>/dev/null; echo "12 0 * * * /etc/init.d/beszel-agent update") | crontab -u root -
|
(read_root_crontab; echo "12 0 * * * /etc/init.d/beszel-agent update") | crontab -u root -
|
||||||
fi
|
fi
|
||||||
|
|
||||||
/etc/init.d/cron restart
|
/etc/init.d/cron restart
|
||||||
@@ -1072,7 +1223,7 @@ EOF
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
service beszel-agent restart
|
service beszel-agent restart || fail "Could not start the agent; check service logs."
|
||||||
|
|
||||||
# Check if service started successfully
|
# Check if service started successfully
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -1088,8 +1239,7 @@ EOF
|
|||||||
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
|
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
|
||||||
AUTO_UPDATE="n"
|
AUTO_UPDATE="n"
|
||||||
else
|
else
|
||||||
printf "\nEnable automatic daily updates for beszel-agent? (y/n): "
|
prompt_auto_update
|
||||||
read AUTO_UPDATE
|
|
||||||
fi
|
fi
|
||||||
case "$AUTO_UPDATE" in
|
case "$AUTO_UPDATE" in
|
||||||
[Yy]*)
|
[Yy]*)
|
||||||
@@ -1170,7 +1320,7 @@ EOF
|
|||||||
printf "\nLoading and starting the agent service...\n"
|
printf "\nLoading and starting the agent service...\n"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable beszel-agent.service >/dev/null 2>&1
|
systemctl enable beszel-agent.service >/dev/null 2>&1
|
||||||
systemctl restart beszel-agent.service
|
systemctl restart beszel-agent.service || fail "Could not start the agent; check service logs."
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1182,8 +1332,7 @@ EOF
|
|||||||
AUTO_UPDATE="n"
|
AUTO_UPDATE="n"
|
||||||
sleep 1 # give time for the service to start
|
sleep 1 # give time for the service to start
|
||||||
else
|
else
|
||||||
printf "\nEnable automatic daily updates for beszel-agent? (y/n): "
|
prompt_auto_update
|
||||||
read AUTO_UPDATE
|
|
||||||
fi
|
fi
|
||||||
case "$AUTO_UPDATE" in
|
case "$AUTO_UPDATE" in
|
||||||
[Yy]*)
|
[Yy]*)
|
||||||
@@ -1229,6 +1378,7 @@ EOF
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
UPGRADE_PENDING=false
|
||||||
RUNNING_ADDRESS=$(configured_address)
|
RUNNING_ADDRESS=$(configured_address)
|
||||||
[ -n "$RUNNING_ADDRESS" ] || RUNNING_ADDRESS=$PORT
|
[ -n "$RUNNING_ADDRESS" ] || RUNNING_ADDRESS=$PORT
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user