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 <a@b.com>").
- 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.
This commit is contained in:
shaw
2026-10-07 09:27:36 +08:00
parent d97ccc952f
commit 6e315616ed
16 changed files with 368 additions and 57 deletions
+6 -4
View File
@@ -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
+6 -4
View File
@@ -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"
```
#### 升级
+6 -4
View File
@@ -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"
```
#### アップグレード
@@ -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
+10 -15
View File
@@ -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")
+16 -5
View File
@@ -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 <a@b.com>") 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
}
+54 -7
View File
@@ -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{
+236
View File
@@ -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 <owner@example.com>", "<owner@example.com>"} {
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)
}
}
+5 -3
View File
@@ -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=
+1 -1
View File
@@ -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
+8 -8
View File
@@ -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. |
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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:-}
# =======================================================================
+1 -1
View File
@@ -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:-}
# =======================================================================
+1 -1
View File
@@ -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:-}
# =======================================================================
+13
View File
@@ -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!"