feat: embed web UI in server binary + local trigger-scan write endpoint
Server becomes self-contained: web/dist embedded via go:embed (server/internal/webui), SPA served from the binary with JSON-404 guard on /api paths, nginx web container removed from compose (31336 now maps to the server). Clean checkouts without the UI copy build API-only. Agent local API gains its first write endpoint, POST /v1/actions/trigger-scan (FEAT-002 write path): group-ACL authorized, single-flight, 202/409/503 semantics. Registered agents run the same HandleScanUpdates path as a signed scan command (empty command_id, no ack tracking); standalone agents scan through the orchestrator into the local read model only. Also repairs localapi tests left uncompilable by the desktop-provider parameter.
This commit is contained in:
parent
73c1e0ef21
commit
1241c1ef01
14 changed files with 476 additions and 38 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -153,6 +153,10 @@ out
|
|||
.nuxt
|
||||
dist
|
||||
web/dist-desktop/
|
||||
# embedded web UI build target — keep the dir, never its contents
|
||||
!server/internal/webui/dist/
|
||||
server/internal/webui/dist/*
|
||||
!server/internal/webui/dist/.gitkeep
|
||||
desktop/target/
|
||||
|
||||
# vuepress build output
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -110,15 +110,10 @@ Before a package is installed: the agent fetches the expected SHA-256 from the s
|
|||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Web Dashboard │ React + TypeScript
|
||||
│ Port: 31336 │
|
||||
└────────┬────────┘
|
||||
│ HTTPS + JWT + Machine Binding
|
||||
┌────────▼────────┐
|
||||
│ Server (Go) │ PostgreSQL · Ed25519 Signing Service
|
||||
│ Port: 31337 │
|
||||
└────────┬────────┘
|
||||
┌─────────────────────────────┐
|
||||
│ Server (Go) │ PostgreSQL · Ed25519 Signing Service
|
||||
│ Embedded React dashboard │ Dashboard: 31336 · Agent API: 31337
|
||||
└────────┬────────────────────┘
|
||||
│ Pull-based (agents check in, not the reverse)
|
||||
├──────────────────┐
|
||||
┌────────▼────────┐ ┌──────▼──────────┐
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
|
|
@ -183,9 +184,28 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
|
||||
ctx := loopCtx
|
||||
|
||||
// FEAT-002 write path: single-flight scan trigger for the local API.
|
||||
// Authorization is the socket/pipe group ACL; the scan itself runs through
|
||||
// the same handler primitives a signed server scan command uses.
|
||||
var scanInFlight atomic.Bool
|
||||
triggerScan := func(source string) error {
|
||||
if !scanInFlight.CompareAndSwap(false, true) {
|
||||
return localapi.ErrScanInFlight
|
||||
}
|
||||
go func() {
|
||||
defer recovery.Recover("local_triggered_scan")
|
||||
defer scanInFlight.Store(false)
|
||||
if err := handlers.HandleLocalTriggeredScan(ctx.APIClient, ctx.Cfg, ctx.AckTracker, ctx.ScanOrchestrator, source); err != nil {
|
||||
log.Printf("[ERROR] [agent] [localapi] local_scan_failed source=%s error=%v", source, err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
localAPIServer, err := localapi.Start(localapi.Options{
|
||||
Config: ctx.Cfg,
|
||||
DesktopProvider: ctx.DesktopManager,
|
||||
TriggerScan: triggerScan,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [localapi] start_failed error=%v", err)
|
||||
|
|
|
|||
83
agent/internal/handlers/local_trigger.go
Normal file
83
agent/internal/handlers/local_trigger.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/scanner"
|
||||
)
|
||||
|
||||
// HandleLocalTriggeredScan runs the full package-update scan on behalf of the
|
||||
// local API write path (FEAT-002). There is no signed server command behind
|
||||
// it, so no command ID exists and nothing is ack-tracked.
|
||||
//
|
||||
// Registered agents run the same HandleScanUpdates path a server scan command
|
||||
// uses — results are reported so the server ingests and reconciles them
|
||||
// (RECONCILE-001 close-by-absence included). Unregistered (standalone) agents
|
||||
// run the scanners through the orchestrator and record the local read model
|
||||
// only; no doomed HTTP calls are attempted.
|
||||
func HandleLocalTriggeredScan(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, source string) error {
|
||||
log.Printf("[INFO] [agent] [localapi] local_scan_started source=%s registered=%v", source, cfg.IsRegistered())
|
||||
|
||||
if cfg.IsRegistered() {
|
||||
return HandleScanUpdates(apiClient, cfg, ackTracker, orch, "")
|
||||
}
|
||||
return runStandaloneUpdateScan(cfg, orch)
|
||||
}
|
||||
|
||||
// runStandaloneUpdateScan mirrors HandleScanUpdates's scanner set (the virtual
|
||||
// "updates" subsystem) without any server reporting: orchestrator-managed
|
||||
// scans (circuit breakers, timeouts) feeding the local read model.
|
||||
func runStandaloneUpdateScan(cfg *config.Config, orch *orchestrator.Orchestrator) error {
|
||||
ctx := context.Background()
|
||||
var results []orchestrator.ScanResult
|
||||
var errs []string
|
||||
|
||||
type updateScanner struct {
|
||||
name string
|
||||
available func() bool
|
||||
}
|
||||
var candidates []updateScanner
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
candidates = []updateScanner{
|
||||
{"apt", scanner.NewAPTScanner().IsAvailable},
|
||||
{"dnf", scanner.NewDNFScanner().IsAvailable},
|
||||
}
|
||||
case "windows":
|
||||
candidates = []updateScanner{
|
||||
{"windows", scanner.NewWindowsUpdateScanner().IsAvailable},
|
||||
{"winget", scanner.NewWingetScanner().IsAvailable},
|
||||
}
|
||||
}
|
||||
|
||||
ran := 0
|
||||
for _, cand := range candidates {
|
||||
if !cand.available() {
|
||||
continue
|
||||
}
|
||||
ran++
|
||||
result, err := orch.ScanSingle(ctx, cand.name)
|
||||
results = append(results, result)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", cand.name, err))
|
||||
}
|
||||
}
|
||||
|
||||
recordLocalScanResults(cfg, results, true)
|
||||
|
||||
if len(errs) > 0 {
|
||||
err := fmt.Errorf("standalone scan errors: %s", strings.Join(errs, "; "))
|
||||
log.Printf("[ERROR] [agent] [localapi] standalone_scan_failed scanners_run=%d error=%v", ran, err)
|
||||
return err
|
||||
}
|
||||
log.Printf("[INFO] [agent] [localapi] standalone_scan_completed scanners_run=%d", ran)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -22,8 +22,14 @@ func mapAny[T any](v interface{}) T {
|
|||
return r
|
||||
}
|
||||
|
||||
// reportLogWithAck reports a command log to the server and tracks it for acknowledgment
|
||||
// reportLogWithAck reports a command log to the server and tracks it for acknowledgment.
|
||||
// A log with no command ID (locally triggered scan — no signed server command behind it)
|
||||
// is reported without ack tracking: there is no command completion to guarantee.
|
||||
func reportLogWithAck(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, logReport client.LogReport) error {
|
||||
if logReport.CommandID == "" {
|
||||
return apiClient.ReportLog(cfg.AgentID, logReport)
|
||||
}
|
||||
|
||||
// Track this command result as pending acknowledgment
|
||||
ackTracker.Add(logReport.CommandID)
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ const (
|
|||
DefaultWindowsPipeName = `\\.\pipe\RedFlagAgentLocal`
|
||||
)
|
||||
|
||||
// ErrScanInFlight is returned by a TriggerScan callback while a previously
|
||||
// triggered scan is still running. The handler maps it to 409 Conflict.
|
||||
var ErrScanInFlight = errors.New("localapi: scan already in flight")
|
||||
|
||||
// Options configures the local read-only API. Group, socket, and pipe defaults
|
||||
// are platform-specific and enforced by the listener implementation.
|
||||
type Options struct {
|
||||
|
|
@ -34,6 +38,11 @@ type Options struct {
|
|||
RequestLog func(format string, args ...interface{})
|
||||
ListenerOverride net.Listener
|
||||
DesktopProvider DesktopStatusProvider
|
||||
// TriggerScan enqueues a package-update scan through the agent's existing
|
||||
// scanner primitives (FEAT-002 write path). Authorization is the OS-local
|
||||
// group boundary on the socket/pipe — anyone who can connect may trigger.
|
||||
// Nil disables the endpoint (503). Return ErrScanInFlight to signal 409.
|
||||
TriggerScan func(source string) error
|
||||
}
|
||||
|
||||
// Server owns the local API listener and HTTP server.
|
||||
|
|
@ -57,7 +66,7 @@ func Start(opts Options) (*Server, error) {
|
|||
opts.RequestLog = log.Printf
|
||||
}
|
||||
|
||||
handler := newHandler(opts.Config, opts.LoadCache, opts.DesktopProvider)
|
||||
handler := newHandler(opts.Config, opts.LoadCache, opts.DesktopProvider, opts.TriggerScan)
|
||||
httpServer := &http.Server{Handler: handler}
|
||||
|
||||
listener := opts.ListenerOverride
|
||||
|
|
@ -100,9 +109,10 @@ func (s *Server) Stop() {
|
|||
}
|
||||
|
||||
type handler struct {
|
||||
cfg *config.Config
|
||||
loadCache func() (*cache.LocalCache, error)
|
||||
desktop DesktopStatusProvider
|
||||
cfg *config.Config
|
||||
loadCache func() (*cache.LocalCache, error)
|
||||
desktop DesktopStatusProvider
|
||||
triggerScan func(source string) error
|
||||
}
|
||||
|
||||
// DesktopStatusProvider allows the desktop manager to report its status.
|
||||
|
|
@ -162,8 +172,8 @@ type TokenResponse struct {
|
|||
Capabilities cache.CapabilityTokenState `json:"capabilities"`
|
||||
}
|
||||
|
||||
func newHandler(cfg *config.Config, loadCache func() (*cache.LocalCache, error), desktop DesktopStatusProvider) http.Handler {
|
||||
h := &handler{cfg: cfg, loadCache: loadCache, desktop: desktop}
|
||||
func newHandler(cfg *config.Config, loadCache func() (*cache.LocalCache, error), desktop DesktopStatusProvider, triggerScan func(source string) error) http.Handler {
|
||||
h := &handler{cfg: cfg, loadCache: loadCache, desktop: desktop, triggerScan: triggerScan}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/identity", h.identity)
|
||||
mux.HandleFunc("/v1/status", h.status)
|
||||
|
|
@ -171,6 +181,7 @@ func newHandler(cfg *config.Config, loadCache func() (*cache.LocalCache, error),
|
|||
mux.HandleFunc("/v1/packages", h.scansLatest)
|
||||
mux.HandleFunc("/v1/tokens/active", h.tokensActive)
|
||||
mux.HandleFunc("/v1/desktop", h.desktopHealth)
|
||||
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
|
||||
return mux
|
||||
}
|
||||
|
||||
|
|
@ -293,6 +304,43 @@ func (h *handler) desktopHealth(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
|
||||
// triggerScanAction handles POST /v1/actions/trigger-scan — the first local
|
||||
// write endpoint (FEAT-002). The OS-local group ACL on the socket/pipe is the
|
||||
// authorization boundary. The scan runs through the agent's existing scanner
|
||||
// primitives; this never bypasses server command-signing or package
|
||||
// authorization paths because it cannot install anything.
|
||||
func (h *handler) triggerScanAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.triggerScan == nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] scan_trigger_unavailable")
|
||||
http.Error(w, "scan trigger unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.triggerScan("localapi")
|
||||
if errors.Is(err, ErrScanInFlight) {
|
||||
log.Printf("[INFO] [agent] [localapi] scan_trigger_rejected reason=in_flight")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
writeJSONBody(w, map[string]interface{}{"accepted": false, "error": "scan already in flight"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [localapi] scan_trigger_failed error=%v", err)
|
||||
http.Error(w, "scan trigger failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [localapi] scan_trigger_accepted source=localapi")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
writeJSONBody(w, map[string]interface{}{"accepted": true})
|
||||
}
|
||||
|
||||
func (h *handler) load(w http.ResponseWriter) (*cache.LocalCache, bool) {
|
||||
localCache, err := h.loadCache()
|
||||
if err != nil {
|
||||
|
|
@ -314,6 +362,12 @@ func requireGet(w http.ResponseWriter, r *http.Request) bool {
|
|||
|
||||
func writeJSON(w http.ResponseWriter, value interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
writeJSONBody(w, value)
|
||||
}
|
||||
|
||||
// writeJSONBody encodes without touching headers — for callers that already
|
||||
// wrote a non-200 status code.
|
||||
func writeJSONBody(w http.ResponseWriter, value interface{}) {
|
||||
if err := json.NewEncoder(w).Encode(value); err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] response_encode_failed error=%v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ func TestIdentityRedactsSecrets(t *testing.T) {
|
|||
cfg := testConfig(t)
|
||||
handler := newHandler(cfg, func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
})
|
||||
}, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/identity", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -88,7 +88,7 @@ func TestStatusAndPackagesReadLocalCache(t *testing.T) {
|
|||
}
|
||||
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
|
||||
return localCache, nil
|
||||
})
|
||||
}, nil, nil)
|
||||
|
||||
var status StatusResponse
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/status", &status)
|
||||
|
|
@ -118,7 +118,7 @@ func TestStatusAndPackagesReadLocalCache(t *testing.T) {
|
|||
func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
|
||||
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
|
||||
return nil, errors.New("cannot read cache")
|
||||
})
|
||||
}, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -132,7 +132,7 @@ func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
|
|||
func TestOnlyGetMethodsAllowed(t *testing.T) {
|
||||
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
})
|
||||
}, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
|
|||
88
agent/internal/localapi/trigger_test.go
Normal file
88
agent/internal/localapi/trigger_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package localapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
||||
)
|
||||
|
||||
func triggerHandler(t *testing.T, trigger func(source string) error) http.Handler {
|
||||
t.Helper()
|
||||
return newHandler(testConfig(t), func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
}, nil, trigger)
|
||||
}
|
||||
|
||||
func postTrigger(t *testing.T, handler http.Handler) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/actions/trigger-scan", nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestTriggerScanAccepted(t *testing.T) {
|
||||
var gotSource string
|
||||
handler := triggerHandler(t, func(source string) error {
|
||||
gotSource = source
|
||||
return nil
|
||||
})
|
||||
|
||||
rec := postTrigger(t, handler)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
|
||||
}
|
||||
if gotSource != "localapi" {
|
||||
t.Fatalf("source = %q, want localapi", gotSource)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp["accepted"] != true {
|
||||
t.Fatalf("accepted = %v, want true", resp["accepted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerScanInFlightIsConflict(t *testing.T) {
|
||||
handler := triggerHandler(t, func(string) error { return ErrScanInFlight })
|
||||
|
||||
rec := postTrigger(t, handler)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerScanUnavailableWithoutCallback(t *testing.T) {
|
||||
handler := triggerHandler(t, nil)
|
||||
|
||||
rec := postTrigger(t, handler)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerScanFailureIs500(t *testing.T) {
|
||||
handler := triggerHandler(t, func(string) error { return errors.New("boom") })
|
||||
|
||||
rec := postTrigger(t, handler)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerScanRejectsGet(t *testing.T) {
|
||||
handler := triggerHandler(t, func(string) error { return nil })
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/actions/trigger-scan", nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
if rec.Header().Get("Allow") != http.MethodPost {
|
||||
t.Fatalf("Allow = %q, want POST", rec.Header().Get("Allow"))
|
||||
}
|
||||
}
|
||||
|
|
@ -37,30 +37,15 @@ services:
|
|||
start_period: 15s
|
||||
retries: 3
|
||||
ports:
|
||||
# 31337 = agent/API endpoint, 31336 = dashboard. Same process now —
|
||||
# the UI is embedded in the server binary; the nginx web container is gone.
|
||||
- "31337:8080"
|
||||
- "31336:8080"
|
||||
command: ["./redflag-server"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./config/.env
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: Dockerfile
|
||||
container_name: redflag-web
|
||||
ports:
|
||||
- "31336:80"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
server-data:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
# Stage 0: Build the web dashboard for embedding into the server binary
|
||||
FROM node:20-alpine AS web-builder
|
||||
|
||||
WORKDIR /web
|
||||
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY web/ ./
|
||||
RUN npx vite build
|
||||
|
||||
# Stage 1: Build server binary
|
||||
FROM golang:1.25-alpine AS server-builder
|
||||
|
||||
|
|
@ -12,6 +23,9 @@ RUN go mod download
|
|||
# Copy server contents to /app
|
||||
COPY server/ ./
|
||||
|
||||
# Embed the dashboard build — server/internal/webui picks this up via go:embed
|
||||
COPY --from=web-builder /web/dist ./internal/webui/dist
|
||||
|
||||
# Build server with version injection
|
||||
RUN echo "Building server version: $BUILD_VERSION" && \
|
||||
CGO_ENABLED=0 go build \
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ import (
|
|||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
|
@ -31,6 +34,7 @@ import (
|
|||
"github.com/Fimeg/RedFlag/server/internal/services/upstream"
|
||||
"github.com/Fimeg/RedFlag/server/internal/taskrunner"
|
||||
"github.com/Fimeg/RedFlag/server/internal/version"
|
||||
"github.com/Fimeg/RedFlag/server/internal/webui"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
|
@ -1001,6 +1005,8 @@ func main() {
|
|||
gin.WrapH(metricsExporter),
|
||||
)
|
||||
|
||||
registerWebUI(router)
|
||||
|
||||
// Add graceful shutdown for services
|
||||
defer func() {
|
||||
log.Println("Shutting down services...")
|
||||
|
|
@ -1030,6 +1036,61 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
// registerWebUI serves the embedded dashboard build (server/internal/webui)
|
||||
// from the server binary itself — no nginx, no separate web container. The
|
||||
// frontend calls relative /api/v1 paths, so same-origin serving needs no
|
||||
// frontend changes. Binaries built without the UI copy step run API-only.
|
||||
func registerWebUI(router *gin.Engine) {
|
||||
if !webui.Present() {
|
||||
log.Printf("[INFO] [server] [webui] no embedded UI build in this binary — serving API only")
|
||||
return
|
||||
}
|
||||
uiFS, err := webui.FS()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [server] [webui] embedded UI unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(uiFS))
|
||||
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
p := c.Request.URL.Path
|
||||
// Unmatched API/infra paths stay JSON 404s — never fall through to HTML.
|
||||
if strings.HasPrefix(p, "/api/") || p == "/metrics" || p == "/health" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
clean := strings.TrimPrefix(path.Clean(p), "/")
|
||||
if clean == "" {
|
||||
clean = "index.html"
|
||||
}
|
||||
if _, statErr := fs.Stat(uiFS, clean); statErr == nil {
|
||||
// Vite asset filenames are content-hashed — safe to cache hard.
|
||||
if strings.HasPrefix(clean, "assets/") {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Client-side route (e.g. /agents/123) — serve the SPA shell.
|
||||
index, readErr := fs.ReadFile(uiFS, "index.html")
|
||||
if readErr != nil {
|
||||
log.Printf("[ERROR] [server] [webui] index.html read failed: %v", readErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "ui unavailable"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
|
||||
})
|
||||
|
||||
log.Printf("[INFO] [server] [webui] embedded dashboard enabled")
|
||||
}
|
||||
|
||||
// getOperationalSetting reads a timeout value from the DB settings with fallback to a default.
|
||||
func getOperationalSetting(svc *services.SecuritySettingsService, key string, defaultVal int) int {
|
||||
if svc == nil {
|
||||
|
|
|
|||
94
server/cmd/server/webui_test.go
Normal file
94
server/cmd/server/webui_test.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/webui"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// These tests only run when a UI build is embedded (dist populated before
|
||||
// compile). A clean checkout builds API-only and skips.
|
||||
func newWebUIRouter(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
if !webui.Present() {
|
||||
t.Skip("no embedded UI build in this binary")
|
||||
}
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
registerWebUI(router)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestWebUIServesIndexAtRoot(t *testing.T) {
|
||||
router := newWebUIRouter(t)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET / = %d, want 200", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Fatalf("GET / content-type = %q, want html", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebUISPAFallbackForClientRoutes(t *testing.T) {
|
||||
router := newWebUIRouter(t)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/agents/123", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /agents/123 = %d, want 200 (SPA fallback)", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Fatalf("SPA fallback content-type = %q, want html", ct)
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); cc != "no-cache" {
|
||||
t.Fatalf("SPA fallback cache-control = %q, want no-cache", cc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebUIUnmatchedAPIPathStaysJSON404(t *testing.T) {
|
||||
router := newWebUIRouter(t)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/does-not-exist", nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /api/v1/does-not-exist = %d, want 404", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||
t.Fatalf("API 404 content-type = %q, want json (must not leak SPA html)", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebUINonGetIs404(t *testing.T) {
|
||||
router := newWebUIRouter(t)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/agents/123", nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("POST /agents/123 = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebUIHashedAssetsCacheHard(t *testing.T) {
|
||||
router := newWebUIRouter(t)
|
||||
uiFS, err := webui.FS()
|
||||
if err != nil {
|
||||
t.Fatalf("webui.FS: %v", err)
|
||||
}
|
||||
entries, err := fs.ReadDir(uiFS, "assets")
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Skip("no assets directory in embedded build")
|
||||
}
|
||||
asset := "/assets/" + entries[0].Name()
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, asset, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d, want 200", asset, w.Code)
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
|
||||
t.Fatalf("asset cache-control = %q, want immutable", cc)
|
||||
}
|
||||
}
|
||||
0
server/internal/webui/dist/.gitkeep
vendored
Normal file
0
server/internal/webui/dist/.gitkeep
vendored
Normal file
34
server/internal/webui/webui.go
Normal file
34
server/internal/webui/webui.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Package webui embeds the production web dashboard build into the server
|
||||
// binary so a native install needs no nginx and no separate web container.
|
||||
//
|
||||
// The build pipeline copies web/dist into this package's dist/ directory
|
||||
// before `go build`. When that copy has not happened (plain `go build` from
|
||||
// a clean checkout), the embed holds only the .gitkeep placeholder and the
|
||||
// server runs API-only — Present() reports false and the caller logs it.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// FS returns the embedded UI file tree rooted at the dist directory.
|
||||
func FS() (fs.FS, error) {
|
||||
return fs.Sub(distFS, "dist")
|
||||
}
|
||||
|
||||
// Present reports whether a real UI build is embedded (index.html exists),
|
||||
// as opposed to the empty placeholder tree from a UI-less build.
|
||||
func Present() bool {
|
||||
sub, err := FS()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := fs.Stat(sub, "index.html"); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
Loading…
Reference in a new issue