From 6e315616edeeedf55cf477ab8f27cb8d7c68ebfb Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 7 Oct 2026 09:27:36 +0800 Subject: [PATCH] fix(setup): stop shipping guessable admin credentials on fresh installs (fixes #7850) Fresh Docker installs created the admin as admin@sub2api.local and accepted any ADMIN_PASSWORD (e.g. "123456"), making them easy brute-force targets. - Generate a random admin-<12 hex>@sub2api.local login email when ADMIN_EMAIL is empty; drop the fixed default from .env.example and the compose files, and have docker-deploy.sh write a random ADMIN_EMAIL. - Validate user-supplied ADMIN_PASSWORD (8-72 bytes) and ADMIN_EMAIL on the auto-setup path, only when an admin is actually about to be created, so existing deployments are never blocked at startup. - Align validateEmail with the login endpoint's email binding so setup can no longer create an admin that can never log in (e.g. "a@b", "Name "). - Cap validatePassword at bcrypt's 72-byte limit; the CLI reuses it and now defaults to a random admin email instead of admin@example.com. - Update READMEs/deploy docs to stop suggesting guessable credentials. --- README.md | 10 +- README_CN.md | 10 +- README_JA.md | 10 +- .../integration/e2e_user_flow_test.go | 5 +- backend/internal/setup/cli.go | 25 +- backend/internal/setup/handler.go | 21 +- backend/internal/setup/setup.go | 61 ++++- backend/internal/setup/setup_test.go | 236 ++++++++++++++++++ deploy/.env.example | 8 +- deploy/APPLE_CONTAINER.md | 2 +- deploy/README.md | 16 +- deploy/docker-compose.dev.yml | 2 +- deploy/docker-compose.local.yml | 2 +- deploy/docker-compose.standalone.yml | 2 +- deploy/docker-compose.yml | 2 +- deploy/docker-deploy.sh | 13 + 16 files changed, 368 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 8ec0b7f59c..1e8ea3f0be 100644 --- a/README.md +++ b/README.md @@ -354,8 +354,10 @@ JWT_SECRET=your_jwt_secret_here TOTP_ENCRYPTION_KEY=your_totp_key_here # Optional: Admin account -ADMIN_EMAIL=admin@example.com -ADMIN_PASSWORD=your_admin_password +# Leave empty to auto-generate a random email (login username) and password, shown in logs on first startup. +# Avoid guessable values such as admin@example.com: they are brute-force targets. +ADMIN_EMAIL= +ADMIN_PASSWORD= # Optional: Custom port SERVER_PORT=8080 @@ -404,9 +406,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api Open `http://YOUR_SERVER_IP:8080` in your browser. -If admin password was auto-generated, find it in logs: +If the admin email (login username) or password was auto-generated, find them in logs: ```bash -docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" +docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin" ``` #### Upgrade diff --git a/README_CN.md b/README_CN.md index 70eef1990d..57d92ca92a 100644 --- a/README_CN.md +++ b/README_CN.md @@ -380,8 +380,10 @@ JWT_SECRET=your_jwt_secret_here TOTP_ENCRYPTION_KEY=your_totp_key_here # 可选:管理员账号 -ADMIN_EMAIL=admin@example.com -ADMIN_PASSWORD=your_admin_password +# 留空则首次启动时自动生成随机邮箱(登录用户名)和密码,并输出到日志。 +# 请勿使用 admin@example.com 这类可被猜中的值,它们是暴力破解的目标。 +ADMIN_EMAIL= +ADMIN_PASSWORD= # 可选:自定义端口 SERVER_PORT=8080 @@ -442,9 +444,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api 在浏览器中打开 `http://你的服务器IP:8080` -如果管理员密码是自动生成的,在日志中查找: +如果管理员邮箱(登录用户名)或密码是自动生成的,在日志中查找: ```bash -docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" +docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin" ``` #### 升级 diff --git a/README_JA.md b/README_JA.md index ca94ba55d3..24ddd4dd63 100644 --- a/README_JA.md +++ b/README_JA.md @@ -355,8 +355,10 @@ JWT_SECRET=your_jwt_secret_here TOTP_ENCRYPTION_KEY=your_totp_key_here # オプション: 管理者アカウント -ADMIN_EMAIL=admin@example.com -ADMIN_PASSWORD=your_admin_password +# 空欄の場合、初回起動時にランダムなメールアドレス(ログインユーザー名)とパスワードが自動生成され、ログに出力されます。 +# admin@example.com のような推測されやすい値は総当たり攻撃の標的になるため避けてください。 +ADMIN_EMAIL= +ADMIN_PASSWORD= # オプション: カスタムポート SERVER_PORT=8080 @@ -405,9 +407,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api ブラウザで `http://YOUR_SERVER_IP:8080` を開いてください。 -管理者パスワードが自動生成された場合は、ログで確認できます: +管理者メールアドレス(ログインユーザー名)またはパスワードが自動生成された場合は、ログで確認できます: ```bash -docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" +docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin" ``` #### アップグレード diff --git a/backend/internal/integration/e2e_user_flow_test.go b/backend/internal/integration/e2e_user_flow_test.go index 5489d0a326..9a28c66230 100644 --- a/backend/internal/integration/e2e_user_flow_test.go +++ b/backend/internal/integration/e2e_user_flow_test.go @@ -264,10 +264,11 @@ func loginTestUser(t *testing.T) string { t.Helper() // 先尝试用管理员账户登录 - adminEmail := getEnv("ADMIN_EMAIL", "admin@sub2api.local") + // 管理员邮箱不再有固定默认值(未设置时安装阶段随机生成),需同时提供邮箱与密码。 + adminEmail := getEnv("ADMIN_EMAIL", "") adminPassword := getEnv("ADMIN_PASSWORD", "") - if adminPassword == "" { + if adminEmail == "" || adminPassword == "" { // 尝试用测试用户 adminEmail = testUserEmail adminPassword = testUserPassword diff --git a/backend/internal/setup/cli.go b/backend/internal/setup/cli.go index 2b323acf4a..8d4e9e419e 100644 --- a/backend/internal/setup/cli.go +++ b/backend/internal/setup/cli.go @@ -4,7 +4,6 @@ package setup import ( "bufio" "fmt" - "net/mail" "os" "regexp" "strconv" @@ -29,11 +28,6 @@ func cliValidateUsername(name string) bool { return validName.MatchString(name) && len(name) <= 63 } -func cliValidateEmail(email string) bool { - _, err := mail.ParseAddress(email) - return err == nil && len(email) <= 254 -} - func cliValidatePort(port int) bool { return port > 0 && port <= 65535 } @@ -163,9 +157,14 @@ func RunCLI() error { fmt.Println() fmt.Println("── Admin Account ──") + // 默认值使用随机邮箱,避免直接回车得到可被猜中的固定管理员用户名(issue #7850)。 + defaultAdminEmail, err := generateAdminEmail() + if err != nil { + return err + } for { - cfg.Admin.Email = promptString(reader, "Admin Email", "admin@example.com") - if cliValidateEmail(cfg.Admin.Email) { + cfg.Admin.Email = promptString(reader, "Admin Email (login username)", defaultAdminEmail) + if validateEmail(cfg.Admin.Email) { break } fmt.Println(" Invalid email format.") @@ -173,13 +172,9 @@ func RunCLI() error { for { cfg.Admin.Password = promptPassword("Admin Password") - // SECURITY: Match Web API requirement of 8 characters minimum - if len(cfg.Admin.Password) < 8 { - fmt.Println(" Password must be at least 8 characters") - continue - } - if len(cfg.Admin.Password) > 128 { - fmt.Println(" Password must be at most 128 characters") + // SECURITY: Match Web API password requirements + if err := validatePassword(cfg.Admin.Password); err != nil { + fmt.Printf(" Invalid password: %v\n", err) continue } confirm := promptPassword("Confirm Password") diff --git a/backend/internal/setup/handler.go b/backend/internal/setup/handler.go index 9207b4335c..e54945061a 100644 --- a/backend/internal/setup/handler.go +++ b/backend/internal/setup/handler.go @@ -13,6 +13,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/sysutil" "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" ) // installMutex prevents concurrent installation attempts (TOCTOU protection) @@ -83,19 +84,29 @@ func validateUsername(name string) bool { return validName.MatchString(name) && len(name) <= 63 } -// validateEmail checks if email format is valid +// validateEmail checks if email format is valid. The address must also pass the +// login endpoint's `binding:"email"` rule, otherwise the admin could be created +// with an email (e.g. "a@b" or "Name ") that can never log in. func validateEmail(email string) bool { - _, err := mail.ParseAddress(email) - return err == nil && len(email) <= 254 + if _, err := mail.ParseAddress(email); err != nil || len(email) > 254 { + return false + } + loginReq := struct { + Email string `binding:"required,email"` + }{Email: email} + return binding.Validator.ValidateStruct(&loginReq) == nil } +// maxPasswordBytes is bcrypt's input limit; longer passwords fail to hash. +const maxPasswordBytes = 72 + // validatePassword checks password strength func validatePassword(password string) error { if len(password) < 8 { return fmt.Errorf("password must be at least 8 characters") } - if len(password) > 128 { - return fmt.Errorf("password must be at most 128 characters") + if len(password) > maxPasswordBytes { + return fmt.Errorf("password must be at most %d bytes", maxPasswordBytes) } return nil } diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index 963926fa4e..922ab62469 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -417,6 +417,10 @@ func createAdminUser(cfg *SetupConfig) (bool, string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + return bootstrapAdminUser(ctx, db, cfg) +} + +func bootstrapAdminUser(ctx context.Context, db *sql.DB, cfg *SetupConfig) (bool, string, error) { var totalUsers int64 if err := db.QueryRowContext(ctx, "SELECT COUNT(1) FROM users").Scan(&totalUsers); err != nil { return false, "", err @@ -430,12 +434,14 @@ func createAdminUser(cfg *SetupConfig) (bool, string, error) { return false, decision.reason, nil } - if strings.TrimSpace(cfg.Admin.Password) == "" { - password, genErr := generateSecret(16) - if genErr != nil { - return false, "", fmt.Errorf("failed to generate admin password: %w", genErr) - } - cfg.Admin.Password = password + emailGenerated, passwordGenerated, err := prepareAdminCredentials(&cfg.Admin) + if err != nil { + return false, "", err + } + if emailGenerated { + fmt.Printf("Generated admin email (login username): %s\n", cfg.Admin.Email) + } + if passwordGenerated { fmt.Printf("Generated admin password (one-time): %s\n", cfg.Admin.Password) fmt.Println("IMPORTANT: Save this password! It will not be shown again.") } @@ -473,6 +479,47 @@ func createAdminUser(cfg *SetupConfig) (bool, string, error) { return true, decision.reason, nil } +// prepareAdminCredentials fills in missing admin credentials with random values +// and rejects invalid user-supplied emails and weak passwords. It only runs +// when an admin is actually about to be created, so existing deployments are +// never affected. A random email keeps fresh installs off the well-known +// default username that brute-force scanners target (issue #7850). +func prepareAdminCredentials(admin *AdminConfig) (emailGenerated, passwordGenerated bool, err error) { + admin.Email = strings.TrimSpace(admin.Email) + if admin.Email == "" { + email, genErr := generateAdminEmail() + if genErr != nil { + return false, false, genErr + } + admin.Email = email + emailGenerated = true + } else if !validateEmail(admin.Email) { + return false, false, fmt.Errorf("invalid admin email: %q is not a valid login email", admin.Email) + } + + if strings.TrimSpace(admin.Password) == "" { + password, genErr := generateSecret(16) + if genErr != nil { + return false, false, fmt.Errorf("failed to generate admin password: %w", genErr) + } + admin.Password = password + passwordGenerated = true + } else if validateErr := validatePassword(admin.Password); validateErr != nil { + return false, false, fmt.Errorf("invalid admin password: %w", validateErr) + } + + return emailGenerated, passwordGenerated, nil +} + +// generateAdminEmail returns a random, non-guessable admin login email. +func generateAdminEmail() (string, error) { + suffix, err := generateSecret(6) + if err != nil { + return "", fmt.Errorf("failed to generate admin email: %w", err) + } + return fmt.Sprintf("admin-%s@sub2api.local", suffix), nil +} + func writeConfigFile(cfg *SetupConfig) error { // Ensure timezone has a default value tz := cfg.Timezone @@ -596,7 +643,7 @@ func AutoSetupFromEnv() error { EnableTLS: getEnvOrDefault("REDIS_ENABLE_TLS", "false") == "true", }, Admin: AdminConfig{ - Email: getEnvOrDefault("ADMIN_EMAIL", "admin@sub2api.local"), + Email: getEnvOrDefault("ADMIN_EMAIL", ""), Password: getEnvOrDefault("ADMIN_PASSWORD", ""), }, Server: ServerConfig{ diff --git a/backend/internal/setup/setup_test.go b/backend/internal/setup/setup_test.go index 7d4320ada0..33ffae25fd 100644 --- a/backend/internal/setup/setup_test.go +++ b/backend/internal/setup/setup_test.go @@ -1,15 +1,19 @@ package setup import ( + "context" "database/sql" "fmt" "os" "path/filepath" + "regexp" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin/binding" "github.com/lib/pq" "gopkg.in/yaml.v3" ) @@ -398,3 +402,235 @@ func TestDatabaseConnectionDoesNotFallbackForTargetAuthenticationError(t *testin t.Fatalf("opened databases = %v, want only configured target %q", opened, cfg.DBName) } } + +func TestPrepareAdminCredentialsGeneratesMissingValues(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: " ", Password: ""} + emailGenerated, passwordGenerated, err := prepareAdminCredentials(&admin) + if err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + if !emailGenerated || !passwordGenerated { + t.Fatalf("generated flags = (%v, %v), want (true, true)", emailGenerated, passwordGenerated) + } + if !regexp.MustCompile(`^admin-[0-9a-f]{12}@sub2api\.local$`).MatchString(admin.Email) { + t.Fatalf("generated email = %q, want admin-<12 hex>@sub2api.local", admin.Email) + } + // 生成的邮箱必须能通过登录接口的 binding:"required,email" 校验。 + loginReq := struct { + Email string `binding:"required,email"` + }{Email: admin.Email} + if err := binding.Validator.ValidateStruct(&loginReq); err != nil { + t.Fatalf("generated email %q rejected by login validator: %v", admin.Email, err) + } + if len(admin.Password) != 32 { + t.Fatalf("generated password length = %d, want 32", len(admin.Password)) + } + + other := AdminConfig{} + if _, _, err := prepareAdminCredentials(&other); err != nil { + t.Fatalf("prepareAdminCredentials() second call error = %v", err) + } + if other.Email == admin.Email { + t.Fatalf("generated emails should be random, got %q twice", admin.Email) + } +} + +func TestPrepareAdminCredentialsKeepsProvidedValues(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: "owner@example.com", Password: "a-strong-password"} + emailGenerated, passwordGenerated, err := prepareAdminCredentials(&admin) + if err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + if emailGenerated || passwordGenerated { + t.Fatalf("generated flags = (%v, %v), want (false, false)", emailGenerated, passwordGenerated) + } + if admin.Email != "owner@example.com" || admin.Password != "a-strong-password" { + t.Fatalf("provided credentials were modified: %+v", admin) + } +} + +func TestPrepareAdminCredentialsRejectsWeakPassword(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + password string + }{ + {name: "too short", password: "123456"}, + {name: "seven chars", password: "1234567"}, + {name: "exceeds bcrypt limit", password: strings.Repeat("a", 73)}, + {name: "former 128 limit", password: strings.Repeat("a", 128)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: "owner@example.com", Password: tt.password} + _, _, err := prepareAdminCredentials(&admin) + if err == nil || !strings.Contains(err.Error(), "invalid admin password") { + t.Fatalf("prepareAdminCredentials(%q) error = %v, want invalid admin password error", tt.password, err) + } + }) + } +} + +func TestPrepareAdminCredentialsRejectsUnloginableEmail(t *testing.T) { + t.Parallel() + + for _, email := range []string{"admin", "a@b", "Owner ", ""} { + t.Run(email, func(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: email, Password: "a-strong-password"} + _, _, err := prepareAdminCredentials(&admin) + if err == nil || !strings.Contains(err.Error(), "invalid admin email") { + t.Fatalf("prepareAdminCredentials(%q) error = %v, want invalid admin email error", email, err) + } + }) + } +} + +func TestPrepareAdminCredentialsTrimsProvidedEmail(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: " owner@example.com\n", Password: "a-strong-password"} + if _, _, err := prepareAdminCredentials(&admin); err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + if admin.Email != "owner@example.com" { + t.Fatalf("email = %q, want trimmed owner@example.com", admin.Email) + } +} + +func TestPrepareAdminCredentialsAcceptsBcryptMaxLengthPassword(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: "owner@example.com", Password: strings.Repeat("a", 72)} + if _, _, err := prepareAdminCredentials(&admin); err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + // 校验上限必须与 bcrypt 实际可哈希的上限一致,否则通过校验后仍会在建号时失败。 + user := service.User{} + if err := user.SetPassword(admin.Password); err != nil { + t.Fatalf("SetPassword() with max-length password error = %v", err) + } +} + +func expectAdminBootstrapCounts(mock sqlmock.Sqlmock, totalUsers, adminUsers int64) { + mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(1) FROM users")). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(totalUsers)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(1) FROM users WHERE role = $1")). + WithArgs(service.RoleAdmin). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(adminUsers)) +} + +func TestBootstrapAdminUserSkipsValidationWhenNotCreating(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + totalUsers int64 + adminUsers int64 + reason string + }{ + {name: "admin exists", totalUsers: 3, adminUsers: 1, reason: adminBootstrapReasonAdminExists}, + {name: "users exist without admin", totalUsers: 3, adminUsers: 0, reason: adminBootstrapReasonUsersExistWithoutAdmin}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer func() { _ = db.Close() }() + expectAdminBootstrapCounts(mock, tt.totalUsers, tt.adminUsers) + + // 已有部署里遗留的弱密码/非法邮箱不能阻断启动。 + cfg := &SetupConfig{Admin: AdminConfig{Email: "admin", Password: "123456"}} + created, reason, err := bootstrapAdminUser(context.Background(), db, cfg) + if err != nil || created || reason != tt.reason { + t.Fatalf("bootstrapAdminUser() = (%v, %q, %v), want (false, %q, nil)", created, reason, err, tt.reason) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected database interaction: %v", err) + } + }) + } +} + +func TestBootstrapAdminUserRejectsWeakPasswordWithoutInsert(t *testing.T) { + t.Parallel() + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer func() { _ = db.Close() }() + expectAdminBootstrapCounts(mock, 0, 0) + + cfg := &SetupConfig{Admin: AdminConfig{Password: "123456"}} + created, _, err := bootstrapAdminUser(context.Background(), db, cfg) + if err == nil || created || !strings.Contains(err.Error(), "invalid admin password") { + t.Fatalf("bootstrapAdminUser() = (%v, %v), want invalid admin password error", created, err) + } + // 未设置 INSERT 期望:若发生插入,sqlmock 会返回非预期调用错误,上面的错误断言即失败。 + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected database interaction: %v", err) + } +} + +func TestBootstrapAdminUserCreatesAdminWithGeneratedCredentials(t *testing.T) { + t.Parallel() + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer func() { _ = db.Close() }() + expectAdminBootstrapCounts(mock, 0, 0) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO users")). + WithArgs( + sqlmock.AnyArg(), sqlmock.AnyArg(), service.RoleAdmin, sqlmock.AnyArg(), + sqlmock.AnyArg(), service.StatusActive, sqlmock.AnyArg(), sqlmock.AnyArg(), + ). + WillReturnResult(sqlmock.NewResult(1, 1)) + + cfg := &SetupConfig{} + created, reason, err := bootstrapAdminUser(context.Background(), db, cfg) + if err != nil || !created || reason != adminBootstrapReasonEmptyDatabase { + t.Fatalf("bootstrapAdminUser() = (%v, %q, %v), want (true, %q, nil)", created, reason, err, adminBootstrapReasonEmptyDatabase) + } + if !regexp.MustCompile(`^admin-[0-9a-f]{12}@sub2api\.local$`).MatchString(cfg.Admin.Email) { + t.Fatalf("admin email = %q, want generated admin-<12 hex>@sub2api.local", cfg.Admin.Email) + } + if len(cfg.Admin.Password) != 32 { + t.Fatalf("admin password length = %d, want generated 32", len(cfg.Admin.Password)) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("database expectations not met: %v", err) + } +} + +func TestPrepareAdminCredentialsAcceptsMinimumLengthPassword(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Password: "12345678"} + emailGenerated, passwordGenerated, err := prepareAdminCredentials(&admin) + if err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + if !emailGenerated || passwordGenerated { + t.Fatalf("generated flags = (%v, %v), want (true, false)", emailGenerated, passwordGenerated) + } + if admin.Password != "12345678" { + t.Fatalf("password = %q, want unchanged", admin.Password) + } +} diff --git a/deploy/.env.example b/deploy/.env.example index 1299e45c56..fa10e83e79 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -206,10 +206,12 @@ REDIS_ENABLE_TLS=false # ----------------------------------------------------------------------------- # Admin Account # ----------------------------------------------------------------------------- -# Email for the admin account -ADMIN_EMAIL=admin@sub2api.local +# Email for the admin account (used as the login username) +# Leave empty to auto-generate a random one (will be shown in logs on first run). +# Avoid well-known values such as admin@sub2api.local: they are brute-force targets. +ADMIN_EMAIL= -# Password for admin account +# Password for admin account (8-72 bytes) # Leave empty to auto-generate (will be shown in logs on first run) ADMIN_PASSWORD= diff --git a/deploy/APPLE_CONTAINER.md b/deploy/APPLE_CONTAINER.md index 9e44f24408..f0b86b33d9 100644 --- a/deploy/APPLE_CONTAINER.md +++ b/deploy/APPLE_CONTAINER.md @@ -41,7 +41,7 @@ nano .env ./apple-container.sh status ``` -Open `http://localhost:8080`. If `ADMIN_PASSWORD` is empty, retrieve the generated password with: +Open `http://localhost:8080`. If `ADMIN_EMAIL` / `ADMIN_PASSWORD` are empty, retrieve the generated admin email (login username) and password with: ```bash ./apple-container.sh logs app diff --git a/deploy/README.md b/deploy/README.md index f38257e397..b71334d0a9 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -79,8 +79,8 @@ docker compose -f docker-compose.local.yml up -d # View logs docker compose -f docker-compose.local.yml logs -f sub2api -# If admin password was auto-generated, find it in logs: -docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" +# If admin email/password were auto-generated, find them in logs: +docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin" # Access Web UI # http://localhost:8080 @@ -112,7 +112,7 @@ mkdir -p data postgres_data redis_data # Start all services using local directory version docker compose -f docker-compose.local.yml up -d -# View logs (check for auto-generated admin password) +# View logs (check for auto-generated admin email and password) docker compose -f docker-compose.local.yml logs -f sub2api # Access Web UI @@ -136,14 +136,14 @@ When using Docker Compose with `AUTO_SETUP=true`: - Connects to PostgreSQL and Redis - Applies database migrations (SQL files in `backend/migrations/*.sql`) and records them in `schema_migrations` - Generates JWT secret (if not provided) - - Creates admin account (password auto-generated if not provided) + - Creates admin account (email and password auto-generated if not provided; a provided password must be 8-72 bytes) - Writes config.yaml 2. No manual Setup Wizard needed - just configure `.env` and start -3. If `ADMIN_PASSWORD` is not set, check logs for the generated password: +3. If `ADMIN_EMAIL` / `ADMIN_PASSWORD` are not set, check logs for the generated admin email (login username) and password: ```bash - docker compose logs sub2api | grep "admin password" + docker compose logs sub2api | grep "Generated admin" ``` ### Startup and Database Recovery @@ -257,8 +257,8 @@ docker compose down -v | `JWT_SECRET` | **Recommended** | *(auto-generated)* | JWT secret (fixed for persistent sessions) | | `TOTP_ENCRYPTION_KEY` | **Recommended** | *(auto-generated)* | TOTP encryption key (fixed for persistent 2FA) | | `SERVER_PORT` | No | `8080` | Server port | -| `ADMIN_EMAIL` | No | `admin@sub2api.local` | Admin email | -| `ADMIN_PASSWORD` | No | *(auto-generated)* | Admin password | +| `ADMIN_EMAIL` | No | *(auto-generated)* | Admin email (login username) | +| `ADMIN_PASSWORD` | No | *(auto-generated)* | Admin password (8-72 bytes) | | `TZ` | No | `Asia/Shanghai` | Timezone | | `UPDATE_GITHUB_TOKEN` | No | *(empty)* | Token for `api.github.com` release checks only; asset downloads remain anonymous. | | `GEMINI_OAUTH_CLIENT_ID` | No | *(builtin)* | Google OAuth client ID (Gemini OAuth). Leave empty to use the built-in Gemini CLI client. | diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 80d9db1785..45789f48ab 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -43,7 +43,7 @@ services: - REDIS_USERNAME=${REDIS_USERNAME:-} - REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_DB=${REDIS_DB:-0} - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} - JWT_SECRET=${JWT_SECRET:-} - SETUP_MIGRATION_TIMEOUT_SECONDS=${SETUP_MIGRATION_TIMEOUT_SECONDS:-0} diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 672463c4d7..32bc439f4d 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -88,7 +88,7 @@ services: # ======================================================================= # Admin Account (auto-created on first run) # ======================================================================= - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # ======================================================================= diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index abbd8c23f7..fe9619df15 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -74,7 +74,7 @@ services: # ======================================================================= # Admin Account (auto-created on first run) # ======================================================================= - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # ======================================================================= diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3308df0ffe..84c98643ea 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -85,7 +85,7 @@ services: # ======================================================================= # Admin Account (auto-created on first run) # ======================================================================= - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # ======================================================================= diff --git a/deploy/docker-deploy.sh b/deploy/docker-deploy.sh index a07f4f417a..97e8e8e04c 100644 --- a/deploy/docker-deploy.sh +++ b/deploy/docker-deploy.sh @@ -5,6 +5,7 @@ # This script prepares deployment files for Sub2API: # - Downloads docker-compose.local.yml and .env.example # - Generates secure secrets (JWT_SECRET, TOTP_ENCRYPTION_KEY, POSTGRES_PASSWORD) +# - Generates a random admin login email (ADMIN_EMAIL) # - Creates necessary data directories # # After running this script, you can start services with: @@ -45,6 +46,14 @@ generate_secret() { openssl rand -hex 32 } +# Generate a random admin login email so new installs never use a guessable default +generate_admin_email() { + local suffix + suffix=$(openssl rand -hex 6) || return 1 + [ -n "$suffix" ] || return 1 + echo "admin-${suffix}@sub2api.local" +} + # Check if command exists command_exists() { command -v "$1" >/dev/null 2>&1 @@ -104,6 +113,7 @@ main() { JWT_SECRET=$(generate_secret) TOTP_ENCRYPTION_KEY=$(generate_secret) POSTGRES_PASSWORD=$(generate_secret) + ADMIN_EMAIL=$(generate_admin_email) # Create .env from .env.example cp .env.example .env @@ -114,11 +124,13 @@ main() { sed -i "s/^JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" .env sed -i "s/^TOTP_ENCRYPTION_KEY=.*/TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY}/" .env sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${POSTGRES_PASSWORD}/" .env + sed -i "s/^ADMIN_EMAIL=.*/ADMIN_EMAIL=${ADMIN_EMAIL}/" .env else # BSD sed (macOS) sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" .env sed -i '' "s/^TOTP_ENCRYPTION_KEY=.*/TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY}/" .env sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${POSTGRES_PASSWORD}/" .env + sed -i '' "s/^ADMIN_EMAIL=.*/ADMIN_EMAIL=${ADMIN_EMAIL}/" .env fi # Create data directories @@ -139,6 +151,7 @@ main() { echo " POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}" echo " JWT_SECRET: ${JWT_SECRET}" echo " TOTP_ENCRYPTION_KEY: ${TOTP_ENCRYPTION_KEY}" + echo " ADMIN_EMAIL: ${ADMIN_EMAIL} (admin login username)" echo "" print_warning "These credentials have been saved to .env file." print_warning "Please keep them secure and do not share publicly!"