Add CI build workflow and tighten proxy diagnostics

This commit is contained in:
rcourtman 2025-11-14 13:32:29 +00:00
parent 65fbd72b15
commit 20194d9bb7
7 changed files with 223 additions and 27 deletions

62
.github/workflows/build-and-test.yml vendored Normal file
View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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<SettingsProps> = (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<SettingsProps> = (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<SettingsProps> = (props) => {
</For>
</div>
</Show>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => {
if (!proxyActionLoading()) {
void handleRegisterProxyNodes();
}
}}
disabled={proxyActionLoading() !== null}
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{proxyActionLoading() === 'register-nodes'
? 'Checking nodes...'
: 'Check proxy nodes'}
</button>
</div>
<Show
when={proxyNodeChecksSupported()}
fallback={
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
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.
</div>
}
>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => {
if (!proxyActionLoading()) {
void handleRegisterProxyNodes();
}
}}
disabled={proxyActionLoading() !== null}
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{proxyActionLoading() === 'register-nodes'
? 'Checking nodes...'
: 'Check proxy nodes'}
</button>
</div>
</Show>
<Show
when={
proxyRegisterSummary() && proxyRegisterSummary()!.length > 0

View file

@ -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
}
}

View file

@ -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

View file

@ -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)
}