Files
Pulse/cmd/pulse-sensor-proxy/audit.go
rcourtman 2ee693cc63 Add HTTP mode to pulse-sensor-proxy for multi-instance temperature monitoring
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)
2025-11-13 16:13:53 +00:00

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