diff --git a/Makefile b/Makefile index 55ddb1d..66f2829 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: test-acc test-acc: TF_ACC=1 \ - go test ./provider/ -v -run=^TestAcc_ -count=1 -coverprofile=cov.out -coverpkg "github.com/ctfer-io/terraform-provider-ctfd/provider,github.com/ctfer-io/terraform-provider-ctfd/provider/challenge,github.com/ctfer-io/terraform-provider-ctfd/provider/utils,github.com/ctfer-io/terraform-provider-ctfd/provider/validators" + go test ./provider/ -v -run=^TestAcc_ -count=1 -coverprofile=cov.out -coverpkg "github.com/ctfer-io/terraform-provider-ctfd/provider,github.com/ctfer-io/terraform-provider-ctfd/provider/utils,github.com/ctfer-io/terraform-provider-ctfd/provider/validators" .PHONY: docs docs: diff --git a/docs/data-sources/challenges.md b/docs/data-sources/challenges.md index 6a847c2..3d28791 100644 --- a/docs/data-sources/challenges.md +++ b/docs/data-sources/challenges.md @@ -29,10 +29,7 @@ Read-Only: - `connection_info` (String) Connection Information to connect to the challenge instance, useful for pwn or web pentest. - `decay` (Number) - `description` (String) Description of the challenge, consider using multiline descriptions for better style. -- `files` (Attributes List) List of files given to players to flag the challenge. (see [below for nested schema](#nestedatt--challenges--files)) -- `flags` (Attributes List) List of challenge flags that solves it. (see [below for nested schema](#nestedatt--challenges--flags)) - `function` (String) Decay function to define how the challenge value evolve through solves, either linear or logarithmic. -- `hints` (Attributes List) List of hints about the challenge displayed to the end-user. (see [below for nested schema](#nestedatt--challenges--hints)) - `id` (String) Identifier of the challenge. - `max_attempts` (Number) Maximum amount of attempts before being unable to flag the challenge. - `minimum` (Number) @@ -45,40 +42,6 @@ Read-Only: - `type` (String) Type of the challenge defining its layout, either standard or dynamic. - `value` (Number) - -### Nested Schema for `challenges.files` - -Read-Only: - -- `content` (String) -- `contentb64` (String) -- `id` (String) -- `location` (String) -- `name` (String) - - - -### Nested Schema for `challenges.flags` - -Read-Only: - -- `content` (String) -- `data` (String) -- `id` (String) -- `type` (String) - - - -### Nested Schema for `challenges.hints` - -Read-Only: - -- `content` (String) -- `cost` (Number) -- `id` (String) -- `requirements` (List of String) - - ### Nested Schema for `challenges.requirements` diff --git a/docs/resources/challenge.md b/docs/resources/challenge.md index 156b222..86ac1f0 100644 --- a/docs/resources/challenge.md +++ b/docs/resources/challenge.md @@ -26,10 +26,6 @@ resource "ctfd_challenge" "http" { state = "visible" function = "logarithmic" - flags = [{ - content = "CTF{some_flag}" - }] - topics = [ "Misc" ] @@ -37,19 +33,30 @@ resource "ctfd_challenge" "http" { "misc", "basic" ] +} - hints = [{ - content = "Some super-helpful hint" - cost = 50 - }, { - content = "Even more helpful hint !" - cost = 50 - }] - - files = [{ - name = "image.png" - contentb64 = filebase64(".../image.png") - }] +resource "ctfd_flag" "http_flag" { + challenge_id = ctfd_challenge.http.id + content = "CTF{some_flag}" +} + +resource "ctfd_hint" "http_hint_1" { + challenge_id = ctfd_challenge.http.id + content = "Some super-helpful hint" + cost = 50 +} + +resource "ctfd_hint" "http_hint_2" { + challenge_id = ctfd_challenge.http.id + content = "Even more helpful hint !" + cost = 50 + requirements = [ctfd_hint.http_hint_1.id] +} + +resource "ctfd_file" "http_file" { + challenge_id = ctfd_challenge.http.id + name = "image.png" + contentb64 = filebase64(".../image.png") } ``` @@ -67,10 +74,7 @@ resource "ctfd_challenge" "http" { - `connection_info` (String) Connection Information to connect to the challenge instance, useful for pwn, web and infrastructure pentests. - `decay` (Number) The decay defines from each number of solves does the decay function triggers until reaching minimum. This function is defined by CTFd and could be configured through `.function`. -- `files` (Attributes List) List of files given to players to flag the challenge. (see [below for nested schema](#nestedatt--files)) -- `flags` (Attributes List) List of challenge flags that solves it. (see [below for nested schema](#nestedatt--flags)) - `function` (String) Decay function to define how the challenge value evolve through solves, either linear or logarithmic. -- `hints` (Attributes List) List of hints about the challenge displayed to the end-user. (see [below for nested schema](#nestedatt--hints)) - `max_attempts` (Number) Maximum amount of attempts before being unable to flag the challenge. - `minimum` (Number) The minimum points for a dynamic-score challenge to reach with the decay function. Once there, no solve could have more value. - `next` (Number) Suggestion for the end-user as next challenge to work on. @@ -84,59 +88,6 @@ resource "ctfd_challenge" "http" { - `id` (String) Identifier of the challenge. - -### Nested Schema for `files` - -Required: - -- `name` (String) Name of the file as displayed to end-users. - -Optional: - -- `content` (String, Sensitive) Raw content of the file, perfectly fit the use-cases of a .txt document or anything with a simple binary content. You could provide it from the file-system using `file("${path.module}/...")`. -- `contentb64` (String, Sensitive) Base 64 content of the file, perfectly fit the use-cases of complex binaries. You could provide it from the file-system using `filebase64("${path.module}/...")`. -- `location` (String) Location where the file is stored on the CTFd instance, for download purposes. - -Read-Only: - -- `id` (String) Identifier of the file, used internally to handle the CTFd corresponding object. -- `sha1sum` (String) The sha1 sum of the file. - - - -### Nested Schema for `flags` - -Required: - -- `content` (String, Sensitive) The actual flag to match. Consider using the convention `MYCTF{value}` with `MYCTF` being the shortcode of your event's name and `value` depending on each challenge. - -Optional: - -- `data` (String) The flag sensitivity information, either case_sensitive or case_insensitive -- `type` (String) The type of the flag, could be either static or regex - -Read-Only: - -- `id` (String) Identifier of the flag, used internally to handle the CTFd corresponding object. - - - -### Nested Schema for `hints` - -Required: - -- `content` (String) Content of the hint as displayed to the end-user. - -Optional: - -- `cost` (Number) Cost of the hint, and if any specified, the end-user will consume its own (or team) points to get it. -- `requirements` (List of String) Other hints required to be consumed before getting this one. Useful for cost-increasing hint strategies with more and more help. - -Read-Only: - -- `id` (String) Identifier of the hint, used internally to handle the CTFd corresponding object. - - ### Nested Schema for `requirements` diff --git a/docs/resources/file.md b/docs/resources/file.md new file mode 100644 index 0000000..84b3960 --- /dev/null +++ b/docs/resources/file.md @@ -0,0 +1,59 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "ctfd_file Resource - terraform-provider-ctfd" +subcategory: "" +description: |- + A CTFd file for a challenge. +--- + +# ctfd_file (Resource) + +A CTFd file for a challenge. + +## Example Usage + +```terraform +resource "ctfd_challenge" "http" { + name = "My Challenge" + category = "misc" + description = "..." + value = 500 + decay = 100 + minimum = 50 + state = "visible" + function = "logarithmic" + + topics = [ + "Misc" + ] + tags = [ + "misc", + "basic" + ] +} + +resource "ctfd_file" "http_file" { + challenge_id = ctfd_challenge.http.id + name = "image.png" + contentb64 = filebase64(".../image.png") +} +``` + + +## Schema + +### Required + +- `name` (String) Name of the file as displayed to end-users. + +### Optional + +- `challenge_id` (String) Challenge of the file. +- `content` (String, Sensitive) Raw content of the file, perfectly fit the use-cases of a .txt document or anything with a simple binary content. You could provide it from the file-system using `file("${path.module}/...")`. +- `contentb64` (String, Sensitive) Base 64 content of the file, perfectly fit the use-cases of complex binaries. You could provide it from the file-system using `filebase64("${path.module}/...")`. +- `location` (String) Location where the file is stored on the CTFd instance, for download purposes. + +### Read-Only + +- `id` (String) Identifier of the file, used internally to handle the CTFd corresponding object. WARNING: updating this file does not work, requires full replacement. +- `sha1sum` (String) The sha1 sum of the file. diff --git a/docs/resources/flag.md b/docs/resources/flag.md new file mode 100644 index 0000000..97a8077 --- /dev/null +++ b/docs/resources/flag.md @@ -0,0 +1,56 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "ctfd_flag Resource - terraform-provider-ctfd" +subcategory: "" +description: |- + A flag to solve the challenge. +--- + +# ctfd_flag (Resource) + +A flag to solve the challenge. + +## Example Usage + +```terraform +resource "ctfd_challenge" "http" { + name = "My Challenge" + category = "misc" + description = "..." + value = 500 + decay = 100 + minimum = 50 + state = "visible" + function = "logarithmic" + + topics = [ + "Misc" + ] + tags = [ + "misc", + "basic" + ] +} + +resource "ctfd_flag" "http_flag" { + challenge_id = ctfd_challenge.http.id + content = "CTF{some_flag}" +} +``` + + +## Schema + +### Required + +- `challenge_id` (String) Challenge of the flag. +- `content` (String, Sensitive) The actual flag to match. Consider using the convention `MYCTF{value}` with `MYCTF` being the shortcode of your event's name and `value` depending on each challenge. + +### Optional + +- `data` (String) The flag sensitivity information, either case_sensitive or case_insensitive +- `type` (String) The type of the flag, could be either static or regex + +### Read-Only + +- `id` (String) Identifier of the flag, used internally to handle the CTFd corresponding object. diff --git a/docs/resources/hint.md b/docs/resources/hint.md new file mode 100644 index 0000000..b41fdf5 --- /dev/null +++ b/docs/resources/hint.md @@ -0,0 +1,69 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "ctfd_hint Resource - terraform-provider-ctfd" +subcategory: "" +description: |- + A hint for a challenge to help players solve it. +--- + +# ctfd_hint (Resource) + +A hint for a challenge to help players solve it. + +## Example Usage + +```terraform +resource "ctfd_challenge" "http" { + name = "My Challenge" + category = "misc" + description = "..." + value = 500 + decay = 100 + minimum = 50 + state = "visible" + function = "logarithmic" + + topics = [ + "Misc" + ] + tags = [ + "misc", + "basic" + ] +} + +resource "ctfd_flag" "http_flag" { + challenge_id = ctfd_challenge.http.id + content = "CTF{some_flag}" +} + +resource "ctfd_hint" "http_hint" { + challenge_id = ctfd_challenge.http.id + content = "Some super-helpful hint" + cost = 50 +} + +resource "ctfd_hint" "http_hint_2" { + challenge_id = ctfd_challenge.http.id + content = "Even more helpful hint !" + cost = 50 + requirements = [ctfd_hint.http_hint_1.id] +} +``` + + +## Schema + +### Required + +- `challenge_id` (String) Challenge of the hint. +- `content` (String) Content of the hint as displayed to the end-user. + +### Optional + +- `cost` (Number) Cost of the hint, and if any specified, the end-user will consume its own (or team) points to get it. +- `requirements` (List of String) List of the other hints it depends on. + +### Read-Only + +- `id` (String) Identifier of the hint, used internally to handle the CTFd corresponding object. diff --git a/examples/provider-install-verification/main.tf b/examples/provider-install-verification/main.tf index a812404..dbb1450 100644 --- a/examples/provider-install-verification/main.tf +++ b/examples/provider-install-verification/main.tf @@ -10,6 +10,8 @@ provider "ctfd" { url = "http://localhost:8080" } + + resource "ctfd_challenge" "http" { name = "HTTP Authentication" category = "network" @@ -27,10 +29,6 @@ resource "ctfd_challenge" "http" { state = "visible" function = "logarithmic" - flags = [{ - content = "24HIUT{Http_1s_n0t_s3cuR3}" - }] - topics = [ "Network" ] @@ -38,21 +36,34 @@ resource "ctfd_challenge" "http" { "network", "http" ] +} - hints = [{ - content = "Les flux http ne sont pas chiffrés" - cost = 50 - }, { - content = "Les informations sont POSTées en HTTP :)" - cost = 50 - }] +resource "ctfd_flag" "http_flag" { + challenge_id = ctfd_challenge.http.id + content = "24HIUT{Http_1s_n0t_s3cuR3}" +} - files = [{ - name = "capture.pcapng" - contentb64 = filebase64("${path.module}/capture.pcapng") - }] +resource "ctfd_hint" "http_hint_1" { + challenge_id = ctfd_challenge.http.id + content = "Les flux http ne sont pas chiffrés" + cost = 50 } +resource "ctfd_hint" "http_hint_2" { + challenge_id = ctfd_challenge.http.id + content = "Les informations sont POSTées en HTTP :)" + cost = 50 + requirements = [ctfd_hint.http_hint_1.id] +} + +resource "ctfd_file" "http_file" { + challenge_id = ctfd_challenge.http.id + name = "capture.pcapng" + contentb64 = filebase64("${path.module}/capture.pcapng") +} + + + resource "ctfd_challenge" "icmp" { name = "Stealing data" category = "network" @@ -85,17 +96,28 @@ resource "ctfd_challenge" "icmp" { "network", "icmp" ] +} - hints = [{ - content = "Vous ne trouvez pas qu'il ya beaucoup de requêtes ICMP ?" - cost = 50 - }, { - content = "Pour l'exo, le ttl a été modifié, tente un `ip.ttl<=20`" - cost = 50 - }] +resource "ctfd_flag" "icmp_flag" { + challenge_id = ctfd_challenge.icmp.id + content = "24HIUT{IcmpExfiltrationIsEasy}" +} - files = [{ - name = "icmp.pcap" - contentb64 = filebase64("${path.module}/icmp.pcap") - }] +resource "ctfd_hint" "icmp_hint_1" { + challenge_id = ctfd_challenge.icmp.id + content = "Vous ne trouvez pas qu'il ya beaucoup de requêtes ICMP ?" + cost = 50 +} + +resource "ctfd_hint" "icmp_hint_2" { + challenge_id = ctfd_challenge.icmp.id + content = "Pour l'exo, le ttl a été modifié, tente un `ip.ttl<=20`" + cost = 50 + requirements = [ctfd_hint.icmp_hint_2.id] +} + +resource "ctfd_file" "icmp_file" { + challenge_id = ctfd_challenge.icmp.id + name = "icmp.pcap" + contentb64 = filebase64("${path.module}/icmp.pcap") } diff --git a/examples/resources/ctfd_challenge/resource.tf b/examples/resources/ctfd_challenge/resource.tf index d74d52b..e698b07 100644 --- a/examples/resources/ctfd_challenge/resource.tf +++ b/examples/resources/ctfd_challenge/resource.tf @@ -8,10 +8,6 @@ resource "ctfd_challenge" "http" { state = "visible" function = "logarithmic" - flags = [{ - content = "CTF{some_flag}" - }] - topics = [ "Misc" ] @@ -19,17 +15,28 @@ resource "ctfd_challenge" "http" { "misc", "basic" ] +} + +resource "ctfd_flag" "http_flag" { + challenge_id = ctfd_challenge.http.id + content = "CTF{some_flag}" +} - hints = [{ - content = "Some super-helpful hint" - cost = 50 - }, { - content = "Even more helpful hint !" - cost = 50 - }] +resource "ctfd_hint" "http_hint_1" { + challenge_id = ctfd_challenge.http.id + content = "Some super-helpful hint" + cost = 50 +} + +resource "ctfd_hint" "http_hint_2" { + challenge_id = ctfd_challenge.http.id + content = "Even more helpful hint !" + cost = 50 + requirements = [ctfd_hint.http_hint_1.id] +} - files = [{ - name = "image.png" - contentb64 = filebase64(".../image.png") - }] +resource "ctfd_file" "http_file" { + challenge_id = ctfd_challenge.http.id + name = "image.png" + contentb64 = filebase64(".../image.png") } diff --git a/examples/resources/ctfd_file/resource.tf b/examples/resources/ctfd_file/resource.tf new file mode 100644 index 0000000..bf47e4e --- /dev/null +++ b/examples/resources/ctfd_file/resource.tf @@ -0,0 +1,24 @@ +resource "ctfd_challenge" "http" { + name = "My Challenge" + category = "misc" + description = "..." + value = 500 + decay = 100 + minimum = 50 + state = "visible" + function = "logarithmic" + + topics = [ + "Misc" + ] + tags = [ + "misc", + "basic" + ] +} + +resource "ctfd_file" "http_file" { + challenge_id = ctfd_challenge.http.id + name = "image.png" + contentb64 = filebase64(".../image.png") +} diff --git a/examples/resources/ctfd_flag/resource.tf b/examples/resources/ctfd_flag/resource.tf new file mode 100644 index 0000000..4ca13f3 --- /dev/null +++ b/examples/resources/ctfd_flag/resource.tf @@ -0,0 +1,23 @@ +resource "ctfd_challenge" "http" { + name = "My Challenge" + category = "misc" + description = "..." + value = 500 + decay = 100 + minimum = 50 + state = "visible" + function = "logarithmic" + + topics = [ + "Misc" + ] + tags = [ + "misc", + "basic" + ] +} + +resource "ctfd_flag" "http_flag" { + challenge_id = ctfd_challenge.http.id + content = "CTF{some_flag}" +} diff --git a/examples/resources/ctfd_hint/resource.tf b/examples/resources/ctfd_hint/resource.tf new file mode 100644 index 0000000..94a1c6d --- /dev/null +++ b/examples/resources/ctfd_hint/resource.tf @@ -0,0 +1,36 @@ +resource "ctfd_challenge" "http" { + name = "My Challenge" + category = "misc" + description = "..." + value = 500 + decay = 100 + minimum = 50 + state = "visible" + function = "logarithmic" + + topics = [ + "Misc" + ] + tags = [ + "misc", + "basic" + ] +} + +resource "ctfd_flag" "http_flag" { + challenge_id = ctfd_challenge.http.id + content = "CTF{some_flag}" +} + +resource "ctfd_hint" "http_hint" { + challenge_id = ctfd_challenge.http.id + content = "Some super-helpful hint" + cost = 50 +} + +resource "ctfd_hint" "http_hint_2" { + challenge_id = ctfd_challenge.http.id + content = "Even more helpful hint !" + cost = 50 + requirements = [ctfd_hint.http_hint_1.id] +} diff --git a/go.mod b/go.mod index 44242cb..6367fbc 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/ctfer-io/terraform-provider-ctfd go 1.22 require ( - github.com/ctfer-io/go-ctfd v0.7.0 + github.com/ctfer-io/go-ctfd v0.8.1 github.com/hashicorp/terraform-plugin-docs v0.19.2 github.com/hashicorp/terraform-plugin-framework v1.8.0 github.com/hashicorp/terraform-plugin-go v0.23.0 diff --git a/go.sum b/go.sum index e32ead6..5fdabb7 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,8 @@ github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZ github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= -github.com/ctfer-io/go-ctfd v0.7.0 h1:/p8ynT3DTu201DANFIVc0otgE6eViSwcRuosyE9azaQ= -github.com/ctfer-io/go-ctfd v0.7.0/go.mod h1:zOOgs1LmKEVW3rilcog0jT921vjShmR3avJbSMtvNyM= +github.com/ctfer-io/go-ctfd v0.8.1 h1:/cXBR6rJ6S4Q2w7HS5otQvyQ4GK9pzDTsrqCxOl69oc= +github.com/ctfer-io/go-ctfd v0.8.1/go.mod h1:zOOgs1LmKEVW3rilcog0jT921vjShmR3avJbSMtvNyM= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/provider/challenge/file_subdata_source.go b/provider/challenge/file_subdata_source.go deleted file mode 100644 index b35b184..0000000 --- a/provider/challenge/file_subdata_source.go +++ /dev/null @@ -1,23 +0,0 @@ -package challenge - -import "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - -func FileSubdatasourceAttributes() map[string]schema.Attribute { - return map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - }, - "name": schema.StringAttribute{ - Computed: true, - }, - "location": schema.StringAttribute{ - Computed: true, - }, - "content": schema.StringAttribute{ - Computed: true, - }, - "contentb64": schema.StringAttribute{ - Computed: true, - }, - } -} diff --git a/provider/challenge/file_subresource.go b/provider/challenge/file_subresource.go deleted file mode 100644 index b8c06c2..0000000 --- a/provider/challenge/file_subresource.go +++ /dev/null @@ -1,168 +0,0 @@ -package challenge - -import ( - "context" - "crypto/sha1" - "encoding/base64" - "encoding/hex" - "fmt" - "strconv" - - "github.com/ctfer-io/go-ctfd/api" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-log/tflog" -) - -type FileSubresourceModel struct { - ID types.String `tfsdk:"id"` - Name types.String `tfsdk:"name"` - Location types.String `tfsdk:"location"` - SHA1Sum types.String `tfsdk:"sha1sum"` - Content types.String `tfsdk:"content"` - ContentB64 types.String `tfsdk:"contentb64"` -} - -func FileSubresourceAttributes() map[string]schema.Attribute { - return map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - MarkdownDescription: "Identifier of the file, used internally to handle the CTFd corresponding object.", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "name": schema.StringAttribute{ - MarkdownDescription: "Name of the file as displayed to end-users.", - Required: true, - }, - "location": schema.StringAttribute{ - MarkdownDescription: "Location where the file is stored on the CTFd instance, for download purposes.", - Optional: true, - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "sha1sum": schema.StringAttribute{ - MarkdownDescription: "The sha1 sum of the file.", - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "content": schema.StringAttribute{ - MarkdownDescription: "Raw content of the file, perfectly fit the use-cases of a .txt document or anything with a simple binary content. You could provide it from the file-system using `file(\"${path.module}/...\")`.", - Optional: true, - Computed: true, - Sensitive: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "contentb64": schema.StringAttribute{ - MarkdownDescription: "Base 64 content of the file, perfectly fit the use-cases of complex binaries. You could provide it from the file-system using `filebase64(\"${path.module}/...\")`.", - Optional: true, - Computed: true, - Sensitive: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - } -} - -// Read fetches all the file's information, only requiring the ID to be set. -func (file *FileSubresourceModel) Read(ctx context.Context, diags diag.Diagnostics, client *api.Client) { - content, err := client.GetFileContent(&api.File{ - Location: file.Location.ValueString(), - }, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "CTFd Error", - fmt.Sprintf("Unable to read files at location %s, got error: %s", file.Location, err), - ) - } - - file.Content = types.StringValue(string(content)) - file.PropagateContent(ctx, diags) - - h := sha1.New() - _, err = h.Write(content) - if err != nil { - diags.AddError( - "Internal Error", - fmt.Sprintf("Failed to compute SHA1 sum, got error: %s", err), - ) - } - sum := h.Sum(nil) - file.SHA1Sum = types.StringValue(hex.EncodeToString(sum)) -} - -func (data *FileSubresourceModel) Create(ctx context.Context, diags diag.Diagnostics, client *api.Client, challengeID int) { - // Fetch raw or base64 content prior to creating it with raw - data.PropagateContent(ctx, diags) - if diags.HasError() { - return - } - - res, err := client.PostFiles(&api.PostFilesParams{ - Challenge: &challengeID, - Files: []*api.InputFile{ - { - Name: data.Name.ValueString(), - Content: []byte(data.Content.ValueString()), - }, - }, - Location: data.Location.ValueStringPointer(), - }, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to create file, got error: %s", err), - ) - return - } - - tflog.Trace(ctx, "created a file") - - data.ID = types.StringValue(strconv.Itoa(res[0].ID)) - data.SHA1Sum = types.StringValue(res[0].SHA1sum) - data.Location = types.StringValue(res[0].Location) -} - -func (data *FileSubresourceModel) Delete(ctx context.Context, diags diag.Diagnostics, client *api.Client) { - if err := client.DeleteFile(data.ID.ValueString(), api.WithContext(ctx)); err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to delete file %s, got error: %s", data.Name, err), - ) - return - } - - tflog.Trace(ctx, "deleted a file") -} - -func (data *FileSubresourceModel) PropagateContent(ctx context.Context, diags diag.Diagnostics) { - // If the other content source is set, get the other from it - if len(data.Content.ValueString()) != 0 { - cb64 := base64.StdEncoding.EncodeToString([]byte(data.Content.ValueString())) - data.ContentB64 = types.StringValue(cb64) - return - } - if len(data.ContentB64.ValueString()) != 0 { - c, err := base64.StdEncoding.DecodeString(data.ContentB64.ValueString()) - diags.AddError( - "File Error", - fmt.Sprintf("Base64 file content failed at decoding: %s", err), - ) - data.Content = types.StringValue(string(c)) - return - } - // If no content seems to be set, set them both empty - data.Content = types.StringValue("") - data.ContentB64 = types.StringValue("") -} diff --git a/provider/challenge/flag_subdata_source.go b/provider/challenge/flag_subdata_source.go deleted file mode 100644 index c7ba890..0000000 --- a/provider/challenge/flag_subdata_source.go +++ /dev/null @@ -1,22 +0,0 @@ -package challenge - -import ( - "github.com/hashicorp/terraform-plugin-framework/datasource/schema" -) - -func FlagSubdatasourceAttributes() map[string]schema.Attribute { - return map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - }, - "content": schema.StringAttribute{ - Computed: true, - }, - "data": schema.StringAttribute{ - Computed: true, - }, - "type": schema.StringAttribute{ - Computed: true, - }, - } -} diff --git a/provider/challenge/flag_subresource.go b/provider/challenge/flag_subresource.go deleted file mode 100644 index e187006..0000000 --- a/provider/challenge/flag_subresource.go +++ /dev/null @@ -1,122 +0,0 @@ -package challenge - -import ( - "context" - "fmt" - "strconv" - - "github.com/ctfer-io/go-ctfd/api" - "github.com/ctfer-io/terraform-provider-ctfd/provider/validators" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/schema/validator" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework/types/basetypes" - "github.com/hashicorp/terraform-plugin-log/tflog" -) - -type FlagSubresourceModel struct { - ID types.String `tfsdk:"id"` - Content types.String `tfsdk:"content"` - Data types.String `tfsdk:"data"` - Type types.String `tfsdk:"type"` -} - -func FlagSubresourceAttributes() map[string]schema.Attribute { - return map[string]schema.Attribute{ - "id": schema.StringAttribute{ - MarkdownDescription: "Identifier of the flag, used internally to handle the CTFd corresponding object.", - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "content": schema.StringAttribute{ - MarkdownDescription: "The actual flag to match. Consider using the convention `MYCTF{value}` with `MYCTF` being the shortcode of your event's name and `value` depending on each challenge.", - Required: true, - Sensitive: true, - }, - "data": schema.StringAttribute{ - MarkdownDescription: "The flag sensitivity information, either case_sensitive or case_insensitive", - Optional: true, - Computed: true, - // default value is "" (empty string) according to Web UI - Default: stringdefault.StaticString("case_sensitive"), - Validators: []validator.String{ - validators.NewStringEnumValidator([]basetypes.StringValue{ - types.StringValue("case_sensitive"), - types.StringValue("case_insensitive"), - }), - }, - }, - "type": schema.StringAttribute{ - MarkdownDescription: "The type of the flag, could be either static or regex", - Optional: true, - Computed: true, - // default value is "static" according to ctfcli - Default: stringdefault.StaticString("static"), - Validators: []validator.String{ - validators.NewStringEnumValidator([]basetypes.StringValue{ - types.StringValue("static"), - types.StringValue("regex"), - }), - }, - }, - } -} - -func (data *FlagSubresourceModel) Create(ctx context.Context, diags diag.Diagnostics, client *api.Client, challengeID int) { - res, err := client.PostFlags(&api.PostFlagsParams{ - Challenge: challengeID, - Content: data.Content.ValueString(), - Data: data.Data.ValueString(), - Type: data.Type.ValueString(), - }, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to create flag, got error: %s", err), - ) - return - } - - tflog.Trace(ctx, "created a flag") - - data.ID = types.StringValue(strconv.Itoa(res.ID)) -} - -func (data *FlagSubresourceModel) Update(ctx context.Context, diags diag.Diagnostics, client *api.Client) { - res, err := client.PatchFlag(data.ID.ValueString(), &api.PatchFlagParams{ - Content: data.Content.ValueString(), - Data: data.Data.ValueString(), - Type: data.Type.ValueString(), - }, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to update flag, got error: %s", err), - ) - return - } - - tflog.Trace(ctx, "updated a flag") - - data.Content = types.StringValue(res.Content) - data.Data = types.StringValue(res.Data) - data.Type = types.StringValue(res.Type) -} - -func (data *FlagSubresourceModel) Delete(ctx context.Context, diags diag.Diagnostics, client *api.Client) { - if err := client.DeleteFlag(data.ID.ValueString(), api.WithContext(ctx)); err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to delete flag, got error: %s", err), - ) - return - } - - tflog.Trace(ctx, "deleted a flag") -} diff --git a/provider/challenge/function.go b/provider/challenge/function.go deleted file mode 100644 index 12ea7e7..0000000 --- a/provider/challenge/function.go +++ /dev/null @@ -1,10 +0,0 @@ -// This file is related to the challenge.function attribute - -package challenge - -import "github.com/hashicorp/terraform-plugin-framework/types" - -var ( - FunctionLinear = types.StringValue("linear") - FunctionLogarithmic = types.StringValue("logarithmic") -) diff --git a/provider/challenge/hint_subdata_source.go b/provider/challenge/hint_subdata_source.go deleted file mode 100644 index d883178..0000000 --- a/provider/challenge/hint_subdata_source.go +++ /dev/null @@ -1,24 +0,0 @@ -package challenge - -import ( - "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func HintSubdatasourceAttributes() map[string]schema.Attribute { - return map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - }, - "content": schema.StringAttribute{ - Computed: true, - }, - "cost": schema.Int64Attribute{ - Computed: true, - }, - "requirements": schema.ListAttribute{ - ElementType: types.StringType, - Computed: true, - }, - } -} diff --git a/provider/challenge/hint_subresource.go b/provider/challenge/hint_subresource.go deleted file mode 100644 index 4ce34fb..0000000 --- a/provider/challenge/hint_subresource.go +++ /dev/null @@ -1,126 +0,0 @@ -package challenge - -import ( - "context" - "fmt" - "strconv" - - "github.com/ctfer-io/go-ctfd/api" - "github.com/ctfer-io/terraform-provider-ctfd/provider/utils" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework/types/basetypes" - "github.com/hashicorp/terraform-plugin-log/tflog" -) - -type HintSubresourceModel struct { - ID types.String `tfsdk:"id"` - Content types.String `tfsdk:"content"` - Cost types.Int64 `tfsdk:"cost"` - Requirements types.List `tfsdk:"requirements"` -} - -func HintSubresourceAttributes() map[string]schema.Attribute { - return map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - MarkdownDescription: "Identifier of the hint, used internally to handle the CTFd corresponding object.", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "content": schema.StringAttribute{ - MarkdownDescription: "Content of the hint as displayed to the end-user.", - Required: true, - }, - "cost": schema.Int64Attribute{ - MarkdownDescription: "Cost of the hint, and if any specified, the end-user will consume its own (or team) points to get it.", - Optional: true, - Computed: true, - Default: int64default.StaticInt64(0), - }, - "requirements": schema.ListAttribute{ - MarkdownDescription: "Other hints required to be consumed before getting this one. Useful for cost-increasing hint strategies with more and more help.", - ElementType: types.StringType, - Computed: true, - Optional: true, - Default: listdefault.StaticValue(basetypes.NewListValueMust(types.StringType, []attr.Value{})), - }, - } -} - -func (data *HintSubresourceModel) Create(ctx context.Context, diags diag.Diagnostics, client *api.Client, challengeID int) { - preq := make([]int, 0, len(data.Requirements.Elements())) - for _, req := range data.Requirements.Elements() { - // TODO use strconv.Atoi and handle error properly - reqid := utils.Atoi(req.(types.String).ValueString()) - preq = append(preq, reqid) - } - - res, err := client.PostHints(&api.PostHintsParams{ - ChallengeID: challengeID, - Content: data.Content.ValueString(), - Cost: int(data.Cost.ValueInt64()), - Requirements: api.Requirements{ - Prerequisites: preq, - }, - }, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to create hint, got error: %s", err), - ) - return - } - - tflog.Trace(ctx, "created a hint") - - data.ID = types.StringValue(strconv.Itoa(res.ID)) -} - -func (data *HintSubresourceModel) Update(ctx context.Context, diags diag.Diagnostics, client *api.Client) { - preq := make([]int, 0, len(data.Requirements.Elements())) - for _, req := range data.Requirements.Elements() { - // TODO use strconv.Atoi and handle error properly - reqid := utils.Atoi(req.(types.String).ValueString()) - preq = append(preq, reqid) - } - - res, err := client.PatchHint(data.ID.ValueString(), &api.PatchHintsParams{ - Content: data.Content.ValueString(), - Cost: int(data.Cost.ValueInt64()), - Requirements: api.Requirements{ - Prerequisites: preq, - }, - }, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to update hint, got error: %s", err), - ) - return - } - - tflog.Trace(ctx, "updated a hint") - - data.Content = types.StringValue(*res.Content) - data.Cost = types.Int64Value(int64(res.Cost)) -} - -func (data *HintSubresourceModel) Delete(ctx context.Context, diags diag.Diagnostics, client *api.Client) { - if err := client.DeleteHint(data.ID.ValueString(), api.WithContext(ctx)); err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to delete hint, got error: %s", err), - ) - return - } - - tflog.Trace(ctx, "deleted a hint") -} diff --git a/provider/challenge/requirements_subresource.go b/provider/challenge/requirements_subresource.go deleted file mode 100644 index 3698bd6..0000000 --- a/provider/challenge/requirements_subresource.go +++ /dev/null @@ -1,36 +0,0 @@ -package challenge - -import ( - "github.com/ctfer-io/terraform-provider-ctfd/provider/utils" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -var ( - BehaviorHidden = types.StringValue("hidden") - BehaviorAnonymized = types.StringValue("anonymized") -) - -type RequirementsSubresourceModel struct { - Behavior types.String `tfsdk:"behavior"` - Prerequisites []types.String `tfsdk:"prerequisites"` -} - -func GetAnon(str types.String) *bool { - switch { - case str.Equal(BehaviorHidden): - return nil - case str.Equal(BehaviorAnonymized): - return utils.Ptr(true) - } - panic("invalid anonymization value: " + str.ValueString()) -} - -func FromAnon(b *bool) types.String { - if b == nil { - return BehaviorHidden - } - if *b { - return BehaviorAnonymized - } - panic("invalid anonymization value, got boolean false") -} diff --git a/provider/challenge_data_source.go b/provider/challenge_data_source.go index eca6694..a1b8035 100644 --- a/provider/challenge_data_source.go +++ b/provider/challenge_data_source.go @@ -6,7 +6,6 @@ import ( "strconv" "github.com/ctfer-io/go-ctfd/api" - "github.com/ctfer-io/terraform-provider-ctfd/provider/challenge" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/types" @@ -109,13 +108,6 @@ func (ch *challengeDataSource) Schema(ctx context.Context, req datasource.Schema }, }, }, - "flags": schema.ListNestedAttribute{ - MarkdownDescription: "List of challenge flags that solves it.", - NestedObject: schema.NestedAttributeObject{ - Attributes: challenge.FlagSubdatasourceAttributes(), - }, - Computed: true, - }, "tags": schema.ListAttribute{ MarkdownDescription: "List of challenge tags that will be displayed to the end-user. You could use them to give some quick insights of what a challenge involves.", ElementType: types.StringType, @@ -126,20 +118,6 @@ func (ch *challengeDataSource) Schema(ctx context.Context, req datasource.Schema ElementType: types.StringType, Computed: true, }, - "hints": schema.ListNestedAttribute{ - MarkdownDescription: "List of hints about the challenge displayed to the end-user.", - NestedObject: schema.NestedAttributeObject{ - Attributes: challenge.HintSubdatasourceAttributes(), - }, - Computed: true, - }, - "files": schema.ListNestedAttribute{ - MarkdownDescription: "List of files given to players to flag the challenge.", - NestedObject: schema.NestedAttributeObject{ - Attributes: challenge.FileSubdatasourceAttributes(), - }, - Computed: true, - }, }, }, }, @@ -181,7 +159,10 @@ func (ch *challengeDataSource) Read(ctx context.Context, req datasource.ReadRequ chall := challengeResourceModel{ ID: types.StringValue(strconv.Itoa(c.ID)), } - chall.Read(ctx, resp.Diagnostics, ch.client) + chall.Read(ctx, ch.client, resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } state.Challenges = append(state.Challenges, chall) } diff --git a/provider/challenge_model.go b/provider/challenge_model.go deleted file mode 100644 index 80297a2..0000000 --- a/provider/challenge_model.go +++ /dev/null @@ -1,202 +0,0 @@ -package provider - -import ( - "context" - "crypto/sha1" - "encoding/hex" - "fmt" - "strconv" - - "github.com/ctfer-io/go-ctfd/api" - "github.com/ctfer-io/terraform-provider-ctfd/provider/challenge" - "github.com/ctfer-io/terraform-provider-ctfd/provider/utils" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework/types/basetypes" -) - -type challengeResourceModel struct { - ID types.String `tfsdk:"id"` - Name types.String `tfsdk:"name"` - Category types.String `tfsdk:"category"` - Description types.String `tfsdk:"description"` - ConnectionInfo types.String `tfsdk:"connection_info"` - MaxAttempts types.Int64 `tfsdk:"max_attempts"` - Function types.String `tfsdk:"function"` - Value types.Int64 `tfsdk:"value"` - Decay types.Int64 `tfsdk:"decay"` - Minimum types.Int64 `tfsdk:"minimum"` - State types.String `tfsdk:"state"` - Type types.String `tfsdk:"type"` - Next types.Int64 `tfsdk:"next"` - Requirements *challenge.RequirementsSubresourceModel `tfsdk:"requirements"` - Flags []challenge.FlagSubresourceModel `tfsdk:"flags"` - Tags []types.String `tfsdk:"tags"` - Topics []types.String `tfsdk:"topics"` - Hints []challenge.HintSubresourceModel `tfsdk:"hints"` - Files []challenge.FileSubresourceModel `tfsdk:"files"` -} - -func (chall *challengeResourceModel) Read(ctx context.Context, diags diag.Diagnostics, client *api.Client) { - // Retrieve challenge - res, err := client.GetChallenge(utils.Atoi(chall.ID.ValueString()), api.WithContext(ctx)) - if err != nil { - diags.AddError("Client Error", fmt.Sprintf("Unable to read challenge %s, got error: %s", chall.ID.ValueString(), err)) - return - } - chall.Name = types.StringValue(res.Name) - chall.Category = types.StringValue(res.Category) - chall.Description = types.StringValue(res.Description) - chall.ConnectionInfo = utils.ToTFString(res.ConnectionInfo) - chall.MaxAttempts = utils.ToTFInt64(res.MaxAttempts) - chall.Function = types.StringValue("linear") // XXX CTFd does not return the `function` attribute - chall.Decay = utils.ToTFInt64(res.Decay) - chall.Minimum = utils.ToTFInt64(res.Minimum) - chall.State = types.StringValue(res.State) - chall.Type = types.StringValue(res.Type) - chall.Next = utils.ToTFInt64(res.NextID) - - switch res.Type { - case "standard": - chall.Value = types.Int64Value(int64(res.Value)) - case "dynamic": - chall.Value = utils.ToTFInt64(res.Initial) - } - - id := utils.Atoi(chall.ID.ValueString()) - - // Get subresources - // => Requirements - resReqs, err := client.GetChallengeRequirements(id, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to read challenge %d requirements, got error: %s", id, err), - ) - } - reqs := (*challenge.RequirementsSubresourceModel)(nil) - if resReqs != nil { - challPreqs := make([]types.String, 0, len(resReqs.Prerequisites)) - for _, req := range resReqs.Prerequisites { - challPreqs = append(challPreqs, types.StringValue(strconv.Itoa(req))) - } - reqs = &challenge.RequirementsSubresourceModel{ - Behavior: challenge.FromAnon(resReqs.Anonymize), - Prerequisites: challPreqs, - } - } - chall.Requirements = reqs - - // => Files - resFiles, err := client.GetChallengeFiles(id, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to read challenge %d files, got error: %s", id, err), - ) - return - } - chall.Files = make([]challenge.FileSubresourceModel, 0, len(resFiles)) - for _, file := range resFiles { - c, err := client.GetFileContent(file, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to read file content at %s, got error: %s", file.Location, err), - ) - continue - } - nf := challenge.FileSubresourceModel{ - ID: types.StringValue(strconv.Itoa(file.ID)), - Name: types.StringValue(utils.Filename(file.Location)), - Location: types.StringValue(file.Location), - Content: types.StringValue(string(c)), - } - nf.PropagateContent(ctx, diags) - h := sha1.New() - _, err = h.Write(c) - if err != nil { - diags.AddError( - "Internal Error", - fmt.Sprintf("Failed to compute SHA1 sum, got error: %s", err), - ) - } - sum := h.Sum(nil) - nf.SHA1Sum = types.StringValue(hex.EncodeToString(sum)) - chall.Files = append(chall.Files, nf) - } - - // => Flags - resFlags, err := client.GetChallengeFlags(id, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to read challenge %d flags, got error: %s", id, err), - ) - return - } - chall.Flags = make([]challenge.FlagSubresourceModel, 0, len(resFlags)) - for _, flag := range resFlags { - chall.Flags = append(chall.Flags, challenge.FlagSubresourceModel{ - ID: types.StringValue(strconv.Itoa(flag.ID)), - Content: types.StringValue(flag.Content), - Data: types.StringValue(flag.Data), - Type: types.StringValue(flag.Type), - }) - } - - // => Hints - resHints, err := client.GetChallengeHints(id, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to read challenge %d hints, got error: %s", id, err), - ) - return - } - chall.Hints = make([]challenge.HintSubresourceModel, 0, len(resHints)) - for _, hint := range resHints { - reqs := []attr.Value{} - if hint.Requirements != nil { - reqs = make([]attr.Value, 0, len(hint.Requirements.Prerequisites)) - for _, req := range hint.Requirements.Prerequisites { - reqs = append(reqs, types.StringValue(strconv.Itoa(req))) - } - } - chall.Hints = append(chall.Hints, challenge.HintSubresourceModel{ - ID: types.StringValue(strconv.Itoa(hint.ID)), - Content: types.StringValue(*hint.Content), - Cost: types.Int64Value(int64(hint.Cost)), - Requirements: types.ListValueMust(types.StringType, reqs), - }) - } - - // => Tags - resTags, err := client.GetChallengeTags(id, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to read challenge %d tags, got error: %s", id, err), - ) - return - } - chall.Tags = make([]basetypes.StringValue, 0, len(resTags)) - for _, tag := range resTags { - chall.Tags = append(chall.Tags, types.StringValue(tag.Value)) - } - - // => Topics - resTopics, err := client.GetChallengeTopics(id, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to read challenge %d topics, got error: %s", id, err), - ) - return - } - chall.Topics = make([]basetypes.StringValue, 0, len(resTopics)) - for _, topic := range resTopics { - chall.Topics = append(chall.Topics, types.StringValue(topic.Value)) - } -} diff --git a/provider/challenge_resource.go b/provider/challenge_resource.go index 6dd3b93..2369ba0 100644 --- a/provider/challenge_resource.go +++ b/provider/challenge_resource.go @@ -6,14 +6,17 @@ import ( "strconv" "github.com/ctfer-io/go-ctfd/api" - "github.com/ctfer-io/terraform-provider-ctfd/provider/challenge" "github.com/ctfer-io/terraform-provider-ctfd/provider/utils" "github.com/ctfer-io/terraform-provider-ctfd/provider/validators" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" @@ -37,6 +40,25 @@ type challengeResource struct { client *api.Client } +type challengeResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Category types.String `tfsdk:"category"` + Description types.String `tfsdk:"description"` + ConnectionInfo types.String `tfsdk:"connection_info"` + MaxAttempts types.Int64 `tfsdk:"max_attempts"` + Function types.String `tfsdk:"function"` + Value types.Int64 `tfsdk:"value"` + Decay types.Int64 `tfsdk:"decay"` + Minimum types.Int64 `tfsdk:"minimum"` + State types.String `tfsdk:"state"` + Type types.String `tfsdk:"type"` + Next types.Int64 `tfsdk:"next"` + Requirements *RequirementsSubresourceModel `tfsdk:"requirements"` + Tags []types.String `tfsdk:"tags"` + Topics []types.String `tfsdk:"topics"` +} + func (r *challengeResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_challenge" } @@ -83,8 +105,8 @@ func (r *challengeResource) Schema(ctx context.Context, req resource.SchemaReque Default: defaults.String(stringdefault.StaticString("linear")), Validators: []validator.String{ validators.NewStringEnumValidator([]basetypes.StringValue{ - challenge.FunctionLinear, - challenge.FunctionLogarithmic, + FunctionLinear, + FunctionLogarithmic, }), }, }, @@ -96,10 +118,18 @@ func (r *challengeResource) Schema(ctx context.Context, req resource.SchemaReque "decay": schema.Int64Attribute{ MarkdownDescription: "The decay defines from each number of solves does the decay function triggers until reaching minimum. This function is defined by CTFd and could be configured through `.function`.", Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, }, "minimum": schema.Int64Attribute{ MarkdownDescription: "The minimum points for a dynamic-score challenge to reach with the decay function. Once there, no solve could have more value.", Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, }, "state": schema.StringAttribute{ MarkdownDescription: "State of the challenge, either hidden or visible.", @@ -143,8 +173,8 @@ func (r *challengeResource) Schema(ctx context.Context, req resource.SchemaReque Default: stringdefault.StaticString("hidden"), Validators: []validator.String{ validators.NewStringEnumValidator([]basetypes.StringValue{ - challenge.BehaviorHidden, - challenge.BehaviorAnonymized, + BehaviorHidden, + BehaviorAnonymized, }), }, }, @@ -155,36 +185,19 @@ func (r *challengeResource) Schema(ctx context.Context, req resource.SchemaReque }, }, }, - "flags": schema.ListNestedAttribute{ - MarkdownDescription: "List of challenge flags that solves it.", - NestedObject: schema.NestedAttributeObject{ - Attributes: challenge.FlagSubresourceAttributes(), - }, - Optional: true, - }, "tags": schema.ListAttribute{ MarkdownDescription: "List of challenge tags that will be displayed to the end-user. You could use them to give some quick insights of what a challenge involves.", ElementType: types.StringType, Optional: true, + Computed: true, + Default: listdefault.StaticValue(basetypes.NewListValueMust(types.StringType, []attr.Value{})), }, "topics": schema.ListAttribute{ MarkdownDescription: "List of challenge topics that are displayed to the administrators for maintenance and planification.", ElementType: types.StringType, Optional: true, - }, - "hints": schema.ListNestedAttribute{ - MarkdownDescription: "List of hints about the challenge displayed to the end-user.", - NestedObject: schema.NestedAttributeObject{ - Attributes: challenge.HintSubresourceAttributes(), - }, - Optional: true, - }, - "files": schema.ListNestedAttribute{ - MarkdownDescription: "List of files given to players to flag the challenge.", - NestedObject: schema.NestedAttributeObject{ - Attributes: challenge.FileSubresourceAttributes(), - }, - Optional: true, + Computed: true, + Default: listdefault.StaticValue(basetypes.NewListValueMust(types.StringType, []attr.Value{})), }, }, } @@ -224,7 +237,7 @@ func (r *challengeResource) Create(ctx context.Context, req resource.CreateReque preqs = append(preqs, id) } reqs = &api.Requirements{ - Anonymize: challenge.GetAnon(data.Requirements.Behavior), + Anonymize: GetAnon(data.Requirements.Behavior), Prerequisites: preqs, } } @@ -256,26 +269,8 @@ func (r *challengeResource) Create(ctx context.Context, req resource.CreateReque // Save computed attributes in state data.ID = types.StringValue(strconv.Itoa(res.ID)) - - // Create files - challFiles := make([]challenge.FileSubresourceModel, 0, len(data.Files)) - for _, file := range data.Files { - file.Create(ctx, resp.Diagnostics, r.client, utils.Atoi(data.ID.ValueString())) - challFiles = append(challFiles, file) - } - if data.Files != nil { - data.Files = challFiles - } - - // Create flags - challFlags := make([]challenge.FlagSubresourceModel, 0, len(data.Flags)) - for _, flag := range data.Flags { - flag.Create(ctx, resp.Diagnostics, r.client, utils.Atoi(data.ID.ValueString())) - challFlags = append(challFlags, flag) - } - if data.Flags != nil { - data.Flags = challFlags - } + data.Decay = utils.ToTFInt64(res.Decay) + data.Minimum = utils.ToTFInt64(res.Minimum) // Create tags challTags := make([]types.String, 0, len(data.Tags)) @@ -318,16 +313,6 @@ func (r *challengeResource) Create(ctx context.Context, req resource.CreateReque data.Topics = challTopics } - // Create hints - challHints := make([]challenge.HintSubresourceModel, 0, len(data.Hints)) - for _, hint := range data.Hints { - hint.Create(ctx, resp.Diagnostics, r.client, utils.Atoi(data.ID.ValueString())) - challHints = append(challHints, hint) - } - if data.Hints != nil { - data.Hints = challHints - } - if resp.Diagnostics.HasError() { return } @@ -341,7 +326,7 @@ func (r *challengeResource) Read(ctx context.Context, req resource.ReadRequest, return } - data.Read(ctx, resp.Diagnostics, r.client) + data.Read(ctx, r.client, resp.Diagnostics) if resp.Diagnostics.HasError() { return @@ -367,7 +352,7 @@ func (r *challengeResource) Update(ctx context.Context, req resource.UpdateReque preqs = append(preqs, id) } reqs = &api.Requirements{ - Anonymize: challenge.GetAnon(data.Requirements.Behavior), + Anonymize: GetAnon(data.Requirements.Behavior), Prerequisites: preqs, } } @@ -394,110 +379,6 @@ func (r *challengeResource) Update(ctx context.Context, req resource.UpdateReque return } - // Update its files - currentFiles, err := r.client.GetChallengeFiles(utils.Atoi(data.ID.ValueString()), api.WithContext(ctx)) - if err != nil { - resp.Diagnostics.AddError( - "Client Error", - fmt.Sprintf("Unable to get challenge's files, got error: %s", err), - ) - } - files := []challenge.FileSubresourceModel{} - for _, file := range data.Files { - exists := false - for _, currentFile := range currentFiles { - if file.ID.ValueString() == strconv.Itoa(currentFile.ID) { - exists = true - - // Get corresponding file from state - var corFile challenge.FileSubresourceModel - for _, fState := range dataState.Files { - if file.ID.Equal(fState.ID) { - corFile = fState - break - } - } - - // => Drop and replace iif content changed - update := !corFile.Content.Equal(file.Content) - if update { - file.Delete(ctx, resp.Diagnostics, r.client) - file.Create(ctx, resp.Diagnostics, r.client, utils.Atoi(data.ID.ValueString())) - } - - files = append(files, file) - break - } - } - if !exists { - file.Create(ctx, resp.Diagnostics, r.client, utils.Atoi(data.ID.ValueString())) - files = append(files, file) - } - } - for _, currentFile := range currentFiles { - exists := false - for _, tfFile := range data.Files { - if tfFile.ID.ValueString() == strconv.Itoa(currentFile.ID) { - exists = true - break - } - } - if !exists { - f := challenge.FileSubresourceModel{ - ID: types.StringValue(strconv.Itoa(currentFile.ID)), - Location: types.StringValue(currentFile.Location), - } - f.Delete(ctx, resp.Diagnostics, r.client) - } - } - if data.Files != nil { - data.Files = files - } - - // Update its flags - currentFlags, err := r.client.GetChallengeFlags(utils.Atoi(data.ID.ValueString()), api.WithContext(ctx)) - if err != nil { - resp.Diagnostics.AddError( - "Client Error", - fmt.Sprintf("Unable to get challenge's flags, got error: %s", err), - ) - return - } - flags := []challenge.FlagSubresourceModel{} - for _, tfFlag := range data.Flags { - exists := false - for _, currentFlag := range currentFlags { - if tfFlag.ID.ValueString() == strconv.Itoa(currentFlag.ID) { - exists = true - tfFlag.Update(ctx, resp.Diagnostics, r.client) - flags = append(flags, tfFlag) - break - } - } - if !exists { - tfFlag.Create(ctx, resp.Diagnostics, r.client, utils.Atoi(data.ID.ValueString())) - flags = append(flags, tfFlag) - } - } - for _, currentFlag := range currentFlags { - exists := false - for _, tfFlag := range data.Flags { - if tfFlag.ID.ValueString() == strconv.Itoa(currentFlag.ID) { - exists = true - break - } - } - if !exists { - f := &challenge.FlagSubresourceModel{ - ID: types.StringValue(strconv.Itoa(currentFlag.ID)), - } - f.Delete(ctx, resp.Diagnostics, r.client) - } - } - if data.Flags != nil { - data.Flags = flags - } - // Update its tags (drop them all, create new ones) challTags, err := r.client.GetChallengeTags(utils.Atoi(data.ID.ValueString()), api.WithContext(ctx)) if err != nil { @@ -576,50 +457,6 @@ func (r *challengeResource) Update(ctx context.Context, req resource.UpdateReque data.Topics = topics } - // Update its hints - currentHints, err := r.client.GetChallengeHints(utils.Atoi(data.ID.ValueString()), api.WithContext(ctx)) - if err != nil { - resp.Diagnostics.AddError( - "Client Error", - fmt.Sprintf("Unable to get challenge's hints, got error: %s", err), - ) - return - } - hints := []challenge.HintSubresourceModel{} - for _, tfHint := range data.Hints { - exists := false - for _, currentHint := range currentHints { - if tfHint.ID.ValueString() == strconv.Itoa(currentHint.ID) { - exists = true - tfHint.Update(ctx, resp.Diagnostics, r.client) - hints = append(hints, tfHint) - break - } - } - if !exists { - tfHint.Create(ctx, resp.Diagnostics, r.client, utils.Atoi(data.ID.ValueString())) - hints = append(hints, tfHint) - } - } - for _, currentHint := range currentHints { - exists := false - for _, tfHint := range data.Hints { - if tfHint.ID.ValueString() == strconv.Itoa(currentHint.ID) { - exists = true - break - } - } - if !exists { - h := &challenge.HintSubresourceModel{ - ID: types.StringValue(strconv.Itoa(currentHint.ID)), - } - h.Delete(ctx, resp.Diagnostics, r.client) - } - } - if data.Hints != nil { - data.Hints = hints - } - if resp.Diagnostics.HasError() { return } @@ -646,3 +483,119 @@ func (r *challengeResource) ImportState(ctx context.Context, req resource.Import // Automatically call r.Read } + +// +// Starting from this are helper or types-specific code related to the ctfd_challenge resource +// + +func (chall *challengeResourceModel) Read(ctx context.Context, client *api.Client, diags diag.Diagnostics) { + res, err := client.GetChallenge(utils.Atoi(chall.ID.ValueString()), api.WithContext(ctx)) + if err != nil { + diags.AddError("Client Error", fmt.Sprintf("Unable to read challenge %s, got error: %s", chall.ID.ValueString(), err)) + return + } + chall.Name = types.StringValue(res.Name) + chall.Category = types.StringValue(res.Category) + chall.Description = types.StringValue(res.Description) + chall.ConnectionInfo = utils.ToTFString(res.ConnectionInfo) + chall.MaxAttempts = utils.ToTFInt64(res.MaxAttempts) + chall.Function = types.StringValue(res.Function) + chall.Decay = utils.ToTFInt64(res.Decay) + chall.Minimum = utils.ToTFInt64(res.Minimum) + chall.State = types.StringValue(res.State) + chall.Type = types.StringValue(res.Type) + chall.Next = utils.ToTFInt64(res.NextID) + + switch res.Type { + case "standard": + chall.Value = types.Int64Value(int64(res.Value)) + case "dynamic": + chall.Value = utils.ToTFInt64(res.Initial) + } + + id := utils.Atoi(chall.ID.ValueString()) + + // Get subresources + // => Requirements + resReqs, err := client.GetChallengeRequirements(id, api.WithContext(ctx)) + if err != nil { + diags.AddError( + "Client Error", + fmt.Sprintf("Unable to read challenge %d requirements, got error: %s", id, err), + ) + return + } + reqs := (*RequirementsSubresourceModel)(nil) + if resReqs != nil { + challPreqs := make([]types.String, 0, len(resReqs.Prerequisites)) + for _, req := range resReqs.Prerequisites { + challPreqs = append(challPreqs, types.StringValue(strconv.Itoa(req))) + } + reqs = &RequirementsSubresourceModel{ + Behavior: FromAnon(resReqs.Anonymize), + Prerequisites: challPreqs, + } + } + chall.Requirements = reqs + + // => Tags + resTags, err := client.GetChallengeTags(id, api.WithContext(ctx)) + if err != nil { + diags.AddError( + "Client Error", + fmt.Sprintf("Unable to read challenge %d tags, got error: %s", id, err), + ) + return + } + chall.Tags = make([]basetypes.StringValue, 0, len(resTags)) + for _, tag := range resTags { + chall.Tags = append(chall.Tags, types.StringValue(tag.Value)) + } + + // => Topics + resTopics, err := client.GetChallengeTopics(id, api.WithContext(ctx)) + if err != nil { + diags.AddError( + "Client Error", + fmt.Sprintf("Unable to read challenge %d topics, got error: %s", id, err), + ) + return + } + chall.Topics = make([]basetypes.StringValue, 0, len(resTopics)) + for _, topic := range resTopics { + chall.Topics = append(chall.Topics, types.StringValue(topic.Value)) + } +} + +var ( + BehaviorHidden = types.StringValue("hidden") + BehaviorAnonymized = types.StringValue("anonymized") + + FunctionLinear = types.StringValue("linear") + FunctionLogarithmic = types.StringValue("logarithmic") +) + +type RequirementsSubresourceModel struct { + Behavior types.String `tfsdk:"behavior"` + Prerequisites []types.String `tfsdk:"prerequisites"` +} + +func GetAnon(str types.String) *bool { + switch { + case str.Equal(BehaviorHidden): + return nil + case str.Equal(BehaviorAnonymized): + return utils.Ptr(true) + } + panic("invalid anonymization value: " + str.ValueString()) +} + +func FromAnon(b *bool) types.String { + if b == nil { + return BehaviorHidden + } + if *b { + return BehaviorAnonymized + } + panic("invalid anonymization value, got boolean false") +} diff --git a/provider/challenge_resource_test.go b/provider/challenge_resource_test.go index 879d7cf..2433f98 100644 --- a/provider/challenge_resource_test.go +++ b/provider/challenge_resource_test.go @@ -21,50 +21,24 @@ resource "ctfd_challenge" "http" { I hope no one spied me... Authors: - - NicolasFgrx + - Nicolas EOT value = 500 - decay = 17 + decay = 20 minimum = 50 - state = "visible" + state = "hidden" - flags = [{ - content = "24HIUT{Http_1s_n0t_s3cuR3}}" - }] topics = [ "Network" ] tags = [ - "network", - "http" + "network" ] - hints = [{ - content = "HTTP exchanges are not ciphered." - }, { - content = "Content is POSTed in HTTP :)" - cost = 50 - }] - files = [{ - name = "something.txt", - content = "I won't be really useful as a file, but I tried my best :)" - }, { - name = "something-b64.txt", - contentb64 = "SSB3b24ndCBiZSByZWFsbHkgdXNlZnVsbCBhcyBhIGZpbGUsIGJ1dCBJIHRyaWVkIG15IGJlc3QgOikK" - }] } `, Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("ctfd_challenge.http", "files.#", "2"), // Verify dynamic values have any value set in the state. resource.TestCheckResourceAttrSet("ctfd_challenge.http", "id"), - resource.TestCheckResourceAttrSet("ctfd_challenge.http", "flags.0.id"), - resource.TestCheckResourceAttrSet("ctfd_challenge.http", "hints.0.id"), - resource.TestCheckResourceAttrSet("ctfd_challenge.http", "files.0.id"), - resource.TestCheckResourceAttrSet("ctfd_challenge.http", "files.0.location"), - resource.TestCheckResourceAttrSet("ctfd_challenge.http", "files.0.contentb64"), - resource.TestCheckResourceAttrSet("ctfd_challenge.http", "files.1.id"), - resource.TestCheckResourceAttrSet("ctfd_challenge.http", "files.1.location"), - resource.TestCheckResourceAttrSet("ctfd_challenge.http", "files.1.content"), ), }, // ImportState testing @@ -91,9 +65,6 @@ resource "ctfd_challenge" "http" { minimum = 50 state = "visible" - flags = [{ - content = "24HIUT{Http_1s_n0t_s3cuR3}}" - }] topics = [ "Network" ] @@ -101,16 +72,6 @@ resource "ctfd_challenge" "http" { "network", "http" ] - hints = [{ - content = "HTTP exchanges are not ciphered." - }, { - content = "Content is POSTed in HTTP :)" - cost = 50 - }] - files = [{ - name = "something.txt", - content = "I won't be really useful as a file, but I tried my best :)" - }] } resource "ctfd_challenge" "icmp" { @@ -126,42 +87,14 @@ resource "ctfd_challenge" "icmp" { - NicolasFgrx EOT value = 500 - decay = 17 - minimum = 50 - state = "visible" + requirements = { behavior = "anonymized" prerequisites = [ctfd_challenge.http.id] } - - flags = [{ - content = "24HIUT{IcmpExfiltrationIsEasy}" - }] - - topics = [ - "Network" - ] - tags = [ - "network", - "icmp" - ] - - hints = [{ - content = "Vous ne trouvez pas qu'il ya beaucoup de requêtes ICMP ?" - cost = 50 - }, { - content = "Pour l'exo, le ttl a été modifié, tente un ` + "`ip.ttl<=20`" + `" - cost = 50 - }] - - files = [{ - name = "icmp.pcap" - contentb64 = "c29tZS1jb250ZW50Cg==" - }] } `, Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("ctfd_challenge.http", "files.#", "1"), resource.TestCheckResourceAttr("ctfd_challenge.icmp", "requirements.prerequisites.#", "1"), ), }, diff --git a/provider/file_resource.go b/provider/file_resource.go new file mode 100644 index 0000000..1ded325 --- /dev/null +++ b/provider/file_resource.go @@ -0,0 +1,298 @@ +package provider + +import ( + "context" + "encoding/base64" + "fmt" + "path/filepath" + "strconv" + + "github.com/ctfer-io/go-ctfd/api" + "github.com/ctfer-io/terraform-provider-ctfd/provider/utils" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + _ resource.Resource = (*fileResource)(nil) + _ resource.ResourceWithConfigure = (*fileResource)(nil) + _ resource.ResourceWithImportState = (*fileResource)(nil) +) + +func NewFileResource() resource.Resource { + return &fileResource{} +} + +type fileResource struct { + client *api.Client +} + +type fileResourceModel struct { + ID types.String `tfsdk:"id"` + ChallengeID types.String `tfsdk:"challenge_id"` + Name types.String `tfsdk:"name"` + Location types.String `tfsdk:"location"` + SHA1Sum types.String `tfsdk:"sha1sum"` + Content types.String `tfsdk:"content"` + ContentB64 types.String `tfsdk:"contentb64"` +} + +func (r *fileResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_file" +} + +func (r *fileResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "A CTFd file for a challenge.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Identifier of the file, used internally to handle the CTFd corresponding object. WARNING: updating this file does not work, requires full replacement.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "challenge_id": schema.StringAttribute{ + MarkdownDescription: "Challenge of the file.", + Optional: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "Name of the file as displayed to end-users.", + Required: true, + }, + "location": schema.StringAttribute{ + MarkdownDescription: "Location where the file is stored on the CTFd instance, for download purposes.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "sha1sum": schema.StringAttribute{ + MarkdownDescription: "The sha1 sum of the file.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "content": schema.StringAttribute{ + MarkdownDescription: "Raw content of the file, perfectly fit the use-cases of a .txt document or anything with a simple binary content. You could provide it from the file-system using `file(\"${path.module}/...\")`.", + Optional: true, + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "contentb64": schema.StringAttribute{ + MarkdownDescription: "Base 64 content of the file, perfectly fit the use-cases of complex binaries. You could provide it from the file-system using `filebase64(\"${path.module}/...\")`.", + Optional: true, + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *fileResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*api.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *github.com/ctfer-io/go-ctfd/api.Client, got: %T. Please open an issue at https://github.com/ctfer-io/terraform-provider-ctfd", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *fileResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data fileResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Fetch raw or base64 content prior to creating it with raw + data.PropagateContent(ctx, resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // Create file + params := &api.PostFilesParams{ + Files: []*api.InputFile{ + { + Name: data.Name.ValueString(), + Content: []byte(data.Content.ValueString()), + }, + }, + Location: data.Location.ValueStringPointer(), + } + if !data.ChallengeID.IsNull() { + params.Challenge = utils.Ptr(utils.Atoi(data.ChallengeID.ValueString())) + } + res, err := r.client.PostFiles(params, api.WithContext(ctx)) + if err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to create file, got error: %s", err), + ) + return + } + + tflog.Trace(ctx, "created a file") + + // Save computed attributes in state + data.ID = types.StringValue(strconv.Itoa(res[0].ID)) + data.SHA1Sum = types.StringValue(res[0].SHA1sum) + data.Location = types.StringValue(res[0].Location) + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *fileResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data fileResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + res, err := r.client.GetFile(data.ID.ValueString(), api.WithContext(ctx)) + if err != nil { + resp.Diagnostics.AddError( + "CTFd Error", + fmt.Sprintf("Unable to retrieve file %s, got error: %s", data.ID.ValueString(), err), + ) + return + } + + data.Name = types.StringValue(filepath.Base(res.Location)) + data.Location = types.StringValue(res.Location) + data.SHA1Sum = types.StringValue(res.SHA1sum) + data.ChallengeID = lookForChallengeId(ctx, r.client, res.ID, resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + content, err := r.client.GetFileContent(&api.File{ + Location: res.Location, + }, api.WithContext(ctx)) + if err != nil { + resp.Diagnostics.AddError( + "CTFd Error", + fmt.Sprintf("Unable to read file at location %s, got error: %s", res.Location, err), + ) + return + } + + data.ContentB64 = types.StringValue(base64.StdEncoding.EncodeToString(content)) + data.PropagateContent(ctx, resp.Diagnostics) + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *fileResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data fileResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.AddError("Provider Error", "CTFd does not permit update of file-related information thus this provider cannot do so. This operation should not have been possible.") +} + +func (r *fileResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data fileResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.client.DeleteFile(data.ID.ValueString(), api.WithContext(ctx)); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete file %s, got error: %s", data.ID.ValueString(), err)) + return + } +} + +func (r *fileResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) + + // Automatically call r.Read +} + +func (data *fileResourceModel) PropagateContent(ctx context.Context, diags diag.Diagnostics) { + // If the other content source is set, get the other from it + if len(data.Content.ValueString()) != 0 { + cb64 := base64.StdEncoding.EncodeToString([]byte(data.Content.ValueString())) + data.ContentB64 = types.StringValue(cb64) + return + } + if len(data.ContentB64.ValueString()) != 0 { + c, err := base64.StdEncoding.DecodeString(data.ContentB64.ValueString()) + diags.AddError( + "File Error", + fmt.Sprintf("Base64 file content failed at decoding: %s", err), + ) + data.Content = types.StringValue(string(c)) + return + } + // If no content seems to be set, set them both empty + data.Content = types.StringValue("") + data.ContentB64 = types.StringValue("") +} + +// XXX this helper only exist because CTFd does not return the challenge id of a file if it exist... +func lookForChallengeId(ctx context.Context, client *api.Client, fileID int, diags diag.Diagnostics) types.String { + challs, err := client.GetChallenges(&api.GetChallengesParams{ + View: utils.Ptr("admin"), // required, else CTFd only returns the "visible" challenges + }, api.WithContext(ctx)) + if err != nil { + diags.AddError( + "CTFd Error", + fmt.Sprintf("Unable to query challenges, got error: %s", err), + ) + return types.StringNull() + } + + for _, chall := range challs { + files, err := client.GetChallengeFiles(chall.ID, api.WithContext(ctx)) + if err != nil { + diags.AddError( + "CTFd Error", + fmt.Sprintf("Unable to query challenge %d files, got error: %s", chall.ID, err), + ) + return types.StringNull() + } + for _, file := range files { + if file.ID == fileID { + return types.StringValue(strconv.Itoa(chall.ID)) + } + } + } + return types.StringNull() +} diff --git a/provider/file_resource_test.go b/provider/file_resource_test.go new file mode 100644 index 0000000..44e2632 --- /dev/null +++ b/provider/file_resource_test.go @@ -0,0 +1,66 @@ +package provider_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAcc_File_Lifecycle(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: providerConfig + ` +resource "ctfd_challenge" "example" { + name = "Example challenge" + category = "test" + description = "Example challenge description..." + value = 500 +} + +resource "ctfd_file" "pouet" { + challenge_id = ctfd_challenge.example.id + name = "pouet.txt" + content = "Pouet is a clown cat" +} + +resource "ctfd_file" "pouet_2" { + name = "pouet-2.txt" + content = "Pouet is a clown cat, but has not challenge" +} +`, + }, + // ImportState testing + { + ResourceName: "ctfd_file.pouet", + ImportState: true, + ImportStateVerify: true, + }, + // Update and Read testing + { + Config: providerConfig + ` +resource "ctfd_challenge" "example" { + name = "Example challenge" + category = "test" + description = "Example challenge description..." + value = 500 +} + +resource "ctfd_file" "pouet" { + challenge_id = ctfd_challenge.example.id + name = "pouet.txt" + content = "Pouet the 2nd is the clowniest cat ever" +} + +resource "ctfd_file" "pouet_2" { + name = "pouet-2.txt" + content = "Pouet is a clown cat, but has not challenge" +} +`, + }, + // Delete testing automatically occurs in TestCase + }, + }) +} diff --git a/provider/flag_resource.go b/provider/flag_resource.go new file mode 100644 index 0000000..e588e80 --- /dev/null +++ b/provider/flag_resource.go @@ -0,0 +1,226 @@ +package provider + +import ( + "context" + "fmt" + "strconv" + + "github.com/ctfer-io/go-ctfd/api" + "github.com/ctfer-io/terraform-provider-ctfd/provider/utils" + "github.com/ctfer-io/terraform-provider-ctfd/provider/validators" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + _ resource.Resource = (*flagResource)(nil) + _ resource.ResourceWithConfigure = (*flagResource)(nil) + _ resource.ResourceWithImportState = (*flagResource)(nil) +) + +func NewFlagResource() resource.Resource { + return &flagResource{} +} + +type flagResource struct { + client *api.Client +} + +type flagResourceModel struct { + ID types.String `tfsdk:"id"` + ChallengeID types.String `tfsdk:"challenge_id"` + Content types.String `tfsdk:"content"` + Data types.String `tfsdk:"data"` + Type types.String `tfsdk:"type"` +} + +func (r *flagResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_flag" +} + +func (r *flagResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "A flag to solve the challenge.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "Identifier of the flag, used internally to handle the CTFd corresponding object.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "challenge_id": schema.StringAttribute{ + MarkdownDescription: "Challenge of the flag.", + Required: true, + }, + "content": schema.StringAttribute{ + MarkdownDescription: "The actual flag to match. Consider using the convention `MYCTF{value}` with `MYCTF` being the shortcode of your event's name and `value` depending on each challenge.", + Required: true, + Sensitive: true, + }, + "data": schema.StringAttribute{ + MarkdownDescription: "The flag sensitivity information, either case_sensitive or case_insensitive", + Optional: true, + Computed: true, + // default value is "" (empty string) according to Web UI + Default: stringdefault.StaticString("case_sensitive"), + Validators: []validator.String{ + validators.NewStringEnumValidator([]basetypes.StringValue{ + types.StringValue("case_sensitive"), + types.StringValue("case_insensitive"), + }), + }, + }, + "type": schema.StringAttribute{ + MarkdownDescription: "The type of the flag, could be either static or regex", + Optional: true, + Computed: true, + // default value is "static" according to ctfcli + Default: stringdefault.StaticString("static"), + Validators: []validator.String{ + validators.NewStringEnumValidator([]basetypes.StringValue{ + types.StringValue("static"), + types.StringValue("regex"), + }), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *flagResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*api.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *github.com/ctfer-io/go-ctfd/api.Client, got: %T. Please open an issue at https://github.com/ctfer-io/terraform-provider-ctfd", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *flagResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data flagResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Create flag + res, err := r.client.PostFlags(&api.PostFlagsParams{ + Challenge: utils.Atoi(data.ChallengeID.ValueString()), + Content: data.Content.ValueString(), + Data: data.Data.ValueString(), + Type: data.Type.ValueString(), + }, api.WithContext(ctx)) + if err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to create flag, got error: %s", err), + ) + return + } + + tflog.Trace(ctx, "created a flag") + + // Save computed attributes in state + data.ID = types.StringValue(strconv.Itoa(res.ID)) + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *flagResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data flagResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve flag + res, err := r.client.GetFlag(data.ID.ValueString(), api.WithContext(ctx)) + if err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to read flag %s, got error: %s", data.ID.ValueString(), err), + ) + return + } + + // Upsert values + data.ChallengeID = types.StringValue(strconv.Itoa(res.ChallengeID)) + data.Content = types.StringValue(res.Content) + data.Data = types.StringValue(res.Data) + data.Type = types.StringValue(res.Type) + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *flagResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data flagResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Update flag + if _, err := r.client.PatchFlag(data.ID.ValueString(), &api.PatchFlagParams{ + ID: data.ID.ValueString(), + Content: data.Content.ValueString(), + Data: data.Data.ValueString(), + Type: data.Type.ValueString(), + }, api.WithContext(ctx)); err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to update flag %s, got error: %s", data.ID.ValueString(), err), + ) + return + } + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *flagResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data flagResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.client.DeleteFlag(data.ID.ValueString(), api.WithContext(ctx)); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete flag %s, got error: %s", data.ID.ValueString(), err)) + return + } +} + +func (r *flagResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) + + // Automatically call r.Read +} diff --git a/provider/flag_resource_test.go b/provider/flag_resource_test.go new file mode 100644 index 0000000..7dc2526 --- /dev/null +++ b/provider/flag_resource_test.go @@ -0,0 +1,63 @@ +package provider_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAcc_Flag_Lifecycle(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: providerConfig + ` +resource "ctfd_challenge" "example" { + name = "Example challenge" + category = "test" + description = "Example challenge description..." + value = 500 +} + +resource "ctfd_flag" "static" { + challenge_id = ctfd_challenge.example.id + content = "This is a first flag" + type = "static" +} +`, + }, + // ImportState testing + { + ResourceName: "ctfd_flag.static", + ImportState: true, + ImportStateVerify: true, + }, + // Update and Read testing + { + Config: providerConfig + ` +resource "ctfd_challenge" "example" { + name = "Example challenge" + category = "test" + description = "Example challenge description..." + value = 500 +} + +resource "ctfd_flag" "static" { + challenge_id = ctfd_challenge.example.id + content = "This is a first flag" + data = "case_insensitive" + type = "static" +} + +resource "ctfd_flag" "regex" { + challenge_id = ctfd_challenge.example.id + content = "CTFER{.*}" + type = "regex" +} +`, + }, + // Delete testing automatically occurs in TestCase + }, + }) +} diff --git a/provider/hint_resource.go b/provider/hint_resource.go new file mode 100644 index 0000000..cf35216 --- /dev/null +++ b/provider/hint_resource.go @@ -0,0 +1,243 @@ +package provider + +import ( + "context" + "fmt" + "strconv" + + "github.com/ctfer-io/go-ctfd/api" + "github.com/ctfer-io/terraform-provider-ctfd/provider/utils" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + _ resource.Resource = (*hintResource)(nil) + _ resource.ResourceWithConfigure = (*hintResource)(nil) + _ resource.ResourceWithImportState = (*hintResource)(nil) +) + +func NewHintResource() resource.Resource { + return &hintResource{} +} + +type hintResource struct { + client *api.Client +} + +type hintResourceModel struct { + ID types.String `tfsdk:"id"` + ChallengeID types.String `tfsdk:"challenge_id"` + Content types.String `tfsdk:"content"` + Cost types.Int64 `tfsdk:"cost"` + Requirements []types.String `tfsdk:"requirements"` +} + +func (r *hintResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_hint" +} + +func (r *hintResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "A hint for a challenge to help players solve it.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Identifier of the hint, used internally to handle the CTFd corresponding object.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "challenge_id": schema.StringAttribute{ + MarkdownDescription: "Challenge of the hint.", + Required: true, + }, + "content": schema.StringAttribute{ + MarkdownDescription: "Content of the hint as displayed to the end-user.", + Required: true, + }, + "cost": schema.Int64Attribute{ + MarkdownDescription: "Cost of the hint, and if any specified, the end-user will consume its own (or team) points to get it.", + Computed: true, + Optional: true, + Default: int64default.StaticInt64(0), + }, + "requirements": schema.ListAttribute{ + MarkdownDescription: "List of the other hints it depends on.", + ElementType: types.StringType, + Computed: true, + Optional: true, + Default: listdefault.StaticValue(basetypes.NewListValueMust(types.StringType, []attr.Value{})), + }, + }, + } +} + +func (r *hintResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*api.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *github.com/ctfer-io/go-ctfd/api.Client, got: %T. Please open an issue at https://github.com/ctfer-io/terraform-provider-ctfd", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *hintResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data hintResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Create hint + reqs := make([]int, 0, len(data.Requirements)) + for _, preq := range data.Requirements { + id, _ := strconv.Atoi(preq.ValueString()) + reqs = append(reqs, id) + } + res, err := r.client.PostHints(&api.PostHintsParams{ + ChallengeID: utils.Atoi(data.ChallengeID.ValueString()), + Content: data.Content.ValueString(), + Cost: int(data.Cost.ValueInt64()), + Requirements: api.Requirements{ + Prerequisites: reqs, + }, + }, api.WithContext(ctx)) + if err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to create hint, got error: %s", err), + ) + return + } + + tflog.Trace(ctx, "created a hint") + + // Save computed attributes in state + data.ID = types.StringValue(strconv.Itoa(res.ID)) + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *hintResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data hintResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve hint + h, err := r.client.GetHint(data.ID.ValueString(), api.WithContext(ctx)) + if err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to update hint %s, got error: %s", data.ID.ValueString(), err), + ) + return + } + // Forced to pass by all hints as CTFd does not return content for direct query + hints, err := r.client.GetChallengeHints(h.ChallengeID, api.WithContext(ctx)) + hint := (*api.Hint)(nil) + for _, h := range hints { + if h.ID == utils.Atoi(data.ID.ValueString()) { + hint = h + break + } + } + if hint == nil { + resp.Diagnostics.AddError( + "CTFd Error", + fmt.Sprintf("Unable to get hint %s of challenge %s, got error: %s", data.ID.ValueString(), data.ChallengeID.ValueString(), err), + ) + return + } + + // Upsert values + data.ChallengeID = types.StringValue(strconv.Itoa(h.ChallengeID)) + data.Content = types.StringValue(*hint.Content) + data.Cost = types.Int64Value(int64(hint.Cost)) + reqs := make([]basetypes.StringValue, 0, len(hint.Requirements.Prerequisites)) + for _, preq := range hint.Requirements.Prerequisites { + reqs = append(reqs, types.StringValue(strconv.Itoa(preq))) + } + data.Requirements = reqs + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *hintResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data hintResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Update hint + preqs := make([]int, 0, len(data.Requirements)) + for _, preq := range data.Requirements { + id, _ := strconv.Atoi(preq.ValueString()) + preqs = append(preqs, id) + } + if _, err := r.client.PatchHint(data.ID.ValueString(), &api.PatchHintsParams{ + ChallengeID: utils.Atoi(data.ChallengeID.ValueString()), + Content: data.Content.ValueString(), + Cost: int(data.Cost.ValueInt64()), + Requirements: api.Requirements{ + Prerequisites: preqs, + }, + }, api.WithContext(ctx)); err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to update hint %s, got error: %s", data.ID.ValueString(), err), + ) + return + } + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *hintResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data hintResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.client.DeleteHint(data.ID.ValueString(), api.WithContext(ctx)); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete hint %s, got error: %s", data.ID.ValueString(), err)) + return + } +} + +func (r *hintResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) + + // Automatically call r.Read +} diff --git a/provider/hint_resource_test.go b/provider/hint_resource_test.go new file mode 100644 index 0000000..968dbcb --- /dev/null +++ b/provider/hint_resource_test.go @@ -0,0 +1,71 @@ +package provider_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAcc_Hint_Lifecycle(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: providerConfig + ` +resource "ctfd_challenge" "example" { + name = "Example challenge" + category = "test" + description = "Example challenge description..." + value = 500 +} + +resource "ctfd_hint" "first" { + challenge_id = ctfd_challenge.example.id + content = "This is a first hint" + cost = 1 +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + // Verify dynamic values have any value set in the state. + resource.TestCheckResourceAttr("ctfd_hint.first", "requirements.#", "0"), + ), + }, + // ImportState testing + { + ResourceName: "ctfd_hint.first", + ImportState: true, + ImportStateVerify: true, + }, + // Update and Read testing + { + Config: providerConfig + ` +resource "ctfd_challenge" "example" { + name = "Example challenge" + category = "test" + description = "Example challenge description..." + value = 500 +} + +resource "ctfd_hint" "first" { + challenge_id = ctfd_challenge.example.id + content = "This is a first hint" + cost = 1 +} + +resource "ctfd_hint" "second" { + challenge_id = ctfd_challenge.example.id + content = "This is a second hint" + cost = 2 + requirements = [ctfd_hint.first.id] +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ctfd_hint.first", "requirements.#", "0"), + resource.TestCheckResourceAttr("ctfd_hint.second", "requirements.#", "1"), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} diff --git a/provider/provider.go b/provider/provider.go index 43d5a4d..8b6ce67 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -5,6 +5,7 @@ import ( "os" "github.com/ctfer-io/go-ctfd/api" + "github.com/ctfer-io/terraform-provider-ctfd/provider/utils" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -171,9 +172,9 @@ func (p *CTFdProvider) Configure(ctx context.Context, req provider.ConfigureRequ // Instantiate CTFd API client ctx = tflog.SetField(ctx, "ctfd_url", url) - ctx = addSensitive(ctx, "ctfd_session", session) - ctx = addSensitive(ctx, "ctfd_nonce", nonce) - ctx = addSensitive(ctx, "ctfd_api_key", apiKey) + ctx = utils.AddSensitive(ctx, "ctfd_session", session) + ctx = utils.AddSensitive(ctx, "ctfd_nonce", nonce) + ctx = utils.AddSensitive(ctx, "ctfd_api_key", apiKey) tflog.Debug(ctx, "Creating CTFd API client") client := api.NewClient(url, session, nonce, apiKey) @@ -188,6 +189,9 @@ func (p *CTFdProvider) Configure(ctx context.Context, req provider.ConfigureRequ func (p *CTFdProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ NewChallengeResource, + NewHintResource, + NewFlagResource, + NewFileResource, NewUserResource, NewTeamResource, } diff --git a/provider/team_resource.go b/provider/team_resource.go index 6ad2f28..339df2f 100644 --- a/provider/team_resource.go +++ b/provider/team_resource.go @@ -72,6 +72,9 @@ func (r *teamResource) Schema(ctx context.Context, req resource.SchemaRequest, r "password": schema.StringAttribute{ MarkdownDescription: "Password of the team. Notice that during a CTF you may not want to update those to avoid defaulting team accesses.", Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, }, "website": schema.StringAttribute{ MarkdownDescription: "Website, blog, or anything similar (displayed to other participants).", diff --git a/provider/team_resource_test.go b/provider/team_resource_test.go index 2d82ba9..3b2de62 100644 --- a/provider/team_resource_test.go +++ b/provider/team_resource_test.go @@ -47,7 +47,7 @@ resource "ctfd_team" "cybercombattants" { resource "ctfd_user" "ctfer" { name = "CTFer" email = "ctfer-io-team@protonmail.com" - password = "password" + password = "new-password" } resource "ctfd_team" "cybercombattants" { diff --git a/provider/utils.go b/provider/utils.go deleted file mode 100644 index 7a5036f..0000000 --- a/provider/utils.go +++ /dev/null @@ -1,12 +0,0 @@ -package provider - -import ( - "context" - - "github.com/hashicorp/terraform-plugin-log/tflog" -) - -func addSensitive(ctx context.Context, key string, value any) context.Context { - ctx = tflog.SetField(ctx, key, value) - return tflog.MaskFieldValuesWithFieldKeys(ctx, key) -} diff --git a/provider/utils/utils.go b/provider/utils/utils.go index f11399d..b16bdc9 100644 --- a/provider/utils/utils.go +++ b/provider/utils/utils.go @@ -1,12 +1,18 @@ package utils import ( + "context" "strconv" - "strings" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" ) +func AddSensitive(ctx context.Context, key string, value any) context.Context { + ctx = tflog.SetField(ctx, key, value) + return tflog.MaskFieldValuesWithFieldKeys(ctx, key) +} + // return a null types.Int64 if pointer is nil, else its value func ToTFInt64(i *int) types.Int64 { if i == nil { @@ -31,11 +37,6 @@ func ToInt(itf types.Int64) *int { return &i } -func Filename(location string) string { - pts := strings.Split(location, "/") - return pts[len(pts)-1] -} - func Ptr[T any](t T) *T { return &t }