diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a5f6c2c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: local + hooks: + - id: go-vet + name: go vet + entry: go vet ./... + language: system + pass_filenames: false + types: [go] + + - id: go-test + name: go test + entry: go test ./... -race -count=1 + language: system + pass_filenames: false + types: [go] diff --git a/cmd/krakenkey/main.go b/cmd/krakenkey/main.go index 04c06a4..44e948b 100644 --- a/cmd/krakenkey/main.go +++ b/cmd/krakenkey/main.go @@ -18,6 +18,7 @@ import ( "github.com/krakenkey/cli/internal/cert" "github.com/krakenkey/cli/internal/config" "github.com/krakenkey/cli/internal/domain" + "github.com/krakenkey/cli/internal/endpoint" "github.com/krakenkey/cli/internal/output" ) @@ -113,6 +114,8 @@ func run() int { cmdErr = runAccount(ctx, client, printer, subArgs) case "cert": cmdErr = runCert(ctx, client, printer, cfg, subArgs) + case "endpoint": + cmdErr = runEndpoint(ctx, client, printer, subArgs) default: fmt.Fprintf(os.Stderr, "error: unknown command %q — run 'krakenkey help'\n", cmd) return 1 @@ -636,6 +639,180 @@ func runCert(ctx context.Context, client *api.Client, printer *output.Printer, c } } +// ── endpoint ────────────────────────────────────────────────────────────────── + +func runEndpoint(ctx context.Context, client *api.Client, printer *output.Printer, args []string) error { + if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { + fmt.Print(endpointUsage) + return nil + } + + sub := args[0] + subArgs := args[1:] + + switch sub { + case "add": + fs := flag.NewFlagSet("endpoint add", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var ( + port int + sniFlag string + labelFlag string + probes stringsFlag + ) + fs.IntVar(&port, "port", 443, "Port to monitor (default: 443)") + fs.StringVar(&sniFlag, "sni", "", "SNI override (optional)") + fs.StringVar(&labelFlag, "label", "", "Label (optional)") + fs.Var(&probes, "probe", "Connected probe ID to assign (repeat for multiple)") + fs.Usage = func() { + fmt.Fprint(os.Stderr, "Usage: krakenkey endpoint add [--port 443] [--sni host] [--label name] [--probe id]\n") + } + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "hostname is required"} + } + var sni, label *string + if sniFlag != "" { + sni = &sniFlag + } + if labelFlag != "" { + label = &labelFlag + } + return endpoint.RunAdd(ctx, client, printer, fs.Arg(0), port, sni, label, []string(probes)) + + case "probes": + return endpoint.RunListProbes(ctx, client, printer) + + case "list": + return endpoint.RunList(ctx, client, printer) + + case "show": + fs := flag.NewFlagSet("endpoint show", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: krakenkey endpoint show \n") } + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "endpoint ID is required"} + } + return endpoint.RunShow(ctx, client, printer, fs.Arg(0)) + + case "enable": + fs := flag.NewFlagSet("endpoint enable", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: krakenkey endpoint enable \n") } + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "endpoint ID is required"} + } + return endpoint.RunEnable(ctx, client, printer, fs.Arg(0)) + + case "disable": + fs := flag.NewFlagSet("endpoint disable", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: krakenkey endpoint disable \n") } + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "endpoint ID is required"} + } + return endpoint.RunDisable(ctx, client, printer, fs.Arg(0)) + + case "delete": + fs := flag.NewFlagSet("endpoint delete", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: krakenkey endpoint delete \n") } + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "endpoint ID is required"} + } + return endpoint.RunDelete(ctx, client, printer, fs.Arg(0)) + + case "scan": + fs := flag.NewFlagSet("endpoint scan", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: krakenkey endpoint scan \n") } + if err := fs.Parse(subArgs); err != nil { + return err + } + if fs.NArg() == 0 { + return &api.ErrConfig{Message: "endpoint ID is required"} + } + return endpoint.RunScan(ctx, client, printer, fs.Arg(0)) + + case "region": + return runEndpointRegion(ctx, client, printer, subArgs) + + case "probe": + return runEndpointProbe(ctx, client, printer, subArgs) + + default: + return fmt.Errorf("unknown endpoint subcommand %q — run 'krakenkey endpoint --help'", sub) + } +} + +func runEndpointRegion(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 endpoint region \n") + return nil + } + + sub := args[0] + subArgs := args[1:] + + switch sub { + case "add": + if len(subArgs) < 2 { + return &api.ErrConfig{Message: "endpoint ID and region are required"} + } + return endpoint.RunAddRegion(ctx, client, printer, subArgs[0], subArgs[1]) + + case "remove": + if len(subArgs) < 2 { + return &api.ErrConfig{Message: "endpoint ID and region are required"} + } + return endpoint.RunRemoveRegion(ctx, client, printer, subArgs[0], subArgs[1]) + + default: + return fmt.Errorf("unknown region subcommand %q — use 'add' or 'remove'", sub) + } +} + +func runEndpointProbe(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 endpoint probe \n") + return nil + } + + sub := args[0] + subArgs := args[1:] + + switch sub { + case "add": + if len(subArgs) < 2 { + return &api.ErrConfig{Message: "endpoint ID and probe ID are required"} + } + return endpoint.RunAssignProbe(ctx, client, printer, subArgs[0], subArgs[1]) + + case "remove": + if len(subArgs) < 2 { + return &api.ErrConfig{Message: "endpoint ID and probe ID are required"} + } + return endpoint.RunUnassignProbe(ctx, client, printer, subArgs[0], subArgs[1]) + + default: + return fmt.Errorf("unknown probe subcommand %q — use 'add' or 'remove'", sub) + } +} + // ── usage strings ───────────────────────────────────────────────────────────── func printUsage() { @@ -648,6 +825,7 @@ Commands: auth Manage authentication and API keys cert Certificate lifecycle management domain Domain registration and verification + endpoint Endpoint monitoring management account Account and subscription info version Print version and exit @@ -735,3 +913,31 @@ Examples: krakenkey cert download 42 --out ./example.crt krakenkey cert update 42 --auto-renew=true ` + +const endpointUsage = `Manage monitored endpoints. + +Usage: + krakenkey endpoint [flags] + +Subcommands: + add Add a monitored endpoint + list List all endpoints + show Show endpoint details + scan Request an on-demand scan + probes List your connected probes + enable Enable a disabled endpoint + disable Disable an endpoint + delete Delete an endpoint + probe add Assign a connected probe + probe remove Remove a connected probe + region add Add a hosted probe region + region remove Remove a hosted probe region + +Examples: + krakenkey endpoint probes + krakenkey endpoint add example.com --port 443 --label "Production" --probe + krakenkey endpoint scan + krakenkey endpoint probe add + krakenkey endpoint region add us-east-1 + krakenkey endpoint list +` diff --git a/internal/api/client.go b/internal/api/client.go index 2fecf57..3374e41 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -249,6 +249,96 @@ func (c *Client) DeleteAPIKey(ctx context.Context, id string) error { return c.do(ctx, http.MethodDelete, "/auth/api-keys/"+id, nil, nil) } +// Endpoint methods + +func (c *Client) CreateEndpoint(ctx context.Context, host string, port int, sni, label *string, probeIds []string) (*Endpoint, error) { + body := map[string]any{"host": host, "port": port} + if sni != nil { + body["sni"] = *sni + } + if label != nil { + body["label"] = *label + } + if len(probeIds) > 0 { + body["probeIds"] = probeIds + } + var ep Endpoint + if err := c.do(ctx, http.MethodPost, "/endpoints", body, &ep); err != nil { + return nil, err + } + return &ep, nil +} + +func (c *Client) ListUserProbes(ctx context.Context) ([]Probe, error) { + var probes []Probe + if err := c.do(ctx, http.MethodGet, "/endpoints/probes/mine", nil, &probes); err != nil { + return nil, err + } + return probes, nil +} + +func (c *Client) ListEndpoints(ctx context.Context) ([]Endpoint, error) { + var endpoints []Endpoint + if err := c.do(ctx, http.MethodGet, "/endpoints", nil, &endpoints); err != nil { + return nil, err + } + return endpoints, nil +} + +func (c *Client) GetEndpoint(ctx context.Context, id string) (*Endpoint, error) { + var ep Endpoint + if err := c.do(ctx, http.MethodGet, "/endpoints/"+id, nil, &ep); err != nil { + return nil, err + } + return &ep, nil +} + +func (c *Client) UpdateEndpoint(ctx context.Context, id string, updates map[string]any) (*Endpoint, error) { + var ep Endpoint + if err := c.do(ctx, http.MethodPatch, "/endpoints/"+id, updates, &ep); err != nil { + return nil, err + } + return &ep, nil +} + +func (c *Client) DeleteEndpoint(ctx context.Context, id string) error { + return c.do(ctx, http.MethodDelete, "/endpoints/"+id, nil, nil) +} + +func (c *Client) AddEndpointRegion(ctx context.Context, id, region string) (*EndpointHostedRegion, error) { + body := map[string]string{"region": region} + var ehr EndpointHostedRegion + if err := c.do(ctx, http.MethodPost, "/endpoints/"+id+"/regions", body, &ehr); err != nil { + return nil, err + } + return &ehr, nil +} + +func (c *Client) RemoveEndpointRegion(ctx context.Context, id, region string) error { + return c.do(ctx, http.MethodDelete, "/endpoints/"+id+"/regions/"+region, nil, nil) +} + +func (c *Client) AssignProbes(ctx context.Context, id string, probeIds []string) ([]EndpointProbeAssignment, error) { + body := map[string]any{"probeIds": probeIds} + var assignments []EndpointProbeAssignment + if err := c.do(ctx, http.MethodPost, "/endpoints/"+id+"/probes", body, &assignments); err != nil { + return nil, err + } + return assignments, nil +} + +func (c *Client) UnassignProbe(ctx context.Context, endpointID, probeID string) error { + return c.do(ctx, http.MethodDelete, "/endpoints/"+endpointID+"/probes/"+probeID, nil, nil) +} + +func (c *Client) RequestScan(ctx context.Context, id string) (*Endpoint, error) { + var ep Endpoint + if err := c.do(ctx, http.MethodPost, "/endpoints/"+id+"/scan", nil, &ep); err != nil { + return nil, err + } + return &ep, nil +} + // Billing methods func (c *Client) GetSubscription(ctx context.Context) (*Subscription, error) { diff --git a/internal/api/types.go b/internal/api/types.go index 6abbe2b..d75a637 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -131,6 +131,52 @@ type Subscription struct { CreatedAt time.Time `json:"createdAt"` } +// Endpoint represents a monitored endpoint. +type Endpoint struct { + ID string `json:"id"` + UserID string `json:"userId"` + Host string `json:"host"` + Port int `json:"port"` + SNI *string `json:"sni"` + Label *string `json:"label"` + IsActive bool `json:"isActive"` + HostedRegions []EndpointHostedRegion `json:"hostedRegions"` + ProbeAssignments []EndpointProbeAssignment `json:"probeAssignments"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// EndpointHostedRegion links an endpoint to a hosted probe region. +type EndpointHostedRegion struct { + ID string `json:"id"` + EndpointID string `json:"endpointId"` + Region string `json:"region"` + CreatedAt time.Time `json:"createdAt"` +} + +// Probe represents a registered probe (connected or hosted). +type Probe struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Mode string `json:"mode"` + Region *string `json:"region"` + OS string `json:"os"` + Arch string `json:"arch"` + Status string `json:"status"` + LastSeenAt *time.Time `json:"lastSeenAt"` + CreatedAt time.Time `json:"createdAt"` +} + +// EndpointProbeAssignment links an endpoint to a connected probe. +type EndpointProbeAssignment struct { + ID string `json:"id"` + EndpointID string `json:"endpointId"` + ProbeID string `json:"probeId"` + Probe *Probe `json:"probe"` + CreatedAt time.Time `json:"createdAt"` +} + // APIError is the error shape returned by the KrakenKey API. type APIError struct { StatusCode int `json:"statusCode"` diff --git a/internal/endpoint/endpoint.go b/internal/endpoint/endpoint.go new file mode 100644 index 0000000..935d7f8 --- /dev/null +++ b/internal/endpoint/endpoint.go @@ -0,0 +1,258 @@ +// Package endpoint implements the `krakenkey endpoint` subcommands. +package endpoint + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/krakenkey/cli/internal/api" + "github.com/krakenkey/cli/internal/output" +) + +// RunAdd creates a new monitored endpoint. +func RunAdd(ctx context.Context, client *api.Client, printer *output.Printer, host string, port int, sni, label *string, probeIds []string) error { + ep, err := client.CreateEndpoint(ctx, host, port, sni, label, probeIds) + if err != nil { + return err + } + + printer.JSON(ep) + printer.Success("Endpoint added: %s:%d", ep.Host, ep.Port) + if ep.Label != nil && *ep.Label != "" { + printer.Info("Label: %s", *ep.Label) + } + if len(ep.ProbeAssignments) > 0 { + names := make([]string, len(ep.ProbeAssignments)) + for i, a := range ep.ProbeAssignments { + if a.Probe != nil { + names[i] = a.Probe.Name + } else { + names[i] = a.ProbeID + } + } + printer.Info("Assigned probes: %s", strings.Join(names, ", ")) + } + printer.Info("Run `krakenkey endpoint list` to see all monitored endpoints") + return nil +} + +// RunListProbes lists the user's connected probes available for assignment. +func RunListProbes(ctx context.Context, client *api.Client, printer *output.Printer) error { + probes, err := client.ListUserProbes(ctx) + if err != nil { + return err + } + + printer.JSON(probes) + + if len(probes) == 0 { + printer.Info("No connected probes registered") + printer.Println("") + printer.Println("Set up a probe with KK_PROBE_MODE=connected and your API key to get started") + return nil + } + + headers := []string{"ID", "Name", "Region", "Status", "Last Seen"} + rows := make([][]string, len(probes)) + for i, p := range probes { + region := "-" + if p.Region != nil { + region = *p.Region + } + lastSeen := "-" + if p.LastSeenAt != nil { + lastSeen = p.LastSeenAt.Format(time.RFC3339) + } + rows[i] = []string{p.ID, p.Name, region, p.Status, lastSeen} + } + printer.Table(headers, rows) + return nil +} + +// RunList lists all monitored endpoints. +func RunList(ctx context.Context, client *api.Client, printer *output.Printer) error { + endpoints, err := client.ListEndpoints(ctx) + if err != nil { + return err + } + + printer.JSON(endpoints) + + if len(endpoints) == 0 { + printer.Info("No endpoints registered") + return nil + } + + headers := []string{"ID", "Host", "Port", "Label", "Active", "Probes", "Regions", "Created"} + rows := make([][]string, len(endpoints)) + for i, ep := range endpoints { + active := "yes" + if !ep.IsActive { + active = "no" + } + label := "-" + if ep.Label != nil && *ep.Label != "" { + label = *ep.Label + } + probes := "-" + if len(ep.ProbeAssignments) > 0 { + probeNames := make([]string, len(ep.ProbeAssignments)) + for j, a := range ep.ProbeAssignments { + if a.Probe != nil { + probeNames[j] = a.Probe.Name + } else { + probeNames[j] = a.ProbeID[:8] + } + } + probes = strings.Join(probeNames, ", ") + } + regions := "-" + if len(ep.HostedRegions) > 0 { + regionNames := make([]string, len(ep.HostedRegions)) + for j, r := range ep.HostedRegions { + regionNames[j] = r.Region + } + regions = strings.Join(regionNames, ", ") + } + rows[i] = []string{ + ep.ID, + ep.Host, + fmt.Sprintf("%d", ep.Port), + label, + active, + probes, + regions, + ep.CreatedAt.Format(time.RFC3339), + } + } + printer.Table(headers, rows) + return nil +} + +// RunShow prints full details for an endpoint. +func RunShow(ctx context.Context, client *api.Client, printer *output.Printer, id string) error { + ep, err := client.GetEndpoint(ctx, id) + if err != nil { + return err + } + + printer.JSON(ep) + printer.Println("ID: %s", ep.ID) + printer.Println("Host: %s", ep.Host) + printer.Println("Port: %d", ep.Port) + if ep.SNI != nil { + printer.Println("SNI: %s", *ep.SNI) + } + if ep.Label != nil { + printer.Println("Label: %s", *ep.Label) + } + printer.Println("Active: %v", ep.IsActive) + if len(ep.HostedRegions) > 0 { + regionNames := make([]string, len(ep.HostedRegions)) + for i, r := range ep.HostedRegions { + regionNames[i] = r.Region + } + printer.Println("Regions: %s", strings.Join(regionNames, ", ")) + } + if len(ep.ProbeAssignments) > 0 { + probeNames := make([]string, len(ep.ProbeAssignments)) + for i, a := range ep.ProbeAssignments { + if a.Probe != nil { + probeNames[i] = fmt.Sprintf("%s (%s)", a.Probe.Name, a.ProbeID) + } else { + probeNames[i] = a.ProbeID + } + } + printer.Println("Probes: %s", strings.Join(probeNames, ", ")) + } + printer.Println("Created: %s", ep.CreatedAt.Format(time.RFC3339)) + return nil +} + +// RunUpdate updates an endpoint's mutable fields. +func RunUpdate(ctx context.Context, client *api.Client, printer *output.Printer, id string, updates map[string]any) error { + ep, err := client.UpdateEndpoint(ctx, id, updates) + if err != nil { + return err + } + + printer.JSON(ep) + printer.Success("Endpoint %s:%d updated", ep.Host, ep.Port) + return nil +} + +// RunDelete deletes an endpoint by ID. +func RunDelete(ctx context.Context, client *api.Client, printer *output.Printer, id string) error { + if err := client.DeleteEndpoint(ctx, id); err != nil { + return err + } + printer.Success("Endpoint %s deleted", id) + return nil +} + +// RunEnable enables a disabled endpoint. +func RunEnable(ctx context.Context, client *api.Client, printer *output.Printer, id string) error { + return RunUpdate(ctx, client, printer, id, map[string]any{"isActive": true}) +} + +// RunDisable disables an endpoint. +func RunDisable(ctx context.Context, client *api.Client, printer *output.Printer, id string) error { + return RunUpdate(ctx, client, printer, id, map[string]any{"isActive": false}) +} + +// RunAddRegion adds a hosted probe region to an endpoint. +func RunAddRegion(ctx context.Context, client *api.Client, printer *output.Printer, id, region string) error { + ehr, err := client.AddEndpointRegion(ctx, id, region) + if err != nil { + return err + } + + printer.JSON(ehr) + printer.Success("Region %s added to endpoint %s", ehr.Region, id) + return nil +} + +// RunRemoveRegion removes a hosted probe region from an endpoint. +func RunRemoveRegion(ctx context.Context, client *api.Client, printer *output.Printer, id, region string) error { + if err := client.RemoveEndpointRegion(ctx, id, region); err != nil { + return err + } + printer.Success("Region %s removed from endpoint %s", region, id) + return nil +} + +// RunAssignProbe assigns a connected probe to an endpoint. +func RunAssignProbe(ctx context.Context, client *api.Client, printer *output.Printer, id, probeID string) error { + assignments, err := client.AssignProbes(ctx, id, []string{probeID}) + if err != nil { + return err + } + + printer.JSON(assignments) + printer.Success("Probe %s assigned to endpoint %s", probeID, id) + return nil +} + +// RunUnassignProbe removes a connected probe from an endpoint. +func RunUnassignProbe(ctx context.Context, client *api.Client, printer *output.Printer, id, probeID string) error { + if err := client.UnassignProbe(ctx, id, probeID); err != nil { + return err + } + printer.Success("Probe %s removed from endpoint %s", probeID, id) + return nil +} + +// RunScan requests an on-demand scan of an endpoint. +func RunScan(ctx context.Context, client *api.Client, printer *output.Printer, id string) error { + ep, err := client.RequestScan(ctx, id) + if err != nil { + return err + } + + printer.JSON(ep) + printer.Success("Scan requested for %s:%d", ep.Host, ep.Port) + printer.Info("Results will appear shortly in the dashboard or via `krakenkey endpoint show %s`", id) + return nil +}