Automate sensor proxy container mount and auth
This commit is contained in:
parent
156fd34c50
commit
e4c3b06f14
7 changed files with 467 additions and 75 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
25
cmd/pulse-sensor-proxy/auth_test.go
Normal file
25
cmd/pulse-sensor-proxy/auth_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue