Skip to content

Commit 53551ae

Browse files
committed
feat(socai): add ThreatWinds as a zero-config default LLM provider
1 parent c095b30 commit 53551ae

19 files changed

Lines changed: 707 additions & 385 deletions

File tree

backend/modules.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,8 @@ func initModules(db *gorm.DB, cfg *config) *modules {
184184

185185
iamMod := iam.NewModule(authUsecase, userUsecase, roleUsecase, tfaUsecase, apiKeyUsecase, idpUsecase, samlUsecase, cfg.uploadDir)
186186
socAIMod := socai.NewModule(cfg.socAIBaseURL, cfg.internalKey, cipher,
187-
env.String("INTEGRATIONS_TENANT_DIR", "/workdir/pipeline", false))
187+
env.String("INTEGRATIONS_TENANT_DIR", "/workdir/pipeline", false),
188+
env.String("UPDATES_DIR", "/updates", false))
188189
incidentsMod := incidents.NewModule(
189190
db,
190191
incidents_connectors.NewNoopMailer(),

backend/modules/socai/module.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"github.com/utmstack/utmstack/backend/modules/socai/repository"
55
"github.com/utmstack/utmstack/backend/modules/socai/usecase"
66
"github.com/utmstack/utmstack/backend/modules/socai/verifier"
7+
"github.com/utmstack/utmstack/backend/pkg/instanceconfig"
78
"github.com/utmstack/utmstack/backend/pkg/secret"
89
)
910

@@ -12,10 +13,15 @@ type Module struct {
1213
config *usecase.ConfigService
1314
}
1415

15-
func NewModule(baseURL, internalKey string, cipher *secret.Cipher, pipelineDir string) *Module {
16+
func NewModule(baseURL, internalKey string, cipher *secret.Cipher, pipelineDir, updatesDir string) *Module {
17+
instanceconfig.Init(updatesDir)
18+
19+
config := usecase.NewConfigService(repository.NewConfigStore(pipelineDir), cipher, verifier.New())
20+
config.StartEnsureDefaultLoop()
21+
1622
return &Module{
1723
client: NewSocAIClient(baseURL, internalKey),
18-
config: usecase.NewConfigService(repository.NewConfigStore(pipelineDir), cipher, verifier.New()),
24+
config: config,
1925
}
2026
}
2127

backend/modules/socai/usecase/config.go

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,92 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"strings"
8+
"time"
79

10+
"github.com/threatwinds/go-sdk/catcher"
811
"github.com/utmstack/utmstack/backend/modules/socai/dto"
912
"github.com/utmstack/utmstack/backend/modules/socai/repository"
1013
"github.com/utmstack/utmstack/backend/modules/socai/verifier"
14+
"github.com/utmstack/utmstack/backend/pkg/instanceconfig"
1115
"github.com/utmstack/utmstack/backend/pkg/secret"
1216
)
1317

1418
var ErrVerificationFailed = errors.New("connection verification failed")
1519

20+
// threatWindsAIPath is the CM proxy path for the AI chat-completions endpoint
21+
// (see backend/modules/threatintel: same reverse-proxy target, same id/key auth).
22+
const threatWindsAIPath = "/proxy/api/ai/v1/chat/completions"
23+
const threatWindsDefaultModel = "silas-1.0"
24+
25+
var ErrInstanceNotRegistered = errors.New("instance not registered yet — cannot use the ThreatWinds provider")
26+
27+
const ensureDefaultRetryInterval = 30 * time.Second
28+
29+
// StartEnsureDefaultLoop provisions the default ThreatWinds config in the
30+
// background, retrying until it succeeds. A fresh install's backend can
31+
// start before the updater service (a separate host process installed after
32+
// the Docker stack — see installer/install.go) finishes registering the
33+
// instance and writing instance-config.yml, so a single boot-time attempt
34+
// isn't enough; this keeps checking every 30s until the instance is
35+
// registered or a config already exists.
36+
func (s *ConfigService) StartEnsureDefaultLoop() {
37+
if s.EnsureDefault() {
38+
return
39+
}
40+
go func() {
41+
for range time.Tick(ensureDefaultRetryInterval) {
42+
if s.EnsureDefault() {
43+
return
44+
}
45+
}
46+
}()
47+
}
48+
49+
func (s *ConfigService) EnsureDefault() bool {
50+
existing, err := s.store.Load()
51+
if err != nil {
52+
catcher.Warn("socai: failed to check existing config for default provisioning", map[string]any{"error": err.Error()})
53+
return false
54+
}
55+
if existing != nil {
56+
return true
57+
}
58+
59+
inst := instanceconfig.Get()
60+
if inst == nil || inst.Server == "" || inst.InstanceID == "" || inst.InstanceKey == "" {
61+
return false
62+
}
63+
64+
idEnc, err := s.cipher.Encrypt(inst.InstanceID)
65+
if err != nil {
66+
catcher.Warn("socai: failed to encrypt default ThreatWinds credentials", map[string]any{"error": err.Error()})
67+
return false
68+
}
69+
keyEnc, err := s.cipher.Encrypt(inst.InstanceKey)
70+
if err != nil {
71+
catcher.Warn("socai: failed to encrypt default ThreatWinds credentials", map[string]any{"error": err.Error()})
72+
return false
73+
}
74+
75+
fc := &repository.FileConfig{
76+
Provider: "threatwinds",
77+
Model: threatWindsDefaultModel,
78+
URL: strings.TrimRight(inst.Server, "/") + threatWindsAIPath,
79+
AuthType: "none",
80+
CustomHeaders: map[string]string{"id": idEnc, "key": keyEnc},
81+
MaxTokens: 4096,
82+
MaxToolIterations: 12,
83+
AutoAnalyze: true,
84+
}
85+
if err := s.store.Save(fc); err != nil {
86+
catcher.Warn("socai: failed to save default ThreatWinds config", map[string]any{"error": err.Error()})
87+
return false
88+
}
89+
catcher.Info("socai: auto-configured default ThreatWinds provider", nil)
90+
return true
91+
}
92+
1693
// connectionVerifier pings the LLM endpoint with the candidate credentials.
1794
type connectionVerifier interface {
1895
Verify(ctx context.Context, c verifier.Config) error
@@ -94,14 +171,29 @@ func (s *ConfigService) Update(ctx context.Context, req dto.ConfigRequest) (*dto
94171
authType = "bearer"
95172
}
96173

174+
url := req.URL
175+
authHeaderName := req.AuthHeaderName
176+
177+
if req.Provider == "threatwinds" {
178+
inst := instanceconfig.Get()
179+
if inst == nil || inst.Server == "" || inst.InstanceID == "" || inst.InstanceKey == "" {
180+
return nil, ErrInstanceNotRegistered
181+
}
182+
url = strings.TrimRight(inst.Server, "/") + threatWindsAIPath
183+
authType = "none"
184+
authHeaderName = ""
185+
apiKeyPlain = ""
186+
headersPlain = map[string]string{"id": inst.InstanceID, "key": inst.InstanceKey}
187+
}
188+
97189
// 2. Verify the connection before writing anything.
98190
if err := s.verifier.Verify(ctx, verifier.Config{
99191
Provider: req.Provider,
100-
URL: req.URL,
192+
URL: url,
101193
Model: req.Model,
102194
APIKey: apiKeyPlain,
103195
AuthType: authType,
104-
AuthHeaderName: req.AuthHeaderName,
196+
AuthHeaderName: authHeaderName,
105197
CustomHeaders: headersPlain,
106198
}); err != nil {
107199
return nil, fmt.Errorf("%w: %v", ErrVerificationFailed, err)
@@ -136,10 +228,10 @@ func (s *ConfigService) Update(ctx context.Context, req dto.ConfigRequest) (*dto
136228
fc := &repository.FileConfig{
137229
Provider: req.Provider,
138230
Model: req.Model,
139-
URL: req.URL,
231+
URL: url,
140232
APIKey: apiKeyEnc,
141233
AuthType: authType,
142-
AuthHeaderName: req.AuthHeaderName,
234+
AuthHeaderName: authHeaderName,
143235
CustomHeaders: headersEnc,
144236
MaxTokens: maxTokens,
145237
MaxToolIterations: maxIters,

backend/modules/threatintel/handler/proxy.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
"time"
1111

1212
"github.com/gin-gonic/gin"
13-
"github.com/utmstack/utmstack/backend/modules/threatintel/internal"
13+
"github.com/utmstack/utmstack/backend/pkg/instanceconfig"
1414
)
1515

1616
// ReverseProxyHandler creates a reverse proxy that maps a local route to a CM proxy target.
@@ -25,7 +25,7 @@ func NewReverseProxyHandler(targetPath string) *ReverseProxyHandler {
2525

2626
// Handle is the Gin handler that proxies the request.
2727
func (h *ReverseProxyHandler) Handle(c *gin.Context) {
28-
cfg := internal.Get()
28+
cfg := instanceconfig.Get()
2929
if cfg == nil || cfg.Server == "" || cfg.InstanceID == "" || cfg.InstanceKey == "" {
3030
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "instance not configured"})
3131
return
@@ -51,7 +51,7 @@ func (h *ReverseProxyHandler) Handle(c *gin.Context) {
5151
}
5252

5353
// directorFunc returns a Director function that rewrites the request.
54-
func (h *ReverseProxyHandler) directorFunc(cfg *internal.InstanceConfig, targetURL *url.URL) func(*http.Request) {
54+
func (h *ReverseProxyHandler) directorFunc(cfg *instanceconfig.InstanceConfig, targetURL *url.URL) func(*http.Request) {
5555
return func(req *http.Request) {
5656
// Set scheme and host from CM proxy
5757
req.URL.Scheme = targetURL.Scheme
@@ -77,7 +77,7 @@ func (h *ReverseProxyHandler) directorFunc(cfg *internal.InstanceConfig, targetU
7777

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

backend/modules/threatintel/handler/proxy_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import (
66
"strings"
77
"testing"
88

9-
"github.com/utmstack/utmstack/backend/modules/threatintel/internal"
9+
"github.com/utmstack/utmstack/backend/pkg/instanceconfig"
1010
)
1111

1212
func TestDirectorRewritesPathAndHeaders(t *testing.T) {
1313
// Setup instance config
14-
cfg := &internal.InstanceConfig{
14+
cfg := &instanceconfig.InstanceConfig{
1515
Server: "https://example.com",
1616
InstanceID: "test-id-123",
1717
InstanceKey: "test-key-456",

backend/modules/threatintel/internal/instanceconfig.go

Lines changed: 0 additions & 47 deletions
This file was deleted.

backend/modules/threatintel/module.go

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,16 @@ package threatintel
33
import (
44
"context"
55

6-
"github.com/threatwinds/go-sdk/catcher"
7-
"github.com/utmstack/utmstack/backend/modules/threatintel/internal"
6+
"github.com/utmstack/utmstack/backend/pkg/instanceconfig"
87
)
98

109
type Module struct {
1110
updatesDir string
1211
}
1312

1413
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
14+
instanceconfig.Init(updatesDir)
15+
return &Module{updatesDir: updatesDir}
2216
}
2317

2418
func (m *Module) Start(ctx context.Context) {}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package instanceconfig
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+
mu sync.RWMutex
19+
updatesDir string
20+
cached *InstanceConfig
21+
)
22+
23+
// Init records where instance-config.yml lives. Call once at startup.
24+
func Init(dir string) {
25+
mu.Lock()
26+
updatesDir = dir
27+
mu.Unlock()
28+
}
29+
30+
func Get() *InstanceConfig {
31+
mu.RLock()
32+
if cached != nil {
33+
defer mu.RUnlock()
34+
return cached
35+
}
36+
dir := updatesDir
37+
mu.RUnlock()
38+
39+
if dir == "" {
40+
return nil
41+
}
42+
43+
data, err := os.ReadFile(filepath.Join(dir, "instance-config.yml"))
44+
if err != nil {
45+
return nil
46+
}
47+
var cfg InstanceConfig
48+
if err := yaml.Unmarshal(data, &cfg); err != nil {
49+
return nil
50+
}
51+
52+
mu.Lock()
53+
cached = &cfg
54+
mu.Unlock()
55+
return &cfg
56+
}

0 commit comments

Comments
 (0)