Improve portable launch, forge persistence, and operator auth UX.

Persist build extra_files for Build Manager history, print dashboard login on every start, add libp2p for Mesh P2P forge, defer WebSocket until login, and split devrun.bat from LAUNCH.bat with USB deck auto-detection.
This commit is contained in:
AetherForge
2026-05-31 18:56:43 -07:00
parent 2f528229f2
commit feba06e008
80 changed files with 2897 additions and 675 deletions

View File

@@ -0,0 +1,39 @@
package api
import "testing"
func TestAgentConfigPoolHostOrDefault(t *testing.T) {
t.Run("empty uses fallback", func(t *testing.T) {
cfg := AgentForgeConfig{}
if got := cfg.poolHostOrDefault("fallback.host"); got != "fallback.host" {
t.Fatalf("got %q, want fallback.host", got)
}
})
t.Run("explicit host wins", func(t *testing.T) {
cfg := AgentForgeConfig{PoolHost: "pool.example.com"}
if got := cfg.poolHostOrDefault("fallback"); got != "pool.example.com" {
t.Fatalf("got %q, want pool.example.com", got)
}
})
}
func TestAgentConfigPoolPortOrDefault(t *testing.T) {
t.Run("zero uses fallback", func(t *testing.T) {
cfg := AgentForgeConfig{}
if got := cfg.poolPortOrDefault(3333); got != 3333 {
t.Fatalf("got %d, want 3333", got)
}
})
t.Run("negative uses fallback", func(t *testing.T) {
cfg := AgentForgeConfig{PoolPort: -1}
if got := cfg.poolPortOrDefault(3333); got != 3333 {
t.Fatalf("got %d, want 3333", got)
}
})
t.Run("explicit port wins", func(t *testing.T) {
cfg := AgentForgeConfig{PoolPort: 443}
if got := cfg.poolPortOrDefault(3333); got != 443 {
t.Fatalf("got %d, want 443", got)
}
})
}

View File

@@ -556,8 +556,7 @@ func TestFleetPostAgentCommandErrors(t *testing.T) {
fh, _, ws, _ := newTestFleetHandler(t)
t.Run("nil ws", func(t *testing.T) {
bad := *fh
bad.ws = nil
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`))
fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req)
@@ -730,8 +729,7 @@ func TestFleetPostBulkCommandErrors(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
t.Run("nil ws", func(t *testing.T) {
bad := *fh
bad.ws = nil
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`))

View File

@@ -64,18 +64,19 @@ func authCacheSet(user, pass string) {
}
var (
// authUsers is populated from data/users.json on startup. On the very first
// run (no users.json) a random password is generated, saved, and printed to
// the console — no hard-coded credentials anywhere in the binary.
authUsers = map[string]string{}
usersFilePath string
usersMu sync.RWMutex
// authUsers is populated from data/users.json on startup. Plain-text copies
// for console display live in data/login-credentials.json (0600).
authUsers = map[string]string{}
usersFilePath string
usersMu sync.RWMutex
authLoadMu sync.Mutex
authLoadedDataDir string
// fleetSecretForAgentPaths holds the shared fleet secret used to authenticate
// agent-facing REST endpoints (/api/v1/agent/*). Set once from main.go via
// SetAgentPathSecret so basicAuthMiddleware can check X-Fleet-Secret headers.
fleetSecretForAgentPaths string
fleetSecretForAgentPathsMu sync.RWMutex
fleetSecretForAgentPaths string
fleetSecretForAgentPathsMu sync.RWMutex
// rotateSecretFn is called when POST /server/rotate-secret is hit.
// Wired from main.go so the server can generate, persist, and propagate the new secret.
@@ -120,8 +121,94 @@ func checkPassword(stored, provided string) bool {
return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1
}
func loadUsers(dataDir string) {
func loginSidecarPath(dataDir string) string {
return filepath.Join(dataDir, "login-credentials.json")
}
func readLoginSidecar(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var creds map[string]string
if err := json.Unmarshal(data, &creds); err != nil {
return nil, err
}
if len(creds) == 0 {
return nil, fmt.Errorf("empty login sidecar")
}
return creds, nil
}
func writeLoginSidecar(path string, creds map[string]string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func upsertLoginSidecar(dataDir, username, password string) error {
path := loginSidecarPath(dataDir)
creds, _ := readLoginSidecar(path)
if creds == nil {
creds = map[string]string{}
}
creds[username] = password
return writeLoginSidecar(path, creds)
}
func printStartupCredentials(dataDir string) {
creds, err := readLoginSidecar(loginSidecarPath(dataDir))
if err != nil || len(creds) == 0 {
return
}
fmt.Println(formatLoginBanner(creds))
}
func formatLoginBanner(creds map[string]string) string {
var b strings.Builder
b.WriteString("\n╔══════════════════════════════════════════════════╗\n")
b.WriteString("║ AetherForge — Dashboard Login ║\n")
b.WriteString("║ ║\n")
for user, pass := range creds {
fmt.Fprintf(&b, "║ Username : %-34s║\n", user)
fmt.Fprintf(&b, "║ Password : %-34s║\n", pass)
b.WriteString("║ ║\n")
}
b.WriteString("║ Also saved in data/login-credentials.json ║\n")
b.WriteString("║ Change passwords in Calibrate → Users. ║\n")
b.WriteString("╚══════════════════════════════════════════════════╝\n")
return b.String()
}
// LoadUsers loads dashboard accounts and prints login credentials to the console.
// Call once during startup (before heavy init) so operators always see passwords.
func LoadUsers(dataDir string) {
ensureUsersLoaded(dataDir)
printStartupCredentials(dataDir)
}
func ensureUsersLoaded(dataDir string) {
abs, err := filepath.Abs(dataDir)
if err != nil {
abs = dataDir
}
authLoadMu.Lock()
defer authLoadMu.Unlock()
if authLoadedDataDir == abs {
return
}
bootstrapUsers(dataDir)
authLoadedDataDir = abs
}
func bootstrapUsers(dataDir string) {
usersFilePath = filepath.Join(dataDir, "users.json")
sidecarPath := loginSidecarPath(dataDir)
usersMu.Lock()
defer usersMu.Unlock()
@@ -129,7 +216,6 @@ func loadUsers(dataDir string) {
if err == nil {
var loaded map[string]string
if json.Unmarshal(data, &loaded) == nil && len(loaded) > 0 {
// Migration: re-hash any plain-text entries left from an older version.
migrated := false
for u, v := range loaded {
if !isBcryptHash(v) {
@@ -145,16 +231,15 @@ func loadUsers(dataDir string) {
d, _ := json.MarshalIndent(authUsers, "", " ")
_ = os.WriteFile(usersFilePath, d, 0600)
}
reconcileLoginSidecar(dataDir, sidecarPath, loaded)
return
}
}
// First run — no users.json (or empty). Generate a random admin password,
// hash it, save it, and print the plain-text once to the console.
pw := generateRandomPassword()
hashed, herr := hashPassword(pw)
if herr != nil {
hashed = pw // extremely unlikely; degrade gracefully
hashed = pw
log.Printf("[Auth] WARNING: bcrypt failed, storing plain-text password: %v", herr)
}
authUsers = map[string]string{"admin": hashed}
@@ -163,20 +248,35 @@ func loadUsers(dataDir string) {
if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil {
log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr)
}
_ = writeLoginSidecar(sidecarPath, map[string]string{"admin": pw})
}
}
banner := fmt.Sprintf(`
╔══════════════════════════════════════════════════╗
║ AetherForge — First Run ║
║ ║
║ Dashboard login ║
║ Username : admin ║
║ Password : %-34s║
║ ║
║ Save this — it will not be shown again. ║
║ Change it later in Calibrate → Users. ║
╚══════════════════════════════════════════════════╝`, pw)
log.Print(banner)
func reconcileLoginSidecar(dataDir, sidecarPath string, users map[string]string) {
if _, err := readLoginSidecar(sidecarPath); err == nil {
return
}
if _, ok := users["admin"]; !ok {
return
}
pw := generateRandomPassword()
hashed, herr := hashPassword(pw)
if herr != nil {
log.Printf("[Auth] WARNING: could not regenerate admin password: %v", herr)
return
}
users["admin"] = hashed
authUsers = users
d, _ := json.MarshalIndent(authUsers, "", " ")
if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil {
log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr)
return
}
if writeErr := writeLoginSidecar(sidecarPath, map[string]string{"admin": pw}); writeErr != nil {
log.Printf("[Auth] WARNING: could not save login-credentials.json: %v", writeErr)
return
}
log.Printf("[Auth] Regenerated admin password (login-credentials.json was missing)")
}
// generateRandomPassword returns a 20-character hex string suitable for use
@@ -195,16 +295,25 @@ func saveUser(username, password string) error {
return fmt.Errorf("bcrypt: %w", err)
}
usersMu.Lock()
defer usersMu.Unlock()
authUsers[username] = hashed
if usersFilePath == "" {
usersFilePath = filepath.Join("data", "users.json")
}
if err := os.MkdirAll(filepath.Dir(usersFilePath), 0755); err != nil {
dataDir := filepath.Dir(usersFilePath)
if err := os.MkdirAll(dataDir, 0755); err != nil {
usersMu.Unlock()
return err
}
data, _ := json.MarshalIndent(authUsers, "", " ")
return os.WriteFile(usersFilePath, data, 0600)
if err := os.WriteFile(usersFilePath, data, 0600); err != nil {
usersMu.Unlock()
return err
}
usersMu.Unlock()
if err := upsertLoginSidecar(dataDir, username, password); err != nil {
log.Printf("[Auth] WARNING: could not update login-credentials.json: %v", err)
}
return nil
}
func basicAuthMiddleware(next http.Handler) http.Handler {
@@ -294,7 +403,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, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
loadUsers(dataDir)
ensureUsersLoaded(dataDir)
r := chi.NewRouter()
@@ -414,7 +523,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
writeJSON(w, map[string]interface{}{"success": true})
})
// AI Autonomy (Ollama)
// Agent autonomy REST — forged Go agents only (X-Fleet-Secret header).
// Not exposed in dashboard client.ts; see agent/client and README API auth table.
r.Post("/agent/decide", aiHandler.HandleDecide)
r.Post("/agent/report", aiHandler.HandleReport)
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)

View File

@@ -28,6 +28,9 @@ func resetAuthState(t *testing.T) {
authUsers = map[string]string{}
usersFilePath = ""
usersMu.Unlock()
authLoadMu.Lock()
authLoadedDataDir = ""
authLoadMu.Unlock()
SetAgentPathSecret("")
SetRotateSecretFn(nil)
t.Cleanup(resetAuthGlobals)
@@ -437,3 +440,35 @@ func TestRouterNoWebRootFallback(t *testing.T) {
t.Fatalf("unexpected body: %s", rec.Body.String())
}
}
func TestLoadUsersCreatesAndReloadsLoginSidecar(t *testing.T) {
resetAuthState(t)
dataDir := t.TempDir()
LoadUsers(dataDir)
sidecarPath := filepath.Join(dataDir, "login-credentials.json")
data, err := os.ReadFile(sidecarPath)
if err != nil {
t.Fatalf("login sidecar: %v", err)
}
var creds map[string]string
if err := json.Unmarshal(data, &creds); err != nil {
t.Fatal(err)
}
pw, ok := creds["admin"]
if !ok || pw == "" {
t.Fatalf("expected admin password in sidecar: %v", creds)
}
if !checkPassword(authUsers["admin"], pw) {
t.Fatal("sidecar password should match users.json hash")
}
if err := saveUser("admin", "new-secret-pass"); err != nil {
t.Fatal(err)
}
reloaded, err := readLoginSidecar(sidecarPath)
if err != nil || reloaded["admin"] != "new-secret-pass" {
t.Fatalf("sidecar not updated after saveUser: %v err=%v", reloaded, err)
}
}

View File

@@ -0,0 +1,31 @@
package api
import (
"encoding/json"
"testing"
)
func TestServerPolicyJSONRoundTrip(t *testing.T) {
in := ServerPolicy{
MaxAgents: 128,
LogAgentConnections: true,
LogShareSubmissions: false,
LogPoolTraffic: true,
StrictWalletValidation: true,
MaxBuildSizeMB: 64,
PoolReconnectSeconds: 30,
}
data, err := json.Marshal(in)
if err != nil {
t.Fatal(err)
}
var out ServerPolicy
if err := json.Unmarshal(data, &out); err != nil {
t.Fatal(err)
}
if out != in {
t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", out, in)
}
}

View File

@@ -0,0 +1,32 @@
{
"WSDashboardInit": [
"agents"
],
"WSAgentOffline": [
"agent_id"
],
"WSStatsUpdate": [
"agent_id",
"cpu_usage_pct",
"hashrate_15m",
"hashrate_15s",
"hashrate_1m",
"memory_usage_pct",
"shares_accepted",
"shares_submitted",
"uptime_seconds"
],
"WSCommandResult": [
"action",
"agent_id",
"message",
"success"
],
"WSAgentLog": [
"agent_id",
"content"
],
"WSServerLog": [
"line"
]
}

View File

@@ -1,6 +1,8 @@
package api
// Dashboard WebSocket payload types (keep in sync with server/web/src/types/ws.ts).
// Shared types: WSDashboardInit, WSAgentOffline, WSStatsUpdate, WSCommandResult, WSAgentLog, WSServerLog.
// Cross-language drift guard: testdata/ws_types_fixture.json (Go ws_types_test.go, TS ws.test.ts).
type WSDashboardInit struct {
Agents []interface{} `json:"agents"`

View File

@@ -2,14 +2,96 @@ package api
import (
"encoding/json"
"flag"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
)
var updateWSFixture = flag.Bool("updateWSFixture", false, "rewrite testdata/ws_types_fixture.json from ws_types.go struct tags")
func TestMain(m *testing.M) {
flag.Parse()
os.Exit(m.Run())
}
// Synced with server/web/src/types/ws.ts — see testdata/ws_types_fixture.json.
var wsTypeSamples = map[string]interface{}{
"WSDashboardInit": WSDashboardInit{},
"WSAgentOffline": WSAgentOffline{},
"WSStatsUpdate": WSStatsUpdate{},
"WSCommandResult": WSCommandResult{},
"WSAgentLog": WSAgentLog{},
"WSServerLog": WSServerLog{},
}
func wsTypeFieldKeys(v interface{}) []string {
t := reflect.TypeOf(v)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
var keys []string
for i := 0; i < t.NumField(); i++ {
tag := t.Field(i).Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
name, _, _ := strings.Cut(tag, ",")
if name != "" {
keys = append(keys, name)
}
}
sort.Strings(keys)
return keys
}
func wsTypeKeysFromStructs() map[string][]string {
out := make(map[string][]string, len(wsTypeSamples))
for name, sample := range wsTypeSamples {
out[name] = wsTypeFieldKeys(sample)
}
return out
}
func TestWSTypeFieldKeysMatchFixture(t *testing.T) {
got := wsTypeKeysFromStructs()
fixturePath := filepath.Join("testdata", "ws_types_fixture.json")
if *updateWSFixture {
data, err := json.MarshalIndent(got, "", " ")
if err != nil {
t.Fatal(err)
}
data = append(data, '\n')
if err := os.WriteFile(fixturePath, data, 0o644); err != nil {
t.Fatal(err)
}
t.Logf("updated %s", fixturePath)
return
}
raw, err := os.ReadFile(fixturePath)
if err != nil {
t.Fatalf("read fixture: %v (run with -updateWSFixture to create)", err)
}
var want map[string][]string
if err := json.Unmarshal(raw, &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ws_types fixture drift: re-run go test -run TestWSTypeFieldKeysMatchFixture -updateWSFixture ./internal/api/")
}
}
func TestWSTypesJSONRoundTrip(t *testing.T) {
cases := []struct {
name string
in interface{}
}{
{"dashboard_init", WSDashboardInit{Agents: []interface{}{}}},
{"stats", WSStatsUpdate{AgentID: "a1", Hashrate15m: 123.4, CPUUsagePct: 50}},
{"offline", WSAgentOffline{AgentID: "a1"}},
{"command", WSCommandResult{AgentID: "a1", Action: "exec", Success: true, Message: "ok"}},