fix: pool backup failover on reconnect, perfect dropper scripts, bcrypt password hashing

This commit is contained in:
drjones
2026-05-30 12:49:40 -07:00
parent 9eaf1e82ac
commit 0e19eb9eb9
7 changed files with 276 additions and 110 deletions

View File

@@ -19,6 +19,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"golang.org/x/crypto/bcrypt"
)
var (
@@ -53,6 +54,31 @@ func SetRotateSecretFn(fn func() (string, error)) {
rotateSecretFn = fn
}
// isBcryptHash returns true when s looks like a bcrypt hash ($2a$, $2b$, $2y$).
func isBcryptHash(s string) bool {
return len(s) > 4 && s[0] == '$' && s[1] == '2'
}
// hashPassword returns a bcrypt hash of password (cost 12).
func hashPassword(password string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return "", err
}
return string(h), nil
}
// checkPassword verifies password against the stored value. Stored values are
// always bcrypt hashes after migration; plain-text legacy values are accepted
// once then re-hashed automatically.
func checkPassword(stored, provided string) bool {
if isBcryptHash(stored) {
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(provided)) == nil
}
// Legacy plain-text comparison (constant-time).
return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1
}
func loadUsers(dataDir string) {
usersFilePath = filepath.Join(dataDir, "users.json")
usersMu.Lock()
@@ -62,16 +88,36 @@ 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) {
if h, herr := hashPassword(v); herr == nil {
loaded[u] = h
migrated = true
log.Printf("[Auth] Migrated plain-text password for user %q to bcrypt", u)
}
}
}
authUsers = loaded
if migrated {
d, _ := json.MarshalIndent(authUsers, "", " ")
_ = os.WriteFile(usersFilePath, d, 0600)
}
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.
// hash it, save it, and print the plain-text once to the console.
pw := generateRandomPassword()
authUsers = map[string]string{"admin": pw}
if err := os.MkdirAll(dataDir, 0755); err == nil {
hashed, herr := hashPassword(pw)
if herr != nil {
hashed = pw // extremely unlikely; degrade gracefully
log.Printf("[Auth] WARNING: bcrypt failed, storing plain-text password: %v", herr)
}
authUsers = map[string]string{"admin": hashed}
if mkErr := os.MkdirAll(dataDir, 0755); mkErr == 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)
@@ -97,16 +143,19 @@ func loadUsers(dataDir string) {
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 {
hashed, err := hashPassword(password)
if err != nil {
return fmt.Errorf("bcrypt: %w", err)
}
usersMu.Lock()
defer usersMu.Unlock()
authUsers[username] = password
authUsers[username] = hashed
if usersFilePath == "" {
usersFilePath = filepath.Join("data", "users.json")
}
@@ -161,10 +210,10 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
}
usersMu.RLock()
expectedPass, exists := authUsers[user]
storedHash, exists := authUsers[user]
usersMu.RUnlock()
if !exists || subtle.ConstantTimeCompare([]byte(pass), []byte(expectedPass)) != 1 {
if !exists || !checkPassword(storedHash, pass) {
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return