diff --git a/README.md b/README.md index 78d5a7c..9036fc1 100644 --- a/README.md +++ b/README.md @@ -148,15 +148,28 @@ fusion-deliverables/Vacation/ 7. Watch them appear on **Command Deck** and **Fleet Roster** -### Default login +### First-run login -| Field | Value | -|-------|--------| -| Username | `drjones` | -| Password | `czapiewski` | +On the very first launch the server generates a random admin password and prints it once to the console: -Change or add users under **Calibrate → Users** (writes `data/users.json`). -API routes under `/api/v1/*` require Basic auth; static dashboard and `/ws/dashboard` do not. +``` +=== First Run === +Dashboard login: admin / +Save this — it is not shown again. Change it in Calibrate → Users. +================= +``` + +Subsequent runs load credentials from `data/users.json`. Change or add users under **Calibrate → Users**. + +**API auth summary** + +| Surface | Auth mechanism | +|---------|----------------| +| `/api/v1/*` REST | HTTP Basic Auth (`Authorization: Basic `) | +| `/ws/dashboard` | `?token=` query parameter | +| `/ws/agent` | Fleet-secret `auth` JSON frame on connect | +| `/api/v1/agent/*` | `X-Fleet-Secret: ` header (agents only) | +| Static SPA + `/api/v1/health` | Open (no auth) | ### Output locations @@ -223,7 +236,7 @@ crypto miner/ | GET | `/api/v1/agents` | Fleet list | | POST | `/api/v1/agents/{id}/command` | Remote action (pause, powershell, …) | | WS | `/ws/agent` | Worker connection | -| WS | `/ws/dashboard` | Live dashboard feed | +| WS | `/ws/dashboard?token=` | Live dashboard feed (token = base64 of `user:pass`) | Full route list: `server/internal/api/router.go` @@ -279,7 +292,7 @@ Vite proxies `/api` and `/ws` to `localhost:8989`. Run `miner-server.exe` separa | Symptom | Likely cause | Fix | |---------|----------------|-----| | **Black screen**, empty page | Stale service worker or React/R3F version mismatch | Hard refresh (Ctrl+Shift+R); clear site data for `localhost:8989`; ensure `npm install` + `npm run build` in `server/web`; copy `dist` → `webroot`; restart server | -| Login loop / 401 | Wrong password or `users.json` | Use Calibrate → Users or default `drjones` / `czapiewski` | +| Login loop / 401 | Wrong password or missing `users.json` | Check the server console for the first-run password; reset by deleting `data/users.json` and restarting | | Dashboard builds but server shows placeholder HTML | Missing `server/webroot/index.html` | Run `run.bat` or copy `server/web/dist/*` → `server/webroot/` | | Forge upload fails | File > 2 GiB | Use paired mode + compress, or embedded for smaller sources | | Workers never appear | Wrong server URL / firewall | Use LAN IP in Forge; open 8989 on control PC | diff --git a/server/internal/api/router.go b/server/internal/api/router.go index d0d049e..676bb1b 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -1,8 +1,12 @@ package api import ( + "crypto/rand" "crypto/subtle" + "encoding/hex" "encoding/json" + "fmt" + "log" "net/http" "os" "path/filepath" @@ -18,7 +22,10 @@ import ( ) var ( - authUsers = map[string]string{"drjones": "czapiewski"} // default until users.json loads + // 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 @@ -50,13 +57,50 @@ func loadUsers(dataDir string) { usersFilePath = filepath.Join(dataDir, "users.json") usersMu.Lock() defer usersMu.Unlock() + data, err := os.ReadFile(usersFilePath) if err == nil { var loaded map[string]string if json.Unmarshal(data, &loaded) == nil && len(loaded) > 0 { authUsers = loaded + return } } + + // First run — no users.json (or empty). Generate a random admin password, + // save it, and print it clearly so the operator can log in immediately. + pw := generateRandomPassword() + authUsers = map[string]string{"admin": pw} + if err := os.MkdirAll(dataDir, 0755); err == nil { + d, _ := json.MarshalIndent(authUsers, "", " ") + if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil { + log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr) + } + } + + 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) +} + +// generateRandomPassword returns a 20-character hex string suitable for use +// as an initial admin password. +func generateRandomPassword() string { + b := make([]byte, 10) + if _, err := rand.Read(b); err != nil { + // Fallback to a fixed marker so the operator knows something went wrong. + return "CHANGE-ME-NOW-12345" + } + return hex.EncodeToString(b) } func saveUser(username, password string) error { @@ -141,7 +185,10 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + // 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"}, AllowCredentials: false, }))