● Notice //GitHub (@shehzade) is temporarily suspended pending an automated false-positive review. All tools are hosted on this Code Mirror and available on request (contact); GitHub links will return once reinstated.
PUBLIC MIRRORGo v1.0

Multi-protocol network egress assessment and DLP verification framework featuring automated L4 sweeps, corporate proxy traversal, and covert protocol smuggling across 20+ channels.

$ git clone https://shehzade.io/repos/contraband.git Copy
Source ZIP (1.93 MB)
Code 174 Commits 1
contraband / pkg / server / server.go
← Back to directory
718 lines · 19.7 KB
Raw
1package server
2
3import (
4 "context"
5 "crypto/tls"
6 "fmt"
7 "io"
8 "math"
9 "net"
10 "net/http"
11 "os"
12 "path/filepath"
13 "strconv"
14 "strings"
15 "sync"
16 "time"
17
18 "github.com/shehzade/contraband/pkg/common"
19 "github.com/shehzade/contraband/pkg/server/certgen"
20 "github.com/shehzade/contraband/pkg/server/live"
21)
22
23// ServerConfig defines runtime options for the Contraband reflection and listener server.
24type ServerConfig struct {
25 BindIP string `json:"bind_ip"`
26 Domain string `json:"domain"`
27 Ports string `json:"ports"`
28 Protocols string `json:"protocols"`
29 SMBMode string `json:"smb_mode"`
30 CertPath string `json:"cert_path"`
31 KeyPath string `json:"key_path"`
32 EnableEBPF bool `json:"enable_ebpf"`
33 DBPath string `json:"db_path"`
34 SavePayload bool `json:"save_payload"`
35 EvidenceDir string `json:"evidence_dir"`
36 DashboardAddr string `json:"dashboard_addr,omitempty"`
37 EnableTCP bool `json:"enable_tcp"`
38 EnableUDP bool `json:"enable_udp"`
39 Banner string `json:"banner"`
40 PeekTimeout time.Duration `json:"peek_timeout"`
41 SocketTimeout time.Duration `json:"socket_timeout"`
42}
43
44// DefaultServerConfig returns a ServerConfig with standard operational defaults.
45func DefaultServerConfig() ServerConfig {
46 return ServerConfig{
47 BindIP: "0.0.0.0",
48 Domain: "contraband.example.com",
49 Ports: "quick",
50 Protocols: "all",
51 SMBMode: "hash-capture",
52 DBPath: ":memory:",
53 EvidenceDir: "evidence",
54 EnableTCP: true,
55 EnableUDP: true,
56 PeekTimeout: 100 * time.Millisecond,
57 SocketTimeout: 3 * time.Second,
58 }
59}
60
61// Listener defines the contract required for dedicated protocol servers.
62// Compatible with listeners.ProtocolListener.
63type Listener interface {
64 Name() string
65 Protocol() string
66 Ports() []int
67 Start(ctx context.Context, ledger Ledger) error
68 Stop() error
69}
70
71// DemuxHandler defines the handler function for demuxed smuggling connections.
72type DemuxHandler func(conn net.Conn, peeked []byte, port int) error
73
74// Multiplexer defines the contract for multi-port reflection.
75// Compatible with multiplexer.Multiplexer.
76type Multiplexer interface {
77 Start(ctx context.Context) error
78 Shutdown(ctx context.Context) error
79 RegisterDemuxHandler(magic uint32, handler DemuxHandler)
80}
81
82// ServerEngine orchestrates the session ledger, dynamic TLS/SSH certs, dedicated L7 listeners,
83// and userspace port multiplexer reflector.
84type ServerEngine struct {
85 mu sync.RWMutex
86 config ServerConfig
87 ledger Ledger
88 listeners []Listener
89 multiplexer Multiplexer
90 tlsConfig *tls.Config
91 sshHostKey *certgen.SSHHostKey
92 logger common.Logger
93 ctx context.Context
94 cancel context.CancelFunc
95 running bool
96 wg sync.WaitGroup
97 savedMu sync.Mutex
98 savedSessions map[string]bool
99 dashboardServer *live.DashboardServer
100 liveHub *live.LiveHub
101}
102
103// NewServerEngine initializes the server components, ledger, and dynamic certs.
104func NewServerEngine(cfg ServerConfig) (*ServerEngine, error) {
105 logger := common.GetLogger()
106
107 // Apply defaults
108 if cfg.BindIP == "" {
109 cfg.BindIP = "0.0.0.0"
110 }
111 if cfg.Domain == "" {
112 cfg.Domain = "contraband.example.com"
113 }
114 if cfg.Ports == "" {
115 cfg.Ports = "quick"
116 }
117 if cfg.DBPath == "" {
118 cfg.DBPath = ":memory:"
119 }
120 if cfg.SMBMode == "" {
121 cfg.SMBMode = "hash-capture"
122 }
123 if cfg.EvidenceDir == "" {
124 cfg.EvidenceDir = "evidence"
125 }
126 if cfg.PeekTimeout <= 0 {
127 cfg.PeekTimeout = 100 * time.Millisecond
128 }
129 if cfg.SocketTimeout <= 0 {
130 cfg.SocketTimeout = 3 * time.Second
131 }
132
133 // 1. Initialize Session Ledger
134 ledger, err := NewLedger(cfg.DBPath)
135 if err != nil {
136 return nil, fmt.Errorf("failed to initialize ledger: %w", err)
137 }
138
139 // 2. TLS & SSH Dynamic Cert Generation
140 var tlsConfig *tls.Config
141 if cfg.CertPath != "" && cfg.KeyPath != "" {
142 cert, err := tls.LoadX509KeyPair(cfg.CertPath, cfg.KeyPath)
143 if err != nil {
144 ledger.Close()
145 return nil, fmt.Errorf("failed to load TLS certificate pair: %w", err)
146 }
147 tlsConfig = &tls.Config{
148 Certificates: []tls.Certificate{cert},
149 MinVersion: tls.VersionTLS12,
150 NextProtos: []string{"h2", "http/1.1"},
151 }
152 } else {
153 ca, err := certgen.NewCA("Contraband Assessment Root CA")
154 if err != nil {
155 ledger.Close()
156 return nil, fmt.Errorf("failed to generate ephemeral CA: %w", err)
157 }
158 hosts := []string{"localhost", "127.0.0.1", cfg.Domain, cfg.BindIP}
159 tlsConfig, err = certgen.NewServerTLSConfig(hosts, ca)
160 if err != nil {
161 ledger.Close()
162 return nil, fmt.Errorf("failed to generate dynamic TLS config: %w", err)
163 }
164 }
165
166 sshHostKey, err := certgen.GenerateSSHHostKey()
167 if err != nil {
168 ledger.Close()
169 return nil, fmt.Errorf("failed to generate dynamic SSH host key: %w", err)
170 }
171
172 var liveHub *live.LiveHub
173 var dashServer *live.DashboardServer
174 if cfg.DashboardAddr != "" {
175 liveHub = live.NewLiveHub()
176 ds, err := live.NewDashboardServer(cfg.DashboardAddr, liveHub)
177 if err != nil {
178 ledger.Close()
179 return nil, fmt.Errorf("failed to initialize live dashboard server: %w", err)
180 }
181 dashServer = ds
182 ledger.SetObserver(NewHubLedgerObserver(liveHub))
183 }
184
185 engine := &ServerEngine{
186 config: cfg,
187 ledger: ledger,
188 listeners: make([]Listener, 0),
189 tlsConfig: tlsConfig,
190 sshHostKey: sshHostKey,
191 logger: logger,
192 savedSessions: make(map[string]bool),
193 dashboardServer: dashServer,
194 liveHub: liveHub,
195 }
196
197 if dashServer != nil {
198 dashServer.SetEvidenceDir(cfg.EvidenceDir)
199 dashServer.SetResetCallback(func(deleteFiles bool) error {
200 return engine.ResetState(deleteFiles)
201 })
202 }
203
204 return engine, nil
205}
206
207// HubLedgerObserver bridges state.Ledger events to the real-time LiveHub.
208type HubLedgerObserver struct {
209 hub *live.LiveHub
210}
211
212// NewHubLedgerObserver creates a new HubLedgerObserver.
213func NewHubLedgerObserver(hub *live.LiveHub) *HubLedgerObserver {
214 return &HubLedgerObserver{hub: hub}
215}
216
217// OnChunkReceived forwards chunk arrival events to LiveHub.
218func (o *HubLedgerObserver) OnChunkReceived(session *common.Session, seq, total uint32, chunkSize int64) {
219 if o.hub == nil || session == nil {
220 return
221 }
222 clientIP := session.ClientIP
223 host := session.ClientHost
224 zone := session.NetworkZone
225
226 var progressPct float64
227 if total > 0 {
228 progressPct = math.Round((float64(seq)/float64(total))*10000) / 100
229 }
230
231 durMs := float64(session.LastActive.Sub(session.StartTime).Microseconds()) / 1000.0
232 if durMs < 0 {
233 durMs = 0
234 }
235
236 var instantMBs float64
237 if durMs > 0 && session.TotalBytes > 0 {
238 instantMBs = math.Round(((float64(session.TotalBytes)/(1024*1024))/(durMs/1000.0))*100) / 100
239 }
240
241 expectedBytes := session.ExpectedBytes
242 if expectedBytes == 0 && total > 0 && chunkSize > 0 {
243 expectedBytes = int64(total) * chunkSize
244 }
245
246 o.hub.RecordChunk(live.SessionProgressData{
247 SessionID: session.Token.String(),
248 NetworkZone: zone,
249 ClientHost: host,
250 ClientIP: clientIP,
251 Protocol: session.Protocol,
252 Port: session.Port,
253 Sequence: seq,
254 TotalChunks: total,
255 BytesTransferred: session.TotalBytes,
256 TotalBytes: expectedBytes,
257 ProgressPct: progressPct,
258 InstantMBs: instantMBs,
259 ElapsedMS: durMs,
260 }, chunkSize)
261}
262
263// OnSessionCompleted forwards session finalization events to LiveHub.
264func (o *HubLedgerObserver) OnSessionCompleted(session *common.Session) {
265 if o.hub == nil || session == nil {
266 return
267 }
268 clientIP := session.ClientIP
269 host := session.ClientHost
270 zone := session.NetworkZone
271
272 durMs := float64(session.LastActive.Sub(session.StartTime).Microseconds()) / 1000.0
273 if durMs < 0 {
274 durMs = 0
275 }
276
277 var avgMBs float64
278 if durMs > 0 && session.TotalBytes > 0 {
279 avgMBs = math.Round(((float64(session.TotalBytes)/(1024*1024))/(durMs/1000.0))*100) / 100
280 }
281
282 hashMatched := session.ExpectedHash != "" && strings.EqualFold(session.PayloadHash, session.ExpectedHash)
283
284 o.hub.RecordSessionComplete(live.SessionCompleteData{
285 SessionID: session.Token.String(),
286 NetworkZone: zone,
287 ClientHost: host,
288 ClientIP: clientIP,
289 Protocol: session.Protocol,
290 Port: session.Port,
291 TotalBytes: session.TotalBytes,
292 TotalChunks: session.TotalChunks,
293 DurationMS: math.Round(durMs*100) / 100,
294 AverageMBs: avgMBs,
295 FinalHash: session.PayloadHash,
296 HashMatched: hashMatched,
297 Verdict: session.Verdict,
298 })
299}
300
301// OnProbeRecorded forwards probe results to LiveHub.
302func (o *HubLedgerObserver) OnProbeRecorded(probe *common.ProbeResult) {
303 if o.hub == nil || probe == nil {
304 return
305 }
306 o.hub.RecordProbe(*probe)
307}
308
309// RegisterListener registers a dedicated protocol listener with the engine.
310func (s *ServerEngine) RegisterListener(l Listener) {
311 s.mu.Lock()
312 defer s.mu.Unlock()
313 s.listeners = append(s.listeners, l)
314}
315
316// SetMultiplexer sets the userspace port multiplexer reflector and binds smuggling demux handler.
317func (s *ServerEngine) SetMultiplexer(m Multiplexer) {
318 s.mu.Lock()
319 defer s.mu.Unlock()
320 s.multiplexer = m
321 if m != nil && s.ledger != nil {
322 m.RegisterDemuxHandler(common.MagicHeader, s.handleDemuxSmuggling)
323 }
324}
325
326// handleDemuxSmuggling processes incoming raw TCP smuggling streams prefixed with 32-byte ChunkHeader.
327func (s *ServerEngine) handleDemuxSmuggling(conn net.Conn, peeked []byte, port int) error {
328 defer conn.Close()
329
330 buf := make([]byte, 65536)
331 copy(buf, peeked)
332 peekedLen := len(peeked)
333
334 for {
335 for peekedLen < common.ChunkHeaderSize {
336 _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
337 n, err := conn.Read(buf[peekedLen:])
338 if err != nil {
339 if err == io.EOF && peekedLen == 0 {
340 return nil
341 }
342 return err
343 }
344 peekedLen += n
345 }
346
347 var hdr common.ChunkHeader
348 if err := hdr.UnmarshalBinary(buf[:common.ChunkHeaderSize]); err != nil {
349 return err
350 }
351 if hdr.Magic != common.MagicHeader {
352 return fmt.Errorf("invalid chunk header magic: 0x%x", hdr.Magic)
353 }
354
355 totalNeeded := common.ChunkHeaderSize + int(hdr.PayloadLen)
356 for cap(buf) < totalNeeded {
357 newBuf := make([]byte, totalNeeded*2)
358 copy(newBuf, buf[:peekedLen])
359 buf = newBuf
360 }
361 for peekedLen < totalNeeded {
362 _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
363 n, err := conn.Read(buf[peekedLen:totalNeeded])
364 if err != nil {
365 return err
366 }
367 peekedLen += n
368 }
369
370 payload := make([]byte, hdr.PayloadLen)
371 copy(payload, buf[common.ChunkHeaderSize:totalNeeded])
372
373 if s.ledger != nil {
374 clientIP, portStr, _ := net.SplitHostPort(conn.RemoteAddr().String())
375 port, _ := strconv.Atoi(portStr)
376 if clientIP == "" {
377 clientIP = conn.RemoteAddr().String()
378 }
379 _, _, _ = s.ledger.AssembleChunkWithMeta(hdr.Token, hdr.Sequence, hdr.TotalChunks, payload, "", clientIP, "raw_tcp_smuggle", port, clientIP, "")
380 }
381
382 ack := fmt.Sprintf("{\"status\":\"OK\",\"session\":\"%s\",\"seq\":%d}\n", hdr.Token.String(), hdr.Sequence)
383 _ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
384 if _, err := conn.Write([]byte(ack)); err != nil {
385 return err
386 }
387
388 remaining := peekedLen - totalNeeded
389 if remaining > 0 {
390 copy(buf, buf[totalNeeded:peekedLen])
391 peekedLen = remaining
392 } else {
393 peekedLen = 0
394 if hdr.Sequence >= hdr.TotalChunks {
395 break
396 }
397 }
398 }
399 return nil
400}
401
402// GetLedger returns the underlying session ledger.
403func (s *ServerEngine) GetLedger() Ledger {
404 return s.ledger
405}
406
407// GetTLSConfig returns the generated or loaded TLS configuration.
408func (s *ServerEngine) GetTLSConfig() *tls.Config {
409 return s.tlsConfig
410}
411
412// GetSSHHostKey returns the generated SSH host key.
413func (s *ServerEngine) GetSSHHostKey() *certgen.SSHHostKey {
414 return s.sshHostKey
415}
416
417// GetConfig returns the runtime server configuration.
418func (s *ServerEngine) GetConfig() ServerConfig {
419 return s.config
420}
421
422// GetListeners returns all registered listeners.
423func (s *ServerEngine) GetListeners() []Listener {
424 s.mu.RLock()
425 defer s.mu.RUnlock()
426 copied := make([]Listener, len(s.listeners))
427 copy(copied, s.listeners)
428 return copied
429}
430
431// Start spawns all registered protocol listeners and the port multiplexer reflector.
432func (s *ServerEngine) Start(ctx context.Context) error {
433 s.mu.Lock()
434 if s.running {
435 s.mu.Unlock()
436 return fmt.Errorf("server engine is already running")
437 }
438 s.ctx, s.cancel = context.WithCancel(ctx)
439 s.running = true
440 listenersCopy := make([]Listener, len(s.listeners))
441 copy(listenersCopy, s.listeners)
442 mux := s.multiplexer
443 s.mu.Unlock()
444
445 // Ensure evidence directory exists if SavePayload is enabled
446 if s.config.SavePayload {
447 if err := os.MkdirAll(s.config.EvidenceDir, 0755); err != nil {
448 s.logger.Warn(fmt.Sprintf("Failed to create evidence directory %q: %v", s.config.EvidenceDir, err))
449 }
450 }
451
452 // Launch dedicated protocol listeners
453 for _, l := range listenersCopy {
454 listener := l
455 s.wg.Add(1)
456 go func() {
457 defer s.wg.Done()
458 if err := listener.Start(s.ctx, s.ledger); err != nil {
459 s.logger.Warn(fmt.Sprintf("Listener %s terminated: %v", listener.Name(), err))
460 }
461 }()
462 }
463
464 // Launch userspace port multiplexer
465 if mux != nil {
466 s.wg.Add(1)
467 go func() {
468 defer s.wg.Done()
469 if err := mux.Start(s.ctx); err != nil {
470 s.logger.Warn(fmt.Sprintf("Multiplexer terminated: %v", err))
471 }
472 }()
473 }
474
475 // Payload retention worker
476 if s.config.SavePayload {
477 s.wg.Add(1)
478 go s.payloadRetentionWorker()
479 }
480
481 // Launch live command center dashboard
482 if s.dashboardServer != nil && s.liveHub != nil {
483 s.wg.Add(1)
484 go func() {
485 defer s.wg.Done()
486 if err := s.dashboardServer.Start(s.ctx); err != nil {
487 s.logger.Warn(fmt.Sprintf("Dashboard server exited: %v", err))
488 }
489 }()
490 s.logger.Info(fmt.Sprintf("Live Command Center Dashboard active at http://%s/dashboard", s.config.DashboardAddr))
491 trySpawnPort8088Redirect(s.ctx, s.config.DashboardAddr)
492 }
493
494 s.logger.Info(fmt.Sprintf("Server Engine running on %s (%d protocol listeners registered)",
495 s.config.BindIP, len(listenersCopy)))
496
497 return nil
498}
499
500// flushCompletedSessionsToDisk iterates all ledger sessions, writes completed
501// payloads to evidence_dir if not previously saved, and marks them in savedSessions.
502func (s *ServerEngine) flushCompletedSessionsToDisk() {
503 if s.ledger == nil || !s.config.SavePayload {
504 return
505 }
506 sessions, err := s.ledger.GetAllSessions()
507 if err != nil {
508 return
509 }
510
511 _ = os.MkdirAll(s.config.EvidenceDir, 0755)
512
513 s.savedMu.Lock()
514 defer s.savedMu.Unlock()
515
516 for _, sess := range sessions {
517 if sess == nil || sess.Status != "completed" {
518 continue
519 }
520 tokStr := sess.Token.String()
521 if s.savedSessions[tokStr] {
522 continue
523 }
524
525 payload, err := s.ledger.GetSessionPayload(sess.Token)
526 if err != nil || len(payload) == 0 {
527 continue
528 }
529
530 safeProto := common.SanitizeProtocolName(sess.Protocol)
531 filename := fmt.Sprintf("%s_%s.bin", tokStr, safeProto)
532 dest := filepath.Join(s.config.EvidenceDir, filename)
533 cleanDest := filepath.Clean(dest)
534 cleanEvidenceDir := filepath.Clean(s.config.EvidenceDir)
535 rel, err := filepath.Rel(cleanEvidenceDir, cleanDest)
536 if err != nil || strings.HasPrefix(rel, "..") || rel == "." {
537 s.logger.Warn(fmt.Sprintf("Path traversal detected! Refusing to write to %s", dest))
538 continue
539 }
540 if writeErr := os.WriteFile(cleanDest, payload, 0600); writeErr == nil {
541 s.savedSessions[tokStr] = true
542 s.logger.Info(fmt.Sprintf("Reconstructed payload written to %s (%d bytes)", cleanDest, len(payload)))
543 } else {
544 s.logger.Warn(fmt.Sprintf("Failed to write evidence payload to %s: %v", cleanDest, writeErr))
545 }
546 }
547}
548
549func (s *ServerEngine) payloadRetentionWorker() {
550 defer s.wg.Done()
551 ticker := time.NewTicker(2 * time.Second)
552 defer ticker.Stop()
553
554 for {
555 select {
556 case <-s.ctx.Done():
557 // Flush any sessions completed prior to context cancellation
558 s.flushCompletedSessionsToDisk()
559 return
560 case <-ticker.C:
561 s.flushCompletedSessionsToDisk()
562 }
563 }
564}
565
566// FlushEvidence triggers an immediate flush of all completed sessions to the evidence directory.
567func (s *ServerEngine) FlushEvidence() {
568 s.flushCompletedSessionsToDisk()
569}
570
571// Stop gracefully terminates all listeners, the multiplexer, and closes the ledger with a default 5-second timeout.
572func (s *ServerEngine) Stop() error {
573 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
574 defer cancel()
575 return s.Shutdown(ctx)
576}
577
578// Shutdown gracefully terminates all listeners, the multiplexer, flushes evidence, and closes the ledger.
579func (s *ServerEngine) Shutdown(ctx context.Context) error {
580 s.mu.Lock()
581 if !s.running {
582 s.mu.Unlock()
583 return nil
584 }
585 s.running = false
586 if s.cancel != nil {
587 s.cancel()
588 }
589 listenersCopy := make([]Listener, len(s.listeners))
590 copy(listenersCopy, s.listeners)
591 mux := s.multiplexer
592 s.mu.Unlock()
593
594 s.logger.Info("Initiating graceful server engine shutdown...")
595
596 // Stop dedicated listeners
597 for _, l := range listenersCopy {
598 _ = l.Stop()
599 }
600
601 // Stop multiplexer
602 if mux != nil {
603 _ = mux.Shutdown(ctx)
604 }
605
606 // Stop dashboard server
607 if s.dashboardServer != nil {
608 _ = s.dashboardServer.Shutdown(ctx)
609 }
610
611 // Wait for goroutines with timeout
612 done := make(chan struct{})
613 go func() {
614 s.wg.Wait()
615 close(done)
616 }()
617
618 select {
619 case <-done:
620 s.logger.Info("All server listeners stopped cleanly")
621 case <-ctx.Done():
622 s.logger.Warn("Shutdown context expired before all listeners exited")
623 }
624
625 // Guaranteed final flush of any sessions completed prior to or during shutdown
626 if s.config.SavePayload {
627 s.flushCompletedSessionsToDisk()
628 }
629
630 if s.ledger != nil {
631 _ = s.ledger.Close()
632 }
633
634 return nil
635}
636
637// GetLiveHub returns the live telemetry hub, if active.
638func (s *ServerEngine) GetLiveHub() *live.LiveHub {
639 s.mu.RLock()
640 defer s.mu.RUnlock()
641 return s.liveHub
642}
643
644// GetDashboardServer returns the live dashboard HTTP server, if active.
645func (s *ServerEngine) GetDashboardServer() *live.DashboardServer {
646 s.mu.RLock()
647 defer s.mu.RUnlock()
648 return s.dashboardServer
649}
650
651// ResetState purges the ledger state, completed session registry, and optionally evidence files from disk.
652func (s *ServerEngine) ResetState(deleteFiles bool) error {
653 s.mu.Lock()
654 defer s.mu.Unlock()
655
656 // 1. Reset the underlying ledger
657 if s.ledger != nil {
658 if err := s.ledger.Reset(); err != nil {
659 s.logger.Warn(fmt.Sprintf("Failed to reset ledger: %v", err))
660 }
661 }
662
663 // 2. Clear saved sessions tracking
664 s.savedMu.Lock()
665 s.savedSessions = make(map[string]bool)
666 s.savedMu.Unlock()
667
668 // 3. If requested, delete exfiltrated evidence files from disk
669 if deleteFiles && s.config.EvidenceDir != "" {
670 entries, err := os.ReadDir(s.config.EvidenceDir)
671 if err == nil {
672 for _, entry := range entries {
673 if !entry.IsDir() {
674 filePath := filepath.Join(s.config.EvidenceDir, entry.Name())
675 _ = os.Remove(filePath)
676 }
677 }
678 s.logger.Info(fmt.Sprintf("Purged exfiltrated evidence files from %s", s.config.EvidenceDir))
679 }
680 }
681
682 s.logger.Info(fmt.Sprintf("Server state reset complete (deleteFiles=%v)", deleteFiles))
683 return nil
684}
685
686// trySpawnPort8088Redirect attempts to bind 8088 and redirect requests to the live dashboard port.
687func trySpawnPort8088Redirect(ctx context.Context, targetDashAddr string) {
688 if strings.Contains(targetDashAddr, ":8088") {
689 return
690 }
691 ln, err := net.Listen("tcp", "0.0.0.0:8088")
692 if err != nil {
693 return
694 }
695 dashPort := "8080"
696 parts := strings.Split(targetDashAddr, ":")
697 if len(parts) >= 2 && parts[len(parts)-1] != "" {
698 dashPort = parts[len(parts)-1]
699 }
700 srv := &http.Server{
701 Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
702 target := fmt.Sprintf("http://127.0.0.1:%s/dashboard", dashPort)
703 http.Redirect(w, r, target, http.StatusTemporaryRedirect)
704 }),
705 ReadTimeout: 5 * time.Second,
706 WriteTimeout: 5 * time.Second,
707 IdleTimeout: 15 * time.Second,
708 }
709 go func() {
710 <-ctx.Done()
711 shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
712 defer cancel()
713 _ = srv.Shutdown(shutdownCtx)
714 }()
715 go func() {
716 _ = srv.Serve(ln)
717 }()
718}