Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `internal/domain`, `internal/subdomain`, and `internal/dns` read
modules with the matching CLI subcommand trees: `kasapi-cli domains
list` and `domains get <name>` (`get_domains`, the latter passing a
`domain_name` filter and unwrapping the single-entry result),
`kasapi-cli subdomains list` (`get_subdomains`), `kasapi-cli tlds
list` (`get_topleveldomains`), and `kasapi-cli dns list --domain <d>
[--nameserver <ns>]` (`get_dns_settings`). Domain types `Domain`,
`SSL`, `TLD`, `Subdomain`, and DNS `Record` decode the KAS Map/Array
payloads into typed Go values; the SSL cert/key/CSR PEM bodies are
carried through but summarised as `<bytes,lines>` in the
`--output=table` view of `domains get` so the key/value layout stays
readable. Mapping tests run against the shipped `testdata/domain/`,
`testdata/subdomain/`, and `testdata/dns/` fixtures. Closes #8.

- `internal/usage` package and `kasapi-cli usage` subcommand tree
covering the three KAS read endpoints around webspace and traffic
counters: `usage space` (`get_space`) lists per-account webspace
Expand Down
4 changes: 4 additions & 0 deletions cmd/kasapi-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ func main() {
root.AddCommand(
cli.NewAccountCmd(opts),
cli.NewServerCmd(opts),
cli.NewDomainsCmd(opts),
cli.NewSubdomainsCmd(opts),
cli.NewTLDsCmd(opts),
cli.NewDNSCmd(opts),
cli.NewUsageCmd(opts),
cli.NewConfigCmd(opts),
)
Expand Down
50 changes: 50 additions & 0 deletions internal/cli/dns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package cli

import (
"errors"

"github.com/spf13/cobra"

"github.com/chmmou/kasapi-cli/internal/dns"
)

// NewDNSCmd returns the "kasapi-cli dns" subcommand tree:
// list --domain <d> [--nameserver <ns>] (get_dns_settings).
func NewDNSCmd(opts *RootOptions) *cobra.Command {
cmd := &cobra.Command{
Use: "dns",
Short: "Inspect DNS records for a zone",
}
cmd.AddCommand(newDNSListCmd(opts))
return cmd
}

func newDNSListCmd(opts *RootOptions) *cobra.Command {
var domainName, nameserver string
cmd := &cobra.Command{
Use: "list",
Short: "List DNS records for a zone (get_dns_settings)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if domainName == "" {
return UserError(errors.New("required flag --domain not provided"), "--domain")
}
api, err := BuildAPIClient(opts)
if err != nil {
return err
}
list, err := dns.NewClient(api).Settings(cmd.Context(), domainName, nameserver)
if err != nil {
return APIError(err, "get_dns_settings")
}
if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil {
return UserError(err, "render")
}
return nil
},
}
cmd.Flags().StringVar(&domainName, "domain", "", "zone host (required, e.g. example.com)")
cmd.Flags().StringVar(&nameserver, "nameserver", "",
"authoritative nameserver to query; empty uses the KAS default")
return cmd
}
65 changes: 65 additions & 0 deletions internal/cli/domains.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package cli

import (
"github.com/spf13/cobra"

"github.com/chmmou/kasapi-cli/internal/domain"
)

// NewDomainsCmd returns the "kasapi-cli domains" subcommand tree:
// list (get_domains), get (get_domains with domain_name).
func NewDomainsCmd(opts *RootOptions) *cobra.Command {
cmd := &cobra.Command{
Use: "domains",
Short: "Inspect domains owned by the authenticated account",
}
cmd.AddCommand(
newDomainsListCmd(opts),
newDomainsGetCmd(opts),
)
return cmd
}

func newDomainsListCmd(opts *RootOptions) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all domains (get_domains)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
api, err := BuildAPIClient(opts)
if err != nil {
return err
}
list, err := domain.NewClient(api).List(cmd.Context())
if err != nil {
return APIError(err, "get_domains")
}
if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil {
return UserError(err, "render")
}
return nil
},
}
}

func newDomainsGetCmd(opts *RootOptions) *cobra.Command {
return &cobra.Command{
Use: "get <domain>",
Short: "Show details for a single domain (get_domains with domain_name)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
api, err := BuildAPIClient(opts)
if err != nil {
return err
}
d, err := domain.NewClient(api).Get(cmd.Context(), args[0])
if err != nil {
return APIError(err, "get_domains")
}
if err := Render(cmd.OutOrStdout(), opts.Output, d); err != nil {
return UserError(err, "render")
}
return nil
},
}
}
40 changes: 40 additions & 0 deletions internal/cli/subdomains.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package cli

import (
"github.com/spf13/cobra"

"github.com/chmmou/kasapi-cli/internal/subdomain"
)

// NewSubdomainsCmd returns the "kasapi-cli subdomains" subcommand
// tree: list (get_subdomains).
func NewSubdomainsCmd(opts *RootOptions) *cobra.Command {
cmd := &cobra.Command{
Use: "subdomains",
Short: "Inspect subdomains owned by the authenticated account",
}
cmd.AddCommand(newSubdomainsListCmd(opts))
return cmd
}

func newSubdomainsListCmd(opts *RootOptions) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all subdomains (get_subdomains)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
api, err := BuildAPIClient(opts)
if err != nil {
return err
}
list, err := subdomain.NewClient(api).List(cmd.Context())
if err != nil {
return APIError(err, "get_subdomains")
}
if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil {
return UserError(err, "render")
}
return nil
},
}
}
40 changes: 40 additions & 0 deletions internal/cli/tlds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package cli

import (
"github.com/spf13/cobra"

"github.com/chmmou/kasapi-cli/internal/domain"
)

// NewTLDsCmd returns the "kasapi-cli tlds" subcommand tree:
// list (get_topleveldomains).
func NewTLDsCmd(opts *RootOptions) *cobra.Command {
cmd := &cobra.Command{
Use: "tlds",
Short: "Inspect the catalog of registrable top-level domains",
}
cmd.AddCommand(newTLDsListCmd(opts))
return cmd
}

func newTLDsListCmd(opts *RootOptions) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all registrable TLDs (get_topleveldomains)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
api, err := BuildAPIClient(opts)
if err != nil {
return err
}
list, err := domain.NewClient(api).TopLevelDomains(cmd.Context())
if err != nil {
return APIError(err, "get_topleveldomains")
}
if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil {
return UserError(err, "render")
}
return nil
},
}
}
149 changes: 149 additions & 0 deletions internal/dns/dns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package dns

import (
"context"
"fmt"
"strconv"
"strings"

"github.com/chmmou/kasapi-cli/internal/soap"
)

// Caller is the subset of *api.Client this package depends on. The
// indirection keeps tests free of network setup: a fake Caller can
// return a *soap.Response decoded from a fixture.
type Caller interface {
Call(ctx context.Context, action string, params map[string]any) (*soap.Response, error)
}

// Record is one DNS resource record returned by get_dns_settings.
// record_id is xsd:string in the wire format and we keep it as a
// string here — the value identifies the record on update / delete
// calls but is otherwise opaque.
type Record struct {
Zone string `json:"record_zone" yaml:"record_zone"`
Name string `json:"record_name" yaml:"record_name"`
Type string `json:"record_type" yaml:"record_type"`
Data string `json:"record_data" yaml:"record_data"`
Aux int `json:"record_aux" yaml:"record_aux"`
ID string `json:"record_id" yaml:"record_id"`
Changeable string `json:"record_changeable" yaml:"record_changeable"`
Deleteable string `json:"record_deleteable" yaml:"record_deleteable"`
}

// RecordList is the typed payload of get_dns_settings; satisfies
// cli.Tabular.
type RecordList []Record

// Client groups the read endpoints scoped to DNS settings.
type Client struct {
API Caller
}

// NewClient returns a Client backed by the given Caller.
func NewClient(c Caller) *Client { return &Client{API: c} }

// Settings calls get_dns_settings for the given zone host and decodes
// the response into a RecordList. zoneHost is required (the zone the
// records belong to, e.g. "example.com"). nameserver is optional and
// pinpoints which authoritative NS to query when the zone is served
// by more than one — leave it empty to use the KAS default.
func (c *Client) Settings(ctx context.Context, zoneHost, nameserver string) (RecordList, error) {
if zoneHost == "" {
return nil, fmt.Errorf("dns: zone_host is required")
}
params := map[string]any{"zone_host": zoneHost}
if nameserver != "" {
params["nameserver"] = nameserver
}
resp, err := c.API.Call(ctx, "get_dns_settings", params)
if err != nil {
return nil, err
}
list, err := DecodeRecords(resp.Body.ReturnInfo)
if err != nil {
return nil, fmt.Errorf("dns: get_dns_settings: %w", err)
}
return list, nil
}

// DecodeRecords maps the ReturnInfo of a get_dns_settings response
// (an Array of Maps) into the typed RecordList.
func DecodeRecords(returnInfo soap.Value) (RecordList, error) {
if returnInfo.Kind != soap.KindArray {
return nil, fmt.Errorf("dns: expected ReturnInfo array, got kind %d", returnInfo.Kind)
}
out := make(RecordList, 0, len(returnInfo.Array))
for i, item := range returnInfo.Array {
if item.Kind != soap.KindMap {
return nil, fmt.Errorf("dns: ReturnInfo[%d] is not a Map", i)
}
out = append(out, Record{
Zone: getString(item, "record_zone"),
Name: getString(item, "record_name"),
Type: getString(item, "record_type"),
Data: getString(item, "record_data"),
Aux: getInt(item, "record_aux"),
ID: getString(item, "record_id"),
Changeable: getString(item, "record_changeable"),
Deleteable: getString(item, "record_deleteable"),
})
}
return out, nil
}

func getString(m soap.Value, key string) string {
v, ok := m.Get(key)
if !ok {
return ""
}
return v.AsString()
}

func getInt(m soap.Value, key string) int {
v, ok := m.Get(key)
if !ok {
return 0
}
switch v.Kind {
case soap.KindInt:
return int(v.Int)
case soap.KindFloat:
return int(v.Float)
case soap.KindString:
s := strings.TrimSpace(v.String)
if s == "" {
return 0
}
n, err := strconv.Atoi(s)
if err != nil {
return 0
}
return n
}
return 0
}

// TableHeaders returns the columns used by --output=table for
// RecordList.
func (RecordList) TableHeaders() []string {
return []string{"ID", "ZONE", "NAME", "TYPE", "AUX", "DATA", "CHG", "DEL"}
}

// TableRows emits one row per Record entry.
func (l RecordList) TableRows() [][]string {
rows := make([][]string, 0, len(l))
for _, r := range l {
rows = append(rows, []string{
r.ID,
r.Zone,
r.Name,
r.Type,
strconv.Itoa(r.Aux),
r.Data,
r.Changeable,
r.Deleteable,
})
}
return rows
}
Loading
Loading