mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 23:41:48 +01:00
This implements HTTP/HTTPS support for pulse-sensor-proxy to enable temperature monitoring across multiple separate Proxmox instances. Architecture changes: - Dual-mode operation: Unix socket (local) + HTTPS (remote) - Unix socket remains default for security/performance (no breaking change) - HTTP mode enables temps from external PVE hosts Backend implementation: - Add HTTPS server with TLS + Bearer token authentication to sensor-proxy - Add TemperatureProxyURL and TemperatureProxyToken fields to PVEInstance - Add HTTP client (internal/tempproxy/http_client.go) for remote proxy calls - Update temperature collector to prefer HTTP proxy when configured - Fallback logic: HTTP proxy → Unix socket → direct SSH (if not containerized) Configuration: - pulse-sensor-proxy config: http_enabled, http_listen_addr, http_tls_cert/key, http_auth_token - PVEInstance config: temperature_proxy_url, temperature_proxy_token - Environment variables: PULSE_SENSOR_PROXY_HTTP_* for all HTTP settings Security: - TLS 1.2+ with modern cipher suites - Constant-time token comparison (timing attack prevention) - Rate limiting applied to HTTP requests (shared with socket mode) - Audit logging for all HTTP requests Next steps: - Update installer script to support HTTP mode + auto-registration - Add Pulse API endpoint for proxy registration - Generate TLS certificates during installation - Test multi-instance temperature collection Related to #571 (multi-instance architecture)
337 lines
8.5 KiB
Go
337 lines
8.5 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// auditLogger emits append-only, hash-chained audit events.
|
|
type auditLogger struct {
|
|
mu sync.Mutex
|
|
file *os.File
|
|
logger zerolog.Logger
|
|
prevHash []byte
|
|
sequence uint64
|
|
}
|
|
|
|
// AuditEvent captures a single security-relevant action.
|
|
type AuditEvent struct {
|
|
Sequence uint64 `json:"seq"`
|
|
Timestamp time.Time `json:"ts"`
|
|
EventType string `json:"event_type"`
|
|
CorrelationID string `json:"correlation_id,omitempty"`
|
|
PeerUID *uint32 `json:"peer_uid,omitempty"`
|
|
PeerGID *uint32 `json:"peer_gid,omitempty"`
|
|
PeerPID *uint32 `json:"peer_pid,omitempty"`
|
|
RemoteAddr string `json:"remote_addr,omitempty"`
|
|
Command string `json:"command,omitempty"`
|
|
Args []string `json:"args,omitempty"`
|
|
Target string `json:"target,omitempty"`
|
|
Decision string `json:"decision,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Limiter string `json:"limiter,omitempty"`
|
|
ExitCode *int `json:"exit_code,omitempty"`
|
|
DurationMs *int64 `json:"duration_ms,omitempty"`
|
|
StdoutHash string `json:"stdout_sha256,omitempty"`
|
|
StderrHash string `json:"stderr_sha256,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
PrevHash string `json:"prev_hash"`
|
|
EventHash string `json:"event_hash"`
|
|
}
|
|
|
|
// newAuditLogger opens the audit log file and prepares hash chaining.
|
|
// If the file cannot be opened (e.g., read-only filesystem), it automatically
|
|
// falls back to stderr which integrates with systemd journal.
|
|
// This function always succeeds and returns a valid audit logger.
|
|
func newAuditLogger(path string) *auditLogger {
|
|
// Try to open the audit log file
|
|
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
|
|
var writer zerolog.Logger
|
|
var usedFallback bool
|
|
|
|
if err != nil {
|
|
// Fallback to stderr if file cannot be opened
|
|
log.Warn().
|
|
Err(err).
|
|
Str("path", path).
|
|
Msg("Cannot open audit log file, falling back to stderr (systemd journal)")
|
|
|
|
writer = zerolog.New(os.Stderr).With().Timestamp().Logger()
|
|
usedFallback = true
|
|
file = nil
|
|
} else {
|
|
writer = zerolog.New(file).With().Timestamp().Logger()
|
|
}
|
|
|
|
// Log initialization event to standard logger (not to audit log itself)
|
|
if usedFallback {
|
|
log.Warn().
|
|
Str("path", path).
|
|
Str("mode", "stderr").
|
|
Msg("Audit logger initialized with stderr fallback due to filesystem constraints")
|
|
} else {
|
|
log.Info().
|
|
Str("path", path).
|
|
Str("mode", "file").
|
|
Msg("Audit logger initialized with file backend")
|
|
}
|
|
|
|
return &auditLogger{
|
|
file: file,
|
|
logger: writer,
|
|
}
|
|
}
|
|
|
|
// Close flushes and closes the audit log file.
|
|
func (a *auditLogger) Close() error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.file == nil {
|
|
return nil
|
|
}
|
|
err := a.file.Close()
|
|
a.file = nil
|
|
return err
|
|
}
|
|
|
|
// LogConnectionAccepted records an authorized connection.
|
|
func (a *auditLogger) LogConnectionAccepted(correlationID string, cred *peerCredentials, remote string) {
|
|
event := AuditEvent{
|
|
EventType: "connection.accepted",
|
|
CorrelationID: correlationID,
|
|
RemoteAddr: remote,
|
|
Decision: "allowed",
|
|
}
|
|
event.applyPeer(cred)
|
|
a.log(&event)
|
|
}
|
|
|
|
// LogConnectionDenied records a rejected connection attempt.
|
|
func (a *auditLogger) LogConnectionDenied(correlationID string, cred *peerCredentials, remote, reason string) {
|
|
event := AuditEvent{
|
|
EventType: "connection.denied",
|
|
CorrelationID: correlationID,
|
|
RemoteAddr: remote,
|
|
Decision: "denied",
|
|
Reason: reason,
|
|
}
|
|
event.applyPeer(cred)
|
|
a.log(&event)
|
|
}
|
|
|
|
// LogRateLimitHit records limiter rejections.
|
|
func (a *auditLogger) LogRateLimitHit(correlationID string, cred *peerCredentials, remote, limiter string) {
|
|
event := AuditEvent{
|
|
EventType: "limiter.rejection",
|
|
CorrelationID: correlationID,
|
|
RemoteAddr: remote,
|
|
Decision: "denied",
|
|
Limiter: limiter,
|
|
}
|
|
event.applyPeer(cred)
|
|
a.log(&event)
|
|
}
|
|
|
|
// LogCommandStart records command execution approval.
|
|
func (a *auditLogger) LogCommandStart(correlationID string, cred *peerCredentials, remote, target, command string, args []string) {
|
|
event := AuditEvent{
|
|
EventType: "command.start",
|
|
CorrelationID: correlationID,
|
|
RemoteAddr: remote,
|
|
Decision: "allowed",
|
|
Command: command,
|
|
Args: args,
|
|
Target: target,
|
|
}
|
|
event.applyPeer(cred)
|
|
a.log(&event)
|
|
}
|
|
|
|
// LogCommandResult records command completion.
|
|
func (a *auditLogger) LogCommandResult(correlationID string, cred *peerCredentials, remote, target, command string, args []string, exitCode int, duration time.Duration, stdoutHash, stderrHash string, execErr error) {
|
|
event := AuditEvent{
|
|
EventType: "command.finish",
|
|
CorrelationID: correlationID,
|
|
RemoteAddr: remote,
|
|
Command: command,
|
|
Args: args,
|
|
Target: target,
|
|
ExitCode: intPtr(exitCode),
|
|
StdoutHash: stdoutHash,
|
|
StderrHash: stderrHash,
|
|
}
|
|
event.applyPeer(cred)
|
|
if duration > 0 {
|
|
ms := duration.Milliseconds()
|
|
event.DurationMs = int64Ptr(ms)
|
|
}
|
|
if execErr != nil {
|
|
event.Error = execErr.Error()
|
|
event.Decision = "failed"
|
|
} else {
|
|
event.Decision = "completed"
|
|
}
|
|
a.log(&event)
|
|
}
|
|
|
|
// LogValidationFailure records validator rejections.
|
|
func (a *auditLogger) LogValidationFailure(correlationID string, cred *peerCredentials, remote, command string, args []string, reason string) {
|
|
event := AuditEvent{
|
|
EventType: "command.validation_failed",
|
|
CorrelationID: correlationID,
|
|
RemoteAddr: remote,
|
|
Command: command,
|
|
Args: args,
|
|
Decision: "denied",
|
|
Reason: reason,
|
|
}
|
|
event.applyPeer(cred)
|
|
a.log(&event)
|
|
}
|
|
|
|
func (e *AuditEvent) applyPeer(cred *peerCredentials) {
|
|
if cred == nil {
|
|
return
|
|
}
|
|
e.PeerUID = uint32Ptr(cred.uid)
|
|
e.PeerGID = uint32Ptr(cred.gid)
|
|
e.PeerPID = uint32Ptr(cred.pid)
|
|
}
|
|
|
|
// log persists the event with hash chaining.
|
|
func (a *auditLogger) log(event *AuditEvent) {
|
|
if event == nil {
|
|
log.Error().Msg("audit log called with nil event")
|
|
return
|
|
}
|
|
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
a.sequence++
|
|
event.Sequence = a.sequence
|
|
|
|
if event.Timestamp.IsZero() {
|
|
event.Timestamp = time.Now().UTC()
|
|
} else {
|
|
event.Timestamp = event.Timestamp.UTC()
|
|
}
|
|
|
|
event.PrevHash = hex.EncodeToString(a.prevHash)
|
|
|
|
payload, err := eventMarshalForHash(event)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to marshal audit event")
|
|
return
|
|
}
|
|
|
|
sum := sha256.Sum256(append(a.prevHash, payload...))
|
|
a.prevHash = sum[:]
|
|
event.EventHash = hex.EncodeToString(sum[:])
|
|
|
|
a.logger.Info().Fields(eventToMap(event)).Send()
|
|
}
|
|
|
|
func eventMarshalForHash(event *AuditEvent) ([]byte, error) {
|
|
clone := *event
|
|
clone.EventHash = ""
|
|
return json.Marshal(clone)
|
|
}
|
|
|
|
func eventToMap(event *AuditEvent) map[string]interface{} {
|
|
m := map[string]interface{}{
|
|
"ts": event.Timestamp.Format(time.RFC3339Nano),
|
|
"event_type": event.EventType,
|
|
"seq": event.Sequence,
|
|
"prev_hash": event.PrevHash,
|
|
"event_hash": event.EventHash,
|
|
"decision": event.Decision,
|
|
"correlation_id": event.CorrelationID,
|
|
}
|
|
|
|
if event.PeerUID != nil {
|
|
m["peer_uid"] = *event.PeerUID
|
|
}
|
|
if event.PeerGID != nil {
|
|
m["peer_gid"] = *event.PeerGID
|
|
}
|
|
if event.PeerPID != nil {
|
|
m["peer_pid"] = *event.PeerPID
|
|
}
|
|
if event.RemoteAddr != "" {
|
|
m["remote_addr"] = event.RemoteAddr
|
|
}
|
|
if event.Command != "" {
|
|
m["command"] = event.Command
|
|
}
|
|
if len(event.Args) > 0 {
|
|
m["args"] = event.Args
|
|
}
|
|
if event.Target != "" {
|
|
m["target"] = event.Target
|
|
}
|
|
if event.Reason != "" {
|
|
m["reason"] = event.Reason
|
|
}
|
|
if event.Limiter != "" {
|
|
m["limiter"] = event.Limiter
|
|
}
|
|
if event.ExitCode != nil {
|
|
m["exit_code"] = *event.ExitCode
|
|
}
|
|
if event.DurationMs != nil {
|
|
m["duration_ms"] = *event.DurationMs
|
|
}
|
|
if event.StdoutHash != "" {
|
|
m["stdout_sha256"] = event.StdoutHash
|
|
}
|
|
if event.StderrHash != "" {
|
|
m["stderr_sha256"] = event.StderrHash
|
|
}
|
|
if event.Error != "" {
|
|
m["error"] = event.Error
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
func uint32Ptr(v uint32) *uint32 {
|
|
value := v
|
|
return &value
|
|
}
|
|
|
|
func intPtr(v int) *int {
|
|
value := v
|
|
return &value
|
|
}
|
|
|
|
func int64Ptr(v int64) *int64 {
|
|
value := v
|
|
return &value
|
|
}
|
|
|
|
// LogHTTPRequest logs HTTP requests for audit trail
|
|
func (a *auditLogger) LogHTTPRequest(remoteAddr, method, path string, statusCode int, reason string) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
|
|
event := AuditEvent{
|
|
EventType: "http_request",
|
|
RemoteAddr: remoteAddr,
|
|
Command: method,
|
|
Target: path,
|
|
ExitCode: &statusCode,
|
|
Reason: reason,
|
|
}
|
|
|
|
a.log(&event)
|
|
}
|