Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ profile.cov

# OS files
.DS_Store
forge-supervisor-bin
46 changes: 46 additions & 0 deletions forge-supervisor/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Build stage
FROM golang:1.21-alpine AS builder

# Install certificates for TLS and useradd
RUN apk add --no-cache ca-certificates

WORKDIR /build

# Copy go mod files
COPY go.mod go.sum ./

# Download dependencies
RUN go mod download

# Copy source code
COPY . .

# Create agent user (UID 1000) in builder — we'll copy its entry to scratch
RUN adduser -D -u 1000 -G 1000 agent

# Build static binary with netgo (no cgo)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w" \
-installsuffix netgo \
-tags netgo \
-o /usr/local/bin/forge-supervisor .

# Final stage — scratch image (no shell, minimal)
FROM scratch

# Copy certificates
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Copy supervisor binary
COPY --from=builder /usr/local/bin/forge-supervisor /usr/local/bin/forge-supervisor

# Copy passwd/group for UID 1000 (so 'id' and 'groups' work for the agent)
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group

# Create /etc/forge/ directory for policy file (mounted at runtime)
COPY --from=builder /etc/group /etc/group
RUN mkdir -p /etc/forge && touch /etc/forge/egress_allowlist.json

# forge-supervisor runs as PID 1 (UID 0), agent child runs as UID 1000 via exec.go
ENTRYPOINT ["/usr/local/bin/forge-supervisor"]
54 changes: 54 additions & 0 deletions forge-supervisor/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package main

import (
"encoding/json"
"log"
"os"
"sync"
"time"
)

// AuditEvent represents an audit log entry in NDJSON format.
type AuditEvent struct {
Timestamp time.Time `json:"timestamp"`
Action string `json:"action"` // "allowed", "denied", "exit"
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
PID int `json:"pid,omitempty"`
ExitCode int `json:"exit_code,omitempty"`
}

// AuditLogger writes NDJSON audit events to stdout.
type AuditLogger struct {
mu sync.Mutex
}

// NewAuditLogger creates a new AuditLogger.
func NewAuditLogger() *AuditLogger {
return &AuditLogger{}
}

// Log writes an audit event to stdout in NDJSON format.
func (a *AuditLogger) Log(event *AuditEvent) {
a.mu.Lock()
defer a.mu.Unlock()

data, err := json.Marshal(event)
if err != nil {
log.Printf("ERROR: marshal audit event: %v", err)
return
}

os.Stdout.Write(data)
os.Stdout.Write([]byte("\n"))
}

// LogExitEvent logs an agent exit event.
func (a *AuditLogger) LogExitEvent(pid, exitCode int) {
a.Log(&AuditEvent{
Timestamp: time.Now().UTC(),
Action: "exit",
PID: pid,
ExitCode: exitCode,
})
}
61 changes: 61 additions & 0 deletions forge-supervisor/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"fmt"
"log"
"os"
"os/exec"
"syscall"

"golang.org/x/sys/unix"
)

// ExecAgent forks and executes the agent process as UID 1000.
// The supervisor stays as UID 0 so its own traffic is NOT redirected
// by the iptables OUTPUT chain (which targets UID 1000 only).
// Returns the *os.Process of the child.
func ExecAgent(args []string) (*os.Process, error) {
path, err := exec.LookPath(args[0])
if err != nil {
return nil, fmt.Errorf("lookpath %q: %w", args[0], err)
}

cmd := exec.Command(path, args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

// Set UID/GID on the child — supervisor stays as UID 0.
// iptables redirects only UID 1000 traffic, so supervisor is unaffected.
cmd.SysProcAttr = &syscall.SysProcAttr{
Credential: &syscall.Credential{Uid: 1000, Gid: 1000},
Setsid: true,
// Setctty only when stdin is a TTY (containers may not have one)
Setctty: isStdinTTY(),
}

if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start: %w", err)
}

log.Printf("INFO: started agent (PID %d) as UID 1000: %s", cmd.Process.Pid, path)
return cmd.Process, nil
}

// ForwardSignal forwards a signal to the process.
func ForwardSignal(pid int, sig syscall.Signal) {
proc, err := os.FindProcess(pid)
if err != nil {
log.Printf("ERROR: find process %d: %v", pid, err)
return
}
if err := proc.Signal(sig); err != nil {
log.Printf("ERROR: signal %d to %d: %v", sig, pid, err)
}
}

// isStdinTTY returns true if stdin is a terminal.
func isStdinTTY() bool {
_, err := unix.IoctlGetTermios(int(os.Stdin.Fd()), unix.TIOCGWINSZ)
return err == nil
}
11 changes: 11 additions & 0 deletions forge-supervisor/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module github.com/initializ/forge/forge-supervisor

go 1.25.0

toolchain go1.25.0

require (
github.com/initializ/forge/forge-core v0.0.0
)

replace github.com/initializ/forge/forge-core => ../forge-core
72 changes: 72 additions & 0 deletions forge-supervisor/health.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package main

import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
)

// DenialEvent represents a single egress denial event.
type DenialEvent struct {
Timestamp time.Time `json:"timestamp"`
Host string `json:"host"`
Port int `json:"port"`
}

// DenialTracker stores denial events for the /denials endpoint.
type DenialTracker struct {
mu sync.RWMutex
denials []DenialEvent
}

// Add records a new denial event.
func (d *DenialTracker) Add(event DenialEvent) {
d.mu.Lock()
defer d.mu.Unlock()
d.denials = append(d.denials, event)
// Keep only the last 1000 denials
if len(d.denials) > 1000 {
d.denials = d.denials[len(d.denials)-1000:]
}
}

// GetAll returns all recorded denial events.
func (d *DenialTracker) GetAll() []DenialEvent {
d.mu.RLock()
defer d.mu.RUnlock()
result := make([]DenialEvent, len(d.denials))
copy(result, d.denials)
return result
}

// StartHealthEndpoints starts HTTP endpoints for health checks.
func StartHealthEndpoints(tracker *DenialTracker, port int) {
mux := http.NewServeMux()

mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})

mux.HandleFunc("/denials", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
denials := tracker.GetAll()
if err := json.NewEncoder(w).Encode(denials); err != nil {
log.Printf("ERROR: encode denials: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
})

addr := fmt.Sprintf("127.0.0.1:%d", port)
log.Printf("INFO: health endpoints listening on %s", addr)

go func() {
if err := http.ListenAndServe(addr, mux); err != nil && err != http.ErrServerClosed {
log.Printf("ERROR: health server: %v", err)
}
}()
}
54 changes: 54 additions & 0 deletions forge-supervisor/http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package main

import (
"bufio"
"io"
"net"
"strings"
)

// ExtractHTTPHost reads an HTTP request from the connection (using initialBytes
// as the start) to find the Host header. Returns consumed bytes (for replay)
// and the hostname.
func ExtractHTTPHost(initialBytes []byte, conn net.Conn) ([]byte, string) {
reader := bufio.NewReader(io.MultiReader(
strings.NewReader(string(initialBytes)),
conn,
))

// Read and consume request line
_, err := reader.ReadString('\n')
if err != nil {
return initialBytes, ""
}

// Read headers
for {
line, err := reader.ReadString('\n')
if err != nil {
return initialBytes, ""
}

// End of headers
if line == "\r\n" || line == "\n" {
break
}

// Host: header — line is "Host: value\r\n"
// "Host:" is 5 characters
if strings.HasPrefix(strings.ToLower(line), "host:") {
host := strings.TrimSpace(line[5:]) // Skip "Host:" (5 chars)
host = strings.TrimSuffix(host, "\r")
host = strings.ToLower(host)
// Remove port if present
if idx := strings.Index(host, ":"); idx != -1 {
host = host[:idx]
}
// Consume all bytes up to and including headers
consumed := append([]byte(line), []byte("\r\n")...)
return consumed, host
}
}

return initialBytes, ""
}
Loading