First working version
This commit is contained in:
parent
63ed6316bc
commit
d8eda81e0e
31 changed files with 3134 additions and 0 deletions
85
internal/provider/provider.go
Normal file
85
internal/provider/provider.go
Normal file
|
@ -0,0 +1,85 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/datasource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/function"
|
||||
"github.com/hashicorp/terraform-plugin-framework/provider"
|
||||
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
)
|
||||
|
||||
// Ensure ScaffoldingProvider satisfies various provider interfaces.
|
||||
var _ provider.Provider = &ScaffoldingProvider{}
|
||||
var _ provider.ProviderWithFunctions = &ScaffoldingProvider{}
|
||||
|
||||
// ScaffoldingProvider defines the provider implementation.
|
||||
type ScaffoldingProvider struct {
|
||||
// version is set to the provider version on release, "dev" when the
|
||||
// provider is built and ran locally, and "test" when running acceptance
|
||||
// testing.
|
||||
version string
|
||||
}
|
||||
|
||||
// ScaffoldingProviderModel describes the provider data model.
|
||||
type ScaffoldingProviderModel struct {
|
||||
}
|
||||
|
||||
func (p *ScaffoldingProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
|
||||
resp.TypeName = "tor"
|
||||
resp.Version = p.version
|
||||
}
|
||||
|
||||
func (p *ScaffoldingProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{
|
||||
MarkdownDescription: "The Tor provider generates cryptographic identity materials for obfs4 Tor bridges, enabling stateless bridge deployments.",
|
||||
Attributes: map[string]schema.Attribute{},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ScaffoldingProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
|
||||
var data ScaffoldingProviderModel
|
||||
|
||||
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
|
||||
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
// No configuration needed for crypto generation provider
|
||||
// Set nil for data sources and resources since no client is needed
|
||||
resp.DataSourceData = nil
|
||||
resp.ResourceData = nil
|
||||
}
|
||||
|
||||
func (p *ScaffoldingProvider) Resources(ctx context.Context) []func() resource.Resource {
|
||||
return []func() resource.Resource{
|
||||
NewTorObfs4StateResource,
|
||||
NewTorRelayIdentityRsaResource,
|
||||
NewTorRelayIdentityEd25519Resource,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func (p *ScaffoldingProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
|
||||
return []func() datasource.DataSource{
|
||||
NewTorObfs4BridgeLineDataSource,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ScaffoldingProvider) Functions(ctx context.Context) []func() function.Function {
|
||||
return []func() function.Function{
|
||||
}
|
||||
}
|
||||
|
||||
func New(version string) func() provider.Provider {
|
||||
return func() provider.Provider {
|
||||
return &ScaffoldingProvider{
|
||||
version: version,
|
||||
}
|
||||
}
|
||||
}
|
25
internal/provider/provider_test.go
Normal file
25
internal/provider/provider_test.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/providerserver"
|
||||
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
|
||||
)
|
||||
|
||||
// testAccProtoV6ProviderFactories are used to instantiate a provider during
|
||||
// acceptance testing. The factory function will be invoked for every Terraform
|
||||
// CLI command executed to create a provider server to which the CLI can
|
||||
// reattach.
|
||||
var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
|
||||
"tor": providerserver.NewProtocol6WithError(New("test")()),
|
||||
}
|
||||
|
||||
func testAccPreCheck(t *testing.T) {
|
||||
// You can add code here to run prior to any test case execution, for example assertions
|
||||
// about the appropriate environment variables being set are common to see in a pre-check
|
||||
// function.
|
||||
}
|
107
internal/provider/tor_obfs4_bridge_line_data_source.go
Normal file
107
internal/provider/tor_obfs4_bridge_line_data_source.go
Normal file
|
@ -0,0 +1,107 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/datasource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
"github.com/hashicorp/terraform-plugin-log/tflog"
|
||||
)
|
||||
|
||||
// Ensure provider defined types fully satisfy framework interfaces.
|
||||
var _ datasource.DataSource = &TorObfs4BridgeLineDataSource{}
|
||||
|
||||
func NewTorObfs4BridgeLineDataSource() datasource.DataSource {
|
||||
return &TorObfs4BridgeLineDataSource{}
|
||||
}
|
||||
|
||||
// TorObfs4BridgeLineDataSource defines the data source implementation.
|
||||
type TorObfs4BridgeLineDataSource struct{}
|
||||
|
||||
// TorObfs4BridgeLineDataSourceModel describes the data source data model.
|
||||
type TorObfs4BridgeLineDataSourceModel struct {
|
||||
Id types.String `tfsdk:"id"`
|
||||
IpAddress types.String `tfsdk:"ip_address"`
|
||||
Port types.Int64 `tfsdk:"port"`
|
||||
IdentityFingerprintSha1 types.String `tfsdk:"identity_fingerprint_sha1"`
|
||||
Obfs4StateCertificate types.String `tfsdk:"obfs4_state_certificate"`
|
||||
Obfs4StateIatMode types.Int64 `tfsdk:"obfs4_state_iat_mode"`
|
||||
BridgeLine types.String `tfsdk:"bridge_line"`
|
||||
}
|
||||
|
||||
func (d *TorObfs4BridgeLineDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_obfs4_bridge_line"
|
||||
}
|
||||
|
||||
func (d *TorObfs4BridgeLineDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{
|
||||
MarkdownDescription: "Generates a complete Tor bridge line using obfs4 state and network details",
|
||||
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "Data source identifier",
|
||||
},
|
||||
"ip_address": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "Bridge IP address",
|
||||
},
|
||||
"port": schema.Int64Attribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "Bridge port number",
|
||||
},
|
||||
"identity_fingerprint_sha1": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "SHA1 fingerprint of the RSA identity key",
|
||||
},
|
||||
"obfs4_state_certificate": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "Base64-encoded certificate from tor_obfs4_state resource",
|
||||
},
|
||||
"obfs4_state_iat_mode": schema.Int64Attribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "Inter-Arrival Time mode from tor_obfs4_state resource",
|
||||
},
|
||||
"bridge_line": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "Complete bridge line ready for client use",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *TorObfs4BridgeLineDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
|
||||
// No configuration needed for this data source
|
||||
}
|
||||
|
||||
func (d *TorObfs4BridgeLineDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
|
||||
var data TorObfs4BridgeLineDataSourceModel
|
||||
|
||||
// Read Terraform configuration data into the model
|
||||
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate bridge line using the provided inputs
|
||||
bridgeLine := fmt.Sprintf("Bridge obfs4 %s:%d %s cert=%s iat-mode=%d",
|
||||
data.IpAddress.ValueString(),
|
||||
data.Port.ValueInt64(),
|
||||
data.IdentityFingerprintSha1.ValueString(),
|
||||
data.Obfs4StateCertificate.ValueString(),
|
||||
data.Obfs4StateIatMode.ValueInt64())
|
||||
|
||||
// Set computed values
|
||||
data.Id = types.StringValue(fmt.Sprintf("bridge-%s-%d", data.IpAddress.ValueString(), data.Port.ValueInt64()))
|
||||
data.BridgeLine = types.StringValue(bridgeLine)
|
||||
|
||||
tflog.Trace(ctx, "read bridge line data source")
|
||||
|
||||
// Save data into Terraform state
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
|
||||
}
|
197
internal/provider/tor_obfs4_bridge_line_data_source_test.go
Normal file
197
internal/provider/tor_obfs4_bridge_line_data_source_test.go
Normal file
|
@ -0,0 +1,197 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
|
||||
"github.com/hashicorp/terraform-plugin-testing/terraform"
|
||||
)
|
||||
|
||||
func TestTorObfs4BridgeLineDataSource(t *testing.T) {
|
||||
resource.Test(t, resource.TestCase{
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
|
||||
Steps: []resource.TestStep{
|
||||
// Read testing
|
||||
{
|
||||
Config: testTorObfs4BridgeLineDataSourceConfig(),
|
||||
Check: resource.ComposeAggregateTestCheckFunc(
|
||||
// Check that the data source was created
|
||||
resource.TestCheckResourceAttrSet("data.tor_obfs4_bridge_line.test", "id"),
|
||||
|
||||
// Check input values are preserved
|
||||
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test", "ip_address", "192.0.2.1"),
|
||||
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test", "port", "443"),
|
||||
|
||||
// Check computed values are generated
|
||||
resource.TestCheckResourceAttrSet("data.tor_obfs4_bridge_line.test", "bridge_line"),
|
||||
|
||||
// Check bridge line format
|
||||
resource.TestMatchResourceAttr("data.tor_obfs4_bridge_line.test", "bridge_line",
|
||||
regexp.MustCompile(`^Bridge obfs4 192\.0\.2\.1:443 [0-9a-f]{40} cert=[A-Za-z0-9+/]+ iat-mode=[0-2]$`)),
|
||||
|
||||
// Check that input values are used correctly
|
||||
resource.TestCheckResourceAttrPair("data.tor_obfs4_bridge_line.test", "identity_fingerprint_sha1", "tor_relay_identity_rsa.test", "public_key_fingerprint_sha1"),
|
||||
resource.TestCheckResourceAttrPair("data.tor_obfs4_bridge_line.test", "obfs4_state_certificate", "tor_obfs4_state.test", "certificate"),
|
||||
resource.TestCheckResourceAttrPair("data.tor_obfs4_bridge_line.test", "obfs4_state_iat_mode", "tor_obfs4_state.test", "iat_mode"),
|
||||
|
||||
// Validate bridge line consistency
|
||||
testTorObfs4BridgeLineConsistency("data.tor_obfs4_bridge_line.test"),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestTorObfs4BridgeLineDataSourceWithCustomIATMode(t *testing.T) {
|
||||
resource.Test(t, resource.TestCase{
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: testTorObfs4BridgeLineDataSourceConfigWithIATMode(2),
|
||||
Check: resource.ComposeAggregateTestCheckFunc(
|
||||
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test", "obfs4_state_iat_mode", "2"),
|
||||
resource.TestMatchResourceAttr("data.tor_obfs4_bridge_line.test", "bridge_line",
|
||||
regexp.MustCompile(`iat-mode=2$`)),
|
||||
testTorObfs4BridgeLineConsistency("data.tor_obfs4_bridge_line.test"),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestTorObfs4BridgeLineDataSourceMultiplePorts(t *testing.T) {
|
||||
resource.Test(t, resource.TestCase{
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: testTorObfs4BridgeLineDataSourceConfigMultiplePorts(),
|
||||
Check: resource.ComposeAggregateTestCheckFunc(
|
||||
// Check first bridge line
|
||||
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test1", "ip_address", "192.0.2.1"),
|
||||
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test1", "port", "443"),
|
||||
resource.TestMatchResourceAttr("data.tor_obfs4_bridge_line.test1", "bridge_line",
|
||||
regexp.MustCompile(`^Bridge obfs4 192\.0\.2\.1:443`)),
|
||||
|
||||
// Check second bridge line (same obfs4_state, different port)
|
||||
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test2", "ip_address", "192.0.2.1"),
|
||||
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test2", "port", "80"),
|
||||
resource.TestMatchResourceAttr("data.tor_obfs4_bridge_line.test2", "bridge_line",
|
||||
regexp.MustCompile(`^Bridge obfs4 192\.0\.2\.1:80`)),
|
||||
|
||||
// Verify both use the same certificate and fingerprint (same obfs4_state)
|
||||
resource.TestCheckResourceAttrPair("data.tor_obfs4_bridge_line.test1", "obfs4_state_certificate", "data.tor_obfs4_bridge_line.test2", "obfs4_state_certificate"),
|
||||
resource.TestCheckResourceAttrPair("data.tor_obfs4_bridge_line.test1", "identity_fingerprint_sha1", "data.tor_obfs4_bridge_line.test2", "identity_fingerprint_sha1"),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Test configuration functions
|
||||
func testTorObfs4BridgeLineDataSourceConfig() string {
|
||||
return `
|
||||
resource "tor_relay_identity_rsa" "test" {}
|
||||
|
||||
resource "tor_relay_identity_ed25519" "test" {}
|
||||
|
||||
resource "tor_obfs4_state" "test" {
|
||||
rsa_identity_private_key = tor_relay_identity_rsa.test.private_key_pem
|
||||
ed25519_identity_private_key = tor_relay_identity_ed25519.test.private_key_pem
|
||||
}
|
||||
|
||||
data "tor_obfs4_bridge_line" "test" {
|
||||
ip_address = "192.0.2.1"
|
||||
port = 443
|
||||
identity_fingerprint_sha1 = tor_relay_identity_rsa.test.public_key_fingerprint_sha1
|
||||
obfs4_state_certificate = tor_obfs4_state.test.certificate
|
||||
obfs4_state_iat_mode = tor_obfs4_state.test.iat_mode
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
func testTorObfs4BridgeLineDataSourceConfigWithIATMode(iatMode int) string {
|
||||
return fmt.Sprintf(`
|
||||
resource "tor_relay_identity_rsa" "test" {}
|
||||
|
||||
resource "tor_relay_identity_ed25519" "test" {}
|
||||
|
||||
resource "tor_obfs4_state" "test" {
|
||||
rsa_identity_private_key = tor_relay_identity_rsa.test.private_key_pem
|
||||
ed25519_identity_private_key = tor_relay_identity_ed25519.test.private_key_pem
|
||||
iat_mode = %d
|
||||
}
|
||||
|
||||
data "tor_obfs4_bridge_line" "test" {
|
||||
ip_address = "192.0.2.1"
|
||||
port = 443
|
||||
identity_fingerprint_sha1 = tor_relay_identity_rsa.test.public_key_fingerprint_sha1
|
||||
obfs4_state_certificate = tor_obfs4_state.test.certificate
|
||||
obfs4_state_iat_mode = tor_obfs4_state.test.iat_mode
|
||||
}
|
||||
`, iatMode)
|
||||
}
|
||||
|
||||
func testTorObfs4BridgeLineDataSourceConfigMultiplePorts() string {
|
||||
return `
|
||||
resource "tor_relay_identity_rsa" "test" {}
|
||||
|
||||
resource "tor_relay_identity_ed25519" "test" {}
|
||||
|
||||
resource "tor_obfs4_state" "test" {
|
||||
rsa_identity_private_key = tor_relay_identity_rsa.test.private_key_pem
|
||||
ed25519_identity_private_key = tor_relay_identity_ed25519.test.private_key_pem
|
||||
}
|
||||
|
||||
data "tor_obfs4_bridge_line" "test1" {
|
||||
ip_address = "192.0.2.1"
|
||||
port = 443
|
||||
identity_fingerprint_sha1 = tor_relay_identity_rsa.test.public_key_fingerprint_sha1
|
||||
obfs4_state_certificate = tor_obfs4_state.test.certificate
|
||||
obfs4_state_iat_mode = tor_obfs4_state.test.iat_mode
|
||||
}
|
||||
|
||||
data "tor_obfs4_bridge_line" "test2" {
|
||||
ip_address = "192.0.2.1"
|
||||
port = 80
|
||||
identity_fingerprint_sha1 = tor_relay_identity_rsa.test.public_key_fingerprint_sha1
|
||||
obfs4_state_certificate = tor_obfs4_state.test.certificate
|
||||
obfs4_state_iat_mode = tor_obfs4_state.test.iat_mode
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
// Custom test check functions
|
||||
func testTorObfs4BridgeLineConsistency(resourceName string) resource.TestCheckFunc {
|
||||
return func(s *terraform.State) error {
|
||||
rs, ok := s.RootModule().Resources[resourceName]
|
||||
if !ok {
|
||||
return fmt.Errorf("Resource not found: %s", resourceName)
|
||||
}
|
||||
|
||||
ipAddress := rs.Primary.Attributes["ip_address"]
|
||||
port := rs.Primary.Attributes["port"]
|
||||
fingerprint := rs.Primary.Attributes["identity_fingerprint_sha1"]
|
||||
certificate := rs.Primary.Attributes["obfs4_state_certificate"]
|
||||
iatMode := rs.Primary.Attributes["obfs4_state_iat_mode"]
|
||||
bridgeLine := rs.Primary.Attributes["bridge_line"]
|
||||
|
||||
// Verify bridge line format matches expected pattern
|
||||
expectedBridgeLine := fmt.Sprintf("Bridge obfs4 %s:%s %s cert=%s iat-mode=%s",
|
||||
ipAddress, port, fingerprint, certificate, iatMode)
|
||||
|
||||
if bridgeLine != expectedBridgeLine {
|
||||
return fmt.Errorf("Bridge line doesn't match expected format.\nExpected: %s\nActual: %s",
|
||||
expectedBridgeLine, bridgeLine)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
425
internal/provider/tor_obfs4_state_resource.go
Normal file
425
internal/provider/tor_obfs4_state_resource.go
Normal file
|
@ -0,0 +1,425 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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/planmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
"github.com/hashicorp/terraform-plugin-log/tflog"
|
||||
|
||||
"gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/lyrebird/common/drbg"
|
||||
"gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/lyrebird/common/ntor"
|
||||
)
|
||||
|
||||
// Ensure provider defined types fully satisfy framework interfaces.
|
||||
var _ resource.Resource = &TorObfs4StateResource{}
|
||||
|
||||
func NewTorObfs4StateResource() resource.Resource {
|
||||
return &TorObfs4StateResource{}
|
||||
}
|
||||
|
||||
// TorObfs4StateResource defines the resource implementation.
|
||||
type TorObfs4StateResource struct{}
|
||||
|
||||
// TorObfs4StateResourceModel describes the resource data model.
|
||||
type TorObfs4StateResourceModel struct {
|
||||
Id types.String `tfsdk:"id"`
|
||||
RsaIdentityPrivateKey types.String `tfsdk:"rsa_identity_private_key"`
|
||||
Ed25519IdentityPrivateKey types.String `tfsdk:"ed25519_identity_private_key"`
|
||||
NodeId types.String `tfsdk:"node_id"`
|
||||
PrivateKey types.String `tfsdk:"private_key"`
|
||||
PublicKey types.String `tfsdk:"public_key"`
|
||||
DrbgSeed types.String `tfsdk:"drbg_seed"`
|
||||
IatMode types.Int64 `tfsdk:"iat_mode"`
|
||||
Certificate types.String `tfsdk:"certificate"`
|
||||
StateJson types.String `tfsdk:"state_json"`
|
||||
BridgeLine types.String `tfsdk:"bridge_line"`
|
||||
}
|
||||
|
||||
// obfs4StateJson represents the JSON structure for the state file
|
||||
type obfs4StateJson struct {
|
||||
NodeId string `json:"node-id"`
|
||||
PrivateKey string `json:"private-key"`
|
||||
PublicKey string `json:"public-key"`
|
||||
DrbgSeed string `json:"drbg-seed"`
|
||||
IatMode int `json:"iat-mode"`
|
||||
}
|
||||
|
||||
func (r *TorObfs4StateResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_obfs4_state"
|
||||
}
|
||||
|
||||
func (r *TorObfs4StateResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{
|
||||
MarkdownDescription: "Generates obfs4 state and certificate for Tor bridges using external relay identity keys",
|
||||
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "Resource identifier",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"rsa_identity_private_key": schema.StringAttribute{
|
||||
Required: true,
|
||||
Sensitive: true,
|
||||
MarkdownDescription: "RSA identity private key in PEM format (from tor_relay_identity_rsa resource)",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
},
|
||||
"ed25519_identity_private_key": schema.StringAttribute{
|
||||
Required: true,
|
||||
Sensitive: true,
|
||||
MarkdownDescription: "Ed25519 identity private key in PEM format (from tor_relay_identity_ed25519 resource)",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
},
|
||||
"node_id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "20-byte node ID in hex format",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"private_key": schema.StringAttribute{
|
||||
Computed: true,
|
||||
Sensitive: true,
|
||||
MarkdownDescription: "32-byte Curve25519 private key in hex format",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"public_key": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "32-byte Curve25519 public key in hex format",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"drbg_seed": schema.StringAttribute{
|
||||
Computed: true,
|
||||
Sensitive: true,
|
||||
MarkdownDescription: "24-byte DRBG seed in hex format",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"iat_mode": schema.Int64Attribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Default: int64default.StaticInt64(0),
|
||||
MarkdownDescription: "Inter-Arrival Time mode (0=none, 1=enabled, 2=paranoid)",
|
||||
},
|
||||
"certificate": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "Base64-encoded certificate for bridge lines",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"state_json": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "Complete obfs4 state in JSON format",
|
||||
},
|
||||
"bridge_line": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "Complete bridge line ready for client use (placeholder IP and fingerprint)",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TorObfs4StateResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
// No configuration needed for this crypto generation resource
|
||||
}
|
||||
|
||||
func (r *TorObfs4StateResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var data TorObfs4StateResourceModel
|
||||
|
||||
// Read Terraform plan data into the model
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate obfs4 state
|
||||
err := r.generateObfs4State(ctx, &data)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("obfs4 State Generation Error",
|
||||
fmt.Sprintf("Unable to generate obfs4 state: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Set ID to a hash of the public key for uniqueness
|
||||
data.Id = types.StringValue(fmt.Sprintf("obfs4-%s", data.PublicKey.ValueString()[:16]))
|
||||
|
||||
tflog.Trace(ctx, "created obfs4 state resource")
|
||||
|
||||
// Save data into Terraform state
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
|
||||
}
|
||||
|
||||
func (r *TorObfs4StateResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var data TorObfs4StateResourceModel
|
||||
|
||||
// Read Terraform prior state data into the model
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
// For this resource, the state is static once created, so no external API calls needed
|
||||
// Just ensure the computed fields are still consistent
|
||||
if !data.NodeId.IsNull() && !data.PublicKey.IsNull() {
|
||||
// Regenerate certificate to ensure consistency
|
||||
cert, err := r.generateCertificate(data.NodeId.ValueString(), data.PublicKey.ValueString())
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Certificate Generation Error",
|
||||
fmt.Sprintf("Unable to regenerate certificate: %s", err))
|
||||
return
|
||||
}
|
||||
data.Certificate = types.StringValue(cert)
|
||||
|
||||
// Regenerate state JSON
|
||||
stateJson, err := r.generateStateJson(&data)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("State JSON Generation Error",
|
||||
fmt.Sprintf("Unable to regenerate state JSON: %s", err))
|
||||
return
|
||||
}
|
||||
data.StateJson = types.StringValue(stateJson)
|
||||
|
||||
// Regenerate bridge line
|
||||
bridgeLine := r.generateBridgeLine(data.Certificate.ValueString(), data.IatMode.ValueInt64())
|
||||
data.BridgeLine = types.StringValue(bridgeLine)
|
||||
}
|
||||
|
||||
// Save updated data into Terraform state
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
|
||||
}
|
||||
|
||||
func (r *TorObfs4StateResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
var currentState TorObfs4StateResourceModel
|
||||
var plannedState TorObfs4StateResourceModel
|
||||
|
||||
// Read current state
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, ¤tState)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
// Read planned state
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plannedState)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
// Only iat_mode can be updated; preserve all crypto fields from current state
|
||||
currentState.IatMode = plannedState.IatMode
|
||||
|
||||
// Regenerate computed fields with new iat_mode
|
||||
stateJson, err := r.generateStateJson(¤tState)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("State JSON Generation Error",
|
||||
fmt.Sprintf("Unable to regenerate state JSON: %s", err))
|
||||
return
|
||||
}
|
||||
currentState.StateJson = types.StringValue(stateJson)
|
||||
|
||||
// Regenerate bridge line with new iat_mode
|
||||
bridgeLine := r.generateBridgeLine(currentState.Certificate.ValueString(), currentState.IatMode.ValueInt64())
|
||||
currentState.BridgeLine = types.StringValue(bridgeLine)
|
||||
|
||||
tflog.Trace(ctx, "updated obfs4 state resource")
|
||||
|
||||
// Save updated data into Terraform state
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, ¤tState)...)
|
||||
}
|
||||
|
||||
func (r *TorObfs4StateResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
// No external resources to clean up - this is a stateless resource
|
||||
tflog.Trace(ctx, "deleted obfs4 state resource")
|
||||
}
|
||||
|
||||
|
||||
// generateObfs4State generates all the obfs4 state fields
|
||||
func (r *TorObfs4StateResource) generateObfs4State(ctx context.Context, data *TorObfs4StateResourceModel) error {
|
||||
// Derive node ID from RSA identity key (required)
|
||||
nodeId, err := r.deriveNodeIdFromRsaKey(data.RsaIdentityPrivateKey.ValueString())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive node ID from RSA key: %w", err)
|
||||
}
|
||||
data.NodeId = types.StringValue(nodeId.Hex())
|
||||
|
||||
// Generate Curve25519 keypair
|
||||
keypair, err := ntor.NewKeypair(false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate keypair: %w", err)
|
||||
}
|
||||
data.PrivateKey = types.StringValue(keypair.Private().Hex())
|
||||
data.PublicKey = types.StringValue(keypair.Public().Hex())
|
||||
|
||||
// Generate DRBG seed
|
||||
seed, err := drbg.NewSeed()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate DRBG seed: %w", err)
|
||||
}
|
||||
data.DrbgSeed = types.StringValue(seed.Hex())
|
||||
|
||||
// Set default iat_mode if not specified
|
||||
if data.IatMode.IsNull() {
|
||||
data.IatMode = types.Int64Value(0)
|
||||
}
|
||||
|
||||
// Generate certificate
|
||||
cert, err := r.generateCertificate(data.NodeId.ValueString(), data.PublicKey.ValueString())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate certificate: %w", err)
|
||||
}
|
||||
data.Certificate = types.StringValue(cert)
|
||||
|
||||
// Generate state JSON
|
||||
stateJson, err := r.generateStateJson(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate state JSON: %w", err)
|
||||
}
|
||||
data.StateJson = types.StringValue(stateJson)
|
||||
|
||||
// Generate bridge line
|
||||
bridgeLine := r.generateBridgeLine(data.Certificate.ValueString(), data.IatMode.ValueInt64())
|
||||
data.BridgeLine = types.StringValue(bridgeLine)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateCertificate creates the base64-encoded certificate from node ID and public key
|
||||
func (r *TorObfs4StateResource) generateCertificate(nodeIdHex, publicKeyHex string) (string, error) {
|
||||
// Decode hex strings to bytes
|
||||
nodeIdBytes, err := hex.DecodeString(nodeIdHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode node ID: %w", err)
|
||||
}
|
||||
|
||||
publicKeyBytes, err := hex.DecodeString(publicKeyHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode public key: %w", err)
|
||||
}
|
||||
|
||||
// Validate lengths
|
||||
if len(nodeIdBytes) != 20 {
|
||||
return "", fmt.Errorf("node ID must be 20 bytes, got %d", len(nodeIdBytes))
|
||||
}
|
||||
if len(publicKeyBytes) != 32 {
|
||||
return "", fmt.Errorf("public key must be 32 bytes, got %d", len(publicKeyBytes))
|
||||
}
|
||||
|
||||
// Concatenate node ID + public key (52 bytes total)
|
||||
certBytes := make([]byte, 52)
|
||||
copy(certBytes[:20], nodeIdBytes)
|
||||
copy(certBytes[20:], publicKeyBytes)
|
||||
|
||||
// Base64 encode and remove padding
|
||||
cert := base64.StdEncoding.EncodeToString(certBytes)
|
||||
cert = strings.TrimSuffix(cert, "==")
|
||||
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// generateStateJson creates the JSON representation of the obfs4 state
|
||||
func (r *TorObfs4StateResource) generateStateJson(data *TorObfs4StateResourceModel) (string, error) {
|
||||
state := obfs4StateJson{
|
||||
NodeId: data.NodeId.ValueString(),
|
||||
PrivateKey: data.PrivateKey.ValueString(),
|
||||
PublicKey: data.PublicKey.ValueString(),
|
||||
DrbgSeed: data.DrbgSeed.ValueString(),
|
||||
IatMode: int(data.IatMode.ValueInt64()),
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal state JSON: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// generateBridgeLine creates a complete bridge line with placeholder IP and fingerprint
|
||||
func (r *TorObfs4StateResource) generateBridgeLine(certificate string, iatMode int64) string {
|
||||
// Use placeholder values that users can easily replace
|
||||
placeholderIP := "<IP:PORT>"
|
||||
placeholderFingerprint := "<FINGERPRINT>"
|
||||
|
||||
return fmt.Sprintf("Bridge obfs4 %s %s cert=%s iat-mode=%d",
|
||||
placeholderIP, placeholderFingerprint, certificate, iatMode)
|
||||
}
|
||||
|
||||
// deriveNodeIdFromRsaKey derives a 20-byte node ID from an RSA private key PEM
|
||||
// This follows the Tor specification for relay identity
|
||||
func (r *TorObfs4StateResource) deriveNodeIdFromRsaKey(rsaPrivateKeyPem string) (*ntor.NodeID, error) {
|
||||
// Parse the PEM-encoded RSA private key
|
||||
block, _ := pem.Decode([]byte(rsaPrivateKeyPem))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to parse PEM block")
|
||||
}
|
||||
|
||||
var privateKey *rsa.PrivateKey
|
||||
var err error
|
||||
|
||||
// Try different RSA private key formats
|
||||
if block.Type == "RSA PRIVATE KEY" {
|
||||
privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
} else if block.Type == "PRIVATE KEY" {
|
||||
parsedKey, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err2 == nil {
|
||||
var ok bool
|
||||
privateKey, ok = parsedKey.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("parsed key is not an RSA private key")
|
||||
}
|
||||
} else {
|
||||
err = err2
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("unsupported PEM block type: %s", block.Type)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse RSA private key: %w", err)
|
||||
}
|
||||
|
||||
// Extract the public key and encode it
|
||||
publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal public key: %w", err)
|
||||
}
|
||||
|
||||
// Generate SHA1 hash of public key (this is the relay fingerprint/node ID)
|
||||
hash := sha1.Sum(publicKeyBytes)
|
||||
|
||||
// Create node ID from the first 20 bytes (which is all of SHA1)
|
||||
nodeId, err := ntor.NewNodeID(hash[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create node ID: %w", err)
|
||||
}
|
||||
|
||||
return nodeId, nil
|
||||
}
|
455
internal/provider/tor_obfs4_state_resource_test.go
Normal file
455
internal/provider/tor_obfs4_state_resource_test.go
Normal file
|
@ -0,0 +1,455 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
|
||||
"github.com/hashicorp/terraform-plugin-testing/terraform"
|
||||
)
|
||||
|
||||
func TestTorObfs4StateResource(t *testing.T) {
|
||||
resource.Test(t, resource.TestCase{
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
|
||||
Steps: []resource.TestStep{
|
||||
// Create and Read testing
|
||||
{
|
||||
Config: testTorObfs4StateResourceConfigWithIdentityKeys(),
|
||||
Check: resource.ComposeAggregateTestCheckFunc(
|
||||
// Check that the resource was created
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "id"),
|
||||
|
||||
// Check that all fields are generated
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "node_id"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "private_key"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "public_key"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "drbg_seed"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "certificate"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "state_json"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "bridge_line"),
|
||||
|
||||
// Check default iat_mode
|
||||
resource.TestCheckResourceAttr("tor_obfs4_state.test", "iat_mode", "0"),
|
||||
|
||||
// Check field lengths (hex-encoded)
|
||||
resource.TestMatchResourceAttr("tor_obfs4_state.test", "node_id", regexp.MustCompile(`^[0-9a-f]{40}$`)),
|
||||
resource.TestMatchResourceAttr("tor_obfs4_state.test", "private_key", regexp.MustCompile(`^[0-9a-f]{64}$`)),
|
||||
resource.TestMatchResourceAttr("tor_obfs4_state.test", "public_key", regexp.MustCompile(`^[0-9a-f]{64}$`)),
|
||||
resource.TestMatchResourceAttr("tor_obfs4_state.test", "drbg_seed", regexp.MustCompile(`^[0-9a-f]{48}$`)),
|
||||
|
||||
// Check certificate format (base64 without padding)
|
||||
resource.TestMatchResourceAttr("tor_obfs4_state.test", "certificate", regexp.MustCompile(`^[A-Za-z0-9+/]+$`)),
|
||||
|
||||
// Check bridge_line format
|
||||
resource.TestMatchResourceAttr("tor_obfs4_state.test", "bridge_line", regexp.MustCompile(`^Bridge obfs4 <IP:PORT> <FINGERPRINT> cert=[A-Za-z0-9+/]+ iat-mode=0$`)),
|
||||
|
||||
// Check that the JSON is valid
|
||||
testTorObfs4StateValidateJSON("tor_obfs4_state.test"),
|
||||
|
||||
// Check that certificate is properly generated from node_id + public_key
|
||||
testTorObfs4StateCertificateValidity("tor_obfs4_state.test"),
|
||||
|
||||
// Check that bridge_line contains the correct certificate and iat_mode
|
||||
testTorObfs4StateBridgeLineValidity("tor_obfs4_state.test"),
|
||||
|
||||
// Verify the external identity keys are being used
|
||||
resource.TestCheckResourceAttrPair("tor_obfs4_state.test", "rsa_identity_private_key", "tor_relay_identity_rsa.test", "private_key_pem"),
|
||||
resource.TestCheckResourceAttrPair("tor_obfs4_state.test", "ed25519_identity_private_key", "tor_relay_identity_ed25519.test", "private_key_pem"),
|
||||
),
|
||||
},
|
||||
// Update testing (iat_mode)
|
||||
{
|
||||
Config: testTorObfs4StateResourceConfigWithIATMode(1),
|
||||
Check: resource.ComposeAggregateTestCheckFunc(
|
||||
// Check that iat_mode was updated
|
||||
resource.TestCheckResourceAttr("tor_obfs4_state.test", "iat_mode", "1"),
|
||||
|
||||
// Check that crypto fields remain unchanged (immutable)
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "node_id"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "private_key"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "public_key"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "drbg_seed"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "certificate"),
|
||||
|
||||
// Check that bridge_line reflects the updated iat_mode
|
||||
resource.TestMatchResourceAttr("tor_obfs4_state.test", "bridge_line", regexp.MustCompile(`^Bridge obfs4 <IP:PORT> <FINGERPRINT> cert=[A-Za-z0-9+/]+ iat-mode=1$`)),
|
||||
|
||||
// Check that state_json reflects the updated iat_mode
|
||||
testTorObfs4StateValidateJSON("tor_obfs4_state.test"),
|
||||
|
||||
// Check that bridge_line contains the correct certificate and iat_mode
|
||||
testTorObfs4StateBridgeLineValidity("tor_obfs4_state.test"),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestTorObfs4StateResourceWithCustomIATMode(t *testing.T) {
|
||||
resource.Test(t, resource.TestCase{
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: testTorObfs4StateResourceConfigWithIATMode(2),
|
||||
Check: resource.ComposeAggregateTestCheckFunc(
|
||||
resource.TestCheckResourceAttr("tor_obfs4_state.test", "iat_mode", "2"),
|
||||
// Check that bridge_line reflects the custom iat_mode
|
||||
resource.TestMatchResourceAttr("tor_obfs4_state.test", "bridge_line", regexp.MustCompile(`^Bridge obfs4 <IP:PORT> <FINGERPRINT> cert=[A-Za-z0-9+/]+ iat-mode=2$`)),
|
||||
testTorObfs4StateValidateJSON("tor_obfs4_state.test"),
|
||||
testTorObfs4StateBridgeLineValidity("tor_obfs4_state.test"),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestTorObfs4StateResourceMultiple(t *testing.T) {
|
||||
resource.Test(t, resource.TestCase{
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: testTorObfs4StateResourceConfigMultiple(),
|
||||
Check: resource.ComposeAggregateTestCheckFunc(
|
||||
// Check first resource
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test1", "node_id"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test1", "certificate"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test1", "bridge_line"),
|
||||
|
||||
// Check second resource
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test2", "node_id"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test2", "certificate"),
|
||||
resource.TestCheckResourceAttrSet("tor_obfs4_state.test2", "bridge_line"),
|
||||
|
||||
// Verify they have different keys (uniqueness)
|
||||
testTorObfs4StateResourcesAreDifferent("tor_obfs4_state.test1", "tor_obfs4_state.test2"),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Unit tests for helper functions
|
||||
func TestTorObfs4StateGenerateCertificate(t *testing.T) {
|
||||
resource := &TorObfs4StateResource{}
|
||||
|
||||
// Test with known values
|
||||
nodeIdHex := "9be04232642c50ee2864c2724500870f5fce6d2d"
|
||||
publicKeyHex := "45c9411955294f9b4306e4a65941ea546dee63e1c8a83dd984d1095c4a2f3911"
|
||||
|
||||
cert, err := resource.generateCertificate(nodeIdHex, publicKeyHex)
|
||||
if err != nil {
|
||||
t.Fatalf("generateCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
// Certificate should be base64 encoded 52 bytes (node_id + public_key)
|
||||
if len(cert) != 70 { // 52 bytes base64 encoded without padding = 70 chars
|
||||
t.Errorf("Expected certificate length 70, got %d", len(cert))
|
||||
}
|
||||
|
||||
// Verify the certificate decodes correctly
|
||||
decoded, err := base64.StdEncoding.DecodeString(cert + "==") // Add padding for decode
|
||||
if err != nil {
|
||||
t.Fatalf("Certificate is not valid base64: %v", err)
|
||||
}
|
||||
|
||||
if len(decoded) != 52 {
|
||||
t.Errorf("Expected decoded certificate length 52, got %d", len(decoded))
|
||||
}
|
||||
|
||||
// Verify node_id and public_key are correctly concatenated
|
||||
nodeIdBytes, _ := hex.DecodeString(nodeIdHex)
|
||||
publicKeyBytes, _ := hex.DecodeString(publicKeyHex)
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
if decoded[i] != nodeIdBytes[i] {
|
||||
t.Errorf("Node ID mismatch at byte %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 32; i++ {
|
||||
if decoded[20+i] != publicKeyBytes[i] {
|
||||
t.Errorf("Public key mismatch at byte %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTorObfs4StateGenerateCertificateInvalidInput(t *testing.T) {
|
||||
resource := &TorObfs4StateResource{}
|
||||
|
||||
// Test with invalid node ID length
|
||||
_, err := resource.generateCertificate("invalid", "45c9411955294f9b4306e4a65941ea546dee63e1c8a83dd984d1095c4a2f3911")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid node ID length")
|
||||
}
|
||||
|
||||
// Test with invalid public key length
|
||||
_, err = resource.generateCertificate("9be04232642c50ee2864c2724500870f5fce6d2d", "invalid")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid public key length")
|
||||
}
|
||||
|
||||
// Test with non-hex input
|
||||
_, err = resource.generateCertificate("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "45c9411955294f9b4306e4a65941ea546dee63e1c8a83dd984d1095c4a2f3911")
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-hex node ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTorObfs4StateGenerateBridgeLine(t *testing.T) {
|
||||
resource := &TorObfs4StateResource{}
|
||||
|
||||
// Test with known values
|
||||
certificate := "Y+m2Eny/3H4JeW2RwRYGlNXpdYk8MXRbRFuv5AaTZfxnAUbhEnWCTmJM+VTMssTCYrxyag"
|
||||
iatMode := int64(0)
|
||||
|
||||
bridgeLine := resource.generateBridgeLine(certificate, iatMode)
|
||||
|
||||
expectedBridgeLine := "Bridge obfs4 <IP:PORT> <FINGERPRINT> cert=Y+m2Eny/3H4JeW2RwRYGlNXpdYk8MXRbRFuv5AaTZfxnAUbhEnWCTmJM+VTMssTCYrxyag iat-mode=0"
|
||||
|
||||
if bridgeLine != expectedBridgeLine {
|
||||
t.Errorf("Expected bridge line:\n%s\nGot:\n%s", expectedBridgeLine, bridgeLine)
|
||||
}
|
||||
|
||||
// Test with different iat_mode
|
||||
iatMode = int64(2)
|
||||
bridgeLine = resource.generateBridgeLine(certificate, iatMode)
|
||||
expectedBridgeLine = "Bridge obfs4 <IP:PORT> <FINGERPRINT> cert=Y+m2Eny/3H4JeW2RwRYGlNXpdYk8MXRbRFuv5AaTZfxnAUbhEnWCTmJM+VTMssTCYrxyag iat-mode=2"
|
||||
|
||||
if bridgeLine != expectedBridgeLine {
|
||||
t.Errorf("Expected bridge line with iat-mode=2:\n%s\nGot:\n%s", expectedBridgeLine, bridgeLine)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTorObfs4StateGenerateStateJson(t *testing.T) {
|
||||
resource := &TorObfs4StateResource{}
|
||||
|
||||
// Create test data
|
||||
data := &TorObfs4StateResourceModel{
|
||||
NodeId: typeStringValue("9be04232642c50ee2864c2724500870f5fce6d2d"),
|
||||
PrivateKey: typeStringValue("c858003d52fea6be52c6d7f8a3a44692fb3b9ad1bc3b7434871efd9acb8b1554"),
|
||||
PublicKey: typeStringValue("45c9411955294f9b4306e4a65941ea546dee63e1c8a83dd984d1095c4a2f3911"),
|
||||
DrbgSeed: typeStringValue("fae8faa79b74d08933251727aab6ca9627736a2387231b03"),
|
||||
IatMode: typeInt64Value(0),
|
||||
}
|
||||
|
||||
jsonStr, err := resource.generateStateJson(data)
|
||||
if err != nil {
|
||||
t.Fatalf("generateStateJson failed: %v", err)
|
||||
}
|
||||
|
||||
// Parse the JSON to verify structure
|
||||
var parsed obfs4StateJson
|
||||
err = json.Unmarshal([]byte(jsonStr), &parsed)
|
||||
if err != nil {
|
||||
t.Fatalf("Generated JSON is invalid: %v", err)
|
||||
}
|
||||
|
||||
// Verify fields
|
||||
if parsed.NodeId != "9be04232642c50ee2864c2724500870f5fce6d2d" {
|
||||
t.Errorf("Node ID mismatch in JSON")
|
||||
}
|
||||
if parsed.PrivateKey != "c858003d52fea6be52c6d7f8a3a44692fb3b9ad1bc3b7434871efd9acb8b1554" {
|
||||
t.Errorf("Private key mismatch in JSON")
|
||||
}
|
||||
if parsed.PublicKey != "45c9411955294f9b4306e4a65941ea546dee63e1c8a83dd984d1095c4a2f3911" {
|
||||
t.Errorf("Public key mismatch in JSON")
|
||||
}
|
||||
if parsed.DrbgSeed != "fae8faa79b74d08933251727aab6ca9627736a2387231b03" {
|
||||
t.Errorf("DRBG seed mismatch in JSON")
|
||||
}
|
||||
if parsed.IatMode != 0 {
|
||||
t.Errorf("IAT mode mismatch in JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// Test configuration functions
|
||||
func testTorObfs4StateResourceConfigWithIdentityKeys() string {
|
||||
return `
|
||||
resource "tor_relay_identity_rsa" "test" {}
|
||||
|
||||
resource "tor_relay_identity_ed25519" "test" {}
|
||||
|
||||
resource "tor_obfs4_state" "test" {
|
||||
rsa_identity_private_key = tor_relay_identity_rsa.test.private_key_pem
|
||||
ed25519_identity_private_key = tor_relay_identity_ed25519.test.private_key_pem
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
func testTorObfs4StateResourceConfigWithIATMode(iatMode int) string {
|
||||
return fmt.Sprintf(`
|
||||
resource "tor_relay_identity_rsa" "test" {}
|
||||
|
||||
resource "tor_relay_identity_ed25519" "test" {}
|
||||
|
||||
resource "tor_obfs4_state" "test" {
|
||||
rsa_identity_private_key = tor_relay_identity_rsa.test.private_key_pem
|
||||
ed25519_identity_private_key = tor_relay_identity_ed25519.test.private_key_pem
|
||||
iat_mode = %d
|
||||
}
|
||||
`, iatMode)
|
||||
}
|
||||
|
||||
func testTorObfs4StateResourceConfigMultiple() string {
|
||||
return `
|
||||
resource "tor_relay_identity_rsa" "test1" {}
|
||||
|
||||
resource "tor_relay_identity_ed25519" "test1" {}
|
||||
|
||||
resource "tor_relay_identity_rsa" "test2" {}
|
||||
|
||||
resource "tor_relay_identity_ed25519" "test2" {}
|
||||
|
||||
resource "tor_obfs4_state" "test1" {
|
||||
rsa_identity_private_key = tor_relay_identity_rsa.test1.private_key_pem
|
||||
ed25519_identity_private_key = tor_relay_identity_ed25519.test1.private_key_pem
|
||||
iat_mode = 0
|
||||
}
|
||||
|
||||
resource "tor_obfs4_state" "test2" {
|
||||
rsa_identity_private_key = tor_relay_identity_rsa.test2.private_key_pem
|
||||
ed25519_identity_private_key = tor_relay_identity_ed25519.test2.private_key_pem
|
||||
iat_mode = 1
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
// Custom test check functions
|
||||
func testTorObfs4StateValidateJSON(resourceName string) resource.TestCheckFunc {
|
||||
return func(s *terraform.State) error {
|
||||
rs, ok := s.RootModule().Resources[resourceName]
|
||||
if !ok {
|
||||
return fmt.Errorf("Resource not found: %s", resourceName)
|
||||
}
|
||||
|
||||
stateJsonStr := rs.Primary.Attributes["state_json"]
|
||||
if stateJsonStr == "" {
|
||||
return fmt.Errorf("state_json is empty")
|
||||
}
|
||||
|
||||
// Parse JSON to verify it's valid
|
||||
var parsed obfs4StateJson
|
||||
err := json.Unmarshal([]byte(stateJsonStr), &parsed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("state_json is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
// Verify fields match attributes
|
||||
if parsed.NodeId != rs.Primary.Attributes["node_id"] {
|
||||
return fmt.Errorf("JSON node_id doesn't match attribute")
|
||||
}
|
||||
if parsed.PrivateKey != rs.Primary.Attributes["private_key"] {
|
||||
return fmt.Errorf("JSON private_key doesn't match attribute")
|
||||
}
|
||||
if parsed.PublicKey != rs.Primary.Attributes["public_key"] {
|
||||
return fmt.Errorf("JSON public_key doesn't match attribute")
|
||||
}
|
||||
if parsed.DrbgSeed != rs.Primary.Attributes["drbg_seed"] {
|
||||
return fmt.Errorf("JSON drbg_seed doesn't match attribute")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func testTorObfs4StateCertificateValidity(resourceName string) resource.TestCheckFunc {
|
||||
return func(s *terraform.State) error {
|
||||
rs, ok := s.RootModule().Resources[resourceName]
|
||||
if !ok {
|
||||
return fmt.Errorf("Resource not found: %s", resourceName)
|
||||
}
|
||||
|
||||
nodeIdHex := rs.Primary.Attributes["node_id"]
|
||||
publicKeyHex := rs.Primary.Attributes["public_key"]
|
||||
certificate := rs.Primary.Attributes["certificate"]
|
||||
|
||||
// Regenerate certificate and compare
|
||||
resource := &TorObfs4StateResource{}
|
||||
expectedCert, err := resource.generateCertificate(nodeIdHex, publicKeyHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to generate expected certificate: %v", err)
|
||||
}
|
||||
|
||||
if certificate != expectedCert {
|
||||
return fmt.Errorf("Certificate doesn't match expected value")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func testTorObfs4StateBridgeLineValidity(resourceName string) resource.TestCheckFunc {
|
||||
return func(s *terraform.State) error {
|
||||
rs, ok := s.RootModule().Resources[resourceName]
|
||||
if !ok {
|
||||
return fmt.Errorf("Resource not found: %s", resourceName)
|
||||
}
|
||||
|
||||
certificate := rs.Primary.Attributes["certificate"]
|
||||
iatMode := rs.Primary.Attributes["iat_mode"]
|
||||
bridgeLine := rs.Primary.Attributes["bridge_line"]
|
||||
|
||||
// Expected bridge line format
|
||||
expectedBridgeLine := fmt.Sprintf("Bridge obfs4 <IP:PORT> <FINGERPRINT> cert=%s iat-mode=%s", certificate, iatMode)
|
||||
|
||||
if bridgeLine != expectedBridgeLine {
|
||||
return fmt.Errorf("Bridge line doesn't match expected format.\nExpected: %s\nActual: %s", expectedBridgeLine, bridgeLine)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func testTorObfs4StateResourcesAreDifferent(resource1, resource2 string) resource.TestCheckFunc {
|
||||
return func(s *terraform.State) error {
|
||||
rs1, ok := s.RootModule().Resources[resource1]
|
||||
if !ok {
|
||||
return fmt.Errorf("Resource not found: %s", resource1)
|
||||
}
|
||||
|
||||
rs2, ok := s.RootModule().Resources[resource2]
|
||||
if !ok {
|
||||
return fmt.Errorf("Resource not found: %s", resource2)
|
||||
}
|
||||
|
||||
// Check that crypto fields are different
|
||||
if rs1.Primary.Attributes["node_id"] == rs2.Primary.Attributes["node_id"] {
|
||||
return fmt.Errorf("Resources have same node_id")
|
||||
}
|
||||
if rs1.Primary.Attributes["private_key"] == rs2.Primary.Attributes["private_key"] {
|
||||
return fmt.Errorf("Resources have same private_key")
|
||||
}
|
||||
if rs1.Primary.Attributes["public_key"] == rs2.Primary.Attributes["public_key"] {
|
||||
return fmt.Errorf("Resources have same public_key")
|
||||
}
|
||||
if rs1.Primary.Attributes["certificate"] == rs2.Primary.Attributes["certificate"] {
|
||||
return fmt.Errorf("Resources have same certificate")
|
||||
}
|
||||
if rs1.Primary.Attributes["bridge_line"] == rs2.Primary.Attributes["bridge_line"] {
|
||||
return fmt.Errorf("Resources have same bridge_line")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Helper functions for creating types.String and types.Int64 values in tests
|
||||
func typeStringValue(value string) types.String {
|
||||
return types.StringValue(value)
|
||||
}
|
||||
|
||||
func typeInt64Value(value int64) types.Int64 {
|
||||
return types.Int64Value(value)
|
||||
}
|
188
internal/provider/tor_relay_identity_ed25519_resource.go
Normal file
188
internal/provider/tor_relay_identity_ed25519_resource.go
Normal file
|
@ -0,0 +1,188 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
|
||||
"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 = &TorRelayIdentityEd25519Resource{}
|
||||
|
||||
func NewTorRelayIdentityEd25519Resource() resource.Resource {
|
||||
return &TorRelayIdentityEd25519Resource{}
|
||||
}
|
||||
|
||||
type TorRelayIdentityEd25519Resource struct{}
|
||||
|
||||
type TorRelayIdentityEd25519ResourceModel struct {
|
||||
Id types.String `tfsdk:"id"`
|
||||
Algorithm types.String `tfsdk:"algorithm"`
|
||||
PrivateKeyPem types.String `tfsdk:"private_key_pem"`
|
||||
PublicKeyPem types.String `tfsdk:"public_key_pem"`
|
||||
PublicKeyFingerprintSha256 types.String `tfsdk:"public_key_fingerprint_sha256"`
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityEd25519Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_relay_identity_ed25519"
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityEd25519Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{
|
||||
MarkdownDescription: "Generates Ed25519 private key for Tor relay identity as required by the Tor specification.",
|
||||
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "Unique identifier based on public key fingerprint",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"algorithm": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "Name of the algorithm used when generating the private key (always 'Ed25519')",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"private_key_pem": schema.StringAttribute{
|
||||
Computed: true,
|
||||
Sensitive: true,
|
||||
MarkdownDescription: "Private key data in PEM (RFC 1421) format",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"public_key_pem": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "Public key data in PEM (RFC 1421) format",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"public_key_fingerprint_sha256": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "SHA256 fingerprint of the public key in hex format",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityEd25519Resource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityEd25519Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var data TorRelayIdentityEd25519ResourceModel
|
||||
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
// Set algorithm
|
||||
data.Algorithm = types.StringValue("Ed25519")
|
||||
|
||||
// Generate Ed25519 key pair
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ed25519 Key Generation Error",
|
||||
fmt.Sprintf("Unable to generate Ed25519 private key: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Encode private key as PEM
|
||||
privateKeyPem, err := r.encodePrivateKeyPEM(privateKey)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Private Key Encoding Error",
|
||||
fmt.Sprintf("Unable to encode private key as PEM: %s", err))
|
||||
return
|
||||
}
|
||||
data.PrivateKeyPem = types.StringValue(privateKeyPem)
|
||||
|
||||
// Encode public key as PEM
|
||||
publicKeyPem, err := r.encodePublicKeyPEM(publicKey)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Public Key Encoding Error",
|
||||
fmt.Sprintf("Unable to encode public key as PEM: %s", err))
|
||||
return
|
||||
}
|
||||
data.PublicKeyPem = types.StringValue(publicKeyPem)
|
||||
|
||||
// Generate SHA256 fingerprint
|
||||
sha256Fingerprint := r.generateSha256Fingerprint(publicKey)
|
||||
data.PublicKeyFingerprintSha256 = types.StringValue(sha256Fingerprint)
|
||||
|
||||
// Generate ID from SHA256 fingerprint
|
||||
data.Id = types.StringValue(fmt.Sprintf("ed25519-%s", sha256Fingerprint[:16]))
|
||||
|
||||
tflog.Trace(ctx, "created tor relay identity Ed25519 resource")
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityEd25519Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var data TorRelayIdentityEd25519ResourceModel
|
||||
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityEd25519Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
resp.Diagnostics.AddError("Update Not Supported",
|
||||
"Ed25519 relay identity keys are immutable. Any changes require resource replacement.")
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityEd25519Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
tflog.Trace(ctx, "deleted tor relay identity Ed25519 resource")
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityEd25519Resource) encodePrivateKeyPEM(privateKey ed25519.PrivateKey) (string, error) {
|
||||
privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
privateKeyPem := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: privateKeyBytes,
|
||||
})
|
||||
return string(privateKeyPem), nil
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityEd25519Resource) encodePublicKeyPEM(publicKey ed25519.PublicKey) (string, error) {
|
||||
publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
publicKeyPem := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: publicKeyBytes,
|
||||
})
|
||||
return string(publicKeyPem), nil
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityEd25519Resource) generateSha256Fingerprint(publicKey ed25519.PublicKey) string {
|
||||
publicKeyBytes, _ := x509.MarshalPKIXPublicKey(publicKey)
|
||||
sha256Sum := sha256.Sum256(publicKeyBytes)
|
||||
return fmt.Sprintf("%x", sha256Sum)
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
|
||||
)
|
||||
|
||||
func TestAccTorRelayIdentityEd25519Resource(t *testing.T) {
|
||||
resource.Test(t, resource.TestCase{
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
|
||||
Steps: []resource.TestStep{
|
||||
// Create and Read testing
|
||||
{
|
||||
Config: testAccTorRelayIdentityEd25519ResourceConfig,
|
||||
Check: resource.ComposeAggregateTestCheckFunc(
|
||||
resource.TestCheckResourceAttr("tor_relay_identity_ed25519.test", "algorithm", "Ed25519"),
|
||||
resource.TestCheckResourceAttrSet("tor_relay_identity_ed25519.test", "id"),
|
||||
resource.TestCheckResourceAttrSet("tor_relay_identity_ed25519.test", "private_key_pem"),
|
||||
resource.TestCheckResourceAttrSet("tor_relay_identity_ed25519.test", "public_key_pem"),
|
||||
resource.TestCheckResourceAttrSet("tor_relay_identity_ed25519.test", "public_key_fingerprint_sha256"),
|
||||
// Verify PEM format
|
||||
resource.TestMatchResourceAttr("tor_relay_identity_ed25519.test", "private_key_pem", regexp.MustCompile(`^-----BEGIN PRIVATE KEY-----`)),
|
||||
resource.TestMatchResourceAttr("tor_relay_identity_ed25519.test", "public_key_pem", regexp.MustCompile(`^-----BEGIN PUBLIC KEY-----`)),
|
||||
// Verify fingerprint format (64 hex characters for SHA256)
|
||||
resource.TestMatchResourceAttr("tor_relay_identity_ed25519.test", "public_key_fingerprint_sha256", regexp.MustCompile(`^[0-9a-f]{64}$`)),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const testAccTorRelayIdentityEd25519ResourceConfig = `
|
||||
resource "tor_relay_identity_ed25519" "test" {}
|
||||
`
|
211
internal/provider/tor_relay_identity_rsa_resource.go
Normal file
211
internal/provider/tor_relay_identity_rsa_resource.go
Normal file
|
@ -0,0 +1,211 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
|
||||
"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 = &TorRelayIdentityRsaResource{}
|
||||
|
||||
func NewTorRelayIdentityRsaResource() resource.Resource {
|
||||
return &TorRelayIdentityRsaResource{}
|
||||
}
|
||||
|
||||
type TorRelayIdentityRsaResource struct{}
|
||||
|
||||
type TorRelayIdentityRsaResourceModel struct {
|
||||
Id types.String `tfsdk:"id"`
|
||||
Algorithm types.String `tfsdk:"algorithm"`
|
||||
PrivateKeyPem types.String `tfsdk:"private_key_pem"`
|
||||
PublicKeyPem types.String `tfsdk:"public_key_pem"`
|
||||
PublicKeyFingerprintSha1 types.String `tfsdk:"public_key_fingerprint_sha1"`
|
||||
PublicKeyFingerprintSha256 types.String `tfsdk:"public_key_fingerprint_sha256"`
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityRsaResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_relay_identity_rsa"
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityRsaResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{
|
||||
MarkdownDescription: "Generates 1024-bit RSA private key for Tor relay identity as required by the Tor specification.",
|
||||
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "Unique identifier based on public key fingerprint",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"algorithm": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "Name of the algorithm used when generating the private key (always 'RSA')",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"private_key_pem": schema.StringAttribute{
|
||||
Computed: true,
|
||||
Sensitive: true,
|
||||
MarkdownDescription: "Private key data in PEM (RFC 1421) format",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"public_key_pem": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "Public key data in PEM (RFC 1421) format",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"public_key_fingerprint_sha1": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "SHA1 fingerprint of the public key in hex format",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"public_key_fingerprint_sha256": schema.StringAttribute{
|
||||
Computed: true,
|
||||
MarkdownDescription: "SHA256 fingerprint of the public key in hex format",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityRsaResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityRsaResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var data TorRelayIdentityRsaResourceModel
|
||||
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
// Set algorithm
|
||||
data.Algorithm = types.StringValue("RSA")
|
||||
|
||||
// Generate 1024-bit RSA private key as required by Tor specification
|
||||
// See: https://spec.torproject.org/tor-spec/preliminaries.html#ciphers
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("RSA Key Generation Error",
|
||||
fmt.Sprintf("Unable to generate RSA private key: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Encode private key as PEM
|
||||
privateKeyPem, err := r.encodePrivateKeyPEM(privateKey)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Private Key Encoding Error",
|
||||
fmt.Sprintf("Unable to encode private key as PEM: %s", err))
|
||||
return
|
||||
}
|
||||
data.PrivateKeyPem = types.StringValue(privateKeyPem)
|
||||
|
||||
// Extract and encode public key
|
||||
publicKey := &privateKey.PublicKey
|
||||
publicKeyPem, err := r.encodePublicKeyPEM(publicKey)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Public Key Encoding Error",
|
||||
fmt.Sprintf("Unable to encode public key as PEM: %s", err))
|
||||
return
|
||||
}
|
||||
data.PublicKeyPem = types.StringValue(publicKeyPem)
|
||||
|
||||
// Generate fingerprints
|
||||
sha1Fingerprint, sha256Fingerprint, err := r.generateFingerprints(publicKey)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Fingerprint Generation Error",
|
||||
fmt.Sprintf("Unable to generate public key fingerprints: %s", err))
|
||||
return
|
||||
}
|
||||
data.PublicKeyFingerprintSha1 = types.StringValue(sha1Fingerprint)
|
||||
data.PublicKeyFingerprintSha256 = types.StringValue(sha256Fingerprint)
|
||||
|
||||
// Generate ID from SHA1 fingerprint
|
||||
data.Id = types.StringValue(fmt.Sprintf("rsa-%s", sha1Fingerprint[:16]))
|
||||
|
||||
tflog.Trace(ctx, "created tor relay identity RSA resource")
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityRsaResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var data TorRelayIdentityRsaResourceModel
|
||||
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityRsaResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
resp.Diagnostics.AddError("Update Not Supported",
|
||||
"RSA relay identity keys are immutable. Any changes require resource replacement.")
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityRsaResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
tflog.Trace(ctx, "deleted tor relay identity RSA resource")
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityRsaResource) encodePrivateKeyPEM(privateKey *rsa.PrivateKey) (string, error) {
|
||||
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
|
||||
privateKeyPem := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: privateKeyBytes,
|
||||
})
|
||||
return string(privateKeyPem), nil
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityRsaResource) encodePublicKeyPEM(publicKey *rsa.PublicKey) (string, error) {
|
||||
publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
publicKeyPem := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: publicKeyBytes,
|
||||
})
|
||||
return string(publicKeyPem), nil
|
||||
}
|
||||
|
||||
func (r *TorRelayIdentityRsaResource) generateFingerprints(publicKey *rsa.PublicKey) (string, string, error) {
|
||||
publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
sha1Sum := sha1.Sum(publicKeyBytes)
|
||||
sha256Sum := sha256.Sum256(publicKeyBytes)
|
||||
|
||||
sha1Fingerprint := fmt.Sprintf("%x", sha1Sum)
|
||||
sha256Fingerprint := fmt.Sprintf("%x", sha256Sum)
|
||||
|
||||
return sha1Fingerprint, sha256Fingerprint, nil
|
||||
}
|
42
internal/provider/tor_relay_identity_rsa_resource_test.go
Normal file
42
internal/provider/tor_relay_identity_rsa_resource_test.go
Normal file
|
@ -0,0 +1,42 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
|
||||
)
|
||||
|
||||
func TestAccTorRelayIdentityRsaResource(t *testing.T) {
|
||||
resource.Test(t, resource.TestCase{
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
|
||||
Steps: []resource.TestStep{
|
||||
// Create and Read testing
|
||||
{
|
||||
Config: testAccTorRelayIdentityRsaResourceConfig,
|
||||
Check: resource.ComposeAggregateTestCheckFunc(
|
||||
resource.TestCheckResourceAttr("tor_relay_identity_rsa.test", "algorithm", "RSA"),
|
||||
resource.TestCheckResourceAttrSet("tor_relay_identity_rsa.test", "id"),
|
||||
resource.TestCheckResourceAttrSet("tor_relay_identity_rsa.test", "private_key_pem"),
|
||||
resource.TestCheckResourceAttrSet("tor_relay_identity_rsa.test", "public_key_pem"),
|
||||
resource.TestCheckResourceAttrSet("tor_relay_identity_rsa.test", "public_key_fingerprint_sha1"),
|
||||
resource.TestCheckResourceAttrSet("tor_relay_identity_rsa.test", "public_key_fingerprint_sha256"),
|
||||
// Verify PEM format
|
||||
resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "private_key_pem", regexp.MustCompile(`^-----BEGIN RSA PRIVATE KEY-----`)),
|
||||
resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_pem", regexp.MustCompile(`^-----BEGIN PUBLIC KEY-----`)),
|
||||
// Verify fingerprint formats (hex strings)
|
||||
resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_fingerprint_sha1", regexp.MustCompile(`^[0-9a-f]{40}$`)),
|
||||
resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_fingerprint_sha256", regexp.MustCompile(`^[0-9a-f]{64}$`)),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const testAccTorRelayIdentityRsaResourceConfig = `
|
||||
resource "tor_relay_identity_rsa" "test" {}
|
||||
`
|
Loading…
Add table
Add a link
Reference in a new issue