diff --git a/README.md b/README.md index 62f8804..d6f61d5 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,79 @@ # tailscale-route-tiler -This helper tool generates a list of subnets for TailScale and runs the TailScale command to update the routes. +The `tailscale-route-tiler` is a helper tool designed to automate the management of network routes for Tailscale, using configuration changes detected through AWS CloudWatch and SQS. This utility listens for messages on a specified AWS SQS queue, triggered by CloudWatch events, indicating when route updates are needed. It requires a YAML configuration file for initial setup SQS queue details, and Tailscale authentication information. -This tool uses a YAML configuration file; you can find an example in the repo. The Client ID and Tailscale key are required. +## AWS Setup + +To use `tailscale-route-tiler`, you must configure AWS CloudWatch and SQS services to trigger route updates: + +1. **SQS Queue Creation**: Create an SQS queue in AWS to receive CloudWatch events. Note the queue URL and ARN for configuration. + +2. **CloudWatch Rule Configuration**: Set up a CloudWatch rule to monitor specific events + +```json +{ + "source": ["aws.ec2"], + "detail-type": ["AWS API Call via CloudTrail"], + "detail": { + "sourceIPAddress": ["elasticloadbalancing.amazonaws.com"], + "eventName": ["CreateNetworkInterface", "DeleteNetworkInterface"], + "requestParameters": { + "description": [{ + "prefix": "ELB app/xxxxxxxx/xxxxxxx" + }, { + "prefix": "ELB app/xxxxxxx/xxxxxx" + }] + } + } +} +``` + +3. **IAM Permissions**: Ensure the IAM role or user running `tailscale-route-tiler` has permissions to read from the SQS queue and execute necessary Tailscale API calls. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ], + "Resource": "arn:aws:sqs:your-region:your-account-id:your-queue-name" + } + ] +} +``` + +### Configuration + +The tool requires a YAML configuration file with the following structure: + +```yaml +TailscaleclientId: "EXAMPLE-CLIENT-ID" +TailscaleKey: "DUMMY-KEY" +subnets: + - 10.49.0.0/24 + - 10.0.0.22/32 + - 10.0.0.0/24 +sites: + - google.com +TailscaleCommand: /usr/bin/tailscale up --accept-dns=false --advertise-routes=%s +EnableIpv6: false +Slack: + WebhookURL: https://hooks.slack.com/services/EXAMPLE/EXAMPLE/EXAMPLE + Enabled: true +SQS: + QueueURL: https://sqs.us-west-2.amazonaws.com/EXAMPLE/EXAMPLE + Region: us-west-2 +``` ## Usage ```bash - tailscale-route-tiler run -c config.yaml + tailscale-route-tiler worker -c config.yaml ``` ## Help diff --git a/cloudwatchevent/cloudwatchevent.go b/cloudwatchevent/cloudwatchevent.go new file mode 100644 index 0000000..7313a29 --- /dev/null +++ b/cloudwatchevent/cloudwatchevent.go @@ -0,0 +1,128 @@ +package cloudwatchevent + +import "time" + +// Represents the top-level structure of a CloudTrail event. +type CloudTrailEvent struct { + Version string `json:"version"` + ID string `json:"id"` + DetailType string `json:"detail-type"` + Source string `json:"source"` + Account string `json:"account"` + Time time.Time `json:"time"` + Region string `json:"region"` + Detail Detail `json:"detail"` +} + +// Detail holds information about the API call event. +type Detail struct { + EventVersion string `json:"eventVersion"` + UserIdentity UserIdentity `json:"userIdentity"` + EventTime time.Time `json:"eventTime"` + EventSource string `json:"eventSource"` + EventName string `json:"eventName"` + AwsRegion string `json:"awsRegion"` + SourceIPAddress string `json:"sourceIPAddress"` + UserAgent string `json:"userAgent"` + RequestParameters RequestParameters `json:"requestParameters"` + ResponseElements ResponseElements `json:"responseElements"` + RequestID string `json:"requestID"` + EventID string `json:"eventID"` + ReadOnly bool `json:"readOnly"` + EventType string `json:"eventType"` + ManagementEvent bool `json:"managementEvent"` + RecipientAccountId string `json:"recipientAccountId"` + EventCategory string `json:"eventCategory"` +} + +// UserIdentity describes the identity of the requester. +type UserIdentity struct { + Type string `json:"type"` + PrincipalID string `json:"principalId"` + ARN string `json:"arn"` + AccountId string `json:"accountId"` + SessionContext SessionContext `json:"sessionContext"` + InvokedBy string `json:"invokedBy"` +} + +// SessionContext provides context for the session in which the request was made. +type SessionContext struct { + SessionIssuer SessionIssuer `json:"sessionIssuer"` + Attributes Attributes `json:"attributes"` +} + +// SessionIssuer details about the entity that provided the session. +type SessionIssuer struct { + Type string `json:"type"` + PrincipalID string `json:"principalId"` + ARN string `json:"arn"` + AccountId string `json:"accountId"` + UserName string `json:"userName"` +} + +// Attributes holds session attributes such as creation time and MFA authentication status. +type Attributes struct { + CreationDate time.Time `json:"creationDate"` + MfaAuthenticated string `json:"mfaAuthenticated"` +} + +// RequestParameters includes parameters specific to the API call. +type RequestParameters struct { + SubnetId string `json:"subnetId"` + Description string `json:"description"` + GroupSet GroupSet `json:"groupSet"` + PrivateIpAddressesSet interface{} `json:"privateIpAddressesSet"` // Can be empty, adjust based on actual use + Ipv6AddressCount int `json:"ipv6AddressCount"` + ClientToken string `json:"clientToken"` +} + +// ResponseElements contains the response from the API call. +type ResponseElements struct { + RequestId string `json:"requestId"` + NetworkInterface NetworkInterface `json:"networkInterface"` +} + +// NetworkInterface details about the created or modified network interface. +type NetworkInterface struct { + NetworkInterfaceId string `json:"networkInterfaceId"` + SubnetId string `json:"subnetId"` + VpcId string `json:"vpcId"` + AvailabilityZone string `json:"availabilityZone"` + Description string `json:"description"` + OwnerId string `json:"ownerId"` + RequesterId string `json:"requesterId"` + RequesterManaged bool `json:"requesterManaged"` + Status string `json:"status"` + MacAddress string `json:"macAddress"` + PrivateIpAddress string `json:"privateIpAddress"` + PrivateDnsName string `json:"privateDnsName"` + SourceDestCheck bool `json:"sourceDestCheck"` + InterfaceType string `json:"interfaceType"` + GroupSet GroupSet `json:"groupSet"` + PrivateIpAddressesSet PrivateIpAddressesSet `json:"privateIpAddressesSet"` + Ipv6AddressesSet interface{} `json:"ipv6AddressesSet"` + TagSet interface{} `json:"tagSet"` +} + +// GroupSet represents a set of security groups. +type GroupSet struct { + Items []GroupItem `json:"items"` +} + +// GroupItem details of a single security group. +type GroupItem struct { + GroupId string `json:"groupId"` + GroupName string `json:"groupName"` // Not present in your JSON, included for completeness +} + +// PrivateIpAddressesSet structure to match the JSON structure for private IP addresses. +type PrivateIpAddressesSet struct { + Item []PrivateIpAddressSetItem `json:"item"` +} + +// PrivateIpAddressSetItem contains details about a single private IP address. +type PrivateIpAddressSetItem struct { + PrivateIpAddress string `json:"privateIpAddress"` + PrivateDnsName string `json:"privateDnsName"` + Primary bool `json:"primary"` +} diff --git a/config/config.go b/config/config.go index 4bde90b..087168d 100644 --- a/config/config.go +++ b/config/config.go @@ -18,6 +18,7 @@ type Config struct { TailscaleclientId string `yaml:"TailscaleclientId"` TailscaleKey string `yaml:"TailscaleKey"` Slack Slack `yaml:"Slack"` + SQS SQS `yaml:"SQS"` } type Slack struct { @@ -25,6 +26,11 @@ type Slack struct { Enabled bool `yaml:"Enabled"` } +type SQS struct { + QueueURL string `yaml:"QueueURL"` + Region string `yaml:"Region"` +} + var ActiveConfig *Config // ReadYAML reads the YAML configuration file diff --git a/examples/cloudwatch-rule.json b/examples/cloudwatch-rule.json new file mode 100644 index 0000000..883aee4 --- /dev/null +++ b/examples/cloudwatch-rule.json @@ -0,0 +1,27 @@ +{ + "source": [ + "aws.ec2" + ], + "detail-type": [ + "AWS API Call via CloudTrail" + ], + "detail": { + "sourceIPAddress": [ + "elasticloadbalancing.amazonaws.com" + ], + "eventName": [ + "CreateNetworkInterface", + "DeleteNetworkInterface" + ], + "requestParameters": { + "description": [ + { + "prefix": "ELB app/xxxxxxxx/xxxxxxx" + }, + { + "prefix": "ELB app/xxxxxxx/xxxxxx" + } + ] + } + } +} \ No newline at end of file diff --git a/config.yml-example b/examples/config.yml-example similarity index 79% rename from config.yml-example rename to examples/config.yml-example index 3c5950e..e403f07 100644 --- a/config.yml-example +++ b/examples/config.yml-example @@ -11,3 +11,6 @@ EnableIpv6: false Slack: WebhookURL: https://hooks.slack.com/services/EXAMPLE/EXAMPLE/EXAMPLE Enabled: true +SQS: + QueueURL: https://sqs.us-west-2.amazonaws.com/EXAMPLE/EXAMPLE + Region: us-west-2 \ No newline at end of file diff --git a/examples/example-sqs.json b/examples/example-sqs.json new file mode 100644 index 0000000..da10b80 --- /dev/null +++ b/examples/example-sqs.json @@ -0,0 +1,105 @@ +{ + "version": "0", + "id": "xxxxxxxxx-dd17-e765-4df0-xxxxxxxxxx", + "detail-type": "AWS API Call via CloudTrail", + "source": "aws.ec2", + "account": "xxxxxxxxxxx", + "time": "2024-02-02T06:34:30Z", + "region": "us-west-2", + "resources": [], + "detail": { + "eventVersion": "1.09", + "userIdentity": { + "type": "AssumedRole", + "principalId": "xxxxxxxx:ElasticLoadBalancing", + "arn": "arn:aws:sts::xxxxxxx:assumed-role/AWSServiceRoleForElasticLoadBalancing/ElasticLoadBalancing", + "accountId": "xxxxxxx", + "sessionContext": { + "sessionIssuer": { + "type": "Role", + "principalId": "xxxxxxxxxx", + "arn": "arn:aws:iam::xxxxxxx:role/aws-service-role/elasticloadbalancing.amazonaws.com/AWSServiceRoleForElasticLoadBalancing", + "accountId": "xxxxxxxxx", + "userName": "AWSServiceRoleForElasticLoadBalancing" + }, + "attributes": { + "creationDate": "2024-02-02T06:34:30Z", + "mfaAuthenticated": "false" + } + }, + "invokedBy": "elasticloadbalancing.amazonaws.com" + }, + "eventTime": "2024-02-02T06:34:30Z", + "eventSource": "ec2.amazonaws.com", + "eventName": "CreateNetworkInterface", + "awsRegion": "us-west-2", + "sourceIPAddress": "elasticloadbalancing.amazonaws.com", + "userAgent": "elasticloadbalancing.amazonaws.com", + "requestParameters": { + "subnetId": "subnet-xxxxxxx", + "description": "ELB app/xxxxxx/xxxxxxxx", + "groupSet": { + "items": [ + { + "groupId": "sg-xxxxxxxx" + }, + { + "groupId": "sg-xxxxxxx" + } + ] + }, + "privateIpAddressesSet": {}, + "ipv6AddressCount": 0, + "clientToken": "xxxxx-b2xxxxb5-xxx-ae66-xxxxx" + }, + "responseElements": { + "requestId": "xxxxxxx-ca5a-4526-96ca-xxxxxxxx", + "networkInterface": { + "networkInterfaceId": "eni-xxxxxxxxxx", + "subnetId": "subnet-xxxxxxx", + "vpcId": "vpc-xxxxxxxx", + "availabilityZone": "us-west-2b", + "description": "ELB app/xxxxxxx/cxxxxxxxxxxxx2", + "ownerId": "xxxxxxxxxx", + "requesterId": "amazon-elb", + "requesterManaged": true, + "status": "pending", + "macAddress": "xx:xx:xx:5a:78:77", + "privateIpAddress": "10.49.61.187", + "privateDnsName": "ixxxxxxxxx.us-west-2.compute.internal", + "sourceDestCheck": true, + "interfaceType": "interface", + "groupSet": { + "items": [ + { + "groupId": "sg-xxxxxxxx", + "groupName": "default" + }, + { + "groupId": "sg-xxxxxxx", + "groupName": "xxxxxx-alb" + } + ] + }, + "privateIpAddressesSet": { + "item": [ + { + "privateIpAddress": "10.49.61.0", + "privateDnsName": "xxxxxxxxxxx.us-west-2.compute.internal", + "primary": true + } + ] + }, + "ipv6AddressesSet": {}, + "tagSet": {} + } + }, + "requestID": "xxxxxxx-ca5a-4526-96ca-xxxxxxxxx", + "eventID": "xxxxxxxx-0270-4b29-8130-xxxxxxxx", + "readOnly": false, + "eventType": "AwsApiCall", + "managementEvent": true, + "recipientAccountId": "xxxxxxxxx", + "eventCategory": "Management" + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index fa2f762..df6b8d3 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,18 @@ module tailscale-route-tiller go 1.20 require ( + github.com/aws/aws-sdk-go v1.50.9 + github.com/miekg/dns v1.1.55 github.com/spf13/cobra v1.7.0 gopkg.in/yaml.v2 v2.4.0 ) require ( - github.com/go-stack/stack v1.8.1 // indirect - github.com/inconshreveable/log15 v2.16.0+incompatible // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.16 // indirect - github.com/miekg/dns v1.1.55 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/mod v0.7.0 // indirect - golang.org/x/net v0.2.0 // indirect - golang.org/x/sys v0.2.0 // indirect - golang.org/x/term v0.2.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect golang.org/x/tools v0.3.0 // indirect ) diff --git a/go.sum b/go.sum index 3fc7619..7fba7a2 100644 --- a/go.sum +++ b/go.sum @@ -1,34 +1,36 @@ +github.com/aws/aws-sdk-go v1.50.9 h1:yX66aKnEtRc/uNV/1EH8CudRT5aLwVwcSwTBphuVPt8= +github.com/aws/aws-sdk-go v1.50.9/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= -github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= -github.com/inconshreveable/log15 v2.16.0+incompatible h1:6nvMKxtGcpgm7q0KiGs+Vc+xDvUXaBqsPKHWKsinccw= -github.com/inconshreveable/log15 v2.16.0+incompatible/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM= golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 6e68046..fa915bc 100644 --- a/main.go +++ b/main.go @@ -24,7 +24,7 @@ var ( func runUpdates(testMode bool, config config.Config) { - resolvedSubnets, _, err := utils.PerformDNSLookups(config.Sites, config.EnableIpv6) + resolvedSubnets, _, err := utils.PerformDNSLookupsWithTTL(config.Sites, config.EnableIpv6) if err != nil { log.Println("Error: ", err.Error()) slack.PostError(err) @@ -127,7 +127,7 @@ func main() { // worker Command workerCmd := &cobra.Command{ Use: "worker", - Short: "Run in worker mode, will run periodically, based on the lowest record TTL", + Short: "Run in worker mode, waits for an SQS messages, then runs the tailscale command to update the routes", Run: func(cmd *cobra.Command, args []string) { initConfig(ConfigFile) worker.Run(testMode, *config.ActiveConfig) diff --git a/slack/slack.go b/slack/slack.go index cd4ec17..e6630c0 100644 --- a/slack/slack.go +++ b/slack/slack.go @@ -73,6 +73,45 @@ func PostRouteUpdate(subnets []string, nodeID string) { sendit(payload) } +func PostRouteUpdateSQS(description string, nodeID string) { + + // if !Enabled { + // return + // } + + message := SlackMessage{ + Blocks: []SlackBlock{ + { + Type: "section", + Text: struct { + Type string `json:"type"` + Text string `json:"text"` + }{ + Type: "mrkdwn", + Text: "*Updating Advertised routes for Node ID:* " + nodeID, + }, + }, + { + Type: "section", + Text: struct { + Type string `json:"type"` + Text string `json:"text"` + }{ + Type: "mrkdwn", + Text: "*Description:* " + description, + }, + }, + }, + } + + payload, err := json.Marshal(message) + if err != nil { + log.Fatal("Error marshaling Slack message:", err) + } + + sendit(payload) +} + func PostError(err error) { if !Enabled { diff --git a/utils/utils.go b/utils/utils.go index d07a1c8..3e8ca8f 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -70,7 +70,7 @@ func lookupIPsWithTTL(host string, enableIpv6 bool) ([]IPWithTTL, error) { return results, nil } -func PerformDNSLookups(sites []string, enableIPv6 bool) ([]string, int, error) { +func PerformDNSLookupsWithTTL(sites []string, enableIPv6 bool) ([]string, int, error) { var subnetsList []string var lowestTTL int = 60 diff --git a/worker/worker.go b/worker/worker.go index 7a7c80b..85b2dc2 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -1,142 +1,134 @@ package worker import ( + "encoding/json" "fmt" "log" "os" - "os/signal" "strings" - "syscall" "tailscale-route-tiller/config" "tailscale-route-tiller/slack" "tailscale-route-tiller/tailscale" "tailscale-route-tiller/utils" - "time" -) -type IPWithTTL struct { - IP string - TTL time.Duration -} + "tailscale-route-tiller/cloudwatchevent" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sqs" +) -var interval time.Duration = 1 * time.Second -var currentSubnets []string -var firstRun bool = true var TestMode bool = false var Command string -func getAddedElements(current, newArray []string) []string { - // Create a map to store the elements of the current array - currentMap := make(map[string]bool) - for _, item := range current { - currentMap[item] = true - } - - // Iterate over the new array and check for elements not present in the current array - var added []string - for _, item := range newArray { - if !currentMap[item] { - added = append(added, item) - } +func parseCloudWatchEvent(message *sqs.Message) (*cloudwatchevent.CloudTrailEvent, error) { + var event cloudwatchevent.CloudTrailEvent + err := json.Unmarshal([]byte(*message.Body), &event) + if err != nil { + return nil, err } - - return added -} - -func getRemovedElements(current, newArray []string) []string { - // Use the getAddedElements function by swapping the arrays - return getAddedElements(newArray, current) + return &event, nil } func Run(testMode bool, config config.Config) { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + // Initialize a session in us-west-2 region that the SDK will use to load credentials + sess, err := session.NewSession(&aws.Config{ + Region: aws.String("us-west-2")}, + ) - // Create a channel to control the main loop - done := make(chan bool) - - // Start the goroutine - go runUpdates(done, testMode, config) + if err != nil { + log.Fatalf("failed to create session, %v", err) + } - // Wait for termination signals - <-sigChan + // Create a SQS service client + svc := sqs.New(sess) - // Signal the main loop to stop - done <- true + for { + // Receive a message from the SQS queue + result, err := svc.ReceiveMessage(&sqs.ReceiveMessageInput{ + QueueUrl: &config.SQS.QueueURL, + MaxNumberOfMessages: aws.Int64(1), + WaitTimeSeconds: aws.Int64(20), // Use long polling + }) + + if err != nil { + log.Fatalf("Unable to receive message from queue %q, %v.", config.SQS.QueueURL, err) + } - log.Println("Program terminated") + if len(result.Messages) > 0 { + // Call a function to process the message here + // processMessage(result.Messages[0]) -} + message := result.Messages[0] -func runUpdates(done chan bool, testMode bool, config config.Config) { - sleepChan := time.After(interval) // Start initial sleep duration + if testMode { + log.Println("Test mode enabled. Message: ", *message.Body) + } - for { - select { - case <-done: - done <- true // Signal completion to the main function - return - case <-sleepChan: - // Perform updates - resolvedSubnets, lowestTTL, err := utils.PerformDNSLookups(config.Sites, config.EnableIpv6) + // lets parse the message and hand off to the runUpdates function + event, err := parseCloudWatchEvent(message) if err != nil { - log.Println("Error: ", err.Error()) - slack.PostError(err) + log.Printf("Error parsing CloudWatch event: %v", err) + continue // Skip this message or handle the error as appropriate } - // Set the Interval to the lowest TTL unless lower than 60 - if lowestTTL > 120 { - interval = time.Duration(lowestTTL) * time.Second - } else { - interval = 120 * time.Second - } - // fmt.Println("New Interval: ", interval) + runUpdates(testMode, config, event) - // Get the final list of subnets to approve - resolvedSubnets = append(resolvedSubnets, config.Subnets...) - resolvedSubnets = utils.Unique(resolvedSubnets) + // Delete the message from the queue after processing + _, err = svc.DeleteMessage(&sqs.DeleteMessageInput{ + QueueUrl: &config.SQS.QueueURL, + ReceiptHandle: message.ReceiptHandle, + }) - // Cases to handle: first run, no changes, changes - if firstRun { - currentSubnets = resolvedSubnets - firstRun = false + if err != nil { + log.Fatalf("Failed to delete message from queue, %v", err) + } + } + } +} - subnetsString := strings.Join(currentSubnets, ",") +func runUpdates(testMode bool, config config.Config, event *cloudwatchevent.CloudTrailEvent) { - fullCommand := fmt.Sprintf(config.TailscaleCommand, subnetsString) - output := utils.RunShellCommand(fullCommand, testMode) - log.Println(string(output)) - err = tailscale.SetTailscaleApprovedSubnets(resolvedSubnets) - if err != nil { - log.Println("Error: ", err.Error()) - slack.PostError(err) - os.Exit(1) - } + resolvedSubnets, _, err := utils.PerformDNSLookupsWithTTL(config.Sites, config.EnableIpv6) + if err != nil { + log.Println("Error: ", err.Error()) + slack.PostError(err) + } - slack.PostRouteUpdate(resolvedSubnets, config.TailscaleclientId) + // Get the final list of subnets to approve + resolvedSubnets = append(resolvedSubnets, config.Subnets...) + resolvedSubnets = utils.Unique(resolvedSubnets) - } else if len(resolvedSubnets) == len(currentSubnets) { + log.Println("Resolved subnets: ", resolvedSubnets) - log.Println("No changes detected, moving along, New Interval: " + interval.String()) + // format subnets as a string for the tailscale command + subnetsString := strings.Join(resolvedSubnets, ",") - } else { - log.Println("Changes detected, updating, New Interval: " + interval.String()) + // combine the command with the subnets + fullCommand := fmt.Sprintf(config.TailscaleCommand, subnetsString) - subnetsString := strings.Join(resolvedSubnets, ",") + if testMode { + log.Println("Test mode enabled. Command: ", fullCommand) + } else { + // run the command + output := utils.RunShellCommand(fullCommand, testMode) - fullCommand := fmt.Sprintf(config.TailscaleCommand, subnetsString) - output := utils.RunShellCommand(fullCommand, testMode) - log.Println(string(output)) - err = tailscale.SetTailscaleApprovedSubnets(resolvedSubnets) - if err != nil { - log.Println("Error: ", err.Error()) - slack.PostError(err) - os.Exit(1) - } + // log the output + log.Println(string(output)) + } - sleepChan = time.After(interval) // Reset sleep duration - } + networkDescription := event.Detail.RequestParameters.Description + slack.PostRouteUpdateSQS(networkDescription, config.TailscaleclientId) + + if testMode { + log.Println("Test mode enabled, not updating tailscale routes.") + } else { + err = tailscale.SetTailscaleApprovedSubnets(resolvedSubnets) + if err != nil { + log.Println("Error: ", err.Error()) + slack.PostError(err) + os.Exit(1) } } }