mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-05-04 00:32:37 +02:00
Release prep: 54 engines, self-hosted signatures, i18n, dashboard updates
This commit is contained in:
parent
694e32be26
commit
41cbfd6e0a
178 changed files with 36008 additions and 399 deletions
286
internal/infrastructure/ipc/ipc.go
Normal file
286
internal/infrastructure/ipc/ipc.go
Normal 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)
|
||||
}
|
||||
172
internal/infrastructure/ipc/ipc_test.go
Normal file
172
internal/infrastructure/ipc/ipc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
50
internal/infrastructure/ipc/ipc_unix.go
Normal file
50
internal/infrastructure/ipc/ipc_unix.go
Normal 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
|
||||
}
|
||||
53
internal/infrastructure/ipc/ipc_windows.go
Normal file
53
internal/infrastructure/ipc/ipc_windows.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue