cli

package module
v0.0.0-...-279a53c Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 29, 2026 License: BSD-2-Clause Imports: 42 Imported by: 1

Documentation

Overview

* Copyright (c) 2024 Johan Stenstam, johani@johani.org

* Copyright (c) 2024 Johan Stenstam, johani@johani.org

* Copyright (c) 2025 Johan Stenstam, johan.stenstam@internetstiftelsen.se

* Copyright (c) 2026 Johan Stenstam, johan.stenstam@internetstiftelsen.se

* Copyright (c) Johan Stenstam, johani@johani.org * * API client configuration, role → clientKey registry, and the * shared InitApiClients helper used by each CLI binary's root.go.

* Copyright (c) 2024 Johan Stenstam, johani@johani.org

* Copyright (c) 2024 Johan Stenstam, johani@johani.org

* Copyright (c) 2024 Johan Stenstam, johan.stenstam@internetstiftelsen.se

* Copyright (c) Johan Stenstam, johani@johani.org

* Copyright (c) 2024 Johan Stenstam, johani@johani.org

* Copyright (c) Johan Stenstam, johani@johani.org

* Copyright (c) Johan Stenstam, johani@johani.org

* Copyright (c) 2024 Johan Stenstam, johani@johani.org

* Copyright (c) Johan Stenstam, johani@johani.org

* Copyright (c) Johan Stenstam, johani@johani.org

* Copyright (c) 2024 Johan Stenstam, johani@johani.org

* Johan Stenstam

* Copyright (c) 2026 Johan Stenstam, johani@johani.org

* Copyright (c) Johan Stenstam, johani@johani.org

* Copyright (c) Johan Stenstam, johan.stenstam@internetstiftelsen.se

* Copyright (c) 2024 Johan Stenstam, johani@johani.org

* Copyright (c) Johan Stenstam, johani@johani.org

* Copyright (c) 2024 Johan Stenstam, johani@johani.org

* Copyright (c) 2024 Johan Stenstam, johani@johani.org

* Copyright (c) Johan Stenstam, johan.stenstam@internetstiftelsen.se

* Johan Stenstam

* Copyright (c) Johan Stenstam, johani@johani.org

* Copyright (c) Johan Stenstam, johani@johani.org

Index

Constants

View Source
const DefaultDomainSuffix = "example.com."

Default domain suffix for CLI

Variables

View Source
var AgentCmd = &cobra.Command{
	Use:   "agent",
	Short: "TDNS Agent commands",
}
View Source
var AgentZoneCmd = &cobra.Command{
	Use:   "zone",
	Short: "Agent zone management commands",
}

AgentZoneCmd is the agent-specific "zone" command group. It contains only the zone subcommands relevant to the agent, plus the new addrr/delrr commands for managing synced RRs.

View Source
var AuthCmd = &cobra.Command{
	Use:   "auth",
	Short: "Interact with tdns-auth (authoritative) via API",
}

AuthCmd is the parent command for all auth-related commands

View Source
var Base32Cmd = &cobra.Command{
	Use:   "base32",
	Short: "Convert data to/from base32 encoding and domain format",
	Long: `This command converts data to or from base32 encoding and domain format.
It can read from standard input or from a file.

Examples:
  echo '{"name":"example","value":123}' | tdns base32 encode --suffix=example.com.
  cat domains.txt | tdns base32 decode`,
}

base32Cmd represents the base32 command

View Source
var Base32decodeCmd = &cobra.Command{
	Use:   "decode",
	Short: "Decode base32 domain data to JSON",
	Long:  `Decode base32 domain data from stdin to JSON.`,
	Run: func(cmd *cobra.Command, args []string) {

		stat, _ := os.Stdin.Stat()
		if (stat.Mode() & os.ModeCharDevice) != 0 {
			fmt.Println("No stdin data provided. Please pipe domain data to this command.")
			fmt.Println("Example: cat domains.txt | tdns base32 decode")
			return
		}

		cookie, _ := cmd.Flags().GetString("cookie")

		reader := bufio.NewReader(os.Stdin)
		processBase32DomainsToJson(reader, cmd, cookie)
	},
}

decodeCmd represents the decode subcommand

View Source
var Base32encodeCmd = &cobra.Command{
	Use:   "encode",
	Short: "Encode JSON data to base32 domain format",
	Long:  `Encode JSON data from stdin to base32 domain format.`,
	Run: func(cmd *cobra.Command, args []string) {

		stat, _ := os.Stdin.Stat()
		if (stat.Mode() & os.ModeCharDevice) != 0 {
			fmt.Println("No stdin data provided. Please pipe JSON data to this command.")
			fmt.Println("Example: echo '{\"name\":\"example\"}' | tdns base32 encode --suffix=example.com.")
			return
		}

		suffix, _ := cmd.Flags().GetString("suffix")
		cookie, _ := cmd.Flags().GetString("cookie")

		if suffix == "" {
			fmt.Fprintf(os.Stderr, "Error: domain suffix is required. Use --suffix flag.\n")
			return
		}

		if !strings.HasSuffix(suffix, ".") {
			suffix += "."
			fmt.Fprintf(os.Stderr, "Note: Added trailing dot to domain suffix: %s\n", suffix)
		}

		reader := bufio.NewReader(os.Stdin)
		processJsonToBase32Domains(reader, suffix, cookie)
	},
}

encodeCmd represents the encode subcommand

View Source
var CatalogCmd = &cobra.Command{
	Use:   "catalog",
	Short: "Manage catalog zones (RFC 9432)",
	Long:  `Create and manage catalog zones, add/remove member zones and groups.`,
}

CatalogCmd is the root command for catalog zone management

View Source
var CatalogGroupCmd = &cobra.Command{
	Use:   "group",
	Short: "Manage groups in catalog",
}

CatalogGroupCmd is the subcommand group for group operations

View Source
var CatalogNotifyCmd = &cobra.Command{
	Use:   "notify",
	Short: "Manage notify addresses for catalog zones",
}

CatalogNotifyCmd is the subcommand group for notify address operations

View Source
var CatalogZoneCmd = &cobra.Command{
	Use:   "zone",
	Short: "Manage member zones in catalog",
}

CatalogZoneCmd is the subcommand group for zone operations

View Source
var CatalogZoneGroupCmd = &cobra.Command{
	Use:   "group",
	Short: "Manage group associations for zones",
}

CatalogZoneGroupCmd is the subcommand group for zone-group associations

View Source
var ChildCmd = &cobra.Command{
	Use:   "child",
	Short: "Prefix, only useable via the 'child update create' subcommand",
}
View Source
var DbCmd = &cobra.Command{
	Use:   "db",
	Short: "TDNS DB commands",
}
View Source
var DdnsCmd = &cobra.Command{
	Use:   "ddns",
	Short: "Send a DDNS update. Only usable via sub-commands.",
}
View Source
var DelCmd = &cobra.Command{
	Use:   "del",
	Short: "Delegation prefix command. Only usable via sub-commands.",
}
View Source
var DeleteCmd = &cobra.Command{
	Use:   "delete [job-id]",
	Short: "Delete scan job(s)",
	Long:  `Delete a specific scan job by job ID, or all jobs if --all is used`,
	Args:  cobra.MaximumNArgs(1),
	Run: func(cmd *cobra.Command, args []string) {

		api, err := GetApiClient("scanner", true)
		if err != nil {
			log.Fatalf("Error getting API client for scanner: %v", err)
		}

		deleteAll, _ := cmd.Flags().GetBool("all")

		var endpoint string
		if deleteAll {
			endpoint = "/scanner/delete?all=true"
		} else if len(args) > 0 {
			endpoint = fmt.Sprintf("/scanner/delete?job_id=%s", args[0])
		} else {
			log.Fatal("Error: either specify a job ID or use --all flag")
		}

		status, buf, err := api.RequestNG("DELETE", endpoint, nil, true)
		if err != nil {
			log.Fatalf("Error from scanner API: %v", err)
		}

		if status == http.StatusNotFound {
			log.Fatalf("Job not found")
		}
		if status == http.StatusBadRequest {
			log.Fatalf("Bad request: %s", string(buf))
		}
		if status != http.StatusOK {
			log.Fatalf("Unexpected status code: %d", status)
		}

		var resp tdns.ScannerResponse
		err = json.Unmarshal(buf, &resp)
		if err != nil {
			log.Fatalf("Error unmarshaling response: %v", err)
		}

		if resp.Error {
			log.Fatalf("Error: %s", resp.ErrorMsg)
		}

		fmt.Printf("%s\n", resp.Msg)
	},
}
View Source
var DsyncDiscoveryCmd = &cobra.Command{
	Use:   "dsync-query",
	Short: "Send a DNS query for 'zone. DSYNC' and present the result.",
	Run: func(cmd *cobra.Command, args []string) {

		PrepArgs("zonename")
		tdns.Globals.Zonename = dns.Fqdn(tdns.Globals.Zonename)

		ctx, cancel, imr, err := StartImrForCli("")
		if err != nil {
			log.Fatalf("Error: %v", err)
		}
		defer cancel()

		dsync_res, err := imr.DsyncDiscovery(ctx, tdns.Globals.Zonename, tdns.Globals.Verbose)
		if err != nil {
			log.Fatalf("Error: %v", err)
		}

		fmt.Printf("Parent: %s\n", dsync_res.Parent)
		if len(dsync_res.Rdata) == 0 {
			fmt.Printf("No DSYNC record associated with '%s'\n", tdns.Globals.Zonename)
		} else {
			for _, nr := range dsync_res.Rdata {
				fmt.Printf("%s\tIN\tDSYNC\t%s\n", dsync_res.Qname, nr.String())
			}
		}
	},
}
View Source
var ExitCmd = &cobra.Command{
	Use:   "exit",
	Short: "Exit the interactive shell",
	Run: func(cmd *cobra.Command, args []string) {
		Terminate()
	},
}

exitCmd represents the exit command

View Source
var (
	GenerateCmd = &cobra.Command{
		Use:   "generate",
		Short: "Generate DNS records or encodings",
	}
)
View Source
var ImrCmd = &cobra.Command{
	Use:   "imr",
	Short: "Interact with tdns-imr via API",
}

ImrCmd is the parent command for all IMR-related commands

View Source
var ImrDumpCmd = &cobra.Command{
	Use:   "dump",
	Short: "List records in the RRsetCache",

	Run: func(cmd *cobra.Command, args []string) {
		fmt.Printf("Listing records in the RRsetCache\n")
		if Conf.Internal.RRsetCache == nil {
			fmt.Println("RRsetCache is nil")
			return
		}

		items := []core.Tuple[string, cache.CachedRRset]{}
		for item := range Conf.Internal.RRsetCache.RRsets.IterBuffered() {
			items = append(items, item)
		}
		sort.Slice(items, func(i, j int) bool {
			return lessByReverseLabels(items[i].Val.Name, items[j].Val.Name)
		})
		for _, it := range items {
			PrintCacheItem(it, ".")
		}
	},
}
View Source
var ImrFlushCmd = &cobra.Command{
	Use:   "flush",
	Short: "Flush cached data",
}
View Source
var ImrQueryCmd = &cobra.Command{
	Use:   "query [name] [type]",
	Short: "Query DNS records",
	Long:  `Query DNS records for a given name and type`,
	Args:  cobra.ExactArgs(2),
	Run: func(cmd *cobra.Command, args []string) {
		if len(args) < 2 {
			fmt.Println("Error: both name and type are required.")
			_ = cmd.Usage()
			return
		}
		fmt.Printf("Querying %s for %s records (verbose mode: %t)\n", args[0], args[1], tdns.Globals.Verbose)

		qname := dns.Fqdn(args[0])
		if _, ok := dns.IsDomainName(qname); !ok {
			fmt.Printf("Not a valid domain name: '%s'\n", qname)
			return
		}

		qtype, exist := dns.StringToType[strings.ToUpper(args[1])]
		if !exist {
			fmt.Printf("Not a valid DNS RR type: '%s'\n", args[1])
			return
		}

		if Conf.Internal.RecursorCh == nil {
			fmt.Printf("No active channel to RecursorEngine. Terminating.\n")
			return
		}

		resp := make(chan tdns.ImrResponse, 1)
		Conf.Internal.RecursorCh <- tdns.ImrRequest{
			Qname:      qname,
			Qclass:     dns.ClassINET,
			Qtype:      qtype,
			ResponseCh: resp,
		}

		select {
		case r := <-resp:
			// Check cache entry to determine if this is a negative response
			var cached *cache.CachedRRset
			if Conf.Internal.RRsetCache != nil {
				cached = Conf.Internal.RRsetCache.Get(qname, qtype)
			}

			if cached != nil && (cached.Context == cache.ContextNXDOMAIN || cached.Context == cache.ContextNoErrNoAns) {

				vstate := cached.State
				stateStr := cache.ValidationStateToString[vstate]
				ctxStr := cache.CacheContextToString[cached.Context]

				fmt.Printf("%s %s (state: %s)\n", qname, ctxStr, stateStr)

				if tdns.Globals.Verbose {

					if vstate == cache.ValidationStateIndeterminate {
						fmt.Printf("Proof: not possible for zone in state=indeterminate\n")
					} else if len(cached.NegAuthority) > 0 {
						fmt.Printf("Proof:\n")
						for _, negRRset := range cached.NegAuthority {
							if negRRset != nil {
								for _, rr := range negRRset.RRs {
									fmt.Printf("  %s\n", rr.String())
								}
								for _, rr := range negRRset.RRSIGs {
									fmt.Printf("  %s\n", rr.String())
								}
							}
						}
					} else if cached.RRset != nil {

						for _, rr := range cached.RRset.RRs {
							if rr.Header().Rrtype == dns.TypeSOA {
								fmt.Printf("  %s\n", rr.String())
							}
						}
					}
				} else {
					fmt.Printf("Proof only presented in verbose mode\n")
				}
			} else if r.RRset != nil {

				vstate := cache.ValidationStateNone
				if cached != nil {
					vstate = cached.State
				}
				suffix := fmt.Sprintf(" (state: %s)", cache.ValidationStateToString[vstate])

				for _, rr := range r.RRset.RRs {
					switch rr.Header().Rrtype {
					case qtype, dns.TypeCNAME:
						fmt.Printf("%s%s\n", rr.String(), suffix)
					default:
						fmt.Printf("Not printing: %q\n", rr.String())
					}
				}
				for _, rr := range r.RRset.RRSIGs {
					fmt.Printf("%s\n", rr.String())
				}
			} else if r.Error {
				fmt.Printf("Error: %s\n", r.ErrorMsg)
			} else {
				fmt.Printf("No records found: %s\n", r.Msg)
			}
		case <-time.After(3 * time.Second):
			fmt.Println("Timeout waiting for response")
			return
		}
	},
}

Query command - takes name and type

View Source
var ImrSetCmd = &cobra.Command{
	Use:   "set",
	Short: "Set IMR runtime parameters",
}
View Source
var ImrShowCmd = &cobra.Command{
	Use:   "show",
	Short: "Show IMR state",
}
View Source
var ImrStatsCmd = &cobra.Command{
	Use:   "stats",
	Short: "Show statistics",
	Long:  `Show DNS query statistics`,
	Args:  cobra.NoArgs,
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Println("Showing statistics")
	},
}

Stats command - no arguments

View Source
var ImrZoneCmd = &cobra.Command{
	Use:   "zone",
	Short: "prefix command for zone operations",
	Long:  `prefix command for zone operations`,
}

Zone command - takes zone name

View Source
var JwtCmd = &cobra.Command{
	Use:   "jwt",
	Short: "JWT inspection and manipulation commands",
}
View Source
var MaxWait int

MaxWait bound by --maxwait on "daemon start".

View Source
var NewState string
View Source
var NotifyCmd = &cobra.Command{
	Use:   "notify",
	Short: "The 'notify' command is only usable via defined sub-commands",
}
View Source
var QuitCmd = &cobra.Command{
	Use:    "quit",
	Short:  "Exit the interactive shell",
	Long:   `Exit the interactive shell`,
	Hidden: true,
	Run: func(cmd *cobra.Command, args []string) {
		Terminate()
	},
}

quitCmd represents the quit command

View Source
var ReportCmd = &cobra.Command{
	Use:   "report <qname>",
	Short: "Send a report and (optionally) discover DSYNC via the internal resolver (imr)",
	Run: func(cmd *cobra.Command, args []string) {

		PrepArgs("zonename")
		dsyncLookup := true
		if dsyncTarget != "" && dsyncPort != 0 {
			dsyncLookup = false
		}

		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()

		tdns.Globals.App.Type = tdns.AppTypeCli
		if tdns.Globals.Debug {
			fmt.Printf("ReportCmd: Calling Conf.MainInit(%q)\n", tdns.DefaultCliCfgFile)
		}
		if err := Conf.MainInit(ctx, tdns.DefaultCliCfgFile); err != nil {
			tdns.Shutdowner(&Conf, fmt.Sprintf("Error initializing tdns-cli: %v", err))
		}

		if reportSender == "" {
			fmt.Printf("Error: sender not specified\n")
			return
		}

		if dsyncLookup {

			_, cancel, imr, err := StartImrForCli("")
			if err != nil {
				log.Fatalf("Error initializing IMR: %v", err)
			}
			defer cancel()

			log.Printf("ReportCmd: Discovering DSYNC via IMR for %s", tdns.Globals.Zonename)

			dsyncRes, derr := imr.DsyncDiscovery(ctx, tdns.Globals.Zonename, tdns.Globals.Verbose)
			if derr != nil {
				log.Printf("ReportCmd: DSYNC discovery error: %v", derr)
				return
			}

			// Find DSYNC record with REPORT scheme
			var reportDSYNC *core.DSYNC
			for _, ds := range dsyncRes.Rdata {
				if ds.Scheme == core.SchemeReport {
					reportDSYNC = ds
					break
				}
			}

			if reportDSYNC == nil {
				log.Printf("ReportCmd: no DSYNC REPORT found for %s, aborting report", tdns.Globals.Zonename)
				return
			}

			targetIP = reportDSYNC.Target
			port = strconv.Itoa(int(reportDSYNC.Port))
		} else {
			targetIP = dsyncTarget
			port = strconv.Itoa(dsyncPort)
		}

		if edeCode == 0 {
			edeCode = int(edns0.EDEMPZoneXfrFailure)
		}

		m := new(dns.Msg)
		m.SetNotify(tdns.Globals.Zonename)
		err := edns0.AddReportOptionToMessage(m, &edns0.ReportOption{
			ZoneName: tdns.Globals.Zonename,
			EDECode:  uint16(edeCode),
			Severity: 17,
			Sender:   reportSender,
			Details:  reportDetails,
		})
		if err != nil {
			log.Printf("ReportCmd: failed to build report EDNS0 option: %v", err)
			return
		}

		log.Printf("ReportCmd: sending report to %s:%s (from DSYNC REPORT)", targetIP, port)

		c := core.NewDNSClient(core.TransportDo53, port, nil)

		if reportTsig {

			tsig := tdns.Globals.TsigKeys[reportSender+".key."]
			if tsig == nil {
				fmt.Printf("Error: tsig key not found for sender: %s\n", reportSender)
				return
			}

			// There is no built-in map or function in miekg/dns for this, so we use a switch.
			var alg string
			switch strings.ToLower(tsig.Algorithm) {
			case "hmac-sha1":
				alg = dns.HmacSHA1
			case "hmac-sha256":
				alg = dns.HmacSHA256
			case "hmac-sha384":
				alg = dns.HmacSHA384
			case "hmac-sha512":
				alg = dns.HmacSHA512
			default:
				alg = tsig.Algorithm
			}
			if tdns.Globals.Debug {
				fmt.Printf("TSIG signing the report with %s\n", tsig.Name)
			}

			tsigMap := map[string]string{tsig.Name: tsig.Secret}
			if c.DNSClientUDP != nil {
				c.DNSClientUDP.TsigSecret = tsigMap
			}
			if c.DNSClientTCP != nil {
				c.DNSClientTCP.TsigSecret = tsigMap
			}
			if c.DNSClientTLS != nil {
				c.DNSClientTLS.TsigSecret = tsigMap
			}
			m.SetTsig(tsig.Name, alg, 300, time.Now().Unix())
		}

		if tdns.Globals.Debug {
			fmt.Printf("%s\n", m.String())
		}

		resp, _, err := c.Exchange(m, targetIP, false)
		if err != nil {
			fmt.Printf("ReportCmd: error sending report: %v\n", err)
			os.Exit(1)
		}
		rcode := resp.Rcode
		if rcode == dns.RcodeSuccess {
			fmt.Printf("Report accepted (rcode: %s)\n", dns.RcodeToString[rcode])
		} else {
			hasede, edecode, edemsg := edns0.ExtractEDEFromMsg(resp)
			if hasede {
				fmt.Printf("Error: rcode: %s, EDE Message: %s (EDE code: %d)\n", dns.RcodeToString[rcode], edemsg, edecode)
			} else {
				fmt.Printf("Error: rcode: %s\n", dns.RcodeToString[rcode])
				fmt.Printf("Response msg:\n%s\n", resp.String())
			}
			os.Exit(1)
		}
	},
}
View Source
var ResultsCmd = &cobra.Command{
	Use:   "results [job-id]",
	Short: "Get results of a completed scan job",
	Long:  `Get detailed results of a completed scan job by job ID. Use --delete to delete the job after retrieving results.`,
	Args:  cobra.ExactArgs(1),
	Run: func(cmd *cobra.Command, args []string) {

		api, err := GetApiClient("scanner", true)
		if err != nil {
			log.Fatalf("Error getting API client for scanner: %v", err)
		}

		jobID := args[0]
		endpoint := fmt.Sprintf("/scanner/status?job_id=%s", jobID)

		status, buf, err := api.RequestNG("GET", endpoint, nil, true)
		if err != nil {
			log.Fatalf("Error from scanner API: %v", err)
		}

		if status == http.StatusNotFound {
			log.Fatalf("Job not found: %s", jobID)
		}
		if status != http.StatusOK {
			log.Fatalf("Unexpected status code: %d", status)
		}

		var job tdns.ScanJobStatus
		err = json.Unmarshal(buf, &job)
		if err != nil {
			log.Fatalf("Error unmarshaling job status: %v", err)
		}

		if job.Status != "completed" {
			fmt.Printf("Job %s is not completed yet. Status: %s\n", jobID, job.Status)
			fmt.Printf("Progress: %d/%d tuples processed\n", job.ProcessedTuples, job.TotalTuples)
			return
		}

		if job.Error {
			fmt.Printf("Job %s completed with error: %s\n", jobID, job.ErrorMsg)
			return
		}

		deleteFlag, _ := cmd.Flags().GetBool("delete")
		if deleteFlag {

			deleteEndpoint := fmt.Sprintf("/scanner/delete?job_id=%s", jobID)
			delStatus, delBuf, err := api.RequestNG("DELETE", deleteEndpoint, nil, true)
			if err != nil {
				log.Printf("Warning: Error deleting job %s: %v", jobID, err)
			} else if delStatus == http.StatusOK {
				fmt.Printf("Job %s deleted successfully\n", jobID)
			} else {
				log.Printf("Warning: Failed to delete job %s: status %d, response: %s", jobID, delStatus, string(delBuf))
			}
		}

		if tdns.Globals.Verbose {
			// Pretty print JSON
			var prettyJSON bytes.Buffer
			json.Indent(&prettyJSON, buf, "", "  ")
			fmt.Println(prettyJSON.String())
		} else {

			fmt.Printf("Job ID: %s\n", job.JobID)
			fmt.Printf("Status: %s\n", job.Status)
			fmt.Printf("Total Responses: %d\n\n", len(job.Responses))

			for i, resp := range job.Responses {
				fmt.Printf("Response %d: %s (%s)\n", i+1, resp.Qname, tdns.ScanTypeToString[resp.ScanType])
				if resp.DataChanged {
					fmt.Printf("  Data changed: Yes\n")
				} else {
					fmt.Printf("  Data changed: No\n")
				}
				if resp.AllNSInSync {
					fmt.Printf("  All NS in sync: Yes\n")
				} else if len(resp.Options) > 0 {
					for _, opt := range resp.Options {
						if opt == "all-ns" {
							fmt.Printf("  All NS in sync: No\n")
							break
						}
					}
				}
				if resp.Error {
					fmt.Printf("  Error: %s\n", resp.ErrorMsg)
				}
				fmt.Println()
			}
		}
	},
}
View Source
var RootKeysCmd = &cobra.Command{
	Use:   "keys",
	Short: "Generate long-term keypairs for agent/combiner (JOSE)",
	Long: `Generate JOSE keypairs used by tdns-agentv2 and tdns-combinerv2 for
authenticated NOTIFY(CHUNK) and API traffic. Use one keypair per party:
  - Agent:  agent.jose.private (+ optional agent.jose.pub for combiner config)
  - Combiner: combiner.jose.private (+ optional combiner.jose.pub for agent config)
`,
}

RootKeysCmd is the root-level "keys" command (e.g. tdns-cli keys generate). It does not require tdns-cli config or API; use for generating JOSE keypairs on disk.

View Source
var ScanCdsCmd = &cobra.Command{
	Use:   "cds [zone...]",
	Short: "Send CDS scan request with ScanTuple data to tdns-scanner",
	Long:  `Send CDS scan request for one or more zones. Zones can be specified as arguments or via --zone flag.`,
	Args:  cobra.MinimumNArgs(0),
	Run: func(cmd *cobra.Command, args []string) {

		api, err := GetApiClient("scanner", true)
		if err != nil {
			log.Fatalf("Error getting API client for scanner: %v", err)
		}

		// Get zones from command arguments
		var zones []string

		if len(args) > 0 {
			for _, arg := range args {
				zone := strings.TrimSpace(arg)
				if zone != "" {
					zones = append(zones, zone)
				}
			}
		}

		if len(zones) == 0 {
			if tdns.Globals.Zonename == "" {
				log.Fatal("Error: specify zones as arguments or use --zone flag")
			}
			zones = []string{tdns.Globals.Zonename}
		}

		if tdns.Globals.Verbose {
			fmt.Printf("Scanning %d zones: %v\n", len(zones), zones)
		}

		scanTuples := make([]tdns.ScanTuple, 0, len(zones))
		for _, zone := range zones {
			zone = dns.Fqdn(zone)

			scanTuple := tdns.ScanTuple{
				Zone: zone,

				CurrentData: tdns.CurrentScanData{
					CDS: nil,
				},
			}
			scanTuples = append(scanTuples, scanTuple)
		}

		post := tdns.ScannerPost{
			Command:    "SCAN",
			ScanType:   tdns.ScanCDS,
			ScanTuples: scanTuples,
		}

		status, buf, err := api.RequestNG("POST", "/scanner", post, true)
		if err != nil {
			log.Fatalf("Error from scanner API: %v", err)
		}

		if tdns.Globals.Verbose {
			fmt.Printf("Status: %d\n", status)
		}

		var resp tdns.ScannerResponse
		err = json.Unmarshal(buf, &resp)
		if err != nil {
			log.Fatalf("Error unmarshaling response: %v", err)
		}

		if resp.Error {
			log.Fatalf("Error from scanner: %s", resp.ErrorMsg)
		}

		fmt.Printf("Scanner response: %s\n", resp.Msg)
		if resp.Status != "" {
			fmt.Printf("Status: %s\n", resp.Status)
		}
		if resp.JobID != "" {
			fmt.Printf("Job ID: %s\n", resp.JobID)
			fmt.Printf("Use 'tdns-cli scanner status %s' to check job status\n", resp.JobID)
		}
	},
}
View Source
var ScanCmd = &cobra.Command{
	Use:   "scan",
	Short: "Send scan requests to tdns-scanner",
}
View Source
var ScannerCmd = &cobra.Command{
	Use:   "scanner",
	Short: "Interact with tdns-scanner via API",
}
View Source
var ServerName string = "PLACEHOLDER"
View Source
var StatusCmd = &cobra.Command{
	Use:   "status [job-id]",
	Short: "Get status of scan job(s)",
	Long:  `Get status of a specific scan job by job ID, or list all jobs if no job ID is provided`,
	Args:  cobra.MaximumNArgs(1),
	Run: func(cmd *cobra.Command, args []string) {

		api, err := GetApiClient("scanner", true)
		if err != nil {
			log.Fatalf("Error getting API client for scanner: %v", err)
		}

		var endpoint string
		if len(args) > 0 {

			endpoint = fmt.Sprintf("/scanner/status?job_id=%s", args[0])
		} else {

			endpoint = "/scanner/status"
		}

		status, buf, err := api.RequestNG("GET", endpoint, nil, true)
		if err != nil {
			log.Fatalf("Error from scanner API: %v", err)
		}

		if status == http.StatusNotFound {
			log.Fatalf("Job not found")
		}
		if status == http.StatusBadRequest {
			log.Fatalf("Bad request: %s", string(buf))
		}
		if status != http.StatusOK {
			log.Fatalf("Unexpected status code: %d", status)
		}

		if len(args) > 0 {
			// Single job - show detailed status
			var job tdns.ScanJobStatus
			err = json.Unmarshal(buf, &job)
			if err != nil {
				log.Fatalf("Error unmarshaling job status: %v", err)
			}

			fmt.Printf("Job ID: %s\n", job.JobID)
			fmt.Printf("Status: %s\n", job.Status)
			fmt.Printf("Created: %s\n", job.CreatedAt.Format("2006-01-02 15:04:05"))
			if job.StartedAt != nil {
				fmt.Printf("Started: %s\n", job.StartedAt.Format("2006-01-02 15:04:05"))
			}
			if job.CompletedAt != nil {
				fmt.Printf("Completed: %s\n", job.CompletedAt.Format("2006-01-02 15:04:05"))
			}
			fmt.Printf("Progress: %d/%d tuples processed\n", job.ProcessedTuples, job.TotalTuples)

			if job.Error {
				fmt.Printf("Error: %s\n", job.ErrorMsg)
			}

			if len(job.Responses) > 0 {
				fmt.Printf("\nResults (%d responses):\n", len(job.Responses))
				for i, resp := range job.Responses {
					fmt.Printf("\n  Response %d:\n", i+1)
					fmt.Printf("    Qname: %s\n", resp.Qname)
					fmt.Printf("    Scan Type: %s\n", tdns.ScanTypeToString[resp.ScanType])
					fmt.Printf("    Data Changed: %t\n", resp.DataChanged)
					if resp.AllNSInSync {
						fmt.Printf("    All NS In Sync: true\n")
					} else if len(resp.Options) > 0 {
						for _, opt := range resp.Options {
							if opt == "all-ns" {
								fmt.Printf("    All NS In Sync: false\n")
								break
							}
						}
					}
					if resp.Error {
						fmt.Printf("    Error: %s\n", resp.ErrorMsg)
					}
				}
			}
		} else {
			// All jobs - show summary table
			var jobs []*tdns.ScanJobStatus
			err = json.Unmarshal(buf, &jobs)
			if err != nil {
				log.Fatalf("Error unmarshaling job list: %v", err)
			}

			if len(jobs) == 0 {
				fmt.Println("No jobs found")
				return
			}

			sort.Slice(jobs, func(i, j int) bool {
				return jobs[i].CreatedAt.After(jobs[j].CreatedAt)
			})

			t := acidtab.New("JOB ID", "STATUS", "CREATED", "PROGRESS", "ERROR")
			for _, job := range jobs {
				progress := fmt.Sprintf("%d/%d", job.ProcessedTuples, job.TotalTuples)
				errorStr := ""
				if job.Error {
					errorStr = job.ErrorMsg
					if len(errorStr) > 30 {
						errorStr = errorStr[:27] + "..."
					}
				}
				created := job.CreatedAt.Format("2006-01-02 15:04:05")
				t.Row(job.JobID, job.Status, created, progress, errorStr)
			}
			fmt.Println(t.String())
		}
	},
}
View Source
var SupportedDnssecAlgorithms = []string{
	"RSASHA256",
	"RSASHA512",
	"ECDSAP256SHA256",
	"ECDSAP384SHA384",
	"ED25519",
}

SupportedDnssecAlgorithms lists the DNSSEC algorithm names tdns accepts for zone signing (RRSIG).

View Source
var SupportedSig0Algorithms = []string{
	"RSASHA256",
	"RSASHA512",
	"ECDSAP256SHA256",
	"ECDSAP384SHA384",
	"ED25519",
}

SupportedSig0Algorithms lists the DNSSEC algorithm names tdns accepts for SIG(0) key generation and transaction signing.

View Source
var ToRFC3597Cmd = &cobra.Command{
	Use:   "rfc3597",
	Short: "Generate the RFC 3597 representation of a DNS record",
	Run: func(cmd *cobra.Command, args []string) {
		if rrstr == "" {
			log.Fatalf("Record to generate RFC 3597 representation for not specified.")
		}

		rr, err := dns.NewRR(rrstr)
		if err != nil {
			log.Fatalf("Could not parse record \"%s\": %v", rrstr, err)
		}

		fmt.Printf("Normal   (len=%d): \"%s\"\n", dns.Len(rr), rr.String())
		u := new(dns.RFC3597)
		u.ToRFC3597(rr)
		fmt.Printf("RFC 3597 (len=%d): \"%s\"\n", dns.Len(u), u.String())
	},
}
View Source
var UpdateCmd = &cobra.Command{
	Use:   "update",
	Short: "[OBE] Create and ultimately send a DNS UPDATE msg for zone auth data",
}
View Source
var VersionCmd = &cobra.Command{
	Use:   "version",
	Short: "Print the version of the app, more or less verbosely",
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Printf("This is %s, version %s, compiled on %v\n", tdns.Globals.App.Name, tdns.Globals.App.Version, tdns.Globals.App.Date)
	},
}

Functions

func AttachUpdateCreateFlags

func AttachUpdateCreateFlags(cmd *cobra.Command)

AttachUpdateCreateFlags adds the three flags common to every "update create" entry point — --signer, --server, --key — to cmd, bound to the package-level vars CreateUpdate reads. Use this from every leaf create command (including cross-package ones in tdns-mp/v2/cli) so the signer name, target server, and keyfile can always be overridden from the CLI.

func CreateUpdate

func CreateUpdate(role, updateType string)

CreateUpdate starts an interactive CLI for composing, signing, and sending DNS UPDATEs.

It initializes the keystore and signing state, then enters a prompt loop that lets the user add or delete RRs, view the pending update, sign it with SIG(0) keys (from a keyfile or the keystore), select the target server, and send the update. The function updates package-level globals such as `zone`, `signer`, and `server` as needed. It may call os.Exit on fatal initialization or signing errors.

role is the api-client role used to discover the server's keystore path when the local "db.file" config setting is unset (e.g. "auth", "agent", "combiner"). The role's /config status response carries the DBFile field added by commit d03f360.

The updateType parameter selects the operational context used to initialize the interactive session (for example "child" or "zone") and does not affect the format of the DNS UPDATE messages produced.

func GetApiClient

func GetApiClient(role string, dieOnError bool) (*tdns.ApiClient, error)

GetApiClient returns the configured ApiClient for the given role. The role is resolved to a clientKey via the RegisterRole registry and looked up in tdns.Globals.ApiClients. dieOnError controls whether unknown/missing roles terminate the process.

func InitApiClients

func InitApiClients(c *CliConf) error

InitApiClients creates a tdns.ApiClient for every entry in c.ApiServers and stashes them in tdns.Globals.ApiClients. Also parses TSIG keys from c.Keys and records c for later config lookups.

Unlike the previous per-binary implementations, this does *not* require a client named "tdns-auth". Callers resolve their ApiClient via GetApiClient(role, …) and get a use-time failure if the role they need isn't configured.

func ListZones

func ListZones(cr tdns.ZoneResponse)

func NewConfigCmd

func NewConfigCmd(role string) *cobra.Command

NewConfigCmd returns a fresh "config" command tree bound to the given role. Each attachment point gets its own *cobra.Command.

func NewDaemonCmd

func NewDaemonCmd(role string) *cobra.Command

NewDaemonCmd returns a fresh "daemon" command tree bound to the given role. Each attachment point (auth, agent, imr, scanner in tdns-cli; signer, combiner, agent in mpcli; plus root "server") gets its own *cobra.Command.

func NewDebugCmd

func NewDebugCmd(role string, extras ...*cobra.Command) *cobra.Command

NewDebugCmd returns a fresh "debug" command tree bound to the given role. Extras are attached as additional subcommands — used by tdns-mp to inject DebugAgentCmd under the agent tree only.

func NewKeysCmd

func NewKeysCmd(role string) *cobra.Command

NewKeysCmd returns a fresh "keys" command tree bound to the given role. Role must be "agent" or "combiner" — the tree is only meaningful under those two API clients (their configs point to the long_term_jose_priv_key).

func NewKeystoreCmd

func NewKeystoreCmd(role string) *cobra.Command

NewKeystoreCmd returns a fresh "keystore" command tree bound to the given role. The subtree (sig0 + dnssec branches with their children and flags) is built inline so every attachment point gets unique *cobra.Command instances.

func NewPingCmd

func NewPingCmd(role string) *cobra.Command

NewPingCmd returns a fresh ping *cobra.Command bound to the given role. Each attachment site must create its own command (Cobra does not allow the same *cobra.Command under multiple parents). The role string is the registry key from RegisterRole.

func NewStopCmd

func NewStopCmd(role string) *cobra.Command

NewStopCmd returns a fresh "stop" *cobra.Command bound to the given role. Each attachment site must create its own command (root "server" default in tdns-cli; signer / combiner / agent in mpcli).

func NewTruststoreCmd

func NewTruststoreCmd(role string) *cobra.Command

NewTruststoreCmd returns a fresh "truststore" command tree bound to the given role. The nested sig0 subtree and its children/flags are built inline so every attachment point gets unique *cobra.Command instances.

func NewZoneCmd

func NewZoneCmd(role string, extras ...*cobra.Command) *cobra.Command

NewZoneCmd returns a fresh "zone" command tree bound to the given role. Additional subcommands may be attached via extras — used by tdns-mp to inject signer-specific mplist under the signer's tree.

func PrepArgs

func PrepArgs(args ...interface{})

PrepArgs validates and normalizes CLI parameters. It can work in two modes: 1. Legacy mode: reads from global variables (tdns.Globals.*) 2. Flag mode: reads from cobra.Command flags (pass cmd as first argument)

Usage:

PrepArgs("zonename")                    // reads from tdns.Globals.Zonename
PrepArgs(cmd, "zonename")               // reads from --zone flag
PrepArgs(cmd, "zonename", "service")    // reads from --zone and --service flags

func PrintCacheItem

func PrintCacheItem(item core.Tuple[string, cache.CachedRRset], suffix string)

func PrintUpdateResult

func PrintUpdateResult(ur tdns.UpdateResult)

func ReadZoneData

func ReadZoneData(zonename string) error

func RegisterRole

func RegisterRole(role, clientKey string)

RegisterRole associates role with clientKey. Later calls for the same role override earlier ones — this is how tdns-mp overrides the "agent" → "tdns-agent" default with "agent" → "tdns-mpagent".

Safe only from init() (map is not concurrency-safe; init ordering inside a package is deterministic, across imported packages it is topological and thus deterministic for override purposes).

func RunZoneBump

func RunZoneBump(parent string, args []string)

func RunZoneList

func RunZoneList(parent string, args []string)

func RunZoneReload

func RunZoneReload(parent string, args []string)

func RunZoneWrite

func RunZoneWrite(parent string, args []string)

func SendAgentMgmtCmd

func SendAgentMgmtCmd(req *tdns.AgentMgmtPost) (*tdns.AgentMgmtResponse, error)

SendAgentMgmtCmd POSTs an AgentMgmtPost to the agent daemon's /agent endpoint. Every caller in this package talks to the agent, so the role is fixed rather than inferred from the Cobra tree.

func SendCatalogCommand

func SendCatalogCommand(api *tdns.ApiClient, data tdns.CatalogPost) (*tdns.CatalogResponse, error)

SendCatalogCommand sends a catalog command to the API

func SendCommandNG

func SendCommandNG(api *tdns.ApiClient, data tdns.CommandPost) (tdns.CommandResponse, error)

func SendConfigCommand

func SendConfigCommand(api *tdns.ApiClient, data tdns.ConfigPost) (tdns.ConfigResponse, error)

func SendDebug

func SendDebug(api *tdns.ApiClient, data tdns.DebugPost) tdns.DebugResponse

func SendDelegationCmd

func SendDelegationCmd(api *tdns.ApiClient, data tdns.DelegationPost) (tdns.DelegationResponse, error)

func SendDsyncCommand

func SendDsyncCommand(api *tdns.ApiClient, data tdns.ZoneDsyncPost) (tdns.ZoneDsyncResponse, error)

func SendKeystoreCmd

func SendKeystoreCmd(api *tdns.ApiClient, data tdns.KeystorePost) (tdns.KeystoreResponse, error)

func SendNotify

func SendNotify(zonename string, ntype string)

func SendTruststore

func SendTruststore(api *tdns.ApiClient, data tdns.TruststorePost) (tdns.TruststoreResponse, error)

func SendZoneCommand

func SendZoneCommand(api *tdns.ApiClient, data tdns.ZonePost) (tdns.ZoneResponse, error)

func SetRootCommand

func SetRootCommand(cmd *cobra.Command)

SetRootCommand allows the root package to provide the root command reference

func StartImrForCli

func StartImrForCli(rootHints string) (context.Context, context.CancelFunc, *tdns.Imr, error)

StartImrForCli initializes and starts the internal IMR for CLI commands. It sets up the minimal config, starts RecursorEngine, and waits for initialization. Returns the context, cancel function, and the Imr instance, or an error if initialization fails.

func StartInteractiveMode

func StartInteractiveMode()

func Terminate

func Terminate()

func ValidateBySection

func ValidateBySection(config *Config, configsections map[string]interface{}, cfgfile string) error

func ValidateConfig

func ValidateConfig(v *viper.Viper, cfgfile string) error

func ValidateZoneConfig

func ValidateZoneConfig(v *viper.Viper, cfgfile string) error

func VerboseListZone

func VerboseListZone(cr tdns.ZoneResponse)

Types

type ApiDetails

type ApiDetails struct {
	Name       string `validate:"required" yaml:"name"`
	BaseURL    string `validate:"required" yaml:"baseurl"`
	ApiKey     string `validate:"required" yaml:"apikey"`
	AuthMethod string `validate:"required" yaml:"authmethod"`
	RootCA     string `yaml:"rootca"`
	Command    string `yaml:"command,omitempty"`
	ConfigFile string `yaml:"config_file,omitempty"`
}

ApiDetails is one entry in the apiservers list of a CLI config file.

type CliConf

type CliConf struct {
	ApiServers []ApiDetails
	Keys       tdns.KeyConf
}

CliConf is the parsed CLI config. Each CLI binary's root.go populates one of these (currently via viper + Unmarshal) and hands it to InitApiClients.

type CommandNode

type CommandNode struct {
	Name        string                  // Command name
	Command     *cobra.Command          // Reference to original Cobra command
	SubCommands map[string]*CommandNode // Child commands
	Parent      *CommandNode            // Parent command (nil for root)
	Args        []string                // Expected arguments from Use field
	Guide       string                  // Guide text for this command
}

CommandNode represents a node in our command tree

func BuildCommandTree

func BuildCommandTree(cmd *cobra.Command, parent *CommandNode) *CommandNode

BuildCommandTree creates a tree structure from Cobra commands

func (*CommandNode) DebugPrint

func (n *CommandNode) DebugPrint(indent string)

Debug function to print the tree structure

type Config

type Config struct {
	Verbose *bool    `validate:"required"`
	Zones   []string `validate:"required"`
}

Directories

Path Synopsis
* Copyright (c) 2026 Johan Stenstam, johani@johani.org * * Bootstrap-configure library: diff preview + top-level confirmation.
* Copyright (c) 2026 Johan Stenstam, johani@johani.org * * Bootstrap-configure library: diff preview + top-level confirmation.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL