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
4 changes: 4 additions & 0 deletions backend/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
opensearchgw "github.com/utmstack/utmstack/backend/modules/opensearch"
"github.com/utmstack/utmstack/backend/modules/soar"
"github.com/utmstack/utmstack/backend/modules/socai"
"github.com/utmstack/utmstack/backend/modules/threatintel"
"github.com/utmstack/utmstack/backend/pkg/agentmanager"
"github.com/utmstack/utmstack/backend/pkg/env"
jwtpkg "github.com/utmstack/utmstack/backend/pkg/jwt"
Expand Down Expand Up @@ -74,6 +75,7 @@ type modules struct {
notifications *notifications.Module
socAI *socai.Module
adaudit *adaudit.Module
threatIntel *threatintel.Module
mcp *mcpmod.Module
signer *jwtpkg.Signer
}
Expand Down Expand Up @@ -191,6 +193,7 @@ func initModules(db *gorm.DB, cfg *config) *modules {
auditMod.Logger(),
)
adauditMod := adaudit.NewModule(db)
threatintelMod := threatintel.NewModule(env.String("UPDATES_DIR", "/updates", false))

//loaded after opensearch has fully loaded
integrationsMod := integrations.NewModule(db, cipher,
Expand Down Expand Up @@ -244,6 +247,7 @@ func initModules(db *gorm.DB, cfg *config) *modules {
incidents: incidentsMod,
notifications: notificationsMod,
adaudit: adauditMod,
threatIntel: threatintelMod,
mcp: mcpModule,
signer: signer,
}
Expand Down
142 changes: 142 additions & 0 deletions backend/modules/threatintel/handler/proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package handler

import (
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/textproto"
"net/url"
"time"

"github.com/gin-gonic/gin"
"github.com/utmstack/utmstack/backend/modules/threatintel/internal"
)

// ReverseProxyHandler creates a reverse proxy that maps a local route to a CM proxy target.
type ReverseProxyHandler struct {
targetPath string
}

// NewReverseProxyHandler creates a handler for a specific route mapping.
func NewReverseProxyHandler(targetPath string) *ReverseProxyHandler {
return &ReverseProxyHandler{targetPath: targetPath}
}

// Handle is the Gin handler that proxies the request.
func (h *ReverseProxyHandler) Handle(c *gin.Context) {
cfg := internal.Get()
if cfg == nil || cfg.Server == "" || cfg.InstanceID == "" || cfg.InstanceKey == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "instance not configured"})
return
}

targetURL, err := url.Parse(cfg.Server)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "invalid CM server URL"})
return
}

// Create reverse proxy with custom director
proxy := &httputil.ReverseProxy{
Director: h.directorFunc(cfg, targetURL),
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(`{"error":"upstream service error"}`))
},
}

proxy.ServeHTTP(c.Writer, c.Request)
}

// directorFunc returns a Director function that rewrites the request.
func (h *ReverseProxyHandler) directorFunc(cfg *internal.InstanceConfig, targetURL *url.URL) func(*http.Request) {
return func(req *http.Request) {
// Set scheme and host from CM proxy
req.URL.Scheme = targetURL.Scheme
req.URL.Host = targetURL.Host

// Rewrite path to the target on CM proxy
req.URL.Path = h.targetPath
// Preserve query string (already in req.URL.RawQuery)

req.RequestURI = "" // must be cleared for the reverse proxy
req.Host = targetURL.Host

// Clear auth headers
req.Header.Del("Authorization")
req.Header.Del("id")
req.Header.Del("key")

// Set instance credentials
req.Header.Set("id", cfg.InstanceID)
req.Header.Set("key", cfg.InstanceKey)
}
}

// HandleUsageEndpoint is a special handler for the /usage endpoint which has a different prefix structure.
func (h *ReverseProxyHandler) HandleUsageEndpoint(c *gin.Context) {
cfg := internal.Get()
if cfg == nil || cfg.Server == "" || cfg.InstanceID == "" || cfg.InstanceKey == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "instance not configured"})
return
}

// Build the full target URL for usage endpoint (no /proxy/api prefix, just /proxy/usage)
fullURL := fmt.Sprintf("%s%s", cfg.Server, h.targetPath)
targetURL, err := url.Parse(fullURL)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "invalid target URL"})
return
}

// Create and execute the request
req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL.String(), c.Request.Body)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "failed to create request"})
return
}

allowed := map[string]struct{}{
"Accept": {},
"Content-Type": {},
"User-Agent": {},
"X-Request-Id": {},
"X-Correlation-Id": {},
}

// Copy headers
for k, values := range c.Request.Header {
canonical := textproto.CanonicalMIMEHeaderKey(k)
if _, ok := allowed[canonical]; ok {
for _, v := range values {
req.Header.Add(canonical, v)
}
}
}

// Set instance credentials
req.Header.Set("id", cfg.InstanceID)
req.Header.Set("key", cfg.InstanceKey)

// Execute request
client := &http.Client{
Timeout: 20* time.Second,
}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream service error"})
return
}
defer resp.Body.Close()

// Copy response
for k, v := range resp.Header {
for _, vv := range v {
c.Header(k, vv)
}
}
c.Status(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
}
65 changes: 65 additions & 0 deletions backend/modules/threatintel/handler/proxy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package handler

import (
"net/http/httptest"
"net/url"
"strings"
"testing"

"github.com/utmstack/utmstack/backend/modules/threatintel/internal"
)

func TestDirectorRewritesPathAndHeaders(t *testing.T) {
// Setup instance config
cfg := &internal.InstanceConfig{
Server: "https://example.com",
InstanceID: "test-id-123",
InstanceKey: "test-key-456",
}

// Create a test request
req := httptest.NewRequest("GET", "http://localhost:8080/api/v1/threat-intel/entity/foo?param=value", nil)
req.Header.Set("Authorization", "Bearer token123")
req.Header.Set("id", "old-id")
req.Header.Set("key", "old-key")

// Create a reverse proxy handler
h := NewReverseProxyHandler("/proxy/api/analytics/v1/entity/foo/details")

// Parse the target URL
targetURL, err := url.Parse("https://cm.example.com")
if err != nil {
t.Fatal(err)
}

// Get the director function
director := h.directorFunc(cfg, targetURL)

// Apply director
director(req)

// Verify URL rewrite
if req.URL.Scheme != "https" {
t.Fatalf("expected scheme https, got %s", req.URL.Scheme)
}
if req.URL.Host != "cm.example.com" {
t.Fatalf("expected host cm.example.com, got %s", req.URL.Host)
}
if req.URL.Path != "/proxy/api/analytics/v1/entity/foo/details" {
t.Fatalf("expected path /proxy/api/analytics/v1/entity/foo/details, got %s", req.URL.Path)
}
if !strings.Contains(req.URL.RawQuery, "param=value") {
t.Fatalf("expected query param preserved, got %s", req.URL.RawQuery)
}

// Verify headers
if req.Header.Get("Authorization") != "" {
t.Fatal("Authorization header should be deleted")
}
if req.Header.Get("id") != "test-id-123" {
t.Fatalf("expected id header test-id-123, got %s", req.Header.Get("id"))
}
if req.Header.Get("key") != "test-key-456" {
t.Fatalf("expected key header test-key-456, got %s", req.Header.Get("key"))
}
}
47 changes: 47 additions & 0 deletions backend/modules/threatintel/internal/instanceconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package internal

import (
"os"
"path/filepath"
"sync"

"github.com/threatwinds/go-sdk/catcher"
"gopkg.in/yaml.v3"
)

type InstanceConfig struct {
Server string `yaml:"server"`
InstanceID string `yaml:"instance_id"`
InstanceKey string `yaml:"instance_key"`
}

var (
instanceConfig *InstanceConfig
configOnce sync.Once
)

// LoadInstanceConfig reads instance-config.yml from updatesDir and caches it.
// Returns an error if the file is missing or cannot be parsed.
// Returns nil and valid config only if all fields are present.
func LoadInstanceConfig(updatesDir string) (*InstanceConfig, error) {
var err error
configOnce.Do(func() {
data, err := os.ReadFile(filepath.Join(updatesDir, "instance-config.yml"))
if err != nil {
_ = catcher.Error("error loading instance configuration",err,map[string]any{})
return
}
var cfg InstanceConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
_ = catcher.Error("error loading instance configuration",err,map[string]any{})
return
}
instanceConfig = &cfg
})
return instanceConfig, err
}

// Get returns the cached instance config, or nil if not loaded.
func Get() *InstanceConfig {
return instanceConfig
}
24 changes: 24 additions & 0 deletions backend/modules/threatintel/module.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package threatintel

import (
"context"

"github.com/threatwinds/go-sdk/catcher"
"github.com/utmstack/utmstack/backend/modules/threatintel/internal"
)

type Module struct {
updatesDir string
}

func NewModule(updatesDir string) *Module {
m := &Module{updatesDir: updatesDir}

if _, err := internal.LoadInstanceConfig(updatesDir); err != nil {
catcher.Warn("threatintel: failed to load instance config", map[string]any{"error": err.Error()})
}

return m
}

func (m *Module) Start(ctx context.Context) {}
49 changes: 49 additions & 0 deletions backend/modules/threatintel/routes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package threatintel

import (
"strings"

"github.com/gin-gonic/gin"
"github.com/utmstack/utmstack/backend/modules/threatintel/handler"
)

func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) {
g := api.Group("/threat-intel", userAuth)

// POST /api/v1/threat-intel/search → /proxy/api/search/v1/entities/simple
searchHandler := handler.NewReverseProxyHandler("/proxy/api/search/v1/entities/simple")
g.POST("/search", searchHandler.Handle)

// GET /api/v1/threat-intel/entity/:id → /proxy/api/analytics/v1/entity/{id}/details
g.GET("/entity/:id", func(c *gin.Context) {
id := c.Param("id")
targetPath := "/proxy/api/analytics/v1/entity/" + sanitizeId(id) + "/details"
h := handler.NewReverseProxyHandler(targetPath)
h.Handle(c)
})

// GET /api/v1/threat-intel/entity/:id/relations → /proxy/api/analytics/v1/entity/{id}/relations
g.GET("/entity/:id/relations", func(c *gin.Context) {
id := c.Param("id")
targetPath := "/proxy/api/analytics/v1/entity/" + sanitizeId(id) + "/relations"
h := handler.NewReverseProxyHandler(targetPath)
h.Handle(c)
})

// GET /api/v1/threat-intel/feeds → /proxy/api/feeds/v1/list
feedsHandler := handler.NewReverseProxyHandler("/proxy/api/feeds/v1/list")
g.GET("/feeds", feedsHandler.Handle)

// POST /api/v1/threat-intel/ai/chat → /proxy/api/ai/v1/chat/completions
chatHandler := handler.NewReverseProxyHandler("/proxy/api/ai/v1/chat/completions")
g.POST("/ai/chat", chatHandler.Handle)

// GET /api/v1/threat-intel/usage → /proxy/usage (special endpoint, no /api prefix)
usageHandler := handler.NewReverseProxyHandler("/proxy/usage")
g.GET("/usage", usageHandler.HandleUsageEndpoint)
}

func sanitizeId(id string) string{
replacer := strings.NewReplacer(".", "", "\\", "","/","")
return replacer.Replace(id)
}
2 changes: 2 additions & 0 deletions backend/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
opensearchgw "github.com/utmstack/utmstack/backend/modules/opensearch"
"github.com/utmstack/utmstack/backend/modules/soar"
"github.com/utmstack/utmstack/backend/modules/socai"
"github.com/utmstack/utmstack/backend/modules/threatintel"
"github.com/utmstack/utmstack/backend/pkg/http/middleware"
)

Expand Down Expand Up @@ -137,6 +138,7 @@ func registerRoutes(engine *gin.Engine, m *modules, cfg *config) {
incidents.RegisterRoutes(api, m.incidents, userAuth)
notifications.RegisterRoutes(api, m.notifications, userAuth)
socai.RegisterRoutes(api, m.socAI, userAuth)
threatintel.RegisterRoutes(api, m.threatIntel, userAuth)
datasources.RegisterRoutes(api, m.datasources, userAuth)
adaudit.RegisterRoutes(api, m.adaudit, userAuth)
if m.mcp != nil {
Expand Down
Loading