feat: M17 OpenSSL/Custom CA issuer connector + M16b CLI tool with bulk import

M17: Script-based issuer connector delegating sign/revoke/CRL to user-provided
scripts. Compatible with any CA tooling (OpenSSL, cfssl, custom PKI). Configurable
timeout, environment variable passthrough. 14 tests including timeout enforcement.

M16b: certctl-cli wraps all 76 REST API endpoints for terminal workflows. Supports
certs/agents/jobs list/get/renew/revoke/cancel, bulk PEM import with progress
reporting, server health status, table and JSON output formats. Zero external
dependencies (stdlib only). 14 tests with mock HTTP server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-23 18:12:40 -04:00
parent 9b0ff37973
commit df1aaa37f8
10 changed files with 2213 additions and 1 deletions
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"flag"
"fmt"
"os"
"github.com/shankar0123/certctl/internal/cli"
)
func main() {
// Parse global flags
fs := flag.NewFlagSet("certctl-cli", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprintf(os.Stderr, `certctl-cli — CLI for certificate lifecycle management
Usage:
certctl-cli [global flags] <command> [command flags]
Global flags:
`)
fs.PrintDefaults()
fmt.Fprintf(os.Stderr, `
Commands:
certs list List certificates
certs get ID Get certificate details
certs renew ID Trigger certificate renewal
certs revoke ID Revoke a certificate
agents list List agents
agents get ID Get agent details
jobs list List jobs
jobs get ID Get job details
jobs cancel ID Cancel a pending job
import FILE Bulk import certificates from PEM file(s)
status Show server health + summary stats
version Show CLI version
Examples:
certctl-cli --server http://localhost:8443 --api-key mykey certs list
certctl-cli certs renew mc-prod --format json
certctl-cli import certs.pem
`)
}
serverURL := fs.String("server", os.Getenv("CERTCTL_SERVER_URL"), "certctl server URL (env: CERTCTL_SERVER_URL)")
if *serverURL == "" {
*serverURL = "http://localhost:8443"
}
apiKey := fs.String("api-key", os.Getenv("CERTCTL_API_KEY"), "API key for authentication (env: CERTCTL_API_KEY)")
format := fs.String("format", "table", "Output format: table, json")
fs.Parse(os.Args[1:])
args := fs.Args()
if len(args) == 0 {
fs.Usage()
os.Exit(1)
}
// Create client
client := cli.NewClient(*serverURL, *apiKey, *format)
// Dispatch to appropriate command
command := args[0]
cmdArgs := args[1:]
var err error
switch command {
case "certs":
err = handleCerts(client, cmdArgs)
case "agents":
err = handleAgents(client, cmdArgs)
case "jobs":
err = handleJobs(client, cmdArgs)
case "import":
err = handleImport(client, cmdArgs)
case "status":
err = handleStatus(client)
case "version":
fmt.Println("certctl-cli version 0.1.0")
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", command)
fs.Usage()
os.Exit(1)
}
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func handleCerts(client *cli.Client, args []string) error {
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "usage: certs <list|get|renew|revoke> [options]\n")
return nil
}
subcommand := args[0]
subArgs := args[1:]
switch subcommand {
case "list":
return client.ListCertificates(subArgs)
case "get":
if len(subArgs) == 0 {
fmt.Fprintf(os.Stderr, "usage: certs get <id>\n")
return nil
}
return client.GetCertificate(subArgs[0])
case "renew":
if len(subArgs) == 0 {
fmt.Fprintf(os.Stderr, "usage: certs renew <id>\n")
return nil
}
return client.RenewCertificate(subArgs[0])
case "revoke":
if len(subArgs) == 0 {
fmt.Fprintf(os.Stderr, "usage: certs revoke <id> [--reason <reason>]\n")
return nil
}
id := subArgs[0]
reason := "unspecified"
if len(subArgs) > 2 && subArgs[1] == "--reason" {
reason = subArgs[2]
}
return client.RevokeCertificate(id, reason)
default:
fmt.Fprintf(os.Stderr, "unknown subcommand: certs %s\n", subcommand)
return nil
}
}
func handleAgents(client *cli.Client, args []string) error {
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "usage: agents <list|get> [options]\n")
return nil
}
subcommand := args[0]
subArgs := args[1:]
switch subcommand {
case "list":
return client.ListAgents(subArgs)
case "get":
if len(subArgs) == 0 {
fmt.Fprintf(os.Stderr, "usage: agents get <id>\n")
return nil
}
return client.GetAgent(subArgs[0])
default:
fmt.Fprintf(os.Stderr, "unknown subcommand: agents %s\n", subcommand)
return nil
}
}
func handleJobs(client *cli.Client, args []string) error {
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "usage: jobs <list|get|cancel> [options]\n")
return nil
}
subcommand := args[0]
subArgs := args[1:]
switch subcommand {
case "list":
return client.ListJobs(subArgs)
case "get":
if len(subArgs) == 0 {
fmt.Fprintf(os.Stderr, "usage: jobs get <id>\n")
return nil
}
return client.GetJob(subArgs[0])
case "cancel":
if len(subArgs) == 0 {
fmt.Fprintf(os.Stderr, "usage: jobs cancel <id>\n")
return nil
}
return client.CancelJob(subArgs[0])
default:
fmt.Fprintf(os.Stderr, "unknown subcommand: jobs %s\n", subcommand)
return nil
}
}
func handleImport(client *cli.Client, args []string) error {
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "usage: import <file> [file2 ...]\n")
return nil
}
return client.ImportCertificates(args)
}
func handleStatus(client *cli.Client) error {
return client.GetStatus()
}
+25
View File
@@ -19,6 +19,7 @@ import (
"github.com/shankar0123/certctl/internal/domain"
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
"github.com/shankar0123/certctl/internal/connector/issuer/local"
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
@@ -117,15 +118,27 @@ func main() {
}, logger)
logger.Info("initialized step-ca issuer connector")
// Initialize OpenSSL/Custom CA issuer connector (for script-based CA integrations).
// Delegates certificate signing to user-provided scripts.
opensslConnector := opensslissuer.New(&opensslissuer.Config{
SignScript: os.Getenv("CERTCTL_OPENSSL_SIGN_SCRIPT"),
RevokeScript: os.Getenv("CERTCTL_OPENSSL_REVOKE_SCRIPT"),
CRLScript: os.Getenv("CERTCTL_OPENSSL_CRL_SCRIPT"),
TimeoutSeconds: getEnvIntDefault(os.Getenv("CERTCTL_OPENSSL_TIMEOUT_SECONDS"), 30),
}, logger)
logger.Info("initialized OpenSSL/Custom CA issuer connector")
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
// "iss-local" matches the seed data issuer ID for the Local CA.
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
// "iss-stepca" is the step-ca private CA connector.
// "iss-openssl" is the custom CA/OpenSSL connector.
issuerRegistry := map[string]service.IssuerConnector{
"iss-local": service.NewIssuerConnectorAdapter(localCA),
"iss-acme-staging": service.NewIssuerConnectorAdapter(acmeConnector),
"iss-acme-prod": service.NewIssuerConnectorAdapter(acmeConnector),
"iss-stepca": service.NewIssuerConnectorAdapter(stepcaConnector),
"iss-openssl": service.NewIssuerConnectorAdapter(opensslConnector),
}
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
@@ -400,3 +413,15 @@ func main() {
logger.Info("certctl server stopped")
}
// getEnvIntDefault parses an integer from a string with a default fallback.
func getEnvIntDefault(s string, defaultVal int) int {
if s == "" {
return defaultVal
}
val, err := strconv.Atoi(s)
if err != nil {
return defaultVal
}
return val
}