diff --git a/README.md b/README.md index 3b8059d..1ab85d6 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Thanks to this, you can integrate it using **GitHub Actions**, **Drone CI** or e For the CLI configuration, please refer to the binary's specific API through `ctfd-setup --help`. In use of IaC provisionning scenario, the corresponding environment variables are also mapped to the output, so please refer to it. -### GitHub Actions +### GitHub Actions To improve our own workflows and share knownledges and tooling, we built a GitHub Action: `ctfer-io/ctfd-setup`. You can use it given the following example. diff --git a/cmd/ctfd-setup/main.go b/cmd/ctfd-setup/main.go index 93905a0..8084b0e 100644 --- a/cmd/ctfd-setup/main.go +++ b/cmd/ctfd-setup/main.go @@ -5,6 +5,7 @@ import ( "log" "os" "os/signal" + "path/filepath" "syscall" ctfdsetup "github.com/ctfer-io/ctfd-setup" @@ -47,110 +48,378 @@ func main() { EnvVars: []string{"URL", "PLUGIN_URL"}, Category: management, }, + &cli.StringFlag{ + Name: "api_key", + Usage: "The API key to use (for instance for a CI SA), used for updating a running CTFd instance.", + EnvVars: []string{"API_KEY", "PLUGIN_API_KEY"}, + Category: management, + }, // Configuration file - // => global + // => Appearance &cli.StringFlag{ - Name: "global.name", - Usage: "The name of your CTF, displayed as is. Required.", - EnvVars: []string{"GLOBAL_NAME", "PLUGIN_GLOBAL_NAME"}, + Name: "appearance.name", + Usage: "The name of your CTF, displayed as is.", + EnvVars: []string{"APPEARANCE_NAME", "PLUGIN_APPEARANCE_NAME"}, Category: configuration, }, &cli.StringFlag{ - Name: "global.description", - Usage: "The description of your CTF, displayed as is. Required.", - EnvVars: []string{"GLOBAL_DESCRIPTION", "PLUGIN_GLOBAL_DESCRIPTION"}, + Name: "appearance.description", + Usage: "The description of your CTF, displayed as is.", + EnvVars: []string{"APPEARANCE_DESCRIPTION", "PLUGIN_APPEARANCE_DESCRIPTION"}, Category: configuration, }, + // => Theme &cli.StringFlag{ - Name: "global.mode", - Usage: "The mode of your CTFd, either users or teams.", - Value: "users", - EnvVars: []string{"GLOBAL_MODE", "PLUGIN_GLOBAL_MODE"}, + Name: "theme.logo", + Usage: "The frontend logo. Provide a path to a locally-accessible file.", + EnvVars: []string{"THEME_LOGO", "PLUGIN_THEME_LOGO"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "theme.small_icon", + Usage: "The frontend small icon. Provide a path to a locally-accessible file.", + EnvVars: []string{"THEME_SMALL_ICON", "PLUGIN_THEME_SMALL_ICON"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "theme.name", + Usage: "The frontend theme name.", + Value: "core", + EnvVars: []string{"THEME_NAME", "PLUGIN_THEME_NAME"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "theme.color", + Usage: "The frontend theme color.", + EnvVars: []string{"THEME_THEME_COLOR", "PLUGIN_THEME_THEME_COLOR"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "theme.header", + Usage: "The frontend header. Provide a path to a locally-accessible file.", + EnvVars: []string{"THEME_HEADER", "PLUGIN_THEME_HEADER"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "theme.footer", + Usage: "The frontend footer. Provide a path to a locally-accessible file.", + EnvVars: []string{"THEME_FOOTER", "PLUGIN_THEME_FOOTER"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "theme.settings", + Usage: "The frontend settings (JSON). Provide a path to a locally-accessible file.", + EnvVars: []string{"THEME_SETTINGS", "PLUGIN_THEME_SETTINGS"}, + Category: configuration, + }, + // => Accounts + &cli.StringFlag{ + Name: "accounts.domain_whitelist", + Usage: "The domain whitelist (a list separated by colons) to allow users to have email addresses from.", + EnvVars: []string{"ACCOUNTS_DOMAIN_WHITELIST", "PLUGIN_ACCOUNTS_DOMAIN_WHITELIST"}, + Category: configuration, + }, + &cli.BoolFlag{ + Name: "accounts.verify_emails", + Usage: "Whether to verify emails once a user register or not.", + Value: false, + EnvVars: []string{"ACCOUNTS_VERIFY_EMAILS", "PLUGIN_ACCOUNTS_VERIFY_EMAILS"}, + Category: configuration, + }, + &cli.BoolFlag{ + Name: "accounts.team_creation", + Usage: "Whether to allow team creation by players or not.", + EnvVars: []string{"ACCOUNTS_TEAM_CREATION", "PLUGIN_ACCOUNTS_TEAM_CREATION"}, + Category: configuration, + }, + &cli.IntFlag{ + Name: "accounts.team_size", + Usage: "Maximum size (number of players) in a team.", + EnvVars: []string{"ACCOUNTS_TEAM_SIZE", "PLUGIN_ACCOUNTS_TEAM_SIZE"}, + Category: configuration, + }, + &cli.IntFlag{ + Name: "accounts.num_teams", + Usage: "The total number of teams allowed.", + EnvVars: []string{"ACCOUNTS_NUM_TEAMS", "PLUGIN_ACCOUNTS_NUM_TEAMS"}, + Category: configuration, + }, + &cli.IntFlag{ + Name: "accounts.num_users", + Usage: "The total number of users allowed.", + EnvVars: []string{"ACCOUNTS_NUM_USERS", "PLUGIN_ACCOUNTS_NUM_USERS"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "accounts.team_disbanding", + Usage: "Whether to allow teams to be disbanded or not. Could be inactive_only or disabled.", + EnvVars: []string{"ACCOUNTS_TEAM_DISBANDING", "PLUGIN_ACCOUNTS_TEAM_DISBANDING"}, + Category: configuration, + }, + &cli.IntFlag{ + Name: "accounts.incorrect_submissions_per_minute", + Usage: "Maximum number of invalid submissions per minute (per user/team). We suggest you use it as part of an anti-brute-force strategy (rate limiting).", + EnvVars: []string{"ACCOUNTS_INCORRECT_SUBMISSIONS_PER_MINUTE", "PLUGIN_ACCOUNTS_INCORRECT_SUBMISSIONS_PER_MINUTE"}, + Category: configuration, + }, + &cli.BoolFlag{ + Name: "accounts.name_changes", + Usage: "Whether a user can change its name or not.", + EnvVars: []string{"ACCOUNTS_NAME_CHANGES", "PLUGIN_ACCOUNTS_NAME_CHANGES"}, Category: configuration, }, + // => Pages &cli.StringFlag{ - Name: "global.team_size", - Usage: "The team size you want to enforce. Works only if global.mode is \"teams\".", - EnvVars: []string{"GLOBAL_TEAM_SIZE", "PLUGIN_GLOBAL_TEAM_SIZE"}, + Name: "pages.robots_txt", + Usage: "Define the /robots.txt file content, for web crawlers indexing.", + EnvVars: []string{"PAGES_ROBOTS_TXT", "PLUGIN_PAGES_ROBOTS_TXT"}, Category: configuration, }, - // => visibilities + // => MajorLeagueCyber &cli.StringFlag{ - Name: "visibilities.challenge", + Name: "major_league_cyber.client_id", + Usage: "The MajorLeagueCyber OAuth ClientID.", + EnvVars: []string{"MAJOR_LEAGUE_CYBER_CLIENT_ID", "PLUGIN_MAJOR_LEAGUE_CYBER_CLIENT_ID"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "major_league_cyber.client_secret", + Usage: "The MajorLeagueCyber OAuth Client Secret.", + EnvVars: []string{"MAJOR_LEAGUE_CYBER_CLIENT_SECRET", "PLUGIN_MAJOR_LEAGUE_CYBER_CLIENT_SECRET"}, + Category: configuration, + }, + // => Settings + &cli.StringFlag{ + Name: "settings.challenge_visibility", Usage: "The visibility for the challenges. Please refer to CTFd documentation (https://docs.ctfd.io/docs/settings/visibility-settings/)", Value: "public", - EnvVars: []string{"VISIBILITIES_CHALLENGE", "PLUGIN_VISIBILITIES_CHALLENGE"}, + EnvVars: []string{"SETTINGS_CHALLENGE_VISIBILITY", "PLUGIN_SETTINGS_CHALLENGE_VISIBILITY"}, Category: configuration, }, &cli.StringFlag{ - Name: "visibilities.account", + Name: "settings.account_visibility", Usage: "The visibility for the accounts. Please refer to CTFd documentation (https://docs.ctfd.io/docs/settings/visibility-settings/)", Value: "public", - EnvVars: []string{"VISIBILITIES_ACCOUNT", "PLUGIN_VISIBILITIES_ACCOUNT"}, + EnvVars: []string{"SETTINGS_ACCOUNT_VISIBILITY", "PLUGIN_SETTINGS_ACCOUNT_VISIBILITY"}, Category: configuration, }, &cli.StringFlag{ - Name: "visibilities.score", + Name: "settings.score_visibility", Usage: "The visibility for the scoreboard. Please refer to CTFd documentation (https://docs.ctfd.io/docs/settings/visibility-settings/)", Value: "public", - EnvVars: []string{"VISIBILITIES_SCORE", "PLUGIN_VISIBILITIES_SCORE"}, + EnvVars: []string{"SETTINGS_SCORE_VISIBILITY", "PLUGIN_SETTINGS_SCORE_VISIBILITY"}, Category: configuration, }, &cli.StringFlag{ - Name: "visibilities.registration", + Name: "settings.registration_visibility", Usage: "The visibility for the registration. Please refer to CTFd documentation (https://docs.ctfd.io/docs/settings/visibility-settings/)", Value: "public", - EnvVars: []string{"VISIBILITIES_REGISTRATION", "PLUGIN_VISIBILITIES_REGISTRATION"}, + EnvVars: []string{"SETTINGS_REGISTRATION_VISIBILITY", "PLUGIN_SETTINGS_REGISTRATION_VISIBILITY"}, + Category: configuration, + }, + &cli.BoolFlag{ + Name: "settings.paused", + Usage: "Whether the CTFd is paused or not.", + EnvVars: []string{"SETTINGS_PAUSED", "PLUGIN_SETTINGS_PAUSED"}, + Category: configuration, + }, + // => Security + &cli.BoolFlag{ + Name: "security.html_sanitization", + Usage: "Whether to turn on HTML sanitization or not.", + EnvVars: []string{"SECURITY_HTML_SANITIZATION", "PLUGIN_SECURITY_HTML_SANITIZATION"}, Category: configuration, }, - // => front &cli.StringFlag{ - Name: "front.theme", - Usage: "The frontend theme name.", - Value: "core", - EnvVars: []string{"FRONT_THEME", "PLUGIN_FRONT_THEME"}, + Name: "security.registration_code", + Usage: "The registration code (secret) to join the CTF.", + EnvVars: []string{"SECURITY_REGISTRATION_CODE", "PLUGIN_SECURITY_REGISTRATION_CODE"}, Category: configuration, }, + // => Email &cli.StringFlag{ - Name: "front.theme_color", - Usage: "The frontend theme color.", - EnvVars: []string{"FRONT_THEME_COLOR", "PLUGIN_FRONT_THEME_COLOR"}, + Name: "email.registration.subject", + Usage: "The email registration subject of the mail.", + EnvVars: []string{"EMAIL_REGISTRATION_SUBJECT", "PLUGIN_EMAIL_REGISTRATION_SUBJECT"}, Category: configuration, }, &cli.StringFlag{ - Name: "front.logo", - Usage: "The frontend logo. Provide a path to a locally-accessible file.", - EnvVars: []string{"FRONT_LOGO", "PLUGIN_FRONT_LOGO"}, + Name: "email.registration.body", + Usage: "The email registration body of the mail.", + EnvVars: []string{"EMAIL_REGISTRATION_BODY", "PLUGIN_EMAIL_REGISTRATION_BODY"}, Category: configuration, }, &cli.StringFlag{ - Name: "front.banner", - Usage: "The frontend banner. Provide a path to a locally-accessible file.", - EnvVars: []string{"FRONT_BANNER", "PLUGIN_FRONT_BANNER"}, + Name: "email.confirmation.subject", + Usage: "The email confirmation subject of the mail.", + EnvVars: []string{"EMAIL_CONFIRMATION_SUBJECT", "PLUGIN_EMAIL_CONFIRMATION_SUBJECT"}, Category: configuration, }, &cli.StringFlag{ - Name: "front.small_icon", - Usage: "The frontend small icon. Provide a path to a locally-accessible file.", - EnvVars: []string{"FRONT_SMALL_ICON", "PLUGIN_FRONT_SMALL_ICON"}, + Name: "email.confirmation.body", + Usage: "The email confirmation body of the mail.", + EnvVars: []string{"EMAIL_CONFIRMATION_BODY", "PLUGIN_EMAIL_CONFIRMATION_BODY"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "email.new_account.subject", + Usage: "The email new_account subject of the mail.", + EnvVars: []string{"EMAIL_NEW_ACCOUNT_SUBJECT", "PLUGIN_EMAIL_NEW_ACCOUNT_SUBJECT"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "email.new_account.body", + Usage: "The email new_account body of the mail.", + EnvVars: []string{"EMAIL_REGISTRATION_BODY", "PLUGIN_EMAIL_REGISTRATION_BODY"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "email.password_reset.subject", + Usage: "The email password_reset subject of the mail.", + EnvVars: []string{"EMAIL_PASSWORD_RESET_SUBJECT", "PLUGIN_EMAIL_PASSWORD_RESET_SUBJECT"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "email.password_reset.body", + Usage: "The email password_reset body of the mail.", + EnvVars: []string{"EMAIL_PASSWORD_RESET_BODY", "PLUGIN_EMAIL_PASSWORD_RESET_BODY"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "email.password_reset_confirmation.subject", + Usage: "The email password_reset_confirmation subject of the mail.", + EnvVars: []string{"EMAIL_PASSWORD_RESET_CONFIRMATION_SUBJECT", "PLUGIN_EMAIL_PASSWORD_RESET_CONFIRMATION_SUBJECT"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "email.password_reset_confirmation.body", + Usage: "The email password_reset_confirmation body of the mail.", + EnvVars: []string{"EMAIL_PASSWORD_RESET_CONFIRMATION_BODY", "PLUGIN_EMAIL_PASSWORD_RESET_CONFIRMATION_BODY"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "email.from", + Usage: "The 'From:' to sent to mail with.", + EnvVars: []string{"EMAIL_MAIL_FROM", "PLUGIN_EMAIL_MAIL_FROM"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "email.server", + Usage: "The mail server to use.", + EnvVars: []string{"EMAIL_MAIL_SERVER", "PLUGIN_EMAIL_MAIL_SERVER"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "email.port", + Usage: "The mail server port to reach.", + EnvVars: []string{"EMAIL_MAIL_SERVER_PORT", "PLUGIN_EMAIL_MAIL_SERVER_PORT"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "email.username", + Usage: "The username to log in to the mail server.", + EnvVars: []string{"EMAIL_USERNAME", "PLUGIN_EMAIL_USERNAME"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "email.password", + Usage: "The password to log in to the mail server.", + EnvVars: []string{"EMAIL_PASSWORD", "PLUGIN_EMAIL_PASSWORD"}, + Category: configuration, + }, + &cli.BoolFlag{ + Name: "email.tls_ssl", + Usage: "Whether to turn on TLS/SSL or not.", + EnvVars: []string{"EMAIL_TLS_SSL", "PLUGIN_EMAIL_TLS_SSL"}, + Category: configuration, + }, + &cli.BoolFlag{ + Name: "email.starttls", + Usage: "Whether to turn on STARTTLS or not.", + EnvVars: []string{"EMAIL_STARTTLS", "PLUGIN_EMAIL_STARTTLS"}, + Category: configuration, + }, + // => Time + &cli.StringFlag{ + Name: "time.start", + Usage: "The start timestamp at which the CTFd will open.", + EnvVars: []string{"TIME_START", "PLUGIN_TIME_START"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "time.end", + Usage: "The end timestamp at which the CTFd will close.", + EnvVars: []string{"TIME_END", "PLUGIN_TIME_END"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "time.freeze", + Usage: "The freeze timestamp at which the CTFd will remain open but won't accept any further submissions.", + EnvVars: []string{"TIME_FREEZE", "PLUGIN_TIME_FREEZE"}, + Category: configuration, + }, + &cli.BoolFlag{ + Name: "time.view_after", + Usage: "Whether allows users to view challenges after end or not.", + EnvVars: []string{"TIME_VIEW_AFTER", "PLUGIN_TIME_VIEW_AFTER"}, + Category: configuration, + }, + // => Social + &cli.BoolFlag{ + Name: "social.shares", + Usage: "Whether to enable users share they solved a challenge or not.", + EnvVars: []string{"SOCIAL_SHARES", "PLUGIN_SOCIAL_SHARES"}, + Category: configuration, + }, + // => Legal + &cli.StringFlag{ + Name: "legal.tos.url", + Usage: "The Terms of Services URL.", + EnvVars: []string{"LEGAL_TOS_URL", "PLUGIN_LEGAL_TOS_URL"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "legal.tos.content", + Usage: "The Terms of Services content.", + EnvVars: []string{"LEGAL_TOS_CONTENT", "PLUGIN_LEGAL_TOS_CONTENT"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "legal.privacy_policy.url", + Usage: "The Privacy Policy URL.", + EnvVars: []string{"LEGAL_PRIVACY_POLICY_URL", "PLUGIN_LEGAL_PRIVACY_POLICY_URL"}, + Category: configuration, + }, + &cli.StringFlag{ + Name: "legal.privacy_policy.content", + Usage: "The Privacy Policy content.", + EnvVars: []string{"LEGAL_PRIVACY_POLICY_CONTENT", "PLUGIN_LEGAL_PRIVACY_POLICY_CONTENT"}, + Category: configuration, + }, + // => UserMode + &cli.StringFlag{ + Name: "mode", + Usage: "The mode of your CTFd, either users or teams.", + Value: "users", + EnvVars: []string{"MODE", "PLUGIN_MODE"}, Category: configuration, }, // => admin &cli.StringFlag{ Name: "admin.name", - Usage: "The administrator name. Required.", + Usage: "The administrator name. Immutable, or need the administrator to change the CTFd data AND the configuration (CLI, varenv, file). Required.", EnvVars: []string{"ADMIN_NAME", "PLUGIN_ADMIN_NAME"}, Category: configuration, }, &cli.StringFlag{ Name: "admin.email", - Usage: "The administrator email address. Required.", + Usage: "The administrator email address. Immutable, or need the administrator to change the CTFd data AND the configuration (CLI, varenv, file). Required.", EnvVars: []string{"ADMIN_EMAIL", "PLUGIN_ADMIN_EMAIL"}, Category: configuration, }, &cli.StringFlag{ Name: "admin.password", - Usage: "The administrator password. Required. Please use the environment variables.", + Usage: "The administrator password, recommended to use the varenvs. Immutable, or need the administrator to change the CTFd data AND the configuration (CLI, varenv, file). Required.", EnvVars: []string{"ADMIN_PASSWORD", "PLUGIN_ADMIN_PASSWORD"}, Category: configuration, }, @@ -183,29 +452,120 @@ func main() { func run(ctx *cli.Context) error { log := ctfdsetup.Log() + logo, err := filePtr(ctx, "theme.logo") + if err != nil { + return err + } + smallIcon, err := filePtr(ctx, "theme.small_icon") + if err != nil { + return err + } + header, err := filePtr(ctx, "theme.header") + if err != nil { + return err + } + footer, err := filePtr(ctx, "theme.footer") + if err != nil { + return err + } + settings, err := filePtr(ctx, "theme.settings") + if err != nil { + return err + } + robotsTxt, err := filePtr(ctx, "pages.robots_txt") + if err != nil { + return err + } conf := &ctfdsetup.Config{ - Global: ctfdsetup.Global{ - Name: ctx.String("global.name"), - Description: ctx.String("global.description"), - Mode: ctx.String("global.mode"), - TeamSize: toPtr(ctx.Int("global.team_size")), - VerifyEmails: ctx.Bool("global.verify_emails"), - Start: ctx.String("global.start"), - End: ctx.String("global.end"), + Appearance: ctfdsetup.Appearance{ + Name: ctx.String("appearance.name"), + Description: ctx.String("appearance.description"), }, - Visibilities: ctfdsetup.Visibilities{ - Challenge: ctx.String("visibilities.challenge"), - Account: ctx.String("visibilities.account"), - Score: ctx.String("visibilities.score"), - Registration: ctx.String("visibilities.registration"), + Theme: ctfdsetup.Theme{ + Logo: logo, + SmallIcon: smallIcon, + Name: ctx.String("theme.name"), + Color: ctx.String("theme.color"), + Header: header, + Footer: footer, + Settings: settings, }, - Front: ctfdsetup.Front{ - Theme: ctx.String("front.theme"), - ThemeColor: ctx.String("front.color"), - Logo: toPtr(ctx.String("front.logo")), - Banner: toPtr(ctx.String("front.banner")), - SmallIcon: toPtr(ctx.String("front.small_icon")), + Accounts: ctfdsetup.Accounts{ + DomainWhitelist: stringPtr(ctx, "accounts.domain_whitelist"), + VerifyEmails: ctx.Bool("accounts.verify_emails"), + TeamCreation: boolPtr(ctx, "accounts.team_creation"), + TeamSize: intPtr(ctx, "accounts.team_size"), + NumTeams: intPtr(ctx, "accounts.num_teams"), + NumUsers: intPtr(ctx, "accounts.num_users"), + TeamDisbanding: stringPtr(ctx, "accounts.team_disbanding"), + IncorrectSubmissionsPerMinute: intPtr(ctx, "accounts.incorrect_submissions_per_minute"), + NameChanges: boolPtr(ctx, "accounts.name_changes"), }, + Pages: ctfdsetup.Pages{ + RobotsTxt: robotsTxt, + }, + MajorLeagueCyber: ctfdsetup.MajorLeagueCyber{ + ClientID: stringPtr(ctx, "major_league_cyber.client_id"), + ClientSecret: stringPtr(ctx, "major_league_cyber.client_secret"), + }, + Settings: ctfdsetup.Settings{ + ChallengeVisibility: ctx.String("settings.challenge_visibility"), + AccountVisibility: ctx.String("settings.account_visibility"), + ScoreVisibility: ctx.String("settings.score_visibility"), + RegistrationVisibility: ctx.String("settings.registration_visibility"), + Paused: boolPtr(ctx, "settings.paused"), + }, + Security: ctfdsetup.Security{ + HTMLSanitization: boolPtr(ctx, "security.html_sanitization"), + RegistrationCode: stringPtr(ctx, "security.registration_code"), + }, + Email: ctfdsetup.Email{ + Registration: ctfdsetup.EmailContent{ + Subject: stringPtr(ctx, "email.registration.subject"), + Body: stringPtr(ctx, "email.registration.body"), + }, + Confirmation: ctfdsetup.EmailContent{ + Subject: stringPtr(ctx, "email.confirmation.subject"), + Body: stringPtr(ctx, "email.confirmation.body"), + }, + NewAccount: ctfdsetup.EmailContent{ + Subject: stringPtr(ctx, "email.new_account.subject"), + Body: stringPtr(ctx, "email.new_account.body"), + }, + PasswordReset: ctfdsetup.EmailContent{ + Subject: stringPtr(ctx, "email.password_reset.subject"), + Body: stringPtr(ctx, "email.password_reset.body"), + }, + PasswordResetConfirmation: ctfdsetup.EmailContent{ + Subject: stringPtr(ctx, "email.password_reset_confirmation.subject"), + Body: stringPtr(ctx, "email.password_reset_confirmation.body"), + }, + From: stringPtr(ctx, "email.mail_from"), + Server: stringPtr(ctx, "email.mail_server"), + Port: stringPtr(ctx, "email.mail_server_port"), + Username: stringPtr(ctx, "email.username"), + Password: stringPtr(ctx, "email.password"), + }, + Time: ctfdsetup.Time{ + Start: stringPtr(ctx, "time.start"), + End: stringPtr(ctx, "time.end"), + Freeze: stringPtr(ctx, "time.freeze"), + ViewAfter: boolPtr(ctx, "time.view_after"), + }, + Social: ctfdsetup.Social{ + Shares: boolPtr(ctx, "social.shares"), + }, + Legal: ctfdsetup.Legal{ + TOS: ctfdsetup.ExternalReference{ + URL: stringPtr(ctx, "legal.tos.url"), + Content: stringPtr(ctx, "legal.tos.content"), + }, + PrivacyPolicy: ctfdsetup.ExternalReference{ + URL: stringPtr(ctx, "legal.privacy_policy.url"), + Content: stringPtr(ctx, "legal.privacy_policy.content"), + }, + }, + Mode: ctx.String("mode"), Admin: ctfdsetup.Admin{ Name: ctx.String("admin.name"), Email: ctx.String("admin.email"), @@ -232,13 +592,40 @@ func run(ctx *cli.Context) error { } // Connect to CTFd - url := ctx.String("url") - return ctfdsetup.Setup(ctx.Context, url, conf) + return ctfdsetup.Setup(ctx.Context, ctx.String("url"), ctx.String("api_key"), conf) +} + +func stringPtr(ctx *cli.Context, key string) *string { + return genPtr(ctx, key, ctx.String) +} + +func boolPtr(ctx *cli.Context, key string) *bool { + return genPtr(ctx, key, ctx.Bool) +} + +func intPtr(ctx *cli.Context, key string) *int { + return genPtr(ctx, key, ctx.Int) +} + +func filePtr(ctx *cli.Context, key string) (*ctfdsetup.File, error) { + if !ctx.IsSet(key) { + return &ctfdsetup.File{}, nil + } + fp := ctx.String(key) + content, err := os.ReadFile(fp) + if err != nil { + return nil, err + } + return &ctfdsetup.File{ + Name: filepath.Base(fp), + Content: []byte(content), + }, nil } -func toPtr[T comparable](t T) *T { - if t == *new(T) { - return &t +func genPtr[T string | int | bool](ctx *cli.Context, key string, f func(key string) T) *T { + if ctx.IsSet(key) { + return nil } - return nil + v := f(key) + return &v } diff --git a/config.go b/config.go index 2ea2514..4aaa197 100644 --- a/config.go +++ b/config.go @@ -8,35 +8,113 @@ import ( type ( Config struct { - Global Global `yaml:"global"` - Visibilities Visibilities `yaml:"visibilities"` - Front Front `yaml:"front"` - Admin Admin `yaml:"admin"` + Appearance Appearance `yaml:"appearance"` + Theme Theme `yaml:"theme"` + Accounts Accounts `yaml:"accounts"` + Pages Pages `yaml:"pages"` + // Don't handle brackets here, should not be part of those settings but CRUD objects + // CustomFields are not handled as they are not predictable and would be hard to handle + bad practice (API changes on the fly) + MajorLeagueCyber MajorLeagueCyber `yaml:"major_league_cyber"` + Settings Settings `yaml:"settings"` + Security Security `yaml:"security"` + Email Email `yaml:"email"` + Time Time `yaml:"time"` + Social Social `yaml:"social"` + Legal Legal `yaml:"legal"` + Mode string `yaml:"mode"` + + Admin Admin `yaml:"admin"` + } + + Appearance struct { + Name string `yaml:"name"` // required + Description string `yaml:"description"` // required + } + + Theme struct { + Logo *File `yaml:"logo"` + SmallIcon *File `yaml:"small_icon"` + Name string `yaml:"name"` + Color string `yaml:"color"` + // Banner is only supported by bare setup, need to be at least support by PatchConfigs + Header *File `yaml:"header"` + Footer *File `yaml:"footer"` + Settings *File `yaml:"settings"` + } + + Accounts struct { + DomainWhitelist *string `yaml:"domain_whitelist"` + VerifyEmails bool `yaml:"verify_emails"` + TeamCreation *bool `yaml:"team_creation"` + TeamSize *int `yaml:"team_size"` + NumTeams *int `yaml:"num_teams"` + NumUsers *int `yaml:"num_users"` + TeamDisbanding *string `yaml:"team_disbanding"` + IncorrectSubmissionsPerMinute *int `yaml:"incorrect_submissions_per_minutes"` + NameChanges *bool `yaml:"name_changes"` + } + + Pages struct { + RobotsTxt *File `yaml:"robots_txt"` + } + + MajorLeagueCyber struct { + ClientID *string `yaml:"client_id"` + ClientSecret *string `yaml:"client_secret"` + } + + Settings struct { + ChallengeVisibility string `yaml:"challenge_visibility"` + AccountVisibility string `yaml:"account_visibility"` + ScoreVisibility string `yaml:"score_visibility"` + RegistrationVisibility string `yaml:"registration_visibility"` + Paused *bool `yaml:"paused"` + } + + Security struct { + HTMLSanitization *bool `yaml:"html_sanitization"` + RegistrationCode *string `yaml:"registration_code"` + } + + Email struct { + Registration EmailContent `yaml:"registration"` + Confirmation EmailContent `yaml:"confirmation"` + NewAccount EmailContent `yaml:"new_account"` + PasswordReset EmailContent `yaml:"password_reset"` + PasswordResetConfirmation EmailContent `yaml:"password_reset_confirmation"` + From *string `yaml:"from"` + Server *string `yaml:"server"` + Port *string `yaml:"port"` + Username *string `yaml:"username"` + Password *string `yaml:"password"` + TLS_SSL *bool `yaml:"tls_ssl"` + STARTTLS *bool `yaml:"starttls"` + } + + EmailContent struct { + Subject *string `yaml:"subject"` + Body *string `yaml:"body"` + } + + Time struct { + Start *string `yaml:"start"` + End *string `yaml:"end"` + Freeze *string `yaml:"freeze"` + ViewAfter *bool `yaml:"view_after"` } - Global struct { - Name string `yaml:"name"` // required - Description string `yaml:"description"` // required - Mode string `yaml:"mode"` - TeamSize *int `yaml:"team_size"` - VerifyEmails bool `yaml:"verify_emails"` - Start string `yaml:"start"` - End string `yaml:"end"` + Social struct { + Shares *bool `yaml:"shares"` } - Visibilities struct { - Challenge string `yaml:"challenge"` - Account string `yaml:"account"` - Score string `yaml:"score"` - Registration string `yaml:"registration"` + Legal struct { + TOS ExternalReference `yaml:"tos"` + PrivacyPolicy ExternalReference `yaml:"privacy_policy"` } - Front struct { - Theme string `yaml:"theme"` - ThemeColor string `yaml:"theme_color"` - Logo *string `yaml:"logo"` - Banner *string `yaml:"banner"` - SmallIcon *string `yaml:"small_icon"` + ExternalReference struct { + URL *string `yaml:"url"` + Content *string `yaml:"content"` } Admin struct { @@ -48,11 +126,11 @@ type ( func (conf Config) Validate() error { var merr error - if conf.Global.Name == "" { - merr = multierr.Append(merr, &ErrRequired{Attribute: "global.name"}) + if conf.Appearance.Name == "" { + merr = multierr.Append(merr, &ErrRequired{Attribute: "appearance.name"}) } - if conf.Global.Description == "" { - merr = multierr.Append(merr, &ErrRequired{Attribute: "global.description"}) + if conf.Appearance.Description == "" { + merr = multierr.Append(merr, &ErrRequired{Attribute: "appearance.description"}) } if conf.Admin.Name == "" { merr = multierr.Append(merr, &ErrRequired{Attribute: "admin.name"}) @@ -68,7 +146,7 @@ func (conf Config) Validate() error { } // Does not validate attributes content, let CTFd deal with - // that and provide a meaningful error message + // that and provide a meaningful error message... if it can :) return nil } diff --git a/examples/minimal.yaml b/examples/minimal.yaml new file mode 100644 index 0000000..49a3410 --- /dev/null +++ b/examples/minimal.yaml @@ -0,0 +1,8 @@ +global: + name: 'MyCTF 20XX' + description: 'MyCTF description' + +admin: + name: 'my-name' + email: 'my-email@dns.tld' + password: 'dont put password in a config file, use the varenv' diff --git a/file.go b/file.go index 20a70e2..48c9a60 100644 --- a/file.go +++ b/file.go @@ -2,21 +2,37 @@ package ctfdsetup import ( "os" - "path" - "github.com/ctfer-io/go-ctfd/api" + "gopkg.in/yaml.v3" ) -func File(loc *string) (*api.InputFile, error) { - if loc == nil || *loc == "" { - return nil, nil +type File struct { + Name string + Content []byte +} + +var _ yaml.Unmarshaler = (*File)(nil) + +func (file *File) UnmarshalYAML(node *yaml.Node) error { + if node.Value != "" { + file.Content = []byte(node.Value) + } + type lfi struct { + FromFile *string `yaml:"from_file"` } - b, err := os.ReadFile(*loc) + var lfiv lfi + if err := node.Decode(&lfiv); err != nil { + return err + } + + if lfiv.FromFile == nil { + return nil + } + + fc, err := os.ReadFile(*lfiv.FromFile) if err != nil { - return nil, err + return err } - return &api.InputFile{ - Name: path.Base(*loc), - Content: b, - }, nil + file.Content = fc + return nil } diff --git a/go.mod b/go.mod index 5232563..cda0251 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/ctfer-io/ctfd-setup go 1.22.2 require ( - github.com/ctfer-io/go-ctfd v0.6.2 + github.com/ctfer-io/go-ctfd v0.6.5 github.com/pkg/errors v0.9.1 github.com/urfave/cli/v2 v2.27.2 go.uber.org/multierr v1.11.0 diff --git a/go.sum b/go.sum index e5bed86..14ce7d4 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/ctfer-io/go-ctfd v0.6.2 h1:dRTWiyi9LSimOaUGuShqg3oYXN0Ur3h7bt2lHlB5zok= -github.com/ctfer-io/go-ctfd v0.6.2/go.mod h1:zOOgs1LmKEVW3rilcog0jT921vjShmR3avJbSMtvNyM= +github.com/ctfer-io/go-ctfd v0.6.5 h1:9Pdg+oft9SLQlt4/gvOBlX8bW85vsedeOIF7Pb6Z8hM= +github.com/ctfer-io/go-ctfd v0.6.5/go.mod h1:zOOgs1LmKEVW3rilcog0jT921vjShmR3avJbSMtvNyM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gorilla/schema v1.3.0 h1:rbciOzXAx3IB8stEFnfTwO3sYa6EWlQk79XdyustPDA= diff --git a/setup.go b/setup.go index dbfb06f..9f7a9c3 100644 --- a/setup.go +++ b/setup.go @@ -3,29 +3,38 @@ package ctfdsetup import ( "context" "net/http" - "strconv" "github.com/ctfer-io/go-ctfd/api" "github.com/pkg/errors" "go.uber.org/zap" ) -func Setup(ctx context.Context, url string, conf *Config) error { +func Setup(ctx context.Context, url string, apiKey string, conf *Config) error { nonce, session, err := api.GetNonceAndSession(url, api.WithContext(ctx)) if err != nil { return errors.Wrap(err, "getting CTFd nonce and session") } - client := api.NewClient(url, nonce, session, "") + client := api.NewClient(url, nonce, session, apiKey) b, err := bare(ctx, url) if err != nil { return err } - Log().Info("deciding on CTFd setup strategy", zap.Bool("bare", b)) + Log().Info("deciding on CTFd setup strategy", + zap.Bool("bare", b), + zap.Bool("login", apiKey == ""), + ) if b { if err := bareSetup(ctx, client, conf); err != nil { return err } + } else if apiKey == "" { + if err := client.Login(&api.LoginParams{ + Name: conf.Admin.Name, + Password: conf.Admin.Password, + }, api.WithContext(ctx)); err != nil { + return &ErrClient{err: err} + } } return updateSetup(ctx, client, conf) } @@ -48,43 +57,20 @@ func bare(ctx context.Context, url string) (bool, error) { } func bareSetup(ctx context.Context, client *api.Client, conf *Config) error { - Log().Info("setting up fresh CTFd instance") - - logo, err := File(conf.Front.Logo) - if err != nil { - return err - } - banner, err := File(conf.Front.Banner) - if err != nil { - return err - } - smallicon, err := File(conf.Front.SmallIcon) - if err != nil { - return err - } - - // Flatten configuration and setup it - // TODO basic setup only, will be updated in the upcoming API calls + // Flatten configuration and (basic) setup it if err := client.Setup(&api.SetupParams{ - CTFName: conf.Global.Name, - CTFDescription: conf.Global.Description, - UserMode: conf.Global.Mode, - ChallengeVisibility: conf.Visibilities.Challenge, - AccountVisibility: conf.Visibilities.Account, - ScoreVisibility: conf.Visibilities.Score, - RegistrationVisibility: conf.Visibilities.Registration, - VerifyEmails: conf.Global.VerifyEmails, - TeamSize: conf.Global.TeamSize, + CTFName: conf.Appearance.Name, + CTFDescription: conf.Appearance.Description, + UserMode: conf.Mode, + ChallengeVisibility: conf.Settings.ChallengeVisibility, + AccountVisibility: conf.Settings.AccountVisibility, + ScoreVisibility: conf.Settings.ScoreVisibility, + RegistrationVisibility: conf.Settings.RegistrationVisibility, + VerifyEmails: conf.Accounts.VerifyEmails, + TeamSize: conf.Accounts.TeamSize, Name: conf.Admin.Name, Email: conf.Admin.Email, Password: conf.Admin.Password, - CTFLogo: logo, - CTFBanner: banner, - CTFSmallIcon: smallicon, - CTFTheme: conf.Front.Theme, - ThemeColor: conf.Front.ThemeColor, - Start: conf.Global.Start, - End: conf.Global.End, }, api.WithContext(ctx)); err != nil { return &ErrClient{err: err} } @@ -92,42 +78,112 @@ func bareSetup(ctx context.Context, client *api.Client, conf *Config) error { } func updateSetup(ctx context.Context, client *api.Client, conf *Config) error { - Log().Info("logging in") + // Push logo + if conf.Theme.Logo.Name != "" { + lf, err := client.PostFiles(&api.PostFilesParams{ + Files: []*api.InputFile{ + (*api.InputFile)(conf.Theme.Logo), + }, + }, api.WithContext(ctx)) + if err != nil { + return err + } + if _, err := client.PatchConfigsCTFLogo(&api.PatchConfigsCTFLogo{ + Value: &lf[0].Location, + }, api.WithContext(ctx)); err != nil { + return err + } + } - if err := client.Login(&api.LoginParams{ - Name: conf.Admin.Name, - Password: conf.Admin.Password, - }, api.WithContext(ctx)); err != nil { - return &ErrClient{err: err} + // Push small icon + if conf.Theme.SmallIcon.Name != "" { + smf, err := client.PostFiles(&api.PostFilesParams{ + Files: []*api.InputFile{ + (*api.InputFile)(conf.Theme.SmallIcon), + }, + }, api.WithContext(ctx)) + if err != nil { + return err + } + if _, err := client.PatchConfigsCTFSmallIcon(&api.PatchConfigsCTFLogo{ + Value: &smf[0].Location, + }, api.WithContext(ctx)); err != nil { + return err + } } - Log().Info("updating existing CTFd instance") + // Update configs attributes + params := &api.PatchConfigsParams{ + CTFDescription: &conf.Appearance.Description, + CTFName: &conf.Appearance.Name, + CTFTheme: &conf.Theme.Name, + ThemeFooter: ptr(string(conf.Theme.Footer.Content)), + ThemeHeader: ptr(string(conf.Theme.Header.Content)), + ThemeSettings: ptr(string(conf.Theme.Settings.Content)), + DomainWhitelist: conf.Accounts.DomainWhitelist, + IncorrectSubmissionsPerMin: conf.Accounts.IncorrectSubmissionsPerMinute, + NameChanges: conf.Accounts.NameChanges, + NumTeams: conf.Accounts.NumTeams, + NumUsers: conf.Accounts.NumUsers, + TeamCreation: conf.Accounts.TeamCreation, + TeamDisbanding: conf.Accounts.TeamDisbanding, + TeamSize: conf.Accounts.TeamSize, + VerifyEmails: &conf.Accounts.VerifyEmails, + RobotsTxt: ptr(string(conf.Pages.RobotsTxt.Content)), + OauthClientID: conf.MajorLeagueCyber.ClientID, + OauthClientSecret: conf.MajorLeagueCyber.ClientSecret, + AccountVisibility: &conf.Settings.AccountVisibility, + ChallengeVisibility: &conf.Settings.ChallengeVisibility, + RegistrationVisibility: &conf.Settings.RegistrationVisibility, + ScoreVisibility: &conf.Settings.ScoreVisibility, + Paused: conf.Settings.Paused, + HTMLSanitization: conf.Security.HTMLSanitization, + RegistrationCode: conf.Security.RegistrationCode, + MailUseAuth: nil, // Handled later + MailUsername: nil, // Handled later + MailPassword: nil, // Handled later + MailFromAddr: nil, // Deprecated, set to nil for autocomplete + MailGunAPIKey: nil, // Deprecated, set to nil for autocomplete + MailGunBaseURL: nil, // Deprecated, set to nil for autocomplete + MailPort: conf.Email.Port, + MailServer: conf.Email.Server, + MailSSL: conf.Email.TLS_SSL, + MailTLS: conf.Email.STARTTLS, + SuccessfulRegistrationEmailSubject: conf.Email.Registration.Subject, + SuccessfulRegistrationEmailBody: conf.Email.Registration.Body, + VerificationEmailSubject: conf.Email.Confirmation.Subject, + VerificationEmailBody: conf.Email.Confirmation.Body, + UserCreationEmailSubject: conf.Email.NewAccount.Subject, + UserCreationEmailBody: conf.Email.NewAccount.Body, + PasswordChangeAlertSubject: conf.Email.PasswordReset.Subject, + PasswordChangeAlertBody: conf.Email.PasswordReset.Body, + PasswordResetSubject: conf.Email.PasswordResetConfirmation.Subject, + PasswordResetBody: conf.Email.PasswordResetConfirmation.Body, + Start: conf.Time.Start, + End: conf.Time.End, + Freeze: conf.Time.Freeze, + ViewAfterCTF: conf.Time.ViewAfter, + SocialShares: conf.Social.Shares, + PrivacyURL: conf.Legal.PrivacyPolicy.URL, + PrivacyText: conf.Legal.PrivacyPolicy.Content, + TOSURL: conf.Legal.TOS.URL, + TOSText: conf.Legal.TOS.Content, + UserMode: &conf.Mode, + } - if err := client.PatchConfigs(&api.PatchConfigsParams{ - CTFName: &conf.Global.Name, - CTFDescription: &conf.Global.Description, - UserMode: &conf.Global.Mode, - ChallengeVisibility: &conf.Visibilities.Challenge, - AccountVisibility: &conf.Visibilities.Account, - ScoreVisibility: &conf.Visibilities.Score, - RegistrationVisibility: &conf.Visibilities.Registration, - VerifyEmails: &conf.Global.VerifyEmails, - TeamSize: itoa(conf.Global.TeamSize), - // Admin configuration won't be updated - // TODO add support of front group - // TODO add support of other settings - Start: &conf.Global.Start, - End: &conf.Global.End, - }, api.WithContext(ctx)); err != nil { + // Handle mail server authentication + if conf.Email.Username != nil && conf.Email.Password != nil { + params.MailUseAuth = ptr(true) + params.MailUsername = conf.Email.Username + params.MailPassword = conf.Email.Password + } + + if err := client.PatchConfigs(params, api.WithContext(ctx)); err != nil { return &ErrClient{err: err} } return nil } -func itoa(i *int) *string { - if i == nil { - return nil - } - s := strconv.Itoa(*i) - return &s +func ptr[T any](t T) *T { + return &t }