diff --git a/docs/services.md b/docs/services.md index fb0a34ef..9d650237 100644 --- a/docs/services.md +++ b/docs/services.md @@ -35,6 +35,7 @@ This document lists every service and operation available in CloudEmu across all | 22 | Machine Learning | `sagemaker` (+ `sagemaker-runtime`) | `azureai` (CognitiveServices + MachineLearningServices) | `vertexai` | | 23 | AI Search | — | `azuresearch` (Microsoft.Search) | — | | 24 | Container Orchestration | `ecs` | — | — | +| 25 | DNS Resolver | `route53resolver` | — | — | --- @@ -2218,6 +2219,47 @@ real EC2 instance subject to managed-resource visibility. --- +## 25. DNS Resolver + +**Driver interface:** `services/route53resolver/driver/driver.go` +**AWS:** Route 53 Resolver (`Route53Resolver.*`, AWS JSON 1.1) | **Azure:** — | **GCP:** — + +AWS-only. Real `aws-sdk-go-v2/service/route53resolver` clients work against the +SDK-compat server (`awsserver.Drivers{Route53Resolver: cloud.Route53Resolver}`). +Full parity: **all 72 SDK operations**, no stubs. Each resource group is stored +in an in-memory `memstore.Store` guarded by a single mutex; reads are +copy-on-write clones. Every group is covered by a real-SDK round-trip test. + +Per-VPC configs (Resolver autodefined-reverse, DNSSEC validation, firewall +fail-open) are **lazily materialized** on first Get with their AWS defaults +(reverse ENABLED, DNSSEC DISABLED, fail-open DISABLED) and only appear in the +corresponding List once touched. Firewall rules are identified within a group by +`(FirewallDomainListId, Qtype)`; deleting a rule group cascades to its rules. + +| Family | Operations | +|--------|-----------| +| Resolver endpoints | Create/Get/Update/Delete/ListResolverEndpoint(s), Associate/DisassociateResolverEndpointIpAddress, ListResolverEndpointIpAddresses | +| Resolver rules | Create/Get/Update/Delete/ListResolverRule(s), Associate/DisassociateResolverRule, Get/ListResolverRuleAssociation(s), Put/GetResolverRulePolicy | +| Query-log configs | Create/Get/Delete/ListResolverQueryLogConfig(s), Associate/DisassociateResolverQueryLogConfig, Get/ListResolverQueryLogConfigAssociation(s), Put/GetResolverQueryLogConfigPolicy | +| Resolver & DNSSEC configs | Get/Update/ListResolverConfig(s), Get/Update/ListResolverDnssecConfig(s) | +| DNS Firewall — domain lists | Create/Get/Delete/ListFirewallDomainList(s), Update/Import/ListFirewallDomains | +| DNS Firewall — rules | Create/Update/Delete/ListFirewallRule(s), BatchCreate/BatchUpdate/BatchDeleteFirewallRule | +| DNS Firewall — rule groups | Create/Get/Delete/ListFirewallRuleGroup(s), Put/GetFirewallRuleGroupPolicy | +| DNS Firewall — associations | Associate/Disassociate/Get/Update/ListFirewallRuleGroupAssociation(s) | +| DNS Firewall — configs | Get/Update/ListFirewallConfig(s), ListFirewallRuleTypes | +| Outpost resolvers | Create/Get/Update/Delete/ListOutpostResolver(s) | +| Tagging | TagResource, UntagResource, ListTagsForResource | + +*Accepted but not simulated* (stored/echoed so SDK calls succeed, no behavioral +effect): endpoint/rule/config status stays terminal (no async CREATING→OPERATIONAL +transitions); `ImportFirewallDomains` records the request without fetching the S3 +file; `ListFirewallRuleTypes` returns an empty descriptor list; resource-share +policies are stored verbatim without RAM enforcement. + +**Total: 72 operations.** + +--- + ## Provider-specific resources Resources below are served for one provider only, because the concept exists in @@ -2330,7 +2372,8 @@ still sees success. | Machine Learning — GCP Vertex AI (Go API/driver) | 128 | | AI Search — Azure AI Search (control + data plane) | 53 | | Container Orchestration — AWS ECS | 37 | -| **Grand Total** | **1562** (+138 optional) | +| DNS Resolver — AWS Route 53 Resolver | 72 | +| **Grand Total** | **1634** (+138 optional) | Optional operations are capabilities a driver may implement but is not required to; see the sections marked "optional capability". They are counted separately diff --git a/go.mod b/go.mod index 02946c50..d87c9262 100644 --- a/go.mod +++ b/go.mod @@ -68,6 +68,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/resourceexplorer2 v1.23.6 github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.12 github.com/aws/aws-sdk-go-v2/service/route53 v1.64.0 + github.com/aws/aws-sdk-go-v2/service/route53resolver v1.48.3 github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 github.com/aws/aws-sdk-go-v2/service/sagemaker v1.254.0 github.com/aws/aws-sdk-go-v2/service/sagemakerfeaturestoreruntime v1.35.7 diff --git a/go.sum b/go.sum index 4ffd081a..413329a8 100644 --- a/go.sum +++ b/go.sum @@ -198,6 +198,8 @@ github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.12 h1:kOX5fC github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.12/go.mod h1:n8ixkV2383DfuJhsCMVdfeSfYWqJhO2uadau9wrta9U= github.com/aws/aws-sdk-go-v2/service/route53 v1.64.0 h1:AYtTCOexiOMbe6Ier86t7Jfc8191htzChnNyg027PMo= github.com/aws/aws-sdk-go-v2/service/route53 v1.64.0/go.mod h1:0hIRXFez1bZsDFMGkLZvNJbByTSVZ4sFZWpxZ39NPuM= +github.com/aws/aws-sdk-go-v2/service/route53resolver v1.48.3 h1:ZpybjxxYIArfRTBB+9yG9EEs7b4on+bjpWnUKFSWasw= +github.com/aws/aws-sdk-go-v2/service/route53resolver v1.48.3/go.mod h1:BTVlVIHKi7IiZkv8oam4lEClsIfrh08avL5V5UaQQco= github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk= github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/sagemaker v1.254.0 h1:0vYBf7g+R421AjrPAKh+zoNDhWxqg4KixviLFTQ+6vI= diff --git a/providers/aws/aws.go b/providers/aws/aws.go index 3ae0a79c..fed1d9d3 100644 --- a/providers/aws/aws.go +++ b/providers/aws/aws.go @@ -29,6 +29,7 @@ import ( "github.com/stackshy/cloudemu/v2/providers/aws/rds" "github.com/stackshy/cloudemu/v2/providers/aws/redshift" "github.com/stackshy/cloudemu/v2/providers/aws/route53" + "github.com/stackshy/cloudemu/v2/providers/aws/route53resolver" "github.com/stackshy/cloudemu/v2/providers/aws/s3" "github.com/stackshy/cloudemu/v2/providers/aws/sagemaker" "github.com/stackshy/cloudemu/v2/providers/aws/secretsmanager" @@ -144,6 +145,7 @@ type Provider struct { SageMaker *sagemaker.Mock SSM *ssm.Mock ECS *ecs.Mock + Route53Resolver *route53resolver.Mock ResourceDiscovery *resourcediscovery.Engine AccountID string Region string @@ -181,6 +183,7 @@ func New(opts ...config.Option) *Provider { SageMaker: sagemaker.New(o), SSM: ssm.New(o), ECS: ecs.New(o), + Route53Resolver: route53resolver.New(o), AccountID: o.AccountID, Region: o.Region, } diff --git a/providers/aws/route53resolver/configs.go b/providers/aws/route53resolver/configs.go new file mode 100644 index 00000000..17a66c56 --- /dev/null +++ b/providers/aws/route53resolver/configs.go @@ -0,0 +1,168 @@ +package route53resolver + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +const ( + autodefinedReverseEnabled = "ENABLED" + autodefinedReverseDisabled = "DISABLED" + autodefinedReverseLocal = "USE_LOCAL_RESOURCE_SETTING" + + dnssecStatusEnabled = "ENABLED" + dnssecStatusDisabled = "DISABLED" + dnssecStatusLocal = "USE_LOCAL_RESOURCE_SETTING" + + flagEnable = "ENABLE" + flagLocal = "USE_LOCAL_RESOURCE_SETTING" +) + +func cloneResolverConfig(c *driver.ResolverConfig) driver.ResolverConfig { return *c } + +func cloneDnssecConfig(c *driver.ResolverDnssecConfig) driver.ResolverDnssecConfig { return *c } + +// autodefinedReverseFor maps a request flag to a stored autodefined-reverse +// status. Autodefined reverse-DNS rules are enabled by default in AWS. +func autodefinedReverseFor(flag string) string { + switch flag { + case flagEnable: + return autodefinedReverseEnabled + case flagLocal: + return autodefinedReverseLocal + default: + return autodefinedReverseDisabled + } +} + +// dnssecStatusFor maps a request validation value to a stored DNSSEC status. +// DNSSEC validation is disabled by default in AWS. +func dnssecStatusFor(validation string) string { + switch validation { + case flagEnable: + return dnssecStatusEnabled + case flagLocal: + return dnssecStatusLocal + default: + return dnssecStatusDisabled + } +} + +// resolverConfigFor returns the stored config for a VPC, materializing a +// default (autodefined reverse enabled) one on first access. Caller holds m.mu. +func (m *Mock) resolverConfigFor(resourceID string) *driver.ResolverConfig { + if c, ok := m.rslvrConfigs.Get(resourceID); ok { + return c + } + + c := &driver.ResolverConfig{ + ID: idgen.GenerateID("rslvr-rc-"), + OwnerID: m.opts.AccountID, + ResourceID: resourceID, + AutodefinedReverse: autodefinedReverseEnabled, + } + m.rslvrConfigs.Set(resourceID, c) + + return c +} + +// dnssecConfigFor returns the stored DNSSEC config for a VPC, materializing a +// default (validation disabled) one on first access. Caller holds m.mu. +func (m *Mock) dnssecConfigFor(resourceID string) *driver.ResolverDnssecConfig { + if c, ok := m.dnssecCfgs.Get(resourceID); ok { + return c + } + + c := &driver.ResolverDnssecConfig{ + ID: idgen.GenerateID("rslvr-ds-"), + OwnerID: m.opts.AccountID, + ResourceID: resourceID, + ValidationStatus: dnssecStatusDisabled, + } + m.dnssecCfgs.Set(resourceID, c) + + return c +} + +func (m *Mock) GetResolverConfig(_ context.Context, resourceID string) (*driver.ResolverConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if c, ok := m.rslvrConfigs.Get(resourceID); ok { + out := cloneResolverConfig(c) + + return &out, nil + } + + // A pure read never persists: return the AWS default (autodefined reverse + // enabled) without materializing a record that would then pollute the List. + return &driver.ResolverConfig{ + OwnerID: m.opts.AccountID, + ResourceID: resourceID, + AutodefinedReverse: autodefinedReverseEnabled, + }, nil +} + +func (m *Mock) UpdateResolverConfig( + _ context.Context, resourceID, autodefinedReverseFlag string, +) (*driver.ResolverConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + c := m.resolverConfigFor(resourceID) + c.AutodefinedReverse = autodefinedReverseFor(autodefinedReverseFlag) + + out := cloneResolverConfig(c) + + return &out, nil +} + +func (m *Mock) ListResolverConfigs(_ context.Context) ([]driver.ResolverConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.rslvrConfigs.All(), cloneResolverConfig), nil +} + +func (m *Mock) GetResolverDnssecConfig( + _ context.Context, resourceID string, +) (*driver.ResolverDnssecConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if c, ok := m.dnssecCfgs.Get(resourceID); ok { + out := cloneDnssecConfig(c) + + return &out, nil + } + + // A pure read never persists: return the AWS default (validation disabled). + return &driver.ResolverDnssecConfig{ + OwnerID: m.opts.AccountID, + ResourceID: resourceID, + ValidationStatus: dnssecStatusDisabled, + }, nil +} + +func (m *Mock) UpdateResolverDnssecConfig( + _ context.Context, resourceID, validation string, +) (*driver.ResolverDnssecConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + c := m.dnssecConfigFor(resourceID) + c.ValidationStatus = dnssecStatusFor(validation) + + out := cloneDnssecConfig(c) + + return &out, nil +} + +func (m *Mock) ListResolverDnssecConfigs(_ context.Context) ([]driver.ResolverDnssecConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.dnssecCfgs.All(), cloneDnssecConfig), nil +} diff --git a/providers/aws/route53resolver/endpoints.go b/providers/aws/route53resolver/endpoints.go new file mode 100644 index 00000000..d1cb5dc7 --- /dev/null +++ b/providers/aws/route53resolver/endpoints.go @@ -0,0 +1,277 @@ +package route53resolver + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +// i32 converts a small, bounded slice length to int32 for SDK count fields. +// +//nolint:gosec // resolver endpoint IP-address counts are small and bounded. +func i32(n int) int32 { return int32(n) } + +// endpointIDPrefix mirrors real Resolver endpoint IDs: rslvr-in-* for inbound +// (and inbound delegation), rslvr-out-* for outbound. +func endpointIDPrefix(direction string) string { + if direction == directionInbound || direction == "INBOUND_DELEGATION" { + return "rslvr-in-" + } + + return "rslvr-out-" +} + +// minEndpointIPs is the minimum number of IP addresses AWS requires a Resolver +// endpoint to retain. +const minEndpointIPs = 2 + +func notFound(id string) error { + return errors.Newf(errors.NotFound, "resolver endpoint %q not found", id) +} + +// ipMatches reports whether a stored IP matches a disassociate selector, which +// targets either an explicit IP ID or a subnet (optionally pinned to an IP). +func ipMatches(cur, want *driver.IPAddress) bool { + if want.IPID != "" { + return cur.IPID == want.IPID + } + + return cur.SubnetID == want.SubnetID && (want.IP == "" || cur.IP == want.IP) +} + +func (m *Mock) CreateResolverEndpoint( + _ context.Context, in *driver.CreateResolverEndpointInput, +) (*driver.ResolverEndpoint, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if prior, ok := m.idempotentID("endpoint", in.CreatorRequestID); ok { + if e, found := m.endpoints.Get(prior); found { + out := cloneEndpoint(e) + + return &out, nil + } + } + + id := idgen.GenerateID(endpointIDPrefix(in.Direction)) + now := m.now() + + ips := make([]driver.IPAddress, 0, len(in.IPAddresses)) + for _, r := range in.IPAddresses { + ips = append(ips, driver.IPAddress{ + IPID: idgen.GenerateID("rni-"), + SubnetID: r.SubnetID, + IP: r.IP, + IPv6: r.IPv6, + Status: ipStatusAttached, + CreatedAt: now, + ModifiedAt: now, + }) + } + + epType := in.ResolverEndpointType + if epType == "" { + epType = "IPV4" + } + + e := &driver.ResolverEndpoint{ + ID: id, + ARN: m.arn("resolver-endpoint/" + id), + Name: in.Name, + CreatorRequestID: in.CreatorRequestID, + Direction: in.Direction, + IPAddressCount: i32(len(ips)), + SecurityGroupIDs: append([]string(nil), in.SecurityGroupIDs...), + IPAddresses: ips, + Status: statusOperational, + StatusMessage: "This Resolver Endpoint is operational.", + ResolverEndpointType: epType, + Protocols: append([]string(nil), in.Protocols...), + OutpostARN: in.OutpostARN, + PreferredInstanceType: in.PreferredInstanceType, + DNS64Enabled: in.DNS64Enabled, + IPv6InternetAccessEnabled: in.IPv6InternetAccessEnabled, + CreatedAt: now, + ModifiedAt: now, + } + m.endpoints.Set(id, e) + m.rememberIdempotent("endpoint", in.CreatorRequestID, id) + + if len(in.Tags) > 0 { + m.tags.Set(e.ARN, copyTags(in.Tags)) + } + + out := cloneEndpoint(e) + + return &out, nil +} + +func (m *Mock) GetResolverEndpoint(_ context.Context, id string) (*driver.ResolverEndpoint, error) { + m.mu.Lock() + defer m.mu.Unlock() + + e, ok := m.endpoints.Get(id) + if !ok { + return nil, notFound(id) + } + + out := cloneEndpoint(e) + + return &out, nil +} + +func (m *Mock) UpdateResolverEndpoint( + _ context.Context, id string, in driver.UpdateResolverEndpointInput, +) (*driver.ResolverEndpoint, error) { + m.mu.Lock() + defer m.mu.Unlock() + + e, ok := m.endpoints.Get(id) + if !ok { + return nil, notFound(id) + } + + updated := cloneEndpoint(e) + if in.Name != nil { + updated.Name = *in.Name + } + + if in.ResolverEndpointType != nil { + updated.ResolverEndpointType = *in.ResolverEndpointType + } + + if in.Protocols != nil { + updated.Protocols = append([]string(nil), in.Protocols...) + } + + updated.ModifiedAt = m.now() + m.endpoints.Set(id, &updated) + + out := cloneEndpoint(&updated) + + return &out, nil +} + +func (m *Mock) DeleteResolverEndpoint(_ context.Context, id string) (*driver.ResolverEndpoint, error) { + m.mu.Lock() + defer m.mu.Unlock() + + e, ok := m.endpoints.Get(id) + if !ok { + return nil, notFound(id) + } + + for _, r := range m.rules.All() { + if r.ResolverEndpointID == id { + return nil, errors.Newf(errors.FailedPrecondition, + "resolver endpoint %q still has associated resolver rules", id) + } + } + + m.endpoints.Delete(id) + m.tags.Delete(e.ARN) + + out := cloneEndpoint(e) + out.Status = statusDeleting + out.ModifiedAt = m.now() + + return &out, nil +} + +func (m *Mock) ListResolverEndpoints(_ context.Context) ([]driver.ResolverEndpoint, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.endpoints.All(), cloneEndpoint), nil +} + +func (m *Mock) AssociateResolverEndpointIPAddress( + _ context.Context, id string, ip *driver.IPAddress, +) (*driver.ResolverEndpoint, error) { + m.mu.Lock() + defer m.mu.Unlock() + + e, ok := m.endpoints.Get(id) + if !ok { + return nil, notFound(id) + } + + now := m.now() + add := driver.IPAddress{ + IPID: idgen.GenerateID("rni-"), + SubnetID: ip.SubnetID, + IP: ip.IP, + IPv6: ip.IPv6, + Status: ipStatusAttached, + CreatedAt: now, + ModifiedAt: now, + } + + updated := cloneEndpoint(e) + updated.IPAddresses = append(updated.IPAddresses, add) + updated.IPAddressCount = i32(len(updated.IPAddresses)) + updated.ModifiedAt = now + m.endpoints.Set(id, &updated) + + out := cloneEndpoint(&updated) + + return &out, nil +} + +func (m *Mock) DisassociateResolverEndpointIPAddress( + _ context.Context, id string, ip *driver.IPAddress, +) (*driver.ResolverEndpoint, error) { + m.mu.Lock() + defer m.mu.Unlock() + + e, ok := m.endpoints.Get(id) + if !ok { + return nil, notFound(id) + } + + // AWS requires a Resolver endpoint to keep at least two IP addresses; a + // disassociate that would drop below that minimum is rejected. + if len(e.IPAddresses) <= minEndpointIPs { + return nil, errors.Newf(errors.FailedPrecondition, + "resolver endpoint %q must retain at least %d IP addresses", id, minEndpointIPs) + } + + updated := cloneEndpoint(e) + + idx := -1 + + for i := range updated.IPAddresses { + if ipMatches(&updated.IPAddresses[i], ip) { + idx = i + + break + } + } + + if idx == -1 { + return nil, errors.Newf(errors.NotFound, "ip address not found on resolver endpoint %q", id) + } + + updated.IPAddresses = append(updated.IPAddresses[:idx], updated.IPAddresses[idx+1:]...) + updated.IPAddressCount = i32(len(updated.IPAddresses)) + updated.ModifiedAt = m.now() + m.endpoints.Set(id, &updated) + + out := cloneEndpoint(&updated) + + return &out, nil +} + +func (m *Mock) ListResolverEndpointIPAddresses(_ context.Context, id string) ([]driver.IPAddress, error) { + m.mu.Lock() + defer m.mu.Unlock() + + e, ok := m.endpoints.Get(id) + if !ok { + return nil, notFound(id) + } + + return append([]driver.IPAddress(nil), e.IPAddresses...), nil +} diff --git a/providers/aws/route53resolver/firewall_domains.go b/providers/aws/route53resolver/firewall_domains.go new file mode 100644 index 00000000..61947947 --- /dev/null +++ b/providers/aws/route53resolver/firewall_domains.go @@ -0,0 +1,205 @@ +package route53resolver + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +const ( + fwStatusComplete = "COMPLETE" + fwStatusDeleting = "DELETING" + fwStatusUpdating = "UPDATING" + + domainOpAdd = "ADD" + domainOpRemove = "REMOVE" + domainOpReplace = "REPLACE" +) + +func fwDomainListNotFound(id string) error { + return errors.Newf(errors.NotFound, "firewall domain list %q not found", id) +} + +func cloneFWDomainList(d *driver.FirewallDomainList) driver.FirewallDomainList { return *d } + +func (m *Mock) CreateFirewallDomainList( + _ context.Context, creatorRequestID, name string, tags []driver.Tag, +) (*driver.FirewallDomainList, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if prior, ok := m.idempotentID("fwdomainlist", creatorRequestID); ok { + if d, found := m.fwDomLists.Get(prior); found { + out := cloneFWDomainList(d) + + return &out, nil + } + } + + id := idgen.GenerateID("rslvr-fdl-") + d := &driver.FirewallDomainList{ + ID: id, + ARN: m.arn("firewall-domain-list/" + id), + Name: name, + CreatorRequestID: creatorRequestID, + DomainCount: 0, + Status: fwStatusComplete, + CreatedAt: m.now(), + ModifiedAt: m.now(), + } + m.fwDomLists.Set(id, d) + m.fwDomains.Set(id, nil) + m.rememberIdempotent("fwdomainlist", creatorRequestID, id) + + if len(tags) > 0 { + m.tags.Set(d.ARN, copyTags(tags)) + } + + out := cloneFWDomainList(d) + + return &out, nil +} + +func (m *Mock) GetFirewallDomainList(_ context.Context, id string) (*driver.FirewallDomainList, error) { + m.mu.Lock() + defer m.mu.Unlock() + + d, ok := m.fwDomLists.Get(id) + if !ok { + return nil, fwDomainListNotFound(id) + } + + out := cloneFWDomainList(d) + + return &out, nil +} + +func (m *Mock) DeleteFirewallDomainList(_ context.Context, id string) (*driver.FirewallDomainList, error) { + m.mu.Lock() + defer m.mu.Unlock() + + d, ok := m.fwDomLists.Get(id) + if !ok { + return nil, fwDomainListNotFound(id) + } + + for _, r := range m.fwRules.All() { + if r.FirewallDomainListID == id { + return nil, errors.Newf(errors.FailedPrecondition, + "firewall domain list %q is still referenced by firewall rules", id) + } + } + + m.fwDomLists.Delete(id) + m.fwDomains.Delete(id) + m.tags.Delete(d.ARN) + + out := cloneFWDomainList(d) + out.Status = fwStatusDeleting + + return &out, nil +} + +func (m *Mock) ListFirewallDomainLists(_ context.Context) ([]driver.FirewallDomainList, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.fwDomLists.All(), cloneFWDomainList), nil +} + +func (m *Mock) UpdateFirewallDomains( + _ context.Context, id, operation string, domains []string, +) (*driver.FirewallDomainList, error) { + m.mu.Lock() + defer m.mu.Unlock() + + d, ok := m.fwDomLists.Get(id) + if !ok { + return nil, fwDomainListNotFound(id) + } + + cur, _ := m.fwDomains.Get(id) + m.fwDomains.Set(id, applyDomainOp(cur, operation, domains)) + + updated, _ := m.fwDomains.Get(id) + d.DomainCount = i32(len(updated)) + d.ModifiedAt = m.now() + + out := cloneFWDomainList(d) + out.Status = fwStatusUpdating + + return &out, nil +} + +// applyDomainOp returns the new domain set after applying ADD/REMOVE/REPLACE. +func applyDomainOp(cur []string, operation string, domains []string) []string { + switch operation { + case domainOpReplace: + return append([]string(nil), domains...) + case domainOpRemove: + drop := make(map[string]struct{}, len(domains)) + for _, d := range domains { + drop[d] = struct{}{} + } + + out := make([]string, 0, len(cur)) + + for _, d := range cur { + if _, ok := drop[d]; !ok { + out = append(out, d) + } + } + + return out + default: // ADD + seen := make(map[string]struct{}, len(cur)) + for _, d := range cur { + seen[d] = struct{}{} + } + + out := append([]string(nil), cur...) + + for _, d := range domains { + if _, ok := seen[d]; !ok { + out = append(out, d) + seen[d] = struct{}{} + } + } + + return out + } +} + +func (m *Mock) ImportFirewallDomains( + _ context.Context, id, _, _ string, +) (*driver.FirewallDomainList, error) { + m.mu.Lock() + defer m.mu.Unlock() + + d, ok := m.fwDomLists.Get(id) + if !ok { + return nil, fwDomainListNotFound(id) + } + + d.ModifiedAt = m.now() + + out := cloneFWDomainList(d) + out.Status = fwStatusUpdating + + return &out, nil +} + +func (m *Mock) ListFirewallDomains(_ context.Context, id string) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.fwDomLists.Has(id) { + return nil, fwDomainListNotFound(id) + } + + cur, _ := m.fwDomains.Get(id) + + return append([]string(nil), cur...), nil +} diff --git a/providers/aws/route53resolver/firewall_groups.go b/providers/aws/route53resolver/firewall_groups.go new file mode 100644 index 00000000..70c7b652 --- /dev/null +++ b/providers/aws/route53resolver/firewall_groups.go @@ -0,0 +1,345 @@ +package route53resolver + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +const ( + failOpenEnabled = "ENABLED" + failOpenDisabled = "DISABLED" + failOpenLocal = "USE_LOCAL_RESOURCE_SETTING" + + mutationProtectionDisabled = "DISABLED" +) + +func fwRuleGroupNotFound(id string) error { + return errors.Newf(errors.NotFound, "firewall rule group %q not found", id) +} + +func cloneFWRuleGroup(g *driver.FirewallRuleGroup) driver.FirewallRuleGroup { return *g } + +func cloneFWAssoc(a *driver.FirewallRuleGroupAssociation) driver.FirewallRuleGroupAssociation { + return *a +} + +func cloneFWConfig(c *driver.FirewallConfig) driver.FirewallConfig { return *c } + +// ---- rule groups ---- + +func (m *Mock) CreateFirewallRuleGroup( + _ context.Context, creatorRequestID, name string, tags []driver.Tag, +) (*driver.FirewallRuleGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if prior, ok := m.idempotentID("fwrulegroup", creatorRequestID); ok { + if g, found := m.fwRuleGroups.Get(prior); found { + out := cloneFWRuleGroup(g) + + return &out, nil + } + } + + id := idgen.GenerateID("rslvr-frg-") + g := &driver.FirewallRuleGroup{ + ID: id, + ARN: m.arn("firewall-rule-group/" + id), + Name: name, + CreatorRequestID: creatorRequestID, + OwnerID: m.opts.AccountID, + ShareStatus: shareStatusNotShared, + Status: fwStatusComplete, + CreatedAt: m.now(), + ModifiedAt: m.now(), + } + m.fwRuleGroups.Set(id, g) + m.rememberIdempotent("fwrulegroup", creatorRequestID, id) + + if len(tags) > 0 { + m.tags.Set(g.ARN, copyTags(tags)) + } + + out := cloneFWRuleGroup(g) + + return &out, nil +} + +func (m *Mock) GetFirewallRuleGroup(_ context.Context, id string) (*driver.FirewallRuleGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + g, ok := m.fwRuleGroups.Get(id) + if !ok { + return nil, fwRuleGroupNotFound(id) + } + + out := cloneFWRuleGroup(g) + + return &out, nil +} + +func (m *Mock) DeleteFirewallRuleGroup(_ context.Context, id string) (*driver.FirewallRuleGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + g, ok := m.fwRuleGroups.Get(id) + if !ok { + return nil, fwRuleGroupNotFound(id) + } + + for _, a := range m.fwAssocs.All() { + if a.FirewallRuleGroupID == id { + return nil, errors.Newf(errors.FailedPrecondition, + "firewall rule group %q still has VPC associations", id) + } + } + + for key, rule := range m.fwRules.All() { + if rule.FirewallRuleGroupID == id { + m.fwRules.Delete(key) + } + } + + m.fwRuleGroups.Delete(id) + m.fwPolicies.Delete(g.ARN) + m.tags.Delete(g.ARN) + + out := cloneFWRuleGroup(g) + out.Status = fwStatusDeleting + + return &out, nil +} + +func (m *Mock) ListFirewallRuleGroups(_ context.Context) ([]driver.FirewallRuleGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.fwRuleGroups.All(), cloneFWRuleGroup), nil +} + +func (m *Mock) PutFirewallRuleGroupPolicy(_ context.Context, arn, policy string) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.fwPolicies.Set(arn, policy) + + return nil +} + +func (m *Mock) GetFirewallRuleGroupPolicy(_ context.Context, arn string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + policy, _ := m.fwPolicies.Get(arn) + + return policy, nil +} + +// ---- associations ---- + +func (m *Mock) AssociateFirewallRuleGroup( + _ context.Context, in *driver.AssociateFirewallRuleGroupInput, +) (*driver.FirewallRuleGroupAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.fwRuleGroups.Has(in.FirewallRuleGroupID) { + return nil, fwRuleGroupNotFound(in.FirewallRuleGroupID) + } + + for _, a := range m.fwAssocs.All() { + if a.FirewallRuleGroupID == in.FirewallRuleGroupID && a.VPCID == in.VPCID { + return nil, errors.Newf(errors.AlreadyExists, + "firewall rule group %q is already associated with vpc %q", in.FirewallRuleGroupID, in.VPCID) + } + } + + id := idgen.GenerateID("rslvr-frgassoc-") + + mp := in.MutationProtection + if mp == "" { + mp = mutationProtectionDisabled + } + + a := &driver.FirewallRuleGroupAssociation{ + ID: id, + ARN: m.arn("firewall-rule-group-association/" + id), + Name: in.Name, + CreatorRequestID: in.CreatorRequestID, + FirewallRuleGroupID: in.FirewallRuleGroupID, + VPCID: in.VPCID, + Priority: in.Priority, + MutationProtection: mp, + Status: fwStatusComplete, + CreatedAt: m.now(), + ModifiedAt: m.now(), + } + m.fwAssocs.Set(id, a) + + if len(in.Tags) > 0 { + m.tags.Set(a.ARN, copyTags(in.Tags)) + } + + out := cloneFWAssoc(a) + + return &out, nil +} + +func (m *Mock) DisassociateFirewallRuleGroup( + _ context.Context, assocID string, +) (*driver.FirewallRuleGroupAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.fwAssocs.Get(assocID) + if !ok { + return nil, fwAssocNotFound(assocID) + } + + m.fwAssocs.Delete(assocID) + m.tags.Delete(a.ARN) + + out := cloneFWAssoc(a) + out.Status = fwStatusDeleting + + return &out, nil +} + +func (m *Mock) GetFirewallRuleGroupAssociation( + _ context.Context, assocID string, +) (*driver.FirewallRuleGroupAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.fwAssocs.Get(assocID) + if !ok { + return nil, fwAssocNotFound(assocID) + } + + out := cloneFWAssoc(a) + + return &out, nil +} + +func (m *Mock) ListFirewallRuleGroupAssociations( + _ context.Context, +) ([]driver.FirewallRuleGroupAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.fwAssocs.All(), cloneFWAssoc), nil +} + +func (m *Mock) UpdateFirewallRuleGroupAssociation( + _ context.Context, in *driver.UpdateFirewallRuleGroupAssociationInput, +) (*driver.FirewallRuleGroupAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.fwAssocs.Get(in.ID) + if !ok { + return nil, fwAssocNotFound(in.ID) + } + + if in.Name != nil { + a.Name = *in.Name + } + + if in.Priority != nil { + a.Priority = *in.Priority + } + + if in.MutationProtection != nil { + a.MutationProtection = *in.MutationProtection + } + + a.ModifiedAt = m.now() + + out := cloneFWAssoc(a) + out.Status = fwStatusUpdating + + return &out, nil +} + +func fwAssocNotFound(id string) error { + return errors.Newf(errors.NotFound, "firewall rule group association %q not found", id) +} + +// ---- firewall configs ---- + +// firewallConfigFor materializes a default per-VPC config (fail-open disabled). +// Caller holds m.mu. +func (m *Mock) firewallConfigFor(resourceID string) *driver.FirewallConfig { + if c, ok := m.fwConfigs.Get(resourceID); ok { + return c + } + + c := &driver.FirewallConfig{ + ID: idgen.GenerateID("rslvr-fc-"), + OwnerID: m.opts.AccountID, + ResourceID: resourceID, + FirewallFailOpen: failOpenDisabled, + } + m.fwConfigs.Set(resourceID, c) + + return c +} + +func (m *Mock) GetFirewallConfig(_ context.Context, resourceID string) (*driver.FirewallConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if c, ok := m.fwConfigs.Get(resourceID); ok { + out := cloneFWConfig(c) + + return &out, nil + } + + // A pure read never persists: return the AWS default (fail-open disabled). + return &driver.FirewallConfig{ + OwnerID: m.opts.AccountID, + ResourceID: resourceID, + FirewallFailOpen: failOpenDisabled, + }, nil +} + +func (m *Mock) UpdateFirewallConfig( + _ context.Context, resourceID, failOpen string, +) (*driver.FirewallConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + c := m.firewallConfigFor(resourceID) + c.FirewallFailOpen = failOpenValue(failOpen) + + out := cloneFWConfig(c) + + return &out, nil +} + +// failOpenValue normalizes the request fail-open value to a stored status. +func failOpenValue(v string) string { + switch v { + case failOpenEnabled: + return failOpenEnabled + case failOpenLocal: + return failOpenLocal + default: + return failOpenDisabled + } +} + +func (m *Mock) ListFirewallConfigs(_ context.Context) ([]driver.FirewallConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.fwConfigs.All(), cloneFWConfig), nil +} + +func (*Mock) ListFirewallRuleTypes(_ context.Context) ([]driver.FirewallRuleType, error) { + return []driver.FirewallRuleType{}, nil +} diff --git a/providers/aws/route53resolver/firewall_rules.go b/providers/aws/route53resolver/firewall_rules.go new file mode 100644 index 00000000..4b12f5f0 --- /dev/null +++ b/providers/aws/route53resolver/firewall_rules.go @@ -0,0 +1,273 @@ +package route53resolver + +import ( + "context" + "sort" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +// fwRuleKey builds the composite store key identifying a rule within a group. +func fwRuleKey(groupID, domainListID, qtype string) string { + return groupID + "|" + domainListID + "|" + qtype +} + +func cloneFWRule(r *driver.FirewallRule) driver.FirewallRule { return *r } + +// ruleFromInput materializes a stored rule from an input, stamping timestamps. +func (m *Mock) ruleFromInput(in *driver.FirewallRuleInput) *driver.FirewallRule { + return &driver.FirewallRule{ + FirewallRuleGroupID: in.FirewallRuleGroupID, + FirewallDomainListID: in.FirewallDomainListID, + Name: in.Name, + Priority: in.Priority, + Action: in.Action, + BlockResponse: in.BlockResponse, + BlockOverrideDomain: in.BlockOverrideDomain, + BlockOverrideDNSType: in.BlockOverrideDNSType, + BlockOverrideTTL: in.BlockOverrideTTL, + Qtype: in.Qtype, + ConfidenceThreshold: in.ConfidenceThreshold, + DNSThreatProtection: in.DNSThreatProtection, + FirewallDomainRedirectionAction: in.FirewallDomainRedirectionAction, + CreatorRequestID: in.CreatorRequestID, + Status: fwStatusComplete, + CreatedAt: m.now(), + ModifiedAt: m.now(), + } +} + +// refreshRuleCount recomputes a rule group's RuleCount. Caller holds m.mu. +func (m *Mock) refreshRuleCount(groupID string) { + g, ok := m.fwRuleGroups.Get(groupID) + if !ok { + return + } + + var n int + + for _, r := range m.fwRules.All() { + if r.FirewallRuleGroupID == groupID { + n++ + } + } + + g.RuleCount = i32(n) +} + +func (m *Mock) createRuleLocked(in *driver.FirewallRuleInput) (*driver.FirewallRule, error) { + if !m.fwRuleGroups.Has(in.FirewallRuleGroupID) { + return nil, fwRuleGroupNotFound(in.FirewallRuleGroupID) + } + + key := fwRuleKey(in.FirewallRuleGroupID, in.FirewallDomainListID, in.Qtype) + if m.fwRules.Has(key) { + return nil, errors.Newf(errors.AlreadyExists, + "firewall rule already exists in group %q for domain-list %q qtype %q", + in.FirewallRuleGroupID, in.FirewallDomainListID, in.Qtype) + } + + r := m.ruleFromInput(in) + m.fwRules.Set(key, r) + m.refreshRuleCount(in.FirewallRuleGroupID) + + return r, nil +} + +func (m *Mock) updateRuleLocked(in *driver.FirewallRuleInput) (*driver.FirewallRule, error) { + key := fwRuleKey(in.FirewallRuleGroupID, in.FirewallDomainListID, in.Qtype) + + r, ok := m.fwRules.Get(key) + if !ok { + return nil, errors.Newf(errors.NotFound, + "firewall rule for group %q domain-list %q not found", in.FirewallRuleGroupID, in.FirewallDomainListID) + } + + updated := m.ruleFromInput(in) + updated.CreatedAt = r.CreatedAt + m.fwRules.Set(key, updated) + + return updated, nil +} + +func (m *Mock) deleteRuleLocked(groupID, domainListID, qtype string) (*driver.FirewallRule, error) { + key := fwRuleKey(groupID, domainListID, qtype) + + r, ok := m.fwRules.Get(key) + if !ok { + return nil, errors.Newf(errors.NotFound, + "firewall rule for group %q domain-list %q not found", groupID, domainListID) + } + + m.fwRules.Delete(key) + m.refreshRuleCount(groupID) + + out := cloneFWRule(r) + out.Status = fwStatusDeleting + + return &out, nil +} + +func (m *Mock) CreateFirewallRule(_ context.Context, in *driver.FirewallRuleInput) (*driver.FirewallRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + r, err := m.createRuleLocked(in) + if err != nil { + return nil, err + } + + out := cloneFWRule(r) + + return &out, nil +} + +func (m *Mock) UpdateFirewallRule(_ context.Context, in *driver.FirewallRuleInput) (*driver.FirewallRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + r, err := m.updateRuleLocked(in) + if err != nil { + return nil, err + } + + out := cloneFWRule(r) + + return &out, nil +} + +func (m *Mock) DeleteFirewallRule( + _ context.Context, groupID, domainListID, qtype string, +) (*driver.FirewallRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return m.deleteRuleLocked(groupID, domainListID, qtype) +} + +func (m *Mock) ListFirewallRules(_ context.Context, groupID string) ([]driver.FirewallRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + out := make([]driver.FirewallRule, 0) + + for _, r := range m.fwRules.All() { + if r.FirewallRuleGroupID == groupID { + out = append(out, cloneFWRule(r)) + } + } + + // Deterministic order: by priority, then a stable tiebreaker on + // (domain-list, qtype) so equal-priority rules never reorder across calls. + sort.Slice(out, func(i, j int) bool { + if out[i].Priority != out[j].Priority { + return out[i].Priority < out[j].Priority + } + + if out[i].FirewallDomainListID != out[j].FirewallDomainListID { + return out[i].FirewallDomainListID < out[j].FirewallDomainListID + } + + return out[i].Qtype < out[j].Qtype + }) + + return out, nil +} + +// BatchCreateFirewallRules is atomic: it validates every entry (group exists, +// no existing or in-batch duplicate key) before applying any, so the returned +// slice can never exceed what was stored. +func (m *Mock) BatchCreateFirewallRules( + _ context.Context, in []driver.FirewallRuleInput, +) ([]driver.FirewallRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + seen := make(map[string]struct{}, len(in)) + + for i := range in { + if !m.fwRuleGroups.Has(in[i].FirewallRuleGroupID) { + return nil, fwRuleGroupNotFound(in[i].FirewallRuleGroupID) + } + + key := fwRuleKey(in[i].FirewallRuleGroupID, in[i].FirewallDomainListID, in[i].Qtype) + if _, dup := seen[key]; dup || m.fwRules.Has(key) { + return nil, errors.Newf(errors.AlreadyExists, + "duplicate firewall rule in group %q for domain-list %q qtype %q", + in[i].FirewallRuleGroupID, in[i].FirewallDomainListID, in[i].Qtype) + } + + seen[key] = struct{}{} + } + + return m.applyRuleBatch(in), nil +} + +// applyRuleBatch stores each validated rule and refreshes its group's count. +// Caller holds m.mu and has already validated the batch. +func (m *Mock) applyRuleBatch(in []driver.FirewallRuleInput) []driver.FirewallRule { + out := make([]driver.FirewallRule, 0, len(in)) + + for i := range in { + r := m.ruleFromInput(&in[i]) + out = append(out, cloneFWRule(r)) + + m.fwRules.Set(fwRuleKey(in[i].FirewallRuleGroupID, in[i].FirewallDomainListID, in[i].Qtype), r) + m.refreshRuleCount(in[i].FirewallRuleGroupID) + } + + return out +} + +// BatchUpdateFirewallRules is atomic: every target rule must exist before any +// update is applied. +func (m *Mock) BatchUpdateFirewallRules( + _ context.Context, in []driver.FirewallRuleInput, +) ([]driver.FirewallRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + for i := range in { + key := fwRuleKey(in[i].FirewallRuleGroupID, in[i].FirewallDomainListID, in[i].Qtype) + if !m.fwRules.Has(key) { + return nil, errors.Newf(errors.NotFound, + "firewall rule for group %q domain-list %q not found", + in[i].FirewallRuleGroupID, in[i].FirewallDomainListID) + } + } + + out := make([]driver.FirewallRule, 0, len(in)) + + for i := range in { + r, _ := m.updateRuleLocked(&in[i]) + out = append(out, cloneFWRule(r)) + } + + return out, nil +} + +// BatchDeleteFirewallRules is atomic: every target rule must exist before any +// deletion is applied. +func (m *Mock) BatchDeleteFirewallRules( + _ context.Context, groupID string, keys []driver.FirewallRuleKey, +) ([]driver.FirewallRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + for _, k := range keys { + if !m.fwRules.Has(fwRuleKey(groupID, k.FirewallDomainListID, k.Qtype)) { + return nil, errors.Newf(errors.NotFound, + "firewall rule for group %q domain-list %q not found", groupID, k.FirewallDomainListID) + } + } + + out := make([]driver.FirewallRule, 0, len(keys)) + + for _, k := range keys { + r, _ := m.deleteRuleLocked(groupID, k.FirewallDomainListID, k.Qtype) + out = append(out, *r) + } + + return out, nil +} diff --git a/providers/aws/route53resolver/outpost.go b/providers/aws/route53resolver/outpost.go new file mode 100644 index 00000000..7edd4abf --- /dev/null +++ b/providers/aws/route53resolver/outpost.go @@ -0,0 +1,123 @@ +package route53resolver + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +func outpostNotFound(id string) error { + return errors.Newf(errors.NotFound, "outpost resolver %q not found", id) +} + +func cloneOutpost(o *driver.OutpostResolver) driver.OutpostResolver { return *o } + +func (m *Mock) CreateOutpostResolver( + _ context.Context, in *driver.CreateOutpostResolverInput, +) (*driver.OutpostResolver, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if prior, ok := m.idempotentID("outpost", in.CreatorRequestID); ok { + if o, found := m.outposts.Get(prior); found { + out := cloneOutpost(o) + + return &out, nil + } + } + + id := idgen.GenerateID("rslvr-op-") + o := &driver.OutpostResolver{ + ID: id, + ARN: m.arn("outpost-resolver/" + id), + Name: in.Name, + CreatorRequestID: in.CreatorRequestID, + OutpostARN: in.OutpostARN, + PreferredInstanceType: in.PreferredInstanceType, + InstanceCount: in.InstanceCount, + Status: statusOperational, + CreatedAt: m.now(), + ModifiedAt: m.now(), + } + m.outposts.Set(id, o) + m.rememberIdempotent("outpost", in.CreatorRequestID, id) + + if len(in.Tags) > 0 { + m.tags.Set(o.ARN, copyTags(in.Tags)) + } + + out := cloneOutpost(o) + + return &out, nil +} + +func (m *Mock) GetOutpostResolver(_ context.Context, id string) (*driver.OutpostResolver, error) { + m.mu.Lock() + defer m.mu.Unlock() + + o, ok := m.outposts.Get(id) + if !ok { + return nil, outpostNotFound(id) + } + + out := cloneOutpost(o) + + return &out, nil +} + +func (m *Mock) UpdateOutpostResolver( + _ context.Context, in *driver.UpdateOutpostResolverInput, +) (*driver.OutpostResolver, error) { + m.mu.Lock() + defer m.mu.Unlock() + + o, ok := m.outposts.Get(in.ID) + if !ok { + return nil, outpostNotFound(in.ID) + } + + if in.Name != nil { + o.Name = *in.Name + } + + if in.PreferredInstanceType != nil { + o.PreferredInstanceType = *in.PreferredInstanceType + } + + if in.InstanceCount != nil { + o.InstanceCount = *in.InstanceCount + } + + o.ModifiedAt = m.now() + + out := cloneOutpost(o) + + return &out, nil +} + +func (m *Mock) DeleteOutpostResolver(_ context.Context, id string) (*driver.OutpostResolver, error) { + m.mu.Lock() + defer m.mu.Unlock() + + o, ok := m.outposts.Get(id) + if !ok { + return nil, outpostNotFound(id) + } + + m.outposts.Delete(id) + m.tags.Delete(o.ARN) + + out := cloneOutpost(o) + out.Status = statusDeleting + + return &out, nil +} + +func (m *Mock) ListOutpostResolvers(_ context.Context) ([]driver.OutpostResolver, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.outposts.All(), cloneOutpost), nil +} diff --git a/providers/aws/route53resolver/query_log_config.go b/providers/aws/route53resolver/query_log_config.go new file mode 100644 index 00000000..17e8bc5d --- /dev/null +++ b/providers/aws/route53resolver/query_log_config.go @@ -0,0 +1,225 @@ +package route53resolver + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +const ( + qlcStatusCreated = "CREATED" + qlcStatusDeleting = "DELETING" + qlcAssocStatusActive = "ACTIVE" + qlcAssocStatusDeleting = "DELETING" +) + +func qlcNotFound(id string) error { + return errors.Newf(errors.NotFound, "resolver query log config %q not found", id) +} + +func cloneQLC(c *driver.QueryLogConfig) driver.QueryLogConfig { return *c } + +func cloneQLCAssoc(a *driver.QueryLogConfigAssociation) driver.QueryLogConfigAssociation { return *a } + +// countQLCAssocs counts associations for a config. Caller holds m.mu. +func (m *Mock) countQLCAssocs(configID string) int32 { + var n int + + for _, a := range m.qlcAssocs.All() { + if a.ResolverQueryLogConfigID == configID { + n++ + } + } + + return i32(n) +} + +func (m *Mock) CreateResolverQueryLogConfig( + _ context.Context, in *driver.CreateQueryLogConfigInput, +) (*driver.QueryLogConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if prior, ok := m.idempotentID("qlc", in.CreatorRequestID); ok { + if c, found := m.qlcs.Get(prior); found { + out := cloneQLC(c) + + return &out, nil + } + } + + id := idgen.GenerateID("rqlc-") + c := &driver.QueryLogConfig{ + ID: id, + ARN: m.arn("resolver-query-log-config/" + id), + CreatorRequestID: in.CreatorRequestID, + DestinationARN: in.DestinationARN, + Name: in.Name, + OwnerID: m.opts.AccountID, + ShareStatus: shareStatusNotShared, + Status: qlcStatusCreated, + CreatedAt: m.now(), + } + m.qlcs.Set(id, c) + m.rememberIdempotent("qlc", in.CreatorRequestID, id) + + if len(in.Tags) > 0 { + m.tags.Set(c.ARN, copyTags(in.Tags)) + } + + out := cloneQLC(c) + + return &out, nil +} + +func (m *Mock) GetResolverQueryLogConfig(_ context.Context, id string) (*driver.QueryLogConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + c, ok := m.qlcs.Get(id) + if !ok { + return nil, qlcNotFound(id) + } + + out := cloneQLC(c) + out.AssociationCount = m.countQLCAssocs(id) + + return &out, nil +} + +func (m *Mock) DeleteResolverQueryLogConfig(_ context.Context, id string) (*driver.QueryLogConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + c, ok := m.qlcs.Get(id) + if !ok { + return nil, qlcNotFound(id) + } + + m.qlcs.Delete(id) + m.qlcPolicies.Delete(c.ARN) + m.tags.Delete(c.ARN) + + out := cloneQLC(c) + out.Status = qlcStatusDeleting + + return &out, nil +} + +func (m *Mock) ListResolverQueryLogConfigs(_ context.Context) ([]driver.QueryLogConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + out := sortedValues(m.qlcs.All(), cloneQLC) + for i := range out { + out[i].AssociationCount = m.countQLCAssocs(out[i].ID) + } + + return out, nil +} + +func (m *Mock) AssociateResolverQueryLogConfig( + _ context.Context, configID, resourceID string, +) (*driver.QueryLogConfigAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.qlcs.Has(configID) { + return nil, qlcNotFound(configID) + } + + for _, a := range m.qlcAssocs.All() { + if a.ResolverQueryLogConfigID == configID && a.ResourceID == resourceID { + return nil, errors.Newf(errors.AlreadyExists, + "query log config %q is already associated with resource %q", configID, resourceID) + } + } + + id := idgen.GenerateID("rqlca-") + a := &driver.QueryLogConfigAssociation{ + ID: id, + ResolverQueryLogConfigID: configID, + ResourceID: resourceID, + Status: qlcAssocStatusActive, + CreatedAt: m.now(), + } + m.qlcAssocs.Set(id, a) + + out := cloneQLCAssoc(a) + + return &out, nil +} + +func (m *Mock) DisassociateResolverQueryLogConfig( + _ context.Context, configID, resourceID string, +) (*driver.QueryLogConfigAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + var found *driver.QueryLogConfigAssociation + + for _, a := range m.qlcAssocs.All() { + if a.ResolverQueryLogConfigID == configID && a.ResourceID == resourceID { + found = a + + break + } + } + + if found == nil { + return nil, errors.Newf(errors.NotFound, + "no query log config association for config %q and resource %q", configID, resourceID) + } + + m.qlcAssocs.Delete(found.ID) + + out := cloneQLCAssoc(found) + out.Status = qlcAssocStatusDeleting + + return &out, nil +} + +func (m *Mock) GetResolverQueryLogConfigAssociation( + _ context.Context, assocID string, +) (*driver.QueryLogConfigAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.qlcAssocs.Get(assocID) + if !ok { + return nil, errors.Newf(errors.NotFound, "resolver query log config association %q not found", assocID) + } + + out := cloneQLCAssoc(a) + + return &out, nil +} + +func (m *Mock) ListResolverQueryLogConfigAssociations( + _ context.Context, +) ([]driver.QueryLogConfigAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.qlcAssocs.All(), cloneQLCAssoc), nil +} + +func (m *Mock) PutResolverQueryLogConfigPolicy(_ context.Context, arn, policy string) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.qlcPolicies.Set(arn, policy) + + return nil +} + +func (m *Mock) GetResolverQueryLogConfigPolicy(_ context.Context, arn string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + policy, _ := m.qlcPolicies.Get(arn) + + return policy, nil +} diff --git a/providers/aws/route53resolver/route53resolver.go b/providers/aws/route53resolver/route53resolver.go new file mode 100644 index 00000000..04fdad0d --- /dev/null +++ b/providers/aws/route53resolver/route53resolver.go @@ -0,0 +1,178 @@ +// Package route53resolver provides an in-memory mock of the AWS Route 53 +// Resolver control plane. It satisfies services/route53resolver/driver so the +// real aws-sdk-go-v2/service/route53resolver client works against it via the +// AWS server (AWS JSON 1.1, X-Amz-Target "Route53Resolver."). +package route53resolver + +import ( + "sort" + "sync" + "time" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/internal/memstore" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +// Compile-time check that Mock implements the driver contract. +var _ driver.Route53Resolver = (*Mock)(nil) + +const ( + statusOperational = "OPERATIONAL" + statusDeleting = "DELETING" + ipStatusAttached = "ATTACHED" + + directionInbound = "INBOUND" +) + +// Mock is an in-memory mock of AWS Route 53 Resolver. +type Mock struct { + endpoints *memstore.Store[*driver.ResolverEndpoint] // keyed by endpoint ID + rules *memstore.Store[*driver.ResolverRule] // keyed by rule ID + ruleAssocs *memstore.Store[*driver.ResolverRuleAssociation] // keyed by association ID + rulePolicies *memstore.Store[string] // keyed by resource ARN + qlcs *memstore.Store[*driver.QueryLogConfig] // keyed by config ID + qlcAssocs *memstore.Store[*driver.QueryLogConfigAssociation] // keyed by association ID + qlcPolicies *memstore.Store[string] // keyed by resource ARN + rslvrConfigs *memstore.Store[*driver.ResolverConfig] // keyed by VPC/resource ID + dnssecCfgs *memstore.Store[*driver.ResolverDnssecConfig] // keyed by VPC/resource ID + fwDomLists *memstore.Store[*driver.FirewallDomainList] // keyed by domain-list ID + fwDomains *memstore.Store[[]string] // keyed by domain-list ID + fwRuleGroups *memstore.Store[*driver.FirewallRuleGroup] // keyed by rule-group ID + fwRules *memstore.Store[*driver.FirewallRule] // keyed by group|domainList|qtype + fwAssocs *memstore.Store[*driver.FirewallRuleGroupAssociation] // keyed by association ID + fwConfigs *memstore.Store[*driver.FirewallConfig] // keyed by VPC/resource ID + fwPolicies *memstore.Store[string] // keyed by rule-group ARN + outposts *memstore.Store[*driver.OutpostResolver] // keyed by outpost-resolver ID + tags *memstore.Store[[]driver.Tag] // keyed by resource ARN + idem *memstore.Store[string] // "kind|creatorRequestID" -> resource ID + opts *config.Options + mu sync.Mutex // serializes read-modify-write on stored records +} + +// New returns a Route 53 Resolver mock. +func New(opts *config.Options) *Mock { + return &Mock{ + endpoints: memstore.New[*driver.ResolverEndpoint](), + rules: memstore.New[*driver.ResolverRule](), + ruleAssocs: memstore.New[*driver.ResolverRuleAssociation](), + rulePolicies: memstore.New[string](), + qlcs: memstore.New[*driver.QueryLogConfig](), + qlcAssocs: memstore.New[*driver.QueryLogConfigAssociation](), + qlcPolicies: memstore.New[string](), + rslvrConfigs: memstore.New[*driver.ResolverConfig](), + dnssecCfgs: memstore.New[*driver.ResolverDnssecConfig](), + fwDomLists: memstore.New[*driver.FirewallDomainList](), + fwDomains: memstore.New[[]string](), + fwRuleGroups: memstore.New[*driver.FirewallRuleGroup](), + fwRules: memstore.New[*driver.FirewallRule](), + fwAssocs: memstore.New[*driver.FirewallRuleGroupAssociation](), + fwConfigs: memstore.New[*driver.FirewallConfig](), + fwPolicies: memstore.New[string](), + outposts: memstore.New[*driver.OutpostResolver](), + tags: memstore.New[[]driver.Tag](), + idem: memstore.New[string](), + opts: opts, + } +} + +// idempotentID returns the resource ID a prior create recorded for this +// (kind, creatorRequestID), if any. An empty token never matches. Caller holds +// m.mu. +func (m *Mock) idempotentID(kind, token string) (string, bool) { + if token == "" { + return "", false + } + + return m.idem.Get(kind + "|" + token) +} + +// rememberIdempotent records the resource ID minted for a (kind, +// creatorRequestID) so a replayed create returns the same resource. Caller +// holds m.mu. +func (m *Mock) rememberIdempotent(kind, token, id string) { + if token != "" { + m.idem.Set(kind+"|"+token, id) + } +} + +// storeHasARN reports whether any value in a store carries the given ARN. +func storeHasARN[T any](all map[string]*T, arn string, getARN func(*T) string) bool { + for _, v := range all { + if getARN(v) == arn { + return true + } + } + + return false +} + +// arnExists reports whether arn names a live, taggable resource in the service. +// Caller holds m.mu. +func (m *Mock) arnExists(arn string) bool { + return storeHasARN(m.endpoints.All(), arn, func(e *driver.ResolverEndpoint) string { return e.ARN }) || + storeHasARN(m.rules.All(), arn, func(r *driver.ResolverRule) string { return r.ARN }) || + storeHasARN(m.qlcs.All(), arn, func(c *driver.QueryLogConfig) string { return c.ARN }) || + storeHasARN(m.fwDomLists.All(), arn, func(d *driver.FirewallDomainList) string { return d.ARN }) || + storeHasARN(m.fwRuleGroups.All(), arn, func(g *driver.FirewallRuleGroup) string { return g.ARN }) || + storeHasARN(m.fwAssocs.All(), arn, func(a *driver.FirewallRuleGroupAssociation) string { return a.ARN }) || + storeHasARN(m.outposts.All(), arn, func(o *driver.OutpostResolver) string { return o.ARN }) +} + +// now returns the current time (via the injectable clock) in RFC 3339. +func (m *Mock) now() string { + return m.opts.Clock.Now().UTC().Format(time.RFC3339) +} + +// arn builds a route53resolver ARN for the given resource path. +func (m *Mock) arn(resource string) string { + return idgen.AWSARN("route53resolver", m.opts.Region, m.opts.AccountID, resource) +} + +// cloneEndpoint deep-copies an endpoint so stored records never share a slice +// backing array with a returned value (copy-on-write read discipline). +func cloneEndpoint(e *driver.ResolverEndpoint) driver.ResolverEndpoint { + out := *e + out.SecurityGroupIDs = append([]string(nil), e.SecurityGroupIDs...) + out.Protocols = append([]string(nil), e.Protocols...) + out.IPAddresses = append([]driver.IPAddress(nil), e.IPAddresses...) + + return out +} + +// copyTags returns a defensive copy of a tag slice. +func copyTags(t []driver.Tag) []driver.Tag { + return append([]driver.Tag(nil), t...) +} + +// cloneRule deep-copies a resolver rule (its TargetIPs slice is copied). +func cloneRule(r *driver.ResolverRule) driver.ResolverRule { + out := *r + out.TargetIPs = append([]driver.TargetAddress(nil), r.TargetIPs...) + + return out +} + +// cloneAssoc copies a rule association (no reference-type fields). +func cloneAssoc(a *driver.ResolverRuleAssociation) driver.ResolverRuleAssociation { + return *a +} + +// sortedValues returns the store's values sorted by key, each deep-copied via +// clone — the shared List implementation for every resource group. +func sortedValues[T any](all map[string]*T, clone func(*T) T) []T { + ids := make([]string, 0, len(all)) + for id := range all { + ids = append(ids, id) + } + + sort.Strings(ids) + + out := make([]T, 0, len(all)) + for _, id := range ids { + out = append(out, clone(all[id])) + } + + return out +} diff --git a/providers/aws/route53resolver/route53resolver_test.go b/providers/aws/route53resolver/route53resolver_test.go new file mode 100644 index 00000000..ea63f36c --- /dev/null +++ b/providers/aws/route53resolver/route53resolver_test.go @@ -0,0 +1,686 @@ +package route53resolver + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +func newTestMock() *Mock { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + opts := config.NewOptions(config.WithClock(fc), config.WithRegion("us-east-1")) + + return New(opts) +} + +func ptr[T any](v T) *T { return &v } + +// ---- resolver endpoints ---- + +func TestEndpointCreateGetUpdateDeleteAndIPs(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + ep, err := m.CreateResolverEndpoint(ctx, &driver.CreateResolverEndpointInput{ + Name: "in-1", + Direction: directionInbound, + SecurityGroupIDs: []string{"sg-1"}, + IPAddresses: []driver.IPAddress{{SubnetID: "subnet-a"}, {SubnetID: "subnet-b"}}, + Tags: []driver.Tag{{Key: "env", Value: "test"}}, + }) + require.NoError(t, err) + assert.Contains(t, ep.ID, "rslvr-in-") + assert.Equal(t, int32(2), ep.IPAddressCount) + assert.Equal(t, statusOperational, ep.Status) + + // Tags stored on create are retrievable by ARN. + tags, err := m.ListTagsForResource(ctx, ep.ARN) + require.NoError(t, err) + assert.Equal(t, []driver.Tag{{Key: "env", Value: "test"}}, tags) + + // Add an IP → count grows; remove → count shrinks. + afterAdd, err := m.AssociateResolverEndpointIPAddress(ctx, ep.ID, &driver.IPAddress{SubnetID: "subnet-c"}) + require.NoError(t, err) + assert.Equal(t, int32(3), afterAdd.IPAddressCount) + + ips, err := m.ListResolverEndpointIPAddresses(ctx, ep.ID) + require.NoError(t, err) + assert.Len(t, ips, 3) + assert.NotEmpty(t, ips[0].IPID) + + afterDel, err := m.DisassociateResolverEndpointIPAddress(ctx, ep.ID, &driver.IPAddress{IPID: ips[0].IPID}) + require.NoError(t, err) + assert.Equal(t, int32(2), afterDel.IPAddressCount) + + upd, err := m.UpdateResolverEndpoint(ctx, ep.ID, driver.UpdateResolverEndpointInput{Name: ptr("in-renamed")}) + require.NoError(t, err) + assert.Equal(t, "in-renamed", upd.Name) + + del, err := m.DeleteResolverEndpoint(ctx, ep.ID) + require.NoError(t, err) + assert.Equal(t, statusDeleting, del.Status) + + _, err = m.GetResolverEndpoint(ctx, ep.ID) + assert.True(t, cerrors.IsNotFound(err)) +} + +func TestEndpointErrorPaths(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.GetResolverEndpoint(ctx, "rslvr-in-missing") + assert.True(t, cerrors.IsNotFound(err)) + + _, err = m.UpdateResolverEndpoint(ctx, "nope", driver.UpdateResolverEndpointInput{Name: ptr("x")}) + assert.True(t, cerrors.IsNotFound(err)) + + _, err = m.DeleteResolverEndpoint(ctx, "nope") + assert.True(t, cerrors.IsNotFound(err)) + + _, err = m.AssociateResolverEndpointIPAddress(ctx, "nope", &driver.IPAddress{SubnetID: "s"}) + assert.True(t, cerrors.IsNotFound(err)) + + _, err = m.ListResolverEndpointIPAddresses(ctx, "nope") + assert.True(t, cerrors.IsNotFound(err)) +} + +func TestEndpointListSortedAndCloneIsolation(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + for _, name := range []string{"a", "b", "c"} { + _, err := m.CreateResolverEndpoint(ctx, &driver.CreateResolverEndpointInput{ + Name: name, Direction: directionInbound, + IPAddresses: []driver.IPAddress{{SubnetID: "s"}}, + }) + require.NoError(t, err) + } + + list, err := m.ListResolverEndpoints(ctx) + require.NoError(t, err) + require.Len(t, list, 3) + + // Mutating a returned copy must not corrupt the stored record. + list[0].Name = "MUTATED" + list[0].IPAddresses[0].IP = "9.9.9.9" + + reread, err := m.ListResolverEndpoints(ctx) + require.NoError(t, err) + assert.NotEqual(t, "MUTATED", reread[0].Name) + assert.NotEqual(t, "9.9.9.9", reread[0].IPAddresses[0].IP) +} + +// ---- resolver rules ---- + +func TestRuleLifecycleAssociationsAndPolicy(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + rule, err := m.CreateResolverRule(ctx, &driver.CreateResolverRuleInput{ + Name: "fwd", RuleType: "FORWARD", DomainName: "example.com", + TargetIPs: []driver.TargetAddress{{IP: "10.0.0.2", Port: 53}}, + }) + require.NoError(t, err) + assert.Contains(t, rule.ID, "rslvr-rr-") + + upd, err := m.UpdateResolverRule(ctx, rule.ID, driver.UpdateResolverRuleInput{Name: ptr("fwd2")}) + require.NoError(t, err) + assert.Equal(t, "fwd2", upd.Name) + + assoc, err := m.AssociateResolverRule(ctx, rule.ID, "vpc-1", "assoc-1") + require.NoError(t, err) + assert.Contains(t, assoc.ID, "rslvr-rrassoc-") + + got, err := m.GetResolverRuleAssociation(ctx, assoc.ID) + require.NoError(t, err) + assert.Equal(t, "vpc-1", got.VPCID) + + assocs, err := m.ListResolverRuleAssociations(ctx) + require.NoError(t, err) + assert.Len(t, assocs, 1) + + dis, err := m.DisassociateResolverRule(ctx, rule.ID, "vpc-1") + require.NoError(t, err) + assert.Equal(t, assoc.ID, dis.ID) + + require.NoError(t, m.PutResolverRulePolicy(ctx, rule.ARN, `{"policy":true}`)) + pol, err := m.GetResolverRulePolicy(ctx, rule.ARN) + require.NoError(t, err) + assert.Equal(t, `{"policy":true}`, pol) + + del, err := m.DeleteResolverRule(ctx, rule.ID) + require.NoError(t, err) + assert.Equal(t, rule.ID, del.ID) + + _, err = m.GetResolverRule(ctx, rule.ID) + assert.True(t, cerrors.IsNotFound(err)) +} + +func TestRuleErrorPaths(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.GetResolverRule(ctx, "missing") + assert.True(t, cerrors.IsNotFound(err)) + + _, err = m.UpdateResolverRule(ctx, "missing", driver.UpdateResolverRuleInput{}) + assert.True(t, cerrors.IsNotFound(err)) + + _, err = m.AssociateResolverRule(ctx, "missing", "vpc-1", "n") + assert.True(t, cerrors.IsNotFound(err)) + + _, err = m.DisassociateResolverRule(ctx, "missing", "vpc-1") + assert.True(t, cerrors.IsNotFound(err)) + + _, err = m.GetResolverRuleAssociation(ctx, "missing") + assert.True(t, cerrors.IsNotFound(err)) + + // Empty policy read is not an error, just empty. + pol, err := m.GetResolverRulePolicy(ctx, "arn:none") + require.NoError(t, err) + assert.Empty(t, pol) +} + +// ---- query-log configs ---- + +func TestQueryLogConfigLifecycleAndAssocCount(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + qlc, err := m.CreateResolverQueryLogConfig(ctx, &driver.CreateQueryLogConfigInput{ + Name: "logs", DestinationARN: "arn:aws:s3:::bucket", + }) + require.NoError(t, err) + assert.Contains(t, qlc.ID, "rqlc-") + + a1, err := m.AssociateResolverQueryLogConfig(ctx, qlc.ID, "vpc-1") + require.NoError(t, err) + _, err = m.AssociateResolverQueryLogConfig(ctx, qlc.ID, "vpc-2") + require.NoError(t, err) + + got, err := m.GetResolverQueryLogConfig(ctx, qlc.ID) + require.NoError(t, err) + assert.Equal(t, int32(2), got.AssociationCount) + + list, err := m.ListResolverQueryLogConfigs(ctx) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, int32(2), list[0].AssociationCount) + + gotAssoc, err := m.GetResolverQueryLogConfigAssociation(ctx, a1.ID) + require.NoError(t, err) + assert.Equal(t, "vpc-1", gotAssoc.ResourceID) + + _, err = m.DisassociateResolverQueryLogConfig(ctx, qlc.ID, "vpc-1") + require.NoError(t, err) + + got, err = m.GetResolverQueryLogConfig(ctx, qlc.ID) + require.NoError(t, err) + assert.Equal(t, int32(1), got.AssociationCount) + + del, err := m.DeleteResolverQueryLogConfig(ctx, qlc.ID) + require.NoError(t, err) + assert.Equal(t, qlcStatusDeleting, del.Status) +} + +func TestQueryLogConfigErrorPaths(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.GetResolverQueryLogConfig(ctx, "missing") + assert.True(t, cerrors.IsNotFound(err)) + + _, err = m.DeleteResolverQueryLogConfig(ctx, "missing") + assert.True(t, cerrors.IsNotFound(err)) + + _, err = m.AssociateResolverQueryLogConfig(ctx, "missing", "vpc-1") + assert.True(t, cerrors.IsNotFound(err)) + + _, err = m.DisassociateResolverQueryLogConfig(ctx, "missing", "vpc-1") + assert.True(t, cerrors.IsNotFound(err)) + + _, err = m.GetResolverQueryLogConfigAssociation(ctx, "missing") + assert.True(t, cerrors.IsNotFound(err)) +} + +// ---- resolver & DNSSEC configs ---- + +func TestResolverConfigLazyDefaultAndUpdate(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + // Nothing listed until a VPC is touched. + list, err := m.ListResolverConfigs(ctx) + require.NoError(t, err) + assert.Empty(t, list) + + // A pure Get returns the default but must NOT persist a phantom config. + got, err := m.GetResolverConfig(ctx, "vpc-1") + require.NoError(t, err) + assert.Equal(t, autodefinedReverseEnabled, got.AutodefinedReverse) + + list, err = m.ListResolverConfigs(ctx) + require.NoError(t, err) + assert.Empty(t, list) + + // Update materializes it; now it appears in the List. + _, err = m.UpdateResolverConfig(ctx, "vpc-1", flagEnable) + require.NoError(t, err) + + list, err = m.ListResolverConfigs(ctx) + require.NoError(t, err) + assert.Len(t, list, 1) + + for flag, want := range map[string]string{ + "DISABLE": autodefinedReverseDisabled, + flagEnable: autodefinedReverseEnabled, + "USE_LOCAL_RESOURCE_SETTING": autodefinedReverseLocal, + } { + upd, uerr := m.UpdateResolverConfig(ctx, "vpc-1", flag) + require.NoError(t, uerr) + assert.Equal(t, want, upd.AutodefinedReverse) + } +} + +func TestDnssecConfigLazyDefaultAndUpdate(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + got, err := m.GetResolverDnssecConfig(ctx, "vpc-1") + require.NoError(t, err) + assert.Equal(t, dnssecStatusDisabled, got.ValidationStatus) + + upd, err := m.UpdateResolverDnssecConfig(ctx, "vpc-1", flagEnable) + require.NoError(t, err) + assert.Equal(t, dnssecStatusEnabled, upd.ValidationStatus) + + list, err := m.ListResolverDnssecConfigs(ctx) + require.NoError(t, err) + assert.Len(t, list, 1) +} + +// ---- DNS firewall ---- + +func TestFirewallDomainOps(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + dl, err := m.CreateFirewallDomainList(ctx, "req", "block", nil) + require.NoError(t, err) + assert.Contains(t, dl.ID, "rslvr-fdl-") + + _, err = m.UpdateFirewallDomains(ctx, dl.ID, domainOpAdd, []string{"a.com", "b.com", "a.com"}) + require.NoError(t, err) + domains, err := m.ListFirewallDomains(ctx, dl.ID) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"a.com", "b.com"}, domains) // dedup on ADD + + _, err = m.UpdateFirewallDomains(ctx, dl.ID, domainOpRemove, []string{"a.com"}) + require.NoError(t, err) + domains, err = m.ListFirewallDomains(ctx, dl.ID) + require.NoError(t, err) + assert.Equal(t, []string{"b.com"}, domains) + + _, err = m.UpdateFirewallDomains(ctx, dl.ID, domainOpReplace, []string{"x.com", "y.com"}) + require.NoError(t, err) + domains, err = m.ListFirewallDomains(ctx, dl.ID) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"x.com", "y.com"}, domains) + + got, err := m.GetFirewallDomainList(ctx, dl.ID) + require.NoError(t, err) + assert.Equal(t, int32(2), got.DomainCount) + + _, err = m.ImportFirewallDomains(ctx, dl.ID, domainOpReplace, "s3://x") + require.NoError(t, err) + + del, err := m.DeleteFirewallDomainList(ctx, dl.ID) + require.NoError(t, err) + assert.Equal(t, fwStatusDeleting, del.Status) + + _, err = m.GetFirewallDomainList(ctx, "missing") + assert.True(t, cerrors.IsNotFound(err)) + _, err = m.UpdateFirewallDomains(ctx, "missing", domainOpAdd, []string{"a"}) + assert.True(t, cerrors.IsNotFound(err)) + _, err = m.ListFirewallDomains(ctx, "missing") + assert.True(t, cerrors.IsNotFound(err)) +} + +func TestFirewallRulesBatchAndCascade(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + rg, err := m.CreateFirewallRuleGroup(ctx, "req", "rg", nil) + require.NoError(t, err) + + // Rule against a nonexistent group fails. + _, err = m.CreateFirewallRule(ctx, &driver.FirewallRuleInput{FirewallRuleGroupID: "missing", FirewallDomainListID: "dl"}) + assert.True(t, cerrors.IsNotFound(err)) + + created, err := m.BatchCreateFirewallRules(ctx, []driver.FirewallRuleInput{ + {FirewallRuleGroupID: rg.ID, FirewallDomainListID: "dl-1", Priority: 20, Action: "BLOCK"}, + {FirewallRuleGroupID: rg.ID, FirewallDomainListID: "dl-2", Priority: 10, Action: "ALLOW"}, + }) + require.NoError(t, err) + assert.Len(t, created, 2) + + // ListFirewallRules is sorted by priority ascending. + rules, err := m.ListFirewallRules(ctx, rg.ID) + require.NoError(t, err) + require.Len(t, rules, 2) + assert.Equal(t, int32(10), rules[0].Priority) + + got, err := m.GetFirewallRuleGroup(ctx, rg.ID) + require.NoError(t, err) + assert.Equal(t, int32(2), got.RuleCount) + + // Update preserves creation time, mutates action. + upd, err := m.BatchUpdateFirewallRules(ctx, []driver.FirewallRuleInput{ + {FirewallRuleGroupID: rg.ID, FirewallDomainListID: "dl-1", Action: "ALERT", Priority: 20}, + }) + require.NoError(t, err) + assert.Equal(t, "ALERT", upd[0].Action) + + // Updating a nonexistent rule errors. + _, err = m.UpdateFirewallRule(ctx, &driver.FirewallRuleInput{FirewallRuleGroupID: rg.ID, FirewallDomainListID: "nope"}) + assert.True(t, cerrors.IsNotFound(err)) + + deleted, err := m.BatchDeleteFirewallRules(ctx, rg.ID, []driver.FirewallRuleKey{{FirewallDomainListID: "dl-1"}}) + require.NoError(t, err) + assert.Len(t, deleted, 1) + + got, err = m.GetFirewallRuleGroup(ctx, rg.ID) + require.NoError(t, err) + assert.Equal(t, int32(1), got.RuleCount) + + // Deleting the group cascades to remaining rules. + _, err = m.DeleteFirewallRuleGroup(ctx, rg.ID) + require.NoError(t, err) + rules, err = m.ListFirewallRules(ctx, rg.ID) + require.NoError(t, err) + assert.Empty(t, rules) +} + +func TestFirewallAssociationsConfigAndPolicy(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + rg, err := m.CreateFirewallRuleGroup(ctx, "req", "rg", nil) + require.NoError(t, err) + + require.NoError(t, m.PutFirewallRuleGroupPolicy(ctx, rg.ARN, "pol")) + pol, err := m.GetFirewallRuleGroupPolicy(ctx, rg.ARN) + require.NoError(t, err) + assert.Equal(t, "pol", pol) + + // Associate against a missing group errors. + _, err = m.AssociateFirewallRuleGroup(ctx, &driver.AssociateFirewallRuleGroupInput{FirewallRuleGroupID: "missing"}) + assert.True(t, cerrors.IsNotFound(err)) + + a, err := m.AssociateFirewallRuleGroup(ctx, &driver.AssociateFirewallRuleGroupInput{ + FirewallRuleGroupID: rg.ID, Name: "assoc", Priority: 101, VPCID: "vpc-1", + }) + require.NoError(t, err) + assert.Equal(t, mutationProtectionDisabled, a.MutationProtection) // defaulted + + uAssoc, err := m.UpdateFirewallRuleGroupAssociation(ctx, &driver.UpdateFirewallRuleGroupAssociationInput{ + ID: a.ID, Name: ptr("assoc2"), Priority: ptr(int32(202)), MutationProtection: ptr("ENABLED"), + }) + require.NoError(t, err) + assert.Equal(t, "assoc2", uAssoc.Name) + assert.Equal(t, int32(202), uAssoc.Priority) + + assocs, err := m.ListFirewallRuleGroupAssociations(ctx) + require.NoError(t, err) + assert.Len(t, assocs, 1) + + _, err = m.DisassociateFirewallRuleGroup(ctx, a.ID) + require.NoError(t, err) + _, err = m.GetFirewallRuleGroupAssociation(ctx, a.ID) + assert.True(t, cerrors.IsNotFound(err)) + _, err = m.UpdateFirewallRuleGroupAssociation(ctx, &driver.UpdateFirewallRuleGroupAssociationInput{ID: "missing"}) + assert.True(t, cerrors.IsNotFound(err)) + + // Firewall config lazy default + update, and rule-type enumeration is empty. + fc, err := m.GetFirewallConfig(ctx, "vpc-1") + require.NoError(t, err) + assert.Equal(t, failOpenDisabled, fc.FirewallFailOpen) + ufc, err := m.UpdateFirewallConfig(ctx, "vpc-1", failOpenEnabled) + require.NoError(t, err) + assert.Equal(t, failOpenEnabled, ufc.FirewallFailOpen) + fcs, err := m.ListFirewallConfigs(ctx) + require.NoError(t, err) + assert.Len(t, fcs, 1) + + types, err := m.ListFirewallRuleTypes(ctx) + require.NoError(t, err) + assert.Empty(t, types) + + _, err = m.DeleteFirewallRuleGroup(ctx, "missing") + assert.True(t, cerrors.IsNotFound(err)) +} + +// ---- outpost resolvers ---- + +func TestOutpostResolverLifecycleAndErrors(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + o, err := m.CreateOutpostResolver(ctx, &driver.CreateOutpostResolverInput{ + Name: "op", OutpostARN: "arn:aws:outposts:::op/1", PreferredInstanceType: "m5.large", InstanceCount: 4, + }) + require.NoError(t, err) + assert.Contains(t, o.ID, "rslvr-op-") + + // Zero-value fields on update leave existing values unchanged. + upd, err := m.UpdateOutpostResolver(ctx, &driver.UpdateOutpostResolverInput{ID: o.ID, InstanceCount: ptr(int32(8))}) + require.NoError(t, err) + assert.Equal(t, int32(8), upd.InstanceCount) + assert.Equal(t, "op", upd.Name) + assert.Equal(t, "m5.large", upd.PreferredInstanceType) + + list, err := m.ListOutpostResolvers(ctx) + require.NoError(t, err) + assert.Len(t, list, 1) + + del, err := m.DeleteOutpostResolver(ctx, o.ID) + require.NoError(t, err) + assert.Equal(t, statusDeleting, del.Status) + + _, err = m.GetOutpostResolver(ctx, o.ID) + assert.True(t, cerrors.IsNotFound(err)) + _, err = m.UpdateOutpostResolver(ctx, &driver.UpdateOutpostResolverInput{ID: "missing"}) + assert.True(t, cerrors.IsNotFound(err)) + _, err = m.DeleteOutpostResolver(ctx, "missing") + assert.True(t, cerrors.IsNotFound(err)) +} + +// ---- tagging ---- + +func TestTaggingMergeAndUntag(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + // Tagging targets a real, live resource — a bogus ARN is rejected. + ep, err := m.CreateResolverEndpoint(ctx, &driver.CreateResolverEndpointInput{ + Name: "ep", Direction: directionInbound, + IPAddresses: []driver.IPAddress{{SubnetID: "s"}, {SubnetID: "s2"}}, + }) + require.NoError(t, err) + + arn := ep.ARN + + require.NoError(t, m.TagResource(ctx, arn, []driver.Tag{{Key: "a", Value: "1"}, {Key: "b", Value: "2"}})) + // Overlapping key overwrites by key. + require.NoError(t, m.TagResource(ctx, arn, []driver.Tag{{Key: "a", Value: "9"}})) + + tags, err := m.ListTagsForResource(ctx, arn) + require.NoError(t, err) + got := map[string]string{} + for _, tg := range tags { + got[tg.Key] = tg.Value + } + assert.Equal(t, map[string]string{"a": "9", "b": "2"}, got) + + require.NoError(t, m.UntagResource(ctx, arn, []string{"a"})) + tags, err = m.ListTagsForResource(ctx, arn) + require.NoError(t, err) + assert.Len(t, tags, 1) + assert.Equal(t, "b", tags[0].Key) + + // Tagging / listing an ARN that names no live resource is a NotFound. + assert.True(t, cerrors.IsNotFound(m.TagResource(ctx, "arn:none", nil))) + + _, err = m.ListTagsForResource(ctx, "arn:none") + assert.True(t, cerrors.IsNotFound(err)) +} + +// --- review-driven correctness behaviors --- + +func TestDeleteBlockedByDependents(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + // Endpoint referenced by a rule cannot be deleted. + ep, _ := m.CreateResolverEndpoint(ctx, &driver.CreateResolverEndpointInput{ + Direction: "OUTBOUND", IPAddresses: []driver.IPAddress{{SubnetID: "s"}, {SubnetID: "s2"}}, + }) + rule, _ := m.CreateResolverRule(ctx, &driver.CreateResolverRuleInput{ + Name: "r", RuleType: "FORWARD", DomainName: "x.com", ResolverEndpointID: ep.ID, + }) + _, err := m.DeleteResolverEndpoint(ctx, ep.ID) + assert.True(t, cerrors.IsFailedPrecondition(err)) + + // Rule with a VPC association cannot be deleted. + _, err = m.AssociateResolverRule(ctx, rule.ID, "vpc-1", "a") + require.NoError(t, err) + _, err = m.DeleteResolverRule(ctx, rule.ID) + assert.True(t, cerrors.IsFailedPrecondition(err)) + + // Domain list referenced by a firewall rule cannot be deleted. + rg, _ := m.CreateFirewallRuleGroup(ctx, "", "rg", nil) + dl, _ := m.CreateFirewallDomainList(ctx, "", "dl", nil) + _, err = m.CreateFirewallRule(ctx, &driver.FirewallRuleInput{ + FirewallRuleGroupID: rg.ID, FirewallDomainListID: dl.ID, Priority: 1, Action: "BLOCK", + }) + require.NoError(t, err) + _, err = m.DeleteFirewallDomainList(ctx, dl.ID) + assert.True(t, cerrors.IsFailedPrecondition(err)) + + // Rule group with a VPC association cannot be deleted. + _, err = m.AssociateFirewallRuleGroup(ctx, &driver.AssociateFirewallRuleGroupInput{ + FirewallRuleGroupID: rg.ID, VPCID: "vpc-1", + }) + require.NoError(t, err) + _, err = m.DeleteFirewallRuleGroup(ctx, rg.ID) + assert.True(t, cerrors.IsFailedPrecondition(err)) +} + +func TestFirewallRuleDuplicateAndAtomicBatch(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + rg, _ := m.CreateFirewallRuleGroup(ctx, "", "rg", nil) + + _, err := m.CreateFirewallRule(ctx, &driver.FirewallRuleInput{ + FirewallRuleGroupID: rg.ID, FirewallDomainListID: "dl-1", Priority: 1, Action: "BLOCK", + }) + require.NoError(t, err) + + // Duplicate (group, domain-list, qtype) is rejected. + _, err = m.CreateFirewallRule(ctx, &driver.FirewallRuleInput{ + FirewallRuleGroupID: rg.ID, FirewallDomainListID: "dl-1", Priority: 2, Action: "ALLOW", + }) + assert.True(t, cerrors.IsAlreadyExists(err)) + + // A batch containing an in-batch duplicate is rejected atomically — nothing + // from the batch is stored, so RuleCount stays at the single prior rule. + _, err = m.BatchCreateFirewallRules(ctx, []driver.FirewallRuleInput{ + {FirewallRuleGroupID: rg.ID, FirewallDomainListID: "dl-2", Priority: 3, Action: "BLOCK"}, + {FirewallRuleGroupID: rg.ID, FirewallDomainListID: "dl-2", Priority: 4, Action: "BLOCK"}, + }) + assert.True(t, cerrors.IsAlreadyExists(err)) + + rules, _ := m.ListFirewallRules(ctx, rg.ID) + assert.Len(t, rules, 1) + + got, _ := m.GetFirewallRuleGroup(ctx, rg.ID) + assert.Equal(t, int32(1), got.RuleCount) +} + +func TestAssociationDedupe(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + rule, _ := m.CreateResolverRule(ctx, &driver.CreateResolverRuleInput{Name: "r", RuleType: "FORWARD", DomainName: "x"}) + _, err := m.AssociateResolverRule(ctx, rule.ID, "vpc-1", "a") + require.NoError(t, err) + _, err = m.AssociateResolverRule(ctx, rule.ID, "vpc-1", "a") + assert.True(t, cerrors.IsAlreadyExists(err)) + + qlc, _ := m.CreateResolverQueryLogConfig(ctx, &driver.CreateQueryLogConfigInput{Name: "q"}) + _, err = m.AssociateResolverQueryLogConfig(ctx, qlc.ID, "vpc-1") + require.NoError(t, err) + _, err = m.AssociateResolverQueryLogConfig(ctx, qlc.ID, "vpc-1") + assert.True(t, cerrors.IsAlreadyExists(err)) + + rg, _ := m.CreateFirewallRuleGroup(ctx, "", "rg", nil) + _, err = m.AssociateFirewallRuleGroup(ctx, &driver.AssociateFirewallRuleGroupInput{FirewallRuleGroupID: rg.ID, VPCID: "vpc-1"}) + require.NoError(t, err) + _, err = m.AssociateFirewallRuleGroup(ctx, &driver.AssociateFirewallRuleGroupInput{FirewallRuleGroupID: rg.ID, VPCID: "vpc-1"}) + assert.True(t, cerrors.IsAlreadyExists(err)) +} + +func TestCreatorRequestIDIdempotency(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + in := &driver.CreateResolverEndpointInput{ + CreatorRequestID: "tok-1", Name: "ep", Direction: directionInbound, + IPAddresses: []driver.IPAddress{{SubnetID: "s"}, {SubnetID: "s2"}}, + } + first, _ := m.CreateResolverEndpoint(ctx, in) + second, _ := m.CreateResolverEndpoint(ctx, in) + assert.Equal(t, first.ID, second.ID) + + list, _ := m.ListResolverEndpoints(ctx) + assert.Len(t, list, 1) +} + +func TestDisassociateIPMinimumTwo(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + ep, _ := m.CreateResolverEndpoint(ctx, &driver.CreateResolverEndpointInput{ + Direction: directionInbound, IPAddresses: []driver.IPAddress{{SubnetID: "s1"}, {SubnetID: "s2"}}, + }) + + // At exactly two IPs, a disassociate is rejected. + _, err := m.DisassociateResolverEndpointIPAddress(ctx, ep.ID, &driver.IPAddress{SubnetID: "s1"}) + assert.True(t, cerrors.IsFailedPrecondition(err)) +} + +func TestPointerUpdateAppliesExplicitEmpty(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + ep, _ := m.CreateResolverEndpoint(ctx, &driver.CreateResolverEndpointInput{ + Name: "named", Direction: directionInbound, + IPAddresses: []driver.IPAddress{{SubnetID: "s"}, {SubnetID: "s2"}}, + }) + + // nil pointer leaves the name unchanged... + unchanged, _ := m.UpdateResolverEndpoint(ctx, ep.ID, driver.UpdateResolverEndpointInput{}) + assert.Equal(t, "named", unchanged.Name) + + // ...an explicit empty string clears it (distinct from "absent"). + cleared, _ := m.UpdateResolverEndpoint(ctx, ep.ID, driver.UpdateResolverEndpointInput{Name: ptr("")}) + assert.Equal(t, "", cleared.Name) +} diff --git a/providers/aws/route53resolver/rules.go b/providers/aws/route53resolver/rules.go new file mode 100644 index 00000000..835895f6 --- /dev/null +++ b/providers/aws/route53resolver/rules.go @@ -0,0 +1,245 @@ +package route53resolver + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +const ( + ruleStatusComplete = "COMPLETE" + ruleStatusDeleting = "DELETING" + shareStatusNotShared = "NOT_SHARED" + assocStatusComplete = "COMPLETE" + assocStatusDeleting = "DELETING" +) + +func ruleNotFound(id string) error { + return errors.Newf(errors.NotFound, "resolver rule %q not found", id) +} + +func (m *Mock) CreateResolverRule( + _ context.Context, in *driver.CreateResolverRuleInput, +) (*driver.ResolverRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if prior, ok := m.idempotentID("rule", in.CreatorRequestID); ok { + if r, found := m.rules.Get(prior); found { + out := cloneRule(r) + + return &out, nil + } + } + + id := idgen.GenerateID("rslvr-rr-") + now := m.now() + + r := &driver.ResolverRule{ + ID: id, + ARN: m.arn("resolver-rule/" + id), + CreatorRequestID: in.CreatorRequestID, + DomainName: in.DomainName, + Name: in.Name, + OwnerID: m.opts.AccountID, + ResolverEndpointID: in.ResolverEndpointID, + RuleType: in.RuleType, + ShareStatus: shareStatusNotShared, + Status: ruleStatusComplete, + StatusMessage: "Successfully created Resolver Rule.", + TargetIPs: append([]driver.TargetAddress(nil), in.TargetIPs...), + CreatedAt: now, + ModifiedAt: now, + } + m.rules.Set(id, r) + m.rememberIdempotent("rule", in.CreatorRequestID, id) + + if len(in.Tags) > 0 { + m.tags.Set(r.ARN, copyTags(in.Tags)) + } + + out := cloneRule(r) + + return &out, nil +} + +func (m *Mock) GetResolverRule(_ context.Context, id string) (*driver.ResolverRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + r, ok := m.rules.Get(id) + if !ok { + return nil, ruleNotFound(id) + } + + out := cloneRule(r) + + return &out, nil +} + +func (m *Mock) UpdateResolverRule( + _ context.Context, id string, in driver.UpdateResolverRuleInput, +) (*driver.ResolverRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + r, ok := m.rules.Get(id) + if !ok { + return nil, ruleNotFound(id) + } + + updated := cloneRule(r) + if in.Name != nil { + updated.Name = *in.Name + } + + if in.ResolverEndpointID != nil { + updated.ResolverEndpointID = *in.ResolverEndpointID + } + + if in.TargetIPs != nil { + updated.TargetIPs = append([]driver.TargetAddress(nil), in.TargetIPs...) + } + + updated.ModifiedAt = m.now() + m.rules.Set(id, &updated) + + out := cloneRule(&updated) + + return &out, nil +} + +func (m *Mock) DeleteResolverRule(_ context.Context, id string) (*driver.ResolverRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + r, ok := m.rules.Get(id) + if !ok { + return nil, ruleNotFound(id) + } + + for _, a := range m.ruleAssocs.All() { + if a.ResolverRuleID == id { + return nil, errors.Newf(errors.FailedPrecondition, + "resolver rule %q still has VPC associations", id) + } + } + + m.rules.Delete(id) + m.rulePolicies.Delete(r.ARN) + m.tags.Delete(r.ARN) + + out := cloneRule(r) + out.Status = ruleStatusDeleting + out.ModifiedAt = m.now() + + return &out, nil +} + +func (m *Mock) ListResolverRules(_ context.Context) ([]driver.ResolverRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.rules.All(), cloneRule), nil +} + +func (m *Mock) AssociateResolverRule( + _ context.Context, ruleID, vpcID, name string, +) (*driver.ResolverRuleAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.rules.Has(ruleID) { + return nil, ruleNotFound(ruleID) + } + + for _, a := range m.ruleAssocs.All() { + if a.ResolverRuleID == ruleID && a.VPCID == vpcID { + return nil, errors.Newf(errors.AlreadyExists, + "resolver rule %q is already associated with vpc %q", ruleID, vpcID) + } + } + + id := idgen.GenerateID("rslvr-rrassoc-") + a := &driver.ResolverRuleAssociation{ + ID: id, + Name: name, + ResolverRuleID: ruleID, + VPCID: vpcID, + Status: assocStatusComplete, + } + m.ruleAssocs.Set(id, a) + + out := *a + + return &out, nil +} + +func (m *Mock) DisassociateResolverRule( + _ context.Context, ruleID, vpcID string, +) (*driver.ResolverRuleAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + var found *driver.ResolverRuleAssociation + + for _, a := range m.ruleAssocs.All() { + if a.ResolverRuleID == ruleID && a.VPCID == vpcID { + found = a + + break + } + } + + if found == nil { + return nil, errors.Newf(errors.NotFound, "no association between rule %q and vpc %q", ruleID, vpcID) + } + + m.ruleAssocs.Delete(found.ID) + + out := *found + out.Status = assocStatusDeleting + + return &out, nil +} + +func (m *Mock) GetResolverRuleAssociation(_ context.Context, assocID string) (*driver.ResolverRuleAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.ruleAssocs.Get(assocID) + if !ok { + return nil, errors.Newf(errors.NotFound, "resolver rule association %q not found", assocID) + } + + out := *a + + return &out, nil +} + +func (m *Mock) ListResolverRuleAssociations(_ context.Context) ([]driver.ResolverRuleAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.ruleAssocs.All(), cloneAssoc), nil +} + +func (m *Mock) PutResolverRulePolicy(_ context.Context, arn, policy string) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.rulePolicies.Set(arn, policy) + + return nil +} + +func (m *Mock) GetResolverRulePolicy(_ context.Context, arn string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + policy, _ := m.rulePolicies.Get(arn) + + return policy, nil +} diff --git a/providers/aws/route53resolver/tags.go b/providers/aws/route53resolver/tags.go new file mode 100644 index 00000000..66f2029f --- /dev/null +++ b/providers/aws/route53resolver/tags.go @@ -0,0 +1,95 @@ +package route53resolver + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +func tagResourceNotFound(arn string) error { + return errors.Newf(errors.NotFound, "resource %q not found", arn) +} + +func (m *Mock) TagResource(_ context.Context, arn string, tags []driver.Tag) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.arnExists(arn) { + return tagResourceNotFound(arn) + } + + existing, _ := m.tags.Get(arn) + m.tags.Set(arn, mergeTags(existing, tags)) + + return nil +} + +func (m *Mock) UntagResource(_ context.Context, arn string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.arnExists(arn) { + return tagResourceNotFound(arn) + } + + existing, ok := m.tags.Get(arn) + if !ok { + return nil + } + + remove := make(map[string]bool, len(keys)) + for _, k := range keys { + remove[k] = true + } + + kept := make([]driver.Tag, 0, len(existing)) + + for _, t := range existing { + if !remove[t.Key] { + kept = append(kept, t) + } + } + + m.tags.Set(arn, kept) + + return nil +} + +func (m *Mock) ListTagsForResource(_ context.Context, arn string) ([]driver.Tag, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.arnExists(arn) { + return nil, tagResourceNotFound(arn) + } + + existing, _ := m.tags.Get(arn) + + return copyTags(existing), nil +} + +// mergeTags overlays incoming tags onto existing ones, overwriting by key and +// preserving first-seen order (AWS tag semantics). +func mergeTags(existing, incoming []driver.Tag) []driver.Tag { + idx := make(map[string]int, len(existing)) + merged := make([]driver.Tag, 0, len(existing)+len(incoming)) + + for _, t := range existing { + idx[t.Key] = len(merged) + merged = append(merged, t) + } + + for _, t := range incoming { + if i, ok := idx[t.Key]; ok { + merged[i].Value = t.Value + + continue + } + + idx[t.Key] = len(merged) + merged = append(merged, t) + } + + return merged +} diff --git a/server/aws/aws.go b/server/aws/aws.go index 3b0e6f24..56dc1fde 100644 --- a/server/aws/aws.go +++ b/server/aws/aws.go @@ -33,6 +33,7 @@ import ( "github.com/stackshy/cloudemu/v2/server/aws/resourceexplorer2" "github.com/stackshy/cloudemu/v2/server/aws/resourcegroupstaggingapi" "github.com/stackshy/cloudemu/v2/server/aws/route53" + route53resolversrv "github.com/stackshy/cloudemu/v2/server/aws/route53resolver" "github.com/stackshy/cloudemu/v2/server/aws/s3" sagemakersrv "github.com/stackshy/cloudemu/v2/server/aws/sagemaker" secretsmanagersrv "github.com/stackshy/cloudemu/v2/server/aws/secretsmanager" @@ -64,6 +65,7 @@ import ( ssmdriver "github.com/stackshy/cloudemu/v2/services/parameterstore/driver" rdbdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" "github.com/stackshy/cloudemu/v2/services/resourcediscovery" + route53resolverdriver "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" sagemakerdriver "github.com/stackshy/cloudemu/v2/services/sagemaker/driver" secretsdriver "github.com/stackshy/cloudemu/v2/services/secrets/driver" sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" @@ -93,6 +95,10 @@ type Drivers struct { // ECS serves the Amazon ECS JSON 1.1 protocol (X-Amz-Target prefix // AmazonEC2ContainerServiceV20141113.) against the ecs driver. ECS ecsdriver.ECS + + // Route53Resolver serves the AWS Route 53 Resolver JSON 1.1 protocol + // (X-Amz-Target prefix "Route53Resolver.") against the route53resolver driver. + Route53Resolver route53resolverdriver.Route53Resolver // SecretsManager serves the Secrets Manager JSON 1.1 protocol against // the secrets driver. SecretsManager secretsdriver.Secrets @@ -167,6 +173,7 @@ func DriversFrom(p *awsprovider.Provider) Drivers { BedrockAgentRuntime: p.BedrockAgentRuntime, SageMaker: p.SageMaker, ECS: p.ECS, + Route53Resolver: p.Route53Resolver, SecretsManager: p.SecretsManager, SSM: p.SSM, CloudWatchLogs: p.CloudWatchLogs, @@ -273,6 +280,12 @@ func New(d Drivers) *server.Server { srv.Register(ecssrv.New(d.ECS)) } + // Route53Resolver matches the X-Amz-Target prefix "Route53Resolver." — + // disjoint from the other JSON 1.1 services, so registration order is free. + if d.Route53Resolver != nil { + srv.Register(route53resolversrv.New(d.Route53Resolver)) + } + // SSM Parameter Store matches the X-Amz-Target prefix "AmazonSSM." — // disjoint from DynamoDB, SQS, ECR, SageMaker, Secrets Manager, EventBridge, // CloudWatch Logs, and the tagging API. diff --git a/server/aws/route53resolver/configs.go b/server/aws/route53resolver/configs.go new file mode 100644 index 00000000..3abd7ee3 --- /dev/null +++ b/server/aws/route53resolver/configs.go @@ -0,0 +1,156 @@ +package route53resolver + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +// --- wire shapes --- + +type wireResolverConfig struct { + ID string `json:"Id,omitempty"` + OwnerID string `json:"OwnerId,omitempty"` + ResourceID string `json:"ResourceId,omitempty"` + AutodefinedReverse string `json:"AutodefinedReverse,omitempty"` +} + +type wireDnssecConfig struct { + ID string `json:"Id,omitempty"` + OwnerID string `json:"OwnerId,omitempty"` + ResourceID string `json:"ResourceId,omitempty"` + ValidationStatus string `json:"ValidationStatus,omitempty"` +} + +// --- mapping --- + +func resolverConfigToWire(c *driver.ResolverConfig) wireResolverConfig { + return wireResolverConfig{ + ID: c.ID, + OwnerID: c.OwnerID, + ResourceID: c.ResourceID, + AutodefinedReverse: c.AutodefinedReverse, + } +} + +func dnssecConfigToWire(c *driver.ResolverDnssecConfig) wireDnssecConfig { + return wireDnssecConfig{ + ID: c.ID, + OwnerID: c.OwnerID, + ResourceID: c.ResourceID, + ValidationStatus: c.ValidationStatus, + } +} + +// --- handlers --- + +func (h *Handler) getResolverConfig(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceID string `json:"ResourceId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + c, err := h.r53r.GetResolverConfig(r.Context(), req.ResourceID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverConfig": resolverConfigToWire(c)}) +} + +func (h *Handler) updateResolverConfig(w http.ResponseWriter, r *http.Request) { + var req struct { + AutodefinedReverseFlag string `json:"AutodefinedReverseFlag"` + ResourceID string `json:"ResourceId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + c, err := h.r53r.UpdateResolverConfig(r.Context(), req.ResourceID, req.AutodefinedReverseFlag) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverConfig": resolverConfigToWire(c)}) +} + +func (h *Handler) listResolverConfigs(w http.ResponseWriter, r *http.Request) { + cs, err := h.r53r.ListResolverConfigs(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]wireResolverConfig, 0, len(cs)) + for i := range cs { + out = append(out, resolverConfigToWire(&cs[i])) + } + + wire.WriteJSON(w, map[string]any{"ResolverConfigs": out}) +} + +func (h *Handler) getResolverDnssecConfig(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceID string `json:"ResourceId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + c, err := h.r53r.GetResolverDnssecConfig(r.Context(), req.ResourceID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverDNSSECConfig": dnssecConfigToWire(c)}) +} + +func (h *Handler) updateResolverDnssecConfig(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceID string `json:"ResourceId"` + Validation string `json:"Validation"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + c, err := h.r53r.UpdateResolverDnssecConfig(r.Context(), req.ResourceID, req.Validation) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverDNSSECConfig": dnssecConfigToWire(c)}) +} + +func (h *Handler) listResolverDnssecConfigs(w http.ResponseWriter, r *http.Request) { + cs, err := h.r53r.ListResolverDnssecConfigs(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]wireDnssecConfig, 0, len(cs)) + for i := range cs { + out = append(out, dnssecConfigToWire(&cs[i])) + } + + wire.WriteJSON(w, map[string]any{"ResolverDnssecConfigs": out}) +} diff --git a/server/aws/route53resolver/endpoints.go b/server/aws/route53resolver/endpoints.go new file mode 100644 index 00000000..856df96b --- /dev/null +++ b/server/aws/route53resolver/endpoints.go @@ -0,0 +1,199 @@ +package route53resolver + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +func (h *Handler) createResolverEndpoint(w http.ResponseWriter, r *http.Request) { + var req struct { + CreatorRequestID string `json:"CreatorRequestId"` + Name string `json:"Name"` + Direction string `json:"Direction"` + IPAddresses []wireIPAddressRequest `json:"IpAddresses"` + SecurityGroupIDs []string `json:"SecurityGroupIds"` + ResolverEndpointType string `json:"ResolverEndpointType"` + Protocols []string `json:"Protocols"` + OutpostArn string `json:"OutpostArn"` + PreferredInstanceType string `json:"PreferredInstanceType"` + DNS64Enabled bool `json:"Dns64Enabled"` + IPv6InternetAccessEnabled bool `json:"Ipv6InternetAccessEnabled"` + Tags []wireTag `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + ep, err := h.r53r.CreateResolverEndpoint(r.Context(), &driver.CreateResolverEndpointInput{ + CreatorRequestID: req.CreatorRequestID, + Name: req.Name, + Direction: req.Direction, + IPAddresses: toDriverIPAddresses(req.IPAddresses), + SecurityGroupIDs: req.SecurityGroupIDs, + ResolverEndpointType: req.ResolverEndpointType, + Protocols: req.Protocols, + OutpostARN: req.OutpostArn, + PreferredInstanceType: req.PreferredInstanceType, + DNS64Enabled: req.DNS64Enabled, + IPv6InternetAccessEnabled: req.IPv6InternetAccessEnabled, + Tags: toDriverTags(req.Tags), + }) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverEndpoint": endpointToWire(ep)}) +} + +func (h *Handler) getResolverEndpoint(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverEndpointID string `json:"ResolverEndpointId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + ep, err := h.r53r.GetResolverEndpoint(r.Context(), req.ResolverEndpointID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverEndpoint": endpointToWire(ep)}) +} + +func (h *Handler) updateResolverEndpoint(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverEndpointID string `json:"ResolverEndpointId"` + Name *string `json:"Name"` + ResolverEndpointType *string `json:"ResolverEndpointType"` + Protocols []string `json:"Protocols"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + ep, err := h.r53r.UpdateResolverEndpoint(r.Context(), req.ResolverEndpointID, driver.UpdateResolverEndpointInput{ + Name: req.Name, + ResolverEndpointType: req.ResolverEndpointType, + Protocols: req.Protocols, + }) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverEndpoint": endpointToWire(ep)}) +} + +func (h *Handler) deleteResolverEndpoint(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverEndpointID string `json:"ResolverEndpointId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + ep, err := h.r53r.DeleteResolverEndpoint(r.Context(), req.ResolverEndpointID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverEndpoint": endpointToWire(ep)}) +} + +func (h *Handler) listResolverEndpoints(w http.ResponseWriter, r *http.Request) { + eps, err := h.r53r.ListResolverEndpoints(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]wireResolverEndpoint, 0, len(eps)) + for i := range eps { + out = append(out, endpointToWire(&eps[i])) + } + + wire.WriteJSON(w, map[string]any{"ResolverEndpoints": out}) +} + +func (h *Handler) associateResolverEndpointIP(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverEndpointID string `json:"ResolverEndpointId"` + IPAddress wireIPAddressUpdate `json:"IpAddress"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + ep, err := h.r53r.AssociateResolverEndpointIPAddress(r.Context(), req.ResolverEndpointID, &driver.IPAddress{ + SubnetID: req.IPAddress.SubnetID, + IP: req.IPAddress.IP, + IPv6: req.IPAddress.IPv6, + }) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverEndpoint": endpointToWire(ep)}) +} + +func (h *Handler) disassociateResolverEndpointIP(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverEndpointID string `json:"ResolverEndpointId"` + IPAddress wireIPAddressUpdate `json:"IpAddress"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + ep, err := h.r53r.DisassociateResolverEndpointIPAddress(r.Context(), req.ResolverEndpointID, &driver.IPAddress{ + IPID: req.IPAddress.IPID, + SubnetID: req.IPAddress.SubnetID, + IP: req.IPAddress.IP, + IPv6: req.IPAddress.IPv6, + }) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverEndpoint": endpointToWire(ep)}) +} + +func (h *Handler) listResolverEndpointIPs(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverEndpointID string `json:"ResolverEndpointId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + ips, err := h.r53r.ListResolverEndpointIPAddresses(r.Context(), req.ResolverEndpointID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"IpAddresses": ipsToWire(ips)}) +} diff --git a/server/aws/route53resolver/errors.go b/server/aws/route53resolver/errors.go new file mode 100644 index 00000000..2f36e185 --- /dev/null +++ b/server/aws/route53resolver/errors.go @@ -0,0 +1,45 @@ +package route53resolver + +import ( + stderrors "errors" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// writeErr maps a canonical cloudemu error to the closest Route 53 Resolver +// exception. The service uses AWS JSON 1.1, so the SDK keys off the "__type" +// body written by wire.WriteJSONError to select the typed error. +func writeErr(w http.ResponseWriter, err error) { + msg := wireMessage(err) + + switch { + case cerrors.IsNotFound(err): + wire.WriteJSONError(w, http.StatusBadRequest, "ResourceNotFoundException", msg) + case cerrors.IsAlreadyExists(err): + wire.WriteJSONError(w, http.StatusBadRequest, "ResourceExistsException", msg) + case cerrors.IsInvalidArgument(err): + wire.WriteJSONError(w, http.StatusBadRequest, "InvalidParameterException", msg) + case cerrors.IsFailedPrecondition(err): + wire.WriteJSONError(w, http.StatusBadRequest, "InvalidRequestException", msg) + case cerrors.IsThrottled(err): + wire.WriteJSONError(w, http.StatusBadRequest, "ThrottlingException", msg) + case cerrors.IsPermissionDenied(err): + wire.WriteJSONError(w, http.StatusBadRequest, "AccessDeniedException", msg) + default: + wire.WriteJSONError(w, http.StatusInternalServerError, "InternalServiceErrorException", msg) + } +} + +// wireMessage returns the bare error message, stripping the internal +// ": " prefix that cerrors.Error.Error() adds, so the SDK-surfaced +// message reads like real AWS. +func wireMessage(err error) string { + var ce *cerrors.Error + if stderrors.As(err, &ce) { + return ce.Message + } + + return err.Error() +} diff --git a/server/aws/route53resolver/firewall.go b/server/aws/route53resolver/firewall.go new file mode 100644 index 00000000..0d04832f --- /dev/null +++ b/server/aws/route53resolver/firewall.go @@ -0,0 +1,778 @@ +package route53resolver + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +// --- wire shapes --- + +type wireFWDomainList struct { + ID string `json:"Id,omitempty"` + Arn string `json:"Arn,omitempty"` + Name string `json:"Name,omitempty"` + CreatorRequestID string `json:"CreatorRequestId,omitempty"` + Category string `json:"Category,omitempty"` + ManagedOwnerName string `json:"ManagedOwnerName,omitempty"` + DomainCount int32 `json:"DomainCount"` + Status string `json:"Status,omitempty"` + StatusMessage string `json:"StatusMessage,omitempty"` + CreationTime string `json:"CreationTime,omitempty"` + ModificationTime string `json:"ModificationTime,omitempty"` +} + +type wireFWRule struct { + FirewallRuleGroupID string `json:"FirewallRuleGroupId,omitempty"` + FirewallDomainListID string `json:"FirewallDomainListId,omitempty"` + Name string `json:"Name,omitempty"` + Priority int32 `json:"Priority"` + Action string `json:"Action,omitempty"` + BlockResponse string `json:"BlockResponse,omitempty"` + BlockOverrideDomain string `json:"BlockOverrideDomain,omitempty"` + BlockOverrideDNSType string `json:"BlockOverrideDnsType,omitempty"` + BlockOverrideTTL int32 `json:"BlockOverrideTtl,omitempty"` + Qtype string `json:"Qtype,omitempty"` + ConfidenceThreshold string `json:"ConfidenceThreshold,omitempty"` + DNSThreatProtection string `json:"DnsThreatProtection,omitempty"` + FirewallDomainRedirectionAction string `json:"FirewallDomainRedirectionAction,omitempty"` + CreatorRequestID string `json:"CreatorRequestId,omitempty"` + Status string `json:"Status,omitempty"` + StatusMessage string `json:"StatusMessage,omitempty"` + CreationTime string `json:"CreationTime,omitempty"` + ModificationTime string `json:"ModificationTime,omitempty"` +} + +type wireFWRuleGroup struct { + ID string `json:"Id,omitempty"` + Arn string `json:"Arn,omitempty"` + Name string `json:"Name,omitempty"` + CreatorRequestID string `json:"CreatorRequestId,omitempty"` + OwnerID string `json:"OwnerId,omitempty"` + RuleCount int32 `json:"RuleCount"` + ShareStatus string `json:"ShareStatus,omitempty"` + Status string `json:"Status,omitempty"` + StatusMessage string `json:"StatusMessage,omitempty"` + CreationTime string `json:"CreationTime,omitempty"` + ModificationTime string `json:"ModificationTime,omitempty"` +} + +type wireFWAssoc struct { + ID string `json:"Id,omitempty"` + Arn string `json:"Arn,omitempty"` + Name string `json:"Name,omitempty"` + CreatorRequestID string `json:"CreatorRequestId,omitempty"` + FirewallRuleGroupID string `json:"FirewallRuleGroupId,omitempty"` + VPCID string `json:"VpcId,omitempty"` + Priority int32 `json:"Priority"` + MutationProtection string `json:"MutationProtection,omitempty"` + ManagedOwnerName string `json:"ManagedOwnerName,omitempty"` + Status string `json:"Status,omitempty"` + StatusMessage string `json:"StatusMessage,omitempty"` + CreationTime string `json:"CreationTime,omitempty"` + ModificationTime string `json:"ModificationTime,omitempty"` +} + +type wireFWConfig struct { + ID string `json:"Id,omitempty"` + OwnerID string `json:"OwnerId,omitempty"` + ResourceID string `json:"ResourceId,omitempty"` + FirewallFailOpen string `json:"FirewallFailOpen,omitempty"` +} + +// --- mapping --- + +func fwDomainListToWire(d *driver.FirewallDomainList) wireFWDomainList { + return wireFWDomainList{ + ID: d.ID, Arn: d.ARN, Name: d.Name, CreatorRequestID: d.CreatorRequestID, + Category: d.Category, ManagedOwnerName: d.ManagedOwnerName, DomainCount: d.DomainCount, + Status: d.Status, StatusMessage: d.StatusMessage, + CreationTime: d.CreatedAt, ModificationTime: d.ModifiedAt, + } +} + +func fwRuleToWire(r *driver.FirewallRule) wireFWRule { + w := wireFWRule{} + w.FirewallRuleGroupID = r.FirewallRuleGroupID + w.FirewallDomainListID = r.FirewallDomainListID + w.Name, w.Priority, w.Action = r.Name, r.Priority, r.Action + w.BlockResponse = r.BlockResponse + w.BlockOverrideDomain = r.BlockOverrideDomain + w.BlockOverrideDNSType = r.BlockOverrideDNSType + w.BlockOverrideTTL, w.Qtype = r.BlockOverrideTTL, r.Qtype + w.ConfidenceThreshold = r.ConfidenceThreshold + w.DNSThreatProtection = r.DNSThreatProtection + w.FirewallDomainRedirectionAction = r.FirewallDomainRedirectionAction + w.CreatorRequestID = r.CreatorRequestID + w.Status, w.StatusMessage = r.Status, r.StatusMessage + w.CreationTime, w.ModificationTime = r.CreatedAt, r.ModifiedAt + + return w +} + +func fwRuleGroupToWire(g *driver.FirewallRuleGroup) wireFWRuleGroup { + return wireFWRuleGroup{ + ID: g.ID, Arn: g.ARN, Name: g.Name, CreatorRequestID: g.CreatorRequestID, OwnerID: g.OwnerID, + RuleCount: g.RuleCount, ShareStatus: g.ShareStatus, Status: g.Status, StatusMessage: g.StatusMessage, + CreationTime: g.CreatedAt, ModificationTime: g.ModifiedAt, + } +} + +func fwAssocToWire(a *driver.FirewallRuleGroupAssociation) wireFWAssoc { + return wireFWAssoc{ + ID: a.ID, Arn: a.ARN, Name: a.Name, CreatorRequestID: a.CreatorRequestID, + FirewallRuleGroupID: a.FirewallRuleGroupID, VPCID: a.VPCID, Priority: a.Priority, + MutationProtection: a.MutationProtection, ManagedOwnerName: a.ManagedOwnerName, + Status: a.Status, StatusMessage: a.StatusMessage, + CreationTime: a.CreatedAt, ModificationTime: a.ModifiedAt, + } +} + +func fwConfigToWire(c *driver.FirewallConfig) wireFWConfig { + return wireFWConfig{ID: c.ID, OwnerID: c.OwnerID, ResourceID: c.ResourceID, FirewallFailOpen: c.FirewallFailOpen} +} + +// wireFWRuleEntry is the create/update entry shape shared by single and batch ops. +type wireFWRuleEntry struct { + FirewallRuleGroupID string `json:"FirewallRuleGroupId"` + FirewallDomainListID string `json:"FirewallDomainListId"` + Name string `json:"Name"` + Priority int32 `json:"Priority"` + Action string `json:"Action"` + BlockResponse string `json:"BlockResponse"` + BlockOverrideDomain string `json:"BlockOverrideDomain"` + BlockOverrideDNSType string `json:"BlockOverrideDnsType"` + BlockOverrideTTL int32 `json:"BlockOverrideTtl"` + Qtype string `json:"Qtype"` + ConfidenceThreshold string `json:"ConfidenceThreshold"` + DNSThreatProtection string `json:"DnsThreatProtection"` + FirewallDomainRedirectionAction string `json:"FirewallDomainRedirectionAction"` + CreatorRequestID string `json:"CreatorRequestId"` +} + +func (e *wireFWRuleEntry) toInput() driver.FirewallRuleInput { + return driver.FirewallRuleInput{ + FirewallRuleGroupID: e.FirewallRuleGroupID, FirewallDomainListID: e.FirewallDomainListID, + Name: e.Name, Priority: e.Priority, Action: e.Action, BlockResponse: e.BlockResponse, + BlockOverrideDomain: e.BlockOverrideDomain, BlockOverrideDNSType: e.BlockOverrideDNSType, + BlockOverrideTTL: e.BlockOverrideTTL, Qtype: e.Qtype, ConfidenceThreshold: e.ConfidenceThreshold, + DNSThreatProtection: e.DNSThreatProtection, FirewallDomainRedirectionAction: e.FirewallDomainRedirectionAction, + CreatorRequestID: e.CreatorRequestID, + } +} + +// --- domain-list handlers --- + +func (h *Handler) createFirewallDomainList(w http.ResponseWriter, r *http.Request) { + var req struct { + CreatorRequestID string `json:"CreatorRequestId"` + Name string `json:"Name"` + Tags []wireTag `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + d, err := h.r53r.CreateFirewallDomainList(r.Context(), req.CreatorRequestID, req.Name, toDriverTags(req.Tags)) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallDomainList": fwDomainListToWire(d)}) +} + +func (h *Handler) getFirewallDomainList(w http.ResponseWriter, r *http.Request) { + var req struct { + FirewallDomainListID string `json:"FirewallDomainListId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + d, err := h.r53r.GetFirewallDomainList(r.Context(), req.FirewallDomainListID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallDomainList": fwDomainListToWire(d)}) +} + +func (h *Handler) deleteFirewallDomainList(w http.ResponseWriter, r *http.Request) { + var req struct { + FirewallDomainListID string `json:"FirewallDomainListId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + d, err := h.r53r.DeleteFirewallDomainList(r.Context(), req.FirewallDomainListID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallDomainList": fwDomainListToWire(d)}) +} + +func (h *Handler) listFirewallDomainLists(w http.ResponseWriter, r *http.Request) { + ds, err := h.r53r.ListFirewallDomainLists(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]wireFWDomainList, 0, len(ds)) + for i := range ds { + out = append(out, fwDomainListToWire(&ds[i])) + } + + wire.WriteJSON(w, map[string]any{"FirewallDomainLists": out}) +} + +func (h *Handler) updateFirewallDomains(w http.ResponseWriter, r *http.Request) { + var req struct { + FirewallDomainListID string `json:"FirewallDomainListId"` + Operation string `json:"Operation"` + Domains []string `json:"Domains"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + d, err := h.r53r.UpdateFirewallDomains(r.Context(), req.FirewallDomainListID, req.Operation, req.Domains) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{ + "Id": d.ID, "Name": d.Name, "Status": d.Status, "StatusMessage": d.StatusMessage, + }) +} + +func (h *Handler) importFirewallDomains(w http.ResponseWriter, r *http.Request) { + var req struct { + FirewallDomainListID string `json:"FirewallDomainListId"` + Operation string `json:"Operation"` + DomainFileURL string `json:"DomainFileUrl"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + d, err := h.r53r.ImportFirewallDomains(r.Context(), req.FirewallDomainListID, req.Operation, req.DomainFileURL) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{ + "Id": d.ID, "Name": d.Name, "Status": d.Status, "StatusMessage": d.StatusMessage, + }) +} + +func (h *Handler) listFirewallDomains(w http.ResponseWriter, r *http.Request) { + var req struct { + FirewallDomainListID string `json:"FirewallDomainListId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + domains, err := h.r53r.ListFirewallDomains(r.Context(), req.FirewallDomainListID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"Domains": domains}) +} + +// --- rule handlers --- + +func (h *Handler) createFirewallRule(w http.ResponseWriter, r *http.Request) { + var req wireFWRuleEntry + if !wire.DecodeJSON(w, r, &req) { + return + } + + in := req.toInput() + + rule, err := h.r53r.CreateFirewallRule(r.Context(), &in) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallRule": fwRuleToWire(rule)}) +} + +func (h *Handler) updateFirewallRule(w http.ResponseWriter, r *http.Request) { + var req wireFWRuleEntry + if !wire.DecodeJSON(w, r, &req) { + return + } + + in := req.toInput() + + rule, err := h.r53r.UpdateFirewallRule(r.Context(), &in) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallRule": fwRuleToWire(rule)}) +} + +func (h *Handler) deleteFirewallRule(w http.ResponseWriter, r *http.Request) { + var req struct { + FirewallRuleGroupID string `json:"FirewallRuleGroupId"` + FirewallDomainListID string `json:"FirewallDomainListId"` + Qtype string `json:"Qtype"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + rule, err := h.r53r.DeleteFirewallRule(r.Context(), req.FirewallRuleGroupID, req.FirewallDomainListID, req.Qtype) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallRule": fwRuleToWire(rule)}) +} + +func (h *Handler) listFirewallRules(w http.ResponseWriter, r *http.Request) { + var req struct { + FirewallRuleGroupID string `json:"FirewallRuleGroupId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + rules, err := h.r53r.ListFirewallRules(r.Context(), req.FirewallRuleGroupID) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]wireFWRule, 0, len(rules)) + for i := range rules { + out = append(out, fwRuleToWire(&rules[i])) + } + + wire.WriteJSON(w, map[string]any{"FirewallRules": out}) +} + +func (h *Handler) batchCreateFirewallRule(w http.ResponseWriter, r *http.Request) { + var req struct { + CreateFirewallRuleEntries []wireFWRuleEntry `json:"CreateFirewallRuleEntries"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + rules, err := h.r53r.BatchCreateFirewallRules(r.Context(), entriesToInputs(req.CreateFirewallRuleEntries)) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"CreatedFirewallRules": rulesToWire(rules)}) +} + +func (h *Handler) batchUpdateFirewallRule(w http.ResponseWriter, r *http.Request) { + var req struct { + UpdateFirewallRuleEntries []wireFWRuleEntry `json:"UpdateFirewallRuleEntries"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + rules, err := h.r53r.BatchUpdateFirewallRules(r.Context(), entriesToInputs(req.UpdateFirewallRuleEntries)) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"UpdatedFirewallRules": rulesToWire(rules)}) +} + +func (h *Handler) batchDeleteFirewallRule(w http.ResponseWriter, r *http.Request) { + var req struct { + DeleteFirewallRuleEntries []struct { + FirewallRuleGroupID string `json:"FirewallRuleGroupId"` + FirewallDomainListID string `json:"FirewallDomainListId"` + Qtype string `json:"Qtype"` + } `json:"DeleteFirewallRuleEntries"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if len(req.DeleteFirewallRuleEntries) == 0 { + wire.WriteJSON(w, map[string]any{"DeletedFirewallRules": []wireFWRule{}}) + + return + } + + groupID := req.DeleteFirewallRuleEntries[0].FirewallRuleGroupID + keys := make([]driver.FirewallRuleKey, 0, len(req.DeleteFirewallRuleEntries)) + + for _, e := range req.DeleteFirewallRuleEntries { + keys = append(keys, driver.FirewallRuleKey{FirewallDomainListID: e.FirewallDomainListID, Qtype: e.Qtype}) + } + + rules, err := h.r53r.BatchDeleteFirewallRules(r.Context(), groupID, keys) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"DeletedFirewallRules": rulesToWire(rules)}) +} + +func entriesToInputs(entries []wireFWRuleEntry) []driver.FirewallRuleInput { + out := make([]driver.FirewallRuleInput, 0, len(entries)) + for i := range entries { + out = append(out, entries[i].toInput()) + } + + return out +} + +func rulesToWire(rules []driver.FirewallRule) []wireFWRule { + out := make([]wireFWRule, 0, len(rules)) + for i := range rules { + out = append(out, fwRuleToWire(&rules[i])) + } + + return out +} + +// --- rule-group handlers --- + +func (h *Handler) createFirewallRuleGroup(w http.ResponseWriter, r *http.Request) { + var req struct { + CreatorRequestID string `json:"CreatorRequestId"` + Name string `json:"Name"` + Tags []wireTag `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + g, err := h.r53r.CreateFirewallRuleGroup(r.Context(), req.CreatorRequestID, req.Name, toDriverTags(req.Tags)) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallRuleGroup": fwRuleGroupToWire(g)}) +} + +func (h *Handler) getFirewallRuleGroup(w http.ResponseWriter, r *http.Request) { + var req struct { + FirewallRuleGroupID string `json:"FirewallRuleGroupId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + g, err := h.r53r.GetFirewallRuleGroup(r.Context(), req.FirewallRuleGroupID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallRuleGroup": fwRuleGroupToWire(g)}) +} + +func (h *Handler) deleteFirewallRuleGroup(w http.ResponseWriter, r *http.Request) { + var req struct { + FirewallRuleGroupID string `json:"FirewallRuleGroupId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + g, err := h.r53r.DeleteFirewallRuleGroup(r.Context(), req.FirewallRuleGroupID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallRuleGroup": fwRuleGroupToWire(g)}) +} + +func (h *Handler) listFirewallRuleGroups(w http.ResponseWriter, r *http.Request) { + gs, err := h.r53r.ListFirewallRuleGroups(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]wireFWRuleGroup, 0, len(gs)) + for i := range gs { + out = append(out, fwRuleGroupToWire(&gs[i])) + } + + wire.WriteJSON(w, map[string]any{"FirewallRuleGroups": out}) +} + +func (h *Handler) putFirewallRuleGroupPolicy(w http.ResponseWriter, r *http.Request) { + var req struct { + Arn string `json:"Arn"` + FirewallRuleGroupPolicy string `json:"FirewallRuleGroupPolicy"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := h.r53r.PutFirewallRuleGroupPolicy(r.Context(), req.Arn, req.FirewallRuleGroupPolicy); err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ReturnValue": true}) +} + +func (h *Handler) getFirewallRuleGroupPolicy(w http.ResponseWriter, r *http.Request) { + var req struct { + Arn string `json:"Arn"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + policy, err := h.r53r.GetFirewallRuleGroupPolicy(r.Context(), req.Arn) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallRuleGroupPolicy": policy}) +} + +// --- association handlers --- + +func (h *Handler) associateFirewallRuleGroup(w http.ResponseWriter, r *http.Request) { + var req struct { + CreatorRequestID string `json:"CreatorRequestId"` + FirewallRuleGroupID string `json:"FirewallRuleGroupId"` + Name string `json:"Name"` + Priority int32 `json:"Priority"` + VPCID string `json:"VpcId"` + MutationProtection string `json:"MutationProtection"` + Tags []wireTag `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + a, err := h.r53r.AssociateFirewallRuleGroup(r.Context(), &driver.AssociateFirewallRuleGroupInput{ + CreatorRequestID: req.CreatorRequestID, FirewallRuleGroupID: req.FirewallRuleGroupID, + Name: req.Name, Priority: req.Priority, VPCID: req.VPCID, + MutationProtection: req.MutationProtection, Tags: toDriverTags(req.Tags), + }) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallRuleGroupAssociation": fwAssocToWire(a)}) +} + +func (h *Handler) disassociateFirewallRuleGroup(w http.ResponseWriter, r *http.Request) { + var req struct { + FirewallRuleGroupAssociationID string `json:"FirewallRuleGroupAssociationId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + a, err := h.r53r.DisassociateFirewallRuleGroup(r.Context(), req.FirewallRuleGroupAssociationID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallRuleGroupAssociation": fwAssocToWire(a)}) +} + +func (h *Handler) getFirewallRuleGroupAssociation(w http.ResponseWriter, r *http.Request) { + var req struct { + FirewallRuleGroupAssociationID string `json:"FirewallRuleGroupAssociationId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + a, err := h.r53r.GetFirewallRuleGroupAssociation(r.Context(), req.FirewallRuleGroupAssociationID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallRuleGroupAssociation": fwAssocToWire(a)}) +} + +func (h *Handler) listFirewallRuleGroupAssociations(w http.ResponseWriter, r *http.Request) { + as, err := h.r53r.ListFirewallRuleGroupAssociations(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]wireFWAssoc, 0, len(as)) + for i := range as { + out = append(out, fwAssocToWire(&as[i])) + } + + wire.WriteJSON(w, map[string]any{"FirewallRuleGroupAssociations": out}) +} + +func (h *Handler) updateFirewallRuleGroupAssociation(w http.ResponseWriter, r *http.Request) { + var req struct { + FirewallRuleGroupAssociationID string `json:"FirewallRuleGroupAssociationId"` + MutationProtection *string `json:"MutationProtection"` + Name *string `json:"Name"` + Priority *int32 `json:"Priority"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + a, err := h.r53r.UpdateFirewallRuleGroupAssociation(r.Context(), &driver.UpdateFirewallRuleGroupAssociationInput{ + ID: req.FirewallRuleGroupAssociationID, MutationProtection: req.MutationProtection, + Name: req.Name, Priority: req.Priority, + }) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallRuleGroupAssociation": fwAssocToWire(a)}) +} + +// --- firewall-config handlers --- + +func (h *Handler) getFirewallConfig(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceID string `json:"ResourceId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + c, err := h.r53r.GetFirewallConfig(r.Context(), req.ResourceID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallConfig": fwConfigToWire(c)}) +} + +func (h *Handler) updateFirewallConfig(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceID string `json:"ResourceId"` + FirewallFailOpen string `json:"FirewallFailOpen"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + c, err := h.r53r.UpdateFirewallConfig(r.Context(), req.ResourceID, req.FirewallFailOpen) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"FirewallConfig": fwConfigToWire(c)}) +} + +func (h *Handler) listFirewallConfigs(w http.ResponseWriter, r *http.Request) { + cs, err := h.r53r.ListFirewallConfigs(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]wireFWConfig, 0, len(cs)) + for i := range cs { + out = append(out, fwConfigToWire(&cs[i])) + } + + wire.WriteJSON(w, map[string]any{"FirewallConfigs": out}) +} + +func (h *Handler) listFirewallRuleTypes(w http.ResponseWriter, r *http.Request) { + types, err := h.r53r.ListFirewallRuleTypes(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]map[string]any, 0, len(types)) + for i := range types { + out = append(out, map[string]any{"Name": types[i].Name}) + } + + wire.WriteJSON(w, map[string]any{"FirewallRuleTypes": out}) +} diff --git a/server/aws/route53resolver/handler.go b/server/aws/route53resolver/handler.go new file mode 100644 index 00000000..0c965ca5 --- /dev/null +++ b/server/aws/route53resolver/handler.go @@ -0,0 +1,139 @@ +// Package route53resolver implements the AWS Route 53 Resolver control-plane +// API (AWS JSON 1.1) as a server.Handler. Point the real +// aws-sdk-go-v2/service/route53resolver client at a Server registered with this +// handler and the operations work end-to-end against an in-memory driver. +// +// Route 53 Resolver uses the AWS JSON 1.1 wire shape (POST + JSON body, +// dispatched on the X-Amz-Target header with the prefix "Route53Resolver."). +// The Matches predicate is scoped to that prefix so it never shadows other +// handlers. +package route53resolver + +import ( + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +const targetPrefix = "Route53Resolver." + +// Handler serves Route 53 Resolver requests against a driver. +type Handler struct { + r53r driver.Route53Resolver +} + +// New returns a Route 53 Resolver handler backed by d. +func New(d driver.Route53Resolver) *Handler { + return &Handler{r53r: d} +} + +// Matches returns true for Route 53 Resolver requests, identified by an +// X-Amz-Target header of "Route53Resolver.". +func (*Handler) Matches(r *http.Request) bool { + return strings.HasPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) +} + +// ServeHTTP dispatches operations based on the X-Amz-Target suffix. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) + + fn, ok := h.routes()[op] + if !ok { + wire.WriteJSONError(w, http.StatusBadRequest, "UnknownOperationException", "unknown operation: "+op) + + return + } + + fn(w, r) +} + +// routes maps each supported operation to its handler. +func (h *Handler) routes() map[string]func(http.ResponseWriter, *http.Request) { + return map[string]func(http.ResponseWriter, *http.Request){ + // Resolver endpoints + "CreateResolverEndpoint": h.createResolverEndpoint, + "GetResolverEndpoint": h.getResolverEndpoint, + "UpdateResolverEndpoint": h.updateResolverEndpoint, + "DeleteResolverEndpoint": h.deleteResolverEndpoint, + "ListResolverEndpoints": h.listResolverEndpoints, + "AssociateResolverEndpointIpAddress": h.associateResolverEndpointIP, + "DisassociateResolverEndpointIpAddress": h.disassociateResolverEndpointIP, + "ListResolverEndpointIpAddresses": h.listResolverEndpointIPs, + // Resolver rules + "CreateResolverRule": h.createResolverRule, + "GetResolverRule": h.getResolverRule, + "UpdateResolverRule": h.updateResolverRule, + "DeleteResolverRule": h.deleteResolverRule, + "ListResolverRules": h.listResolverRules, + "AssociateResolverRule": h.associateResolverRule, + "DisassociateResolverRule": h.disassociateResolverRule, + "GetResolverRuleAssociation": h.getResolverRuleAssociation, + "ListResolverRuleAssociations": h.listResolverRuleAssociations, + "PutResolverRulePolicy": h.putResolverRulePolicy, + "GetResolverRulePolicy": h.getResolverRulePolicy, + // Query-log configs + "CreateResolverQueryLogConfig": h.createQueryLogConfig, + "GetResolverQueryLogConfig": h.getQueryLogConfig, + "DeleteResolverQueryLogConfig": h.deleteQueryLogConfig, + "ListResolverQueryLogConfigs": h.listQueryLogConfigs, + "AssociateResolverQueryLogConfig": h.associateQueryLogConfig, + "DisassociateResolverQueryLogConfig": h.disassociateQueryLogConfig, + "GetResolverQueryLogConfigAssociation": h.getQueryLogConfigAssociation, + "ListResolverQueryLogConfigAssociations": h.listQueryLogConfigAssociations, + "PutResolverQueryLogConfigPolicy": h.putQueryLogConfigPolicy, + "GetResolverQueryLogConfigPolicy": h.getQueryLogConfigPolicy, + // Resolver configs + "GetResolverConfig": h.getResolverConfig, + "UpdateResolverConfig": h.updateResolverConfig, + "ListResolverConfigs": h.listResolverConfigs, + "GetResolverDnssecConfig": h.getResolverDnssecConfig, + "UpdateResolverDnssecConfig": h.updateResolverDnssecConfig, + "ListResolverDnssecConfigs": h.listResolverDnssecConfigs, + // DNS Firewall — domain lists + "CreateFirewallDomainList": h.createFirewallDomainList, + "GetFirewallDomainList": h.getFirewallDomainList, + "DeleteFirewallDomainList": h.deleteFirewallDomainList, + "ListFirewallDomainLists": h.listFirewallDomainLists, + "UpdateFirewallDomains": h.updateFirewallDomains, + "ImportFirewallDomains": h.importFirewallDomains, + "ListFirewallDomains": h.listFirewallDomains, + // DNS Firewall — rules + "CreateFirewallRule": h.createFirewallRule, + "UpdateFirewallRule": h.updateFirewallRule, + "DeleteFirewallRule": h.deleteFirewallRule, + "ListFirewallRules": h.listFirewallRules, + "BatchCreateFirewallRule": h.batchCreateFirewallRule, + "BatchUpdateFirewallRule": h.batchUpdateFirewallRule, + "BatchDeleteFirewallRule": h.batchDeleteFirewallRule, + // DNS Firewall — rule groups + "CreateFirewallRuleGroup": h.createFirewallRuleGroup, + "GetFirewallRuleGroup": h.getFirewallRuleGroup, + "DeleteFirewallRuleGroup": h.deleteFirewallRuleGroup, + "ListFirewallRuleGroups": h.listFirewallRuleGroups, + "PutFirewallRuleGroupPolicy": h.putFirewallRuleGroupPolicy, + "GetFirewallRuleGroupPolicy": h.getFirewallRuleGroupPolicy, + // DNS Firewall — rule-group associations + "AssociateFirewallRuleGroup": h.associateFirewallRuleGroup, + "DisassociateFirewallRuleGroup": h.disassociateFirewallRuleGroup, + "GetFirewallRuleGroupAssociation": h.getFirewallRuleGroupAssociation, + "ListFirewallRuleGroupAssociations": h.listFirewallRuleGroupAssociations, + "UpdateFirewallRuleGroupAssociation": h.updateFirewallRuleGroupAssociation, + // DNS Firewall — configs + rule types + "GetFirewallConfig": h.getFirewallConfig, + "UpdateFirewallConfig": h.updateFirewallConfig, + "ListFirewallConfigs": h.listFirewallConfigs, + "ListFirewallRuleTypes": h.listFirewallRuleTypes, + // Outpost resolvers + "CreateOutpostResolver": h.createOutpostResolver, + "GetOutpostResolver": h.getOutpostResolver, + "UpdateOutpostResolver": h.updateOutpostResolver, + "DeleteOutpostResolver": h.deleteOutpostResolver, + "ListOutpostResolvers": h.listOutpostResolvers, + // Tagging + "TagResource": h.tagResource, + "UntagResource": h.untagResource, + "ListTagsForResource": h.listTagsForResource, + } +} diff --git a/server/aws/route53resolver/outpost.go b/server/aws/route53resolver/outpost.go new file mode 100644 index 00000000..2ab78122 --- /dev/null +++ b/server/aws/route53resolver/outpost.go @@ -0,0 +1,143 @@ +package route53resolver + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +// --- wire shape --- + +type wireOutpostResolver struct { + ID string `json:"Id,omitempty"` + Arn string `json:"Arn,omitempty"` + Name string `json:"Name,omitempty"` + CreatorRequestID string `json:"CreatorRequestId,omitempty"` + OutpostArn string `json:"OutpostArn,omitempty"` + PreferredInstanceType string `json:"PreferredInstanceType,omitempty"` + InstanceCount int32 `json:"InstanceCount"` + Status string `json:"Status,omitempty"` + StatusMessage string `json:"StatusMessage,omitempty"` + CreationTime string `json:"CreationTime,omitempty"` + ModificationTime string `json:"ModificationTime,omitempty"` +} + +func outpostToWire(o *driver.OutpostResolver) wireOutpostResolver { + return wireOutpostResolver{ + ID: o.ID, Arn: o.ARN, Name: o.Name, CreatorRequestID: o.CreatorRequestID, + OutpostArn: o.OutpostARN, PreferredInstanceType: o.PreferredInstanceType, + InstanceCount: o.InstanceCount, Status: o.Status, StatusMessage: o.StatusMessage, + CreationTime: o.CreatedAt, ModificationTime: o.ModifiedAt, + } +} + +// --- handlers --- + +func (h *Handler) createOutpostResolver(w http.ResponseWriter, r *http.Request) { + var req struct { + CreatorRequestID string `json:"CreatorRequestId"` + Name string `json:"Name"` + OutpostArn string `json:"OutpostArn"` + PreferredInstanceType string `json:"PreferredInstanceType"` + InstanceCount int32 `json:"InstanceCount"` + Tags []wireTag `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + o, err := h.r53r.CreateOutpostResolver(r.Context(), &driver.CreateOutpostResolverInput{ + CreatorRequestID: req.CreatorRequestID, Name: req.Name, OutpostARN: req.OutpostArn, + PreferredInstanceType: req.PreferredInstanceType, InstanceCount: req.InstanceCount, + Tags: toDriverTags(req.Tags), + }) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"OutpostResolver": outpostToWire(o)}) +} + +func (h *Handler) getOutpostResolver(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"Id"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + o, err := h.r53r.GetOutpostResolver(r.Context(), req.ID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"OutpostResolver": outpostToWire(o)}) +} + +func (h *Handler) updateOutpostResolver(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"Id"` + Name *string `json:"Name"` + PreferredInstanceType *string `json:"PreferredInstanceType"` + InstanceCount *int32 `json:"InstanceCount"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + in := &driver.UpdateOutpostResolverInput{ID: req.ID, Name: req.Name} + in.PreferredInstanceType = req.PreferredInstanceType + in.InstanceCount = req.InstanceCount + + o, err := h.r53r.UpdateOutpostResolver(r.Context(), in) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"OutpostResolver": outpostToWire(o)}) +} + +func (h *Handler) deleteOutpostResolver(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"Id"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + o, err := h.r53r.DeleteOutpostResolver(r.Context(), req.ID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"OutpostResolver": outpostToWire(o)}) +} + +func (h *Handler) listOutpostResolvers(w http.ResponseWriter, r *http.Request) { + os, err := h.r53r.ListOutpostResolvers(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]wireOutpostResolver, 0, len(os)) + for i := range os { + out = append(out, outpostToWire(&os[i])) + } + + wire.WriteJSON(w, map[string]any{"OutpostResolvers": out}) +} diff --git a/server/aws/route53resolver/query_log_config.go b/server/aws/route53resolver/query_log_config.go new file mode 100644 index 00000000..594ce01e --- /dev/null +++ b/server/aws/route53resolver/query_log_config.go @@ -0,0 +1,258 @@ +package route53resolver + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +// --- wire shapes --- + +type wireQueryLogConfig struct { + ID string `json:"Id,omitempty"` + Arn string `json:"Arn,omitempty"` + AssociationCount int32 `json:"AssociationCount"` + CreatorRequestID string `json:"CreatorRequestId,omitempty"` + DestinationArn string `json:"DestinationArn,omitempty"` + Name string `json:"Name,omitempty"` + OwnerID string `json:"OwnerId,omitempty"` + ShareStatus string `json:"ShareStatus,omitempty"` + Status string `json:"Status,omitempty"` + CreationTime string `json:"CreationTime,omitempty"` +} + +type wireQLCAssociation struct { + ID string `json:"Id,omitempty"` + ResolverQueryLogConfigID string `json:"ResolverQueryLogConfigId,omitempty"` + ResourceID string `json:"ResourceId,omitempty"` + Status string `json:"Status,omitempty"` + Error string `json:"Error,omitempty"` + ErrorMessage string `json:"ErrorMessage,omitempty"` + CreationTime string `json:"CreationTime,omitempty"` +} + +// --- mapping --- + +func qlcToWire(c *driver.QueryLogConfig) wireQueryLogConfig { + return wireQueryLogConfig{ + ID: c.ID, + Arn: c.ARN, + AssociationCount: c.AssociationCount, + CreatorRequestID: c.CreatorRequestID, + DestinationArn: c.DestinationARN, + Name: c.Name, + OwnerID: c.OwnerID, + ShareStatus: c.ShareStatus, + Status: c.Status, + CreationTime: c.CreatedAt, + } +} + +func qlcAssocToWire(a *driver.QueryLogConfigAssociation) wireQLCAssociation { + return wireQLCAssociation{ + ID: a.ID, + ResolverQueryLogConfigID: a.ResolverQueryLogConfigID, + ResourceID: a.ResourceID, + Status: a.Status, + Error: a.Error, + ErrorMessage: a.ErrorMessage, + CreationTime: a.CreatedAt, + } +} + +// --- handlers --- + +func (h *Handler) createQueryLogConfig(w http.ResponseWriter, r *http.Request) { + var req struct { + CreatorRequestID string `json:"CreatorRequestId"` + DestinationArn string `json:"DestinationArn"` + Name string `json:"Name"` + Tags []wireTag `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + c, err := h.r53r.CreateResolverQueryLogConfig(r.Context(), &driver.CreateQueryLogConfigInput{ + CreatorRequestID: req.CreatorRequestID, + DestinationARN: req.DestinationArn, + Name: req.Name, + Tags: toDriverTags(req.Tags), + }) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverQueryLogConfig": qlcToWire(c)}) +} + +func (h *Handler) getQueryLogConfig(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverQueryLogConfigID string `json:"ResolverQueryLogConfigId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + c, err := h.r53r.GetResolverQueryLogConfig(r.Context(), req.ResolverQueryLogConfigID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverQueryLogConfig": qlcToWire(c)}) +} + +func (h *Handler) deleteQueryLogConfig(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverQueryLogConfigID string `json:"ResolverQueryLogConfigId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + c, err := h.r53r.DeleteResolverQueryLogConfig(r.Context(), req.ResolverQueryLogConfigID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverQueryLogConfig": qlcToWire(c)}) +} + +func (h *Handler) listQueryLogConfigs(w http.ResponseWriter, r *http.Request) { + cs, err := h.r53r.ListResolverQueryLogConfigs(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]wireQueryLogConfig, 0, len(cs)) + for i := range cs { + out = append(out, qlcToWire(&cs[i])) + } + + wire.WriteJSON(w, map[string]any{"ResolverQueryLogConfigs": out}) +} + +func (h *Handler) associateQueryLogConfig(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverQueryLogConfigID string `json:"ResolverQueryLogConfigId"` + ResourceID string `json:"ResourceId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + a, err := h.r53r.AssociateResolverQueryLogConfig(r.Context(), req.ResolverQueryLogConfigID, req.ResourceID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverQueryLogConfigAssociation": qlcAssocToWire(a)}) +} + +func (h *Handler) disassociateQueryLogConfig(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverQueryLogConfigID string `json:"ResolverQueryLogConfigId"` + ResourceID string `json:"ResourceId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + a, err := h.r53r.DisassociateResolverQueryLogConfig(r.Context(), req.ResolverQueryLogConfigID, req.ResourceID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverQueryLogConfigAssociation": qlcAssocToWire(a)}) +} + +func (h *Handler) getQueryLogConfigAssociation(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverQueryLogConfigAssociationID string `json:"ResolverQueryLogConfigAssociationId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + a, err := h.r53r.GetResolverQueryLogConfigAssociation(r.Context(), req.ResolverQueryLogConfigAssociationID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverQueryLogConfigAssociation": qlcAssocToWire(a)}) +} + +func (h *Handler) listQueryLogConfigAssociations(w http.ResponseWriter, r *http.Request) { + as, err := h.r53r.ListResolverQueryLogConfigAssociations(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]wireQLCAssociation, 0, len(as)) + for i := range as { + out = append(out, qlcAssocToWire(&as[i])) + } + + wire.WriteJSON(w, map[string]any{"ResolverQueryLogConfigAssociations": out}) +} + +func (h *Handler) putQueryLogConfigPolicy(w http.ResponseWriter, r *http.Request) { + var req struct { + Arn string `json:"Arn"` + ResolverQueryLogConfigPolicy string `json:"ResolverQueryLogConfigPolicy"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := h.r53r.PutResolverQueryLogConfigPolicy(r.Context(), req.Arn, req.ResolverQueryLogConfigPolicy); err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ReturnValue": true}) +} + +func (h *Handler) getQueryLogConfigPolicy(w http.ResponseWriter, r *http.Request) { + var req struct { + Arn string `json:"Arn"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + policy, err := h.r53r.GetResolverQueryLogConfigPolicy(r.Context(), req.Arn) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverQueryLogConfigPolicy": policy}) +} diff --git a/server/aws/route53resolver/rules.go b/server/aws/route53resolver/rules.go new file mode 100644 index 00000000..f1102370 --- /dev/null +++ b/server/aws/route53resolver/rules.go @@ -0,0 +1,333 @@ +package route53resolver + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire" + "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" +) + +// --- wire shapes --- + +type wireTargetAddress struct { + IP string `json:"Ip,omitempty"` + IPv6 string `json:"Ipv6,omitempty"` + Port int32 `json:"Port,omitempty"` + Protocol string `json:"Protocol,omitempty"` + ServerNameIndication string `json:"ServerNameIndication,omitempty"` +} + +type wireResolverRule struct { + ID string `json:"Id,omitempty"` + Arn string `json:"Arn,omitempty"` + CreatorRequestID string `json:"CreatorRequestId,omitempty"` + DomainName string `json:"DomainName,omitempty"` + Name string `json:"Name,omitempty"` + OwnerID string `json:"OwnerId,omitempty"` + ResolverEndpointID string `json:"ResolverEndpointId,omitempty"` + RuleType string `json:"RuleType,omitempty"` + ShareStatus string `json:"ShareStatus,omitempty"` + Status string `json:"Status,omitempty"` + StatusMessage string `json:"StatusMessage,omitempty"` + TargetIPs []wireTargetAddress `json:"TargetIps,omitempty"` + CreationTime string `json:"CreationTime,omitempty"` + ModificationTime string `json:"ModificationTime,omitempty"` +} + +type wireRuleAssociation struct { + ID string `json:"Id,omitempty"` + Name string `json:"Name,omitempty"` + ResolverRuleID string `json:"ResolverRuleId,omitempty"` + VPCID string `json:"VPCId,omitempty"` + Status string `json:"Status,omitempty"` + StatusMessage string `json:"StatusMessage,omitempty"` +} + +type wireResolverRuleConfig struct { + Name *string `json:"Name"` + ResolverEndpointID *string `json:"ResolverEndpointId"` + TargetIPs []wireTargetAddress `json:"TargetIps"` +} + +// --- mapping --- + +func targetsToWire(ts []driver.TargetAddress) []wireTargetAddress { + out := make([]wireTargetAddress, 0, len(ts)) + for _, t := range ts { + out = append(out, wireTargetAddress{ + IP: t.IP, IPv6: t.IPv6, Port: t.Port, + Protocol: t.Protocol, ServerNameIndication: t.ServerNameIndication, + }) + } + + return out +} + +func toDriverTargets(ts []wireTargetAddress) []driver.TargetAddress { + out := make([]driver.TargetAddress, 0, len(ts)) + for _, t := range ts { + out = append(out, driver.TargetAddress{ + IP: t.IP, IPv6: t.IPv6, Port: t.Port, + Protocol: t.Protocol, ServerNameIndication: t.ServerNameIndication, + }) + } + + return out +} + +func ruleToWire(r *driver.ResolverRule) wireResolverRule { + return wireResolverRule{ + ID: r.ID, + Arn: r.ARN, + CreatorRequestID: r.CreatorRequestID, + DomainName: r.DomainName, + Name: r.Name, + OwnerID: r.OwnerID, + ResolverEndpointID: r.ResolverEndpointID, + RuleType: r.RuleType, + ShareStatus: r.ShareStatus, + Status: r.Status, + StatusMessage: r.StatusMessage, + TargetIPs: targetsToWire(r.TargetIPs), + CreationTime: r.CreatedAt, + ModificationTime: r.ModifiedAt, + } +} + +func assocToWire(a *driver.ResolverRuleAssociation) wireRuleAssociation { + return wireRuleAssociation{ + ID: a.ID, + Name: a.Name, + ResolverRuleID: a.ResolverRuleID, + VPCID: a.VPCID, + Status: a.Status, + StatusMessage: a.StatusMessage, + } +} + +// --- handlers --- + +func (h *Handler) createResolverRule(w http.ResponseWriter, r *http.Request) { + var req struct { + CreatorRequestID string `json:"CreatorRequestId"` + Name string `json:"Name"` + RuleType string `json:"RuleType"` + DomainName string `json:"DomainName"` + ResolverEndpointID string `json:"ResolverEndpointId"` + TargetIPs []wireTargetAddress `json:"TargetIps"` + Tags []wireTag `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + rule, err := h.r53r.CreateResolverRule(r.Context(), &driver.CreateResolverRuleInput{ + CreatorRequestID: req.CreatorRequestID, + Name: req.Name, + RuleType: req.RuleType, + DomainName: req.DomainName, + ResolverEndpointID: req.ResolverEndpointID, + TargetIPs: toDriverTargets(req.TargetIPs), + Tags: toDriverTags(req.Tags), + }) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverRule": ruleToWire(rule)}) +} + +func (h *Handler) getResolverRule(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverRuleID string `json:"ResolverRuleId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + rule, err := h.r53r.GetResolverRule(r.Context(), req.ResolverRuleID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverRule": ruleToWire(rule)}) +} + +func (h *Handler) updateResolverRule(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverRuleID string `json:"ResolverRuleId"` + Config wireResolverRuleConfig `json:"Config"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + rule, err := h.r53r.UpdateResolverRule(r.Context(), req.ResolverRuleID, driver.UpdateResolverRuleInput{ + Name: req.Config.Name, + ResolverEndpointID: req.Config.ResolverEndpointID, + TargetIPs: toDriverTargets(req.Config.TargetIPs), + }) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverRule": ruleToWire(rule)}) +} + +func (h *Handler) deleteResolverRule(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverRuleID string `json:"ResolverRuleId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + rule, err := h.r53r.DeleteResolverRule(r.Context(), req.ResolverRuleID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverRule": ruleToWire(rule)}) +} + +func (h *Handler) listResolverRules(w http.ResponseWriter, r *http.Request) { + rules, err := h.r53r.ListResolverRules(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]wireResolverRule, 0, len(rules)) + for i := range rules { + out = append(out, ruleToWire(&rules[i])) + } + + wire.WriteJSON(w, map[string]any{"ResolverRules": out}) +} + +func (h *Handler) associateResolverRule(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverRuleID string `json:"ResolverRuleId"` + VPCID string `json:"VPCId"` + Name string `json:"Name"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + a, err := h.r53r.AssociateResolverRule(r.Context(), req.ResolverRuleID, req.VPCID, req.Name) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverRuleAssociation": assocToWire(a)}) +} + +func (h *Handler) disassociateResolverRule(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverRuleID string `json:"ResolverRuleId"` + VPCID string `json:"VPCId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + a, err := h.r53r.DisassociateResolverRule(r.Context(), req.ResolverRuleID, req.VPCID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverRuleAssociation": assocToWire(a)}) +} + +func (h *Handler) getResolverRuleAssociation(w http.ResponseWriter, r *http.Request) { + var req struct { + ResolverRuleAssociationID string `json:"ResolverRuleAssociationId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + a, err := h.r53r.GetResolverRuleAssociation(r.Context(), req.ResolverRuleAssociationID) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverRuleAssociation": assocToWire(a)}) +} + +func (h *Handler) listResolverRuleAssociations(w http.ResponseWriter, r *http.Request) { + assocs, err := h.r53r.ListResolverRuleAssociations(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + out := make([]wireRuleAssociation, 0, len(assocs)) + for i := range assocs { + out = append(out, assocToWire(&assocs[i])) + } + + wire.WriteJSON(w, map[string]any{"ResolverRuleAssociations": out}) +} + +func (h *Handler) putResolverRulePolicy(w http.ResponseWriter, r *http.Request) { + var req struct { + Arn string `json:"Arn"` + ResolverRulePolicy string `json:"ResolverRulePolicy"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := h.r53r.PutResolverRulePolicy(r.Context(), req.Arn, req.ResolverRulePolicy); err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ReturnValue": true}) +} + +func (h *Handler) getResolverRulePolicy(w http.ResponseWriter, r *http.Request) { + var req struct { + Arn string `json:"Arn"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + policy, err := h.r53r.GetResolverRulePolicy(r.Context(), req.Arn) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"ResolverRulePolicy": policy}) +} diff --git a/server/aws/route53resolver/sdk_roundtrip_test.go b/server/aws/route53resolver/sdk_roundtrip_test.go new file mode 100644 index 00000000..37783736 --- /dev/null +++ b/server/aws/route53resolver/sdk_roundtrip_test.go @@ -0,0 +1,757 @@ +package route53resolver_test + +import ( + "context" + "errors" + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + awsr53r "github.com/aws/aws-sdk-go-v2/service/route53resolver" + r53rtypes "github.com/aws/aws-sdk-go-v2/service/route53resolver/types" + + "github.com/stackshy/cloudemu/v2" + awsserver "github.com/stackshy/cloudemu/v2/server/aws" +) + +// newClient builds an httptest-backed Route 53 Resolver client driving the real +// aws-sdk-go-v2 client against the in-memory driver. +func newClient(t *testing.T) *awsr53r.Client { + t.Helper() + + cloud := cloudemu.NewAWS() + srv := awsserver.New(awsserver.Drivers{Route53Resolver: cloud.Route53Resolver}) + + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion("us-east-1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + if err != nil { + t.Fatalf("aws config: %v", err) + } + + return awsr53r.NewFromConfig(cfg, func(o *awsr53r.Options) { + o.BaseEndpoint = aws.String(ts.URL) + }) +} + +func TestSDKResolverEndpointLifecycle(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + created, err := client.CreateResolverEndpoint(ctx, &awsr53r.CreateResolverEndpointInput{ + CreatorRequestId: aws.String("req-1"), + Name: aws.String("inbound-1"), + Direction: r53rtypes.ResolverEndpointDirectionInbound, + SecurityGroupIds: []string{"sg-123"}, + IpAddresses: []r53rtypes.IpAddressRequest{ + {SubnetId: aws.String("subnet-1")}, + {SubnetId: aws.String("subnet-2")}, + }, + }) + if err != nil { + t.Fatalf("CreateResolverEndpoint: %v", err) + } + + ep := created.ResolverEndpoint + if ep == nil || aws.ToString(ep.Id) == "" { + t.Fatalf("no endpoint id: %+v", ep) + } + + if ep.Direction != r53rtypes.ResolverEndpointDirectionInbound { + t.Errorf("direction = %v, want INBOUND", ep.Direction) + } + + if aws.ToInt32(ep.IpAddressCount) != 2 { + t.Errorf("ip count = %d, want 2", aws.ToInt32(ep.IpAddressCount)) + } + + if ep.Status != r53rtypes.ResolverEndpointStatusOperational { + t.Errorf("status = %v, want OPERATIONAL", ep.Status) + } + + id := aws.ToString(ep.Id) + arn := aws.ToString(ep.Arn) + + got, err := client.GetResolverEndpoint(ctx, &awsr53r.GetResolverEndpointInput{ResolverEndpointId: aws.String(id)}) + if err != nil || aws.ToString(got.ResolverEndpoint.Name) != "inbound-1" { + t.Fatalf("GetResolverEndpoint: %v %+v", err, got) + } + + list, err := client.ListResolverEndpoints(ctx, &awsr53r.ListResolverEndpointsInput{}) + if err != nil || len(list.ResolverEndpoints) != 1 { + t.Fatalf("ListResolverEndpoints: %v %+v", err, list) + } + + upd, err := client.UpdateResolverEndpoint(ctx, &awsr53r.UpdateResolverEndpointInput{ + ResolverEndpointId: aws.String(id), + Name: aws.String("renamed"), + }) + if err != nil || aws.ToString(upd.ResolverEndpoint.Name) != "renamed" { + t.Fatalf("UpdateResolverEndpoint: %v %+v", err, upd) + } + + assoc, err := client.AssociateResolverEndpointIpAddress(ctx, &awsr53r.AssociateResolverEndpointIpAddressInput{ + ResolverEndpointId: aws.String(id), + IpAddress: &r53rtypes.IpAddressUpdate{SubnetId: aws.String("subnet-3")}, + }) + if err != nil || aws.ToInt32(assoc.ResolverEndpoint.IpAddressCount) != 3 { + t.Fatalf("AssociateResolverEndpointIpAddress: %v %+v", err, assoc) + } + + ips, err := client.ListResolverEndpointIpAddresses(ctx, &awsr53r.ListResolverEndpointIpAddressesInput{ + ResolverEndpointId: aws.String(id), + }) + if err != nil || len(ips.IpAddresses) != 3 { + t.Fatalf("ListResolverEndpointIpAddresses: %v %+v", err, ips) + } + + dis, err := client.DisassociateResolverEndpointIpAddress(ctx, &awsr53r.DisassociateResolverEndpointIpAddressInput{ + ResolverEndpointId: aws.String(id), + IpAddress: &r53rtypes.IpAddressUpdate{IpId: ips.IpAddresses[0].IpId}, + }) + if err != nil || aws.ToInt32(dis.ResolverEndpoint.IpAddressCount) != 2 { + t.Fatalf("DisassociateResolverEndpointIpAddress: %v %+v", err, dis) + } + + if _, err := client.TagResource(ctx, &awsr53r.TagResourceInput{ + ResourceArn: aws.String(arn), + Tags: []r53rtypes.Tag{{Key: aws.String("env"), Value: aws.String("test")}}, + }); err != nil { + t.Fatalf("TagResource: %v", err) + } + + tags, err := client.ListTagsForResource(ctx, &awsr53r.ListTagsForResourceInput{ResourceArn: aws.String(arn)}) + if err != nil || len(tags.Tags) != 1 || aws.ToString(tags.Tags[0].Value) != "test" { + t.Fatalf("ListTagsForResource: %v %+v", err, tags) + } + + del, err := client.DeleteResolverEndpoint(ctx, &awsr53r.DeleteResolverEndpointInput{ResolverEndpointId: aws.String(id)}) + if err != nil || del.ResolverEndpoint.Status != r53rtypes.ResolverEndpointStatusDeleting { + t.Fatalf("DeleteResolverEndpoint: %v %+v", err, del) + } + + _, err = client.GetResolverEndpoint(ctx, &awsr53r.GetResolverEndpointInput{ResolverEndpointId: aws.String(id)}) + + var nfe *r53rtypes.ResourceNotFoundException + if !errors.As(err, &nfe) { + t.Fatalf("expected ResourceNotFoundException after delete, got %v", err) + } +} + +func TestSDKResolverRuleLifecycle(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + created, err := client.CreateResolverRule(ctx, &awsr53r.CreateResolverRuleInput{ + CreatorRequestId: aws.String("req-rule-1"), + Name: aws.String("fwd-example"), + RuleType: r53rtypes.RuleTypeOptionForward, + DomainName: aws.String("example.com"), + ResolverEndpointId: aws.String("rslvr-out-abc"), + TargetIps: []r53rtypes.TargetAddress{{Ip: aws.String("10.0.0.2"), Port: aws.Int32(53)}}, + }) + if err != nil { + t.Fatalf("CreateResolverRule: %v", err) + } + + rule := created.ResolverRule + if rule == nil || aws.ToString(rule.Id) == "" { + t.Fatalf("no rule id: %+v", rule) + } + + if rule.RuleType != r53rtypes.RuleTypeOptionForward { + t.Errorf("ruleType = %v, want FORWARD", rule.RuleType) + } + + if len(rule.TargetIps) != 1 || aws.ToString(rule.TargetIps[0].Ip) != "10.0.0.2" { + t.Errorf("target ips = %+v", rule.TargetIps) + } + + id := aws.ToString(rule.Id) + arn := aws.ToString(rule.Arn) + + got, err := client.GetResolverRule(ctx, &awsr53r.GetResolverRuleInput{ResolverRuleId: aws.String(id)}) + if err != nil || aws.ToString(got.ResolverRule.DomainName) != "example.com" { + t.Fatalf("GetResolverRule: %v %+v", err, got) + } + + list, err := client.ListResolverRules(ctx, &awsr53r.ListResolverRulesInput{}) + if err != nil || len(list.ResolverRules) != 1 { + t.Fatalf("ListResolverRules: %v %+v", err, list) + } + + upd, err := client.UpdateResolverRule(ctx, &awsr53r.UpdateResolverRuleInput{ + ResolverRuleId: aws.String(id), + Config: &r53rtypes.ResolverRuleConfig{ + Name: aws.String("renamed-rule"), + TargetIps: []r53rtypes.TargetAddress{{Ip: aws.String("10.0.0.3"), Port: aws.Int32(53)}}, + }, + }) + if err != nil || aws.ToString(upd.ResolverRule.Name) != "renamed-rule" { + t.Fatalf("UpdateResolverRule: %v %+v", err, upd) + } + + assoc, err := client.AssociateResolverRule(ctx, &awsr53r.AssociateResolverRuleInput{ + ResolverRuleId: aws.String(id), + VPCId: aws.String("vpc-123"), + Name: aws.String("assoc-1"), + }) + if err != nil || assoc.ResolverRuleAssociation == nil { + t.Fatalf("AssociateResolverRule: %v %+v", err, assoc) + } + + assocID := aws.ToString(assoc.ResolverRuleAssociation.Id) + + ga, err := client.GetResolverRuleAssociation(ctx, &awsr53r.GetResolverRuleAssociationInput{ + ResolverRuleAssociationId: aws.String(assocID), + }) + if err != nil || aws.ToString(ga.ResolverRuleAssociation.VPCId) != "vpc-123" { + t.Fatalf("GetResolverRuleAssociation: %v %+v", err, ga) + } + + la, err := client.ListResolverRuleAssociations(ctx, &awsr53r.ListResolverRuleAssociationsInput{}) + if err != nil || len(la.ResolverRuleAssociations) != 1 { + t.Fatalf("ListResolverRuleAssociations: %v %+v", err, la) + } + + if _, err := client.PutResolverRulePolicy(ctx, &awsr53r.PutResolverRulePolicyInput{ + Arn: aws.String(arn), + ResolverRulePolicy: aws.String(`{"policy":true}`), + }); err != nil { + t.Fatalf("PutResolverRulePolicy: %v", err) + } + + pol, err := client.GetResolverRulePolicy(ctx, &awsr53r.GetResolverRulePolicyInput{Arn: aws.String(arn)}) + if err != nil || aws.ToString(pol.ResolverRulePolicy) != `{"policy":true}` { + t.Fatalf("GetResolverRulePolicy: %v %+v", err, pol) + } + + if _, err := client.DisassociateResolverRule(ctx, &awsr53r.DisassociateResolverRuleInput{ + ResolverRuleId: aws.String(id), + VPCId: aws.String("vpc-123"), + }); err != nil { + t.Fatalf("DisassociateResolverRule: %v", err) + } + + if _, err := client.DeleteResolverRule(ctx, &awsr53r.DeleteResolverRuleInput{ResolverRuleId: aws.String(id)}); err != nil { + t.Fatalf("DeleteResolverRule: %v", err) + } + + _, err = client.GetResolverRule(ctx, &awsr53r.GetResolverRuleInput{ResolverRuleId: aws.String(id)}) + + var rnfe *r53rtypes.ResourceNotFoundException + if !errors.As(err, &rnfe) { + t.Fatalf("expected ResourceNotFoundException after delete, got %v", err) + } +} + +func TestSDKQueryLogConfigLifecycle(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + created, err := client.CreateResolverQueryLogConfig(ctx, &awsr53r.CreateResolverQueryLogConfigInput{ + CreatorRequestId: aws.String("req-qlc-1"), + Name: aws.String("qlc-1"), + DestinationArn: aws.String("arn:aws:s3:::my-logs"), + }) + if err != nil { + t.Fatalf("CreateResolverQueryLogConfig: %v", err) + } + + cfg := created.ResolverQueryLogConfig + if cfg == nil || aws.ToString(cfg.Id) == "" { + t.Fatalf("no config id: %+v", cfg) + } + + id := aws.ToString(cfg.Id) + arn := aws.ToString(cfg.Arn) + + list, err := client.ListResolverQueryLogConfigs(ctx, &awsr53r.ListResolverQueryLogConfigsInput{}) + if err != nil || len(list.ResolverQueryLogConfigs) != 1 { + t.Fatalf("ListResolverQueryLogConfigs: %v %+v", err, list) + } + + assoc, err := client.AssociateResolverQueryLogConfig(ctx, &awsr53r.AssociateResolverQueryLogConfigInput{ + ResolverQueryLogConfigId: aws.String(id), + ResourceId: aws.String("vpc-abc"), + }) + if err != nil || assoc.ResolverQueryLogConfigAssociation == nil { + t.Fatalf("AssociateResolverQueryLogConfig: %v %+v", err, assoc) + } + + assocID := aws.ToString(assoc.ResolverQueryLogConfigAssociation.Id) + + ga, err := client.GetResolverQueryLogConfigAssociation(ctx, &awsr53r.GetResolverQueryLogConfigAssociationInput{ + ResolverQueryLogConfigAssociationId: aws.String(assocID), + }) + if err != nil || aws.ToString(ga.ResolverQueryLogConfigAssociation.ResourceId) != "vpc-abc" { + t.Fatalf("GetResolverQueryLogConfigAssociation: %v %+v", err, ga) + } + + la, err := client.ListResolverQueryLogConfigAssociations(ctx, &awsr53r.ListResolverQueryLogConfigAssociationsInput{}) + if err != nil || len(la.ResolverQueryLogConfigAssociations) != 1 { + t.Fatalf("ListResolverQueryLogConfigAssociations: %v %+v", err, la) + } + + gc, err := client.GetResolverQueryLogConfig(ctx, &awsr53r.GetResolverQueryLogConfigInput{ + ResolverQueryLogConfigId: aws.String(id), + }) + if err != nil || gc.ResolverQueryLogConfig.AssociationCount != 1 { + t.Fatalf("GetResolverQueryLogConfig assoc count: %v %+v", err, gc) + } + + if _, err := client.PutResolverQueryLogConfigPolicy(ctx, &awsr53r.PutResolverQueryLogConfigPolicyInput{ + Arn: aws.String(arn), + ResolverQueryLogConfigPolicy: aws.String(`{"p":1}`), + }); err != nil { + t.Fatalf("PutResolverQueryLogConfigPolicy: %v", err) + } + + pol, err := client.GetResolverQueryLogConfigPolicy(ctx, &awsr53r.GetResolverQueryLogConfigPolicyInput{Arn: aws.String(arn)}) + if err != nil || aws.ToString(pol.ResolverQueryLogConfigPolicy) != `{"p":1}` { + t.Fatalf("GetResolverQueryLogConfigPolicy: %v %+v", err, pol) + } + + if _, err := client.DisassociateResolverQueryLogConfig(ctx, &awsr53r.DisassociateResolverQueryLogConfigInput{ + ResolverQueryLogConfigId: aws.String(id), + ResourceId: aws.String("vpc-abc"), + }); err != nil { + t.Fatalf("DisassociateResolverQueryLogConfig: %v", err) + } + + if _, err := client.DeleteResolverQueryLogConfig(ctx, &awsr53r.DeleteResolverQueryLogConfigInput{ + ResolverQueryLogConfigId: aws.String(id), + }); err != nil { + t.Fatalf("DeleteResolverQueryLogConfig: %v", err) + } + + _, err = client.GetResolverQueryLogConfig(ctx, &awsr53r.GetResolverQueryLogConfigInput{ + ResolverQueryLogConfigId: aws.String(id), + }) + + var qnfe *r53rtypes.ResourceNotFoundException + if !errors.As(err, &qnfe) { + t.Fatalf("expected ResourceNotFoundException after delete, got %v", err) + } +} + +func TestSDKResolverConfigLifecycle(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + got, err := client.GetResolverConfig(ctx, &awsr53r.GetResolverConfigInput{ + ResourceId: aws.String("vpc-cfg-1"), + }) + if err != nil || got.ResolverConfig.AutodefinedReverse != r53rtypes.ResolverAutodefinedReverseStatusEnabled { + t.Fatalf("GetResolverConfig default: %v %+v", err, got.ResolverConfig) + } + + upd, err := client.UpdateResolverConfig(ctx, &awsr53r.UpdateResolverConfigInput{ + ResourceId: aws.String("vpc-cfg-1"), + AutodefinedReverseFlag: r53rtypes.AutodefinedReverseFlagDisable, + }) + if err != nil || upd.ResolverConfig.AutodefinedReverse != r53rtypes.ResolverAutodefinedReverseStatusDisabled { + t.Fatalf("UpdateResolverConfig: %v %+v", err, upd.ResolverConfig) + } + + list, err := client.ListResolverConfigs(ctx, &awsr53r.ListResolverConfigsInput{}) + if err != nil || len(list.ResolverConfigs) != 1 || aws.ToString(list.ResolverConfigs[0].ResourceId) != "vpc-cfg-1" { + t.Fatalf("ListResolverConfigs: %v %+v", err, list.ResolverConfigs) + } +} + +func TestSDKResolverDnssecConfigLifecycle(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + got, err := client.GetResolverDnssecConfig(ctx, &awsr53r.GetResolverDnssecConfigInput{ + ResourceId: aws.String("vpc-ds-1"), + }) + if err != nil || got.ResolverDNSSECConfig.ValidationStatus != r53rtypes.ResolverDNSSECValidationStatusDisabled { + t.Fatalf("GetResolverDnssecConfig default: %v %+v", err, got.ResolverDNSSECConfig) + } + + upd, err := client.UpdateResolverDnssecConfig(ctx, &awsr53r.UpdateResolverDnssecConfigInput{ + ResourceId: aws.String("vpc-ds-1"), + Validation: r53rtypes.ValidationEnable, + }) + if err != nil || upd.ResolverDNSSECConfig.ValidationStatus != r53rtypes.ResolverDNSSECValidationStatusEnabled { + t.Fatalf("UpdateResolverDnssecConfig: %v %+v", err, upd.ResolverDNSSECConfig) + } + + list, err := client.ListResolverDnssecConfigs(ctx, &awsr53r.ListResolverDnssecConfigsInput{}) + if err != nil || len(list.ResolverDnssecConfigs) != 1 { + t.Fatalf("ListResolverDnssecConfigs: %v %+v", err, list.ResolverDnssecConfigs) + } +} + +func TestSDKFirewallLifecycle(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + dl, err := client.CreateFirewallDomainList(ctx, &awsr53r.CreateFirewallDomainListInput{ + CreatorRequestId: aws.String("dl-req"), + Name: aws.String("blocklist"), + }) + if err != nil { + t.Fatalf("CreateFirewallDomainList: %v", err) + } + + dlID := aws.ToString(dl.FirewallDomainList.Id) + + if _, err = client.UpdateFirewallDomains(ctx, &awsr53r.UpdateFirewallDomainsInput{ + FirewallDomainListId: aws.String(dlID), + Operation: r53rtypes.FirewallDomainUpdateOperationAdd, + Domains: []string{"evil.example.com", "bad.example.net"}, + }); err != nil { + t.Fatalf("UpdateFirewallDomains: %v", err) + } + + ld, err := client.ListFirewallDomains(ctx, &awsr53r.ListFirewallDomainsInput{ + FirewallDomainListId: aws.String(dlID), + }) + if err != nil || len(ld.Domains) != 2 { + t.Fatalf("ListFirewallDomains: %v %+v", err, ld.Domains) + } + + rg, err := client.CreateFirewallRuleGroup(ctx, &awsr53r.CreateFirewallRuleGroupInput{ + CreatorRequestId: aws.String("rg-req"), + Name: aws.String("rg-1"), + }) + if err != nil { + t.Fatalf("CreateFirewallRuleGroup: %v", err) + } + + rgID := aws.ToString(rg.FirewallRuleGroup.Id) + + rule, err := client.CreateFirewallRule(ctx, &awsr53r.CreateFirewallRuleInput{ + FirewallRuleGroupId: aws.String(rgID), + FirewallDomainListId: aws.String(dlID), + Name: aws.String("block-evil"), + Priority: aws.Int32(100), + Action: r53rtypes.ActionBlock, + BlockResponse: r53rtypes.BlockResponseNxdomain, + }) + if err != nil || rule.FirewallRule.Action != r53rtypes.ActionBlock { + t.Fatalf("CreateFirewallRule: %v %+v", err, rule.FirewallRule) + } + + lr, err := client.ListFirewallRules(ctx, &awsr53r.ListFirewallRulesInput{ + FirewallRuleGroupId: aws.String(rgID), + }) + if err != nil || len(lr.FirewallRules) != 1 { + t.Fatalf("ListFirewallRules: %v %+v", err, lr.FirewallRules) + } + + gr, err := client.GetFirewallRuleGroup(ctx, &awsr53r.GetFirewallRuleGroupInput{ + FirewallRuleGroupId: aws.String(rgID), + }) + if err != nil || aws.ToInt32(gr.FirewallRuleGroup.RuleCount) != 1 { + t.Fatalf("GetFirewallRuleGroup RuleCount: %v %+v", err, gr.FirewallRuleGroup) + } + + assoc, err := client.AssociateFirewallRuleGroup(ctx, &awsr53r.AssociateFirewallRuleGroupInput{ + CreatorRequestId: aws.String("assoc-req"), + FirewallRuleGroupId: aws.String(rgID), + Name: aws.String("assoc-1"), + Priority: aws.Int32(101), + VpcId: aws.String("vpc-fw-1"), + }) + if err != nil { + t.Fatalf("AssociateFirewallRuleGroup: %v", err) + } + + assocID := aws.ToString(assoc.FirewallRuleGroupAssociation.Id) + + if _, err = client.GetFirewallRuleGroupAssociation(ctx, &awsr53r.GetFirewallRuleGroupAssociationInput{ + FirewallRuleGroupAssociationId: aws.String(assocID), + }); err != nil { + t.Fatalf("GetFirewallRuleGroupAssociation: %v", err) + } + + fc, err := client.GetFirewallConfig(ctx, &awsr53r.GetFirewallConfigInput{ + ResourceId: aws.String("vpc-fw-1"), + }) + if err != nil || fc.FirewallConfig.FirewallFailOpen != r53rtypes.FirewallFailOpenStatusDisabled { + t.Fatalf("GetFirewallConfig default: %v %+v", err, fc.FirewallConfig) + } + + uc, err := client.UpdateFirewallConfig(ctx, &awsr53r.UpdateFirewallConfigInput{ + ResourceId: aws.String("vpc-fw-1"), + FirewallFailOpen: r53rtypes.FirewallFailOpenStatusEnabled, + }) + if err != nil || uc.FirewallConfig.FirewallFailOpen != r53rtypes.FirewallFailOpenStatusEnabled { + t.Fatalf("UpdateFirewallConfig: %v %+v", err, uc.FirewallConfig) + } + + if _, err = client.DisassociateFirewallRuleGroup(ctx, &awsr53r.DisassociateFirewallRuleGroupInput{ + FirewallRuleGroupAssociationId: aws.String(assocID), + }); err != nil { + t.Fatalf("DisassociateFirewallRuleGroup: %v", err) + } + + if _, err = client.DeleteFirewallRule(ctx, &awsr53r.DeleteFirewallRuleInput{ + FirewallRuleGroupId: aws.String(rgID), + FirewallDomainListId: aws.String(dlID), + }); err != nil { + t.Fatalf("DeleteFirewallRule: %v", err) + } +} + +func TestSDKOutpostResolverLifecycle(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + created, err := client.CreateOutpostResolver(ctx, &awsr53r.CreateOutpostResolverInput{ + CreatorRequestId: aws.String("op-req"), + Name: aws.String("op-1"), + OutpostArn: aws.String("arn:aws:outposts:us-east-1:123:outpost/op-abc"), + PreferredInstanceType: aws.String("m5.large"), + InstanceCount: aws.Int32(4), + }) + if err != nil || aws.ToInt32(created.OutpostResolver.InstanceCount) != 4 { + t.Fatalf("CreateOutpostResolver: %v %+v", err, created.OutpostResolver) + } + + id := aws.ToString(created.OutpostResolver.Id) + + upd, err := client.UpdateOutpostResolver(ctx, &awsr53r.UpdateOutpostResolverInput{ + Id: aws.String(id), + InstanceCount: aws.Int32(8), + }) + if err != nil || aws.ToInt32(upd.OutpostResolver.InstanceCount) != 8 { + t.Fatalf("UpdateOutpostResolver: %v %+v", err, upd.OutpostResolver) + } + + got, err := client.GetOutpostResolver(ctx, &awsr53r.GetOutpostResolverInput{Id: aws.String(id)}) + if err != nil || aws.ToString(got.OutpostResolver.Name) != "op-1" { + t.Fatalf("GetOutpostResolver: %v %+v", err, got.OutpostResolver) + } + + list, err := client.ListOutpostResolvers(ctx, &awsr53r.ListOutpostResolversInput{}) + if err != nil || len(list.OutpostResolvers) != 1 { + t.Fatalf("ListOutpostResolvers: %v %+v", err, list.OutpostResolvers) + } + + if _, err = client.DeleteOutpostResolver(ctx, &awsr53r.DeleteOutpostResolverInput{Id: aws.String(id)}); err != nil { + t.Fatalf("DeleteOutpostResolver: %v", err) + } + + if _, err = client.GetOutpostResolver(ctx, &awsr53r.GetOutpostResolverInput{Id: aws.String(id)}); err == nil { + t.Fatal("GetOutpostResolver after delete: expected error, got nil") + } +} + +// TestSDKFirewallFullSurface drives the firewall handlers not exercised by the +// happy-path lifecycle: batch rule ops, policies, list variants, import, and +// association update. +func TestSDKFirewallFullSurface(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + dl, err := client.CreateFirewallDomainList(ctx, &awsr53r.CreateFirewallDomainListInput{ + CreatorRequestId: aws.String("dl"), Name: aws.String("dl-1"), + }) + require(t, err, "CreateFirewallDomainList") + dlID := aws.ToString(dl.FirewallDomainList.Id) + + if _, err = client.GetFirewallDomainList(ctx, &awsr53r.GetFirewallDomainListInput{ + FirewallDomainListId: aws.String(dlID), + }); err != nil { + t.Fatalf("GetFirewallDomainList: %v", err) + } + + if _, err = client.ImportFirewallDomains(ctx, &awsr53r.ImportFirewallDomainsInput{ + FirewallDomainListId: aws.String(dlID), + Operation: r53rtypes.FirewallDomainImportOperationReplace, + DomainFileUrl: aws.String("s3://bucket/domains.txt"), + }); err != nil { + t.Fatalf("ImportFirewallDomains: %v", err) + } + + ldl, err := client.ListFirewallDomainLists(ctx, &awsr53r.ListFirewallDomainListsInput{}) + if err != nil || len(ldl.FirewallDomainLists) != 1 { + t.Fatalf("ListFirewallDomainLists: %v %+v", err, ldl.FirewallDomainLists) + } + + rg, err := client.CreateFirewallRuleGroup(ctx, &awsr53r.CreateFirewallRuleGroupInput{ + CreatorRequestId: aws.String("rg"), Name: aws.String("rg-1"), + }) + require(t, err, "CreateFirewallRuleGroup") + rgID := aws.ToString(rg.FirewallRuleGroup.Id) + + bc, err := client.BatchCreateFirewallRule(ctx, &awsr53r.BatchCreateFirewallRuleInput{ + CreateFirewallRuleEntries: []r53rtypes.CreateFirewallRuleEntry{{ + CreatorRequestId: aws.String("bc-1"), + FirewallRuleGroupId: aws.String(rgID), + FirewallDomainListId: aws.String(dlID), + Name: aws.String("r1"), + Priority: aws.Int32(10), + Action: r53rtypes.ActionBlock, + BlockResponse: r53rtypes.BlockResponseNxdomain, + }}, + }) + require(t, err, "BatchCreateFirewallRule") + if len(bc.CreatedFirewallRules) != 1 { + t.Fatalf("BatchCreateFirewallRule count: %+v", bc.CreatedFirewallRules) + } + + if _, err = client.UpdateFirewallRule(ctx, &awsr53r.UpdateFirewallRuleInput{ + FirewallRuleGroupId: aws.String(rgID), + FirewallDomainListId: aws.String(dlID), + Action: r53rtypes.ActionAlert, + Priority: aws.Int32(10), + }); err != nil { + t.Fatalf("UpdateFirewallRule: %v", err) + } + + bu, err := client.BatchUpdateFirewallRule(ctx, &awsr53r.BatchUpdateFirewallRuleInput{ + UpdateFirewallRuleEntries: []r53rtypes.UpdateFirewallRuleEntry{{ + FirewallRuleGroupId: aws.String(rgID), + FirewallDomainListId: aws.String(dlID), + Action: r53rtypes.ActionAllow, + Priority: aws.Int32(10), + }}, + }) + if err != nil || len(bu.UpdatedFirewallRules) != 1 { + t.Fatalf("BatchUpdateFirewallRule: %v %+v", err, bu.UpdatedFirewallRules) + } + + if _, err = client.PutFirewallRuleGroupPolicy(ctx, &awsr53r.PutFirewallRuleGroupPolicyInput{ + Arn: aws.String(aws.ToString(rg.FirewallRuleGroup.Arn)), FirewallRuleGroupPolicy: aws.String("{}"), + }); err != nil { + t.Fatalf("PutFirewallRuleGroupPolicy: %v", err) + } + gp, err := client.GetFirewallRuleGroupPolicy(ctx, &awsr53r.GetFirewallRuleGroupPolicyInput{ + Arn: aws.String(aws.ToString(rg.FirewallRuleGroup.Arn)), + }) + if err != nil || aws.ToString(gp.FirewallRuleGroupPolicy) != "{}" { + t.Fatalf("GetFirewallRuleGroupPolicy: %v %+v", err, gp) + } + + assoc, err := client.AssociateFirewallRuleGroup(ctx, &awsr53r.AssociateFirewallRuleGroupInput{ + CreatorRequestId: aws.String("a"), FirewallRuleGroupId: aws.String(rgID), + Name: aws.String("a1"), Priority: aws.Int32(101), VpcId: aws.String("vpc-1"), + }) + require(t, err, "AssociateFirewallRuleGroup") + assocID := aws.ToString(assoc.FirewallRuleGroupAssociation.Id) + + if _, err = client.UpdateFirewallRuleGroupAssociation(ctx, &awsr53r.UpdateFirewallRuleGroupAssociationInput{ + FirewallRuleGroupAssociationId: aws.String(assocID), + Name: aws.String("a2"), + Priority: aws.Int32(202), + }); err != nil { + t.Fatalf("UpdateFirewallRuleGroupAssociation: %v", err) + } + + la, err := client.ListFirewallRuleGroupAssociations(ctx, &awsr53r.ListFirewallRuleGroupAssociationsInput{}) + if err != nil || len(la.FirewallRuleGroupAssociations) != 1 { + t.Fatalf("ListFirewallRuleGroupAssociations: %v %+v", err, la.FirewallRuleGroupAssociations) + } + + lrg, err := client.ListFirewallRuleGroups(ctx, &awsr53r.ListFirewallRuleGroupsInput{}) + if err != nil || len(lrg.FirewallRuleGroups) != 1 { + t.Fatalf("ListFirewallRuleGroups: %v %+v", err, lrg.FirewallRuleGroups) + } + + if _, err = client.ListFirewallConfigs(ctx, &awsr53r.ListFirewallConfigsInput{}); err != nil { + t.Fatalf("ListFirewallConfigs: %v", err) + } + if _, err = client.ListFirewallRuleTypes(ctx, &awsr53r.ListFirewallRuleTypesInput{}); err != nil { + t.Fatalf("ListFirewallRuleTypes: %v", err) + } + + bd, err := client.BatchDeleteFirewallRule(ctx, &awsr53r.BatchDeleteFirewallRuleInput{ + DeleteFirewallRuleEntries: []r53rtypes.DeleteFirewallRuleEntry{{ + FirewallRuleGroupId: aws.String(rgID), + FirewallDomainListId: aws.String(dlID), + }}, + }) + if err != nil || len(bd.DeletedFirewallRules) != 1 { + t.Fatalf("BatchDeleteFirewallRule: %v %+v", err, bd.DeletedFirewallRules) + } + + // A rule group with live VPC associations cannot be deleted — disassociate + // first, matching real AWS. + if _, err = client.DisassociateFirewallRuleGroup(ctx, &awsr53r.DisassociateFirewallRuleGroupInput{ + FirewallRuleGroupAssociationId: aws.String(assocID), + }); err != nil { + t.Fatalf("DisassociateFirewallRuleGroup: %v", err) + } + + if _, err = client.DeleteFirewallRuleGroup(ctx, &awsr53r.DeleteFirewallRuleGroupInput{ + FirewallRuleGroupId: aws.String(rgID), + }); err != nil { + t.Fatalf("DeleteFirewallRuleGroup: %v", err) + } + if _, err = client.DeleteFirewallDomainList(ctx, &awsr53r.DeleteFirewallDomainListInput{ + FirewallDomainListId: aws.String(dlID), + }); err != nil { + t.Fatalf("DeleteFirewallDomainList: %v", err) + } + + // Error mapping: a missing rule group surfaces as ResourceNotFoundException. + _, err = client.GetFirewallRuleGroup(ctx, &awsr53r.GetFirewallRuleGroupInput{ + FirewallRuleGroupId: aws.String("rslvr-frg-missing"), + }) + var nfe *r53rtypes.ResourceNotFoundException + if !errors.As(err, &nfe) { + t.Fatalf("expected ResourceNotFoundException, got %v", err) + } +} + +// TestSDKTaggingAndPolicies covers the tagging handlers and resolver/qlc policies. +func TestSDKTaggingAndPolicies(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + rule, err := client.CreateResolverRule(ctx, &awsr53r.CreateResolverRuleInput{ + CreatorRequestId: aws.String("r"), Name: aws.String("rule"), + RuleType: r53rtypes.RuleTypeOptionForward, DomainName: aws.String("example.com"), + ResolverEndpointId: aws.String("rslvr-out-1"), + TargetIps: []r53rtypes.TargetAddress{{Ip: aws.String("10.0.0.2")}}, + }) + require(t, err, "CreateResolverRule") + arn := aws.ToString(rule.ResolverRule.Arn) + + if _, err = client.TagResource(ctx, &awsr53r.TagResourceInput{ + ResourceArn: aws.String(arn), + Tags: []r53rtypes.Tag{{Key: aws.String("team"), Value: aws.String("net")}}, + }); err != nil { + t.Fatalf("TagResource: %v", err) + } + + lt, err := client.ListTagsForResource(ctx, &awsr53r.ListTagsForResourceInput{ResourceArn: aws.String(arn)}) + if err != nil || len(lt.Tags) != 1 { + t.Fatalf("ListTagsForResource: %v %+v", err, lt.Tags) + } + + if _, err = client.UntagResource(ctx, &awsr53r.UntagResourceInput{ + ResourceArn: aws.String(arn), TagKeys: []string{"team"}, + }); err != nil { + t.Fatalf("UntagResource: %v", err) + } + + if _, err = client.PutResolverRulePolicy(ctx, &awsr53r.PutResolverRulePolicyInput{ + Arn: aws.String(arn), ResolverRulePolicy: aws.String("{}"), + }); err != nil { + t.Fatalf("PutResolverRulePolicy: %v", err) + } + if _, err = client.GetResolverRulePolicy(ctx, &awsr53r.GetResolverRulePolicyInput{Arn: aws.String(arn)}); err != nil { + t.Fatalf("GetResolverRulePolicy: %v", err) + } +} + +func require(t *testing.T, err error, op string) { + t.Helper() + + if err != nil { + t.Fatalf("%s: %v", op, err) + } +} diff --git a/server/aws/route53resolver/tags.go b/server/aws/route53resolver/tags.go new file mode 100644 index 00000000..c7a677ad --- /dev/null +++ b/server/aws/route53resolver/tags.go @@ -0,0 +1,64 @@ +package route53resolver + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire" +) + +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceArn string `json:"ResourceArn"` + Tags []wireTag `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := h.r53r.TagResource(r.Context(), req.ResourceArn, toDriverTags(req.Tags)); err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{}) +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceArn string `json:"ResourceArn"` + TagKeys []string `json:"TagKeys"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := h.r53r.UntagResource(r.Context(), req.ResourceArn, req.TagKeys); err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{}) +} + +func (h *Handler) listTagsForResource(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceArn string `json:"ResourceArn"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags, err := h.r53r.ListTagsForResource(r.Context(), req.ResourceArn) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, map[string]any{"Tags": tagsToWire(tags)}) +} diff --git a/server/aws/route53resolver/types.go b/server/aws/route53resolver/types.go new file mode 100644 index 00000000..26de35ee --- /dev/null +++ b/server/aws/route53resolver/types.go @@ -0,0 +1,128 @@ +package route53resolver + +import "github.com/stackshy/cloudemu/v2/services/route53resolver/driver" + +// --- wire shapes (AWS JSON 1.1 member names, PascalCase, in the json tags) --- + +type wireTag struct { + Key string `json:"Key"` + Value string `json:"Value"` +} + +type wireIPAddressRequest struct { + SubnetID string `json:"SubnetId"` + IP string `json:"Ip"` + IPv6 string `json:"Ipv6"` +} + +type wireIPAddressUpdate struct { + IPID string `json:"IpId"` + SubnetID string `json:"SubnetId"` + IP string `json:"Ip"` + IPv6 string `json:"Ipv6"` +} + +type wireIPAddressResponse struct { + IPID string `json:"IpId,omitempty"` + SubnetID string `json:"SubnetId,omitempty"` + IP string `json:"Ip,omitempty"` + IPv6 string `json:"Ipv6,omitempty"` + Status string `json:"Status,omitempty"` + StatusMessage string `json:"StatusMessage,omitempty"` + CreationTime string `json:"CreationTime,omitempty"` + ModificationTime string `json:"ModificationTime,omitempty"` +} + +type wireResolverEndpoint struct { + ID string `json:"Id,omitempty"` + Arn string `json:"Arn,omitempty"` + Name string `json:"Name,omitempty"` + CreatorRequestID string `json:"CreatorRequestId,omitempty"` + Direction string `json:"Direction,omitempty"` + HostVPCID string `json:"HostVPCId,omitempty"` + IPAddressCount int32 `json:"IpAddressCount"` + SecurityGroupIDs []string `json:"SecurityGroupIds,omitempty"` + Status string `json:"Status,omitempty"` + StatusMessage string `json:"StatusMessage,omitempty"` + ResolverEndpointType string `json:"ResolverEndpointType,omitempty"` + Protocols []string `json:"Protocols,omitempty"` + OutpostArn string `json:"OutpostArn,omitempty"` + PreferredInstanceType string `json:"PreferredInstanceType,omitempty"` + DNS64Enabled bool `json:"Dns64Enabled,omitempty"` + IPv6InternetAccessEnabled bool `json:"Ipv6InternetAccessEnabled,omitempty"` + CreationTime string `json:"CreationTime,omitempty"` + ModificationTime string `json:"ModificationTime,omitempty"` +} + +// --- mapping: driver <-> wire --- + +func endpointToWire(e *driver.ResolverEndpoint) wireResolverEndpoint { + return wireResolverEndpoint{ + ID: e.ID, + Arn: e.ARN, + Name: e.Name, + CreatorRequestID: e.CreatorRequestID, + Direction: e.Direction, + HostVPCID: e.HostVPCID, + IPAddressCount: e.IPAddressCount, + SecurityGroupIDs: e.SecurityGroupIDs, + Status: e.Status, + StatusMessage: e.StatusMessage, + ResolverEndpointType: e.ResolverEndpointType, + Protocols: e.Protocols, + OutpostArn: e.OutpostARN, + PreferredInstanceType: e.PreferredInstanceType, + DNS64Enabled: e.DNS64Enabled, + IPv6InternetAccessEnabled: e.IPv6InternetAccessEnabled, + CreationTime: e.CreatedAt, + ModificationTime: e.ModifiedAt, + } +} + +func ipToWire(ip *driver.IPAddress) wireIPAddressResponse { + return wireIPAddressResponse{ + IPID: ip.IPID, + SubnetID: ip.SubnetID, + IP: ip.IP, + IPv6: ip.IPv6, + Status: ip.Status, + CreationTime: ip.CreatedAt, + ModificationTime: ip.ModifiedAt, + } +} + +func ipsToWire(ips []driver.IPAddress) []wireIPAddressResponse { + out := make([]wireIPAddressResponse, 0, len(ips)) + for i := range ips { + out = append(out, ipToWire(&ips[i])) + } + + return out +} + +func toDriverIPAddresses(reqs []wireIPAddressRequest) []driver.IPAddress { + out := make([]driver.IPAddress, 0, len(reqs)) + for _, r := range reqs { + out = append(out, driver.IPAddress{SubnetID: r.SubnetID, IP: r.IP, IPv6: r.IPv6}) + } + + return out +} + +func toDriverTags(tags []wireTag) []driver.Tag { + out := make([]driver.Tag, 0, len(tags)) + for _, t := range tags { + out = append(out, driver.Tag{Key: t.Key, Value: t.Value}) + } + + return out +} + +func tagsToWire(tags []driver.Tag) []wireTag { + out := make([]wireTag, 0, len(tags)) + for _, t := range tags { + out = append(out, wireTag{Key: t.Key, Value: t.Value}) + } + + return out +} diff --git a/services/route53resolver/driver/driver.go b/services/route53resolver/driver/driver.go new file mode 100644 index 00000000..6f626f76 --- /dev/null +++ b/services/route53resolver/driver/driver.go @@ -0,0 +1,314 @@ +// Package driver defines the in-memory contract for the AWS Route 53 Resolver +// control plane. The portable service and the AWS JSON 1.1 server handler both +// depend on this interface; the aws provider implements it. +// +// The interface is composed from one sub-interface per resource group +// (resolver endpoints, resolver rules, query-log configs, resolver/DNSSEC +// configs, DNS firewall, outpost resolvers, tagging) so groups can be added +// incrementally without widening a single monolithic interface. +package driver + +import "context" + +// Route53Resolver is the full Route 53 Resolver control-plane surface. +type Route53Resolver interface { + ResolverEndpoints + ResolverRules + QueryLogConfigs + ResolverConfigs + DnssecConfigs + FirewallService + OutpostResolvers + Tagging +} + +// Tag is a resource tag. +type Tag struct { + Key string + Value string +} + +// ---- Resolver Endpoints ---------------------------------------------------- + +// IPAddress is one IP the endpoint uses for DNS queries. IP/IPv6 are the +// addresses; SubnetID is required on create; IPID/Status are server-assigned. +type IPAddress struct { + IPID string + SubnetID string + IP string + IPv6 string + Status string + CreatedAt string + ModifiedAt string +} + +// ResolverEndpoint is an inbound/outbound Resolver endpoint. +type ResolverEndpoint struct { + ID string + ARN string + Name string + CreatorRequestID string + Direction string + HostVPCID string + IPAddressCount int32 + SecurityGroupIDs []string + IPAddresses []IPAddress + Status string + StatusMessage string + ResolverEndpointType string + Protocols []string + OutpostARN string + PreferredInstanceType string + DNS64Enabled bool + IPv6InternetAccessEnabled bool + CreatedAt string + ModifiedAt string +} + +// CreateResolverEndpointInput carries the create request fields. +type CreateResolverEndpointInput struct { + CreatorRequestID string + Name string + Direction string + IPAddresses []IPAddress + SecurityGroupIDs []string + ResolverEndpointType string + Protocols []string + OutpostARN string + PreferredInstanceType string + DNS64Enabled bool + IPv6InternetAccessEnabled bool + Tags []Tag +} + +// UpdateResolverEndpointInput carries the mutable fields; a nil pointer (or nil +// slice) means "absent from the request, leave unchanged" — distinct from an +// explicit empty value. +type UpdateResolverEndpointInput struct { + Name *string + ResolverEndpointType *string + Protocols []string +} + +// ResolverEndpoints is the endpoint resource group. +type ResolverEndpoints interface { + CreateResolverEndpoint(ctx context.Context, in *CreateResolverEndpointInput) (*ResolverEndpoint, error) + GetResolverEndpoint(ctx context.Context, id string) (*ResolverEndpoint, error) + UpdateResolverEndpoint(ctx context.Context, id string, in UpdateResolverEndpointInput) (*ResolverEndpoint, error) + DeleteResolverEndpoint(ctx context.Context, id string) (*ResolverEndpoint, error) + ListResolverEndpoints(ctx context.Context) ([]ResolverEndpoint, error) + AssociateResolverEndpointIPAddress(ctx context.Context, id string, ip *IPAddress) (*ResolverEndpoint, error) + DisassociateResolverEndpointIPAddress(ctx context.Context, id string, ip *IPAddress) (*ResolverEndpoint, error) + ListResolverEndpointIPAddresses(ctx context.Context, id string) ([]IPAddress, error) +} + +// ---- Resolver Rules -------------------------------------------------------- + +// TargetAddress is one forwarding target for a FORWARD rule. +type TargetAddress struct { + IP string + IPv6 string + Port int32 + Protocol string + ServerNameIndication string +} + +// ResolverRule routes DNS queries for a domain to target IPs via an endpoint. +type ResolverRule struct { + ID string + ARN string + CreatorRequestID string + DomainName string + Name string + OwnerID string + ResolverEndpointID string + RuleType string + ShareStatus string + Status string + StatusMessage string + TargetIPs []TargetAddress + CreatedAt string + ModifiedAt string +} + +// ResolverRuleAssociation associates a rule with a VPC. +type ResolverRuleAssociation struct { + ID string + Name string + ResolverRuleID string + VPCID string + Status string + StatusMessage string +} + +// CreateResolverRuleInput carries the create request fields. +type CreateResolverRuleInput struct { + CreatorRequestID string + Name string + RuleType string + DomainName string + ResolverEndpointID string + TargetIPs []TargetAddress + Tags []Tag +} + +// UpdateResolverRuleInput carries the mutable fields (ResolverRuleConfig); a +// nil pointer (or nil slice) means "absent, leave unchanged". +type UpdateResolverRuleInput struct { + Name *string + ResolverEndpointID *string + TargetIPs []TargetAddress +} + +// ResolverRules is the resolver-rule resource group. +type ResolverRules interface { + CreateResolverRule(ctx context.Context, in *CreateResolverRuleInput) (*ResolverRule, error) + GetResolverRule(ctx context.Context, id string) (*ResolverRule, error) + UpdateResolverRule(ctx context.Context, id string, in UpdateResolverRuleInput) (*ResolverRule, error) + DeleteResolverRule(ctx context.Context, id string) (*ResolverRule, error) + ListResolverRules(ctx context.Context) ([]ResolverRule, error) + AssociateResolverRule(ctx context.Context, ruleID, vpcID, name string) (*ResolverRuleAssociation, error) + DisassociateResolverRule(ctx context.Context, ruleID, vpcID string) (*ResolverRuleAssociation, error) + GetResolverRuleAssociation(ctx context.Context, assocID string) (*ResolverRuleAssociation, error) + ListResolverRuleAssociations(ctx context.Context) ([]ResolverRuleAssociation, error) + PutResolverRulePolicy(ctx context.Context, arn, policy string) error + GetResolverRulePolicy(ctx context.Context, arn string) (string, error) +} + +// ---- Query-Log Configs ----------------------------------------------------- + +// QueryLogConfig logs DNS queries from associated VPCs to a destination. +type QueryLogConfig struct { + ID string + ARN string + AssociationCount int32 + CreatorRequestID string + DestinationARN string + Name string + OwnerID string + ShareStatus string + Status string + CreatedAt string +} + +// QueryLogConfigAssociation associates a query-log config with a VPC/resource. +type QueryLogConfigAssociation struct { + ID string + ResolverQueryLogConfigID string + ResourceID string + Status string + Error string + ErrorMessage string + CreatedAt string +} + +// CreateQueryLogConfigInput carries the create request fields. +type CreateQueryLogConfigInput struct { + CreatorRequestID string + DestinationARN string + Name string + Tags []Tag +} + +// QueryLogConfigs is the query-log-config resource group. +type QueryLogConfigs interface { + CreateResolverQueryLogConfig(ctx context.Context, in *CreateQueryLogConfigInput) (*QueryLogConfig, error) + GetResolverQueryLogConfig(ctx context.Context, id string) (*QueryLogConfig, error) + DeleteResolverQueryLogConfig(ctx context.Context, id string) (*QueryLogConfig, error) + ListResolverQueryLogConfigs(ctx context.Context) ([]QueryLogConfig, error) + AssociateResolverQueryLogConfig(ctx context.Context, configID, resourceID string) (*QueryLogConfigAssociation, error) + DisassociateResolverQueryLogConfig(ctx context.Context, configID, resourceID string) (*QueryLogConfigAssociation, error) + GetResolverQueryLogConfigAssociation(ctx context.Context, assocID string) (*QueryLogConfigAssociation, error) + ListResolverQueryLogConfigAssociations(ctx context.Context) ([]QueryLogConfigAssociation, error) + PutResolverQueryLogConfigPolicy(ctx context.Context, arn, policy string) error + GetResolverQueryLogConfigPolicy(ctx context.Context, arn string) (string, error) +} + +// ---- Resolver Configs ------------------------------------------------------ + +// ResolverConfig is the per-VPC Resolver behavior configuration. +type ResolverConfig struct { + ID string + OwnerID string + ResourceID string + AutodefinedReverse string +} + +// ResolverConfigs is the Resolver-config resource group (per-VPC autodefined +// reverse-DNS behavior). +type ResolverConfigs interface { + GetResolverConfig(ctx context.Context, resourceID string) (*ResolverConfig, error) + UpdateResolverConfig(ctx context.Context, resourceID, autodefinedReverseFlag string) (*ResolverConfig, error) + ListResolverConfigs(ctx context.Context) ([]ResolverConfig, error) +} + +// ---- DNSSEC Configs -------------------------------------------------------- + +// ResolverDnssecConfig is the per-VPC DNSSEC validation configuration. +type ResolverDnssecConfig struct { + ID string + OwnerID string + ResourceID string + ValidationStatus string +} + +// DnssecConfigs is the DNSSEC-config resource group (per-VPC DNSSEC validation). +type DnssecConfigs interface { + GetResolverDnssecConfig(ctx context.Context, resourceID string) (*ResolverDnssecConfig, error) + UpdateResolverDnssecConfig(ctx context.Context, resourceID, validation string) (*ResolverDnssecConfig, error) + ListResolverDnssecConfigs(ctx context.Context) ([]ResolverDnssecConfig, error) +} + +// ---- Outpost Resolvers ----------------------------------------------------- + +// OutpostResolver is a Resolver running on an AWS Outpost. +type OutpostResolver struct { + ID string + ARN string + Name string + CreatorRequestID string + OutpostARN string + PreferredInstanceType string + InstanceCount int32 + Status string + StatusMessage string + CreatedAt string + ModifiedAt string +} + +// CreateOutpostResolverInput carries the create request fields. +type CreateOutpostResolverInput struct { + CreatorRequestID string + Name string + OutpostARN string + PreferredInstanceType string + InstanceCount int32 + Tags []Tag +} + +// UpdateOutpostResolverInput carries the mutable fields; a nil pointer means +// "absent, leave unchanged". ID identifies the target resolver. +type UpdateOutpostResolverInput struct { + ID string + Name *string + PreferredInstanceType *string + InstanceCount *int32 +} + +// OutpostResolvers is the Outpost-resolver resource group. +type OutpostResolvers interface { + CreateOutpostResolver(ctx context.Context, in *CreateOutpostResolverInput) (*OutpostResolver, error) + GetOutpostResolver(ctx context.Context, id string) (*OutpostResolver, error) + UpdateOutpostResolver(ctx context.Context, in *UpdateOutpostResolverInput) (*OutpostResolver, error) + DeleteOutpostResolver(ctx context.Context, id string) (*OutpostResolver, error) + ListOutpostResolvers(ctx context.Context) ([]OutpostResolver, error) +} + +// ---- Tagging --------------------------------------------------------------- + +// Tagging is the shared tag surface (ARN-addressed, like real Route 53 Resolver). +type Tagging interface { + TagResource(ctx context.Context, arn string, tags []Tag) error + UntagResource(ctx context.Context, arn string, keys []string) error + ListTagsForResource(ctx context.Context, arn string) ([]Tag, error) +} diff --git a/services/route53resolver/driver/firewall.go b/services/route53resolver/driver/firewall.go new file mode 100644 index 00000000..5a1ff954 --- /dev/null +++ b/services/route53resolver/driver/firewall.go @@ -0,0 +1,178 @@ +package driver + +import "context" + +// ---- DNS Firewall ---------------------------------------------------------- + +// FirewallDomainList is a reusable set of domains a firewall rule can act on. +type FirewallDomainList struct { + ID string + ARN string + Name string + CreatorRequestID string + Category string + ManagedListType string + ManagedOwnerName string + DomainCount int32 + Status string + StatusMessage string + CreatedAt string + ModifiedAt string +} + +// FirewallRule links a domain list to an action inside a rule group. Within a +// group a rule is identified by (FirewallDomainListID, Qtype). +type FirewallRule struct { + FirewallRuleGroupID string + FirewallDomainListID string + Name string + Priority int32 + Action string + BlockResponse string + BlockOverrideDomain string + BlockOverrideDNSType string + BlockOverrideTTL int32 + Qtype string + ConfidenceThreshold string + DNSThreatProtection string + FirewallDomainRedirectionAction string + CreatorRequestID string + Status string + StatusMessage string + CreatedAt string + ModifiedAt string +} + +// FirewallRuleGroup is a named, ordered container of firewall rules. +type FirewallRuleGroup struct { + ID string + ARN string + Name string + CreatorRequestID string + OwnerID string + RuleCount int32 + ShareStatus string + Status string + StatusMessage string + CreatedAt string + ModifiedAt string +} + +// FirewallRuleGroupAssociation binds a rule group to a VPC at a priority. +type FirewallRuleGroupAssociation struct { + ID string + ARN string + Name string + CreatorRequestID string + FirewallRuleGroupID string + VPCID string + Priority int32 + MutationProtection string + ManagedOwnerName string + Status string + StatusMessage string + CreatedAt string + ModifiedAt string +} + +// FirewallConfig is the per-VPC firewall fail-open behavior. +type FirewallConfig struct { + ID string + OwnerID string + ResourceID string + FirewallFailOpen string +} + +// FirewallRuleInput carries mutable firewall-rule fields (create and update). +type FirewallRuleInput struct { + FirewallRuleGroupID string + FirewallDomainListID string + Name string + Priority int32 + Action string + BlockResponse string + BlockOverrideDomain string + BlockOverrideDNSType string + BlockOverrideTTL int32 + Qtype string + ConfidenceThreshold string + DNSThreatProtection string + FirewallDomainRedirectionAction string + CreatorRequestID string +} + +// FirewallService is the DNS Firewall resource group. +type FirewallService interface { + // Domain lists + CreateFirewallDomainList(ctx context.Context, creatorRequestID, name string, tags []Tag) (*FirewallDomainList, error) + GetFirewallDomainList(ctx context.Context, id string) (*FirewallDomainList, error) + DeleteFirewallDomainList(ctx context.Context, id string) (*FirewallDomainList, error) + ListFirewallDomainLists(ctx context.Context) ([]FirewallDomainList, error) + UpdateFirewallDomains(ctx context.Context, id, operation string, domains []string) (*FirewallDomainList, error) + ImportFirewallDomains(ctx context.Context, id, operation, domainFileURL string) (*FirewallDomainList, error) + ListFirewallDomains(ctx context.Context, id string) ([]string, error) + + // Rules + CreateFirewallRule(ctx context.Context, in *FirewallRuleInput) (*FirewallRule, error) + UpdateFirewallRule(ctx context.Context, in *FirewallRuleInput) (*FirewallRule, error) + DeleteFirewallRule(ctx context.Context, groupID, domainListID, qtype string) (*FirewallRule, error) + ListFirewallRules(ctx context.Context, groupID string) ([]FirewallRule, error) + BatchCreateFirewallRules(ctx context.Context, in []FirewallRuleInput) ([]FirewallRule, error) + BatchUpdateFirewallRules(ctx context.Context, in []FirewallRuleInput) ([]FirewallRule, error) + BatchDeleteFirewallRules(ctx context.Context, groupID string, keys []FirewallRuleKey) ([]FirewallRule, error) + + // Rule groups + CreateFirewallRuleGroup(ctx context.Context, creatorRequestID, name string, tags []Tag) (*FirewallRuleGroup, error) + GetFirewallRuleGroup(ctx context.Context, id string) (*FirewallRuleGroup, error) + DeleteFirewallRuleGroup(ctx context.Context, id string) (*FirewallRuleGroup, error) + ListFirewallRuleGroups(ctx context.Context) ([]FirewallRuleGroup, error) + PutFirewallRuleGroupPolicy(ctx context.Context, arn, policy string) error + GetFirewallRuleGroupPolicy(ctx context.Context, arn string) (string, error) + + // Rule-group associations + AssociateFirewallRuleGroup(ctx context.Context, in *AssociateFirewallRuleGroupInput) (*FirewallRuleGroupAssociation, error) + DisassociateFirewallRuleGroup(ctx context.Context, assocID string) (*FirewallRuleGroupAssociation, error) + GetFirewallRuleGroupAssociation(ctx context.Context, assocID string) (*FirewallRuleGroupAssociation, error) + ListFirewallRuleGroupAssociations(ctx context.Context) ([]FirewallRuleGroupAssociation, error) + UpdateFirewallRuleGroupAssociation(ctx context.Context, in *UpdateFirewallRuleGroupAssociationInput) (*FirewallRuleGroupAssociation, error) + + // Firewall configs + GetFirewallConfig(ctx context.Context, resourceID string) (*FirewallConfig, error) + UpdateFirewallConfig(ctx context.Context, resourceID, failOpen string) (*FirewallConfig, error) + ListFirewallConfigs(ctx context.Context) ([]FirewallConfig, error) + + // Rule-type enumeration (static descriptor list; empty in the mock). + ListFirewallRuleTypes(ctx context.Context) ([]FirewallRuleType, error) +} + +// FirewallRuleKey identifies a rule within a group for batch delete. +type FirewallRuleKey struct { + FirewallDomainListID string + Qtype string +} + +// AssociateFirewallRuleGroupInput carries the associate request fields. +type AssociateFirewallRuleGroupInput struct { + CreatorRequestID string + FirewallRuleGroupID string + Name string + Priority int32 + VPCID string + MutationProtection string + Tags []Tag +} + +// UpdateFirewallRuleGroupAssociationInput carries the mutable association +// fields; a nil pointer means "absent, leave unchanged". ID identifies the +// target association. +type UpdateFirewallRuleGroupAssociationInput struct { + ID string + MutationProtection *string + Name *string + Priority *int32 +} + +// FirewallRuleType is one entry of the rule-type descriptor enumeration. +type FirewallRuleType struct { + Name string +}