hhctl CLI and Terraform provider for Hammerhead

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 17:02:15 -06:00
commit bf340cdab4
19 files changed
+2442

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
// Package client talks to Hammerhead's public HTTP API with an API key.
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Client is a Hammerhead API client. Org is the default organization for org-scoped calls.
type Client struct {
BaseURL string
Token string
Org string
HTTP *http.Client
}
// New returns a client for the server at baseURL authenticating with an hhk_ API key.
func New(baseURL, token, org string) *Client {
return &Client{BaseURL: strings.TrimRight(baseURL, "/"), Token: token, Org: org, HTTP: &http.Client{Timeout: 30 * time.Second}}
}
// APIError is an error response from the server.
type APIError struct {
Status int
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("hammerhead: %s (HTTP %d)", e.Message, e.Status)
}
// IsNotFound reports whether err is a 404 from the server.
func IsNotFound(err error) bool {
e, ok := err.(*APIError)
return ok && e.Status == http.StatusNotFound
}
func (c *Client) do(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)
}
req, err := http.NewRequest(method, c.BaseURL+path, 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, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return err
}
if resp.StatusCode >= 300 {
var e struct {
Error string `json:"error"`
}
msg := strings.TrimSpace(string(raw))
if json.Unmarshal(raw, &e) == nil && e.Error != "" {
msg = e.Error
}
return &APIError{resp.StatusCode, msg}
}
if out != nil && len(raw) > 0 {
return json.Unmarshal(raw, out)
}
return nil
}
func (c *Client) orgPath(format string, args ...any) (string, error) {
if c.Org == "" {
return "", fmt.Errorf("no organization set (use --org, HAMMERHEAD_ORG, or an organization key)")
}
return "/api/orgs/" + url.PathEscape(c.Org) + fmt.Sprintf(format, args...), nil
}
func esc(id string) string { return url.PathEscape(id) }
// ResolveOrg fills in Org when the key can see exactly one organization.
func (c *Client) ResolveOrg() error {
if c.Org != "" {
return nil
}
orgs, err := c.Orgs()
if err != nil {
return err
}
if len(orgs) != 1 {
return fmt.Errorf("the key can reach %d organizations; choose one with --org", len(orgs))
}
c.Org = orgs[0].ID
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package client_test
import (
"testing"
"git.veillax.com/Veillax/hhctl/client"
"git.veillax.com/Veillax/hhctl/internal/fakehh"
)
func TestResourcesRoundTrip(t *testing.T) {
fs := fakehh.New()
defer fs.Close()
c := client.New(fs.URL, fakehh.ServerKey, "")
if err := c.ResolveOrg(); err != nil || c.Org != fakehh.OrgID {
t.Fatalf("resolve org: %q %v", c.Org, err)
}
edge, err := c.CreateEdge(client.Edge{Name: "fra", TunnelAddr: "1.2.3.4:51820"})
if err != nil || edge.ID == "" || edge.Token == "" {
t.Fatalf("create edge: %+v %v", edge, err)
}
site, err := c.CreateSite("home", []string{"lab"})
if err != nil || site.Token == "" {
t.Fatalf("create site: %+v %v", site, err)
}
got, err := c.GetSite(site.ID)
if err != nil || got.Name != "home" || len(got.Tags) != 1 {
t.Fatalf("get site: %+v %v", got, err)
}
if err := c.UpdateSite(site.ID, "house", nil); err != nil {
t.Fatal(err)
}
if got, _ = c.GetSite(site.ID); got.Name != "house" {
t.Fatalf("rename: %+v", got)
}
port, err := c.CreatePort(client.Port{Name: "web", Proto: "https", EdgeID: edge.ID, Port: 443, Hostname: "a.example.com", Enabled: true,
Targets: []client.Target{{SiteID: site.ID, Host: "10.0.0.2", Port: 80}}})
if err != nil || port.ID == "" {
t.Fatalf("create port: %+v %v", port, err)
}
port.Enabled = false
if _, err := c.UpdatePort(*port); err != nil {
t.Fatal(err)
}
if p, _ := c.GetPort(port.ID); p.Enabled || p.Targets[0].Host != "10.0.0.2" {
t.Fatalf("update port: %+v", p)
}
dom, err := c.CreateDomain(client.Domain{Name: "example.com", EdgeID: edge.ID})
if err != nil {
t.Fatal(err)
}
id, err := c.CreateChannel(false, client.Channel{Name: "ops", Kind: "email", Target: "ops@example.com", Enabled: true})
if err != nil || id == "" {
t.Fatalf("channel id: %q %v", id, err)
}
al, err := c.Alerts(false)
if err != nil || len(al.Channels) != 1 {
t.Fatalf("alerts: %+v %v", al, err)
}
for _, del := range []func() error{
func() error { return c.DeleteChannel(false, id) },
func() error { return c.DeleteDomain(dom.ID) },
func() error { return c.DeletePort(port.ID) },
func() error { return c.DeleteSite(site.ID) },
func() error { return c.DeleteEdge(edge.ID) },
} {
if err := del(); err != nil {
t.Fatal(err)
}
}
if _, err := c.GetSite(site.ID); !client.IsNotFound(err) {
t.Fatalf("want not found, got %v", err)
}
}
func TestBadKeyAndOrgScope(t *testing.T) {
fs := fakehh.New()
defer fs.Close()
if _, err := client.New(fs.URL, "nope", fakehh.OrgID).Sites(); err == nil {
t.Fatal("bad key accepted")
}
c := client.New(fs.URL, fakehh.OrgKey, "")
if _, err := c.Sites(); err == nil {
t.Fatal("expected an error without an org")
}
if _, err := c.CreateEdge(client.Edge{Name: "x", TunnelAddr: "a:1"}); err == nil {
t.Fatal("org key must not create edges")
}
}
+287
View File
@@ -0,0 +1,287 @@
package client
import (
"fmt"
"net/http"
)
func notFound(what string) error { return &APIError{http.StatusNotFound, "no such " + what} }
// ---- organizations ----
func (c *Client) Orgs() ([]Org, error) {
var out []Org
return out, c.do("GET", "/api/orgs", nil, &out)
}
// CreateOrg needs a server key.
func (c *Client) CreateOrg(name string, allowLocal bool) (*Org, error) {
var out Org
err := c.do("POST", "/api/orgs", map[string]any{"name": name, "allowLocal": allowLocal}, &out)
return &out, err
}
func (c *Client) DeleteOrg(id string) error { return c.do("DELETE", "/api/orgs/"+esc(id), nil, nil) }
func (c *Client) State() (*State, error) {
p, err := c.orgPath("/state")
if err != nil {
return nil, err
}
var out State
return &out, c.do("GET", p, nil, &out)
}
// ---- edges ----
func (c *Client) Edges() ([]Edge, error) {
var out []Edge
return out, c.do("GET", "/api/edges", nil, &out)
}
func (c *Client) GetEdge(id string) (*Edge, error) {
edges, err := c.Edges()
if err != nil {
return nil, err
}
for i := range edges {
if edges[i].ID == id {
return &edges[i], nil
}
}
return nil, notFound("edge")
}
// CreateEdge needs a server key. The result carries the edge's token.
func (c *Client) CreateEdge(e Edge) (*Edge, error) {
var out Edge
err := c.do("POST", "/api/edges", e, &out)
return &out, err
}
func (c *Client) UpdateEdge(e Edge) (*Edge, error) {
var out Edge
err := c.do("PUT", "/api/edges/"+esc(e.ID), e, &out)
return &out, err
}
func (c *Client) DeleteEdge(id string) error { return c.do("DELETE", "/api/edges/"+esc(id), nil, nil) }
// ---- sites ----
func (c *Client) Sites() ([]Site, error) {
st, err := c.State()
if err != nil {
return nil, err
}
return st.Sites, nil
}
func (c *Client) GetSite(id string) (*Site, error) {
sites, err := c.Sites()
if err != nil {
return nil, err
}
for i := range sites {
if sites[i].ID == id {
return &sites[i], nil
}
}
return nil, notFound("site")
}
// CreateSite returns the site including its agent token.
func (c *Client) CreateSite(name string, tags []string) (*Site, error) {
p, err := c.orgPath("/sites")
if err != nil {
return nil, err
}
var out Site
if err := c.do("POST", p, map[string]string{"name": name}, &out); err != nil {
return nil, err
}
if len(tags) > 0 {
if err := c.UpdateSite(out.ID, name, tags); err != nil {
return &out, err
}
out.Tags = tags
}
return &out, nil
}
func (c *Client) UpdateSite(id, name string, tags []string) error {
p, err := c.orgPath("/sites/%s", esc(id))
if err != nil {
return err
}
if tags == nil {
tags = []string{}
}
return c.do("PUT", p, map[string]any{"name": name, "tags": tags}, nil)
}
func (c *Client) DeleteSite(id string) error {
p, err := c.orgPath("/sites/%s", esc(id))
if err != nil {
return err
}
return c.do("DELETE", p, nil, nil)
}
// ---- domains ----
func (c *Client) Domains() ([]Domain, error) {
st, err := c.State()
if err != nil {
return nil, err
}
return st.Domains, nil
}
func (c *Client) GetDomain(id string) (*Domain, error) {
ds, err := c.Domains()
if err != nil {
return nil, err
}
for i := range ds {
if ds[i].ID == id {
return &ds[i], nil
}
}
return nil, notFound("domain")
}
func (c *Client) CreateDomain(d Domain) (*Domain, error) {
p, err := c.orgPath("/domains")
if err != nil {
return nil, err
}
var out Domain
return &out, c.do("POST", p, d, &out)
}
func (c *Client) DeleteDomain(id string) error {
p, err := c.orgPath("/domains/%s", esc(id))
if err != nil {
return err
}
return c.do("DELETE", p, nil, nil)
}
// ---- ports ----
func (c *Client) Ports() ([]Port, error) {
st, err := c.State()
if err != nil {
return nil, err
}
return st.Mappings, nil
}
func (c *Client) GetPort(id string) (*Port, error) {
ps, err := c.Ports()
if err != nil {
return nil, err
}
for i := range ps {
if ps[i].ID == id {
return &ps[i], nil
}
}
return nil, notFound("port")
}
func (c *Client) CreatePort(pt Port) (*Port, error) {
p, err := c.orgPath("/mappings")
if err != nil {
return nil, err
}
var out Port
return &out, c.do("POST", p, pt, &out)
}
// UpdatePort replaces the port; every field is overwritten.
func (c *Client) UpdatePort(pt Port) (*Port, error) {
p, err := c.orgPath("/mappings/%s", esc(pt.ID))
if err != nil {
return nil, err
}
var out Port
return &out, c.do("PUT", p, pt, &out)
}
func (c *Client) DeletePort(id string) error {
p, err := c.orgPath("/mappings/%s", esc(id))
if err != nil {
return err
}
return c.do("DELETE", p, nil, nil)
}
// ---- alert channels (the organization's, or with server=true the server-level ones) ----
func (c *Client) alertPath(server bool, format string, args ...any) (string, error) {
if server {
return "/api/admin/alerts" + fmt.Sprintf(format, args...), nil
}
return c.orgPath("/alerts"+format, args...)
}
func (c *Client) Alerts(server bool) (*Alerts, error) {
p, err := c.alertPath(server, "")
if err != nil {
return nil, err
}
var out Alerts
return &out, c.do("GET", p, nil, &out)
}
func (c *Client) CreateChannel(server bool, ch Channel) (string, error) {
p, err := c.alertPath(server, "/channels")
if err != nil {
return "", err
}
var out struct {
ID string `json:"id"`
}
err = c.do("POST", p, ch, &out)
return out.ID, err
}
func (c *Client) UpdateChannel(server bool, ch Channel) error {
p, err := c.alertPath(server, "/channels/%s", esc(ch.ID))
if err != nil {
return err
}
return c.do("PUT", p, ch, nil)
}
func (c *Client) DeleteChannel(server bool, id string) error {
p, err := c.alertPath(server, "/channels/%s", esc(id))
if err != nil {
return err
}
return c.do("DELETE", p, nil, nil)
}
func (c *Client) TestChannel(server bool, id string) error {
p, err := c.alertPath(server, "/channels/%s/test", esc(id))
if err != nil {
return err
}
return c.do("POST", p, nil, nil)
}
// CreateOrgKey returns the key's id and its secret token (shown once).
func (c *Client) CreateOrgKey(name string) (id, key string, err error) {
p, err := c.orgPath("/apikeys")
if err != nil {
return
}
var out struct {
ID string `json:"id"`
Key string `json:"key"`
}
err = c.do("POST", p, map[string]string{"name": name}, &out)
return out.ID, out.Key, err
}
+134
View File
@@ -0,0 +1,134 @@
package client
import "time"
type Org struct {
ID string `json:"id"`
Name string `json:"name"`
AllowLocal bool `json:"allowLocal"`
}
type Edge struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
IPs []string `json:"ips,omitempty"`
Online bool `json:"online,omitempty"`
TunnelAddr string `json:"tunnelAddr,omitempty"`
Token string `json:"token,omitempty"`
OrgIDs []string `json:"orgIds"`
Version string `json:"version,omitempty"`
}
type Site struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
Name string `json:"name"`
Token string `json:"token,omitempty"`
Connected []string `json:"connected"`
LastSeen time.Time `json:"lastSeen"`
Local bool `json:"local"`
EdgeID string `json:"edgeId,omitempty"`
Tags []string `json:"tags,omitempty"`
TunnelIP string `json:"tunnelIp,omitempty"`
Version string `json:"version,omitempty"`
}
type Domain struct {
ID string `json:"id,omitempty"`
OrgID string `json:"orgId,omitempty"`
Name string `json:"name"`
EdgeID string `json:"edgeId"`
IP string `json:"ip"`
Note string `json:"note"`
}
type Target struct {
SiteID string `json:"siteId"`
Host string `json:"host"`
Port int `json:"port"`
}
type Auth struct {
SSO bool `json:"sso"`
Password bool `json:"password"`
Pin bool `json:"pin"`
Email bool `json:"email"`
AllowedRoles []string `json:"allowedRoles"`
AllowedUsers []string `json:"allowedUsers"`
AllowedMails []string `json:"allowedMails"`
SessionHours int `json:"sessionHours"`
}
type Rule struct {
Action string `json:"action"`
Match string `json:"match"`
Value string `json:"value"`
}
// Port is what the dashboard calls a port (the server's "mapping"): a public port or hostname
// on an edge, forwarded to targets. NewPassword and NewPin are write-only: nil keeps, "" removes.
type Port struct {
ID string `json:"id,omitempty"`
OrgID string `json:"orgId,omitempty"`
Name string `json:"name"`
Proto string `json:"proto"`
EdgeID string `json:"edgeId"`
BindIP string `json:"bindIp"`
Port int `json:"port"`
PortEnd int `json:"portEnd,omitempty"`
Hostname string `json:"hostname,omitempty"`
Targets []Target `json:"targets"`
Enabled bool `json:"enabled"`
Tags []string `json:"tags,omitempty"`
ProxyProtocol string `json:"proxyProtocol,omitempty"`
RedirectTo string `json:"redirectTo,omitempty"`
RedirectPermanent bool `json:"redirectPermanent,omitempty"`
RedirectKeepPath bool `json:"redirectKeepPath,omitempty"`
Auth Auth `json:"auth"`
AuthEpoch int64 `json:"authEpoch,omitempty"`
Rules []Rule `json:"rules"`
RedirectHTTP bool `json:"redirectHttp"`
BackendHTTPS bool `json:"backendHttps"`
RewriteHost string `json:"rewriteHost"`
HealthPath string `json:"healthPath"`
HealthSeconds int `json:"healthSeconds"`
NewPassword *string `json:"newPassword,omitempty"`
NewPin *string `json:"newPin,omitempty"`
}
type Channel struct {
ID string `json:"id,omitempty"`
OrgID string `json:"orgId,omitempty"`
Name string `json:"name"`
Kind string `json:"kind"` // email, webhook or sandbar
Target string `json:"target"`
Format string `json:"format,omitempty"`
Token string `json:"token,omitempty"`
Policy string `json:"policy,omitempty"`
AllowPrivate bool `json:"allowPrivate"`
Enabled bool `json:"enabled"`
}
type AlertRow struct {
Key string `json:"key"`
Kind string `json:"kind"`
Name string `json:"name"`
Message string `json:"message"`
Since time.Time `json:"since"`
Fired bool `json:"fired"`
}
// Alerts is the alerts overview of an organization (or, with the server flag, of the server).
type Alerts struct {
Channels []Channel `json:"channels"`
Active []AlertRow `json:"active"`
Settings map[string]int `json:"settings"`
}
type State struct {
Edges []Edge `json:"edges"`
Sites []Site `json:"sites"`
Domains []Domain `json:"domains"`
Mappings []Port `json:"mappings"`
}