// Package api is the small slice of Hammerhead's HTTP API that `shark expose` uses. package api import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" ) // Client talks to Hammerhead with an API key (the "user token" from Access in the dashboard). type Client struct { Base string Token string HTTP *http.Client } func New(base, token string) *Client { return &Client{Base: strings.TrimRight(base, "/"), Token: token, HTTP: &http.Client{Timeout: 20 * time.Second}} } // Error is an error Hammerhead answered with. type Error struct { Status int Message string } func (e *Error) Error() string { return e.Message } // IsStatus reports whether err is a Hammerhead error with this HTTP status. func IsStatus(err error, status int) bool { var e *Error return errors.As(err, &e) && e.Status == status } // Request is what to expose. Zero values mean "let Hammerhead choose". type Request struct { Proto string `json:"proto"` // http, https or tcp Name string `json:"name,omitempty"` TargetHost string `json:"targetHost,omitempty"` TargetPort int `json:"targetPort"` TTLSeconds int `json:"ttlSeconds,omitempty"` Port int `json:"port,omitempty"` Hostname string `json:"hostname,omitempty"` EdgeID string `json:"edgeId,omitempty"` Auth *Auth `json:"auth,omitempty"` } // Auth is the login in front of a website. type Auth struct { Password string `json:"password,omitempty"` Pin string `json:"pin,omitempty"` SSO bool `json:"sso,omitempty"` } // Exposure is an ephemeral site and port as Hammerhead describes it. type Exposure struct { ID string `json:"id"` Proto string `json:"proto"` URL string `json:"url"` Host string `json:"host"` Port int `json:"port"` Hostname string `json:"hostname"` SiteToken string `json:"siteToken"` Hammerhead string `json:"hammerhead"` Protected bool `json:"protected"` ExpiresAt time.Time `json:"expiresAt"` Live bool `json:"live"` Error string `json:"error"` } // Address is what visitors use: the URL for websites, host:port for tcp. func (e Exposure) Address() string { if e.URL != "" { return e.URL } return fmt.Sprintf("tcp://%s:%d", e.Host, e.Port) } func (c *Client) do(ctx context.Context, method, path string, in, out any) error { var body io.Reader if in != nil { b, err := json.Marshal(in) if err != nil { return err } body = bytes.NewReader(b) } u, err := url.JoinPath(c.Base, path) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, method, u, body) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.Token) if in != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.HTTP.Do(req) if err != nil { return err } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { var e struct{ Error string } json.Unmarshal(raw, &e) if e.Error == "" { e.Error = fmt.Sprintf("%s %s: %s", method, path, resp.Status) } if resp.StatusCode == http.StatusUnauthorized { e.Error = "Hammerhead did not accept the token (" + e.Error + ")" } return &Error{Status: resp.StatusCode, Message: e.Error} } if out != nil { return json.Unmarshal(raw, out) } return nil } func (c *Client) Create(ctx context.Context, r Request) (Exposure, error) { var e Exposure err := c.do(ctx, "POST", "/api/orgs/-/expose", r, &e) return e, err } // Renew pushes the deadline out by ttl (0 = the server's default). func (c *Client) Renew(ctx context.Context, id string, ttl time.Duration) (Exposure, error) { var e Exposure err := c.do(ctx, "POST", "/api/orgs/-/expose/"+id+"/renew", map[string]int{"ttlSeconds": int(ttl.Seconds())}, &e) return e, err } func (c *Client) Get(ctx context.Context, id string) (Exposure, error) { var e Exposure err := c.do(ctx, "GET", "/api/orgs/-/expose/"+id, nil, &e) return e, err } func (c *Client) List(ctx context.Context) ([]Exposure, error) { var e []Exposure err := c.do(ctx, "GET", "/api/orgs/-/expose", nil, &e) return e, err } func (c *Client) Delete(ctx context.Context, id string) error { return c.do(ctx, "DELETE", "/api/orgs/-/expose/"+id, nil, nil) }