From 20194d9bb70584b9e40b6c860aca6534b8a8fd91 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 14 Nov 2025 13:32:29 +0000 Subject: [PATCH] Add CI build workflow and tighten proxy diagnostics --- .github/workflows/build-and-test.yml | 62 ++++++++++++++++++ cmd/pulse-sensor-proxy/capabilities.go | 14 ++++ cmd/pulse-sensor-proxy/main.go | 37 ++++++++++- .../src/components/Settings/Settings.tsx | 65 ++++++++++++++----- internal/api/diagnostics.go | 42 ++++++++++++ internal/api/router.go | 13 ++++ internal/tempproxy/client.go | 17 +++-- 7 files changed, 223 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/build-and-test.yml diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..718b4d0 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,62 @@ +name: Build and Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + build: + name: Frontend & Backend + runs-on: ubuntu-latest + env: + FRONTEND_DIR: frontend-modern + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend-modern/package-lock.json + + - name: Install frontend dependencies + working-directory: frontend-modern + run: npm ci + + - name: Lint frontend + working-directory: frontend-modern + run: npm run lint + + - name: Frontend unit tests + working-directory: frontend-modern + run: npm run test -- --runInBand + + - name: Type-check frontend + working-directory: frontend-modern + run: npm run type-check + + - name: Build frontend bundle (with embed copy) + run: make frontend + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Go unit tests + run: go test ./... + + - name: Build Pulse backend + run: go build ./cmd/pulse + + - name: Build pulse-sensor-proxy + run: go build ./cmd/pulse-sensor-proxy diff --git a/cmd/pulse-sensor-proxy/capabilities.go b/cmd/pulse-sensor-proxy/capabilities.go index 0f527f5..23960e9 100644 --- a/cmd/pulse-sensor-proxy/capabilities.go +++ b/cmd/pulse-sensor-proxy/capabilities.go @@ -33,3 +33,17 @@ func parseCapabilityList(values []string) Capability { } return caps } + +func capabilityNames(c Capability) []string { + names := make([]string, 0, 3) + if c.Has(CapabilityRead) { + names = append(names, "read") + } + if c.Has(CapabilityWrite) { + names = append(names, "write") + } + if c.Has(CapabilityAdmin) { + names = append(names, "admin") + } + return names +} diff --git a/cmd/pulse-sensor-proxy/main.go b/cmd/pulse-sensor-proxy/main.go index 368df74..e011e1d 100644 --- a/cmd/pulse-sensor-proxy/main.go +++ b/cmd/pulse-sensor-proxy/main.go @@ -272,6 +272,28 @@ type Proxy struct { idMappedGIDRanges []idRange } +type contextKey int + +const ( + contextKeyPeerCapabilities contextKey = iota + 1 +) + +func withPeerCapabilities(ctx context.Context, caps Capability) context.Context { + return context.WithValue(ctx, contextKeyPeerCapabilities, caps) +} + +func peerCapabilitiesFromContext(ctx context.Context) Capability { + if ctx == nil { + return 0 + } + if value := ctx.Value(contextKeyPeerCapabilities); value != nil { + if caps, ok := value.(Capability); ok { + return caps + } + } + return 0 +} + // RPC request types const ( RPCEnsureClusterKeys = "ensure_cluster_keys" @@ -755,6 +777,9 @@ func (p *Proxy) handleConnection(conn net.Conn) { p.audit.LogCommandStart(req.CorrelationID, cred, remoteAddr, "", req.Method, nil) } + // Annotate context with peer capabilities before executing handler + ctx = withPeerCapabilities(ctx, peerCaps) + // Execute handler result, err := handler(ctx, &req, logger) duration := time.Since(startTime) @@ -1041,11 +1066,19 @@ func (p *Proxy) handleGetStatusV2(ctx context.Context, req *RPCRequest, logger z } logger.Info().Msg("Status request served") - return map[string]interface{}{ + response := map[string]interface{}{ "version": Version, "public_key": string(pubKey), "ssh_dir": p.sshKeyPath, - }, nil + } + + if caps := peerCapabilitiesFromContext(ctx); caps != 0 { + if names := capabilityNames(caps); len(names) > 0 { + response["capabilities"] = names + } + } + + return response, nil } // handleEnsureClusterKeysV2 discovers cluster nodes and pushes SSH keys with validation diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index cbf9f98..7bab889 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -152,6 +152,7 @@ interface TemperatureProxyDiagnostic { proxyPublicKeySha256?: string; proxySshDirectory?: string; legacySshKeyCount?: number; + proxyCapabilities?: string[]; notes?: string[]; httpProxies?: TemperatureProxyHTTPStatus[]; } @@ -855,6 +856,16 @@ const Settings: Component = (props) => { return { httpMap, socketStatus }; }); + const proxyNodeChecksSupported = createMemo(() => { + const caps = diagnosticsData()?.temperatureProxy?.proxyCapabilities; + if (!caps || caps.length === 0) { + return true; + } + return caps.some( + (cap) => typeof cap === 'string' && cap.trim().toLowerCase() === 'admin', + ); + }); + const runDiagnostics = async () => { setRunningDiagnostics(true); try { @@ -880,8 +891,14 @@ const Settings: Component = (props) => { const data = await response.json().catch(() => null); if (!response.ok || !data || data.success !== true) { const message = - data && typeof data.message === 'string' ? data.message : 'Failed to query proxy nodes'; - showError(message); + (data && typeof data.error === 'string' && data.error) || + (data && typeof data.message === 'string' && data.message) || + 'Failed to query proxy nodes'; + if (response.status === 403) { + showWarning(message); + } else { + showError(message); + } return; } @@ -5234,22 +5251,34 @@ const Settings: Component = (props) => { -
- -
+ + Check proxy nodes is only available when the proxy socket + grants admin access. Run this diagnostic on the Proxmox host + or reinstall pulse-sensor-proxy in HTTP mode to manage nodes + remotely. + + } + > +
+ +
+
0 diff --git a/internal/api/diagnostics.go b/internal/api/diagnostics.go index 5308108..a3a26b5 100644 --- a/internal/api/diagnostics.go +++ b/internal/api/diagnostics.go @@ -244,6 +244,7 @@ type TemperatureProxyDiagnostic struct { ProxyPublicKeySHA256 string `json:"proxyPublicKeySha256,omitempty"` ProxySSHDirectory string `json:"proxySshDirectory,omitempty"` LegacySSHKeyCount int `json:"legacySshKeyCount,omitempty"` + ProxyCapabilities []string `json:"proxyCapabilities,omitempty"` Notes []string `json:"notes,omitempty"` HTTPProxies []TemperatureProxyHTTPStatus `json:"httpProxies,omitempty"` } @@ -729,6 +730,14 @@ func buildTemperatureProxyDiagnostic(cfg *config.Config) *TemperatureProxyDiagno appendNote(fmt.Sprintf("Unable to fingerprint proxy public key: %v", err)) } } + if rawCaps, ok := status["capabilities"]; ok { + if caps := interfaceToStringSlice(rawCaps); len(caps) > 0 { + diag.ProxyCapabilities = caps + if !containsFold(caps, "admin") { + appendNote("Proxy socket is running in read-only mode, so 'Check proxy nodes' must be run from the Proxmox host or via an HTTP-mode proxy.") + } + } + } } } else { if diag.SocketFound { @@ -1606,3 +1615,36 @@ func contains(slice []string, str string) bool { } return false } + +func containsFold(slice []string, candidate string) bool { + target := strings.ToLower(strings.TrimSpace(candidate)) + if target == "" { + return false + } + + for _, s := range slice { + if strings.ToLower(strings.TrimSpace(s)) == target { + return true + } + } + return false +} + +func interfaceToStringSlice(value interface{}) []string { + switch v := value.(type) { + case []string: + out := make([]string, len(v)) + copy(out, v) + return out + case []interface{}: + result := make([]string, 0, len(v)) + for _, item := range v { + if str, ok := item.(string); ok { + result = append(result, str) + } + } + return result + default: + return nil + } +} diff --git a/internal/api/router.go b/internal/api/router.go index fb29540..70d8002 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net" @@ -3461,6 +3462,18 @@ func (r *Router) handleDiagnosticsRegisterProxyNodes(w http.ResponseWriter, req nodes, err := client.RegisterNodes() if err != nil { + var proxyErr *tempproxy.ProxyError + if errors.As(err, &proxyErr) { + status := http.StatusBadGateway + code := "proxy_error" + if proxyErr.Type == tempproxy.ErrorTypeAuth { + status = http.StatusForbidden + code = "proxy_permission_denied" + } + writeErrorResponse(w, status, code, proxyErr.Error(), nil) + return + } + log.Error().Err(err).Msg("Failed to request proxy node registration status") writeErrorResponse(w, http.StatusBadGateway, "proxy_error", err.Error(), nil) return diff --git a/internal/tempproxy/client.go b/internal/tempproxy/client.go index 958f289..f4abbbf 100644 --- a/internal/tempproxy/client.go +++ b/internal/tempproxy/client.go @@ -28,12 +28,12 @@ const ( type ErrorType int const ( - ErrorTypeUnknown ErrorType = iota - ErrorTypeTransport // Socket connection/communication failures - ErrorTypeAuth // Authorization failures - ErrorTypeSSH // SSH connectivity issues - ErrorTypeSensor // Sensor command failures - ErrorTypeTimeout // Operation timeout + ErrorTypeUnknown ErrorType = iota + ErrorTypeTransport // Socket connection/communication failures + ErrorTypeAuth // Authorization failures + ErrorTypeSSH // SSH connectivity issues + ErrorTypeSensor // Sensor command failures + ErrorTypeTimeout // Operation timeout ) // ProxyError wraps errors with classification @@ -136,7 +136,7 @@ func classifyError(err error, respError string) *ProxyError { } // Authorization errors - never retry - if respError == "unauthorized" || respError == "method requires host-level privileges" { + if respError == "unauthorized" || respError == "method requires host-level privileges" || respError == "method requires admin capability" || contains(respError, "admin capability") { return &ProxyError{ Type: ErrorTypeAuth, Message: respError, @@ -370,6 +370,9 @@ func (c *Client) RegisterNodes() ([]map[string]interface{}, error) { } if !resp.Success { + if proxyErr := classifyError(nil, resp.Error); proxyErr != nil { + return nil, proxyErr + } return nil, fmt.Errorf("proxy error: %s", resp.Error) }