records.go 7.7 KB

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