records.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. package cmd
  2. import (
  3. "fmt"
  4. "strings"
  5. "github.com/spf13/cobra"
  6. "schlundtech-dns/internal/api"
  7. )
  8. var recordsCmd = &cobra.Command{
  9. Use: "records",
  10. Short: "Read and change the records of a zone",
  11. Long: `Read and change the records of a zone.
  12. A record is addressed by its name relative to the zone: "@" is the zone apex,
  13. "www" is www.example.com. A name and type can hold several values.
  14. Setting a record replaces every value that name and type currently has, so
  15. there is nothing to delete first. Writes are public the moment they land, so
  16. run them with --dry-run first.`,
  17. Example: ` schlundtech-dns records list example.com
  18. schlundtech-dns records list example.com --type TXT`,
  19. }
  20. // recordView is the JSON shape of a record.
  21. type recordView struct {
  22. Name string `json:"name"`
  23. Type string `json:"type"`
  24. Value string `json:"value"`
  25. TTL int `json:"ttl"`
  26. }
  27. var recordsListCmd = &cobra.Command{
  28. Use: "list <zone>",
  29. Short: "Show the records of a zone",
  30. Long: `Show the records of a zone. Use --type to narrow the output to one record
  31. type, for example TXT, A or AAAA.
  32. This is read-only.`,
  33. Example: ` schlundtech-dns records list example.com
  34. schlundtech-dns records list example.com --type TXT
  35. schlundtech-dns records list example.com --output json`,
  36. Args: cobra.ExactArgs(1),
  37. RunE: func(cmd *cobra.Command, args []string) error {
  38. f, err := parseFormat(flagOutput)
  39. if err != nil {
  40. return err
  41. }
  42. recordType, _ := cmd.Flags().GetString("type")
  43. c, err := client()
  44. if err != nil {
  45. return err
  46. }
  47. records, err := c.Records(cmd.Context(), args[0], recordType)
  48. if err != nil {
  49. return err
  50. }
  51. views := make([]recordView, 0, len(records))
  52. rows := make([][]string, 0, len(records))
  53. for _, rr := range records {
  54. views = append(views, recordView{
  55. Name: displayName(rr.Name), Type: rr.Type, Value: rr.Value, TTL: rr.TTL,
  56. })
  57. rows = append(rows, []string{displayName(rr.Name), rr.Type, rr.Value, fmt.Sprint(rr.TTL)})
  58. }
  59. return writeOutput(cmd.OutOrStdout(), f, []string{"NAME", "TYPE", "VALUE", "TTL"}, rows, views)
  60. },
  61. }
  62. var recordsSetCmd = &cobra.Command{
  63. Use: "set <zone>",
  64. Short: "Replace the records at one name and type",
  65. Long: `Replace every value of one record name and type.
  66. Pass --value more than once to set several values at the same name. Anything
  67. currently there is removed in the same task, so a rejected change leaves the
  68. zone untouched.
  69. --ttl defaults to the TTL the record already has. Use @ for the zone apex.`,
  70. Example: ` # preview first, then apply
  71. schlundtech-dns records set example.com --name @ --type A --value 203.0.113.10 --dry-run
  72. schlundtech-dns records set example.com --name @ --type A --value 203.0.113.10
  73. # several TXT values at once
  74. schlundtech-dns records set example.com --name @ --type TXT \
  75. --value 'v=spf1 -all' --value 'google-site-verification=abc'`,
  76. Args: cobra.ExactArgs(1),
  77. RunE: func(cmd *cobra.Command, args []string) error {
  78. zone := args[0]
  79. name, _ := cmd.Flags().GetString("name")
  80. recordType, _ := cmd.Flags().GetString("type")
  81. values, _ := cmd.Flags().GetStringArray("value")
  82. ttl, _ := cmd.Flags().GetInt("ttl")
  83. if err := requireType(recordType); err != nil {
  84. return err
  85. }
  86. if len(values) == 0 {
  87. return fmt.Errorf("--value is required")
  88. }
  89. c, err := client()
  90. if err != nil {
  91. return err
  92. }
  93. if flagDryRun {
  94. plan, err := c.PreviewSet(cmd.Context(), zone, name, recordType, values, ttl)
  95. if err != nil {
  96. return err
  97. }
  98. fmt.Fprint(cmd.OutOrStdout(), plan.String())
  99. fmt.Fprintln(cmd.OutOrStdout(), "dry run: nothing was sent to the gateway")
  100. return nil
  101. }
  102. if err := c.SetRecords(cmd.Context(), zone, name, recordType, values, ttl); err != nil {
  103. return err
  104. }
  105. fmt.Fprintf(cmd.OutOrStdout(), "set %s %s at %s in zone %s\n",
  106. recordType, joinValues(values), displayName(name), zone)
  107. return nil
  108. },
  109. }
  110. var recordsDeleteCmd = &cobra.Command{
  111. Use: "delete <zone>",
  112. Short: "Remove records at one name and type",
  113. Long: `Remove records at one name and type.
  114. Without --value every record of that name and type goes. With one or more
  115. --value only those are removed.`,
  116. Example: ` schlundtech-dns records delete example.com --name @ --type TXT --value 'v=spf1 -all' --dry-run
  117. schlundtech-dns records delete example.com --name @ --type TXT --value 'v=spf1 -all'`,
  118. Args: cobra.ExactArgs(1),
  119. RunE: func(cmd *cobra.Command, args []string) error {
  120. zone := args[0]
  121. name, _ := cmd.Flags().GetString("name")
  122. recordType, _ := cmd.Flags().GetString("type")
  123. values, _ := cmd.Flags().GetStringArray("value")
  124. if err := requireType(recordType); err != nil {
  125. return err
  126. }
  127. c, err := client()
  128. if err != nil {
  129. return err
  130. }
  131. if flagDryRun {
  132. records, err := c.Records(cmd.Context(), zone, recordType)
  133. if err != nil {
  134. return err
  135. }
  136. target, err := matchRecords(records, name, recordType, values)
  137. if err != nil {
  138. return fmt.Errorf("nothing to delete: %w", err)
  139. }
  140. out := cmd.OutOrStdout()
  141. for _, rr := range target {
  142. fmt.Fprintf(out, "would remove %s %s at %s in zone %s\n",
  143. rr.Type, rr.Value, displayName(rr.Name), zone)
  144. }
  145. fmt.Fprintln(out, "dry run: nothing was sent to the gateway")
  146. return nil
  147. }
  148. if err := c.DeleteRecords(cmd.Context(), zone, name, recordType, values); err != nil {
  149. return err
  150. }
  151. fmt.Fprintf(cmd.OutOrStdout(), "removed %s %s from %s in zone %s\n",
  152. recordType, joinValues(values), displayName(name), zone)
  153. return nil
  154. },
  155. }
  156. // requireType rejects an empty --type, which would otherwise be sent to the
  157. // gateway as a blank record type.
  158. func requireType(recordType string) error {
  159. if strings.TrimSpace(recordType) == "" {
  160. return fmt.Errorf("--type is required, for example TXT, A or AAAA")
  161. }
  162. return nil
  163. }
  164. // displayName renders the gateway's empty apex name for humans.
  165. func displayName(name string) string {
  166. if name == "" {
  167. return api.Apex
  168. }
  169. return name
  170. }
  171. // joinValues summarises a value list for a confirmation line.
  172. func joinValues(values []string) string {
  173. if len(values) == 0 {
  174. return "(all)"
  175. }
  176. return strings.Join(values, ", ")
  177. }
  178. // matchRecords picks the records a delete would touch, so a dry run shows the
  179. // same set the real command removes.
  180. func matchRecords(records []api.Record, name, recordType string, values []string) ([]api.Record, error) {
  181. name = strings.TrimSuffix(name, ".")
  182. if name == api.Apex {
  183. name = ""
  184. }
  185. wanted := map[string]bool{}
  186. for _, v := range values {
  187. wanted[v] = true
  188. }
  189. var out []api.Record
  190. for _, rr := range records {
  191. if !strings.EqualFold(rr.Type, recordType) || rr.Name != name {
  192. continue
  193. }
  194. if len(wanted) > 0 && !wanted[rr.Value] {
  195. continue
  196. }
  197. out = append(out, rr)
  198. }
  199. if len(out) == 0 {
  200. if len(values) == 0 {
  201. return nil, fmt.Errorf("no %s record at %s", recordType, displayName(name))
  202. }
  203. return nil, fmt.Errorf("none of the given values is a %s record at %s", recordType, displayName(name))
  204. }
  205. return out, nil
  206. }
  207. func init() {
  208. recordsListCmd.Flags().String("type", "", "only show records of this type, for example TXT")
  209. recordsSetCmd.Flags().String("name", api.Apex, `record name relative to the zone; "@" is the zone apex`)
  210. recordsSetCmd.Flags().String("type", "", "record type, for example TXT, A or AAAA")
  211. recordsSetCmd.Flags().StringArray("value", nil, "record value; repeat for several values at the same name")
  212. recordsSetCmd.Flags().Int("ttl", 0, "TTL in seconds; defaults to the current TTL")
  213. recordsDeleteCmd.Flags().String("name", api.Apex, `record name relative to the zone; "@" is the zone apex`)
  214. recordsDeleteCmd.Flags().String("type", "", "record type, for example TXT, A or AAAA")
  215. recordsDeleteCmd.Flags().StringArray("value", nil, "only remove this value; repeat for several")
  216. recordsCmd.AddCommand(recordsListCmd, recordsSetCmd, recordsDeleteCmd)
  217. rootCmd.AddCommand(recordsCmd)
  218. }