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
}