From e4c3b06f14f055e17e793bd77868adcac9e0b665 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 14 Oct 2025 12:41:48 +0000 Subject: [PATCH] Automate sensor proxy container mount and auth --- cmd/pulse-sensor-proxy/auth.go | 239 +++++++++++++++++++++++---- cmd/pulse-sensor-proxy/auth_test.go | 25 +++ cmd/pulse-sensor-proxy/config.go | 112 ++++++++++++- cmd/pulse-sensor-proxy/main.go | 40 +++-- docs/PULSE_SENSOR_PROXY_HARDENING.md | 43 ++++- internal/tempproxy/client.go | 13 +- scripts/install-sensor-proxy.sh | 70 +++++--- 7 files changed, 467 insertions(+), 75 deletions(-) create mode 100644 cmd/pulse-sensor-proxy/auth_test.go diff --git a/cmd/pulse-sensor-proxy/auth.go b/cmd/pulse-sensor-proxy/auth.go index 64cceda..fdaf7d8 100644 --- a/cmd/pulse-sensor-proxy/auth.go +++ b/cmd/pulse-sensor-proxy/auth.go @@ -1,8 +1,12 @@ package main import ( + "errors" "fmt" "net" + "os" + "strconv" + "strings" "syscall" "github.com/rs/zerolog/log" @@ -15,10 +19,18 @@ type peerCredentials struct { gid uint32 } -// extractPeerCredentials extracts and verifies peer credentials -// Returns credentials if authorized, error otherwise +type idRange struct { + start uint64 + length uint64 +} + +func (r idRange) contains(v uint32) bool { + value := uint64(v) + return value >= r.start && value < r.start+r.length +} + +// extractPeerCredentials extracts peer credentials via SO_PEERCRED func extractPeerCredentials(conn net.Conn) (*peerCredentials, error) { - // Get the underlying file descriptor unixConn, ok := conn.(*net.UnixConn) if !ok { return nil, fmt.Errorf("not a unix connection") @@ -32,7 +44,6 @@ func extractPeerCredentials(conn net.Conn) (*peerCredentials, error) { fd := int(file.Fd()) - // Get peer credentials using SO_PEERCRED cred, err := syscall.GetsockoptUcred(fd, syscall.SOL_SOCKET, syscall.SO_PEERCRED) if err != nil { return nil, fmt.Errorf("failed to get peer credentials: %w", err) @@ -44,31 +55,201 @@ func extractPeerCredentials(conn net.Conn) (*peerCredentials, error) { Uint32("gid", cred.Gid). Msg("Peer credentials") - // Allow root (UID 0) - this covers most service scenarios - if cred.Uid == 0 { - return &peerCredentials{ - uid: cred.Uid, - pid: uint32(cred.Pid), - gid: cred.Gid, - }, nil - } - - // Allow the proxy's own user (for testing/debugging) - if cred.Uid == uint32(syscall.Getuid()) { - return &peerCredentials{ - uid: cred.Uid, - pid: uint32(cred.Pid), - gid: cred.Gid, - }, nil - } - - // Reject all other users - return nil, fmt.Errorf("unauthorized: uid=%d gid=%d", cred.Uid, cred.Gid) + return &peerCredentials{ + uid: cred.Uid, + pid: uint32(cred.Pid), + gid: cred.Gid, + }, nil } -// verifyPeerCredentials checks if the connecting process is authorized (legacy function) -// Returns nil if authorized, error otherwise -func verifyPeerCredentials(conn net.Conn) error { - _, err := extractPeerCredentials(conn) - return err +// initAuthRules builds in-memory allow lists for peer validation +func (p *Proxy) initAuthRules() error { + p.allowedPeerUIDs = make(map[uint32]struct{}) + p.allowedPeerGIDs = make(map[uint32]struct{}) + + // Always allow root and the proxy's own user + p.allowedPeerUIDs[0] = struct{}{} + p.allowedPeerUIDs[uint32(os.Getuid())] = struct{}{} + p.allowedPeerGIDs[0] = struct{}{} + p.allowedPeerGIDs[uint32(os.Getgid())] = struct{}{} + + if len(p.config.AllowedPeerUIDs) > 0 { + for _, uid := range dedupeUint32(p.config.AllowedPeerUIDs) { + p.allowedPeerUIDs[uid] = struct{}{} + } + log.Info(). + Int("explicit_uid_allow_count", len(p.config.AllowedPeerUIDs)). + Msg("Loaded explicit peer UID allow-list entries") + } + + if len(p.config.AllowedPeerGIDs) > 0 { + for _, gid := range dedupeUint32(p.config.AllowedPeerGIDs) { + p.allowedPeerGIDs[gid] = struct{}{} + } + log.Info(). + Int("explicit_gid_allow_count", len(p.config.AllowedPeerGIDs)). + Msg("Loaded explicit peer GID allow-list entries") + } + + if !p.config.AllowIDMappedRoot { + log.Info().Msg("ID-mapped root authentication disabled") + return nil + } + + users := dedupeStrings(p.config.AllowedIDMapUsers) + if len(users) == 0 { + users = []string{"root"} + } + + uidRanges, err := loadSubIDRanges("/etc/subuid", users) + if err != nil { + return fmt.Errorf("loading subordinate UID ranges: %w", err) + } + gidRanges, err := loadSubIDRanges("/etc/subgid", users) + if err != nil { + return fmt.Errorf("loading subordinate GID ranges: %w", err) + } + + p.idMappedUIDRanges = uidRanges + p.idMappedGIDRanges = gidRanges + + if len(uidRanges) == 0 || len(gidRanges) == 0 { + log.Warn(). + Strs("users", users). + Msg("allow_idmapped_root enabled but no subordinate ID ranges detected; LXC connections may fail") + } else { + log.Info(). + Int("uid_range_count", len(uidRanges)). + Int("gid_range_count", len(gidRanges)). + Strs("users", users). + Msg("Loaded subordinate ID ranges for ID-mapped root authentication") + } + + return nil +} + +// authorizePeer verifies the peer credentials against configured allow lists +func (p *Proxy) authorizePeer(cred *peerCredentials) error { + if _, ok := p.allowedPeerUIDs[cred.uid]; ok { + return nil + } + + if p.config.AllowIDMappedRoot && p.isIDMappedRoot(cred) { + return nil + } + + return fmt.Errorf("unauthorized: uid=%d gid=%d", cred.uid, cred.gid) +} + +func (p *Proxy) isIDMappedRoot(cred *peerCredentials) bool { + if len(p.idMappedUIDRanges) == 0 || len(p.idMappedGIDRanges) == 0 { + return false + } + + if !rangeContains(p.idMappedUIDRanges, cred.uid) { + return false + } + + if !rangeContains(p.idMappedGIDRanges, cred.gid) { + return false + } + + return true +} + +func rangeContains(ranges []idRange, value uint32) bool { + for _, r := range ranges { + if r.contains(value) { + return true + } + } + return false +} + +func dedupeUint32(values []uint32) []uint32 { + seen := make(map[uint32]struct{}) + var result []uint32 + for _, val := range values { + if _, ok := seen[val]; ok { + continue + } + seen[val] = struct{}{} + result = append(result, val) + } + return result +} + +func dedupeStrings(values []string) []string { + seen := make(map[string]struct{}) + var result []string + for _, val := range values { + trimmed := strings.TrimSpace(val) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + return result +} + +func loadSubIDRanges(path string, users []string) ([]idRange, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + + userFilter := make(map[string]struct{}, len(users)) + for _, user := range users { + userFilter[user] = struct{}{} + } + + lines := strings.Split(string(data), "\n") + var ranges []idRange + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.Split(line, ":") + if len(parts) < 3 { + continue + } + + if len(userFilter) > 0 { + if _, ok := userFilter[parts[0]]; !ok { + continue + } + } + + start, err := strconv.ParseUint(parts[1], 10, 64) + if err != nil { + log.Warn(). + Str("entry", line). + Err(err). + Msg("Skipping subordinate ID entry with invalid start value") + continue + } + + length, err := strconv.ParseUint(parts[2], 10, 64) + if err != nil || length == 0 { + log.Warn(). + Str("entry", line). + Err(err). + Msg("Skipping subordinate ID entry with invalid length") + continue + } + + ranges = append(ranges, idRange{start: start, length: length}) + } + + return ranges, nil } diff --git a/cmd/pulse-sensor-proxy/auth_test.go b/cmd/pulse-sensor-proxy/auth_test.go new file mode 100644 index 0000000..02c0252 --- /dev/null +++ b/cmd/pulse-sensor-proxy/auth_test.go @@ -0,0 +1,25 @@ +package main + +import "testing" + +func TestAuthorizePeer(t *testing.T) { + p := &Proxy{ + config: &Config{AllowIDMappedRoot: true}, + allowedPeerUIDs: map[uint32]struct{}{0: {}}, + allowedPeerGIDs: map[uint32]struct{}{0: {}}, + idMappedUIDRanges: []idRange{{start: 165536, length: 65536}}, + idMappedGIDRanges: []idRange{{start: 165536, length: 65536}}, + } + + if err := p.authorizePeer(&peerCredentials{uid: 0, gid: 0}); err != nil { + t.Fatalf("expected root to be authorized, got %v", err) + } + + if err := p.authorizePeer(&peerCredentials{uid: 170000, gid: 170000}); err != nil { + t.Fatalf("expected idmapped root to be authorized, got %v", err) + } + + if err := p.authorizePeer(&peerCredentials{uid: 900, gid: 900}); err == nil { + t.Fatalf("expected non-allowed user to be rejected") + } +} diff --git a/cmd/pulse-sensor-proxy/config.go b/cmd/pulse-sensor-proxy/config.go index ddb678a..70f5205 100644 --- a/cmd/pulse-sensor-proxy/config.go +++ b/cmd/pulse-sensor-proxy/config.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "os" + "strconv" "strings" "github.com/rs/zerolog/log" @@ -14,11 +15,19 @@ import ( type Config struct { AllowedSourceSubnets []string `yaml:"allowed_source_subnets"` MetricsAddress string `yaml:"metrics_address"` + + AllowIDMappedRoot bool `yaml:"allow_idmapped_root"` + AllowedPeerUIDs []uint32 `yaml:"allowed_peer_uids"` + AllowedPeerGIDs []uint32 `yaml:"allowed_peer_gids"` + AllowedIDMapUsers []string `yaml:"allowed_idmap_users"` } // loadConfig loads configuration from file and environment variables func loadConfig(configPath string) (*Config, error) { - cfg := &Config{} + cfg := &Config{ + AllowIDMappedRoot: true, + AllowedIDMapUsers: []string{"root"}, + } // Try to load config file if it exists if configPath != "" { @@ -48,6 +57,65 @@ func loadConfig(configPath string) (*Config, error) { Msg("Appended subnets from environment variable") } + // Allow ID-mapped root override + if envAllowIDMap := os.Getenv("PULSE_SENSOR_PROXY_ALLOW_IDMAPPED_ROOT"); envAllowIDMap != "" { + parsed, err := parseBool(envAllowIDMap) + if err != nil { + log.Warn(). + Str("value", envAllowIDMap). + Err(err). + Msg("Invalid PULSE_SENSOR_PROXY_ALLOW_IDMAPPED_ROOT value, ignoring") + } else { + cfg.AllowIDMappedRoot = parsed + log.Info(). + Bool("allow_idmapped_root", parsed). + Msg("Configured allow_idmapped_root from environment variable") + } + } + + // Allowed ID map users override + if envIDMapUsers := os.Getenv("PULSE_SENSOR_PROXY_ALLOWED_IDMAP_USERS"); envIDMapUsers != "" { + envList := splitAndTrim(envIDMapUsers) + if len(envList) > 0 { + cfg.AllowedIDMapUsers = envList + log.Info(). + Strs("allowed_idmap_users", cfg.AllowedIDMapUsers). + Msg("Configured allowed ID map users from environment") + } + } + + // Allowed peer UID overrides + if envUIDs := os.Getenv("PULSE_SENSOR_PROXY_ALLOWED_PEER_UIDS"); envUIDs != "" { + parsed, err := parseUint32List(envUIDs) + if err != nil { + log.Warn(). + Str("value", envUIDs). + Err(err). + Msg("Invalid PULSE_SENSOR_PROXY_ALLOWED_PEER_UIDS value, ignoring") + } else { + cfg.AllowedPeerUIDs = append(cfg.AllowedPeerUIDs, parsed...) + log.Info(). + Int("env_uid_count", len(parsed)). + Msg("Appended allowed peer UIDs from environment") + } + } + + // Allowed peer GID overrides + if envGIDs := os.Getenv("PULSE_SENSOR_PROXY_ALLOWED_PEER_GIDS"); envGIDs != "" { + parsed, err := parseUint32List(envGIDs) + if err != nil { + log.Warn(). + Str("value", envGIDs). + Err(err). + Msg("Invalid PULSE_SENSOR_PROXY_ALLOWED_PEER_GIDS value, ignoring") + } else { + cfg.AllowedPeerGIDs = append(cfg.AllowedPeerGIDs, parsed...) + log.Info(). + Int("env_gid_count", len(parsed)). + Msg("Appended allowed peer GIDs from environment") + } + } + // Metrics address from environment variable if envMetrics := os.Getenv("PULSE_SENSOR_PROXY_METRICS_ADDR"); envMetrics != "" { cfg.MetricsAddress = envMetrics @@ -85,6 +153,48 @@ func loadConfig(configPath string) (*Config, error) { return cfg, nil } +// parseBool returns boolean value for various truthy/falsy strings +func parseBool(raw string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "on": + return true, nil + case "0", "false", "no", "off": + return false, nil + default: + return false, fmt.Errorf("invalid boolean value: %s", raw) + } +} + +// parseUint32List parses comma-separated uint32 list +func parseUint32List(raw string) ([]uint32, error) { + var parsed []uint32 + parts := splitAndTrim(raw) + for _, part := range parts { + if part == "" { + continue + } + val, err := strconv.ParseUint(part, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid uint32 %q: %w", part, err) + } + parsed = append(parsed, uint32(val)) + } + return parsed, nil +} + +// splitAndTrim splits a comma-separated string and trims whitespace +func splitAndTrim(raw string) []string { + parts := strings.Split(raw, ",") + var result []string + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + // detectHostCIDRs detects local host IP addresses as /32 (IPv4) or /128 (IPv6) CIDRs func detectHostCIDRs() []string { var cidrs []string diff --git a/cmd/pulse-sensor-proxy/main.go b/cmd/pulse-sensor-proxy/main.go index cbcc0c7..609b522 100644 --- a/cmd/pulse-sensor-proxy/main.go +++ b/cmd/pulse-sensor-proxy/main.go @@ -70,14 +70,19 @@ func main() { // Proxy manages the temperature monitoring proxy type Proxy struct { - socketPath string - sshKeyPath string - listener net.Listener - rateLimiter *rateLimiter - nodeGate *nodeGate - router map[string]handlerFunc - config *Config - metrics *ProxyMetrics + socketPath string + sshKeyPath string + listener net.Listener + rateLimiter *rateLimiter + nodeGate *nodeGate + router map[string]handlerFunc + config *Config + metrics *ProxyMetrics + + allowedPeerUIDs map[uint32]struct{} + allowedPeerGIDs map[uint32]struct{} + idMappedUIDRanges []idRange + idMappedGIDRanges []idRange } // RPC request types @@ -159,6 +164,10 @@ func runProxy() { RPCGetTemperature: proxy.handleGetTemperatureV2, } + if err := proxy.initAuthRules(); err != nil { + log.Fatal().Err(err).Msg("Failed to initialize authentication rules") + } + if err := proxy.Start(); err != nil { log.Fatal().Err(err).Msg("Failed to start proxy") } @@ -210,9 +219,8 @@ func (p *Proxy) Start() error { } p.listener = listener - // Set socket permissions to owner+group only - // We use SO_PEERCRED for authentication, so we don't need world-readable - if err := os.Chmod(p.socketPath, 0660); err != nil { + // Set liberal socket permissions; SO_PEERCRED enforces auth + if err := os.Chmod(p.socketPath, 0666); err != nil { log.Warn().Err(err).Msg("Failed to set socket permissions") } @@ -276,6 +284,16 @@ func (p *Proxy) handleConnection(conn net.Conn) { return } + if err := p.authorizePeer(cred); err != nil { + log.Warn(). + Err(err). + Uint32("uid", cred.uid). + Uint32("gid", cred.gid). + Msg("Peer authorization failed") + p.sendErrorV2(conn, "unauthorized", "") + return + } + // Check rate limit and concurrency releaseLimiter, ok := p.rateLimiter.allow(peerID{uid: cred.uid, pid: cred.pid}) if !ok { diff --git a/docs/PULSE_SENSOR_PROXY_HARDENING.md b/docs/PULSE_SENSOR_PROXY_HARDENING.md index 9663007..9c3f942 100644 --- a/docs/PULSE_SENSOR_PROXY_HARDENING.md +++ b/docs/PULSE_SENSOR_PROXY_HARDENING.md @@ -6,10 +6,31 @@ The `pulse-sensor-proxy` is a host-side service that provides secure temperature **Architecture:** - Host-side proxy runs with minimal privileges on each Proxmox node -- Containerized Pulse communicates via Unix socket (`/run/pulse-sensor-proxy/pulse-sensor-proxy.sock`) +- Containerized Pulse communicates via Unix socket (inside the container at `/mnt/pulse-proxy/pulse-sensor-proxy.sock`, backed by `/run/pulse-sensor-proxy/pulse-sensor-proxy.sock` on the host) - Proxy authenticates containers using Linux `SO_PEERCRED` (UID/PID verification) - SSH keys never leave the host filesystem +```mermaid +flowchart LR + subgraph Host Node + direction TB + PulseProxy["pulse-sensor-proxy service\n(systemd, user=pulse-sensor-proxy)"] + HostSocket["/run/pulse-sensor-proxy/\npulse-sensor-proxy.sock"] + PulseProxy -- Unix socket --> HostSocket + end + + subgraph LXC Container (Pulse) + direction TB + MountPoint["/mnt/pulse-proxy/\npulse-sensor-proxy.sock"] + PulseBackend["Pulse backend (Go)\n-hot-dev / production"] + MountPoint --> PulseBackend + end + + HostSocket == Proxmox mp mount ==> MountPoint + PulseBackend -.-> Sensors[(Cluster nodes via SSH 'sensors -j')] + PulseProxy -.-> Sensors +``` + **Threat Model:** - ✅ Container compromise cannot access SSH keys - ✅ Container cannot directly SSH to cluster nodes @@ -89,6 +110,8 @@ systemctl show pulse-sensor-proxy | grep -E '(User=|NoNewPrivileges|ProtectSyste /run/pulse-sensor-proxy/ pulse-sensor-proxy:pulse-sensor-proxy 0775 └── pulse-sensor-proxy.sock pulse-sensor-proxy:pulse-sensor-proxy 0777 +/mnt/pulse-proxy/ nobody:nogroup (id-mapped) 0777 +└── pulse-sensor-proxy.sock nobody:nogroup 0777 ``` **Verify permissions:** @@ -103,9 +126,13 @@ ls -l /var/lib/pulse-sensor-proxy/ssh/ # -rw------- pulse-sensor-proxy pulse-sensor-proxy id_ed25519 # -rw-r----- pulse-sensor-proxy pulse-sensor-proxy id_ed25519.pub -# Check socket directory (note: 0775 for container access) +# Check socket directory on host (note: 0775 for container access) ls -ld /run/pulse-sensor-proxy/ # Expected: drwxrwxr-x pulse-sensor-proxy pulse-sensor-proxy + +# Check socket directory inside container +ls -ld /mnt/pulse-proxy/ +# Expected: drwxrwxrwx nobody nogroup (id-mapped) ``` **Why 0775 on socket directory?** @@ -120,7 +147,7 @@ The socket directory needs `0775` (not `0770`) to allow the container's unprivil | `lxc.idmap` | `u 0 100000 65536`
`g 0 100000 65536` | Unprivileged UID/GID mapping | | `lxc.apparmor.profile` | `generated` or custom | AppArmor confinement | | `lxc.cap.drop` | `sys_admin` (optional) | Drop dangerous capabilities | -| `lxc.mount.entry` | Directory-level bind mount | Socket access from container | +| `mpX` | `/run/pulse-sensor-proxy,mp=/mnt/pulse-proxy` | Socket access from container | ### Sample LXC Configuration @@ -137,8 +164,8 @@ lxc.apparmor.profile: generated lxc.cap.drop: sys_admin # Bind mount proxy socket directory (REQUIRED) -# Note: Directory-level mount, not socket-level (socket is recreated by systemd) -lxc.mount.entry: /run/pulse-sensor-proxy run/pulse-sensor-proxy none bind,create=dir 0 0 +# Note: Use an mp entry so Proxmox manages the bind mount automatically +mp0: /run/pulse-sensor-proxy,mp=/mnt/pulse-proxy ``` **Key points:** @@ -187,11 +214,11 @@ capsh --print | grep Current **Check bind mount:** ```bash # Inside container -ls -la /run/pulse-sensor-proxy/ +ls -la /mnt/pulse-proxy/ # Expected: pulse-sensor-proxy.sock visible # Test socket access (requires Pulse to attempt connection) -socat - UNIX-CONNECT:/run/pulse-sensor-proxy/pulse-sensor-proxy.sock +socat - UNIX-CONNECT:/mnt/pulse-proxy/pulse-sensor-proxy.sock # Should connect (may timeout waiting for input, but connection succeeds) ``` @@ -877,7 +904,7 @@ If issues occur during rollout: **Container:** - [ ] Container is unprivileged (`unprivileged: 1` in config) -- [ ] Bind mount exists: `ls /run/pulse-sensor-proxy/pulse-sensor-proxy.sock` +- [ ] Bind mount exists: `ls /mnt/pulse-proxy/pulse-sensor-proxy.sock` - [ ] AppArmor enforced: `cat /proc/self/attr/current` shows confinement - [ ] Pulse can connect to socket (check Pulse logs) diff --git a/internal/tempproxy/client.go b/internal/tempproxy/client.go index 75db564..678f539 100644 --- a/internal/tempproxy/client.go +++ b/internal/tempproxy/client.go @@ -11,8 +11,9 @@ import ( ) const ( - defaultSocketPath = "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock" - defaultTimeout = 10 * time.Second + defaultSocketPath = "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock" + containerSocketPath = "/mnt/pulse-proxy/pulse-sensor-proxy.sock" + defaultTimeout = 10 * time.Second ) // Client communicates with pulse-sensor-proxy via unix socket @@ -25,7 +26,13 @@ type Client struct { func NewClient() *Client { socketPath := os.Getenv("PULSE_SENSOR_PROXY_SOCKET") if socketPath == "" { - socketPath = defaultSocketPath + if _, err := os.Stat(defaultSocketPath); err == nil { + socketPath = defaultSocketPath + } else if _, err := os.Stat(containerSocketPath); err == nil { + socketPath = containerSocketPath + } else { + socketPath = defaultSocketPath + } } return &Client{ diff --git a/scripts/install-sensor-proxy.sh b/scripts/install-sensor-proxy.sh index 2ed2b32..c34a59d 100755 --- a/scripts/install-sensor-proxy.sh +++ b/scripts/install-sensor-proxy.sh @@ -244,44 +244,68 @@ fi print_info "Socket ready at $SOCKET_PATH" -# Configure LXC bind mount - mount entire directory for socket stability -LXC_CONFIG="/etc/pve/lxc/${CTID}.conf" -BIND_ENTRY="lxc.mount.entry: /run/pulse-sensor-proxy run/pulse-sensor-proxy none bind,create=dir 0 0" +# Ensure container mount via mp configuration +print_info "Ensuring container socket mount configuration..." +MOUNT_TARGET="/mnt/pulse-proxy" +CONFIG_CONTENT=$(pct config "$CTID") +CURRENT_MP=$(pct config "$CTID" | awk -v target="$MOUNT_TARGET" '$1 ~ /^mp[0-9]+:$/ && index($0, "mp=" target) {split($1, arr, ":"); print arr[1]; exit}') +MOUNT_UPDATED=false -# Check if bind mount already exists -if grep -q "pulse-sensor-proxy" "$LXC_CONFIG"; then - print_info "Bind mount already configured in LXC config" - # Remove old socket-level bind if it exists - if grep -q "pulse-sensor-proxy.sock" "$LXC_CONFIG"; then - print_info "Upgrading from socket-level to directory-level bind mount..." - sed -i '/pulse-sensor-proxy\.sock/d' "$LXC_CONFIG" - echo "$BIND_ENTRY" >> "$LXC_CONFIG" - NEEDS_RESTART=true +if [[ -z "$CURRENT_MP" ]]; then + for idx in $(seq 0 9); do + if ! printf "%s\n" "$CONFIG_CONTENT" | grep -q "^mp${idx}:"; then + CURRENT_MP="mp${idx}" + break + fi + done + if [[ -z "$CURRENT_MP" ]]; then + print_error "Unable to find available mp slot for container mount" + exit 1 fi + print_info "Configuring container mount using $CURRENT_MP..." + pct set "$CTID" -${CURRENT_MP} "/run/pulse-sensor-proxy,mp=${MOUNT_TARGET},replicate=0" + MOUNT_UPDATED=true else - print_info "Adding bind mount to LXC config..." - echo "$BIND_ENTRY" >> "$LXC_CONFIG" - NEEDS_RESTART=true + print_info "Container already has socket mount configured ($CURRENT_MP)" + pct set "$CTID" -${CURRENT_MP} "/run/pulse-sensor-proxy,mp=${MOUNT_TARGET},replicate=0" + MOUNT_UPDATED=true fi -# Restart container to apply bind mount if needed -if [[ "${NEEDS_RESTART:-false}" == "true" ]]; then - print_info "Restarting container to apply bind mount..." +# Remove legacy lxc.mount.entry directives if present +LXC_CONFIG="/etc/pve/lxc/${CTID}.conf" +if grep -q "lxc.mount.entry: /run/pulse-sensor-proxy" "$LXC_CONFIG"; then + print_info "Removing legacy lxc.mount.entry directives for pulse-sensor-proxy" + sed -i '/lxc\.mount\.entry: \/run\/pulse-sensor-proxy/d' "$LXC_CONFIG" + MOUNT_UPDATED=true +fi + +# Restart container to apply mount if configuration changed or mount missing +if [[ "$MOUNT_UPDATED" = true ]]; then + print_info "Restarting container to apply socket mount..." pct stop "$CTID" || true sleep 2 pct start "$CTID" sleep 5 fi -# Verify socket is accessible in container +# Verify socket directory and file inside container print_info "Verifying socket accessibility..." -if pct exec "$CTID" -- test -S /run/pulse-sensor-proxy/pulse-sensor-proxy.sock; then - print_info "Socket is accessible in container" +if pct exec "$CTID" -- test -S "${MOUNT_TARGET}/pulse-sensor-proxy.sock"; then + print_info "Socket is accessible in container at ${MOUNT_TARGET}/pulse-sensor-proxy.sock" else - print_warn "Socket is not yet accessible in container" - print_info "Container may need additional restart or configuration" + print_warn "Socket not visible at ${MOUNT_TARGET}/pulse-sensor-proxy.sock" + print_info "Check container configuration and restart if necessary" fi +# Configure Pulse backend environment override inside container +print_info "Configuring Pulse backend to use mounted proxy socket..." +pct exec "$CTID" -- bash -lc "mkdir -p /etc/systemd/system/pulse-backend.service.d" +pct exec "$CTID" -- bash -lc "cat <<'EOF' >/etc/systemd/system/pulse-backend.service.d/10-pulse-proxy.conf +[Service] +Environment=PULSE_SENSOR_PROXY_SOCKET=${MOUNT_TARGET}/pulse-sensor-proxy.sock +EOF" +pct exec "$CTID" -- systemctl daemon-reload || true + # Test proxy status print_info "Testing proxy status..." if systemctl is-active --quiet pulse-sensor-proxy; then