diff --git a/cmd/r53/main.go b/cmd/r53/main.go new file mode 100644 index 0000000..caa119b --- /dev/null +++ b/cmd/r53/main.go @@ -0,0 +1,175 @@ +package main + +import ( + "context" + "encoding/json/jsontext" + "encoding/json/v2" + "flag" + "fmt" + "log/slog" + "os" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/route53" + "github.com/aws/aws-sdk-go-v2/service/route53/types" + + "code.chimeric.al/dwrz/r53/pkg/digip" +) + +var ( + ttl = flag.Int64("ttl", 300, "TTL of the DNS record; seconds") + zone = flag.String("zone", "", "hosted zone id") +) + +func main() { + flag.Parse() + + var log = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelDebug, + })) + + // Setup the main context. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := run(ctx, log); err != nil { + log.Error("failed", slog.Any("error", err)) + os.Exit(1) + } +} + +func run(ctx context.Context, log *slog.Logger) error { + if *zone == "" { + return fmt.Errorf("missing hosted zone id") + } + + // Load the AWS configuration. + awscfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return fmt.Errorf("failed to retrieve AWS config: %w", err) + } + + // Create the Route53 client. + var svc = route53.NewFromConfig(awscfg) + + // Get the IP address. + var ip4, ip6 string + + // Dig with OpenDNS. + if ip, err := digip.OpenDNS(ctx, digip.A); err != nil { + log.ErrorContext( + ctx, + "failed to get opendns ipv4 ip", + slog.Any("error", err), + ) + } else { + ip4 = ip.String() + } + if ip, err := digip.OpenDNS(ctx, digip.AAAA); err != nil { + log.ErrorContext( + ctx, + "failed to get opendns ipv6 ip", + slog.Any("error", err), + ) + } else { + ip6 = ip.String() + } + + // Fallback on Google. + if ip4 == "" { + if ip, err := digip.Google(ctx, digip.V4); err != nil { + log.ErrorContext( + ctx, + "failed to get google ipv4 ip", + slog.Any("error", err), + ) + } else { + ip4 = ip.String() + } + } + if ip6 == "" { + if ip, err := digip.Google(ctx, digip.V6); err != nil { + log.ErrorContext( + ctx, + "failed to get google ipv6 ip", + slog.Any("error", err), + ) + } else { + ip6 = ip.String() + } + } + + // Abort if we don't have any IP address. + if ip4 == "" && ip6 == "" { + return fmt.Errorf("failed to retrieve public ip") + } + + // Update the domains. + for _, domain := range flag.Args() { + // Assemble the change(s). + var changes []types.Change + if ip4 != "" { + change := types.Change{ + Action: types.ChangeActionUpsert, + ResourceRecordSet: &types.ResourceRecordSet{ + Name: &domain, + ResourceRecords: []types.ResourceRecord{ + {Value: &ip4}, + }, + TTL: ttl, + Type: types.RRTypeA, + }, + } + + changes = append(changes, change) + } + if ip6 != "" { + change := types.Change{ + Action: types.ChangeActionUpsert, + ResourceRecordSet: &types.ResourceRecordSet{ + Name: &domain, + ResourceRecords: []types.ResourceRecord{ + {Value: &ip6}}, + TTL: ttl, + Type: types.RRTypeAaaa, + }, + } + changes = append(changes, change) + } + + input := &route53.ChangeResourceRecordSetsInput{ + ChangeBatch: &types.ChangeBatch{ + Changes: changes, + Comment: aws.String("DDNS"), + }, + HostedZoneId: zone, + } + + result, err := svc.ChangeResourceRecordSets(ctx, input) + if err != nil { + return fmt.Errorf("failed to update route 53: %w", err) + } + + outcome, err := json.Marshal(result, jsontext.WithIndent(" ")) + if err != nil { + log.ErrorContext( + ctx, + "failed to unmarshal response", + slog.Any("error", err), + ) + + outcome = []byte(result.ChangeInfo.Status) + } + + log.DebugContext( + ctx, + "updated route 53 records", + slog.String("domain", domain), + slog.Any("outcome", outcome), + ) + } + + return nil +}