diff --git a/backend/modules.go b/backend/modules.go index 4e5e94c8f..a2d25d879 100644 --- a/backend/modules.go +++ b/backend/modules.go @@ -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" @@ -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 } @@ -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, @@ -244,6 +247,7 @@ func initModules(db *gorm.DB, cfg *config) *modules { incidents: incidentsMod, notifications: notificationsMod, adaudit: adauditMod, + threatIntel: threatintelMod, mcp: mcpModule, signer: signer, } diff --git a/backend/modules/threatintel/handler/proxy.go b/backend/modules/threatintel/handler/proxy.go new file mode 100644 index 000000000..9d77944ac --- /dev/null +++ b/backend/modules/threatintel/handler/proxy.go @@ -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) +} diff --git a/backend/modules/threatintel/handler/proxy_test.go b/backend/modules/threatintel/handler/proxy_test.go new file mode 100644 index 000000000..0d0de0aed --- /dev/null +++ b/backend/modules/threatintel/handler/proxy_test.go @@ -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")) + } +} diff --git a/backend/modules/threatintel/internal/instanceconfig.go b/backend/modules/threatintel/internal/instanceconfig.go new file mode 100644 index 000000000..9dc1f03c1 --- /dev/null +++ b/backend/modules/threatintel/internal/instanceconfig.go @@ -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 +} diff --git a/backend/modules/threatintel/module.go b/backend/modules/threatintel/module.go new file mode 100644 index 000000000..78dfe85e7 --- /dev/null +++ b/backend/modules/threatintel/module.go @@ -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) {} diff --git a/backend/modules/threatintel/routes.go b/backend/modules/threatintel/routes.go new file mode 100644 index 000000000..bbbf2e001 --- /dev/null +++ b/backend/modules/threatintel/routes.go @@ -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) +} diff --git a/backend/server.go b/backend/server.go index 781582de5..5a6c7b5b3 100644 --- a/backend/server.go +++ b/backend/server.go @@ -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" ) @@ -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 {