From 2af80f842be1e8b556e39765c5524492445f1b69 Mon Sep 17 00:00:00 2001 From: Kim Jun Young Date: Sat, 21 Feb 2026 12:35:24 +0900 Subject: [PATCH 1/7] feat: add bootstrap admin team and user --- .env.example | 7 + README.md | 7 + cmd/server/bootstrap.go | 134 ++++++++++++++++++ cmd/server/main.go | 2 + internal/config/config.go | 51 +++++-- internal/config/config_test.go | 15 ++ scripts/generate_dummy_sql/data_loader.py | 2 +- scripts/generate_dummy_sql/defaults/data.yaml | 2 +- .../generate_dummy_sql/defaults/settings.yaml | 2 + scripts/generate_dummy_sql/generator.py | 64 +++++---- scripts/generate_dummy_sql/main.py | 41 ++++-- scripts/generate_dummy_sql/sql_writer.py | 40 ++++-- 12 files changed, 314 insertions(+), 53 deletions(-) create mode 100644 cmd/server/bootstrap.go diff --git a/.env.example b/.env.example index dc23cbe..a7f2e3b 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,13 @@ LOG_DIR=logs LOG_FILE_PREFIX=app LOG_MAX_BODY_BYTES=1048576 +# Bootstrap +BOOTSTRAP_ADMIN_TEAM=true +BOOTSTRAP_ADMIN_USER=true +BOOTSTRAP_ADMIN_USERNAME=admin +BOOTSTRAP_ADMIN_EMAIL= +BOOTSTRAP_ADMIN_PASSWORD= + # S3 Challenge Files S3_ENABLED=false S3_REGION=ap-northeast-2 diff --git a/README.md b/README.md index 8440135..1c313ac 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,13 @@ LOG_DIR=logs LOG_FILE_PREFIX=app LOG_MAX_BODY_BYTES=1048576 +# Bootstrap +BOOTSTRAP_ADMIN_TEAM_KEY=true +BOOTSTRAP_ADMIN_USER=true +BOOTSTRAP_ADMIN_USERNAME=admin +BOOTSTRAP_ADMIN_EMAIL= +BOOTSTRAP_ADMIN_PASSWORD= + # S3 Challenge Files S3_ENABLED=false S3_REGION=ap-northeast-2 diff --git a/cmd/server/bootstrap.go b/cmd/server/bootstrap.go new file mode 100644 index 0000000..64de09c --- /dev/null +++ b/cmd/server/bootstrap.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "smctf/internal/auth" + "smctf/internal/config" + "smctf/internal/db" + "smctf/internal/logging" + "smctf/internal/models" + "smctf/internal/repo" + + "github.com/uptrace/bun" +) + +const ( + bootstrapAdminTeamName = "Admin" + bootstrapAdminKeyMaxUses = 1 +) + +func bootstrapAdmin(ctx context.Context, cfg config.Config, database *bun.DB, userRepo *repo.UserRepo, teamRepo *repo.TeamRepo, logger *logging.Logger) { + if !cfg.Bootstrap.AdminTeamEnabled && !cfg.Bootstrap.AdminUserEnabled { + return + } + + empty, err := isDatabaseEmpty(ctx, database) + if err != nil { + logger.Error("bootstrap database check error", slog.Any("error", err)) + return + } + + if !empty { + logger.Info("bootstrap skipped: database is not empty") + return + } + + team, err := ensureAdminTeam(ctx, cfg, database, teamRepo) + if err != nil { + logger.Error("bootstrap admin team error", slog.Any("error", err)) + return + } + + if team != nil { + logger.Info("admin team created", slog.Any("team_id", team.ID), slog.Any("team_name", team.Name)) + } + + if team != nil && cfg.Bootstrap.AdminUserEnabled { + user, err := ensureAdminUser(ctx, cfg, team, userRepo) + if err != nil { + logger.Error("bootstrap admin user error", slog.Any("error", err)) + return + } + + if user != nil { + logger.Info("admin user created", slog.Any("user_id", user.ID), slog.Any("email", user.Email)) + } + } +} + +func ensureAdminTeam(ctx context.Context, cfg config.Config, database *bun.DB, teamRepo *repo.TeamRepo) (*models.Team, error) { + team := &models.Team{ + Name: bootstrapAdminTeamName, + CreatedAt: time.Now().UTC(), + } + + if err := teamRepo.Create(ctx, team); err != nil { + if db.IsUniqueViolation(err) { + return ensureAdminTeam(ctx, cfg, database, teamRepo) + } + + return nil, fmt.Errorf("create team: %w", err) + } + + return team, nil +} + +func ensureAdminUser(ctx context.Context, cfg config.Config, team *models.Team, userRepo *repo.UserRepo) (*models.User, error) { + email := strings.TrimSpace(cfg.Bootstrap.AdminEmail) + password := strings.TrimSpace(cfg.Bootstrap.AdminPassword) + if email == "" || password == "" { + return nil, nil + } + + username := strings.TrimSpace(cfg.Bootstrap.AdminUsername) + if username == "" { + username = "admin" + } + + hash, err := auth.HashPassword(password, cfg.PasswordBcryptCost) + if err != nil { + return nil, fmt.Errorf("hash admin password: %w", err) + } + + now := time.Now().UTC() + user := &models.User{ + Email: email, + Username: username, + PasswordHash: hash, + Role: models.AdminRole, + TeamID: team.ID, + CreatedAt: now, + UpdatedAt: now, + } + + if err := userRepo.Create(ctx, user); err != nil { + if db.IsUniqueViolation(err) { + return nil, nil + } + + return nil, fmt.Errorf("create admin user: %w", err) + } + + return user, nil +} + +func isDatabaseEmpty(ctx context.Context, database *bun.DB) (bool, error) { + tables := []string{"users", "teams", "registration_keys"} + for _, table := range tables { + count, err := database.NewSelect().TableExpr(table).Count(ctx) + if err != nil { + return false, fmt.Errorf("count %s: %w", table, err) + } + + if count > 0 { + return false, nil + } + } + + return true, nil +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 7015b7c..a93b031 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -102,6 +102,8 @@ func main() { stackClient := stack.NewClient(cfg.Stack.ProvisionerBaseURL, cfg.Stack.ProvisionerAPIKey, cfg.Stack.ProvisionerTimeout) stackSvc := service.NewStackService(cfg.Stack, stackRepo, challengeRepo, submissionRepo, stackClient, redisClient) + bootstrapAdmin(ctx, cfg, database, userRepo, teamRepo, logger) + if cfg, _, _, err := appConfigSvc.Get(ctx); err != nil { logger.Warn("app config load warning", slog.Any("error", err)) } else if cfg.CTFStartAt == "" && cfg.CTFEndAt == "" { diff --git a/internal/config/config.go b/internal/config/config.go index c589e72..70c38f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,15 +19,16 @@ type Config struct { AutoMigrate bool PasswordBcryptCost int - DB DBConfig - Redis RedisConfig - JWT JWTConfig - Security SecurityConfig - Cache CacheConfig - CORS CORSConfig - Logging LoggingConfig - S3 S3Config - Stack StackConfig + DB DBConfig + Redis RedisConfig + JWT JWTConfig + Security SecurityConfig + Cache CacheConfig + CORS CORSConfig + Logging LoggingConfig + S3 S3Config + Stack StackConfig + Bootstrap BootstrapConfig } type DBConfig struct { @@ -99,6 +100,14 @@ type StackConfig struct { CreateMax int } +type BootstrapConfig struct { + AdminTeamEnabled bool + AdminUserEnabled bool + AdminEmail string + AdminPassword string + AdminUsername string +} + const ( defaultJWTSecret = "change-me" defaultFlagSecret = "change-me-too" @@ -242,6 +251,16 @@ func Load() (Config, error) { errs = append(errs, err) } + bootstrapAdminTeamEnabled, err := getEnvBool("BOOTSTRAP_ADMIN_TEAM", true) + if err != nil { + errs = append(errs, err) + } + + bootstrapAdminUserEnabled, err := getEnvBool("BOOTSTRAP_ADMIN_USER", true) + if err != nil { + errs = append(errs, err) + } + cfg := Config{ AppEnv: appEnv, HTTPAddr: httpAddr, @@ -308,6 +327,13 @@ func Load() (Config, error) { CreateWindow: stackCreateWindow, CreateMax: stackCreateMax, }, + Bootstrap: BootstrapConfig{ + AdminTeamEnabled: bootstrapAdminTeamEnabled, + AdminUserEnabled: bootstrapAdminUserEnabled, + AdminEmail: getEnv("BOOTSTRAP_ADMIN_EMAIL", ""), + AdminPassword: getEnv("BOOTSTRAP_ADMIN_PASSWORD", ""), + AdminUsername: getEnv("BOOTSTRAP_ADMIN_USERNAME", "admin"), + }, } if err := validateConfig(cfg); err != nil { @@ -496,6 +522,7 @@ func Redact(cfg Config) Config { cfg.S3.AccessKeyID = redact(cfg.S3.AccessKeyID) cfg.S3.SecretAccessKey = redact(cfg.S3.SecretAccessKey) cfg.Stack.ProvisionerAPIKey = redact(cfg.Stack.ProvisionerAPIKey) + cfg.Bootstrap.AdminPassword = redact(cfg.Bootstrap.AdminPassword) return cfg } @@ -599,6 +626,12 @@ func FormatForLog(cfg Config) map[string]any { "create_window": seconds(cfg.Stack.CreateWindow), "create_max": cfg.Stack.CreateMax, }, + "bootstrap": map[string]any{ + "admin_team_enabled": cfg.Bootstrap.AdminTeamEnabled, + "admin_user_enabled": cfg.Bootstrap.AdminUserEnabled, + "admin_email": cfg.Bootstrap.AdminEmail, + "admin_password": cfg.Bootstrap.AdminPassword, + }, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fd62f43..b676166 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -749,6 +749,10 @@ func TestRedact(t *testing.T) { if redacted.Stack.ProvisionerAPIKey == cfg.Stack.ProvisionerAPIKey { t.Fatalf("expected stack api key redacted") } + + if redacted.Bootstrap.AdminPassword == cfg.Bootstrap.AdminPassword { + t.Fatalf("expected bootstrap admin password redacted") + } } func TestRedactValueEdgeCases(t *testing.T) { @@ -831,6 +835,12 @@ func TestFormatForLog(t *testing.T) { ProvisionerAPIKey: "stack-key", ProvisionerTimeout: 5 * time.Second, }, + Bootstrap: BootstrapConfig{ + AdminTeamEnabled: true, + AdminUserEnabled: true, + AdminEmail: "admin@example.com", + AdminPassword: "adminpass", + }, } out := FormatForLog(cfg) @@ -848,6 +858,11 @@ func TestFormatForLog(t *testing.T) { t.Fatalf("expected secrets redacted") } + bootstrap := out["bootstrap"].(map[string]any) + if bootstrap["admin_password"].(string) == "adminpass" { + t.Fatalf("expected bootstrap admin password redacted") + } + if out["app_env"] != "local" || out["http_addr"] != ":8080" { t.Fatalf("expected top-level config fields") } diff --git a/scripts/generate_dummy_sql/data_loader.py b/scripts/generate_dummy_sql/data_loader.py index 7bc0659..0e09cd7 100644 --- a/scripts/generate_dummy_sql/data_loader.py +++ b/scripts/generate_dummy_sql/data_loader.py @@ -39,7 +39,7 @@ def validate_data(data: Dict[str, Any], user_count: int, min_user_names: int) -> ): raise SystemExit(f"Error: challenge entry {idx} is missing required fields") - if user_count - 1 > len(users): + if user_count > len(users): raise SystemExit( f"Error: requested {user_count} users but only {len(users)} user names available" ) diff --git a/scripts/generate_dummy_sql/defaults/data.yaml b/scripts/generate_dummy_sql/defaults/data.yaml index 3062897..f385259 100644 --- a/scripts/generate_dummy_sql/defaults/data.yaml +++ b/scripts/generate_dummy_sql/defaults/data.yaml @@ -197,7 +197,7 @@ challenges: flag: "flag{vm_1ns1de_vm_exploit3d}" category: "Pwnable" stack_enabled: true - stack_target_port: 150 + stack_target_port: 80 stack_pod_spec_path: "./stack_pod_spec.yaml" file_name: "challenge.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" diff --git a/scripts/generate_dummy_sql/defaults/settings.yaml b/scripts/generate_dummy_sql/defaults/settings.yaml index 7f2773f..21432ec 100644 --- a/scripts/generate_dummy_sql/defaults/settings.yaml +++ b/scripts/generate_dummy_sql/defaults/settings.yaml @@ -10,6 +10,8 @@ auth: username: "admin" password: "admin123!" role: "admin" +bootstrap: + use_bootstrap_admin: true counts: users: 30 registration_keys: 30 diff --git a/scripts/generate_dummy_sql/generator.py b/scripts/generate_dummy_sql/generator.py index 2104b6a..d830645 100644 --- a/scripts/generate_dummy_sql/generator.py +++ b/scripts/generate_dummy_sql/generator.py @@ -64,33 +64,41 @@ def generate_teams( def generate_users( user_names: List[Dict[str, str]], count: int, - team_ids: List[int], + admin_team_id: int, + non_admin_team_ids: List[int], timing: Dict[str, Any], probabilities: Dict[str, Any], auth: Dict[str, Any], bcrypt_cost: int, + include_admin: bool = True, ) -> List[Tuple[str, str, str, str, str, int]]: - if not team_ids: - raise ValueError("team_ids must not be empty") + if include_admin and admin_team_id <= 0: + raise ValueError("admin_team_id must be positive") + if not non_admin_team_ids: + raise ValueError("non_admin_team_ids must not be empty") users = [] - selected_names = random.sample(user_names, count - 1) - base_time = datetime.now(UTC) - timedelta(hours=timing["users_base_hours_ago"]) - admin = auth["admin"] - admin_password_hash = hash_password(admin["password"], bcrypt_cost) - admin_time = base_time.strftime("%Y-%m-%d %H:%M:%S") - users.append( - ( - admin["email"], - admin["username"], - admin_password_hash, - admin["role"], - admin_time, - team_ids[0], + if include_admin: + admin = auth["admin"] + admin_password_hash = hash_password(admin["password"], bcrypt_cost) + admin_time = base_time.strftime("%Y-%m-%d %H:%M:%S") + users.append( + ( + admin["email"], + admin["username"], + admin_password_hash, + admin["role"], + admin_time, + admin_team_id, + ) ) - ) + + remaining = count - (1 if include_admin else 0) + if remaining < 0: + remaining = 0 + selected_names = random.sample(user_names, remaining) spread_hours = timing["user_created_hours_spread"] @@ -100,7 +108,7 @@ def generate_users( password_hash = hash_password(auth["default_password"], bcrypt_cost) created_at = base_time + timedelta(hours=random.random() * spread_hours) created_at_str = created_at.strftime("%Y-%m-%d %H:%M:%S") - team_id = random.choice(team_ids) + team_id = random.choice(non_admin_team_ids) users.append((email, username, password_hash, "user", created_at_str, team_id)) @@ -208,11 +216,12 @@ def generate_challenges( def generate_registration_keys( - user_count: int, + user_ids: List[int], team_ids: List[int], timing: Dict[str, Any], probabilities: Dict[str, Any], count: int, + created_by: int, ) -> Tuple[ List[Tuple[int, str, int, int, int, int, str]], List[Tuple[int, int, str, str]], @@ -229,6 +238,7 @@ def generate_registration_keys( used_limit = max( 1, int(count * probabilities["registration_keys"]["used_fraction"]) ) + candidate_users = [uid for uid in user_ids if uid != created_by] seen_codes = set() for i in range(count): @@ -250,17 +260,17 @@ def generate_registration_keys( max_uses = random.randint(1, REGISTRATION_CODE_MAX_USES) used_count = 0 - if i < used_limit and user_count > 1: + if i < used_limit and candidate_users: used_count = random.randint(1, max_uses) for _ in range(used_count): - used_by = random.randint(2, user_count) + used_by = random.choice(candidate_users) used_by_ip = f"203.0.113.{random.randint(1, 254)}" used_at = created_at + timedelta(minutes=random.randint(5, 180)) used_at_str = used_at.strftime("%Y-%m-%d %H:%M:%S") uses.append((key_id, used_by, used_by_ip, used_at_str)) keys.append( - (key_id, code, 1, team_id, max_uses, used_count, created_at_str) + (key_id, code, created_by, team_id, max_uses, used_count, created_at_str) ) return keys, uses @@ -272,13 +282,15 @@ def generate_submissions( timing: Dict[str, Any], probabilities: Dict[str, Any], secret: str, + start_user_id: int = 1, + skip_first_user: bool = False, ) -> List[Tuple[int, int, str, bool, str, bool]]: submissions = [] base_time = datetime.now(UTC) - timedelta( hours=timing["submissions_base_hours_ago"] ) - user_team_map = {idx + 1: user[5] for idx, user in enumerate(users)} + user_team_map = {start_user_id + idx: user[5] for idx, user in enumerate(users)} team_solved = {team_id: set() for team_id in set(user_team_map.values())} prob = probabilities["submissions"] @@ -302,7 +314,11 @@ def generate_submissions( challenge_count = len(challenges) - for user_id in range(2, len(users) + 1): + user_ids = list(user_team_map.keys()) + if skip_first_user and user_ids: + user_ids = [uid for uid in user_ids if uid != start_user_id] + + for user_id in user_ids: skill_level = random.betavariate(beta_alpha, beta_beta) attempt_count = random.randint(attempts_min, attempts_max) attempted_challenges = set() diff --git a/scripts/generate_dummy_sql/main.py b/scripts/generate_dummy_sql/main.py index a8cc43d..30b7f95 100644 --- a/scripts/generate_dummy_sql/main.py +++ b/scripts/generate_dummy_sql/main.py @@ -128,10 +128,14 @@ def main(argv: List[str]) -> int: counts = settings["counts"] constraints = settings["constraints"] - validate_data(data, counts["users"], constraints["min_user_names"]) + bootstrap = settings.get("bootstrap", {}) + use_bootstrap_admin = bool(bootstrap.get("use_bootstrap_admin", True)) + non_admin_count = max(0, counts["users"] - 1) + validate_data(data, non_admin_count, constraints["min_user_names"]) security = settings["security"] auth = settings["auth"] + admin_team_name = "Admin" stack_config = settings.get("stack", {}) files_config = settings.get("files", {}) stack_pod_spec = "" @@ -154,10 +158,20 @@ def main(argv: List[str]) -> int: if args.output: output_file = args.output + team_names = list(data["teams"]) + if admin_team_name in team_names: + team_names = [name for name in team_names if name != admin_team_name] + team_names = [admin_team_name] + team_names + print("About to generate dummy SQL data.") print(f"Output file: {output_file}") - print(f"Users: {counts['users']} (including admin)") - print(f"Teams: {len(data['teams'])}") + admin_note = ( + "1 bootstrapped admin + " + if use_bootstrap_admin + else "including generated admin, " + ) + print(f"Users: {counts['users']} ({admin_note}{non_admin_count} generated)") + print(f"Teams: {len(team_names)}") print(f"Challenges: {len(data['challenges'])}") print(f"Registration keys: {counts['registration_keys']}") proceed = input("Type 'Y' to continue: ").strip() @@ -165,17 +179,22 @@ def main(argv: List[str]) -> int: print("Aborted.") return 0 - teams = generate_teams(data["teams"], settings["timing"]) + teams = generate_teams(team_names, settings["timing"]) team_ids = list(range(1, len(teams) + 1)) + admin_team_id = team_ids[0] + non_admin_team_ids = team_ids[1:] users = generate_users( data["users"], counts["users"], - team_ids, + admin_team_id, + non_admin_team_ids, settings["timing"], settings["probabilities"], auth, bcrypt_cost, + include_admin=True, ) + user_ids = list(range(1, len(users) + 1)) challenges = generate_challenges( data["challenges"], settings["timing"], @@ -186,11 +205,12 @@ def main(argv: List[str]) -> int: files_config, ) registration_keys, registration_key_uses = generate_registration_keys( - len(users), - team_ids, + user_ids, + non_admin_team_ids, settings["timing"], settings["probabilities"], counts["registration_keys"], + created_by=1, ) submissions = generate_submissions( users, @@ -198,6 +218,8 @@ def main(argv: List[str]) -> int: settings["timing"], settings["probabilities"], flag_secret, + start_user_id=1, + skip_first_user=True, ) write_sql_file( @@ -214,13 +236,16 @@ def main(argv: List[str]) -> int: "default_password": auth["default_password"], "admin_email": auth["admin"]["email"], "admin_password": auth["admin"]["password"], + "include_admin": not use_bootstrap_admin, + "bootstrap_mode": use_bootstrap_admin, }, ) print("\nSummary") print(f"- Output: {output_file}") print(f"- Teams: {len(teams)}") - print(f"- Users: {len(users)}") + total_users = len(users) + (1 if use_bootstrap_admin else 0) + print(f"- Users: {total_users}") print(f"- Challenges: {len(challenges)}") print(f"- Registration keys: {len(registration_keys)}") print(f"- Submissions: {len(submissions)}") diff --git a/scripts/generate_dummy_sql/sql_writer.py b/scripts/generate_dummy_sql/sql_writer.py index 8dbf58f..468c189 100644 --- a/scripts/generate_dummy_sql/sql_writer.py +++ b/scripts/generate_dummy_sql/sql_writer.py @@ -39,34 +39,54 @@ def write_sql_file( f.write(f"-- FLAG_HMAC_SECRET: {meta['flag_hmac_secret']}\n") f.write(f"-- BCRYPT_COST: {meta['bcrypt_cost']}\n") f.write(f"-- Default password for all users: {meta['default_password']}\n") - f.write( - f"-- Admin credentials: {meta['admin_email']} / {meta['admin_password']}\n\n" - ) + if meta.get("include_admin", True): + f.write( + f"-- Admin credentials: {meta['admin_email']} / {meta['admin_password']}\n\n" + ) + else: + f.write("-- Admin credentials: bootstrapped (see server config)\n\n") f.write("-- App Config\n") f.write("INSERT INTO app_config (key, value, updated_at) VALUES ('title', 'Welcome to My CTF!', NOW()), ('description', 'this is a sample CTF description.', NOW());\n\n") f.write("-- Clear existing data\n") - f.write( - "TRUNCATE TABLE submissions, registration_key_uses, registration_keys, challenges, users, teams RESTART IDENTITY CASCADE;\n\n" - ) + if meta.get("bootstrap_mode", False): + f.write( + "TRUNCATE TABLE submissions, registration_key_uses, registration_keys, challenges RESTART IDENTITY CASCADE;\n" + ) + f.write( + "-- TRUNCATE TABLE users, teams, submissions, registration_key_uses, registration_keys, challenges RESTART IDENTITY CASCADE;\n\n" + ) + else: + f.write( + "-- TRUNCATE TABLE submissions, registration_key_uses, registration_keys, challenges RESTART IDENTITY CASCADE;\n" + ) + f.write( + "TRUNCATE TABLE users, teams, submissions, registration_key_uses, registration_keys, challenges RESTART IDENTITY CASCADE;\n\n" + ) f.write("-- Insert teams\n") - for name, created_at in teams: + for idx, (name, created_at) in enumerate(teams, start=1): name_esc = escape_sql_string(name) - f.write("INSERT INTO teams (name, created_at) VALUES ") + prefix = "" + if meta.get("bootstrap_mode", False) and idx == 1: + prefix = "-- " + f.write(f"{prefix}INSERT INTO teams (name, created_at) VALUES ") f.write(f"('{name_esc}', '{created_at}');\n") f.write("\n") f.write("-- Insert users\n") - for email, username, password_hash, role, created_at, team_id in users: + for idx, (email, username, password_hash, role, created_at, team_id) in enumerate(users, start=1): email_esc = escape_sql_string(email) username_esc = escape_sql_string(username) password_hash_esc = escape_sql_string(password_hash) role_esc = escape_sql_string(role) + prefix = "" + if meta.get("bootstrap_mode", False) and idx == 1: + prefix = "-- " f.write( - "INSERT INTO users (email, username, password_hash, role, team_id, created_at, updated_at) VALUES " + f"{prefix}INSERT INTO users (email, username, password_hash, role, team_id, created_at, updated_at) VALUES " ) f.write( f"('{email_esc}', '{username_esc}', '{password_hash_esc}', '{role_esc}', {team_id}, '{created_at}', '{created_at}');\n" From 4d34ee26be6e14b7a02e415977098932ba4d723e Mon Sep 17 00:00:00 2001 From: Kim Jun Young Date: Sat, 21 Feb 2026 12:50:39 +0900 Subject: [PATCH 2/7] chore: update cmd/server/bootstrap.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/server/bootstrap.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/server/bootstrap.go b/cmd/server/bootstrap.go index 64de09c..c36566d 100644 --- a/cmd/server/bootstrap.go +++ b/cmd/server/bootstrap.go @@ -18,8 +18,7 @@ import ( ) const ( - bootstrapAdminTeamName = "Admin" - bootstrapAdminKeyMaxUses = 1 + bootstrapAdminTeamName = "Admin" ) func bootstrapAdmin(ctx context.Context, cfg config.Config, database *bun.DB, userRepo *repo.UserRepo, teamRepo *repo.TeamRepo, logger *logging.Logger) { From e7bacc60aa49fed6d30b65042f3448f9a79ac60e Mon Sep 17 00:00:00 2001 From: Kim Jun Young Date: Sat, 21 Feb 2026 12:51:55 +0900 Subject: [PATCH 3/7] chore: update scripts/generate_dummy_sql/main.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/generate_dummy_sql/main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/generate_dummy_sql/main.py b/scripts/generate_dummy_sql/main.py index 30b7f95..f88409c 100644 --- a/scripts/generate_dummy_sql/main.py +++ b/scripts/generate_dummy_sql/main.py @@ -244,8 +244,9 @@ def main(argv: List[str]) -> int: print("\nSummary") print(f"- Output: {output_file}") print(f"- Teams: {len(teams)}") - total_users = len(users) + (1 if use_bootstrap_admin else 0) - print(f"- Users: {total_users}") + print(f"- Users: {len(users)}") + if use_bootstrap_admin: + print("- Admin user will be bootstrapped separately") print(f"- Challenges: {len(challenges)}") print(f"- Registration keys: {len(registration_keys)}") print(f"- Submissions: {len(submissions)}") From 2bf9e2faaa69994822f9159b45d9285928fad463 Mon Sep 17 00:00:00 2001 From: Kim Jun Young Date: Sat, 21 Feb 2026 12:52:21 +0900 Subject: [PATCH 4/7] chore: update cmd/server/bootstrap.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/server/bootstrap.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/server/bootstrap.go b/cmd/server/bootstrap.go index c36566d..cafa558 100644 --- a/cmd/server/bootstrap.go +++ b/cmd/server/bootstrap.go @@ -55,7 +55,7 @@ func bootstrapAdmin(ctx context.Context, cfg config.Config, database *bun.DB, us } if user != nil { - logger.Info("admin user created", slog.Any("user_id", user.ID), slog.Any("email", user.Email)) + logger.Info("admin user created", slog.Any("user_id", user.ID)) } } } From b200aeb935d3eb0a2c84872cdc6d08f43566adab Mon Sep 17 00:00:00 2001 From: Kim Jun Young Date: Sat, 21 Feb 2026 12:54:42 +0900 Subject: [PATCH 5/7] chore: update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1c313ac..744e4d0 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ LOG_FILE_PREFIX=app LOG_MAX_BODY_BYTES=1048576 # Bootstrap -BOOTSTRAP_ADMIN_TEAM_KEY=true +BOOTSTRAP_ADMIN_TEAM=true BOOTSTRAP_ADMIN_USER=true BOOTSTRAP_ADMIN_USERNAME=admin BOOTSTRAP_ADMIN_EMAIL= From df29e4139840912e2aa66b858d63f5a14d761330 Mon Sep 17 00:00:00 2001 From: Kim Jun Young Date: Sat, 21 Feb 2026 12:56:17 +0900 Subject: [PATCH 6/7] chore: update cmd/server/bootstrap.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/server/bootstrap.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/cmd/server/bootstrap.go b/cmd/server/bootstrap.go index cafa558..0b37de1 100644 --- a/cmd/server/bootstrap.go +++ b/cmd/server/bootstrap.go @@ -37,14 +37,19 @@ func bootstrapAdmin(ctx context.Context, cfg config.Config, database *bun.DB, us return } - team, err := ensureAdminTeam(ctx, cfg, database, teamRepo) - if err != nil { - logger.Error("bootstrap admin team error", slog.Any("error", err)) - return - } + var team *models.Team + var err error + + if cfg.Bootstrap.AdminTeamEnabled { + team, err = ensureAdminTeam(ctx, cfg, database, teamRepo) + if err != nil { + logger.Error("bootstrap admin team error", slog.Any("error", err)) + return + } - if team != nil { - logger.Info("admin team created", slog.Any("team_id", team.ID), slog.Any("team_name", team.Name)) + if team != nil { + logger.Info("admin team created", slog.Any("team_id", team.ID), slog.Any("team_name", team.Name)) + } } if team != nil && cfg.Bootstrap.AdminUserEnabled { From 23e1a629ab7c239cc41b53ba4d0bfd60c838132e Mon Sep 17 00:00:00 2001 From: Kim Jun Young Date: Sat, 21 Feb 2026 13:17:32 +0900 Subject: [PATCH 7/7] feat: add tests for bootstrap --- cmd/server/main.go | 3 +- .../bootstrap}/bootstrap.go | 11 +- internal/bootstrap/bootstrap_test.go | 220 ++++++++++++++++++ internal/bootstrap/testenv_test.go | 121 ++++++++++ internal/config/config.go | 3 + internal/config/config_test.go | 9 + scripts/generate_dummy_sql/sql_writer.py | 13 +- 7 files changed, 363 insertions(+), 17 deletions(-) rename {cmd/server => internal/bootstrap}/bootstrap.go (89%) create mode 100644 internal/bootstrap/bootstrap_test.go create mode 100644 internal/bootstrap/testenv_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index a93b031..1a2a46b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,6 +9,7 @@ import ( "syscall" "time" + "smctf/internal/bootstrap" "smctf/internal/cache" "smctf/internal/config" "smctf/internal/db" @@ -102,7 +103,7 @@ func main() { stackClient := stack.NewClient(cfg.Stack.ProvisionerBaseURL, cfg.Stack.ProvisionerAPIKey, cfg.Stack.ProvisionerTimeout) stackSvc := service.NewStackService(cfg.Stack, stackRepo, challengeRepo, submissionRepo, stackClient, redisClient) - bootstrapAdmin(ctx, cfg, database, userRepo, teamRepo, logger) + bootstrap.BootstrapAdmin(ctx, cfg, database, userRepo, teamRepo, logger) if cfg, _, _, err := appConfigSvc.Get(ctx); err != nil { logger.Warn("app config load warning", slog.Any("error", err)) diff --git a/cmd/server/bootstrap.go b/internal/bootstrap/bootstrap.go similarity index 89% rename from cmd/server/bootstrap.go rename to internal/bootstrap/bootstrap.go index 0b37de1..cc98799 100644 --- a/cmd/server/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -1,4 +1,4 @@ -package main +package bootstrap import ( "context" @@ -21,7 +21,7 @@ const ( bootstrapAdminTeamName = "Admin" ) -func bootstrapAdmin(ctx context.Context, cfg config.Config, database *bun.DB, userRepo *repo.UserRepo, teamRepo *repo.TeamRepo, logger *logging.Logger) { +func BootstrapAdmin(ctx context.Context, cfg config.Config, database *bun.DB, userRepo *repo.UserRepo, teamRepo *repo.TeamRepo, logger *logging.Logger) { if !cfg.Bootstrap.AdminTeamEnabled && !cfg.Bootstrap.AdminUserEnabled { return } @@ -38,10 +38,9 @@ func bootstrapAdmin(ctx context.Context, cfg config.Config, database *bun.DB, us } var team *models.Team - var err error if cfg.Bootstrap.AdminTeamEnabled { - team, err = ensureAdminTeam(ctx, cfg, database, teamRepo) + team, err = ensureAdminTeam(ctx, teamRepo) if err != nil { logger.Error("bootstrap admin team error", slog.Any("error", err)) return @@ -65,7 +64,7 @@ func bootstrapAdmin(ctx context.Context, cfg config.Config, database *bun.DB, us } } -func ensureAdminTeam(ctx context.Context, cfg config.Config, database *bun.DB, teamRepo *repo.TeamRepo) (*models.Team, error) { +func ensureAdminTeam(ctx context.Context, teamRepo *repo.TeamRepo) (*models.Team, error) { team := &models.Team{ Name: bootstrapAdminTeamName, CreatedAt: time.Now().UTC(), @@ -73,7 +72,7 @@ func ensureAdminTeam(ctx context.Context, cfg config.Config, database *bun.DB, t if err := teamRepo.Create(ctx, team); err != nil { if db.IsUniqueViolation(err) { - return ensureAdminTeam(ctx, cfg, database, teamRepo) + return nil, nil } return nil, fmt.Errorf("create team: %w", err) diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..d747da1 --- /dev/null +++ b/internal/bootstrap/bootstrap_test.go @@ -0,0 +1,220 @@ +package bootstrap + +import ( + "context" + "path/filepath" + "testing" + "time" + + "smctf/internal/config" + "smctf/internal/logging" + "smctf/internal/models" + "smctf/internal/repo" + + "golang.org/x/crypto/bcrypt" +) + +func newTestLogger(t *testing.T) *logging.Logger { + t.Helper() + dir := t.TempDir() + logger, err := logging.New(config.LoggingConfig{ + Dir: filepath.Join(dir, "logs"), + FilePrefix: "test", + MaxBodyBytes: 1024, + }, logging.Options{ + Service: "bootstrap-test", + Env: "test", + }) + + if err != nil { + t.Fatalf("logger init: %v", err) + } + + t.Cleanup(func() { + _ = logger.Close() + }) + + return logger +} + +func baseBootstrapConfig() config.Config { + return config.Config{ + AppEnv: "test", + PasswordBcryptCost: bcrypt.MinCost, + Bootstrap: config.BootstrapConfig{ + AdminTeamEnabled: true, + AdminUserEnabled: true, + AdminEmail: "admin@example.com", + AdminPassword: "adminpass", + AdminUsername: "admin", + }, + } +} + +func TestIsDatabaseEmpty(t *testing.T) { + db := setupBootstrapDB(t) + + empty, err := isDatabaseEmpty(context.Background(), db) + if err != nil { + t.Fatalf("isDatabaseEmpty: %v", err) + } + + if !empty { + t.Fatalf("expected empty database") + } + + teamRepo := repo.NewTeamRepo(db) + team := &models.Team{ + Name: "Temp", + CreatedAt: time.Now().UTC(), + } + + if err := teamRepo.Create(context.Background(), team); err != nil { + t.Fatalf("create team: %v", err) + } + + empty, err = isDatabaseEmpty(context.Background(), db) + if err != nil { + t.Fatalf("isDatabaseEmpty after insert: %v", err) + } + + if empty { + t.Fatalf("expected database to be non-empty") + } +} + +func TestEnsureAdminTeam(t *testing.T) { + db := setupBootstrapDB(t) + teamRepo := repo.NewTeamRepo(db) + + team, err := ensureAdminTeam(context.Background(), teamRepo) + if err != nil { + t.Fatalf("ensureAdminTeam: %v", err) + } + + if team == nil || team.Name != "Admin" { + t.Fatalf("expected admin team created") + } + + team, err = ensureAdminTeam(context.Background(), teamRepo) + if err != nil { + t.Fatalf("ensureAdminTeam second call: %v", err) + } + + if team != nil { + t.Fatalf("expected no team on duplicate create") + } +} + +func TestEnsureAdminUser(t *testing.T) { + db := setupBootstrapDB(t) + userRepo := repo.NewUserRepo(db) + teamRepo := repo.NewTeamRepo(db) + + cfg := baseBootstrapConfig() + team, err := ensureAdminTeam(context.Background(), teamRepo) + if err != nil { + t.Fatalf("ensureAdminTeam: %v", err) + } + + if team == nil { + t.Fatalf("expected team for admin user") + } + + user, err := ensureAdminUser(context.Background(), cfg, team, userRepo) + if err != nil { + t.Fatalf("ensureAdminUser: %v", err) + } + + if user == nil || user.Role != models.AdminRole || user.TeamID != team.ID { + t.Fatalf("unexpected admin user") + } + + user, err = ensureAdminUser(context.Background(), cfg, team, userRepo) + if err != nil { + t.Fatalf("ensureAdminUser second call: %v", err) + } + + if user != nil { + t.Fatalf("expected no user on duplicate create") + } +} + +func TestBootstrapAdminCreatesTeamAndUser(t *testing.T) { + db := setupBootstrapDB(t) + userRepo := repo.NewUserRepo(db) + teamRepo := repo.NewTeamRepo(db) + + cfg := baseBootstrapConfig() + logger := newTestLogger(t) + + BootstrapAdmin(context.Background(), cfg, db, userRepo, teamRepo, logger) + + var teamCount int + if err := db.NewSelect().TableExpr("teams").ColumnExpr("COUNT(*)").Scan(context.Background(), &teamCount); err != nil { + t.Fatalf("count teams: %v", err) + } + + if teamCount != 1 { + t.Fatalf("expected 1 team, got %d", teamCount) + } + + var userCount int + if err := db.NewSelect().TableExpr("users").ColumnExpr("COUNT(*)").Scan(context.Background(), &userCount); err != nil { + t.Fatalf("count users: %v", err) + } + + if userCount != 1 { + t.Fatalf("expected 1 user, got %d", userCount) + } +} + +func TestBootstrapAdminSkipsWhenNotEmpty(t *testing.T) { + db := setupBootstrapDB(t) + userRepo := repo.NewUserRepo(db) + teamRepo := repo.NewTeamRepo(db) + + cfg := baseBootstrapConfig() + logger := newTestLogger(t) + + team := &models.Team{ + Name: "Existing", + CreatedAt: time.Now().UTC(), + } + if err := teamRepo.Create(context.Background(), team); err != nil { + t.Fatalf("create team: %v", err) + } + + BootstrapAdmin(context.Background(), cfg, db, userRepo, teamRepo, logger) + + var userCount int + if err := db.NewSelect().TableExpr("users").ColumnExpr("COUNT(*)").Scan(context.Background(), &userCount); err != nil { + t.Fatalf("count users: %v", err) + } + + if userCount != 0 { + t.Fatalf("expected no users, got %d", userCount) + } +} + +func TestBootstrapAdminDisabled(t *testing.T) { + db := setupBootstrapDB(t) + userRepo := repo.NewUserRepo(db) + teamRepo := repo.NewTeamRepo(db) + + cfg := baseBootstrapConfig() + cfg.Bootstrap.AdminTeamEnabled = false + cfg.Bootstrap.AdminUserEnabled = false + logger := newTestLogger(t) + + BootstrapAdmin(context.Background(), cfg, db, userRepo, teamRepo, logger) + + var teamCount int + if err := db.NewSelect().TableExpr("teams").ColumnExpr("COUNT(*)").Scan(context.Background(), &teamCount); err != nil { + t.Fatalf("count teams: %v", err) + } + + if teamCount != 0 { + t.Fatalf("expected 0 teams, got %d", teamCount) + } +} diff --git a/internal/bootstrap/testenv_test.go b/internal/bootstrap/testenv_test.go new file mode 100644 index 0000000..76dcf69 --- /dev/null +++ b/internal/bootstrap/testenv_test.go @@ -0,0 +1,121 @@ +package bootstrap + +import ( + "context" + "os" + "testing" + "time" + + "smctf/internal/config" + "smctf/internal/db" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "github.com/uptrace/bun" +) + +var ( + testDB *bun.DB + pgContainer testcontainers.Container + skipDBTests bool +) + +func TestMain(m *testing.M) { + skipDBTests = os.Getenv("SMCTF_SKIP_INTEGRATION") != "" + if skipDBTests { + os.Exit(m.Run()) + } + + ctx := context.Background() + container, dbCfg, err := startPostgres(ctx) + if err != nil { + panic(err) + } + + pgContainer = container + + testDB, err = db.New(dbCfg, "test") + if err != nil { + panic(err) + } + + if err := db.AutoMigrate(ctx, testDB); err != nil { + panic(err) + } + + code := m.Run() + + if testDB != nil { + _ = testDB.Close() + } + + if pgContainer != nil { + _ = pgContainer.Terminate(ctx) + } + + os.Exit(code) +} + +func startPostgres(ctx context.Context) (testcontainers.Container, config.DBConfig, error) { + req := testcontainers.ContainerRequest{ + Image: "postgres:16-alpine", + ExposedPorts: []string{"5432/tcp"}, + Env: map[string]string{ + "POSTGRES_USER": "smctf", + "POSTGRES_PASSWORD": "smctf", + "POSTGRES_DB": "smctf_test", + }, + WaitingFor: wait.ForListeningPort("5432/tcp"), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return nil, config.DBConfig{}, err + } + + host, err := container.Host(ctx) + if err != nil { + _ = container.Terminate(ctx) + return nil, config.DBConfig{}, err + } + + port, err := container.MappedPort(ctx, "5432") + if err != nil { + _ = container.Terminate(ctx) + return nil, config.DBConfig{}, err + } + + cfg := config.DBConfig{ + Host: host, + Port: port.Int(), + User: "smctf", + Password: "smctf", + Name: "smctf_test", + SSLMode: "disable", + MaxOpenConns: 5, + MaxIdleConns: 5, + ConnMaxLifetime: 2 * time.Minute, + } + + return container, cfg, nil +} + +func setupBootstrapDB(t *testing.T) *bun.DB { + t.Helper() + if skipDBTests { + t.Skip("db tests disabled via SMCTF_SKIP_INTEGRATION") + } + + if err := db.AutoMigrate(context.Background(), testDB); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + if _, err := testDB.ExecContext(context.Background(), "TRUNCATE TABLE submissions, registration_key_uses, registration_keys, challenges, users, teams RESTART IDENTITY CASCADE"); err != nil { + t.Fatalf("truncate: %v", err) + } + + return testDB +} diff --git a/internal/config/config.go b/internal/config/config.go index 70c38f9..beca296 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -522,7 +522,9 @@ func Redact(cfg Config) Config { cfg.S3.AccessKeyID = redact(cfg.S3.AccessKeyID) cfg.S3.SecretAccessKey = redact(cfg.S3.SecretAccessKey) cfg.Stack.ProvisionerAPIKey = redact(cfg.Stack.ProvisionerAPIKey) + cfg.Bootstrap.AdminEmail = redact(cfg.Bootstrap.AdminEmail) cfg.Bootstrap.AdminPassword = redact(cfg.Bootstrap.AdminPassword) + return cfg } @@ -629,6 +631,7 @@ func FormatForLog(cfg Config) map[string]any { "bootstrap": map[string]any{ "admin_team_enabled": cfg.Bootstrap.AdminTeamEnabled, "admin_user_enabled": cfg.Bootstrap.AdminUserEnabled, + "admin_username": cfg.Bootstrap.AdminUsername, "admin_email": cfg.Bootstrap.AdminEmail, "admin_password": cfg.Bootstrap.AdminPassword, }, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b676166..70d3186 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -719,9 +719,14 @@ func TestRedact(t *testing.T) { Stack: StackConfig{ ProvisionerAPIKey: "stack-key", }, + Bootstrap: BootstrapConfig{ + AdminEmail: "admin@example.com", + AdminPassword: "adminpass", + }, } redacted := Redact(cfg) + if redacted.DB.Password == cfg.DB.Password { t.Fatalf("expected db password redacted") } @@ -750,6 +755,10 @@ func TestRedact(t *testing.T) { t.Fatalf("expected stack api key redacted") } + if redacted.Bootstrap.AdminEmail == cfg.Bootstrap.AdminEmail { + t.Fatalf("expected bootstrap admin email redacted") + } + if redacted.Bootstrap.AdminPassword == cfg.Bootstrap.AdminPassword { t.Fatalf("expected bootstrap admin password redacted") } diff --git a/scripts/generate_dummy_sql/sql_writer.py b/scripts/generate_dummy_sql/sql_writer.py index 468c189..7f30a25 100644 --- a/scripts/generate_dummy_sql/sql_writer.py +++ b/scripts/generate_dummy_sql/sql_writer.py @@ -50,20 +50,13 @@ def write_sql_file( f.write("INSERT INTO app_config (key, value, updated_at) VALUES ('title', 'Welcome to My CTF!', NOW()), ('description', 'this is a sample CTF description.', NOW());\n\n") f.write("-- Clear existing data\n") + f.write("TRUNCATE TABLE submissions, registration_key_uses, registration_keys, challenges RESTART IDENTITY CASCADE;\n") if meta.get("bootstrap_mode", False): f.write( - "TRUNCATE TABLE submissions, registration_key_uses, registration_keys, challenges RESTART IDENTITY CASCADE;\n" - ) - f.write( - "-- TRUNCATE TABLE users, teams, submissions, registration_key_uses, registration_keys, challenges RESTART IDENTITY CASCADE;\n\n" + "-- TRUNCATE TABLE users, teams RESTART IDENTITY CASCADE;\n\n" ) else: - f.write( - "-- TRUNCATE TABLE submissions, registration_key_uses, registration_keys, challenges RESTART IDENTITY CASCADE;\n" - ) - f.write( - "TRUNCATE TABLE users, teams, submissions, registration_key_uses, registration_keys, challenges RESTART IDENTITY CASCADE;\n\n" - ) + f.write("TRUNCATE TABLE users, teams RESTART IDENTITY CASCADE;\n\n") f.write("-- Insert teams\n") for idx, (name, created_at) in enumerate(teams, start=1):