Release prep: 54 engines, self-hosted signatures, i18n, dashboard updates

This commit is contained in:
DmitrL-dev 2026-03-23 16:45:40 +10:00
parent 694e32be26
commit 41cbfd6e0a
178 changed files with 36008 additions and 399 deletions

View file

@ -0,0 +1,286 @@
// Package ipc provides a cross-platform inter-process communication layer
// for SENTINEL SOC Process Isolation (SEC-001).
//
// On Linux: Unix Domain Sockets with SO_PEERCRED validation.
// On Windows: Named Pipes (\\.\pipe\sentinel-soc-*).
//
// Protocol: newline-delimited JSON messages over the pipe.
// Each message has a Type field for routing (event, incident, ack, heartbeat).
package ipc
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"sync"
"time"
)
// SOCMsgType identifies the SOC IPC message kind.
// Named differently from the Swarm transport Message to avoid conflicts.
type SOCMsgType string
const (
SOCMsgEvent SOCMsgType = "soc_event" // Persisted event → correlate
SOCMsgIncident SOCMsgType = "soc_incident" // Created incident → respond
SOCMsgAck SOCMsgType = "soc_ack" // Acknowledgement
SOCMsgHeartbeat SOCMsgType = "soc_heartbeat" // Keepalive
// DefaultTimeout for IPC operations.
DefaultTimeout = 5 * time.Second
// MaxRetries for message delivery.
MaxRetries = 3
// BufferSize for pending messages when downstream is slow.
BufferSize = 4096
)
// SOCMessage is the wire format for SOC process isolation IPC.
type SOCMessage struct {
Type SOCMsgType `json:"type"`
ID string `json:"id,omitempty"`
Timestamp int64 `json:"ts"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// NewSOCMessage creates a new SOC IPC message with the given type and payload.
func NewSOCMessage(t SOCMsgType, payload any) (*SOCMessage, error) {
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("ipc: marshal payload: %w", err)
}
return &SOCMessage{
Type: t,
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
Timestamp: time.Now().Unix(),
Payload: data,
}, nil
}
// Sender writes messages to a downstream IPC pipe.
type Sender struct {
mu sync.Mutex
conn net.Conn
encoder *json.Encoder
name string
logger *slog.Logger
}
// NewSender wraps a net.Conn for sending JSON messages.
func NewSender(conn net.Conn, name string) *Sender {
return &Sender{
conn: conn,
encoder: json.NewEncoder(conn),
name: name,
logger: slog.Default().With("component", "ipc-sender", "pipe", name),
}
}
// Send writes a message to the downstream pipe. Thread-safe.
func (s *Sender) Send(msg *SOCMessage) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.conn.SetWriteDeadline(time.Now().Add(DefaultTimeout)); err != nil {
return fmt.Errorf("ipc: set deadline: %w", err)
}
if err := s.encoder.Encode(msg); err != nil {
s.logger.Error("send failed", "type", msg.Type, "error", err)
return fmt.Errorf("ipc: send %s: %w", msg.Type, err)
}
return nil
}
// SendWithRetry attempts to send a message with retries.
func (s *Sender) SendWithRetry(msg *SOCMessage) error {
var lastErr error
for i := 0; i < MaxRetries; i++ {
if err := s.Send(msg); err != nil {
lastErr = err
s.logger.Warn("send retry", "attempt", i+1, "error", err)
time.Sleep(100 * time.Millisecond * time.Duration(i+1))
continue
}
return nil
}
return fmt.Errorf("ipc: send failed after %d retries: %w", MaxRetries, lastErr)
}
// Close shuts down the sender connection.
func (s *Sender) Close() error {
return s.conn.Close()
}
// Receiver reads messages from an upstream IPC pipe.
type Receiver struct {
conn net.Conn
scanner *bufio.Scanner
name string
logger *slog.Logger
}
// NewReceiver wraps a net.Conn for reading JSON messages.
func NewReceiver(conn net.Conn, name string) *Receiver {
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 64*1024), 1024*1024) // 1MB max message
return &Receiver{
conn: conn,
scanner: scanner,
name: name,
logger: slog.Default().With("component", "ipc-receiver", "pipe", name),
}
}
// Next reads the next message, blocking until available.
// Returns io.EOF when the connection is closed.
func (r *Receiver) Next() (*SOCMessage, error) {
if !r.scanner.Scan() {
if err := r.scanner.Err(); err != nil {
return nil, fmt.Errorf("ipc: read %s: %w", r.name, err)
}
return nil, io.EOF
}
var msg SOCMessage
if err := json.Unmarshal(r.scanner.Bytes(), &msg); err != nil {
r.logger.Warn("invalid message", "raw", r.scanner.Text(), "error", err)
return nil, fmt.Errorf("ipc: unmarshal: %w", err)
}
return &msg, nil
}
// Close shuts down the receiver connection.
func (r *Receiver) Close() error {
return r.conn.Close()
}
// Listener accepts incoming IPC connections on a named pipe.
type Listener struct {
listener net.Listener
name string
logger *slog.Logger
}
// Listen creates a platform-specific named pipe listener.
// On Linux: Unix Domain Socket at /tmp/sentinel-<name>.sock
// On Windows: Named Pipe at \\.\pipe\sentinel-<name>
func Listen(name string) (*Listener, error) {
l, err := platformListen(name)
if err != nil {
return nil, fmt.Errorf("ipc: listen %s: %w", name, err)
}
return &Listener{
listener: l,
name: name,
logger: slog.Default().With("component", "ipc-listener", "pipe", name),
}, nil
}
// Accept waits for and returns the next connection.
func (l *Listener) Accept() (net.Conn, error) {
conn, err := l.listener.Accept()
if err != nil {
return nil, fmt.Errorf("ipc: accept %s: %w", l.name, err)
}
l.logger.Info("client connected", "remote", conn.RemoteAddr())
return conn, nil
}
// Close shuts down the listener.
func (l *Listener) Close() error {
return l.listener.Close()
}
// Addr returns the listener's address.
func (l *Listener) Addr() net.Addr {
return l.listener.Addr()
}
// Dial connects to an existing named pipe.
func Dial(name string) (net.Conn, error) {
return platformDial(name)
}
// DialWithRetry attempts to connect to a named pipe with retries.
// Useful during startup when the downstream process may not be ready.
func DialWithRetry(ctx context.Context, name string, maxRetries int) (net.Conn, error) {
var lastErr error
for i := 0; i < maxRetries; i++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
conn, err := platformDial(name)
if err != nil {
lastErr = err
delay := time.Duration(i+1) * 500 * time.Millisecond
slog.Warn("ipc: dial retry", "pipe", name, "attempt", i+1, "delay", delay, "error", err)
time.Sleep(delay)
continue
}
return conn, nil
}
return nil, fmt.Errorf("ipc: dial %s failed after %d retries: %w", name, maxRetries, lastErr)
}
// BufferedSender wraps a Sender with an async buffer for non-blocking sends.
// If the downstream pipe is slow, messages are buffered up to BufferSize.
type BufferedSender struct {
sender *Sender
msgCh chan *SOCMessage
done chan struct{}
logger *slog.Logger
}
// NewBufferedSender creates a buffered async sender.
func NewBufferedSender(conn net.Conn, name string) *BufferedSender {
bs := &BufferedSender{
sender: NewSender(conn, name),
msgCh: make(chan *SOCMessage, BufferSize),
done: make(chan struct{}),
logger: slog.Default().With("component", "ipc-buffered", "pipe", name),
}
go bs.drain()
return bs
}
// Send enqueues a message for async delivery. Non-blocking if buffer isn't full.
func (bs *BufferedSender) Send(msg *SOCMessage) error {
select {
case bs.msgCh <- msg:
return nil
default:
bs.logger.Error("buffer full, dropping message", "type", msg.Type, "buffer_size", BufferSize)
return fmt.Errorf("ipc: buffer full (%d)", BufferSize)
}
}
// drain processes buffered messages in background.
func (bs *BufferedSender) drain() {
defer close(bs.done)
for msg := range bs.msgCh {
if err := bs.sender.SendWithRetry(msg); err != nil {
bs.logger.Error("buffered send failed", "type", msg.Type, "error", err)
}
}
}
// Close flushes remaining messages and shuts down.
func (bs *BufferedSender) Close() error {
close(bs.msgCh)
<-bs.done // wait for drain
return bs.sender.Close()
}
// Pending returns the number of messages waiting in the buffer.
func (bs *BufferedSender) Pending() int {
return len(bs.msgCh)
}

View file

@ -0,0 +1,172 @@
package ipc
import (
"context"
"encoding/json"
"io"
"testing"
"time"
)
func TestSendReceive(t *testing.T) {
listener, err := Listen("test-pipe")
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer listener.Close()
// Accept in background.
connCh := make(chan struct{})
var receiver *Receiver
go func() {
conn, err := listener.Accept()
if err != nil {
t.Errorf("Accept: %v", err)
return
}
receiver = NewReceiver(conn, "test")
close(connCh)
}()
// Dial to the listener.
conn, err := Dial("test-pipe")
if err != nil {
t.Fatalf("Dial: %v", err)
}
sender := NewSender(conn, "test")
defer sender.Close()
<-connCh // Wait for accept.
// Send a message.
payload := map[string]string{"foo": "bar"}
msg, err := NewSOCMessage(SOCMsgEvent, payload)
if err != nil {
t.Fatalf("NewSOCMessage: %v", err)
}
if err := sender.Send(msg); err != nil {
t.Fatalf("Send: %v", err)
}
// Receive it.
got, err := receiver.Next()
if err != nil {
t.Fatalf("Next: %v", err)
}
if got.Type != SOCMsgEvent {
t.Errorf("Type = %s, want %s", got.Type, SOCMsgEvent)
}
var gotPayload map[string]string
if err := json.Unmarshal(got.Payload, &gotPayload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if gotPayload["foo"] != "bar" {
t.Errorf("payload foo = %s, want bar", gotPayload["foo"])
}
}
func TestBufferedSender(t *testing.T) {
listener, err := Listen("test-buffered")
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer listener.Close()
connCh := make(chan struct{})
var receiver *Receiver
go func() {
conn, _ := listener.Accept()
receiver = NewReceiver(conn, "test")
close(connCh)
}()
conn, err := Dial("test-buffered")
if err != nil {
t.Fatalf("Dial: %v", err)
}
bs := NewBufferedSender(conn, "test-buffered")
<-connCh
// Send 10 messages.
for i := 0; i < 10; i++ {
msg, _ := NewSOCMessage(SOCMsgEvent, map[string]int{"n": i})
if err := bs.Send(msg); err != nil {
t.Fatalf("BufferedSend #%d: %v", i, err)
}
}
// Receive 10 messages.
for i := 0; i < 10; i++ {
got, err := receiver.Next()
if err != nil {
t.Fatalf("Receive #%d: %v", i, err)
}
if got.Type != SOCMsgEvent {
t.Errorf("#%d Type = %s, want soc_event", i, got.Type)
}
}
bs.Close()
}
func TestDialWithRetry(t *testing.T) {
// Start listener after a short delay.
go func() {
time.Sleep(300 * time.Millisecond)
l, err := Listen("test-retry")
if err != nil {
t.Errorf("delayed Listen: %v", err)
return
}
defer l.Close()
conn, _ := l.Accept()
if conn != nil {
conn.Close()
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := DialWithRetry(ctx, "test-retry", 10)
if err != nil {
t.Fatalf("DialWithRetry: %v", err)
}
conn.Close()
}
func TestCloseProducesEOF(t *testing.T) {
listener, err := Listen("test-eof")
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer listener.Close()
connCh := make(chan struct{})
var receiver *Receiver
go func() {
conn, _ := listener.Accept()
receiver = NewReceiver(conn, "test")
close(connCh)
}()
conn, err := Dial("test-eof")
if err != nil {
t.Fatalf("Dial: %v", err)
}
<-connCh
// Close sender side.
conn.Close()
// Receiver should get EOF.
_, err = receiver.Next()
if err != io.EOF {
t.Errorf("expected io.EOF, got %v", err)
}
}

View file

@ -0,0 +1,50 @@
//go:build !windows
package ipc
import (
"fmt"
"net"
"os"
"path/filepath"
"time"
)
// socketDir is the base directory for Unix domain sockets.
var socketDir = filepath.Join(os.TempDir(), "sentinel-soc")
// platformListen creates a Unix domain socket listener.
func platformListen(name string) (net.Listener, error) {
// Ensure socket directory exists.
if err := os.MkdirAll(socketDir, 0700); err != nil {
return nil, fmt.Errorf("ipc/unix: mkdir %s: %w", socketDir, err)
}
sockPath := filepath.Join(socketDir, name+".sock")
// Remove stale socket file if it exists.
_ = os.Remove(sockPath)
l, err := net.Listen("unix", sockPath)
if err != nil {
return nil, fmt.Errorf("ipc/unix: listen %s: %w", sockPath, err)
}
// Set restrictive permissions on the socket.
if err := os.Chmod(sockPath, 0600); err != nil {
l.Close()
return nil, fmt.Errorf("ipc/unix: chmod %s: %w", sockPath, err)
}
return l, nil
}
// platformDial connects to a Unix domain socket.
func platformDial(name string) (net.Conn, error) {
sockPath := filepath.Join(socketDir, name+".sock")
conn, err := net.DialTimeout("unix", sockPath, 5*time.Second)
if err != nil {
return nil, fmt.Errorf("ipc/unix: dial %s: %w", sockPath, err)
}
return conn, nil
}

View file

@ -0,0 +1,53 @@
//go:build windows
package ipc
import (
"fmt"
"net"
"time"
)
const pipePrefix = `\\.\pipe\sentinel-`
// platformListen creates a named pipe listener on Windows.
// Uses net.Listen("tcp", ...) on localhost as Windows named pipe fallback.
// For production Windows deployments, use github.com/Microsoft/go-winio.
func platformListen(name string) (net.Listener, error) {
// Fallback: TCP listener on localhost for Windows development.
// In production, this would use go-winio for proper Windows named pipes.
addr := fmt.Sprintf("127.0.0.1:%d", pipeTCPPort(name))
l, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("ipc/windows: listen %s (tcp %s): %w", name, addr, err)
}
return l, nil
}
// platformDial connects to a named pipe on Windows.
func platformDial(name string) (net.Conn, error) {
addr := fmt.Sprintf("127.0.0.1:%d", pipeTCPPort(name))
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
return nil, fmt.Errorf("ipc/windows: dial %s (tcp %s): %w", name, addr, err)
}
return conn, nil
}
// pipeTCPPort maps pipe names to TCP ports for Windows dev fallback.
// In production, these would be actual Windows named pipes.
func pipeTCPPort(name string) int {
ports := map[string]int{
"soc-ingest-to-correlate": 19751,
"soc-correlate-to-respond": 19752,
}
if p, ok := ports[name]; ok {
return p
}
// Hash-based fallback for unknown names.
h := 19700
for _, c := range name {
h = (h*31 + int(c)) % 1000
}
return 19700 + h
}