Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7be0b4f
Add agent portion of changes
JSmith-Aura Feb 13, 2025
e11027b
Add clarifying comment
JSmith-Aura Feb 13, 2025
fb3709a
Print out public key
JSmith-Aura Feb 13, 2025
5bf8234
Add default connection port for clients
JSmith-Aura Feb 13, 2025
9f9926f
Start starting work on server side, update link random delay to use max
JSmith-Aura Feb 13, 2025
2491619
Add very basic new client connection registration (without auth do no…
JSmith-Aura Feb 14, 2025
4c3412d
Add migration
JSmith-Aura Feb 14, 2025
e43082e
Add startings of ui, add a bunch of new migrations that'll need to be…
JSmith-Aura Feb 14, 2025
258f74e
Fix non-displaying feels
JSmith-Aura Feb 14, 2025
f6f0d0d
Add full UI for accepting/denying clients
NHAS Feb 15, 2025
72affbe
Stop blocked systems from showing up in new systems table
NHAS Feb 15, 2025
94145f6
Fix my faulty logic
NHAS Feb 15, 2025
ff17761
Fix text for direct connection
NHAS Feb 15, 2025
d12f9af
Add connection settings UI
NHAS Feb 15, 2025
b15a791
Strip out footguns, do not allow without api key
NHAS Feb 15, 2025
0e649f6
Add external address to ui, add api key generation
NHAS Feb 15, 2025
d663fad
Add migrations
NHAS Feb 15, 2025
807fc60
Clarify wording
NHAS Feb 15, 2025
d35d235
Fix oversight in missing public ke
NHAS Feb 15, 2025
55a81cc
Add click to copy for connection key (also rename apikey to connectio…
NHAS Feb 15, 2025
6c202cc
Add 6 million migrations
NHAS Feb 15, 2025
738df66
Add code for collecting statistics from clietns
NHAS Feb 15, 2025
db91bf2
Update
NHAS Feb 15, 2025
136bbe5
Merge
NHAS Feb 15, 2025
da3c310
Fix silly bug
NHAS Feb 15, 2025
995cc83
Change logging severity when stats channel cannot be opened (often du…
NHAS Feb 15, 2025
2d39006
Tidy up hub sshd code
NHAS Feb 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions beszel/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,13 @@ dev-agent:
go run beszel/cmd/agent; \
fi

dev-agent-connect-back:
@if command -v entr >/dev/null 2>&1; then \
find ./cmd/agent/*.go ./internal/agent/*.go | entr -r go run beszel/cmd/agent client; \
else \
go run beszel/cmd/agent client; \
fi


# KEY="..." make -j dev
dev: dev-server dev-hub dev-agent
52 changes: 43 additions & 9 deletions beszel/cmd/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,19 @@ import (

func main() {
// handle flags / subcommands
isClient := false
if len(os.Args) > 1 {
switch os.Args[1] {
case "-v":
fmt.Println(beszel.AppName+"-agent", beszel.Version)
os.Exit(0)
case "update":
agent.Update()
os.Exit(0)
case "client":
isClient = true
}
os.Exit(0)

}

// Try to get the key from the KEY environment variable.
Expand All @@ -38,15 +43,44 @@ func main() {
}
}

addr := ":45876"
// TODO: change env var to ADDR
if portEnvVar, exists := agent.GetEnv("PORT"); exists {
// allow passing an address in the form of "127.0.0.1:45876"
if !strings.Contains(portEnvVar, ":") {
portEnvVar = ":" + portEnvVar
addr := ":45877"

envAddr := ""
addrEnvVar, specifiedByAddr := agent.GetEnv("ADDR")
// Legacy from when PORT was used
portEnvVar, specifiedByPort := agent.GetEnv("PORT")

if specifiedByAddr {
envAddr = addrEnvVar
} else if specifiedByPort {
envAddr = portEnvVar
}

if specifiedByAddr || specifiedByPort {
if len(envAddr) == 0 && isClient {
log.Fatal("No address specified for client to connect to, ADDR was empty")
}

// allow passing an address in the form of "127.0.0.1:45877"
if !strings.Contains(envAddr, ":") && !isClient {
envAddr = ":" + envAddr
} else if isClient {
// set the default port if non is specified for clients
envAddr = envAddr + ":45877"
}

addr = envAddr

} else if isClient {
log.Fatal("No address specified for client to connect to (use ADDR env)")
}

if isClient {
_, exists := agent.GetEnv("CONNECTION_KEY")
if !exists {
log.Fatal("Started in client mode without CONNECTION_KEY specified")
}
addr = portEnvVar
}

agent.NewAgent().Run(pubKey, addr)
agent.NewAgent(isClient).Run(pubKey, addr)
}
10 changes: 8 additions & 2 deletions beszel/internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ type Agent struct {
sensorsWhitelist map[string]struct{} // List of sensors to monitor
systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data
client bool // Connect to server rather than open port
}

func NewAgent() *Agent {
func NewAgent(client bool) *Agent {
newAgent := &Agent{
sensorsContext: context.Background(),
fsStats: make(map[string]*system.FsStats),
client: client,
}
newAgent.memCalc, _ = GetEnv("MEM_CALC")
return newAgent
Expand Down Expand Up @@ -97,7 +99,11 @@ func (a *Agent) Run(pubKey []byte, addr string) {
slog.Debug("Stats", "data", a.gatherStats())
}

a.startServer(pubKey, addr)
if a.client {
a.startClient(pubKey, addr)
} else {
a.startServer(pubKey, addr)
}
}

func (a *Agent) gatherStats() system.CombinedData {
Expand Down
227 changes: 227 additions & 0 deletions beszel/internal/agent/link.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package agent

import (
"beszel"
"beszel/internal/entities/system"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net"
"os"
"time"

// We are using this for random stats collection timing
insecureRandom "math/rand"

sshServer "github.com/gliderlabs/ssh"
"golang.org/x/crypto/ssh"
)

func (a *Agent) writeStats(output io.Writer) (system.CombinedData, error) {
stats := a.gatherStats()
return stats, json.NewEncoder(output).Encode(stats)
}

func (a *Agent) startServer(pubKey []byte, addr string) {

sshServer.Handle(func(s sshServer.Session) {
if stats, err := a.writeStats(s); err != nil {
slog.Error("Error encoding stats", "err", err, "stats", stats)
s.Exit(1)
return
}

s.Exit(0)
})

slog.Info("Starting SSH server", "address", addr)
if err := sshServer.ListenAndServe(addr, nil, sshServer.NoPty(),
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
allowed, _, _, _, _ := sshServer.ParseAuthorizedKey(pubKey)
return sshServer.KeysEqual(key, allowed)
}),
); err != nil {
slog.Error("Error starting SSH server", "err", err)
os.Exit(1)
}
}

func (a *Agent) startClient(pubKey []byte, addr string) {

signer, err := createOrLoadKey("id_ed25519")
if err != nil {
slog.Error("Failed to load private key: ", "err", err)
os.Exit(1)
}

slog.Info("Public Key: ", "fingerprint", ssh.FingerprintSHA256(signer.PublicKey()))

allowed, _, _, _, err := sshServer.ParseAuthorizedKey(pubKey)
if err != nil {
slog.Error("Failed to parse server public key: ", "err", err)
os.Exit(1)
}

hostname, err := os.Hostname()
if err != nil {
slog.Warn("failed to get host hostname", "err", err)
hostname = "unknown_hostname"
}

apiKey, _ := GetEnv("CONNECTION_KEY")

c := &ssh.ClientConfig{
User: fmt.Sprintf("%s@%s", hostname, apiKey),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.FixedHostKey(allowed),
ClientVersion: "SSH-2.0-" + beszel.AppName + "-" + beszel.Version,
Timeout: 10 * time.Second,
}

lastBackoff := 1
backoff := func() {
currentBackoff := 2 * time.Second * time.Duration(lastBackoff)
slog.Info("Waiting to reconnect: ", "backoff", currentBackoff)
time.Sleep(currentBackoff)
if lastBackoff < 10 {
lastBackoff++
}
}

var currentConn ssh.Conn

for {
if currentConn != nil {
currentConn.Close()
}
slog.Info("Connecting to beszel SSH server", "address", addr)

conn, err := net.DialTimeout("tcp", addr, c.Timeout)
if err != nil {
slog.Error("Failed to connect to server: ", "addr", addr, "err", err)
backoff()
continue
}

clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, c)
if err != nil {
conn.Close()
slog.Error("Failed to join to server: ", "addr", addr, "err", err)
backoff()
continue
}
currentConn = clientConn

// Reset backoff on first solid connection
lastBackoff = 1

go ssh.DiscardRequests(reqs)
go a.discardChannels(chans)

Inner:
for {

dataChan, req, err := clientConn.OpenChannel("stats", nil)
if err != nil {
slog.Debug("failed to send statistics, server did not allow opening of stats channel", "err", err)
a.randomSleep(15, 30)
break Inner
}

go ssh.DiscardRequests(req)

if stats, err := a.writeStats(dataChan); err != nil {
slog.Error("Error writing stats", "err", err, "stats", stats)
a.exit(dataChan, 1)
break Inner
}

a.exit(dataChan, 0)

// Make sure that all clients arent syncing up and sending massive amounts of data at once
a.randomSleep(15, 30)
}

}

}

func (a *Agent) exit(channel ssh.Channel, code int) error {
status := struct{ Status uint32 }{uint32(code)}
_, err := channel.SendRequest("exit-status", false, ssh.Marshal(&status))
if err != nil {
return err
}
return channel.Close()
}

func (a *Agent) discardChannels(chans <-chan ssh.NewChannel) {
for c := range chans {
c.Reject(ssh.Prohibited, "Clients do not support connections")
}
}

func (a *Agent) randomSleep(min, max int) {
variance := insecureRandom.Intn(max)
time.Sleep(time.Duration(variance)*time.Second + time.Duration(min))
}

// https://github.com/NHAS/reverse_ssh/blob/main/internal/server/server.go
func createOrLoadKey(privateKeyPath string) (ssh.Signer, error) {

//If we have already created a private key (or there is one in the current directory) dont overwrite/create another one
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {

privateKeyPem, err := generatePrivateKey()
if err != nil {
return nil, fmt.Errorf("unable to generate private key, and no private key specified: %s", err)
}

err = os.WriteFile(privateKeyPath, privateKeyPem, 0600)
if err != nil {
return nil, fmt.Errorf("unable to write private key to disk: %s", err)
}
}

privateBytes, err := os.ReadFile(privateKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to load private key (%s): %s", privateKeyPath, err)
}

private, err := ssh.ParsePrivateKey(privateBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %s", err)
}

return private, nil
}

// https://github.com/NHAS/reverse_ssh/blob/71420af670aebbe632f35ce8428cbfbd21dc5f53/internal/global.go#L44
func generatePrivateKey() ([]byte, error) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}

// Convert a generated ed25519 key into a PEM block so that the ssh library can ingest it, bit round about tbh
bytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}

privatePem := pem.EncodeToMemory(
&pem.Block{
Type: "PRIVATE KEY",
Bytes: bytes,
},
)

return privatePem, nil
}
34 changes: 0 additions & 34 deletions beszel/internal/agent/server.go

This file was deleted.

1 change: 1 addition & 0 deletions beszel/internal/hub/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func (h *Hub) syncSystemsWithConfig() error {
existingSystem.Set("name", sysConfig.Name)
existingSystem.Set("users", sysConfig.Users)
existingSystem.Set("port", sysConfig.Port)

if err := h.Save(existingSystem); err != nil {
return err
}
Expand Down
Loading