package cmd import ( "fmt" "strings" "github.com/spf13/cobra" "schlundtech-dns/internal/api" ) var recordsCmd = &cobra.Command{ Use: "records", Short: "Read and change the records of a zone", Long: `Read and change the records of a zone. A record is addressed by its name relative to the zone: "@" is the zone apex, "www" is www.example.com. A name and type can hold several values. Setting a record replaces every value that name and type currently has, so there is nothing to delete first. Writes are public the moment they land, so run them with --dry-run first.`, Example: ` schlundtech-dns records list example.com schlundtech-dns records list example.com --type TXT`, } // recordView is the JSON shape of a record. type recordView struct { Name string `json:"name"` Type string `json:"type"` Value string `json:"value"` TTL int `json:"ttl"` } var recordsListCmd = &cobra.Command{ Use: "list ", Short: "Show the records of a zone", Long: `Show the records of a zone. Use --type to narrow the output to one record type, for example TXT, A or AAAA. This is read-only.`, Example: ` schlundtech-dns records list example.com schlundtech-dns records list example.com --type TXT schlundtech-dns records list example.com --output json`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { f, err := parseFormat(flagOutput) if err != nil { return err } recordType, _ := cmd.Flags().GetString("type") c, err := client() if err != nil { return err } records, err := c.Records(cmd.Context(), args[0], recordType) if err != nil { return err } views := make([]recordView, 0, len(records)) rows := make([][]string, 0, len(records)) for _, rr := range records { views = append(views, recordView{ Name: displayName(rr.Name), Type: rr.Type, Value: rr.Value, TTL: rr.TTL, }) rows = append(rows, []string{displayName(rr.Name), rr.Type, rr.Value, fmt.Sprint(rr.TTL)}) } return writeOutput(cmd.OutOrStdout(), f, []string{"NAME", "TYPE", "VALUE", "TTL"}, rows, views) }, } var recordsSetCmd = &cobra.Command{ Use: "set ", Short: "Replace the records at one name and type", Long: `Replace every value of one record name and type. Pass --value more than once to set several values at the same name. Anything currently there is removed in the same task, so a rejected change leaves the zone untouched. --ttl defaults to the TTL the record already has. Use @ for the zone apex.`, Example: ` # preview first, then apply schlundtech-dns records set example.com --name @ --type A --value 203.0.113.10 --dry-run schlundtech-dns records set example.com --name @ --type A --value 203.0.113.10 # several TXT values at once schlundtech-dns records set example.com --name @ --type TXT \ --value 'v=spf1 -all' --value 'google-site-verification=abc'`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { zone := args[0] name, _ := cmd.Flags().GetString("name") recordType, _ := cmd.Flags().GetString("type") values, _ := cmd.Flags().GetStringArray("value") ttl, _ := cmd.Flags().GetInt("ttl") if err := requireType(recordType); err != nil { return err } if len(values) == 0 { return fmt.Errorf("--value is required") } c, err := client() if err != nil { return err } if flagDryRun { plan, err := c.PreviewSet(cmd.Context(), zone, name, recordType, values, ttl) if err != nil { return err } fmt.Fprint(cmd.OutOrStdout(), plan.String()) fmt.Fprintln(cmd.OutOrStdout(), "dry run: nothing was sent to the gateway") return nil } if err := c.SetRecords(cmd.Context(), zone, name, recordType, values, ttl); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "set %s %s at %s in zone %s\n", recordType, joinValues(values), displayName(name), zone) return nil }, } var recordsDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Remove records at one name and type", Long: `Remove records at one name and type. Without --value every record of that name and type goes. With one or more --value only those are removed.`, Example: ` schlundtech-dns records delete example.com --name @ --type TXT --value 'v=spf1 -all' --dry-run schlundtech-dns records delete example.com --name @ --type TXT --value 'v=spf1 -all'`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { zone := args[0] name, _ := cmd.Flags().GetString("name") recordType, _ := cmd.Flags().GetString("type") values, _ := cmd.Flags().GetStringArray("value") if err := requireType(recordType); err != nil { return err } c, err := client() if err != nil { return err } if flagDryRun { records, err := c.Records(cmd.Context(), zone, recordType) if err != nil { return err } target, err := matchRecords(records, name, recordType, values) if err != nil { return fmt.Errorf("nothing to delete: %w", err) } out := cmd.OutOrStdout() for _, rr := range target { fmt.Fprintf(out, "would remove %s %s at %s in zone %s\n", rr.Type, rr.Value, displayName(rr.Name), zone) } fmt.Fprintln(out, "dry run: nothing was sent to the gateway") return nil } if err := c.DeleteRecords(cmd.Context(), zone, name, recordType, values); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "removed %s %s from %s in zone %s\n", recordType, joinValues(values), displayName(name), zone) return nil }, } // requireType rejects an empty --type, which would otherwise be sent to the // gateway as a blank record type. func requireType(recordType string) error { if strings.TrimSpace(recordType) == "" { return fmt.Errorf("--type is required, for example TXT, A or AAAA") } return nil } // displayName renders the gateway's empty apex name for humans. func displayName(name string) string { if name == "" { return api.Apex } return name } // joinValues summarises a value list for a confirmation line. func joinValues(values []string) string { if len(values) == 0 { return "(all)" } return strings.Join(values, ", ") } // matchRecords picks the records a delete would touch, so a dry run shows the // same set the real command removes. func matchRecords(records []api.Record, name, recordType string, values []string) ([]api.Record, error) { name = strings.TrimSuffix(name, ".") if name == api.Apex { name = "" } wanted := map[string]bool{} for _, v := range values { wanted[v] = true } var out []api.Record for _, rr := range records { if !strings.EqualFold(rr.Type, recordType) || rr.Name != name { continue } if len(wanted) > 0 && !wanted[rr.Value] { continue } out = append(out, rr) } if len(out) == 0 { if len(values) == 0 { return nil, fmt.Errorf("no %s record at %s", recordType, displayName(name)) } return nil, fmt.Errorf("none of the given values is a %s record at %s", recordType, displayName(name)) } return out, nil } func init() { recordsListCmd.Flags().String("type", "", "only show records of this type, for example TXT") recordsSetCmd.Flags().String("name", api.Apex, `record name relative to the zone; "@" is the zone apex`) recordsSetCmd.Flags().String("type", "", "record type, for example TXT, A or AAAA") recordsSetCmd.Flags().StringArray("value", nil, "record value; repeat for several values at the same name") recordsSetCmd.Flags().Int("ttl", 0, "TTL in seconds; defaults to the current TTL") recordsDeleteCmd.Flags().String("name", api.Apex, `record name relative to the zone; "@" is the zone apex`) recordsDeleteCmd.Flags().String("type", "", "record type, for example TXT, A or AAAA") recordsDeleteCmd.Flags().StringArray("value", nil, "only remove this value; repeat for several") recordsCmd.AddCommand(recordsListCmd, recordsSetCmd, recordsDeleteCmd) rootCmd.AddCommand(recordsCmd) }