package client import ( "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/rs/zerolog" "github.com/gosec/gsc-ops-api/internal/config" ) // HockeypuckClient implements the HKP (HTTP Keyserver Protocol) client type HockeypuckClient struct { servers []string client *http.Client logger zerolog.Logger } // NewHockeypuckClient creates a new Hockeypuck HKP client func NewHockeypuckClient(cfg config.HockeypuckConfig, logger zerolog.Logger) *HockeypuckClient { return &HockeypuckClient{ servers: cfg.Servers, client: &http.Client{Timeout: 30 * time.Second}, logger: logger.With().Str("component", "hockeypuck").Logger(), } } // SearchKeys searches for PGP keys by query string (email, name, or key ID) func (c *HockeypuckClient) SearchKeys(query string) (string, error) { params := url.Values{ "search": {query}, "op": {"index"}, "options": {"mr"}, } for _, server := range c.servers { u := fmt.Sprintf("%s/pks/lookup?%s", server, params.Encode()) resp, err := c.client.Get(u) if err != nil { c.logger.Warn().Err(err).Str("server", server).Msg("HKP search failed") continue } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return "", nil } if resp.StatusCode != http.StatusOK { continue } body, err := io.ReadAll(resp.Body) if err != nil { continue } return string(body), nil } return "", fmt.Errorf("all HKP servers failed") } // GetKey retrieves a PGP key by key ID func (c *HockeypuckClient) GetKey(keyID string) (string, error) { // Ensure keyID has 0x prefix if !strings.HasPrefix(keyID, "0x") { keyID = "0x" + keyID } params := url.Values{ "search": {keyID}, "op": {"get"}, "options": {"mr"}, } for _, server := range c.servers { u := fmt.Sprintf("%s/pks/lookup?%s", server, params.Encode()) resp, err := c.client.Get(u) if err != nil { continue } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return "", nil } if resp.StatusCode != http.StatusOK { continue } body, err := io.ReadAll(resp.Body) if err != nil { continue } return string(body), nil } return "", fmt.Errorf("all HKP servers failed") } // UploadKey uploads a PGP public key func (c *HockeypuckClient) UploadKey(armoredKey string) error { form := url.Values{ "keytext": {armoredKey}, } for _, server := range c.servers { u := fmt.Sprintf("%s/pks/add", server) resp, err := c.client.PostForm(u, form) if err != nil { c.logger.Warn().Err(err).Str("server", server).Msg("HKP upload failed") continue } resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } } return fmt.Errorf("failed to upload key to any HKP server") } // DeleteKey deletes a PGP key (Hockeypuck-specific API, not standard HKP) func (c *HockeypuckClient) DeleteKey(keyID string) error { if !strings.HasPrefix(keyID, "0x") { keyID = "0x" + keyID } for _, server := range c.servers { u := fmt.Sprintf("%s/pks/delete?search=%s", server, url.QueryEscape(keyID)) req, err := http.NewRequest("DELETE", u, nil) if err != nil { continue } resp, err := c.client.Do(req) if err != nil { continue } resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } } return fmt.Errorf("failed to delete key from any HKP server") } // Health checks HKP server connectivity func (c *HockeypuckClient) Health() error { for _, server := range c.servers { resp, err := c.client.Get(server + "/pks/lookup?op=stats") if err != nil { continue } resp.Body.Close() if resp.StatusCode == http.StatusOK { return nil } } return fmt.Errorf("no HKP servers reachable") }