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
|
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
|
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
|
// RPC request types
|
||||||
const (
|
const (
|
||||||
RPCEnsureClusterKeys = "ensure_cluster_keys"
|
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)
|
p.audit.LogCommandStart(req.CorrelationID, cred, remoteAddr, "", req.Method, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Annotate context with peer capabilities before executing handler
|
||||||
|
ctx = withPeerCapabilities(ctx, peerCaps)
|
||||||
|
|
||||||
// Execute handler
|
// Execute handler
|
||||||
result, err := handler(ctx, &req, logger)
|
result, err := handler(ctx, &req, logger)
|
||||||
duration := time.Since(startTime)
|
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")
|
logger.Info().Msg("Status request served")
|
||||||
return map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"version": Version,
|
"version": Version,
|
||||||
"public_key": string(pubKey),
|
"public_key": string(pubKey),
|
||||||
"ssh_dir": p.sshKeyPath,
|
"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
|
// handleEnsureClusterKeysV2 discovers cluster nodes and pushes SSH keys with validation
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ interface TemperatureProxyDiagnostic {
|
||||||
proxyPublicKeySha256?: string;
|
proxyPublicKeySha256?: string;
|
||||||
proxySshDirectory?: string;
|
proxySshDirectory?: string;
|
||||||
legacySshKeyCount?: number;
|
legacySshKeyCount?: number;
|
||||||
|
proxyCapabilities?: string[];
|
||||||
notes?: string[];
|
notes?: string[];
|
||||||
httpProxies?: TemperatureProxyHTTPStatus[];
|
httpProxies?: TemperatureProxyHTTPStatus[];
|
||||||
}
|
}
|
||||||
|
|
@ -855,6 +856,16 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
return { httpMap, socketStatus };
|
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 () => {
|
const runDiagnostics = async () => {
|
||||||
setRunningDiagnostics(true);
|
setRunningDiagnostics(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -880,8 +891,14 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
const data = await response.json().catch(() => null);
|
const data = await response.json().catch(() => null);
|
||||||
if (!response.ok || !data || data.success !== true) {
|
if (!response.ok || !data || data.success !== true) {
|
||||||
const message =
|
const message =
|
||||||
data && typeof data.message === 'string' ? data.message : 'Failed to query proxy nodes';
|
(data && typeof data.error === 'string' && data.error) ||
|
||||||
showError(message);
|
(data && typeof data.message === 'string' && data.message) ||
|
||||||
|
'Failed to query proxy nodes';
|
||||||
|
if (response.status === 403) {
|
||||||
|
showWarning(message);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5234,22 +5251,34 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<Show
|
||||||
<button
|
when={proxyNodeChecksSupported()}
|
||||||
type="button"
|
fallback={
|
||||||
onClick={() => {
|
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
if (!proxyActionLoading()) {
|
Check proxy nodes is only available when the proxy socket
|
||||||
void handleRegisterProxyNodes();
|
grants admin access. Run this diagnostic on the Proxmox host
|
||||||
}
|
or reinstall pulse-sensor-proxy in HTTP mode to manage nodes
|
||||||
}}
|
remotely.
|
||||||
disabled={proxyActionLoading() !== null}
|
</div>
|
||||||
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'
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
? 'Checking nodes...'
|
<button
|
||||||
: 'Check proxy nodes'}
|
type="button"
|
||||||
</button>
|
onClick={() => {
|
||||||
</div>
|
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
|
<Show
|
||||||
when={
|
when={
|
||||||
proxyRegisterSummary() && proxyRegisterSummary()!.length > 0
|
proxyRegisterSummary() && proxyRegisterSummary()!.length > 0
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,7 @@ type TemperatureProxyDiagnostic struct {
|
||||||
ProxyPublicKeySHA256 string `json:"proxyPublicKeySha256,omitempty"`
|
ProxyPublicKeySHA256 string `json:"proxyPublicKeySha256,omitempty"`
|
||||||
ProxySSHDirectory string `json:"proxySshDirectory,omitempty"`
|
ProxySSHDirectory string `json:"proxySshDirectory,omitempty"`
|
||||||
LegacySSHKeyCount int `json:"legacySshKeyCount,omitempty"`
|
LegacySSHKeyCount int `json:"legacySshKeyCount,omitempty"`
|
||||||
|
ProxyCapabilities []string `json:"proxyCapabilities,omitempty"`
|
||||||
Notes []string `json:"notes,omitempty"`
|
Notes []string `json:"notes,omitempty"`
|
||||||
HTTPProxies []TemperatureProxyHTTPStatus `json:"httpProxies,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))
|
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 {
|
} else {
|
||||||
if diag.SocketFound {
|
if diag.SocketFound {
|
||||||
|
|
@ -1606,3 +1615,36 @@ func contains(slice []string, str string) bool {
|
||||||
}
|
}
|
||||||
return false
|
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/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
|
@ -3461,6 +3462,18 @@ func (r *Router) handleDiagnosticsRegisterProxyNodes(w http.ResponseWriter, req
|
||||||
|
|
||||||
nodes, err := client.RegisterNodes()
|
nodes, err := client.RegisterNodes()
|
||||||
if err != nil {
|
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")
|
log.Error().Err(err).Msg("Failed to request proxy node registration status")
|
||||||
writeErrorResponse(w, http.StatusBadGateway, "proxy_error", err.Error(), nil)
|
writeErrorResponse(w, http.StatusBadGateway, "proxy_error", err.Error(), nil)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,12 @@ const (
|
||||||
type ErrorType int
|
type ErrorType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ErrorTypeUnknown ErrorType = iota
|
ErrorTypeUnknown ErrorType = iota
|
||||||
ErrorTypeTransport // Socket connection/communication failures
|
ErrorTypeTransport // Socket connection/communication failures
|
||||||
ErrorTypeAuth // Authorization failures
|
ErrorTypeAuth // Authorization failures
|
||||||
ErrorTypeSSH // SSH connectivity issues
|
ErrorTypeSSH // SSH connectivity issues
|
||||||
ErrorTypeSensor // Sensor command failures
|
ErrorTypeSensor // Sensor command failures
|
||||||
ErrorTypeTimeout // Operation timeout
|
ErrorTypeTimeout // Operation timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProxyError wraps errors with classification
|
// ProxyError wraps errors with classification
|
||||||
|
|
@ -136,7 +136,7 @@ func classifyError(err error, respError string) *ProxyError {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorization errors - never retry
|
// 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{
|
return &ProxyError{
|
||||||
Type: ErrorTypeAuth,
|
Type: ErrorTypeAuth,
|
||||||
Message: respError,
|
Message: respError,
|
||||||
|
|
@ -370,6 +370,9 @@ func (c *Client) RegisterNodes() ([]map[string]interface{}, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !resp.Success {
|
if !resp.Success {
|
||||||
|
if proxyErr := classifyError(nil, resp.Error); proxyErr != nil {
|
||||||
|
return nil, proxyErr
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("proxy error: %s", resp.Error)
|
return nil, fmt.Errorf("proxy error: %s", resp.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue