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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user