Skip to content
Open
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
94 changes: 45 additions & 49 deletions cmd/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"github.com/retlehs/quien/internal/seo"
"github.com/retlehs/quien/internal/stack"
"github.com/retlehs/quien/internal/tlsinfo"
"github.com/spf13/cobra"
)

type allResult struct {
Expand All @@ -25,62 +24,59 @@ type allResult struct {
SEO *seo.Result `json:"seo,omitempty"`
}

var allCmd = &cobra.Command{
Use: "all <domain or IP>",
Short: "Run all lookups combined (JSON output)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
input := normalizeDomain(args[0])
result := allResult{}
func init() {
register(&command{
name: "all",
short: "Run all lookups combined (JSON output)",
run: func(args []string) error {
input := normalizeDomain(args[0])
result := allResult{}

isIP := net.ParseIP(input) != nil
isIP := net.ParseIP(input) != nil

if isIP {
info, err := resolver.LookupIP(input)
if err != nil {
return fmt.Errorf("IP lookup failed: %w", err)
if isIP {
info, err := resolver.LookupIP(input)
if err != nil {
return fmt.Errorf("IP lookup failed: %w", err)
}
var w any = info
result.WHOIS = &w
return printJSON(result)
}
var w any = info
result.WHOIS = &w
return printJSON(result)
}

// WHOIS
if info, err := resolver.Lookup(input); err == nil {
var w any = info
result.WHOIS = &w
}

// DNS
if records, err := retry.Do(func() (*dns.Records, error) { return dns.Lookup(input) }); err == nil {
result.DNS = records
}
// WHOIS
if info, err := resolver.Lookup(input); err == nil {
var w any = info
result.WHOIS = &w
}

// Mail
if records, err := retry.Do(func() (*mail.Records, error) { return mail.Lookup(input) }); err == nil {
result.Mail = records
}
// DNS
if records, err := retry.Do(func() (*dns.Records, error) { return dns.Lookup(input) }); err == nil {
result.DNS = records
}

// TLS
if cert, err := retry.Do(func() (*tlsinfo.CertInfo, error) { return tlsinfo.Lookup(input) }); err == nil {
result.TLS = cert
}
// Mail
if records, err := retry.Do(func() (*mail.Records, error) { return mail.Lookup(input) }); err == nil {
result.Mail = records
}

// HTTP
if info, err := retry.Do(func() (*httpinfo.Result, error) { return httpinfo.Lookup(input) }); err == nil {
result.HTTP = info
}
// TLS
if cert, err := retry.Do(func() (*tlsinfo.CertInfo, error) { return tlsinfo.Lookup(input) }); err == nil {
result.TLS = cert
}

// Stack + SEO (shared page fetch)
if page, err := retry.Do(func() (*stack.PageData, error) { return stack.FetchPage(input) }); err == nil {
result.Stack = stack.DetectFromPage(page.Headers, page.Body, input)
result.SEO = seo.AnalyzeWithPage(page, input)
}
// HTTP
if info, err := retry.Do(func() (*httpinfo.Result, error) { return httpinfo.Lookup(input) }); err == nil {
result.HTTP = info
}

return printJSON(result)
},
}
// Stack + SEO (shared page fetch)
if page, err := retry.Do(func() (*stack.PageData, error) { return stack.FetchPage(input) }); err == nil {
result.Stack = stack.DetectFromPage(page.Headers, page.Body, input)
result.SEO = seo.AnalyzeWithPage(page, input)
}

func init() {
rootCmd.AddCommand(allCmd)
return printJSON(result)
},
})
}
172 changes: 172 additions & 0 deletions cmd/cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package cmd

import (
"errors"
"flag"
"fmt"
"io"
"os"
"slices"
"sort"
"strings"

"github.com/retlehs/quien/internal/dnsutil"
"github.com/retlehs/quien/internal/mail"
)

const longDescription = "Inspect a domain or IP across registration (WHOIS/RDAP), DNS, mail authentication (SPF/DMARC/DKIM/BIMI), TLS, HTTP, SEO, and tech stack — interactive TUI by default, JSON via subcommands."

// command is a subcommand taking exactly one domain-or-IP argument.
type command struct {
name string
aliases []string
short string
run func(args []string) error
}

var commands []*command

func register(c *command) {
commands = append(commands, c)
}

func findCommand(name string) *command {
for _, c := range commands {
if c.name == name {
return c
}
if slices.Contains(c.aliases, name) {
return c
}
}
return nil
}

// stringSliceFlag is repeatable and comma-split, like pflag's StringSliceVar.
type stringSliceFlag []string

func (s *stringSliceFlag) String() string {
return strings.Join(*s, ",")
}

func (s *stringSliceFlag) Set(v string) error {
for p := range strings.SplitSeq(v, ",") {
if p = strings.TrimSpace(p); p != "" {
*s = append(*s, p)
}
}
return nil
}

// parseInterspersed re-parses after each positional so flags may appear
// before or after the subcommand and domain (stdlib flag otherwise stops
// at the first positional).
func parseInterspersed(fs *flag.FlagSet, args []string) ([]string, error) {
var pos []string
for {
if err := fs.Parse(args); err != nil {
return nil, err
}
args = fs.Args()
if len(args) == 0 {
return pos, nil
}
pos = append(pos, args[0])
args = args[1:]
}
}

func printUsage(w io.Writer) {
_, _ = fmt.Fprintf(w, "%s\n\nUsage:\n quien [domain or IP] [flags]\n quien [command]\n\nAvailable Commands:\n", longDescription)

type entry struct{ name, short string }
entries := []entry{{"help", "Help about quien"}}
for _, c := range commands {
short := c.short
if len(c.aliases) > 0 {
short += " (alias: " + strings.Join(c.aliases, ", ") + ")"
}
entries = append(entries, entry{c.name, short})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name })
for _, e := range entries {
_, _ = fmt.Fprintf(w, " %-12s%s\n", e.name, e.short)
}

_, _ = fmt.Fprintf(w, `
Flags:
--dkim-selector strings DKIM selector(s) to probe in addition to the built-in common list (repeatable, comma-separated). Overrides %s
-h, --help help for quien
--json output as JSON
--resolver string DNS resolver to use for DNS/mail lookups (host or host:port). Overrides %s
-v, --version version for quien
`, mail.DKIMSelectorsEnvVar, dnsutil.ResolverEnvVar)
}

func fail(err error) {
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}

func Execute(version, commit, date string) {
fs := flag.NewFlagSet("quien", flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.Usage = func() {}

var dkim stringSliceFlag
var showVersion, showHelp bool
fs.BoolVar(&jsonFlag, "json", false, "")
fs.StringVar(&resolverFlag, "resolver", "", "")
fs.Var(&dkim, "dkim-selector", "")
fs.BoolVar(&showVersion, "version", false, "")
fs.BoolVar(&showVersion, "v", false, "")
fs.BoolVar(&showHelp, "help", false, "")
fs.BoolVar(&showHelp, "h", false, "")

pos, err := parseInterspersed(fs, os.Args[1:])
if err != nil {
if errors.Is(err, flag.ErrHelp) {
printUsage(os.Stdout)
return
}
fmt.Fprintln(os.Stderr, "Error:", err)
fmt.Fprintln(os.Stderr, `Run "quien --help" for usage.`)
os.Exit(1)
}
if showHelp {
printUsage(os.Stdout)
return
}
if showVersion {
fmt.Printf("quien version %s (commit %s, built %s)\n", version, commit, date)
return
}
dkimSelectorFlag = dkim

if err := preRun(); err != nil {
fail(err)
}

if len(pos) > 0 {
if pos[0] == "help" {
printUsage(os.Stdout)
return
}
if c := findCommand(pos[0]); c != nil {
if got := len(pos) - 1; got != 1 {
fail(fmt.Errorf("accepts 1 arg(s), received %d", got))
}
if err := c.run(pos[1:]); err != nil {
fail(err)
}
return
}
}

if len(pos) > 1 {
fail(fmt.Errorf("accepts at most 1 arg(s), received %d", len(pos)))
}
if err := runRoot(pos); err != nil {
fail(err)
}
}
32 changes: 14 additions & 18 deletions cmd/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,23 @@ import (

"github.com/retlehs/quien/internal/dns"
"github.com/retlehs/quien/internal/retry"
"github.com/spf13/cobra"
)

var dnsCmd = &cobra.Command{
Use: "dns <domain>",
Short: "DNS record lookup (JSON output)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
domain := normalizeDomain(args[0])
records, err := retry.Do(func() (*dns.Records, error) {
return dns.Lookup(domain)
})
if err != nil {
return fmt.Errorf("DNS lookup failed: %w", err)
}
return printJSON(records)
},
}

func init() {
rootCmd.AddCommand(dnsCmd)
register(&command{
name: "dns",
short: "DNS record lookup (JSON output)",
run: func(args []string) error {
domain := normalizeDomain(args[0])
records, err := retry.Do(func() (*dns.Records, error) {
return dns.Lookup(domain)
})
if err != nil {
return fmt.Errorf("DNS lookup failed: %w", err)
}
return printJSON(records)
},
})
}

func normalizeDomain(s string) string {
Expand Down
32 changes: 14 additions & 18 deletions cmd/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,21 @@ import (

"github.com/retlehs/quien/internal/httpinfo"
"github.com/retlehs/quien/internal/retry"
"github.com/spf13/cobra"
)

var httpCmd = &cobra.Command{
Use: "http <domain>",
Short: "HTTP header and redirect lookup (JSON output)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
domain := normalizeDomain(args[0])
result, err := retry.Do(func() (*httpinfo.Result, error) {
return httpinfo.Lookup(domain)
})
if err != nil {
return fmt.Errorf("HTTP lookup failed: %w", err)
}
return printJSON(result)
},
}

func init() {
rootCmd.AddCommand(httpCmd)
register(&command{
name: "http",
short: "HTTP header and redirect lookup (JSON output)",
run: func(args []string) error {
domain := normalizeDomain(args[0])
result, err := retry.Do(func() (*httpinfo.Result, error) {
return httpinfo.Lookup(domain)
})
if err != nil {
return fmt.Errorf("HTTP lookup failed: %w", err)
}
return printJSON(result)
},
})
}
Loading