Automate sensor proxy container mount and auth

This commit is contained in:
rcourtman 2025-10-14 12:41:48 +00:00
parent 156fd34c50
commit e4c3b06f14
7 changed files with 467 additions and 75 deletions

View file

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

View file

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

View file

@ -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

View file

@ -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 {

View file

@ -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`<br>`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)

View file

@ -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{

View file

@ -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