Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ RUN mkdir -p /app/data /var/run/raven /etc/raven /var/spool/postfix/private && \
RUN echo '#!/bin/sh' > /app/start.sh && \
echo 'echo "Starting Raven services..."' >> /app/start.sh && \
echo 'echo "Starting SASL authentication service..."' >> /app/start.sh && \
echo './raven-sasl -socket /var/spool/postfix/private/auth -config /etc/raven/raven.yaml &' >> /app/start.sh && \
echo './raven-sasl -tcp :12345 -config /etc/raven/raven.yaml &' >> /app/start.sh && \
echo 'SASL_PID=$!' >> /app/start.sh && \
echo 'echo "SASL service started with PID: $SASL_PID"' >> /app/start.sh && \
echo 'echo "SASL service started with PID: $SASL_PID (TCP :12345)"' >> /app/start.sh && \
echo 'sleep 1' >> /app/start.sh && \
echo 'echo "Starting IMAP server..."' >> /app/start.sh && \
echo './imap-server -db ${DB_PATH:-/app/data/databases} &' >> /app/start.sh && \
Expand All @@ -64,7 +64,7 @@ RUN echo '#!/bin/sh' > /app/start.sh && \
echo 'echo ""' >> /app/start.sh && \
echo 'echo "==================================="' >> /app/start.sh && \
echo 'echo "All Raven services started:"' >> /app/start.sh && \
echo 'echo " SASL Auth: PID $SASL_PID"' >> /app/start.sh && \
echo 'echo " SASL Auth: PID $SASL_PID (TCP :12345)"' >> /app/start.sh && \
echo 'echo " IMAP: PID $IMAP_PID"' >> /app/start.sh && \
echo 'echo " LMTP: PID $DELIVERY_PID"' >> /app/start.sh && \
echo 'echo " DB Path: ${DB_PATH:-/app/data/databases}"' >> /app/start.sh && \
Expand All @@ -79,15 +79,15 @@ USER ravenuser
# Expose ports for services
# IMAP: 143 (plaintext), 993 (TLS)
# LMTP: 24
# SASL: Uses Unix socket (no port needed)
EXPOSE 143 993 24
# SASL: 12345 (TCP)
EXPOSE 143 993 24 12345

# Set environment variables - use directory path for DBManager
ENV DB_PATH=/app/data/databases

# Health check - check IMAP and LMTP services (SASL uses Unix socket)
# Health check - check IMAP, LMTP, and SASL services
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD nc -z localhost 143 && nc -z localhost 24 || exit 1
CMD nc -z localhost 143 && nc -z localhost 24 && nc -z localhost 12345 || exit 1

# Start all services
ENTRYPOINT ["/app/start.sh"]
4 changes: 3 additions & 1 deletion cmd/sasl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
func main() {
// Command-line flags
socketPath := flag.String("socket", "/var/spool/postfix/private/auth", "Path to UNIX socket")
tcpAddr := flag.String("tcp", ":12345", "TCP address to bind (e.g., 127.0.0.1:12345 or :12345)")
configPath := flag.String("config", "/etc/raven/raven.yaml", "Path to configuration file")
flag.Parse()

Expand All @@ -37,12 +38,13 @@ func main() {

log.Printf("Configuration loaded:")
log.Printf(" Socket path: %s", *socketPath)
log.Printf(" TCP address: %s", *tcpAddr)
log.Printf(" Config path: %s", *configPath)
log.Printf(" Domain: %s", cfg.Domain)
log.Printf(" Auth URL: %s", cfg.AuthServerURL)

// Create SASL server
server := sasl.NewServer(*socketPath, cfg.AuthServerURL, cfg.Domain)
server := sasl.NewServer(*socketPath, *tcpAddr, cfg.AuthServerURL, cfg.Domain)

// Setup graceful shutdown
sigChan := make(chan os.Signal, 1)
Expand Down
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ cd raven
docker build -t raven .
docker run -d --rm \
--name raven \
-p 143:143 -p 993:993 -p 24:24 \
-p 143:143 -p 993:993 -p 24:24 -p 12345:12345 \
-v $(pwd)/config:/etc/raven \
-v $(pwd)/data:/app/data \
-v $(pwd)/certs:/certs \
Expand All @@ -89,6 +89,7 @@ The server will start and listen on:
- **Port 143** - IMAP
- **Port 993** - IMAPS
- **Port 24** - LMTP
- **Port 12345** - SASL (TCP)

Connect using any IMAP client to start managing your emails.

Expand Down
140 changes: 118 additions & 22 deletions internal/sasl/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package sasl

import (
"bufio"
"context"
"crypto/tls"
"encoding/base64"
"fmt"
Expand All @@ -16,20 +17,23 @@ import (

// Server represents a SASL authentication server
type Server struct {
socketPath string
authURL string
domain string
listener net.Listener
listenerMu sync.RWMutex
wg sync.WaitGroup
shutdown chan struct{}
shutdownOnce sync.Once
socketPath string
tcpAddr string
authURL string
domain string
unixListener net.Listener
tcpListener net.Listener
mu sync.Mutex
wg sync.WaitGroup
shutdown chan struct{}
shutdownOnce sync.Once
}

// NewServer creates a new SASL authentication server
func NewServer(socketPath, authURL, domain string) *Server {
func NewServer(socketPath, tcpAddr, authURL, domain string) *Server {
return &Server{
socketPath: socketPath,
tcpAddr: tcpAddr,
authURL: authURL,
domain: domain,
shutdown: make(chan struct{}),
Expand All @@ -38,6 +42,30 @@ func NewServer(socketPath, authURL, domain string) *Server {

// Start starts the SASL server
func (s *Server) Start() error {
log.Println("Starting SASL server...")

// Start UNIX socket listener if configured
if s.socketPath != "" {
if err := s.startUnixListener(); err != nil {
return fmt.Errorf("failed to start UNIX listener: %w", err)
}
}

// Start TCP listener if configured
if s.tcpAddr != "" {
if err := s.startTCPListener(); err != nil {
return fmt.Errorf("failed to start TCP listener: %w", err)
}
}

// Wait for all connections to finish
s.wg.Wait()
log.Println("All connections closed")
return nil
}

// startUnixListener starts listening on a UNIX socket
func (s *Server) startUnixListener() error {
// Remove existing socket file if it exists
if err := os.RemoveAll(s.socketPath); err != nil {
return fmt.Errorf("failed to remove existing socket: %v", err)
Expand All @@ -48,9 +76,10 @@ func (s *Server) Start() error {
if err != nil {
return fmt.Errorf("failed to create Unix socket: %v", err)
}
s.listenerMu.Lock()
s.listener = listener
s.listenerMu.Unlock()

s.mu.Lock()
s.unixListener = listener
s.mu.Unlock()

// Set socket permissions (0666 so Postfix can access it)
// #nosec G302 -- Unix socket needs world read/write for Postfix access
Expand All @@ -63,25 +92,64 @@ func (s *Server) Start() error {
log.Printf("Using authentication URL: %s", s.authURL)
log.Printf("Domain: %s", s.domain)

// Accept connections
s.wg.Add(1)
go s.acceptConnections(listener, "unix")

return nil
}

// startTCPListener starts listening on a TCP address
func (s *Server) startTCPListener() error {
// Configure TCP listener with keep-alive
lc := net.ListenConfig{
KeepAlive: 30 * time.Second, // Send keep-alive probes every 30 seconds
Control: nil,
}

listener, err := lc.Listen(context.Background(), "tcp", s.tcpAddr)
if err != nil {
return fmt.Errorf("failed to create TCP listener: %v", err)
}

s.mu.Lock()
s.tcpListener = listener
s.mu.Unlock()

log.Printf("SASL server listening on TCP: %s (with keep-alive enabled)", s.tcpAddr)
log.Printf("Using authentication URL: %s", s.authURL)
log.Printf("Domain: %s", s.domain)

s.wg.Add(1)
go s.acceptConnections(listener, "tcp")

return nil
}

// acceptConnections accepts incoming connections
func (s *Server) acceptConnections(listener net.Listener, listenerType string) {
defer s.wg.Done()

for {
select {
case <-s.shutdown:
return nil
log.Printf("Stopping %s listener...", listenerType)
return
default:
}

conn, err := listener.Accept()
if err != nil {
select {
case <-s.shutdown:
return nil
return
default:
log.Printf("Accept error: %v", err)
log.Printf("Accept error on %s listener: %v", listenerType, err)
continue
}
}

log.Printf("New %s connection from: %s", listenerType, conn.RemoteAddr())

s.wg.Add(1)
go s.handleConnection(conn)
}
Expand All @@ -91,15 +159,43 @@ func (s *Server) Start() error {
func (s *Server) Shutdown() error {
var err error
s.shutdownOnce.Do(func() {
s.mu.Lock()
defer s.mu.Unlock()

log.Println("Shutting down SASL server...")

// Signal shutdown
close(s.shutdown)
s.listenerMu.RLock()
listener := s.listener
s.listenerMu.RUnlock()
if listener != nil {
err = listener.Close()

// Close listeners
var errs []error

if s.unixListener != nil {
if closeErr := s.unixListener.Close(); closeErr != nil {
errs = append(errs, fmt.Errorf("error closing Unix listener: %w", closeErr))
}
// Clean up socket file
if s.socketPath != "" {
_ = os.Remove(s.socketPath)
}
}

if s.tcpListener != nil {
if closeErr := s.tcpListener.Close(); closeErr != nil {
errs = append(errs, fmt.Errorf("error closing TCP listener: %w", closeErr))
}
}

// Wait for all connections to finish (outside of lock)
s.mu.Unlock()
s.wg.Wait()
_ = os.Remove(s.socketPath)
s.mu.Lock()

if len(errs) > 0 {
err = fmt.Errorf("shutdown errors: %v", errs)
}

log.Println("SASL server shutdown complete")
})
return err
}
Expand Down
Loading
Loading