Skip to content

Commit 99c85dd

Browse files
feat[backend](threat_intelligense): added custommer manager connection module and threat intelligence proxy module
1 parent b41cdef commit 99c85dd

7 files changed

Lines changed: 308 additions & 0 deletions

File tree

backend/modules.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
opensearchgw "github.com/utmstack/utmstack/backend/modules/opensearch"
3838
"github.com/utmstack/utmstack/backend/modules/soar"
3939
"github.com/utmstack/utmstack/backend/modules/socai"
40+
"github.com/utmstack/utmstack/backend/modules/threatintel"
4041
"github.com/utmstack/utmstack/backend/pkg/agentmanager"
4142
"github.com/utmstack/utmstack/backend/pkg/env"
4243
jwtpkg "github.com/utmstack/utmstack/backend/pkg/jwt"
@@ -74,6 +75,7 @@ type modules struct {
7475
notifications *notifications.Module
7576
socAI *socai.Module
7677
adaudit *adaudit.Module
78+
threatIntel *threatintel.Module
7779
mcp *mcpmod.Module
7880
signer *jwtpkg.Signer
7981
}
@@ -191,6 +193,7 @@ func initModules(db *gorm.DB, cfg *config) *modules {
191193
auditMod.Logger(),
192194
)
193195
adauditMod := adaudit.NewModule(db)
196+
threatintelMod := threatintel.NewModule(env.String("UPDATES_DIR", "/updates", false))
194197

195198
//loaded after opensearch has fully loaded
196199
integrationsMod := integrations.NewModule(db, cipher,
@@ -244,6 +247,7 @@ func initModules(db *gorm.DB, cfg *config) *modules {
244247
incidents: incidentsMod,
245248
notifications: notificationsMod,
246249
adaudit: adauditMod,
250+
threatIntel: threatintelMod,
247251
mcp: mcpModule,
248252
signer: signer,
249253
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package handler
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"net/http/httputil"
8+
"net/url"
9+
10+
"github.com/gin-gonic/gin"
11+
"github.com/utmstack/utmstack/backend/modules/threatintel/internal"
12+
)
13+
14+
// ReverseProxyHandler creates a reverse proxy that maps a local route to a CM proxy target.
15+
type ReverseProxyHandler struct {
16+
targetPath string
17+
}
18+
19+
// NewReverseProxyHandler creates a handler for a specific route mapping.
20+
func NewReverseProxyHandler(targetPath string) *ReverseProxyHandler {
21+
return &ReverseProxyHandler{targetPath: targetPath}
22+
}
23+
24+
// Handle is the Gin handler that proxies the request.
25+
func (h *ReverseProxyHandler) Handle(c *gin.Context) {
26+
cfg := internal.Get()
27+
if cfg == nil || cfg.Server == "" || cfg.InstanceID == "" || cfg.InstanceKey == "" {
28+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "instance not configured"})
29+
return
30+
}
31+
32+
targetURL, err := url.Parse(cfg.Server)
33+
if err != nil {
34+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "invalid CM server URL"})
35+
return
36+
}
37+
38+
// Create reverse proxy with custom director
39+
proxy := &httputil.ReverseProxy{
40+
Director: h.directorFunc(cfg, targetURL),
41+
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
42+
w.Header().Set("Content-Type", "application/json")
43+
w.WriteHeader(http.StatusBadGateway)
44+
w.Write([]byte(`{"error":"upstream service error"}`))
45+
},
46+
}
47+
48+
proxy.ServeHTTP(c.Writer, c.Request)
49+
}
50+
51+
// directorFunc returns a Director function that rewrites the request.
52+
func (h *ReverseProxyHandler) directorFunc(cfg *internal.InstanceConfig, targetURL *url.URL) func(*http.Request) {
53+
return func(req *http.Request) {
54+
// Set scheme and host from CM proxy
55+
req.URL.Scheme = targetURL.Scheme
56+
req.URL.Host = targetURL.Host
57+
58+
// Rewrite path to the target on CM proxy
59+
req.URL.Path = h.targetPath
60+
// Preserve query string (already in req.URL.RawQuery)
61+
62+
req.RequestURI = "" // must be cleared for the reverse proxy
63+
req.Host = targetURL.Host
64+
65+
// Clear auth headers
66+
req.Header.Del("Authorization")
67+
req.Header.Del("id")
68+
req.Header.Del("key")
69+
70+
// Set instance credentials
71+
req.Header.Set("id", cfg.InstanceID)
72+
req.Header.Set("key", cfg.InstanceKey)
73+
}
74+
}
75+
76+
// HandleUsageEndpoint is a special handler for the /usage endpoint which has a different prefix structure.
77+
func (h *ReverseProxyHandler) HandleUsageEndpoint(c *gin.Context) {
78+
cfg := internal.Get()
79+
if cfg == nil || cfg.Server == "" || cfg.InstanceID == "" || cfg.InstanceKey == "" {
80+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "instance not configured"})
81+
return
82+
}
83+
84+
// Build the full target URL for usage endpoint (no /proxy/api prefix, just /proxy/usage)
85+
fullURL := fmt.Sprintf("%s%s", cfg.Server, h.targetPath)
86+
targetURL, err := url.Parse(fullURL)
87+
if err != nil {
88+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "invalid target URL"})
89+
return
90+
}
91+
92+
// Create and execute the request
93+
req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL.String(), c.Request.Body)
94+
if err != nil {
95+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "failed to create request"})
96+
return
97+
}
98+
99+
// Copy headers
100+
for k, v := range c.Request.Header {
101+
if k != "Authorization" && k != "Id" && k != "Key" {
102+
req.Header[k] = v
103+
}
104+
}
105+
106+
// Set instance credentials
107+
req.Header.Set("id", cfg.InstanceID)
108+
req.Header.Set("key", cfg.InstanceKey)
109+
110+
// Execute request
111+
client := &http.Client{}
112+
resp, err := client.Do(req)
113+
if err != nil {
114+
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream service error"})
115+
return
116+
}
117+
defer resp.Body.Close()
118+
119+
// Copy response
120+
for k, v := range resp.Header {
121+
for _, vv := range v {
122+
c.Header(k, vv)
123+
}
124+
}
125+
c.Status(resp.StatusCode)
126+
io.Copy(c.Writer, resp.Body)
127+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package handler
2+
3+
import (
4+
"net/http/httptest"
5+
"net/url"
6+
"strings"
7+
"testing"
8+
9+
"github.com/utmstack/utmstack/backend/modules/threatintel/internal"
10+
)
11+
12+
func TestDirectorRewritesPathAndHeaders(t *testing.T) {
13+
// Setup instance config
14+
cfg := &internal.InstanceConfig{
15+
Server: "https://example.com",
16+
InstanceID: "test-id-123",
17+
InstanceKey: "test-key-456",
18+
}
19+
20+
// Create a test request
21+
req := httptest.NewRequest("GET", "http://localhost:8080/api/v1/threat-intel/entity/foo?param=value", nil)
22+
req.Header.Set("Authorization", "Bearer token123")
23+
req.Header.Set("id", "old-id")
24+
req.Header.Set("key", "old-key")
25+
26+
// Create a reverse proxy handler
27+
h := NewReverseProxyHandler("/proxy/api/analytics/v1/entity/foo/details")
28+
29+
// Parse the target URL
30+
targetURL, err := url.Parse("https://cm.example.com")
31+
if err != nil {
32+
t.Fatal(err)
33+
}
34+
35+
// Get the director function
36+
director := h.directorFunc(cfg, targetURL)
37+
38+
// Apply director
39+
director(req)
40+
41+
// Verify URL rewrite
42+
if req.URL.Scheme != "https" {
43+
t.Fatalf("expected scheme https, got %s", req.URL.Scheme)
44+
}
45+
if req.URL.Host != "cm.example.com" {
46+
t.Fatalf("expected host cm.example.com, got %s", req.URL.Host)
47+
}
48+
if req.URL.Path != "/proxy/api/analytics/v1/entity/foo/details" {
49+
t.Fatalf("expected path /proxy/api/analytics/v1/entity/foo/details, got %s", req.URL.Path)
50+
}
51+
if !strings.Contains(req.URL.RawQuery, "param=value") {
52+
t.Fatalf("expected query param preserved, got %s", req.URL.RawQuery)
53+
}
54+
55+
// Verify headers
56+
if req.Header.Get("Authorization") != "" {
57+
t.Fatal("Authorization header should be deleted")
58+
}
59+
if req.Header.Get("id") != "test-id-123" {
60+
t.Fatalf("expected id header test-id-123, got %s", req.Header.Get("id"))
61+
}
62+
if req.Header.Get("key") != "test-key-456" {
63+
t.Fatalf("expected key header test-key-456, got %s", req.Header.Get("key"))
64+
}
65+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package internal
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"sync"
7+
8+
"gopkg.in/yaml.v3"
9+
)
10+
11+
type InstanceConfig struct {
12+
Server string `yaml:"server"`
13+
InstanceID string `yaml:"instance_id"`
14+
InstanceKey string `yaml:"instance_key"`
15+
}
16+
17+
var (
18+
instanceConfig *InstanceConfig
19+
configOnce sync.Once
20+
)
21+
22+
// LoadInstanceConfig reads instance-config.yml from updatesDir and caches it.
23+
// Returns an error if the file is missing or cannot be parsed.
24+
// Returns nil and valid config only if all fields are present.
25+
func LoadInstanceConfig(updatesDir string) (*InstanceConfig, error) {
26+
var err error
27+
configOnce.Do(func() {
28+
data, err := os.ReadFile(filepath.Join(updatesDir, "instance-config.yml"))
29+
if err != nil {
30+
return
31+
}
32+
var cfg InstanceConfig
33+
if err := yaml.Unmarshal(data, &cfg); err != nil {
34+
return
35+
}
36+
instanceConfig = &cfg
37+
})
38+
return instanceConfig, err
39+
}
40+
41+
// Get returns the cached instance config, or nil if not loaded.
42+
func Get() *InstanceConfig {
43+
return instanceConfig
44+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package threatintel
2+
3+
import (
4+
"context"
5+
6+
"github.com/threatwinds/go-sdk/catcher"
7+
"github.com/utmstack/utmstack/backend/modules/threatintel/internal"
8+
)
9+
10+
type Module struct {
11+
updatesDir string
12+
}
13+
14+
func NewModule(updatesDir string) *Module {
15+
m := &Module{updatesDir: updatesDir}
16+
17+
if _, err := internal.LoadInstanceConfig(updatesDir); err != nil {
18+
catcher.Warn("threatintel: failed to load instance config", map[string]any{"error": err.Error()})
19+
}
20+
21+
return m
22+
}
23+
24+
func (m *Module) Start(ctx context.Context) {}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package threatintel
2+
3+
import (
4+
"github.com/gin-gonic/gin"
5+
"github.com/utmstack/utmstack/backend/modules/threatintel/handler"
6+
)
7+
8+
func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) {
9+
g := api.Group("/threat-intel", userAuth)
10+
11+
// POST /api/v1/threat-intel/search → /proxy/api/search/v1/entities/simple
12+
searchHandler := handler.NewReverseProxyHandler("/proxy/api/search/v1/entities/simple")
13+
g.POST("/search", searchHandler.Handle)
14+
15+
// GET /api/v1/threat-intel/entity/:id → /proxy/api/analytics/v1/entity/{id}/details
16+
g.GET("/entity/:id", func(c *gin.Context) {
17+
id := c.Param("id")
18+
targetPath := "/proxy/api/analytics/v1/entity/" + id + "/details"
19+
h := handler.NewReverseProxyHandler(targetPath)
20+
h.Handle(c)
21+
})
22+
23+
// GET /api/v1/threat-intel/entity/:id/relations → /proxy/api/analytics/v1/entity/{id}/relations
24+
g.GET("/entity/:id/relations", func(c *gin.Context) {
25+
id := c.Param("id")
26+
targetPath := "/proxy/api/analytics/v1/entity/" + id + "/relations"
27+
h := handler.NewReverseProxyHandler(targetPath)
28+
h.Handle(c)
29+
})
30+
31+
// GET /api/v1/threat-intel/feeds → /proxy/api/feeds/v1/list
32+
feedsHandler := handler.NewReverseProxyHandler("/proxy/api/feeds/v1/list")
33+
g.GET("/feeds", feedsHandler.Handle)
34+
35+
// POST /api/v1/threat-intel/ai/chat → /proxy/api/ai/v1/chat/completions
36+
chatHandler := handler.NewReverseProxyHandler("/proxy/api/ai/v1/chat/completions")
37+
g.POST("/ai/chat", chatHandler.Handle)
38+
39+
// GET /api/v1/threat-intel/usage → /proxy/usage (special endpoint, no /api prefix)
40+
usageHandler := handler.NewReverseProxyHandler("/proxy/usage")
41+
g.GET("/usage", usageHandler.HandleUsageEndpoint)
42+
}

backend/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
opensearchgw "github.com/utmstack/utmstack/backend/modules/opensearch"
3333
"github.com/utmstack/utmstack/backend/modules/soar"
3434
"github.com/utmstack/utmstack/backend/modules/socai"
35+
"github.com/utmstack/utmstack/backend/modules/threatintel"
3536
"github.com/utmstack/utmstack/backend/pkg/http/middleware"
3637
)
3738

@@ -137,6 +138,7 @@ func registerRoutes(engine *gin.Engine, m *modules, cfg *config) {
137138
incidents.RegisterRoutes(api, m.incidents, userAuth)
138139
notifications.RegisterRoutes(api, m.notifications, userAuth)
139140
socai.RegisterRoutes(api, m.socAI, userAuth)
141+
threatintel.RegisterRoutes(api, m.threatIntel, userAuth)
140142
datasources.RegisterRoutes(api, m.datasources, userAuth)
141143
adaudit.RegisterRoutes(api, m.adaudit, userAuth)
142144
if m.mcp != nil {

0 commit comments

Comments
 (0)