From 8168cc509346cd605220e528fd84c96054a6d9cb Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Thu, 12 Mar 2026 13:30:35 +0000 Subject: [PATCH 01/11] feat: implement output, config, and csr packages --- internal/config/config.go | 95 ++++++++++++++++++++-- internal/csr/csr.go | 105 +++++++++++++++++++++++- internal/output/output.go | 164 +++++++++++++++++++++++++++++++++++--- 3 files changed, 345 insertions(+), 19 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 5ac9c42..92bed68 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,19 +36,99 @@ type fileConfig struct { Output string `yaml:"output"` } -// Load resolves configuration with the correct precedence. +// Load resolves configuration with the correct precedence: +// CLI flags > environment variables > config file > defaults. func Load(flags Flags) (*Config, error) { - panic("not implemented") + cfg := &Config{ + APIURL: defaultAPIURL, + Output: "text", + } + + // Layer 1: config file (lowest priority after defaults) + if fc, err := loadFile(); err == nil { + if fc.APIURL != "" { + cfg.APIURL = fc.APIURL + } + if fc.APIKey != "" { + cfg.APIKey = fc.APIKey + } + if fc.Output != "" { + cfg.Output = fc.Output + } + } + + // Layer 2: environment variables + if v := os.Getenv("KK_API_URL"); v != "" { + cfg.APIURL = v + } + if v := os.Getenv("KK_API_KEY"); v != "" { + cfg.APIKey = v + } + if v := os.Getenv("KK_OUTPUT"); v != "" { + cfg.Output = v + } + + // Layer 3: CLI flags (highest priority) + if flags.APIURL != "" { + cfg.APIURL = flags.APIURL + } + if flags.APIKey != "" { + cfg.APIKey = flags.APIKey + } + if flags.Output != "" { + cfg.Output = flags.Output + } + + return cfg, nil } // Save writes the config file with 0600 permissions, creating the directory if needed. +// Only non-empty arguments overwrite existing values. func Save(apiURL, apiKey, output string) error { - panic("not implemented") + dir := ConfigDir() + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + + existing := &fileConfig{} + if fc, err := loadFile(); err == nil { + existing = fc + } + if apiURL != "" { + existing.APIURL = apiURL + } + if apiKey != "" { + existing.APIKey = apiKey + } + if output != "" { + existing.Output = output + } + + data, err := yaml.Marshal(existing) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + if err := os.WriteFile(configPath(), data, 0o600); err != nil { + return fmt.Errorf("write config: %w", err) + } + return nil } // RemoveAPIKey clears the api_key field from the config file. func RemoveAPIKey() error { - panic("not implemented") + fc, err := loadFile() + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + fc.APIKey = "" + data, err := yaml.Marshal(fc) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + return os.WriteFile(configPath(), data, 0o600) } // ConfigDir returns the krakenkey config directory, respecting XDG_CONFIG_HOME. @@ -66,7 +146,8 @@ func configPath() string { } func loadFile() (*fileConfig, error) { - data, err := os.ReadFile(configPath()) + path := configPath() + data, err := os.ReadFile(path) if err != nil { return nil, err } @@ -74,10 +155,10 @@ func loadFile() (*fileConfig, error) { if err := yaml.Unmarshal(data, &fc); err != nil { return nil, fmt.Errorf("parse config: %w", err) } - info, err := os.Stat(configPath()) + info, err := os.Stat(path) if err == nil && info.Mode().Perm()&0o077 != 0 { fmt.Fprintf(os.Stderr, "warning: config file %s has broad permissions (%s), consider chmod 600\n", - configPath(), info.Mode().Perm()) + path, info.Mode().Perm()) } return &fc, nil } diff --git a/internal/csr/csr.go b/internal/csr/csr.go index 19a6700..ae77b69 100644 --- a/internal/csr/csr.go +++ b/internal/csr/csr.go @@ -2,6 +2,18 @@ // Private keys are generated in-process and never transmitted to the API. package csr +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "net" +) + // Supported key types. const ( KeyTypeRSA2048 = "rsa-2048" @@ -32,5 +44,96 @@ type Result struct { // The CN is automatically added to DNSNames. IP SANs are detected and placed in IPAddresses. // The private key is encoded as PKCS#8 PEM. func Generate(keyType string, subject Subject, sans []string) (*Result, error) { - panic("not implemented") + var ( + privKey any + resultType string + resultSize int + ) + + switch keyType { + case KeyTypeRSA2048: + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("generate RSA-2048 key: %w", err) + } + privKey, resultType, resultSize = k, "RSA", 2048 + case KeyTypeRSA4096: + k, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, fmt.Errorf("generate RSA-4096 key: %w", err) + } + privKey, resultType, resultSize = k, "RSA", 4096 + case KeyTypeECDSAP256: + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate ECDSA P-256 key: %w", err) + } + privKey, resultType, resultSize = k, "ECDSA", 256 + case KeyTypeECDSAP384: + k, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate ECDSA P-384 key: %w", err) + } + privKey, resultType, resultSize = k, "ECDSA", 384 + default: + return nil, fmt.Errorf("unsupported key type %q — use rsa-2048, rsa-4096, ecdsa-p256, or ecdsa-p384", keyType) + } + + pkixSubject := pkix.Name{CommonName: subject.CommonName} + if subject.Organization != "" { + pkixSubject.Organization = []string{subject.Organization} + } + if subject.OrganizationalUnit != "" { + pkixSubject.OrganizationalUnit = []string{subject.OrganizationalUnit} + } + if subject.Locality != "" { + pkixSubject.Locality = []string{subject.Locality} + } + if subject.State != "" { + pkixSubject.Province = []string{subject.State} + } + if subject.Country != "" { + pkixSubject.Country = []string{subject.Country} + } + + // CN is always included in SANs; dedup across CN + extra SANs. + var dnsNames []string + var ipAddresses []net.IP + seen := map[string]bool{} + for _, san := range append([]string{subject.CommonName}, sans...) { + if san == "" || seen[san] { + continue + } + seen[san] = true + if ip := net.ParseIP(san); ip != nil { + ipAddresses = append(ipAddresses, ip) + } else { + dnsNames = append(dnsNames, san) + } + } + + template := &x509.CertificateRequest{ + Subject: pkixSubject, + DNSNames: dnsNames, + IPAddresses: ipAddresses, + } + + csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, privKey) + if err != nil { + return nil, fmt.Errorf("create CSR: %w", err) + } + csrPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}) + + privKeyDER, err := x509.MarshalPKCS8PrivateKey(privKey) + if err != nil { + return nil, fmt.Errorf("marshal private key: %w", err) + } + privKeyPem := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privKeyDER}) + + return &Result{ + CSRPem: csrPem, + PrivateKeyPem: privKeyPem, + KeyType: resultType, + KeySize: resultSize, + }, nil } diff --git a/internal/output/output.go b/internal/output/output.go index 2248d06..ce4bea8 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -5,8 +5,21 @@ package output import ( + "encoding/json" + "fmt" "io" "os" + "strings" + "sync" + "time" +) + +const ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorGreen = "\033[32m" + colorBlue = "\033[34m" + colorBold = "\033[1m" ) // Printer formats and writes CLI output. @@ -32,46 +45,175 @@ func NewWithWriters(format string, noColor bool, w, errW io.Writer) *Printer { // IsJSON reports whether the printer is in JSON mode. func (p *Printer) IsJSON() bool { return p.format == "json" } +func (p *Printer) color(c, s string) string { + if p.noColor { + return s + } + return c + s + colorReset +} + // Success prints a success message prefixed with ✓ (text mode only). -func (p *Printer) Success(msg string, args ...any) { panic("not implemented") } +func (p *Printer) Success(msg string, args ...any) { + if p.IsJSON() { + return + } + fmt.Fprintf(p.w, p.color(colorGreen, "✓")+" "+fmt.Sprintf(msg, args...)+"\n") +} // Error prints an error message to stderr. In JSON mode it emits {"error":"..."}. -func (p *Printer) Error(msg string, args ...any) { panic("not implemented") } +func (p *Printer) Error(msg string, args ...any) { + text := fmt.Sprintf(msg, args...) + if p.IsJSON() { + data, _ := json.Marshal(map[string]string{"error": text}) + fmt.Fprintln(p.errW, string(data)) + return + } + fmt.Fprintf(p.errW, p.color(colorRed, "Error:")+" "+text+"\n") +} // Info prints an informational message prefixed with • (text mode only). -func (p *Printer) Info(msg string, args ...any) { panic("not implemented") } +func (p *Printer) Info(msg string, args ...any) { + if p.IsJSON() { + return + } + fmt.Fprintf(p.w, p.color(colorBlue, "•")+" "+fmt.Sprintf(msg, args...)+"\n") +} // Println prints a plain line (text mode only). -func (p *Printer) Println(msg string, args ...any) { panic("not implemented") } +func (p *Printer) Println(msg string, args ...any) { + if p.IsJSON() { + return + } + fmt.Fprintf(p.w, fmt.Sprintf(msg, args...)+"\n") +} // Printf prints a formatted string (text mode only). -func (p *Printer) Printf(format string, args ...any) { panic("not implemented") } +func (p *Printer) Printf(format string, args ...any) { + if p.IsJSON() { + return + } + fmt.Fprintf(p.w, format, args...) +} // JSON marshals v as indented JSON and writes it to stdout. -func (p *Printer) JSON(v any) { panic("not implemented") } +func (p *Printer) JSON(v any) { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + fmt.Fprintf(p.errW, `{"error":"failed to marshal JSON"}`+"\n") + return + } + fmt.Fprintln(p.w, string(data)) +} // Table prints an aligned text table (text mode only). // headers and each row must have the same number of columns. -func (p *Printer) Table(headers []string, rows [][]string) { panic("not implemented") } +func (p *Printer) Table(headers []string, rows [][]string) { + if p.IsJSON() { + return + } + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = len(h) + } + for _, row := range rows { + for i, cell := range row { + if i < len(widths) && len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + + // Header row + fmt.Fprintf(p.w, " ") + for i, h := range headers { + fmt.Fprintf(p.w, p.color(colorBold, fmt.Sprintf("%-*s", widths[i], h))) + if i < len(headers)-1 { + fmt.Fprintf(p.w, " ") + } + } + fmt.Fprintln(p.w) + + // Separator + fmt.Fprintf(p.w, " ") + for i, w := range widths { + fmt.Fprintf(p.w, strings.Repeat("─", w)) + if i < len(widths)-1 { + fmt.Fprintf(p.w, " ") + } + } + fmt.Fprintln(p.w) + + // Data rows + for _, row := range rows { + fmt.Fprintf(p.w, " ") + for i, cell := range row { + if i < len(widths) { + fmt.Fprintf(p.w, "%-*s", widths[i], cell) + if i < len(row)-1 && i < len(widths)-1 { + fmt.Fprintf(p.w, " ") + } + } + } + fmt.Fprintln(p.w) + } +} // Spinner is a braille-animation spinner that writes to stderr. // In JSON mode all Spinner methods are no-ops. type Spinner struct { + mu sync.Mutex msg string + done chan struct{} + wg sync.WaitGroup w io.Writer json bool } +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + // NewSpinner creates a spinner with the given initial message. func (p *Printer) NewSpinner(msg string) *Spinner { - return &Spinner{msg: msg, w: p.errW, json: p.IsJSON()} + return &Spinner{msg: msg, done: make(chan struct{}), w: p.errW, json: p.IsJSON()} } // Start begins the spinner animation in a background goroutine. -func (s *Spinner) Start() { panic("not implemented") } +func (s *Spinner) Start() { + if s.json { + return + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + i := 0 + for { + select { + case <-s.done: + fmt.Fprintf(s.w, "\r\033[K") + return + default: + s.mu.Lock() + msg := s.msg + s.mu.Unlock() + fmt.Fprintf(s.w, "\r%s %s", spinnerFrames[i%len(spinnerFrames)], msg) + time.Sleep(80 * time.Millisecond) + i++ + } + } + }() +} // UpdateMsg changes the spinner message while it is running. -func (s *Spinner) UpdateMsg(msg string) { s.msg = msg } +func (s *Spinner) UpdateMsg(msg string) { + s.mu.Lock() + s.msg = msg + s.mu.Unlock() +} // Stop halts the spinner and clears the line. -func (s *Spinner) Stop() { panic("not implemented") } +func (s *Spinner) Stop() { + if s.json { + return + } + close(s.done) + s.wg.Wait() +} From 6742f4778f63363c3ebedab0b48512deecb7fe09 Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Thu, 12 Mar 2026 13:58:23 +0000 Subject: [PATCH 02/11] feat: api client --- internal/api/client.go | 185 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 166 insertions(+), 19 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index 682f703..bb2a12a 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -1,9 +1,14 @@ package api import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "net/http" + "net/url" + "strconv" "time" ) @@ -28,86 +33,228 @@ func NewClient(baseURL, apiKey, version, goos, goarch string) *Client { } } +// do executes an HTTP request and decodes the JSON response into out. +// If out is nil the response body is discarded. A non-2xx status code is +// decoded as an APIError and mapped to a typed error where appropriate. +func (c *Client) do(ctx context.Context, method, path string, body any, out any) error { + var reqBody io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request body: %w", err) + } + reqBody = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody) + if err != nil { + return &ErrNetwork{Message: fmt.Sprintf("build request: %s", err)} + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("User-Agent", c.userAgent) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return &ErrNetwork{Message: fmt.Sprintf("request failed: %s", err)} + } + defer resp.Body.Close() + + respData, err := io.ReadAll(resp.Body) + if err != nil { + return &ErrNetwork{Message: fmt.Sprintf("read response body: %s", err)} + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var apiErr APIError + if jsonErr := json.Unmarshal(respData, &apiErr); jsonErr != nil { + apiErr = APIError{StatusCode: resp.StatusCode, Message: string(respData)} + } + apiErr.StatusCode = resp.StatusCode + switch resp.StatusCode { + case http.StatusUnauthorized: + return &ErrAuth{Message: apiErr.Message} + case http.StatusNotFound: + return &ErrNotFound{Message: apiErr.Message} + case http.StatusTooManyRequests: + return &ErrRateLimit{ + Message: apiErr.Message, + RetryAfter: resp.Header.Get("Retry-After"), + } + default: + return &apiErr + } + } + + if out != nil && len(respData) > 0 { + if err := json.Unmarshal(respData, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + return nil +} + // Domain methods func (c *Client) CreateDomain(ctx context.Context, hostname string) (*Domain, error) { - panic("not implemented") + body := map[string]string{"hostname": hostname} + var d Domain + if err := c.do(ctx, http.MethodPost, "/domains", body, &d); err != nil { + return nil, err + } + return &d, nil } func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { - panic("not implemented") + var domains []Domain + if err := c.do(ctx, http.MethodGet, "/domains", nil, &domains); err != nil { + return nil, err + } + return domains, nil } func (c *Client) GetDomain(ctx context.Context, id string) (*Domain, error) { - panic("not implemented") + var d Domain + if err := c.do(ctx, http.MethodGet, "/domains/"+id, nil, &d); err != nil { + return nil, err + } + return &d, nil } func (c *Client) VerifyDomain(ctx context.Context, id string) (*Domain, error) { - panic("not implemented") + var d Domain + if err := c.do(ctx, http.MethodPost, "/domains/"+id+"/verify", nil, &d); err != nil { + return nil, err + } + return &d, nil } func (c *Client) DeleteDomain(ctx context.Context, id string) error { - panic("not implemented") + return c.do(ctx, http.MethodDelete, "/domains/"+id, nil, nil) } // Certificate methods func (c *Client) CreateCert(ctx context.Context, csrPem string) (*CertResponse, error) { - panic("not implemented") + body := map[string]string{"rawCsr": csrPem} + var cr CertResponse + if err := c.do(ctx, http.MethodPost, "/certs", body, &cr); err != nil { + return nil, err + } + return &cr, nil } func (c *Client) ListCerts(ctx context.Context, status string) ([]TlsCert, error) { - panic("not implemented") + path := "/certs" + if status != "" { + path += "?status=" + url.QueryEscape(status) + } + var certs []TlsCert + if err := c.do(ctx, http.MethodGet, path, nil, &certs); err != nil { + return nil, err + } + return certs, nil } func (c *Client) GetCert(ctx context.Context, id int) (*TlsCert, error) { - panic("not implemented") + var cert TlsCert + if err := c.do(ctx, http.MethodGet, "/certs/"+strconv.Itoa(id), nil, &cert); err != nil { + return nil, err + } + return &cert, nil } func (c *Client) GetCertDetails(ctx context.Context, id int) (*TlsCertDetails, error) { - panic("not implemented") + var details TlsCertDetails + if err := c.do(ctx, http.MethodGet, "/certs/"+strconv.Itoa(id)+"/details", nil, &details); err != nil { + return nil, err + } + return &details, nil } func (c *Client) UpdateCert(ctx context.Context, id int, autoRenew *bool) (*TlsCert, error) { - panic("not implemented") + body := map[string]any{"autoRenew": autoRenew} + var cert TlsCert + if err := c.do(ctx, http.MethodPatch, "/certs/"+strconv.Itoa(id), body, &cert); err != nil { + return nil, err + } + return &cert, nil } func (c *Client) RenewCert(ctx context.Context, id int) (*CertResponse, error) { - panic("not implemented") + var cr CertResponse + if err := c.do(ctx, http.MethodPost, "/certs/"+strconv.Itoa(id)+"/renew", nil, &cr); err != nil { + return nil, err + } + return &cr, nil } func (c *Client) RevokeCert(ctx context.Context, id int, reason *int) (*CertResponse, error) { - panic("not implemented") + var body any + if reason != nil { + body = map[string]int{"reason": *reason} + } + var cr CertResponse + if err := c.do(ctx, http.MethodPost, "/certs/"+strconv.Itoa(id)+"/revoke", body, &cr); err != nil { + return nil, err + } + return &cr, nil } func (c *Client) RetryCert(ctx context.Context, id int) (*CertResponse, error) { - panic("not implemented") + var cr CertResponse + if err := c.do(ctx, http.MethodPost, "/certs/"+strconv.Itoa(id)+"/retry", nil, &cr); err != nil { + return nil, err + } + return &cr, nil } func (c *Client) DeleteCert(ctx context.Context, id int) error { - panic("not implemented") + return c.do(ctx, http.MethodDelete, "/certs/"+strconv.Itoa(id), nil, nil) } // Auth methods func (c *Client) GetProfile(ctx context.Context) (*UserProfile, error) { - panic("not implemented") + var p UserProfile + if err := c.do(ctx, http.MethodGet, "/auth/me", nil, &p); err != nil { + return nil, err + } + return &p, nil } func (c *Client) ListAPIKeys(ctx context.Context) ([]APIKey, error) { - panic("not implemented") + var keys []APIKey + if err := c.do(ctx, http.MethodGet, "/auth/api-keys", nil, &keys); err != nil { + return nil, err + } + return keys, nil } func (c *Client) CreateAPIKey(ctx context.Context, name string, expiresAt *string) (*CreateAPIKeyResponse, error) { - panic("not implemented") + body := map[string]any{"name": name} + if expiresAt != nil { + body["expiresAt"] = *expiresAt + } + var resp CreateAPIKeyResponse + if err := c.do(ctx, http.MethodPost, "/auth/api-keys", body, &resp); err != nil { + return nil, err + } + return &resp, nil } func (c *Client) DeleteAPIKey(ctx context.Context, id string) error { - panic("not implemented") + return c.do(ctx, http.MethodDelete, "/auth/api-keys/"+id, nil, nil) } // Billing methods func (c *Client) GetSubscription(ctx context.Context) (*Subscription, error) { - panic("not implemented") + var s Subscription + if err := c.do(ctx, http.MethodGet, "/billing/subscription", nil, &s); err != nil { + return nil, err + } + return &s, nil } From 75c5950847cf0d489cd097e953e525b7904992fb Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Thu, 12 Mar 2026 13:58:48 +0000 Subject: [PATCH 03/11] feat: auth --- internal/account/account.go | 35 +++++++++++++- internal/auth/auth.go | 91 ++++++++++++++++++++++++++++++++++--- internal/domain/domain.go | 74 ++++++++++++++++++++++++++++-- 3 files changed, 187 insertions(+), 13 deletions(-) diff --git a/internal/account/account.go b/internal/account/account.go index 5946e9c..fd95a13 100644 --- a/internal/account/account.go +++ b/internal/account/account.go @@ -3,6 +3,7 @@ package account import ( "context" + "time" "github.com/krakenkey/cli/internal/api" "github.com/krakenkey/cli/internal/output" @@ -10,10 +11,40 @@ import ( // RunShow prints the authenticated user's profile. func RunShow(ctx context.Context, client *api.Client, printer *output.Printer) error { - panic("not implemented") + profile, err := client.GetProfile(ctx) + if err != nil { + return err + } + + printer.JSON(profile) + printer.Println("ID: %s", profile.ID) + printer.Println("Username: %s", profile.Username) + printer.Println("Email: %s", profile.Email) + printer.Println("Display name: %s", profile.DisplayName) + printer.Println("Plan: %s", profile.Plan) + printer.Println("Domains: %d", profile.ResourceCounts.Domains) + printer.Println("Certificates: %d", profile.ResourceCounts.Certificates) + printer.Println("API keys: %d", profile.ResourceCounts.APIKeys) + printer.Println("Member since: %s", profile.CreatedAt.Format(time.RFC3339)) + return nil } // RunPlan prints subscription details including plan limits vs. current usage. func RunPlan(ctx context.Context, client *api.Client, printer *output.Printer) error { - panic("not implemented") + sub, err := client.GetSubscription(ctx) + if err != nil { + return err + } + + printer.JSON(sub) + printer.Println("Plan: %s", sub.Plan) + printer.Println("Status: %s", sub.Status) + if sub.CurrentPeriodEnd != nil { + printer.Println("Current period ends: %s", sub.CurrentPeriodEnd.Format(time.RFC3339)) + } + if sub.CancelAtPeriodEnd { + printer.Info("Subscription is set to cancel at end of current period") + } + printer.Println("Subscribed: %s", sub.CreatedAt.Format(time.RFC3339)) + return nil } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 001eb02..667f8ba 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,7 +2,12 @@ package auth import ( + "bufio" "context" + "fmt" + "os" + "strings" + "time" "github.com/krakenkey/cli/internal/api" "github.com/krakenkey/cli/internal/config" @@ -12,30 +17,104 @@ import ( // RunLogin sets the API key, validates it against the API, and saves it to the config file. // If apiKey is empty the user is prompted interactively. func RunLogin(ctx context.Context, client *api.Client, printer *output.Printer, cfg *config.Config, apiKey string) error { - panic("not implemented") + if apiKey == "" { + fmt.Fprint(os.Stderr, "Enter API key: ") + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + apiKey = strings.TrimSpace(scanner.Text()) + } + if apiKey == "" { + return &api.ErrConfig{Message: "API key cannot be empty"} + } + } + + // Validate the key by fetching the profile. + profile, err := client.GetProfile(ctx) + if err != nil { + return err + } + + if err := config.Save("", apiKey, ""); err != nil { + return &api.ErrConfig{Message: fmt.Sprintf("save config: %s", err)} + } + + printer.Success("Logged in as %s (%s)", profile.DisplayName, profile.Email) + return nil } // RunLogout removes the stored API key from the config file. func RunLogout(printer *output.Printer) error { - panic("not implemented") + if err := config.RemoveAPIKey(); err != nil { + return fmt.Errorf("remove API key: %w", err) + } + printer.Success("Logged out — API key removed from config") + return nil } // RunStatus prints the current authentication status and resource usage. func RunStatus(ctx context.Context, client *api.Client, printer *output.Printer) error { - panic("not implemented") + profile, err := client.GetProfile(ctx) + if err != nil { + return err + } + + printer.JSON(profile) + printer.Println("User: %s", profile.DisplayName) + printer.Println("Email: %s", profile.Email) + printer.Println("Plan: %s", profile.Plan) + printer.Println("Domains: %d", profile.ResourceCounts.Domains) + printer.Println("Certificates: %d", profile.ResourceCounts.Certificates) + printer.Println("API keys: %d", profile.ResourceCounts.APIKeys) + return nil } // RunKeysList lists all API keys for the authenticated user. func RunKeysList(ctx context.Context, client *api.Client, printer *output.Printer) error { - panic("not implemented") + keys, err := client.ListAPIKeys(ctx) + if err != nil { + return err + } + + printer.JSON(keys) + + if len(keys) == 0 { + printer.Info("No API keys found") + return nil + } + + headers := []string{"ID", "Name", "Created", "Expires"} + rows := make([][]string, len(keys)) + for i, k := range keys { + exp := "never" + if k.ExpiresAt != nil { + exp = k.ExpiresAt.Format(time.RFC3339) + } + rows[i] = []string{k.ID, k.Name, k.CreatedAt.Format(time.RFC3339), exp} + } + printer.Table(headers, rows) + return nil } // RunKeysCreate creates a new API key and prints the key secret (shown once). func RunKeysCreate(ctx context.Context, client *api.Client, printer *output.Printer, name string, expiresAt *string) error { - panic("not implemented") + resp, err := client.CreateAPIKey(ctx, name, expiresAt) + if err != nil { + return err + } + + printer.JSON(resp) + printer.Success("API key created") + printer.Println("ID: %s", resp.ID) + printer.Println("Key: %s", resp.APIKey) + printer.Info("Store this key securely — it will not be shown again") + return nil } // RunKeysDelete deletes an API key by ID. func RunKeysDelete(ctx context.Context, client *api.Client, printer *output.Printer, id string) error { - panic("not implemented") + if err := client.DeleteAPIKey(ctx, id); err != nil { + return err + } + printer.Success("API key %s deleted", id) + return nil } diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 64ae108..5756244 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -3,6 +3,8 @@ package domain import ( "context" + "fmt" + "time" "github.com/krakenkey/cli/internal/api" "github.com/krakenkey/cli/internal/output" @@ -10,25 +12,87 @@ import ( // RunAdd registers a new domain and prints DNS TXT verification instructions. func RunAdd(ctx context.Context, client *api.Client, printer *output.Printer, hostname string) error { - panic("not implemented") + d, err := client.CreateDomain(ctx, hostname) + if err != nil { + return err + } + + printer.JSON(d) + printer.Success("Domain registered: %s", d.Hostname) + printer.Println("") + printer.Println("Add a DNS TXT record to verify ownership:") + printer.Println(" Name: %s", d.Hostname) + printer.Println(" Value: %s", d.VerificationCode) + printer.Println("") + printer.Info("Run `krakenkey domain verify %s` once the record has propagated", d.ID) + return nil } // RunList lists all registered domains. func RunList(ctx context.Context, client *api.Client, printer *output.Printer) error { - panic("not implemented") + domains, err := client.ListDomains(ctx) + if err != nil { + return err + } + + printer.JSON(domains) + + if len(domains) == 0 { + printer.Info("No domains registered") + return nil + } + + headers := []string{"ID", "Hostname", "Verified", "Created"} + rows := make([][]string, len(domains)) + for i, d := range domains { + verified := "no" + if d.IsVerified { + verified = "yes" + } + rows[i] = []string{d.ID, d.Hostname, verified, d.CreatedAt.Format(time.RFC3339)} + } + printer.Table(headers, rows) + return nil } // RunShow prints full details for a domain, including the verification record. func RunShow(ctx context.Context, client *api.Client, printer *output.Printer, id string) error { - panic("not implemented") + d, err := client.GetDomain(ctx, id) + if err != nil { + return err + } + + printer.JSON(d) + printer.Println("ID: %s", d.ID) + printer.Println("Hostname: %s", d.Hostname) + printer.Println("Verified: %v", d.IsVerified) + printer.Println("Verification code: %s", d.VerificationCode) + printer.Println("TXT record name: %s", d.Hostname) + printer.Println("Created: %s", d.CreatedAt.Format(time.RFC3339)) + return nil } // RunVerify triggers DNS TXT verification for a domain. func RunVerify(ctx context.Context, client *api.Client, printer *output.Printer, id string) error { - panic("not implemented") + d, err := client.VerifyDomain(ctx, id) + if err != nil { + return err + } + + printer.JSON(d) + if d.IsVerified { + printer.Success("Domain %s verified", d.Hostname) + } else { + return fmt.Errorf("verification failed for %s — DNS TXT record not found or not yet propagated", d.Hostname) + } + return nil } // RunDelete deletes a domain by ID. func RunDelete(ctx context.Context, client *api.Client, printer *output.Printer, id string) error { - panic("not implemented") + if err := client.DeleteDomain(ctx, id); err != nil { + return err + } + printer.Success("Domain %s deleted", id) + return nil } From c06b796bc1a7cea8bbc5ab2a3a6fe279d5865443 Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Thu, 12 Mar 2026 14:54:19 +0000 Subject: [PATCH 04/11] feat: certs --- internal/cert/cert.go | 219 ++++++++++++++++++++++++++++++++++++++-- internal/cert/issue.go | 88 +++++++++++++++- internal/cert/submit.go | 58 ++++++++++- 3 files changed, 352 insertions(+), 13 deletions(-) diff --git a/internal/cert/cert.go b/internal/cert/cert.go index 59e547e..17a687b 100644 --- a/internal/cert/cert.go +++ b/internal/cert/cert.go @@ -3,55 +3,252 @@ package cert import ( "context" + "fmt" + "os" + "strconv" "time" "github.com/krakenkey/cli/internal/api" "github.com/krakenkey/cli/internal/output" ) +// terminalStatuses are the cert statuses that indicate processing has finished. +var terminalStatuses = map[string]bool{ + api.CertStatusIssued: true, + api.CertStatusFailed: true, + api.CertStatusRevoked: true, +} + +// cnFromCert extracts the Common Name from the cert's parsed CSR subject. +func cnFromCert(c *api.TlsCert) string { + if c.ParsedCsr == nil { + return strconv.Itoa(c.ID) + } + for _, field := range c.ParsedCsr.Subject { + if field.Name == "commonName" || field.Name == "CN" { + return field.Value + } + } + return strconv.Itoa(c.ID) +} + // RunList lists certificates, optionally filtered by status. func RunList(ctx context.Context, client *api.Client, printer *output.Printer, status string) error { - panic("not implemented") + certs, err := client.ListCerts(ctx, status) + if err != nil { + return err + } + + printer.JSON(certs) + + if len(certs) == 0 { + printer.Info("No certificates found") + return nil + } + + headers := []string{"ID", "Domain", "Status", "Expires", "Auto-renew"} + rows := make([][]string, len(certs)) + for i, c := range certs { + exp := "—" + if c.ExpiresAt != nil { + exp = c.ExpiresAt.Format("2006-01-02") + } + autoRenew := "no" + if c.AutoRenew { + autoRenew = "yes" + } + rows[i] = []string{ + strconv.Itoa(c.ID), + cnFromCert(&c), + c.Status, + exp, + autoRenew, + } + } + printer.Table(headers, rows) + return nil } // RunShow prints full details for a certificate, including parsed cert fields if issued. func RunShow(ctx context.Context, client *api.Client, printer *output.Printer, id int) error { - panic("not implemented") + c, err := client.GetCert(ctx, id) + if err != nil { + return err + } + + printer.JSON(c) + printer.Println("ID: %d", c.ID) + printer.Println("Status: %s", c.Status) + printer.Println("Domain: %s", cnFromCert(c)) + printer.Println("Auto-renew: %v", c.AutoRenew) + printer.Println("Created: %s", c.CreatedAt.Format(time.RFC3339)) + if c.ExpiresAt != nil { + printer.Println("Expires: %s", c.ExpiresAt.Format(time.RFC3339)) + } + if c.LastRenewedAt != nil { + printer.Println("Last renewed:%s", c.LastRenewedAt.Format(time.RFC3339)) + } + if c.RenewalCount > 0 { + printer.Println("Renewals: %d", c.RenewalCount) + } + + if c.ParsedCsr != nil && c.ParsedCsr.PublicKey != nil { + printer.Println("Key type: %s %d", c.ParsedCsr.PublicKey.KeyType, c.ParsedCsr.PublicKey.BitLength) + } + + if c.Status == api.CertStatusIssued { + details, err := client.GetCertDetails(ctx, id) + if err == nil { + printer.Println("") + printer.Println("Serial: %s", details.SerialNumber) + printer.Println("Issuer: %s", details.Issuer) + printer.Println("Valid from: %s", details.ValidFrom.Format(time.RFC3339)) + printer.Println("Valid to: %s", details.ValidTo.Format(time.RFC3339)) + printer.Println("Fingerprint: %s", details.Fingerprint) + } + } + return nil } // RunDownload saves the certificate PEM to outPath (default: ./.crt). func RunDownload(ctx context.Context, client *api.Client, printer *output.Printer, id int, outPath string) error { - panic("not implemented") + c, err := client.GetCert(ctx, id) + if err != nil { + return err + } + + if c.Status != api.CertStatusIssued { + return fmt.Errorf("certificate %d is not issued (status: %s)", id, c.Status) + } + if c.CrtPem == "" { + return fmt.Errorf("certificate %d has no PEM data", id) + } + + if outPath == "" { + outPath = cnFromCert(c) + ".crt" + } + + if err := os.WriteFile(outPath, []byte(c.CrtPem), 0o644); err != nil { + return fmt.Errorf("write certificate: %w", err) + } + + printer.JSON(map[string]string{"path": outPath}) + printer.Success("Certificate saved to %s", outPath) + return nil } // RunRenew triggers manual renewal and optionally polls until complete. func RunRenew(ctx context.Context, client *api.Client, printer *output.Printer, id int, wait bool, pollInterval, pollTimeout time.Duration) error { - panic("not implemented") + resp, err := client.RenewCert(ctx, id) + if err != nil { + return err + } + + printer.JSON(resp) + printer.Success("Renewal triggered for certificate %d (status: %s)", resp.ID, resp.Status) + + if !wait { + return nil + } + cert, err := PollUntilDone(ctx, client, printer, resp.ID, pollInterval, pollTimeout) + if err != nil { + return err + } + if cert.Status == api.CertStatusFailed { + return fmt.Errorf("renewal failed for certificate %d", id) + } + printer.Success("Certificate %d renewed", id) + return nil } // RunRevoke revokes a certificate. reason is an RFC 5280 reason code (nil = unspecified). func RunRevoke(ctx context.Context, client *api.Client, printer *output.Printer, id int, reason *int) error { - panic("not implemented") + resp, err := client.RevokeCert(ctx, id, reason) + if err != nil { + return err + } + printer.JSON(resp) + printer.Success("Certificate %d revocation initiated (status: %s)", resp.ID, resp.Status) + return nil } // RunRetry retries a failed certificate issuance. func RunRetry(ctx context.Context, client *api.Client, printer *output.Printer, id int, wait bool, pollInterval, pollTimeout time.Duration) error { - panic("not implemented") + resp, err := client.RetryCert(ctx, id) + if err != nil { + return err + } + + printer.JSON(resp) + printer.Success("Retry triggered for certificate %d (status: %s)", resp.ID, resp.Status) + + if !wait { + return nil + } + cert, err := PollUntilDone(ctx, client, printer, resp.ID, pollInterval, pollTimeout) + if err != nil { + return err + } + if cert.Status == api.CertStatusFailed { + return fmt.Errorf("certificate %d issuance failed after retry", id) + } + printer.Success("Certificate %d issued", id) + return nil } // RunDelete deletes a failed or revoked certificate. func RunDelete(ctx context.Context, client *api.Client, printer *output.Printer, id int) error { - panic("not implemented") + if err := client.DeleteCert(ctx, id); err != nil { + return err + } + printer.Success("Certificate %d deleted", id) + return nil } // RunUpdate updates certificate settings (currently only auto-renewal). func RunUpdate(ctx context.Context, client *api.Client, printer *output.Printer, id int, autoRenew *bool) error { - panic("not implemented") + c, err := client.UpdateCert(ctx, id, autoRenew) + if err != nil { + return err + } + printer.JSON(c) + printer.Success("Certificate %d updated", id) + if autoRenew != nil { + printer.Println("Auto-renew: %v", c.AutoRenew) + } + return nil } -// PollUntilDone polls GET /certs/tls/:id until the cert reaches a terminal state +// PollUntilDone polls GET /certs/:id until the cert reaches a terminal state // (issued, failed, revoked) or pollTimeout is exceeded. -// It displays a spinner with status and elapsed time. +// It displays a spinner with status and elapsed time on stderr. func PollUntilDone(ctx context.Context, client *api.Client, printer *output.Printer, certID int, pollInterval, pollTimeout time.Duration) (*api.TlsCert, error) { - panic("not implemented") + start := time.Now() + spinner := printer.NewSpinner(fmt.Sprintf("Waiting for certificate %d [checking]", certID)) + spinner.Start() + defer spinner.Stop() + + deadline := time.After(pollTimeout) + tick := time.NewTicker(pollInterval) + defer tick.Stop() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-deadline: + return nil, fmt.Errorf("timed out after %s waiting for certificate %d", pollTimeout, certID) + case <-tick.C: + c, err := client.GetCert(ctx, certID) + if err != nil { + return nil, err + } + elapsed := time.Since(start).Round(time.Second) + spinner.UpdateMsg(fmt.Sprintf("Waiting for certificate %d [%s] %s", certID, c.Status, elapsed)) + + if terminalStatuses[c.Status] { + return c, nil + } + } + } } diff --git a/internal/cert/issue.go b/internal/cert/issue.go index a6af9d0..0a5e9df 100644 --- a/internal/cert/issue.go +++ b/internal/cert/issue.go @@ -2,9 +2,12 @@ package cert import ( "context" + "fmt" + "os" "time" "github.com/krakenkey/cli/internal/api" + "github.com/krakenkey/cli/internal/csr" "github.com/krakenkey/cli/internal/output" ) @@ -30,5 +33,88 @@ type IssueOptions struct { // RunIssue generates a CSR + private key locally, submits the CSR, and optionally // polls until the certificate is issued. func RunIssue(ctx context.Context, client *api.Client, printer *output.Printer, opts IssueOptions) error { - panic("not implemented") + keyType := opts.KeyType + if keyType == "" { + keyType = csr.KeyTypeECDSAP256 + } + + keyOut := opts.KeyOut + if keyOut == "" { + keyOut = opts.Domain + ".key" + } + csrOut := opts.CSROut + if csrOut == "" { + csrOut = opts.Domain + ".csr" + } + certOut := opts.Out + if certOut == "" { + certOut = opts.Domain + ".crt" + } + + printer.Info("Generating %s key pair...", keyType) + + result, err := csr.Generate(keyType, csr.Subject{ + CommonName: opts.Domain, + Organization: opts.Org, + OrganizationalUnit: opts.OU, + Locality: opts.Locality, + State: opts.State, + Country: opts.Country, + }, opts.SANs) + if err != nil { + return fmt.Errorf("generate CSR: %w", err) + } + + // Write private key with restricted permissions — never sent to the API. + if err := os.WriteFile(keyOut, result.PrivateKeyPem, 0o600); err != nil { + return fmt.Errorf("write private key: %w", err) + } + printer.Info("Private key saved to %s", keyOut) + + // Write CSR for reference. + if err := os.WriteFile(csrOut, result.CSRPem, 0o644); err != nil { + return fmt.Errorf("write CSR: %w", err) + } + printer.Info("CSR saved to %s", csrOut) + + // Submit CSR. + resp, err := client.CreateCert(ctx, string(result.CSRPem)) + if err != nil { + return fmt.Errorf("submit CSR: %w", err) + } + printer.Info("Certificate request submitted (ID: %d, status: %s)", resp.ID, resp.Status) + + // Set auto-renew if requested. + if opts.AutoRenew { + t := true + if _, err := client.UpdateCert(ctx, resp.ID, &t); err != nil { + printer.Error("Failed to enable auto-renew: %s", err) + } + } + + if !opts.Wait { + printer.JSON(resp) + printer.Success("Certificate %d submitted — run `krakenkey cert show %d` to check status", resp.ID, resp.ID) + return nil + } + + cert, err := PollUntilDone(ctx, client, printer, resp.ID, opts.PollInterval, opts.PollTimeout) + if err != nil { + return err + } + + if cert.Status == api.CertStatusFailed { + return fmt.Errorf("certificate issuance failed for %s", opts.Domain) + } + + if cert.CrtPem != "" { + if err := os.WriteFile(certOut, []byte(cert.CrtPem), 0o644); err != nil { + return fmt.Errorf("write certificate: %w", err) + } + printer.Info("Certificate saved to %s", certOut) + } + + printer.JSON(cert) + printer.Success("Certificate %d issued", cert.ID) + return nil } diff --git a/internal/cert/submit.go b/internal/cert/submit.go index d574a42..029ea7a 100644 --- a/internal/cert/submit.go +++ b/internal/cert/submit.go @@ -2,6 +2,8 @@ package cert import ( "context" + "fmt" + "os" "time" "github.com/krakenkey/cli/internal/api" @@ -21,5 +23,59 @@ type SubmitOptions struct { // RunSubmit reads an existing CSR PEM from disk, submits it to the API, and // optionally polls until the certificate is issued. func RunSubmit(ctx context.Context, client *api.Client, printer *output.Printer, opts SubmitOptions) error { - panic("not implemented") + csrPem, err := os.ReadFile(opts.CSRPath) + if err != nil { + return fmt.Errorf("read CSR file %s: %w", opts.CSRPath, err) + } + + resp, err := client.CreateCert(ctx, string(csrPem)) + if err != nil { + return fmt.Errorf("submit CSR: %w", err) + } + printer.Info("Certificate request submitted (ID: %d, status: %s)", resp.ID, resp.Status) + + // Set auto-renew if requested. + if opts.AutoRenew { + t := true + if _, err := client.UpdateCert(ctx, resp.ID, &t); err != nil { + printer.Error("Failed to enable auto-renew: %s", err) + } + } + + if !opts.Wait { + printer.JSON(resp) + printer.Success("Certificate %d submitted — run `krakenkey cert show %d` to check status", resp.ID, resp.ID) + return nil + } + + cert, err := PollUntilDone(ctx, client, printer, resp.ID, opts.PollInterval, opts.PollTimeout) + if err != nil { + return err + } + + if cert.Status == api.CertStatusFailed { + return fmt.Errorf("certificate issuance failed") + } + + certOut := opts.Out + if certOut == "" && cert.CrtPem != "" { + certOut = cnFromCert(cert) + ".crt" + } + + if certOut != "" && cert.CrtPem != "" { + if err := os.WriteFile(certOut, []byte(cert.CrtPem), 0o644); err != nil { + return fmt.Errorf("write certificate: %w", err) + } + printer.Info("Certificate saved to %s", certOut) + } + + printer.JSON(cert) + printer.Success("Certificate %d issued", cert.ID) + return nil } + +// defaultPollInterval is used when no interval is specified. +const defaultPollInterval = 15 * time.Second + +// defaultPollTimeout is used when no timeout is specified. +const defaultPollTimeout = 10 * time.Minute From cb6c15c306e90a43c566bb19a799ea79ba87f3b2 Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Thu, 12 Mar 2026 14:57:47 +0000 Subject: [PATCH 05/11] feat: main entrypoint --- cmd/krakenkey/main.go | 697 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 689 insertions(+), 8 deletions(-) diff --git a/cmd/krakenkey/main.go b/cmd/krakenkey/main.go index fe4e290..04c06a4 100644 --- a/cmd/krakenkey/main.go +++ b/cmd/krakenkey/main.go @@ -1,8 +1,24 @@ package main import ( + "bufio" + "context" + "errors" + "flag" "fmt" "os" + "runtime" + "strconv" + "strings" + "time" + + "github.com/krakenkey/cli/internal/api" + "github.com/krakenkey/cli/internal/account" + "github.com/krakenkey/cli/internal/auth" + "github.com/krakenkey/cli/internal/cert" + "github.com/krakenkey/cli/internal/config" + "github.com/krakenkey/cli/internal/domain" + "github.com/krakenkey/cli/internal/output" ) // version is injected at build time via -ldflags. @@ -13,35 +29,627 @@ func main() { } func run() int { - if len(os.Args) < 2 { + args := os.Args[1:] + + // Handle bare version/help before flag parsing. + if len(args) == 0 { printUsage() - return 5 + return 0 } - - switch os.Args[1] { - case "version", "--version": + if args[0] == "version" { fmt.Printf("krakenkey-cli %s\n", version) return 0 - case "help", "--help", "-h": + } + if args[0] == "help" || args[0] == "-h" || args[0] == "--help" { + printUsage() + return 0 + } + + // Global flags. + globalFS := flag.NewFlagSet("krakenkey", flag.ContinueOnError) + globalFS.SetOutput(os.Stderr) + var ( + apiURL string + apiKey string + outputFmt string + noColor bool + verbose bool + ) + globalFS.StringVar(&apiURL, "api-url", "", "API base URL (env: KK_API_URL)") + globalFS.StringVar(&apiKey, "api-key", "", "API key (env: KK_API_KEY)") + globalFS.StringVar(&outputFmt, "output", "", "Output format: text or json (env: KK_OUTPUT)") + globalFS.BoolVar(&noColor, "no-color", false, "Disable colored output") + globalFS.BoolVar(&verbose, "verbose", false, "Enable verbose logging") + globalFS.Bool("version", false, "Print version and exit") + + if err := globalFS.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 1 + } + + // --version flag. + if v, _ := globalFS.Lookup("version").Value.(interface{ IsBoolFlag() bool }); v != nil { + if globalFS.Lookup("version").Value.String() == "true" { + fmt.Printf("krakenkey-cli %s\n", version) + return 0 + } + } + + remaining := globalFS.Args() + if len(remaining) == 0 { printUsage() return 0 + } + + // Load configuration. + cfg, err := config.Load(config.Flags{ + APIURL: apiURL, + APIKey: apiKey, + Output: outputFmt, + NoColor: noColor, + Verbose: verbose, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "error: load config: %s\n", err) + return 5 + } + + printer := output.New(cfg.Output, noColor) + client := api.NewClient(cfg.APIURL, cfg.APIKey, version, runtime.GOOS, runtime.GOARCH) + ctx := context.Background() + + cmd := remaining[0] + subArgs := remaining[1:] + + var cmdErr error + switch cmd { + case "auth": + cmdErr = runAuth(ctx, client, printer, cfg, subArgs) + case "domain": + cmdErr = runDomain(ctx, client, printer, subArgs) + case "account": + cmdErr = runAccount(ctx, client, printer, subArgs) + case "cert": + cmdErr = runCert(ctx, client, printer, cfg, subArgs) default: - fmt.Fprintf(os.Stderr, "Error: unknown command %q — run 'krakenkey help' for usage\n", os.Args[1]) + fmt.Fprintf(os.Stderr, "error: unknown command %q — run 'krakenkey help'\n", cmd) + return 1 + } + + return exitCode(printer, cmdErr) +} + +// exitCode prints the error and returns the appropriate exit code. +func exitCode(printer *output.Printer, err error) int { + if err == nil { + return 0 + } + printer.Error("%s", err) + switch err.(type) { + case *api.ErrAuth: + return 2 + case *api.ErrNotFound: + return 3 + case *api.ErrRateLimit: + return 4 + case *api.ErrConfig: return 5 + default: + return 1 + } +} + +// requireAPIKey ensures cfg.APIKey is set, returning an ErrConfig if not. +func requireAPIKey(cfg *config.Config) error { + if cfg.APIKey == "" { + return &api.ErrConfig{Message: "not logged in — run 'krakenkey auth login' or set KK_API_KEY"} + } + return nil +} + +// mustInt parses s as an int, printing usage and exiting on failure. +func mustInt(fs *flag.FlagSet, s, name string) (int, bool) { + n, err := strconv.Atoi(s) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %s must be an integer, got %q\n", name, s) + fs.Usage() + return 0, false + } + return n, true +} + +// stringsFlag accumulates repeated flag values (e.g. --san a --san b). +type stringsFlag []string + +func (f *stringsFlag) String() string { return strings.Join(*f, ",") } +func (f *stringsFlag) Set(v string) error { *f = append(*f, v); return nil } + +// triBoolFlag is a *bool flag that is nil when not specified. +type triBoolFlag struct{ val *bool } + +func (f *triBoolFlag) String() string { + if f.val == nil { + return "" + } + return strconv.FormatBool(*f.val) +} +func (f *triBoolFlag) Set(s string) error { + b, err := strconv.ParseBool(s) + if err != nil { + return err + } + f.val = &b + return nil +} +func (f *triBoolFlag) IsBoolFlag() bool { return true } + +// ── auth ───────────────────────────────────────────────────────────────────── + +func runAuth(ctx context.Context, client *api.Client, printer *output.Printer, cfg *config.Config, args []string) error { + if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { + fmt.Print(authUsage) + return nil + } + + sub := args[0] + subArgs := args[1:] + + switch sub { + case "login": + fs := flag.NewFlagSet("auth login", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var key string + fs.StringVar(&key, "api-key", "", "API key to save") + fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: krakenkey auth login [--api-key ]\n") } + if err := fs.Parse(subArgs); err != nil { + return err + } + if key == "" { + fmt.Fprint(os.Stderr, "Enter API key: ") + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + key = strings.TrimSpace(scanner.Text()) + } + } + if key == "" { + return &api.ErrConfig{Message: "API key cannot be empty"} + } + // Create a client with the key under test. + tempClient := api.NewClient(cfg.APIURL, key, version, runtime.GOOS, runtime.GOARCH) + return auth.RunLogin(ctx, tempClient, printer, key) + + case "logout": + return auth.RunLogout(printer) + + case "status": + if err := requireAPIKey(cfg); err != nil { + return err + } + return auth.RunStatus(ctx, client, printer) + + case "keys": + return runAuthKeys(ctx, client, printer, subArgs) + + default: + return fmt.Errorf("unknown auth subcommand %q — run 'krakenkey auth --help'", sub) + } +} + +func runAuthKeys(ctx context.Context, client *api.Client, printer *output.Printer, args []string) error { + if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { + fmt.Print("Usage: krakenkey auth keys [flags]\n") + return nil + } + + sub := args[0] + subArgs := args[1:] + + switch sub { + case "list": + return auth.RunKeysList(ctx, client, printer) + + case "create": + fs := flag.NewFlagSet("auth keys create", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var name, expiresAt string + fs.StringVar(&name, "name", "", "Name for the API key (required)") + fs.StringVar(&expiresAt, "expires-at", "", "Expiry date in ISO 8601 format (optional)") + fs.Usage = func() { + fmt.Fprint(os.Stderr, "Usage: krakenkey auth keys create --name [--expires-at ]\n") + } + if err := fs.Parse(subArgs); err != nil { + return err + } + if name == "" { + return &api.ErrConfig{Message: "--name is required"} + } + var exp *string + if expiresAt != "" { + exp = &expiresAt + } + return auth.RunKeysCreate(ctx, client, printer, name, exp) + + case "delete": + fs := flag.NewFlagSet("auth keys delete", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: krakenkey auth keys delete \n") } + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "API key ID is required"} + } + return auth.RunKeysDelete(ctx, client, printer, fs.Arg(0)) + + default: + return fmt.Errorf("unknown keys subcommand %q", sub) + } +} + +// ── domain ─────────────────────────────────────────────────────────────────── + +func runDomain(ctx context.Context, client *api.Client, printer *output.Printer, args []string) error { + if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { + fmt.Print(domainUsage) + return nil + } + + sub := args[0] + subArgs := args[1:] + + switch sub { + case "add": + fs := flag.NewFlagSet("domain add", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: krakenkey domain add \n") } + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "hostname is required"} + } + return domain.RunAdd(ctx, client, printer, fs.Arg(0)) + + case "list": + return domain.RunList(ctx, client, printer) + + case "show": + fs := flag.NewFlagSet("domain show", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: krakenkey domain show \n") } + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "domain ID is required"} + } + return domain.RunShow(ctx, client, printer, fs.Arg(0)) + + case "verify": + fs := flag.NewFlagSet("domain verify", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: krakenkey domain verify \n") } + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "domain ID is required"} + } + return domain.RunVerify(ctx, client, printer, fs.Arg(0)) + + case "delete": + fs := flag.NewFlagSet("domain delete", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: krakenkey domain delete \n") } + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "domain ID is required"} + } + return domain.RunDelete(ctx, client, printer, fs.Arg(0)) + + default: + return fmt.Errorf("unknown domain subcommand %q — run 'krakenkey domain --help'", sub) + } +} + +// ── account ────────────────────────────────────────────────────────────────── + +func runAccount(ctx context.Context, client *api.Client, printer *output.Printer, args []string) error { + if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { + fmt.Print(accountUsage) + return nil + } + + sub := args[0] + + switch sub { + case "show": + return account.RunShow(ctx, client, printer) + case "plan": + return account.RunPlan(ctx, client, printer) + default: + return fmt.Errorf("unknown account subcommand %q — run 'krakenkey account --help'", sub) + } +} + +// ── cert ───────────────────────────────────────────────────────────────────── + +func runCert(ctx context.Context, client *api.Client, printer *output.Printer, cfg *config.Config, args []string) error { + if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { + fmt.Print(certUsage) + return nil + } + + sub := args[0] + subArgs := args[1:] + + switch sub { + case "list": + fs := flag.NewFlagSet("cert list", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var status string + fs.StringVar(&status, "status", "", "Filter by status (pending|issuing|issued|failed|renewing|revoking|revoked)") + if err := fs.Parse(subArgs); err != nil { + return err + } + return cert.RunList(ctx, client, printer, status) + + case "show": + fs := flag.NewFlagSet("cert show", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "certificate ID is required"} + } + id, ok := mustInt(fs, fs.Arg(0), "certificate ID") + if !ok { + return &api.ErrConfig{Message: "certificate ID must be an integer"} + } + return cert.RunShow(ctx, client, printer, id) + + case "download": + fs := flag.NewFlagSet("cert download", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var outPath string + fs.StringVar(&outPath, "out", "", "Output file path (default: ./.crt)") + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "certificate ID is required"} + } + id, ok := mustInt(fs, fs.Arg(0), "certificate ID") + if !ok { + return &api.ErrConfig{Message: "certificate ID must be an integer"} + } + return cert.RunDownload(ctx, client, printer, id, outPath) + + case "issue": + fs := flag.NewFlagSet("cert issue", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var ( + domainFlag string + sans stringsFlag + keyType string + org, ou string + locality string + state string + country string + keyOut string + csrOut string + out string + autoRenew bool + wait bool + pollInterval = 15 * time.Second + pollTimeout = 10 * time.Minute + ) + fs.StringVar(&domainFlag, "domain", "", "Primary domain (CN) for the certificate (required)") + fs.Var(&sans, "san", "Additional SAN (repeat for multiple)") + fs.StringVar(&keyType, "key-type", "ecdsa-p256", "Key type: rsa-2048, rsa-4096, ecdsa-p256, ecdsa-p384") + fs.StringVar(&org, "org", "", "Organization (O)") + fs.StringVar(&ou, "ou", "", "Organizational unit (OU)") + fs.StringVar(&locality, "locality", "", "Locality (L)") + fs.StringVar(&state, "state", "", "State or province (ST)") + fs.StringVar(&country, "country", "", "Country code (C, e.g. US)") + fs.StringVar(&keyOut, "key-out", "", "Private key output path (default: ./.key)") + fs.StringVar(&csrOut, "csr-out", "", "CSR output path (default: ./.csr)") + fs.StringVar(&out, "out", "", "Certificate output path (default: ./.crt)") + fs.BoolVar(&autoRenew, "auto-renew", false, "Enable automatic renewal") + fs.BoolVar(&wait, "wait", false, "Wait for issuance to complete") + fs.DurationVar(&pollInterval, "poll-interval", pollInterval, "How often to poll for status") + fs.DurationVar(&pollTimeout, "poll-timeout", pollTimeout, "Maximum time to wait") + fs.Usage = func() { + fmt.Fprint(os.Stderr, "Usage: krakenkey cert issue --domain [flags]\n") + fs.PrintDefaults() + } + if err := fs.Parse(subArgs); err != nil { + return err + } + if domainFlag == "" { + return &api.ErrConfig{Message: "--domain is required"} + } + return cert.RunIssue(ctx, client, printer, cert.IssueOptions{ + Domain: domainFlag, + SANs: []string(sans), + KeyType: keyType, + Org: org, + OU: ou, + Locality: locality, + State: state, + Country: country, + KeyOut: keyOut, + CSROut: csrOut, + Out: out, + AutoRenew: autoRenew, + Wait: wait, + PollInterval: pollInterval, + PollTimeout: pollTimeout, + }) + + case "submit": + fs := flag.NewFlagSet("cert submit", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var ( + csrPath string + out string + autoRenew bool + wait bool + pollInterval = 15 * time.Second + pollTimeout = 10 * time.Minute + ) + fs.StringVar(&csrPath, "csr", "", "Path to CSR PEM file (required)") + fs.StringVar(&out, "out", "", "Certificate output path (default: ./.crt)") + fs.BoolVar(&autoRenew, "auto-renew", false, "Enable automatic renewal") + fs.BoolVar(&wait, "wait", false, "Wait for issuance to complete") + fs.DurationVar(&pollInterval, "poll-interval", pollInterval, "How often to poll for status") + fs.DurationVar(&pollTimeout, "poll-timeout", pollTimeout, "Maximum time to wait") + fs.Usage = func() { + fmt.Fprint(os.Stderr, "Usage: krakenkey cert submit --csr [flags]\n") + fs.PrintDefaults() + } + if err := fs.Parse(subArgs); err != nil { + return err + } + if csrPath == "" { + return &api.ErrConfig{Message: "--csr is required"} + } + return cert.RunSubmit(ctx, client, printer, cert.SubmitOptions{ + CSRPath: csrPath, + Out: out, + AutoRenew: autoRenew, + Wait: wait, + PollInterval: pollInterval, + PollTimeout: pollTimeout, + }) + + case "renew": + fs := flag.NewFlagSet("cert renew", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var ( + wait bool + pollInterval = 15 * time.Second + pollTimeout = 10 * time.Minute + ) + fs.BoolVar(&wait, "wait", false, "Wait for renewal to complete") + fs.DurationVar(&pollInterval, "poll-interval", pollInterval, "How often to poll for status") + fs.DurationVar(&pollTimeout, "poll-timeout", pollTimeout, "Maximum time to wait") + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "certificate ID is required"} + } + id, ok := mustInt(fs, fs.Arg(0), "certificate ID") + if !ok { + return &api.ErrConfig{Message: "certificate ID must be an integer"} + } + return cert.RunRenew(ctx, client, printer, id, wait, pollInterval, pollTimeout) + + case "revoke": + fs := flag.NewFlagSet("cert revoke", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var reasonFlag int + fs.IntVar(&reasonFlag, "reason", -1, "RFC 5280 revocation reason code (optional, 0–10)") + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "certificate ID is required"} + } + id, ok := mustInt(fs, fs.Arg(0), "certificate ID") + if !ok { + return &api.ErrConfig{Message: "certificate ID must be an integer"} + } + var reason *int + if reasonFlag >= 0 { + r := reasonFlag + reason = &r + } + return cert.RunRevoke(ctx, client, printer, id, reason) + + case "retry": + fs := flag.NewFlagSet("cert retry", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var ( + wait bool + pollInterval = 15 * time.Second + pollTimeout = 10 * time.Minute + ) + fs.BoolVar(&wait, "wait", false, "Wait for issuance to complete") + fs.DurationVar(&pollInterval, "poll-interval", pollInterval, "How often to poll for status") + fs.DurationVar(&pollTimeout, "poll-timeout", pollTimeout, "Maximum time to wait") + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "certificate ID is required"} + } + id, ok := mustInt(fs, fs.Arg(0), "certificate ID") + if !ok { + return &api.ErrConfig{Message: "certificate ID must be an integer"} + } + return cert.RunRetry(ctx, client, printer, id, wait, pollInterval, pollTimeout) + + case "delete": + fs := flag.NewFlagSet("cert delete", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "certificate ID is required"} + } + id, ok := mustInt(fs, fs.Arg(0), "certificate ID") + if !ok { + return &api.ErrConfig{Message: "certificate ID must be an integer"} + } + return cert.RunDelete(ctx, client, printer, id) + + case "update": + fs := flag.NewFlagSet("cert update", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var autoRenewFlag triBoolFlag + fs.Var(&autoRenewFlag, "auto-renew", "Enable or disable auto-renewal (true/false)") + fs.Usage = func() { + fmt.Fprint(os.Stderr, "Usage: krakenkey cert update [--auto-renew=true|false]\n") + } + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "certificate ID is required"} + } + id, ok := mustInt(fs, fs.Arg(0), "certificate ID") + if !ok { + return &api.ErrConfig{Message: "certificate ID must be an integer"} + } + return cert.RunUpdate(ctx, client, printer, id, autoRenewFlag.val) + + default: + return fmt.Errorf("unknown cert subcommand %q — run 'krakenkey cert --help'", sub) } } +// ── usage strings ───────────────────────────────────────────────────────────── + func printUsage() { fmt.Print(`krakenkey-cli — TLS certificate management from your terminal Usage: - krakenkey [subcommand] [flags] + krakenkey [global flags] [subcommand] [flags] Commands: auth Manage authentication and API keys cert Certificate lifecycle management domain Domain registration and verification account Account and subscription info + version Print version and exit Global Flags: --api-url string API base URL (env: KK_API_URL, default: https://api.krakenkey.io) @@ -54,3 +662,76 @@ Global Flags: Run 'krakenkey --help' for command-specific help. `) } + +const authUsage = `Manage authentication and API keys. + +Usage: + krakenkey auth [flags] + +Subcommands: + login Save an API key to the config file + logout Remove the stored API key + status Show current user and resource counts + keys list List API keys + keys create Create a new API key + keys delete Delete an API key + +Examples: + krakenkey auth login --api-key kk_... + krakenkey auth status + krakenkey auth keys create --name ci-deploy + krakenkey auth keys delete +` + +const domainUsage = `Register and verify domains. + +Usage: + krakenkey domain [flags] + +Subcommands: + add Register a domain and get the DNS TXT record + list List all registered domains + show Show domain details + verify Trigger DNS TXT verification + delete Delete a domain + +Examples: + krakenkey domain add example.com + krakenkey domain list + krakenkey domain verify +` + +const accountUsage = `View account and subscription info. + +Usage: + krakenkey account + +Subcommands: + show Show profile details + plan Show subscription and billing info +` + +const certUsage = `Certificate lifecycle management. + +Usage: + krakenkey cert [flags] + +Subcommands: + issue Generate a key + CSR locally, submit, and optionally wait + submit Submit an existing CSR file + list List certificates + show Show certificate details + download Download the certificate PEM + renew Trigger manual renewal + revoke Revoke a certificate + retry Retry a failed issuance + update Update certificate settings + delete Delete a certificate + +Examples: + krakenkey cert issue --domain example.com --wait + krakenkey cert submit --csr ./example.csr --wait + krakenkey cert list --status issued + krakenkey cert download 42 --out ./example.crt + krakenkey cert update 42 --auto-renew=true +` From fe4215c429014f0240bb49586217d475fb3adde1 Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Thu, 12 Mar 2026 14:58:13 +0000 Subject: [PATCH 06/11] test: tests for csr, output, config, and api --- internal/api/client_test.go | 193 +++++++++++++++++++++++++++++++++ internal/auth/auth.go | 23 +--- internal/config/config_test.go | 181 +++++++++++++++++++++++++++++++ internal/csr/csr_test.go | 178 ++++++++++++++++++++++++++++++ internal/output/output.go | 12 +- internal/output/output_test.go | 155 ++++++++++++++++++++++++++ 6 files changed, 716 insertions(+), 26 deletions(-) create mode 100644 internal/api/client_test.go create mode 100644 internal/config/config_test.go create mode 100644 internal/csr/csr_test.go create mode 100644 internal/output/output_test.go diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 0000000..c614052 --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,193 @@ +package api_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/krakenkey/cli/internal/api" +) + +// newTestClient creates a Client pointed at the given test server. +func newTestClient(baseURL string) *api.Client { + return api.NewClient(baseURL, "kk_test", "v0.0.0", "linux", "amd64") +} + +func TestNewClient_UserAgent(t *testing.T) { + var gotUA string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUA = r.Header.Get("User-Agent") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(api.UserProfile{ID: "u1"}) + })) + defer srv.Close() + + c := api.NewClient(srv.URL, "kk_test", "v1.2.3", "darwin", "arm64") + _, _ = c.GetProfile(context.Background()) + + want := "krakenkey-cli/v1.2.3 (darwin/arm64)" + if gotUA != want { + t.Errorf("User-Agent = %q, want %q", gotUA, want) + } +} + +func TestNewClient_AuthorizationHeader(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(api.UserProfile{}) + })) + defer srv.Close() + + c := api.NewClient(srv.URL, "kk_supersecret", "dev", "linux", "amd64") + _, _ = c.GetProfile(context.Background()) + + if gotAuth != "Bearer kk_supersecret" { + t.Errorf("Authorization = %q, want Bearer kk_supersecret", gotAuth) + } +} + +func TestClient_401_ReturnsErrAuth(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(api.APIError{StatusCode: 401, Message: "Unauthorized"}) + })) + defer srv.Close() + + c := newTestClient(srv.URL) + _, err := c.GetProfile(context.Background()) + if _, ok := err.(*api.ErrAuth); !ok { + t.Errorf("err type = %T, want *api.ErrAuth", err) + } +} + +func TestClient_404_ReturnsErrNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(api.APIError{StatusCode: 404, Message: "Not found"}) + })) + defer srv.Close() + + c := newTestClient(srv.URL) + _, err := c.GetDomain(context.Background(), "nonexistent-id") + if _, ok := err.(*api.ErrNotFound); !ok { + t.Errorf("err type = %T, want *api.ErrNotFound", err) + } +} + +func TestClient_429_ReturnsErrRateLimit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusTooManyRequests) + json.NewEncoder(w).Encode(api.APIError{StatusCode: 429, Message: "Too many requests"}) + })) + defer srv.Close() + + c := newTestClient(srv.URL) + _, err := c.ListCerts(context.Background(), "") + rl, ok := err.(*api.ErrRateLimit) + if !ok { + t.Fatalf("err type = %T, want *api.ErrRateLimit", err) + } + if rl.RetryAfter != "30" { + t.Errorf("RetryAfter = %q, want 30", rl.RetryAfter) + } +} + +func TestClient_GetProfile_Success(t *testing.T) { + want := api.UserProfile{ + ID: "user-123", + DisplayName: "Alice", + Email: "alice@example.com", + Plan: "pro", + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth/me" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(want) + })) + defer srv.Close() + + c := newTestClient(srv.URL) + got, err := c.GetProfile(context.Background()) + if err != nil { + t.Fatalf("GetProfile: %v", err) + } + if got.ID != want.ID || got.DisplayName != want.DisplayName || got.Email != want.Email { + t.Errorf("got %+v, want %+v", got, want) + } +} + +func TestClient_CreateDomain_PostsJSON(t *testing.T) { + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %q, want POST", r.Method) + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(api.Domain{ID: "d1", Hostname: "example.com"}) + })) + defer srv.Close() + + c := newTestClient(srv.URL) + d, err := c.CreateDomain(context.Background(), "example.com") + if err != nil { + t.Fatalf("CreateDomain: %v", err) + } + if gotBody["hostname"] != "example.com" { + t.Errorf("request body hostname = %q, want example.com", gotBody["hostname"]) + } + if d.ID != "d1" { + t.Errorf("domain ID = %q, want d1", d.ID) + } +} + +func TestClient_DeleteDomain_NoBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("method = %q, want DELETE", r.Method) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := newTestClient(srv.URL) + if err := c.DeleteDomain(context.Background(), "d1"); err != nil { + t.Errorf("DeleteDomain: %v", err) + } +} + +func TestClient_NetworkError_ReturnsErrNetwork(t *testing.T) { + // Point at a server that is not listening. + c := api.NewClient("http://127.0.0.1:1", "kk_test", "dev", "linux", "amd64") + _, err := c.GetProfile(context.Background()) + if _, ok := err.(*api.ErrNetwork); !ok { + t.Errorf("err type = %T, want *api.ErrNetwork", err) + } +} + +func TestClient_ListCerts_StatusFilter(t *testing.T) { + var gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]api.TlsCert{}) + })) + defer srv.Close() + + c := newTestClient(srv.URL) + _, err := c.ListCerts(context.Background(), "issued") + if err != nil { + t.Fatalf("ListCerts: %v", err) + } + if gotQuery != "status=issued" { + t.Errorf("query = %q, want status=issued", gotQuery) + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 667f8ba..d468ccd 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,11 +2,8 @@ package auth import ( - "bufio" "context" "fmt" - "os" - "strings" "time" "github.com/krakenkey/cli/internal/api" @@ -14,30 +11,16 @@ import ( "github.com/krakenkey/cli/internal/output" ) -// RunLogin sets the API key, validates it against the API, and saves it to the config file. -// If apiKey is empty the user is prompted interactively. -func RunLogin(ctx context.Context, client *api.Client, printer *output.Printer, cfg *config.Config, apiKey string) error { - if apiKey == "" { - fmt.Fprint(os.Stderr, "Enter API key: ") - scanner := bufio.NewScanner(os.Stdin) - if scanner.Scan() { - apiKey = strings.TrimSpace(scanner.Text()) - } - if apiKey == "" { - return &api.ErrConfig{Message: "API key cannot be empty"} - } - } - - // Validate the key by fetching the profile. +// RunLogin validates apiKey against the API and saves it to the config file. +// The client must be configured with apiKey already. +func RunLogin(ctx context.Context, client *api.Client, printer *output.Printer, apiKey string) error { profile, err := client.GetProfile(ctx) if err != nil { return err } - if err := config.Save("", apiKey, ""); err != nil { return &api.ErrConfig{Message: fmt.Sprintf("save config: %s", err)} } - printer.Success("Logged in as %s (%s)", profile.DisplayName, profile.Email) return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..5c0a9d8 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,181 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/krakenkey/cli/internal/config" +) + +// withTempConfigDir sets XDG_CONFIG_HOME to a temp dir for the duration of t. +func withTempConfigDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + return dir +} + +func TestLoad_Defaults(t *testing.T) { + withTempConfigDir(t) + // Clear env vars that might be set in the shell. + t.Setenv("KK_API_URL", "") + t.Setenv("KK_API_KEY", "") + t.Setenv("KK_OUTPUT", "") + + cfg, err := config.Load(config.Flags{}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.APIURL != "https://api.krakenkey.io" { + t.Errorf("APIURL = %q, want default", cfg.APIURL) + } + if cfg.Output != "text" { + t.Errorf("Output = %q, want text", cfg.Output) + } + if cfg.APIKey != "" { + t.Errorf("APIKey = %q, want empty", cfg.APIKey) + } +} + +func TestLoad_EnvOverridesDefault(t *testing.T) { + withTempConfigDir(t) + t.Setenv("KK_API_URL", "https://staging.example.com") + t.Setenv("KK_API_KEY", "kk_env_key") + t.Setenv("KK_OUTPUT", "json") + + cfg, err := config.Load(config.Flags{}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.APIURL != "https://staging.example.com" { + t.Errorf("APIURL = %q, want env value", cfg.APIURL) + } + if cfg.APIKey != "kk_env_key" { + t.Errorf("APIKey = %q, want env value", cfg.APIKey) + } + if cfg.Output != "json" { + t.Errorf("Output = %q, want json", cfg.Output) + } +} + +func TestLoad_FlagsOverrideEnv(t *testing.T) { + withTempConfigDir(t) + t.Setenv("KK_API_KEY", "kk_env_key") + + cfg, err := config.Load(config.Flags{APIKey: "kk_flag_key"}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.APIKey != "kk_flag_key" { + t.Errorf("APIKey = %q, want flag value", cfg.APIKey) + } +} + +func TestLoad_FileOverridesDefault(t *testing.T) { + withTempConfigDir(t) + t.Setenv("KK_API_URL", "") + t.Setenv("KK_API_KEY", "") + t.Setenv("KK_OUTPUT", "") + + if err := config.Save("https://file.example.com", "kk_file_key", "json"); err != nil { + t.Fatalf("Save: %v", err) + } + + cfg, err := config.Load(config.Flags{}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.APIURL != "https://file.example.com" { + t.Errorf("APIURL = %q, want file value", cfg.APIURL) + } + if cfg.APIKey != "kk_file_key" { + t.Errorf("APIKey = %q, want file value", cfg.APIKey) + } +} + +func TestSave_CreatesFileWith0600(t *testing.T) { + withTempConfigDir(t) + + if err := config.Save("", "kk_test", ""); err != nil { + t.Fatalf("Save: %v", err) + } + + configFile := filepath.Join(config.ConfigDir(), "config.yaml") + info, err := os.Stat(configFile) + if err != nil { + t.Fatalf("Stat config file: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("config file permissions = %o, want 0600", perm) + } +} + +func TestSave_PreservesExistingValues(t *testing.T) { + withTempConfigDir(t) + t.Setenv("KK_API_URL", "") + t.Setenv("KK_API_KEY", "") + t.Setenv("KK_OUTPUT", "") + + // Save initial values. + if err := config.Save("https://api.example.com", "kk_key1", "text"); err != nil { + t.Fatalf("Save initial: %v", err) + } + + // Save only the API key — other fields must be preserved. + if err := config.Save("", "kk_key2", ""); err != nil { + t.Fatalf("Save update: %v", err) + } + + cfg, err := config.Load(config.Flags{}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.APIURL != "https://api.example.com" { + t.Errorf("APIURL = %q, want preserved value", cfg.APIURL) + } + if cfg.APIKey != "kk_key2" { + t.Errorf("APIKey = %q, want updated value", cfg.APIKey) + } + if cfg.Output != "text" { + t.Errorf("Output = %q, want preserved value", cfg.Output) + } +} + +func TestRemoveAPIKey(t *testing.T) { + withTempConfigDir(t) + t.Setenv("KK_API_URL", "") + t.Setenv("KK_API_KEY", "") + t.Setenv("KK_OUTPUT", "") + + if err := config.Save("", "kk_to_remove", ""); err != nil { + t.Fatalf("Save: %v", err) + } + if err := config.RemoveAPIKey(); err != nil { + t.Fatalf("RemoveAPIKey: %v", err) + } + + cfg, err := config.Load(config.Flags{}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.APIKey != "" { + t.Errorf("APIKey = %q after removal, want empty", cfg.APIKey) + } +} + +func TestRemoveAPIKey_NonexistentFile(t *testing.T) { + withTempConfigDir(t) + // Should not return an error when the config file does not exist. + if err := config.RemoveAPIKey(); err != nil { + t.Errorf("RemoveAPIKey on missing file: %v", err) + } +} + +func TestConfigDir_RespectsXDG(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-test") + dir := config.ConfigDir() + if dir != "/tmp/xdg-test/krakenkey" { + t.Errorf("ConfigDir = %q, want /tmp/xdg-test/krakenkey", dir) + } +} diff --git a/internal/csr/csr_test.go b/internal/csr/csr_test.go new file mode 100644 index 0000000..0cf019a --- /dev/null +++ b/internal/csr/csr_test.go @@ -0,0 +1,178 @@ +package csr_test + +import ( + "crypto/x509" + "encoding/pem" + "net" + "testing" + + "github.com/krakenkey/cli/internal/csr" +) + +func TestGenerate_AllKeyTypes(t *testing.T) { + types := []struct { + keyType string + wantKeyType string + wantSize int + }{ + {csr.KeyTypeRSA2048, "RSA", 2048}, + {csr.KeyTypeRSA4096, "RSA", 4096}, + {csr.KeyTypeECDSAP256, "ECDSA", 256}, + {csr.KeyTypeECDSAP384, "ECDSA", 384}, + } + + for _, tc := range types { + t.Run(tc.keyType, func(t *testing.T) { + result, err := csr.Generate(tc.keyType, csr.Subject{CommonName: "example.com"}, nil) + if err != nil { + t.Fatalf("Generate(%q): unexpected error: %v", tc.keyType, err) + } + if result.KeyType != tc.wantKeyType { + t.Errorf("KeyType = %q, want %q", result.KeyType, tc.wantKeyType) + } + if result.KeySize != tc.wantSize { + t.Errorf("KeySize = %d, want %d", result.KeySize, tc.wantSize) + } + if len(result.CSRPem) == 0 { + t.Error("CSRPem is empty") + } + if len(result.PrivateKeyPem) == 0 { + t.Error("PrivateKeyPem is empty") + } + }) + } +} + +func TestGenerate_CSRIsValid(t *testing.T) { + subject := csr.Subject{ + CommonName: "example.com", + Organization: "Acme Inc", + OrganizationalUnit: "Engineering", + Locality: "San Francisco", + State: "CA", + Country: "US", + } + sans := []string{"www.example.com", "api.example.com"} + + result, err := csr.Generate(csr.KeyTypeECDSAP256, subject, sans) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Parse the CSR PEM. + block, _ := pem.Decode(result.CSRPem) + if block == nil { + t.Fatal("failed to decode CSR PEM") + } + if block.Type != "CERTIFICATE REQUEST" { + t.Errorf("PEM type = %q, want CERTIFICATE REQUEST", block.Type) + } + + parsed, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + t.Fatalf("parse CSR: %v", err) + } + if err := parsed.CheckSignature(); err != nil { + t.Errorf("CSR signature invalid: %v", err) + } + + if parsed.Subject.CommonName != "example.com" { + t.Errorf("CN = %q, want example.com", parsed.Subject.CommonName) + } + if len(parsed.Subject.Organization) == 0 || parsed.Subject.Organization[0] != "Acme Inc" { + t.Errorf("O = %v, want [Acme Inc]", parsed.Subject.Organization) + } +} + +func TestGenerate_CNIncludedInDNSNames(t *testing.T) { + result, err := csr.Generate(csr.KeyTypeECDSAP256, csr.Subject{CommonName: "example.com"}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + block, _ := pem.Decode(result.CSRPem) + parsed, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + t.Fatalf("parse CSR: %v", err) + } + + found := false + for _, name := range parsed.DNSNames { + if name == "example.com" { + found = true + } + } + if !found { + t.Errorf("CN not found in DNSNames: %v", parsed.DNSNames) + } +} + +func TestGenerate_DeduplicatesSANs(t *testing.T) { + // CN + duplicate SAN should only appear once. + result, err := csr.Generate(csr.KeyTypeECDSAP256, csr.Subject{CommonName: "example.com"}, []string{"example.com", "www.example.com"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + block, _ := pem.Decode(result.CSRPem) + parsed, _ := x509.ParseCertificateRequest(block.Bytes) + + count := 0 + for _, name := range parsed.DNSNames { + if name == "example.com" { + count++ + } + } + if count != 1 { + t.Errorf("example.com appears %d times in DNSNames, want 1", count) + } +} + +func TestGenerate_IPSANs(t *testing.T) { + result, err := csr.Generate(csr.KeyTypeECDSAP256, csr.Subject{CommonName: "example.com"}, []string{"192.168.1.1", "::1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + block, _ := pem.Decode(result.CSRPem) + parsed, _ := x509.ParseCertificateRequest(block.Bytes) + + if len(parsed.IPAddresses) != 2 { + t.Errorf("IPAddresses count = %d, want 2", len(parsed.IPAddresses)) + } + wantIPs := []net.IP{net.ParseIP("192.168.1.1"), net.ParseIP("::1")} + for i, ip := range wantIPs { + if i >= len(parsed.IPAddresses) { + break + } + if !parsed.IPAddresses[i].Equal(ip) { + t.Errorf("IPAddresses[%d] = %v, want %v", i, parsed.IPAddresses[i], ip) + } + } +} + +func TestGenerate_PrivateKeyIsPKCS8(t *testing.T) { + result, err := csr.Generate(csr.KeyTypeRSA2048, csr.Subject{CommonName: "example.com"}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + block, _ := pem.Decode(result.PrivateKeyPem) + if block == nil { + t.Fatal("failed to decode private key PEM") + } + if block.Type != "PRIVATE KEY" { + t.Errorf("PEM type = %q, want PRIVATE KEY", block.Type) + } + + if _, err := x509.ParsePKCS8PrivateKey(block.Bytes); err != nil { + t.Errorf("ParsePKCS8PrivateKey: %v", err) + } +} + +func TestGenerate_InvalidKeyType(t *testing.T) { + _, err := csr.Generate("rsa-1024", csr.Subject{CommonName: "example.com"}, nil) + if err == nil { + t.Error("expected error for invalid key type, got nil") + } +} diff --git a/internal/output/output.go b/internal/output/output.go index ce4bea8..d026dbc 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -57,7 +57,7 @@ func (p *Printer) Success(msg string, args ...any) { if p.IsJSON() { return } - fmt.Fprintf(p.w, p.color(colorGreen, "✓")+" "+fmt.Sprintf(msg, args...)+"\n") + fmt.Fprint(p.w, p.color(colorGreen, "✓")+" "+fmt.Sprintf(msg, args...)+"\n") } // Error prints an error message to stderr. In JSON mode it emits {"error":"..."}. @@ -68,7 +68,7 @@ func (p *Printer) Error(msg string, args ...any) { fmt.Fprintln(p.errW, string(data)) return } - fmt.Fprintf(p.errW, p.color(colorRed, "Error:")+" "+text+"\n") + fmt.Fprint(p.errW, p.color(colorRed, "Error:")+" "+text+"\n") } // Info prints an informational message prefixed with • (text mode only). @@ -76,7 +76,7 @@ func (p *Printer) Info(msg string, args ...any) { if p.IsJSON() { return } - fmt.Fprintf(p.w, p.color(colorBlue, "•")+" "+fmt.Sprintf(msg, args...)+"\n") + fmt.Fprint(p.w, p.color(colorBlue, "•")+" "+fmt.Sprintf(msg, args...)+"\n") } // Println prints a plain line (text mode only). @@ -84,7 +84,7 @@ func (p *Printer) Println(msg string, args ...any) { if p.IsJSON() { return } - fmt.Fprintf(p.w, fmt.Sprintf(msg, args...)+"\n") + fmt.Fprint(p.w, fmt.Sprintf(msg, args...)+"\n") } // Printf prints a formatted string (text mode only). @@ -126,7 +126,7 @@ func (p *Printer) Table(headers []string, rows [][]string) { // Header row fmt.Fprintf(p.w, " ") for i, h := range headers { - fmt.Fprintf(p.w, p.color(colorBold, fmt.Sprintf("%-*s", widths[i], h))) + fmt.Fprint(p.w, p.color(colorBold, fmt.Sprintf("%-*s", widths[i], h))) if i < len(headers)-1 { fmt.Fprintf(p.w, " ") } @@ -136,7 +136,7 @@ func (p *Printer) Table(headers []string, rows [][]string) { // Separator fmt.Fprintf(p.w, " ") for i, w := range widths { - fmt.Fprintf(p.w, strings.Repeat("─", w)) + fmt.Fprint(p.w, strings.Repeat("─", w)) if i < len(widths)-1 { fmt.Fprintf(p.w, " ") } diff --git a/internal/output/output_test.go b/internal/output/output_test.go new file mode 100644 index 0000000..22a1e13 --- /dev/null +++ b/internal/output/output_test.go @@ -0,0 +1,155 @@ +package output_test + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/krakenkey/cli/internal/output" +) + +func newPrinter(format string) (*output.Printer, *bytes.Buffer, *bytes.Buffer) { + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + p := output.NewWithWriters(format, true, out, errOut) + return p, out, errOut +} + +func TestPrinter_TextMode_Success(t *testing.T) { + p, out, _ := newPrinter("text") + p.Success("all done %s", "!") + if !strings.Contains(out.String(), "all done !") { + t.Errorf("Success output = %q, want to contain 'all done !'", out.String()) + } +} + +func TestPrinter_TextMode_Println(t *testing.T) { + p, out, _ := newPrinter("text") + p.Println("hello %d", 42) + if out.String() != "hello 42\n" { + t.Errorf("Println = %q, want %q", out.String(), "hello 42\n") + } +} + +func TestPrinter_TextMode_Info(t *testing.T) { + p, out, _ := newPrinter("text") + p.Info("tip: %s", "do something") + if !strings.Contains(out.String(), "tip: do something") { + t.Errorf("Info output = %q, want to contain 'tip: do something'", out.String()) + } +} + +func TestPrinter_TextMode_Error_WritesToStderr(t *testing.T) { + p, out, errOut := newPrinter("text") + p.Error("something went wrong") + if out.Len() != 0 { + t.Errorf("Error wrote to stdout: %q", out.String()) + } + if !strings.Contains(errOut.String(), "something went wrong") { + t.Errorf("Error output = %q, want to contain 'something went wrong'", errOut.String()) + } +} + +func TestPrinter_JSONMode_SuppressesText(t *testing.T) { + p, out, _ := newPrinter("json") + p.Success("this should not appear") + p.Info("nor this") + p.Println("nor this") + if out.Len() != 0 { + t.Errorf("JSON mode wrote text to stdout: %q", out.String()) + } +} + +func TestPrinter_JSONMode_JSON(t *testing.T) { + p, out, _ := newPrinter("json") + type payload struct { + Name string `json:"name"` + Age int `json:"age"` + } + p.JSON(payload{Name: "alice", Age: 30}) + + var got payload + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("unmarshal JSON output: %v\noutput: %s", err, out.String()) + } + if got.Name != "alice" || got.Age != 30 { + t.Errorf("got %+v, want {alice 30}", got) + } +} + +func TestPrinter_JSONMode_Error_WritesJSONToStderr(t *testing.T) { + p, _, errOut := newPrinter("json") + p.Error("oops %s", "bad") + + var got map[string]string + if err := json.Unmarshal(errOut.Bytes(), &got); err != nil { + t.Fatalf("unmarshal error JSON: %v\noutput: %s", err, errOut.String()) + } + if got["error"] != "oops bad" { + t.Errorf("error field = %q, want %q", got["error"], "oops bad") + } +} + +func TestPrinter_Table_Alignment(t *testing.T) { + p, out, _ := newPrinter("text") + headers := []string{"ID", "Name", "Status"} + rows := [][]string{ + {"1", "example.com", "issued"}, + {"42", "long-domain-name.example.com", "pending"}, + } + p.Table(headers, rows) + + result := out.String() + // Headers must appear + for _, h := range headers { + if !strings.Contains(result, h) { + t.Errorf("table output missing header %q:\n%s", h, result) + } + } + // All cell values must appear + for _, row := range rows { + for _, cell := range row { + if !strings.Contains(result, cell) { + t.Errorf("table output missing cell %q:\n%s", cell, result) + } + } + } +} + +func TestPrinter_Table_JSONMode_Suppressed(t *testing.T) { + p, out, _ := newPrinter("json") + p.Table([]string{"A", "B"}, [][]string{{"1", "2"}}) + if out.Len() != 0 { + t.Errorf("Table wrote output in JSON mode: %q", out.String()) + } +} + +func TestPrinter_IsJSON(t *testing.T) { + pText, _, _ := newPrinter("text") + if pText.IsJSON() { + t.Error("text mode: IsJSON() = true, want false") + } + pJSON, _, _ := newPrinter("json") + if !pJSON.IsJSON() { + t.Error("json mode: IsJSON() = false, want true") + } +} + +func TestSpinner_StopsCleanly(t *testing.T) { + p, _, _ := newPrinter("text") + s := p.NewSpinner("working...") + s.Start() + s.UpdateMsg("still working...") + s.Stop() + // Just verify it doesn't deadlock or panic. +} + +func TestSpinner_JSONMode_Noop(t *testing.T) { + p, _, _ := newPrinter("json") + s := p.NewSpinner("working...") + s.Start() + s.UpdateMsg("still working...") + s.Stop() + // Must not panic or block. +} From 312c9627a197bf46a76eaa5486460ba069ecd6e3 Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Thu, 12 Mar 2026 16:13:15 +0000 Subject: [PATCH 07/11] fix: api paths --- .gitignore | 18 ++++++++++++++++++ internal/account/account.go | 4 +++- internal/api/client.go | 22 +++++++++++----------- internal/api/client_test.go | 4 +++- 4 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8381c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Compiled binaries +krakenkey +krakenkey-* + +# Go build artifacts +/dist/ + +# Test output / local dev artifacts +*.key +*.csr +*.crt +*.pem + +# Go test cache +/tmp/ + +# OS +.DS_Store diff --git a/internal/account/account.go b/internal/account/account.go index fd95a13..7e6411a 100644 --- a/internal/account/account.go +++ b/internal/account/account.go @@ -45,6 +45,8 @@ func RunPlan(ctx context.Context, client *api.Client, printer *output.Printer) e if sub.CancelAtPeriodEnd { printer.Info("Subscription is set to cancel at end of current period") } - printer.Println("Subscribed: %s", sub.CreatedAt.Format(time.RFC3339)) + if !sub.CreatedAt.IsZero() { + printer.Println("Subscribed: %s", sub.CreatedAt.Format(time.RFC3339)) + } return nil } diff --git a/internal/api/client.go b/internal/api/client.go index bb2a12a..9a818c2 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -138,16 +138,16 @@ func (c *Client) DeleteDomain(ctx context.Context, id string) error { // Certificate methods func (c *Client) CreateCert(ctx context.Context, csrPem string) (*CertResponse, error) { - body := map[string]string{"rawCsr": csrPem} + body := map[string]string{"csrPem": csrPem} var cr CertResponse - if err := c.do(ctx, http.MethodPost, "/certs", body, &cr); err != nil { + if err := c.do(ctx, http.MethodPost, "/certs/tls", body, &cr); err != nil { return nil, err } return &cr, nil } func (c *Client) ListCerts(ctx context.Context, status string) ([]TlsCert, error) { - path := "/certs" + path := "/certs/tls" if status != "" { path += "?status=" + url.QueryEscape(status) } @@ -160,7 +160,7 @@ func (c *Client) ListCerts(ctx context.Context, status string) ([]TlsCert, error func (c *Client) GetCert(ctx context.Context, id int) (*TlsCert, error) { var cert TlsCert - if err := c.do(ctx, http.MethodGet, "/certs/"+strconv.Itoa(id), nil, &cert); err != nil { + if err := c.do(ctx, http.MethodGet, "/certs/tls/"+strconv.Itoa(id), nil, &cert); err != nil { return nil, err } return &cert, nil @@ -168,7 +168,7 @@ func (c *Client) GetCert(ctx context.Context, id int) (*TlsCert, error) { func (c *Client) GetCertDetails(ctx context.Context, id int) (*TlsCertDetails, error) { var details TlsCertDetails - if err := c.do(ctx, http.MethodGet, "/certs/"+strconv.Itoa(id)+"/details", nil, &details); err != nil { + if err := c.do(ctx, http.MethodGet, "/certs/tls/"+strconv.Itoa(id)+"/details", nil, &details); err != nil { return nil, err } return &details, nil @@ -177,7 +177,7 @@ func (c *Client) GetCertDetails(ctx context.Context, id int) (*TlsCertDetails, e func (c *Client) UpdateCert(ctx context.Context, id int, autoRenew *bool) (*TlsCert, error) { body := map[string]any{"autoRenew": autoRenew} var cert TlsCert - if err := c.do(ctx, http.MethodPatch, "/certs/"+strconv.Itoa(id), body, &cert); err != nil { + if err := c.do(ctx, http.MethodPatch, "/certs/tls/"+strconv.Itoa(id), body, &cert); err != nil { return nil, err } return &cert, nil @@ -185,7 +185,7 @@ func (c *Client) UpdateCert(ctx context.Context, id int, autoRenew *bool) (*TlsC func (c *Client) RenewCert(ctx context.Context, id int) (*CertResponse, error) { var cr CertResponse - if err := c.do(ctx, http.MethodPost, "/certs/"+strconv.Itoa(id)+"/renew", nil, &cr); err != nil { + if err := c.do(ctx, http.MethodPost, "/certs/tls/"+strconv.Itoa(id)+"/renew", nil, &cr); err != nil { return nil, err } return &cr, nil @@ -197,7 +197,7 @@ func (c *Client) RevokeCert(ctx context.Context, id int, reason *int) (*CertResp body = map[string]int{"reason": *reason} } var cr CertResponse - if err := c.do(ctx, http.MethodPost, "/certs/"+strconv.Itoa(id)+"/revoke", body, &cr); err != nil { + if err := c.do(ctx, http.MethodPost, "/certs/tls/"+strconv.Itoa(id)+"/revoke", body, &cr); err != nil { return nil, err } return &cr, nil @@ -205,21 +205,21 @@ func (c *Client) RevokeCert(ctx context.Context, id int, reason *int) (*CertResp func (c *Client) RetryCert(ctx context.Context, id int) (*CertResponse, error) { var cr CertResponse - if err := c.do(ctx, http.MethodPost, "/certs/"+strconv.Itoa(id)+"/retry", nil, &cr); err != nil { + if err := c.do(ctx, http.MethodPost, "/certs/tls/"+strconv.Itoa(id)+"/retry", nil, &cr); err != nil { return nil, err } return &cr, nil } func (c *Client) DeleteCert(ctx context.Context, id int) error { - return c.do(ctx, http.MethodDelete, "/certs/"+strconv.Itoa(id), nil, nil) + return c.do(ctx, http.MethodDelete, "/certs/tls/"+strconv.Itoa(id), nil, nil) } // Auth methods func (c *Client) GetProfile(ctx context.Context) (*UserProfile, error) { var p UserProfile - if err := c.do(ctx, http.MethodGet, "/auth/me", nil, &p); err != nil { + if err := c.do(ctx, http.MethodGet, "/auth/profile", nil, &p); err != nil { return nil, err } return &p, nil diff --git a/internal/api/client_test.go b/internal/api/client_test.go index c614052..1cd01be 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -105,7 +105,7 @@ func TestClient_GetProfile_Success(t *testing.T) { Plan: "pro", } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/auth/me" { + if r.URL.Path != "/auth/profile" { http.NotFound(w, r) return } @@ -190,4 +190,6 @@ func TestClient_ListCerts_StatusFilter(t *testing.T) { if gotQuery != "status=issued" { t.Errorf("query = %q, want status=issued", gotQuery) } + // Verify correct path prefix used. + } From 1e150468de18e4b3933c7eb15603b287abc18703 Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Thu, 12 Mar 2026 16:28:16 +0000 Subject: [PATCH 08/11] fix: modify to support go1.26 --- .github/workflows/ci.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5a5b310..d137e63 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,9 +22,9 @@ jobs: run: go mod verify - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 - with: - version: latest + run: | + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest + golangci-lint run - name: Run tests run: go test ./... -race -coverprofile=coverage.out From 29fe2f81c7b3fb625758b975b37144af52f555f1 Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Thu, 12 Mar 2026 16:44:30 +0000 Subject: [PATCH 09/11] fix: linting and unused vars --- .golangci.yml | 10 ++++++++++ internal/api/client.go | 2 +- internal/cert/submit.go | 5 ----- 3 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .golangci.yml diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..76bc266 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,10 @@ +linters-settings: + errcheck: + exclude-functions: + # Write errors to stdout/stderr are not actionable in a CLI. + - fmt.Fprint + - fmt.Fprintln + - fmt.Fprintf + # JSON encode/decode errors in test HTTP handlers are not actionable. + - (*encoding/json.Encoder).Encode + - (*encoding/json.Decoder).Decode diff --git a/internal/api/client.go b/internal/api/client.go index 9a818c2..2fecf57 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -60,7 +60,7 @@ func (c *Client) do(ctx context.Context, method, path string, body any, out any) if err != nil { return &ErrNetwork{Message: fmt.Sprintf("request failed: %s", err)} } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respData, err := io.ReadAll(resp.Body) if err != nil { diff --git a/internal/cert/submit.go b/internal/cert/submit.go index 029ea7a..49b3dc5 100644 --- a/internal/cert/submit.go +++ b/internal/cert/submit.go @@ -74,8 +74,3 @@ func RunSubmit(ctx context.Context, client *api.Client, printer *output.Printer, return nil } -// defaultPollInterval is used when no interval is specified. -const defaultPollInterval = 15 * time.Second - -// defaultPollTimeout is used when no timeout is specified. -const defaultPollTimeout = 10 * time.Minute From 5593770463bd6c3d9a5d30934a9a8d64cdd0727e Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Thu, 12 Mar 2026 16:50:36 +0000 Subject: [PATCH 10/11] fix: formatting --- .golangci.yml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 76bc266..e2ee667 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,10 +1,13 @@ -linters-settings: - errcheck: - exclude-functions: - # Write errors to stdout/stderr are not actionable in a CLI. - - fmt.Fprint - - fmt.Fprintln - - fmt.Fprintf - # JSON encode/decode errors in test HTTP handlers are not actionable. - - (*encoding/json.Encoder).Encode - - (*encoding/json.Decoder).Decode +version: "2" + +linters: + settings: + errcheck: + exclude-functions: + # Write errors to stdout/stderr are not actionable in a CLI. + - fmt.Fprint + - fmt.Fprintln + - fmt.Fprintf + # JSON encode/decode errors in test HTTP handlers are not actionable. + - (*encoding/json.Encoder).Encode + - (*encoding/json.Decoder).Decode From b38fc141b081e293fb8c95d4cdf2077b47a6c442 Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Thu, 12 Mar 2026 16:56:56 +0000 Subject: [PATCH 11/11] chore: bump version --- .github/workflows/ci.yaml | 2 +- .github/workflows/release.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d137e63..6e69fff 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: '1.26' cache: true diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cb76da6..f2a1578 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -18,7 +18,7 @@ jobs: with: fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: '1.26' cache: true