Files
lolcathost/internal/daemon/security.go
T

235 lines
5.5 KiB
Go

// Package daemon provides security functions including rate limiting and audit logging.
package daemon
import (
"encoding/json"
"fmt"
"os"
"os/user"
"strconv"
"sync"
"time"
)
const (
// AuditLogPath is the path to the audit log file.
AuditLogPath = "/var/log/lolcathost/audit.log"
// RateLimit is the maximum requests per minute per PID.
RateLimit = 100
// RateLimitWindow is the time window for rate limiting.
RateLimitWindow = time.Minute
)
// pidRateBucket holds rate limiting data for a single PID using a ring buffer.
type pidRateBucket struct {
timestamps []time.Time // Ring buffer of request timestamps
head int // Next write position
count int // Number of valid entries
}
// RateLimiter implements per-PID rate limiting with efficient memory usage.
type RateLimiter struct {
mu sync.Mutex
buckets map[int32]*pidRateBucket
limit int
window time.Duration
}
// NewRateLimiter creates a new rate limiter.
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
return &RateLimiter{
buckets: make(map[int32]*pidRateBucket),
limit: limit,
window: window,
}
}
// Allow checks if a request from the given PID should be allowed.
func (r *RateLimiter) Allow(pid int32) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
cutoff := now.Add(-r.window)
bucket, exists := r.buckets[pid]
if !exists {
// Create new bucket with fixed capacity
bucket = &pidRateBucket{
timestamps: make([]time.Time, r.limit),
head: 0,
count: 0,
}
r.buckets[pid] = bucket
}
// Count valid (non-expired) requests in the ring buffer
validCount := 0
for i := 0; i < bucket.count; i++ {
idx := (bucket.head - bucket.count + i + r.limit) % r.limit
if bucket.timestamps[idx].After(cutoff) {
validCount++
}
}
// Check if under limit
if validCount >= r.limit {
return false
}
// Add new request to ring buffer (overwrites oldest if full)
bucket.timestamps[bucket.head] = now
bucket.head = (bucket.head + 1) % r.limit
if bucket.count < r.limit {
bucket.count++
}
return true
}
// Cleanup removes stale PID entries from the rate limiter.
func (r *RateLimiter) Cleanup() {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
cutoff := now.Add(-r.window)
for pid, bucket := range r.buckets {
// Check if all timestamps are expired
hasValid := false
for i := 0; i < bucket.count; i++ {
idx := (bucket.head - bucket.count + i + r.limit) % r.limit
if bucket.timestamps[idx].After(cutoff) {
hasValid = true
break
}
}
if !hasValid {
delete(r.buckets, pid)
}
}
}
// AuditLogger handles audit logging.
type AuditLogger struct {
mu sync.Mutex
file *os.File
path string
encoder *json.Encoder
}
// AuditEntry represents a single audit log entry.
type AuditEntry struct {
Timestamp string `json:"timestamp"`
UID uint32 `json:"uid"`
PID int32 `json:"pid"`
Action string `json:"action"`
Details any `json:"details,omitempty"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// NewAuditLogger creates a new audit logger.
func NewAuditLogger(path string) (*AuditLogger, error) {
// Ensure directory exists
dir := path[:len(path)-len("/audit.log")]
// #nosec G301 - Log directory permissions are intentionally 0755
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create log directory: %w", err)
}
// #nosec G302,G304,G306 - Path is constant, permissions are intentional for audit log
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("failed to open audit log: %w", err)
}
return &AuditLogger{
file: file,
path: path,
encoder: json.NewEncoder(file),
}, nil
}
// Log writes an audit entry.
func (a *AuditLogger) Log(uid uint32, pid int32, action string, details any, success bool, errMsg string) {
a.mu.Lock()
defer a.mu.Unlock()
entry := AuditEntry{
Timestamp: time.Now().UTC().Format(time.RFC3339),
UID: uid,
PID: pid,
Action: action,
Details: details,
Success: success,
Error: errMsg,
}
// Ignore encoding errors - audit logging should not fail the operation
_ = a.encoder.Encode(entry)
}
// Close closes the audit logger.
func (a *AuditLogger) Close() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.file != nil {
err := a.file.Close()
a.file = nil // Prevent double close
return err
}
return nil
}
// PeerCredentials holds the credentials of a connected peer.
type PeerCredentials struct {
UID uint32
GID uint32
PID int32
}
// isUserInGroup checks if a user (by UID) is a member of a group (by GID).
// This checks supplementary groups, not just the primary GID.
func isUserInGroup(uid uint32, targetGID uint32) bool {
// Look up user by UID
u, err := user.LookupId(fmt.Sprintf("%d", uid))
if err != nil {
return false
}
// Get user's group IDs
groupIDs, err := u.GroupIds()
if err != nil {
return false
}
// Check if target GID is in the list
for _, gidStr := range groupIDs {
gid, err := strconv.ParseUint(gidStr, 10, 32)
if err != nil {
continue
}
if uint32(gid) == targetGID {
return true
}
}
return false
}
// lookupGroupGID looks up a group by name and returns its GID.
func lookupGroupGID(name string) (int, error) {
group, err := user.LookupGroup(name)
if err != nil {
return 0, fmt.Errorf("group not found: %s", name)
}
gid, err := strconv.Atoi(group.Gid)
if err != nil {
return 0, fmt.Errorf("invalid GID for group %s: %s", name, group.Gid)
}
return gid, nil
}