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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
@ -15,10 +19,18 @@ type peerCredentials struct {
|
||||||
gid uint32
|
gid uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractPeerCredentials extracts and verifies peer credentials
|
type idRange struct {
|
||||||
// Returns credentials if authorized, error otherwise
|
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) {
|
func extractPeerCredentials(conn net.Conn) (*peerCredentials, error) {
|
||||||
// Get the underlying file descriptor
|
|
||||||
unixConn, ok := conn.(*net.UnixConn)
|
unixConn, ok := conn.(*net.UnixConn)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("not a unix connection")
|
return nil, fmt.Errorf("not a unix connection")
|
||||||
|
|
@ -32,7 +44,6 @@ func extractPeerCredentials(conn net.Conn) (*peerCredentials, error) {
|
||||||
|
|
||||||
fd := int(file.Fd())
|
fd := int(file.Fd())
|
||||||
|
|
||||||
// Get peer credentials using SO_PEERCRED
|
|
||||||
cred, err := syscall.GetsockoptUcred(fd, syscall.SOL_SOCKET, syscall.SO_PEERCRED)
|
cred, err := syscall.GetsockoptUcred(fd, syscall.SOL_SOCKET, syscall.SO_PEERCRED)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get peer credentials: %w", err)
|
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).
|
Uint32("gid", cred.Gid).
|
||||||
Msg("Peer credentials")
|
Msg("Peer credentials")
|
||||||
|
|
||||||
// Allow root (UID 0) - this covers most service scenarios
|
return &peerCredentials{
|
||||||
if cred.Uid == 0 {
|
uid: cred.Uid,
|
||||||
return &peerCredentials{
|
pid: uint32(cred.Pid),
|
||||||
uid: cred.Uid,
|
gid: cred.Gid,
|
||||||
pid: uint32(cred.Pid),
|
}, nil
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyPeerCredentials checks if the connecting process is authorized (legacy function)
|
// initAuthRules builds in-memory allow lists for peer validation
|
||||||
// Returns nil if authorized, error otherwise
|
func (p *Proxy) initAuthRules() error {
|
||||||
func verifyPeerCredentials(conn net.Conn) error {
|
p.allowedPeerUIDs = make(map[uint32]struct{})
|
||||||
_, err := extractPeerCredentials(conn)
|
p.allowedPeerGIDs = make(map[uint32]struct{})
|
||||||
return err
|
|
||||||
|
// 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"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
@ -14,11 +15,19 @@ import (
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AllowedSourceSubnets []string `yaml:"allowed_source_subnets"`
|
AllowedSourceSubnets []string `yaml:"allowed_source_subnets"`
|
||||||
MetricsAddress string `yaml:"metrics_address"`
|
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
|
// loadConfig loads configuration from file and environment variables
|
||||||
func loadConfig(configPath string) (*Config, error) {
|
func loadConfig(configPath string) (*Config, error) {
|
||||||
cfg := &Config{}
|
cfg := &Config{
|
||||||
|
AllowIDMappedRoot: true,
|
||||||
|
AllowedIDMapUsers: []string{"root"},
|
||||||
|
}
|
||||||
|
|
||||||
// Try to load config file if it exists
|
// Try to load config file if it exists
|
||||||
if configPath != "" {
|
if configPath != "" {
|
||||||
|
|
@ -48,6 +57,65 @@ func loadConfig(configPath string) (*Config, error) {
|
||||||
Msg("Appended subnets from environment variable")
|
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
|
// Metrics address from environment variable
|
||||||
if envMetrics := os.Getenv("PULSE_SENSOR_PROXY_METRICS_ADDR"); envMetrics != "" {
|
if envMetrics := os.Getenv("PULSE_SENSOR_PROXY_METRICS_ADDR"); envMetrics != "" {
|
||||||
cfg.MetricsAddress = envMetrics
|
cfg.MetricsAddress = envMetrics
|
||||||
|
|
@ -85,6 +153,48 @@ func loadConfig(configPath string) (*Config, error) {
|
||||||
return cfg, nil
|
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
|
// detectHostCIDRs detects local host IP addresses as /32 (IPv4) or /128 (IPv6) CIDRs
|
||||||
func detectHostCIDRs() []string {
|
func detectHostCIDRs() []string {
|
||||||
var cidrs []string
|
var cidrs []string
|
||||||
|
|
|
||||||
|
|
@ -70,14 +70,19 @@ func main() {
|
||||||
|
|
||||||
// Proxy manages the temperature monitoring proxy
|
// Proxy manages the temperature monitoring proxy
|
||||||
type Proxy struct {
|
type Proxy struct {
|
||||||
socketPath string
|
socketPath string
|
||||||
sshKeyPath string
|
sshKeyPath string
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
rateLimiter *rateLimiter
|
rateLimiter *rateLimiter
|
||||||
nodeGate *nodeGate
|
nodeGate *nodeGate
|
||||||
router map[string]handlerFunc
|
router map[string]handlerFunc
|
||||||
config *Config
|
config *Config
|
||||||
metrics *ProxyMetrics
|
metrics *ProxyMetrics
|
||||||
|
|
||||||
|
allowedPeerUIDs map[uint32]struct{}
|
||||||
|
allowedPeerGIDs map[uint32]struct{}
|
||||||
|
idMappedUIDRanges []idRange
|
||||||
|
idMappedGIDRanges []idRange
|
||||||
}
|
}
|
||||||
|
|
||||||
// RPC request types
|
// RPC request types
|
||||||
|
|
@ -159,6 +164,10 @@ func runProxy() {
|
||||||
RPCGetTemperature: proxy.handleGetTemperatureV2,
|
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 {
|
if err := proxy.Start(); err != nil {
|
||||||
log.Fatal().Err(err).Msg("Failed to start proxy")
|
log.Fatal().Err(err).Msg("Failed to start proxy")
|
||||||
}
|
}
|
||||||
|
|
@ -210,9 +219,8 @@ func (p *Proxy) Start() error {
|
||||||
}
|
}
|
||||||
p.listener = listener
|
p.listener = listener
|
||||||
|
|
||||||
// Set socket permissions to owner+group only
|
// Set liberal socket permissions; SO_PEERCRED enforces auth
|
||||||
// We use SO_PEERCRED for authentication, so we don't need world-readable
|
if err := os.Chmod(p.socketPath, 0666); err != nil {
|
||||||
if err := os.Chmod(p.socketPath, 0660); err != nil {
|
|
||||||
log.Warn().Err(err).Msg("Failed to set socket permissions")
|
log.Warn().Err(err).Msg("Failed to set socket permissions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,6 +284,16 @@ func (p *Proxy) handleConnection(conn net.Conn) {
|
||||||
return
|
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
|
// Check rate limit and concurrency
|
||||||
releaseLimiter, ok := p.rateLimiter.allow(peerID{uid: cred.uid, pid: cred.pid})
|
releaseLimiter, ok := p.rateLimiter.allow(peerID{uid: cred.uid, pid: cred.pid})
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,31 @@ The `pulse-sensor-proxy` is a host-side service that provides secure temperature
|
||||||
|
|
||||||
**Architecture:**
|
**Architecture:**
|
||||||
- Host-side proxy runs with minimal privileges on each Proxmox node
|
- 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)
|
- Proxy authenticates containers using Linux `SO_PEERCRED` (UID/PID verification)
|
||||||
- SSH keys never leave the host filesystem
|
- 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:**
|
**Threat Model:**
|
||||||
- ✅ Container compromise cannot access SSH keys
|
- ✅ Container compromise cannot access SSH keys
|
||||||
- ✅ Container cannot directly SSH to cluster nodes
|
- ✅ 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
|
/run/pulse-sensor-proxy/ pulse-sensor-proxy:pulse-sensor-proxy 0775
|
||||||
└── pulse-sensor-proxy.sock pulse-sensor-proxy:pulse-sensor-proxy 0777
|
└── 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:**
|
**Verify permissions:**
|
||||||
|
|
@ -103,9 +126,13 @@ ls -l /var/lib/pulse-sensor-proxy/ssh/
|
||||||
# -rw------- pulse-sensor-proxy pulse-sensor-proxy id_ed25519
|
# -rw------- pulse-sensor-proxy pulse-sensor-proxy id_ed25519
|
||||||
# -rw-r----- pulse-sensor-proxy pulse-sensor-proxy id_ed25519.pub
|
# -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/
|
ls -ld /run/pulse-sensor-proxy/
|
||||||
# Expected: drwxrwxr-x pulse-sensor-proxy 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?**
|
**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.idmap` | `u 0 100000 65536`<br>`g 0 100000 65536` | Unprivileged UID/GID mapping |
|
||||||
| `lxc.apparmor.profile` | `generated` or custom | AppArmor confinement |
|
| `lxc.apparmor.profile` | `generated` or custom | AppArmor confinement |
|
||||||
| `lxc.cap.drop` | `sys_admin` (optional) | Drop dangerous capabilities |
|
| `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
|
### Sample LXC Configuration
|
||||||
|
|
||||||
|
|
@ -137,8 +164,8 @@ lxc.apparmor.profile: generated
|
||||||
lxc.cap.drop: sys_admin
|
lxc.cap.drop: sys_admin
|
||||||
|
|
||||||
# Bind mount proxy socket directory (REQUIRED)
|
# Bind mount proxy socket directory (REQUIRED)
|
||||||
# Note: Directory-level mount, not socket-level (socket is recreated by systemd)
|
# Note: Use an mp entry so Proxmox manages the bind mount automatically
|
||||||
lxc.mount.entry: /run/pulse-sensor-proxy run/pulse-sensor-proxy none bind,create=dir 0 0
|
mp0: /run/pulse-sensor-proxy,mp=/mnt/pulse-proxy
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key points:**
|
**Key points:**
|
||||||
|
|
@ -187,11 +214,11 @@ capsh --print | grep Current
|
||||||
**Check bind mount:**
|
**Check bind mount:**
|
||||||
```bash
|
```bash
|
||||||
# Inside container
|
# Inside container
|
||||||
ls -la /run/pulse-sensor-proxy/
|
ls -la /mnt/pulse-proxy/
|
||||||
# Expected: pulse-sensor-proxy.sock visible
|
# Expected: pulse-sensor-proxy.sock visible
|
||||||
|
|
||||||
# Test socket access (requires Pulse to attempt connection)
|
# 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)
|
# Should connect (may timeout waiting for input, but connection succeeds)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -877,7 +904,7 @@ If issues occur during rollout:
|
||||||
|
|
||||||
**Container:**
|
**Container:**
|
||||||
- [ ] Container is unprivileged (`unprivileged: 1` in config)
|
- [ ] 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
|
- [ ] AppArmor enforced: `cat /proc/self/attr/current` shows confinement
|
||||||
- [ ] Pulse can connect to socket (check Pulse logs)
|
- [ ] Pulse can connect to socket (check Pulse logs)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultSocketPath = "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock"
|
defaultSocketPath = "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock"
|
||||||
defaultTimeout = 10 * time.Second
|
containerSocketPath = "/mnt/pulse-proxy/pulse-sensor-proxy.sock"
|
||||||
|
defaultTimeout = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client communicates with pulse-sensor-proxy via unix socket
|
// Client communicates with pulse-sensor-proxy via unix socket
|
||||||
|
|
@ -25,7 +26,13 @@ type Client struct {
|
||||||
func NewClient() *Client {
|
func NewClient() *Client {
|
||||||
socketPath := os.Getenv("PULSE_SENSOR_PROXY_SOCKET")
|
socketPath := os.Getenv("PULSE_SENSOR_PROXY_SOCKET")
|
||||||
if socketPath == "" {
|
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{
|
return &Client{
|
||||||
|
|
|
||||||
|
|
@ -244,44 +244,68 @@ fi
|
||||||
|
|
||||||
print_info "Socket ready at $SOCKET_PATH"
|
print_info "Socket ready at $SOCKET_PATH"
|
||||||
|
|
||||||
# Configure LXC bind mount - mount entire directory for socket stability
|
# Ensure container mount via mp configuration
|
||||||
LXC_CONFIG="/etc/pve/lxc/${CTID}.conf"
|
print_info "Ensuring container socket mount configuration..."
|
||||||
BIND_ENTRY="lxc.mount.entry: /run/pulse-sensor-proxy run/pulse-sensor-proxy none bind,create=dir 0 0"
|
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 [[ -z "$CURRENT_MP" ]]; then
|
||||||
if grep -q "pulse-sensor-proxy" "$LXC_CONFIG"; then
|
for idx in $(seq 0 9); do
|
||||||
print_info "Bind mount already configured in LXC config"
|
if ! printf "%s\n" "$CONFIG_CONTENT" | grep -q "^mp${idx}:"; then
|
||||||
# Remove old socket-level bind if it exists
|
CURRENT_MP="mp${idx}"
|
||||||
if grep -q "pulse-sensor-proxy.sock" "$LXC_CONFIG"; then
|
break
|
||||||
print_info "Upgrading from socket-level to directory-level bind mount..."
|
fi
|
||||||
sed -i '/pulse-sensor-proxy\.sock/d' "$LXC_CONFIG"
|
done
|
||||||
echo "$BIND_ENTRY" >> "$LXC_CONFIG"
|
if [[ -z "$CURRENT_MP" ]]; then
|
||||||
NEEDS_RESTART=true
|
print_error "Unable to find available mp slot for container mount"
|
||||||
|
exit 1
|
||||||
fi
|
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
|
else
|
||||||
print_info "Adding bind mount to LXC config..."
|
print_info "Container already has socket mount configured ($CURRENT_MP)"
|
||||||
echo "$BIND_ENTRY" >> "$LXC_CONFIG"
|
pct set "$CTID" -${CURRENT_MP} "/run/pulse-sensor-proxy,mp=${MOUNT_TARGET},replicate=0"
|
||||||
NEEDS_RESTART=true
|
MOUNT_UPDATED=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Restart container to apply bind mount if needed
|
# Remove legacy lxc.mount.entry directives if present
|
||||||
if [[ "${NEEDS_RESTART:-false}" == "true" ]]; then
|
LXC_CONFIG="/etc/pve/lxc/${CTID}.conf"
|
||||||
print_info "Restarting container to apply bind mount..."
|
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
|
pct stop "$CTID" || true
|
||||||
sleep 2
|
sleep 2
|
||||||
pct start "$CTID"
|
pct start "$CTID"
|
||||||
sleep 5
|
sleep 5
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verify socket is accessible in container
|
# Verify socket directory and file inside container
|
||||||
print_info "Verifying socket accessibility..."
|
print_info "Verifying socket accessibility..."
|
||||||
if pct exec "$CTID" -- test -S /run/pulse-sensor-proxy/pulse-sensor-proxy.sock; then
|
if pct exec "$CTID" -- test -S "${MOUNT_TARGET}/pulse-sensor-proxy.sock"; then
|
||||||
print_info "Socket is accessible in container"
|
print_info "Socket is accessible in container at ${MOUNT_TARGET}/pulse-sensor-proxy.sock"
|
||||||
else
|
else
|
||||||
print_warn "Socket is not yet accessible in container"
|
print_warn "Socket not visible at ${MOUNT_TARGET}/pulse-sensor-proxy.sock"
|
||||||
print_info "Container may need additional restart or configuration"
|
print_info "Check container configuration and restart if necessary"
|
||||||
fi
|
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
|
# Test proxy status
|
||||||
print_info "Testing proxy status..."
|
print_info "Testing proxy status..."
|
||||||
if systemctl is-active --quiet pulse-sensor-proxy; then
|
if systemctl is-active --quiet pulse-sensor-proxy; then
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue