Add CI build workflow and tighten proxy diagnostics
This commit is contained in:
parent
65fbd72b15
commit
20194d9bb7
7 changed files with 223 additions and 27 deletions
62
.github/workflows/build-and-test.yml
vendored
Normal file
62
.github/workflows/build-and-test.yml
vendored
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue