feat: fleet secret auth - zero-setup agent authentication, dashboard WS token, remove credential hints
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -17,6 +19,33 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// secureStringEqual compares two strings in constant time to prevent timing attacks.
|
||||
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.
|
||||
func checkDashboardWSToken(r *http.Request) bool {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
user, pass := parts[0], parts[1]
|
||||
usersMu.RLock()
|
||||
expectedPass, exists := authUsers[user]
|
||||
usersMu.RUnlock()
|
||||
return exists && secureStringEqual(pass, expectedPass)
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
@@ -79,6 +108,7 @@ type WSHub struct {
|
||||
agentLogs map[string]string
|
||||
serverPolicy ServerPolicy
|
||||
pingIntervalSec int
|
||||
fleetSecret string // baked into forged agents; verified on WS connect
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -109,6 +139,14 @@ func (h *WSHub) SetPingInterval(seconds int) {
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetFleetSecret stores the shared secret that all forged agents must present.
|
||||
// Called once at startup from main.go after config is loaded.
|
||||
func (h *WSHub) SetFleetSecret(secret string) {
|
||||
h.mu.Lock()
|
||||
h.fleetSecret = secret
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *WSHub) pingInterval() time.Duration {
|
||||
h.mu.RLock()
|
||||
sec := h.pingIntervalSec
|
||||
@@ -270,6 +308,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
case "auth":
|
||||
var auth struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
FleetSecret string `json:"fleet_secret"`
|
||||
Wallet string `json:"wallet"`
|
||||
Version string `json:"version"`
|
||||
Hostname string `json:"hostname"`
|
||||
@@ -300,6 +339,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify fleet secret. If the server has one configured, the agent must match.
|
||||
h.mu.RLock()
|
||||
requiredSecret := h.fleetSecret
|
||||
h.mu.RUnlock()
|
||||
if requiredSecret != "" && !secureStringEqual(auth.FleetSecret, requiredSecret) {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": false, "error": "invalid fleet secret — re-forge this agent",
|
||||
})})
|
||||
log.Printf("[auth] Agent rejected: bad fleet secret (host=%s id=%s)", auth.Hostname, auth.AgentID)
|
||||
return
|
||||
}
|
||||
|
||||
agentID = auth.AgentID
|
||||
if agentID == "" {
|
||||
agentID = uuid.New().String()
|
||||
@@ -603,6 +654,15 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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.
|
||||
if !checkDashboardWSToken(r) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
log.Printf("[auth] Dashboard WS rejected: bad or missing token from %s", r.RemoteAddr)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("Dashboard WebSocket upgrade error: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user