Files
UNITRONIX 1cd9209512 Fix MeshAgent interop: bypass JWT on mesh paths and persist device_type on upsert.
Mesh .ashx endpoints use protocol-level auth, not API JWT; exclude them from authMiddleware so agents can complete the WebSocket handshake. Persist device_type, linked_peer_id, and display_name in SQLite UpsertPeer for mesh_agent inventory. Add meshagent_dev_remote tests for local Windows dev interop validation.
2026-07-03 22:24:58 +02:00

438 lines
12 KiB
Go

//go:build meshagent_dev_remote
package meshcentral
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
"github.com/coder/websocket"
)
type devRemoteInteropEnv struct {
WSURL string
APIBase string
User string
Pass string
}
func devRemoteInteropEnvFromOS() devRemoteInteropEnv {
wsURL := strings.TrimSpace(os.Getenv("MESH_INTEROP_WS"))
if wsURL == "" {
wsURL = "ws://127.0.0.1:21114/agent.ashx"
}
apiBase := strings.TrimSpace(os.Getenv("MESH_INTEROP_API"))
if apiBase == "" {
apiBase = "http://127.0.0.1:21114/api"
}
user := strings.TrimSpace(os.Getenv("MESH_INTEROP_ADMIN_USER"))
if user == "" {
user = "admin"
}
pass := os.Getenv("MESH_INTEROP_ADMIN_PASS")
if pass == "" {
pass = "MeshInteropTest123!"
}
return devRemoteInteropEnv{WSURL: wsURL, APIBase: apiBase, User: user, Pass: pass}
}
type devRemoteAgentSession struct {
conn *websocket.Conn
cancel context.CancelFunc
readCmd func(...uint16) []byte
nodeID string
}
func devRemoteConnectAgent(t *testing.T, env devRemoteInteropEnv) (*devRemoteAgentSession, func()) {
t.Helper()
agentKey, agentCert, nodeID := generateAgentKeyAndCert(t)
ctx, cancel := context.WithCancel(context.Background())
conn, _, err := websocket.Dial(ctx, env.WSURL, nil)
if err != nil {
cancel()
t.Fatal(err)
}
inbound := make(chan []byte, 16)
readDone := make(chan struct{})
go func() {
defer close(readDone)
for {
_, data, err := conn.Read(ctx)
if err != nil {
return
}
select {
case inbound <- data:
default:
}
}
}()
readCmd := func(want ...uint16) []byte {
wantMap := make(map[uint16]bool)
for _, c := range want {
wantMap[c] = true
}
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
select {
case data := <-inbound:
if len(data) >= 2 && wantMap[readU16(data, 0)] {
return data
}
case <-time.After(200 * time.Millisecond):
}
}
t.Fatal("timeout waiting for mesh cmd")
return nil
}
webHash := make([]byte, sha384Size)
agentNonce := random48()
authReq := make([]byte, 98)
putU16(authReq, 0, cmdAuthRequest)
copy(authReq[2:50], webHash)
copy(authReq[50:98], agentNonce)
if err := conn.Write(ctx, websocket.MessageBinary, authReq); err != nil {
cancel()
conn.Close(websocket.StatusInternalError, "")
<-readDone
t.Fatal(err)
}
resp1 := readCmd(cmdAuthRequest)
serverNonce := append([]byte(nil), resp1[50:98]...)
_ = readCmd(cmdAuthVerify)
sig := signAgent(agentKey, webHash, serverNonce, agentNonce)
agentVerify := make([]byte, 4+len(agentCert)+len(sig))
putU16(agentVerify, 0, cmdAuthVerify)
putU16(agentVerify, 2, uint16(len(agentCert)))
copy(agentVerify[4:], agentCert)
copy(agentVerify[4+len(agentCert):], sig)
if err := conn.Write(ctx, websocket.MessageBinary, agentVerify); err != nil {
cancel()
conn.Close(websocket.StatusInternalError, "")
<-readDone
t.Fatal(err)
}
meshRaw := make([]byte, 48)
copy(meshRaw, []byte("BetterDeskInteropDevRemoteAgent!!"))
authInfo := buildAuthInfoPacket(meshRaw, "interop-win-dev")
if err := conn.Write(ctx, websocket.MessageBinary, authInfo); err != nil {
cancel()
conn.Close(websocket.StatusInternalError, "")
<-readDone
t.Fatal(err)
}
_ = readCmd(cmdAuthConfirm)
_ = readCmd(cmdCoreModuleHash)
coreHashPkt := make([]byte, 4+sha384Size)
putU16(coreHashPkt, 0, cmdCoreModuleHash)
copy(coreHashPkt[4:], make([]byte, sha384Size))
if err := conn.Write(ctx, websocket.MessageBinary, coreHashPkt); err != nil {
cancel()
conn.Close(websocket.StatusInternalError, "")
<-readDone
t.Fatal(err)
}
_ = readCmd(cmdCoreOk, cmdCoreModule)
cleanup := func() {
cancel()
conn.Close(websocket.StatusNormalClosure, "")
<-readDone
}
return &devRemoteAgentSession{conn: conn, cancel: cancel, readCmd: readCmd, nodeID: nodeID}, cleanup
}
func devRemoteWaitForPeer(t *testing.T, apiBase, token string, timeout time.Duration) (string, devRemoteMeshStatusResp) {
t.Helper()
deadline := time.Now().Add(timeout)
var peerID string
var status devRemoteMeshStatusResp
for time.Now().Before(deadline) {
var err error
status, err = devRemoteMeshStatus(apiBase, token)
if err != nil {
t.Fatal(err)
}
peers, err := devRemoteListPeers(apiBase, token)
if err != nil {
t.Fatal(err)
}
var candidates []devRemotePeer
for _, p := range peers {
if isMeshAgentPeer(p) {
candidates = append(candidates, p)
}
}
for _, p := range candidates {
if p.MeshConnected {
peerID = p.ID
break
}
}
if peerID == "" && len(candidates) == 1 && status.AgentsOnline >= 1 {
peerID = candidates[0].ID
}
if peerID != "" && status.AgentsOnline >= 1 {
for _, p := range candidates {
if p.ID == peerID {
t.Logf("found mesh peer=%s agents_online=%d mesh_connected=%v os=%s", peerID, status.AgentsOnline, p.MeshConnected, p.OS)
break
}
}
return peerID, status
}
time.Sleep(200 * time.Millisecond)
}
t.Fatal("mesh agent did not appear in /api/peers within timeout")
return "", status
}
func devRemoteRunIntegrationChecks(t *testing.T, apiBase, token, peerID string) map[string]string {
t.Helper()
results := map[string]string{}
status, _ := devRemoteMeshStatus(apiBase, token)
if status.AgentsOnline < 1 {
results["note"] = "agent session not active"
return results
}
if err := devRemotePost(apiBase, token, "/peers/"+peerID+"/exec", map[string]any{"command": "echo mesh-interop-ok", "shell": false}); err != nil {
results["exec"] = "FAIL: " + err.Error()
t.Fatalf("exec: %v", err)
}
results["exec"] = "PASS"
for _, tc := range []struct {
key string
path string
}{
{"desktop_tunnel", "/mesh/devices/" + peerID + "/desktop"},
{"terminal_tunnel", "/mesh/devices/" + peerID + "/terminal"},
{"files_tunnel", "/mesh/devices/" + peerID + "/files"},
} {
body, err := devRemotePostRaw(apiBase, token, tc.path, map[string]any{})
if err != nil {
results[tc.key] = "FAIL: " + err.Error()
t.Fatalf("%s: %v", tc.path, err)
}
if !strings.Contains(string(body), "relay_id") {
results[tc.key] = "FAIL: missing relay_id"
t.Fatalf("%s: missing relay_id in %s", tc.path, string(body))
}
results[tc.key] = "PASS"
}
return results
}
func devRemoteWriteState(path string, payload map[string]any) error {
if path == "" {
return nil
}
data, err := json.Marshal(payload)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
// TestDevRemoteAgentHandshake connects a protocol-compatible agent client to a
// running BetterDesk server (local dev interop) and verifies inventory registration.
func TestDevRemoteAgentHandshake(t *testing.T) {
env := devRemoteInteropEnvFromOS()
session, cleanup := devRemoteConnectAgent(t, env)
defer cleanup()
token, err := devRemoteLogin(env.APIBase, env.User, env.Pass)
if err != nil {
t.Fatal(err)
}
peerID, _ := devRemoteWaitForPeer(t, env.APIBase, token, 30*time.Second)
t.Logf("registered peer %s nodeID=%s", peerID, session.nodeID)
statePath := strings.TrimSpace(os.Getenv("MESH_INTEROP_STATE_FILE"))
if statePath != "" {
_ = devRemoteWriteState(statePath, map[string]any{
"peer_id": peerID,
"node_id": session.nodeID,
"tests": devRemoteRunIntegrationChecks(t, env.APIBase, token, peerID),
})
} else {
devRemoteRunIntegrationChecks(t, env.APIBase, token, peerID)
}
time.Sleep(2 * time.Second)
}
// TestDevRemoteAgentKeepAlive maintains an agent websocket for verify-api.ps1.
func TestDevRemoteAgentKeepAlive(t *testing.T) {
env := devRemoteInteropEnvFromOS()
session, cleanup := devRemoteConnectAgent(t, env)
defer cleanup()
token, err := devRemoteLogin(env.APIBase, env.User, env.Pass)
if err != nil {
t.Fatal(err)
}
start := time.Now()
peerID, status := devRemoteWaitForPeer(t, env.APIBase, token, 30*time.Second)
registerSec := time.Since(start).Seconds()
t.Logf("keepalive peer %s agents_online=%d (%.2fs)", peerID, status.AgentsOnline, registerSec)
statePath := strings.TrimSpace(os.Getenv("MESH_INTEROP_STATE_FILE"))
if statePath == "" {
t.Fatal("MESH_INTEROP_STATE_FILE required for keepalive test")
}
_ = devRemoteWriteState(statePath, map[string]any{
"peer_id": peerID,
"node_id": session.nodeID,
"agents_online": status.AgentsOnline,
"register_seconds": registerSec,
"ready": true,
})
keepSec := 120
if v := strings.TrimSpace(os.Getenv("MESH_INTEROP_KEEPALIVE_SEC")); v != "" {
if n, err := fmt.Sscanf(v, "%d", &keepSec); n != 1 || err != nil {
t.Fatalf("invalid MESH_INTEROP_KEEPALIVE_SEC: %q", v)
}
}
deadline := time.Now().Add(time.Duration(keepSec) * time.Second)
for time.Now().Before(deadline) {
if statePath != "" {
if data, err := os.ReadFile(statePath); err == nil {
if strings.Contains(string(data), `"stop":true`) {
t.Log("stop signal received")
return
}
}
}
time.Sleep(500 * time.Millisecond)
}
t.Log("keepalive timeout reached")
}
func putU16(b []byte, off int, v uint16) {
b[off] = byte(v >> 8)
b[off+1] = byte(v)
}
type devRemoteMeshStatusResp struct {
AgentsOnline int `json:"agents_online"`
}
type devRemotePeer struct {
ID string `json:"id"`
DeviceType string `json:"device_type"`
OS string `json:"os"`
MeshConnected bool `json:"mesh_connected"`
LiveOnline bool `json:"live_online"`
MeshNodeID string `json:"mesh_node_id"`
}
func isMeshAgentPeer(p devRemotePeer) bool {
if p.DeviceType == "mesh_agent" || p.OS == "mesh" || p.MeshNodeID != "" {
return true
}
return strings.HasPrefix(p.ID, "M")
}
func devRemoteLogin(apiBase, user, pass string) (string, error) {
body, _ := json.Marshal(map[string]string{"username": user, "password": pass})
resp, err := http.Post(apiBase+"/auth/login", "application/json", bytes.NewReader(body))
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("login status %d: %s", resp.StatusCode, string(data))
}
var out struct {
Token string `json:"token"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", err
}
if out.Token == "" {
return "", fmt.Errorf("empty token")
}
return out.Token, nil
}
func devRemoteMeshStatus(apiBase, token string) (devRemoteMeshStatusResp, error) {
var out devRemoteMeshStatusResp
req, _ := http.NewRequest(http.MethodGet, apiBase+"/mesh/status", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return out, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return out, fmt.Errorf("mesh status %d: %s", resp.StatusCode, string(data))
}
err = json.Unmarshal(data, &out)
return out, err
}
func devRemotePost(apiBase, token, path string, body map[string]any) error {
_, err := devRemotePostRaw(apiBase, token, path, body)
return err
}
func devRemotePostRaw(apiBase, token, path string, body map[string]any) ([]byte, error) {
data, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, apiBase+path, bytes.NewReader(data))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return out, fmt.Errorf("status %d: %s", resp.StatusCode, string(out))
}
return out, nil
}
func devRemoteListPeers(apiBase, token string) ([]devRemotePeer, error) {
req, _ := http.NewRequest(http.MethodGet, apiBase+"/peers", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("peers %d: %s", resp.StatusCode, string(data))
}
var out []devRemotePeer
if err := json.Unmarshal(data, &out); err != nil {
return nil, err
}
return out, nil
}