From bf340cdab4aeb16810720cdf19e28b1cb6dd8cd6 Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:02:15 -0600 Subject: [PATCH] hhctl CLI and Terraform provider for Hammerhead Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 4 + README.md | 124 +++++++++ client/client.go | 109 ++++++++ client/client_test.go | 92 +++++++ client/resources.go | 287 +++++++++++++++++++ client/types.go | 134 +++++++++ cmd/terraform-provider-hammerhead/main.go | 26 ++ examples/bootstrap.sh | 11 + examples/main.tf | 81 ++++++ go.mod | 30 ++ go.sum | 99 +++++++ internal/fakehh/fakehh.go | 206 ++++++++++++++ internal/provider/alert_channel.go | 151 ++++++++++ internal/provider/domain.go | 107 ++++++++ internal/provider/edge.go | 187 +++++++++++++ internal/provider/port.go | 319 ++++++++++++++++++++++ internal/provider/provider.go | 92 +++++++ internal/provider/provider_test.go | 251 +++++++++++++++++ internal/provider/site.go | 132 +++++++++ 19 files changed, 2442 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 client/client.go create mode 100644 client/client_test.go create mode 100644 client/resources.go create mode 100644 client/types.go create mode 100644 cmd/terraform-provider-hammerhead/main.go create mode 100644 examples/bootstrap.sh create mode 100644 examples/main.tf create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/fakehh/fakehh.go create mode 100644 internal/provider/alert_channel.go create mode 100644 internal/provider/domain.go create mode 100644 internal/provider/edge.go create mode 100644 internal/provider/port.go create mode 100644 internal/provider/provider.go create mode 100644 internal/provider/provider_test.go create mode 100644 internal/provider/site.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27a78f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +hhctl +*.exe +.terraform* +*.tfstate* diff --git a/README.md b/README.md new file mode 100644 index 0000000..950a708 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# hhctl + +A command-line tool and a Terraform provider for [Hammerhead](../hammerhead). Both talk only to Hammerhead's +public HTTP API with an API key, so they work against any server you can reach. + +``` +client/ Go API client (the only thing that speaks HTTP) +cmd/hhctl/ the CLI +cmd/terraform-provider-hammerhead/ +internal/provider/ Terraform provider (terraform-plugin-framework) +internal/fakehh/ in-memory fake of the API, for tests +examples/ Terraform configuration and a shell script +``` + +Needs a Hammerhead with API-key support for server scope (branch `feature/api-tokens-hhctl`; see +`hammerhead/docs/api-automation.md` for the audit of what the API could and could not do before). + +## Credentials + +| Key | Made with | Can | +|---|---|---| +| Organization key | Hammerhead dashboard, or `POST /api/orgs/{org}/apikeys` | sites, ports, domains and alert channels of its organization; list edges it may use | +| Server key | `POST /api/admin/apikeys` as a signed-in server admin | everything above in any organization, plus edges, server alert channels and creating organizations | + +The secret is shown once when the key is created. Keep it out of version control; pass it in the environment. + +```sh +export HAMMERHEAD_URL=https://hammerhead.example.com +export HAMMERHEAD_TOKEN=hhk_... +export HAMMERHEAD_ORG=... # optional with an organization key +``` + +## CLI + +```sh +go install git.veillax.com/Veillax/hhctl/cmd/hhctl@latest + +hhctl orgs list +hhctl edges create --name fra --tunnel 203.0.113.5:51820 # server key; prints the edge's token +hhctl sites create --name home --tag lab # prints the agent's token +hhctl domains add --name example.com --edge fra +hhctl ports create --name web --proto https --edge fra --port 443 \ + --hostname app.example.com --target home:192.168.1.30:8080 +hhctl ports create -f port.json # any option, as JSON +hhctl ports list +hhctl alerts channel-create --name ops --kind email --target ops@example.com +hhctl alerts channels +hhctl ports delete +``` + +Edges and sites can be given by name or ID. `-o json` prints the API's JSON (and the secret created, once) instead of a +table. Commands: `orgs`, `edges`, `sites`, `ports`, `domains` (`list`, `create`/`add`, `delete`) and `alerts` +(`list`, `channels`, `channel-create`, `channel-delete`, `channel-test`; `--server` for server-level ones). +See `hhctl -h`, and `examples/bootstrap.sh`. + +## Terraform provider + +Resources: `hammerhead_site`, `hammerhead_port`, `hammerhead_domain`, `hammerhead_edge`, `hammerhead_alert_channel`. +Data source: `hammerhead_edge` (by name). + +```hcl +terraform { + required_providers { + hammerhead = { source = "veillax/hammerhead" } + } +} + +provider "hammerhead" {} # url, token and org come from HAMMERHEAD_URL / _TOKEN / _ORG + +resource "hammerhead_site" "home" { + name = "home" + tags = ["lab"] +} + +resource "hammerhead_port" "web" { + name = "web" + proto = "https" + edge_id = data.hammerhead_edge.fra.id + port = 443 + hostname = "app.example.com" + target { + site_id = hammerhead_site.home.id + host = "192.168.1.30" + port = 8080 + } +} +``` + +A full example is in `examples/main.tf`. Notes: + +- `hammerhead_site.token` and `hammerhead_edge.token` are sensitive outputs: hand them to the Bamboo / Blacktip program. +- Ports are replaced wholesale by the API. `password` and `pin` are sent only when they change, because changing + them signs every visitor out. Hammerhead never returns them, so out-of-band changes show up only as a + removed password. +- The server does not return an alert channel's webhook URL or Sandbar key, so those are kept as configured and + drift in them is not detected. +- Domains cannot be edited in place; any change replaces them. +- Deleting an edge or a site in Hammerhead also deletes the ports that depend on it; let Terraform order the + destroy via references as in the example. +- Import: `terraform import hammerhead_site.home ` (same for port, domain, edge); alert channels take `` or + `server/`, and need `target` set in configuration after import. + +For local use, build the provider and point Terraform at it with a `dev_overrides` block in `~/.terraformrc`: + +```sh +go build -o ~/go/bin/terraform-provider-hammerhead ./cmd/terraform-provider-hammerhead +``` + +```hcl +provider_installation { + dev_overrides { "veillax/hammerhead" = "/home/you/go/bin" } + direct {} +} +``` + +## Tests + +```sh +go test ./... +``` + +The tests run against `internal/fakehh`, a small in-memory imitation of the API, and drive the provider's +CRUD methods directly (no `terraform` binary needed). The fake was checked by hand against a real Hammerhead +for the CLI flows; there are no automated acceptance tests (`terraform-plugin-testing`) yet. diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..67569b1 --- /dev/null +++ b/client/client.go @@ -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 +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..73c59f5 --- /dev/null +++ b/client/client_test.go @@ -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") + } +} diff --git a/client/resources.go b/client/resources.go new file mode 100644 index 0000000..76dd392 --- /dev/null +++ b/client/resources.go @@ -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 +} diff --git a/client/types.go b/client/types.go new file mode 100644 index 0000000..b4d5e7f --- /dev/null +++ b/client/types.go @@ -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"` +} diff --git a/cmd/terraform-provider-hammerhead/main.go b/cmd/terraform-provider-hammerhead/main.go new file mode 100644 index 0000000..ee946c1 --- /dev/null +++ b/cmd/terraform-provider-hammerhead/main.go @@ -0,0 +1,26 @@ +// Command terraform-provider-hammerhead is the Terraform provider for Hammerhead. +package main + +import ( + "context" + "flag" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + + "git.veillax.com/Veillax/hhctl/internal/provider" +) + +var version = "dev" + +func main() { + debug := flag.Bool("debug", false, "run with support for debuggers") + flag.Parse() + err := providerserver.Serve(context.Background(), provider.New(version), providerserver.ServeOpts{ + Address: "registry.terraform.io/veillax/hammerhead", + Debug: *debug, + }) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/bootstrap.sh b/examples/bootstrap.sh new file mode 100644 index 0000000..a67dd21 --- /dev/null +++ b/examples/bootstrap.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Stand up an edge, a site, a domain and a web port with hhctl. +# Needs HAMMERHEAD_URL and HAMMERHEAD_TOKEN (a server key) in the environment. +set -eu + +hhctl edges create --name fra --tunnel 203.0.113.5:51820 +hhctl sites create --name home --tag lab +hhctl domains add --name example.com --edge fra +hhctl ports create --name web --proto https --edge fra --port 443 \ + --hostname app.example.com --target home:192.168.1.30:8080 +hhctl ports list diff --git a/examples/main.tf b/examples/main.tf new file mode 100644 index 0000000..186171e --- /dev/null +++ b/examples/main.tf @@ -0,0 +1,81 @@ +terraform { + required_providers { + hammerhead = { source = "veillax/hammerhead" } + } +} + +# HAMMERHEAD_URL, HAMMERHEAD_TOKEN (a server key, for edges) and HAMMERHEAD_ORG come from the environment. +provider "hammerhead" {} + +resource "hammerhead_edge" "fra" { + name = "fra" + tunnel_addr = "203.0.113.5:51820" +} + +resource "hammerhead_site" "home" { + name = "home" + tags = ["lab"] +} + +resource "hammerhead_domain" "example" { + name = "example.com" + edge_id = hammerhead_edge.fra.id +} + +resource "hammerhead_port" "web" { + name = "web" + proto = "https" + edge_id = hammerhead_edge.fra.id + port = 443 + hostname = "app.${hammerhead_domain.example.name}" + redirect_http = true + health_path = "/healthz" + auth_sso = true + + target { + site_id = hammerhead_site.home.id + host = "192.168.1.30" + port = 8080 + } + + rule { + action = "allow" + match = "path" + value = "/public" + } +} + +resource "hammerhead_port" "minecraft" { + name = "minecraft" + proto = "tcp" + edge_id = hammerhead_edge.fra.id + port = 25565 + target { + site_id = hammerhead_site.home.id + host = "192.168.1.31" + port = 25565 + } +} + +variable "ops_webhook" { + type = string + sensitive = true +} + +resource "hammerhead_alert_channel" "ops" { + name = "ops" + kind = "webhook" + format = "slack" + target = var.ops_webhook +} + +# Hand these to the Bamboo and Blacktip programs. +output "site_token" { + value = hammerhead_site.home.token + sensitive = true +} + +output "edge_token" { + value = hammerhead_edge.fra.token + sensitive = true +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..655fafa --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module git.veillax.com/Veillax/hhctl + +go 1.27.3 + +require github.com/hashicorp/terraform-plugin-framework v1.19.0 + +require ( + github.com/fatih/color v1.18.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/terraform-plugin-go v0.31.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.10.0 // indirect + github.com/hashicorp/terraform-registry-address v0.4.0 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4ede1c5 --- /dev/null +++ b/go.sum @@ -0,0 +1,99 @@ +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA= +github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc= +github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8= +github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8= +github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= +github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= +github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= +github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= +github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= +github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/fakehh/fakehh.go b/internal/fakehh/fakehh.go new file mode 100644 index 0000000..a08e49a --- /dev/null +++ b/internal/fakehh/fakehh.go @@ -0,0 +1,206 @@ +// Package fakehh is a small in-memory imitation of the parts of Hammerhead's API that hhctl uses, +// for tests. It checks the bearer key and keeps resources in maps; it does not validate like the real server. +package fakehh + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" +) + +const ( + OrgID = "org1" + OrgKey = "hhk_org_secret" + ServerKey = "hhk_srv_secret" +) + +type Server struct { + *httptest.Server + mu sync.Mutex + n int + Edges map[string]map[string]any + Sites map[string]map[string]any + Domains map[string]map[string]any + Mappings map[string]map[string]any + Channels map[string]map[string]any +} + +func New() *Server { + s := &Server{Edges: map[string]map[string]any{}, Sites: map[string]map[string]any{}, Domains: map[string]map[string]any{}, + Mappings: map[string]map[string]any{}, Channels: map[string]map[string]any{}} + s.Server = httptest.NewServer(http.HandlerFunc(s.serve)) + return s +} + +func (s *Server) id(prefix string) string { s.n++; return fmt.Sprintf("%s%d", prefix, s.n) } + +func reply(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(v) +} + +func list(m map[string]map[string]any) []map[string]any { + out := []map[string]any{} + for _, v := range m { + out = append(out, v) + } + return out +} + +func (s *Server) serve(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + key := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if key != OrgKey && key != ServerKey { + reply(w, 401, map[string]string{"error": "Sign in to continue."}) + return + } + server := key == ServerKey + var body map[string]any + if r.Body != nil { + json.NewDecoder(r.Body).Decode(&body) + } + path := strings.Trim(r.URL.Path, "/") + seg := strings.Split(path, "/") // api, ... + m := r.Method + ok := map[string]bool{"ok": true} + + switch { + case !server && (strings.HasPrefix(path, "api/admin") || m != "GET" && strings.HasPrefix(path, "api/edges") || m == "POST" && path == "api/orgs"): + reply(w, 401, map[string]string{"error": "Sign in to continue."}) // the real server refuses org keys here + case path == "api/orgs" && m == "GET": + reply(w, 200, []map[string]any{{"id": OrgID, "name": "Acme"}}) + case path == "api/orgs" && m == "POST" && server: + reply(w, 200, map[string]any{"id": "org2", "name": body["name"]}) + case path == "api/edges" && m == "GET": + reply(w, 200, list(s.Edges)) + case path == "api/edges" && m == "POST" && server: + id := s.id("e") + body["id"], body["token"], body["ips"], body["online"] = id, "edge-token-"+id, []string{}, false + s.Edges[id] = body + reply(w, 200, body) + case len(seg) == 3 && seg[1] == "edges" && server: + e := s.Edges[seg[2]] + switch { + case e == nil: + reply(w, 404, map[string]string{"error": "No such edge"}) + case m == "PUT": + for k, v := range body { + e[k] = v + } + reply(w, 200, e) + case m == "DELETE": + delete(s.Edges, seg[2]) + reply(w, 200, ok) + } + case len(seg) >= 3 && seg[1] == "orgs" && seg[2] == OrgID || len(seg) >= 3 && seg[1] == "orgs" && server: + s.org(w, m, seg[3:], body, server) + case len(seg) >= 3 && seg[1] == "admin" && seg[2] == "alerts" && server: + s.alerts(w, m, seg[3:], body) + default: + reply(w, 404, map[string]string{"error": "not found"}) + } +} + +func (s *Server) org(w http.ResponseWriter, m string, seg []string, body map[string]any, server bool) { + ok := map[string]bool{"ok": true} + if len(seg) == 0 { + reply(w, 404, map[string]string{"error": "not found"}) + return + } + switch seg[0] { + case "state": + reply(w, 200, map[string]any{"edges": list(s.Edges), "sites": list(s.Sites), "domains": list(s.Domains), "mappings": list(s.Mappings)}) + case "sites", "domains", "mappings": + var store map[string]map[string]any + var prefix string + switch seg[0] { + case "sites": + store, prefix = s.Sites, "s" + case "domains": + store, prefix = s.Domains, "d" + default: + store, prefix = s.Mappings, "m" + } + if len(seg) == 1 && m == "POST" { + id := s.id(prefix) + body["id"], body["orgId"] = id, OrgID + if seg[0] == "sites" { + body["token"], body["connected"] = "site-token-"+id, []string{} + } + store[id] = body + reply(w, 200, body) + return + } + if len(seg) == 2 { + cur := store[seg[1]] + switch { + case cur == nil: + reply(w, 404, map[string]string{"error": "No such " + seg[0]}) + case m == "PUT": + for k, v := range body { + cur[k] = v + } + if seg[0] == "sites" { + reply(w, 200, ok) + } else { + reply(w, 200, cur) + } + case m == "DELETE": + delete(store, seg[1]) + reply(w, 200, ok) + } + return + } + reply(w, 404, map[string]string{"error": "not found"}) + case "alerts": + s.alerts(w, m, seg[1:], body) + case "apikeys": + reply(w, 200, map[string]string{"id": "k1", "key": "hhk_k1_secret"}) + default: + reply(w, 404, map[string]string{"error": "not found"}) + } +} + +func (s *Server) alerts(w http.ResponseWriter, m string, seg []string, body map[string]any) { + ok := map[string]bool{"ok": true} + switch { + case len(seg) == 0 && m == "GET": + ch := list(s.Channels) + for _, c := range ch { // like the real server, secrets are not sent back + if c["kind"] != "email" { + c["target"], c["token"] = "", "" + } + } + reply(w, 200, map[string]any{"channels": ch, "active": []any{}, "settings": map[string]int{"debounceMin": 2, "repeatHours": 0}}) + case len(seg) == 1 && seg[0] == "channels" && m == "POST": + id := s.id("c") + body["id"] = id + s.Channels[id] = body + reply(w, 200, map[string]any{"ok": true, "id": id}) + case len(seg) >= 2 && seg[0] == "channels": + cur := s.Channels[seg[1]] + switch { + case cur == nil: + reply(w, 404, map[string]string{"error": "No such channel"}) + case len(seg) == 3 && seg[2] == "test": + reply(w, 200, ok) + case m == "PUT": + for k, v := range body { + if v != "" { + cur[k] = v + } + } + reply(w, 200, ok) + case m == "DELETE": + delete(s.Channels, seg[1]) + reply(w, 200, ok) + } + default: + reply(w, 404, map[string]string{"error": "not found"}) + } +} diff --git a/internal/provider/alert_channel.go b/internal/provider/alert_channel.go new file mode 100644 index 0000000..3fc133d --- /dev/null +++ b/internal/provider/alert_channel.go @@ -0,0 +1,151 @@ +package provider + +import ( + "context" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "git.veillax.com/Veillax/hhctl/client" +) + +var ( + _ resource.Resource = &channelResource{} + _ resource.ResourceWithConfigure = &channelResource{} + _ resource.ResourceWithImportState = &channelResource{} +) + +func NewAlertChannel() resource.Resource { return &channelResource{} } + +type channelResource struct{ c *client.Client } + +type channelModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Kind types.String `tfsdk:"kind"` + Target types.String `tfsdk:"target"` + Format types.String `tfsdk:"format"` + Token types.String `tfsdk:"token"` + Policy types.String `tfsdk:"policy"` + AllowPrivate types.Bool `tfsdk:"allow_private"` + Enabled types.Bool `tfsdk:"enabled"` + Server types.Bool `tfsdk:"server"` +} + +func (r *channelResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_alert_channel" +} + +func (r *channelResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + opt := func(d string) schema.StringAttribute { + return schema.StringAttribute{Optional: true, Computed: true, Default: stringdefault.StaticString(""), Description: d} + } + resp.Schema = schema.Schema{ + Description: "Somewhere alerts are sent: an email address, a webhook or a Sandbar server.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}}, + "name": schema.StringAttribute{Required: true}, + "kind": schema.StringAttribute{Required: true, Description: "email, webhook or sandbar."}, + "target": schema.StringAttribute{Required: true, Sensitive: true, Description: "Address(es), webhook URL or Sandbar address. Webhook URLs often carry tokens, so Hammerhead never returns them."}, + "format": opt("Webhook body: json, slack, discord or ntfy."), + "token": schema.StringAttribute{Optional: true, Sensitive: true, Description: "Sandbar API key."}, + "policy": opt("Sandbar escalation policy ID; empty sends the alert without paging anyone."), + "allow_private": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false), + Description: "Allow a webhook target on a private address (organization admins may need allow-local)."}, + "enabled": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(true)}, + "server": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{boolplanmodifier.RequiresReplace()}, + Description: "A server-level channel (for edges) instead of the organization's. Needs a server key."}, + }, + } +} + +func (r *channelResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { + r.c = clientFrom(req.ProviderData) +} + +func (m *channelModel) toAPI() client.Channel { + return client.Channel{ID: m.ID.ValueString(), Name: m.Name.ValueString(), Kind: m.Kind.ValueString(), Target: m.Target.ValueString(), + Format: m.Format.ValueString(), Token: m.Token.ValueString(), Policy: m.Policy.ValueString(), + AllowPrivate: m.AllowPrivate.ValueBool(), Enabled: m.Enabled.ValueBool()} +} + +func (r *channelResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan channelModel + if resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...); resp.Diagnostics.HasError() { + return + } + id, err := r.c.CreateChannel(plan.Server.ValueBool(), plan.toAPI()) + if err != nil { + resp.Diagnostics.AddError("Creating alert channel", err.Error()) + return + } + plan.ID = types.StringValue(id) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *channelResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state channelModel + if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() { + return + } + a, err := r.c.Alerts(state.Server.ValueBool()) + if err != nil { + resp.Diagnostics.AddError("Reading alert channels", err.Error()) + return + } + for _, ch := range a.Channels { + if ch.ID != state.ID.ValueString() { + continue + } + state.Name, state.Kind, state.Format, state.Policy = types.StringValue(ch.Name), types.StringValue(ch.Kind), types.StringValue(ch.Format), types.StringValue(ch.Policy) + state.AllowPrivate, state.Enabled = types.BoolValue(ch.AllowPrivate), types.BoolValue(ch.Enabled) + if ch.Kind == "email" { // the only target the server sends back; URLs and keys stay as configured + state.Target = types.StringValue(ch.Target) + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + return + } + resp.State.RemoveResource(ctx) +} + +func (r *channelResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state channelModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + plan.ID = state.ID + if err := r.c.UpdateChannel(plan.Server.ValueBool(), plan.toAPI()); err != nil { + resp.Diagnostics.AddError("Updating alert channel", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *channelResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state channelModel + if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() { + return + } + if err := r.c.DeleteChannel(state.Server.ValueBool(), state.ID.ValueString()); err != nil && !client.IsNotFound(err) { + resp.Diagnostics.AddError("Deleting alert channel", err.Error()) + } +} + +// ImportState takes "" for an organization channel or "server/" for a server-level one. The target and +// token are not readable from Hammerhead, so set them in the configuration and apply once after importing. +func (r *channelResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + id, server := strings.CutPrefix(req.ID, "server/") + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("server"), server)...) +} diff --git a/internal/provider/domain.go b/internal/provider/domain.go new file mode 100644 index 0000000..7051426 --- /dev/null +++ b/internal/provider/domain.go @@ -0,0 +1,107 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "git.veillax.com/Veillax/hhctl/client" +) + +var ( + _ resource.Resource = &domainResource{} + _ resource.ResourceWithConfigure = &domainResource{} + _ resource.ResourceWithImportState = &domainResource{} +) + +func NewDomain() resource.Resource { return &domainResource{} } + +type domainResource struct{ c *client.Client } + +type domainModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + EdgeID types.String `tfsdk:"edge_id"` + IP types.String `tfsdk:"ip"` + Note types.String `tfsdk:"note"` +} + +func (r *domainResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_domain" +} + +func (r *domainResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + replace := []planmodifier.String{stringplanmodifier.RequiresReplace()} // the API has no domain update + opt := func(d string) schema.StringAttribute { + return schema.StringAttribute{Optional: true, Computed: true, Default: stringdefault.StaticString(""), PlanModifiers: replace, Description: d} + } + resp.Schema = schema.Schema{ + Description: "A base domain the organization owns. Web hostnames of ports must sit under one. Changing any field replaces the domain.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}}, + "name": schema.StringAttribute{Required: true, PlanModifiers: replace, Description: "Domain, like example.com."}, + "edge_id": opt("The edge the domain's DNS points at."), + "ip": opt("The edge's public IP the domain points at."), + "note": opt("Free-form note."), + }, + } +} + +func (r *domainResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { + r.c = clientFrom(req.ProviderData) +} + +func (r *domainResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan domainModel + if resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...); resp.Diagnostics.HasError() { + return + } + d, err := r.c.CreateDomain(client.Domain{Name: plan.Name.ValueString(), EdgeID: plan.EdgeID.ValueString(), IP: plan.IP.ValueString(), Note: plan.Note.ValueString()}) + if err != nil { + resp.Diagnostics.AddError("Adding domain", err.Error()) + return + } + plan.ID = types.StringValue(d.ID) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *domainResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state domainModel + if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() { + return + } + d, err := r.c.GetDomain(state.ID.ValueString()) + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError("Reading domain", err.Error()) + return + } + state.Name, state.EdgeID, state.IP, state.Note = types.StringValue(d.Name), types.StringValue(d.EdgeID), types.StringValue(d.IP), types.StringValue(d.Note) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *domainResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError("Domains can't be updated", "Every field forces a new domain; this is a provider bug.") +} + +func (r *domainResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state domainModel + if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() { + return + } + if err := r.c.DeleteDomain(state.ID.ValueString()); err != nil && !client.IsNotFound(err) { + resp.Diagnostics.AddError("Deleting domain", err.Error()) + } +} + +func (r *domainResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + importByID(ctx, req, resp) +} diff --git a/internal/provider/edge.go b/internal/provider/edge.go new file mode 100644 index 0000000..8a30bcb --- /dev/null +++ b/internal/provider/edge.go @@ -0,0 +1,187 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + dsschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "git.veillax.com/Veillax/hhctl/client" +) + +var ( + _ resource.Resource = &edgeResource{} + _ resource.ResourceWithConfigure = &edgeResource{} + _ resource.ResourceWithImportState = &edgeResource{} + _ datasource.DataSource = &edgeData{} + _ datasource.DataSourceWithConfigure = &edgeData{} +) + +func NewEdge() resource.Resource { return &edgeResource{} } + +type edgeResource struct{ c *client.Client } + +type edgeModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + TunnelAddr types.String `tfsdk:"tunnel_addr"` + OrgIDs types.Set `tfsdk:"org_ids"` + Token types.String `tfsdk:"token"` + IPs types.List `tfsdk:"ips"` +} + +func (r *edgeResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_edge" +} + +func (r *edgeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "An edge: a public Blacktip node. Needs a server API key.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}}, + "name": schema.StringAttribute{Required: true}, + "tunnel_addr": schema.StringAttribute{Required: true, Description: "The edge's public WireGuard endpoint, host:port (UDP)."}, + "org_ids": schema.SetAttribute{Optional: true, Computed: true, ElementType: types.StringType, Default: setdefault.StaticValue(setOf(nil)), + Description: "Organizations allowed to use the edge; empty means all."}, + "token": schema.StringAttribute{Computed: true, Sensitive: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Description: "The token to give the Blacktip program."}, + "ips": schema.ListAttribute{Computed: true, ElementType: types.StringType, Description: "Addresses the edge reported it can bind."}, + }, + } +} + +func (r *edgeResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { + r.c = clientFrom(req.ProviderData) +} + +func ipsList(ctx context.Context, ips []string) types.List { + if ips == nil { + ips = []string{} + } + l, _ := types.ListValueFrom(ctx, types.StringType, ips) + return l +} + +func (r *edgeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan edgeModel + if resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...); resp.Diagnostics.HasError() { + return + } + e, err := r.c.CreateEdge(client.Edge{Name: plan.Name.ValueString(), TunnelAddr: plan.TunnelAddr.ValueString(), OrgIDs: stringsOf(ctx, plan.OrgIDs)}) + if err != nil { + resp.Diagnostics.AddError("Creating edge", err.Error()) + return + } + plan.ID, plan.Token, plan.IPs = types.StringValue(e.ID), types.StringValue(e.Token), ipsList(ctx, e.IPs) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *edgeResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state edgeModel + if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() { + return + } + e, err := r.c.GetEdge(state.ID.ValueString()) + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError("Reading edge", err.Error()) + return + } + state.Name, state.TunnelAddr, state.OrgIDs, state.IPs = types.StringValue(e.Name), types.StringValue(e.TunnelAddr), setOf(e.OrgIDs), ipsList(ctx, e.IPs) + if e.Token != "" { + state.Token = types.StringValue(e.Token) + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *edgeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state edgeModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + e, err := r.c.UpdateEdge(client.Edge{ID: state.ID.ValueString(), Name: plan.Name.ValueString(), TunnelAddr: plan.TunnelAddr.ValueString(), OrgIDs: stringsOf(ctx, plan.OrgIDs)}) + if err != nil { + resp.Diagnostics.AddError("Updating edge", err.Error()) + return + } + plan.ID, plan.Token, plan.IPs = state.ID, state.Token, ipsList(ctx, e.IPs) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *edgeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state edgeModel + if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() { + return + } + if err := r.c.DeleteEdge(state.ID.ValueString()); err != nil && !client.IsNotFound(err) { + resp.Diagnostics.AddError("Deleting edge", err.Error()) + } +} + +func (r *edgeResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + importByID(ctx, req, resp) +} + +// ---- data source: look an edge up by name, so ports can refer to one created elsewhere ---- + +func NewEdgeData() datasource.DataSource { return &edgeData{} } + +type edgeData struct{ c *client.Client } + +type edgeDataModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + IPs types.List `tfsdk:"ips"` + Online types.Bool `tfsdk:"online"` +} + +func (d *edgeData) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_edge" +} + +func (d *edgeData) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = dsschema.Schema{ + Description: "An existing edge, found by name. Works with an organization key.", + Attributes: map[string]dsschema.Attribute{ + "id": dsschema.StringAttribute{Computed: true}, + "name": dsschema.StringAttribute{Required: true}, + "ips": dsschema.ListAttribute{Computed: true, ElementType: types.StringType}, + "online": dsschema.BoolAttribute{Computed: true}, + }, + } +} + +func (d *edgeData) Configure(_ context.Context, req datasource.ConfigureRequest, _ *datasource.ConfigureResponse) { + d.c = clientFrom(req.ProviderData) +} + +func (d *edgeData) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var m edgeDataModel + if resp.Diagnostics.Append(req.Config.Get(ctx, &m)...); resp.Diagnostics.HasError() { + return + } + edges, err := d.c.Edges() + if err != nil { + resp.Diagnostics.AddError("Listing edges", err.Error()) + return + } + for _, e := range edges { + if e.Name == m.Name.ValueString() { + m.ID, m.IPs, m.Online = types.StringValue(e.ID), ipsList(ctx, e.IPs), types.BoolValue(e.Online) + resp.Diagnostics.Append(resp.State.Set(ctx, &m)...) + return + } + } + resp.Diagnostics.AddError("Edge not found", "No edge named "+m.Name.ValueString()+" is visible to this key.") +} diff --git a/internal/provider/port.go b/internal/provider/port.go new file mode 100644 index 0000000..af61500 --- /dev/null +++ b/internal/provider/port.go @@ -0,0 +1,319 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "git.veillax.com/Veillax/hhctl/client" +) + +var ( + _ resource.Resource = &portResource{} + _ resource.ResourceWithConfigure = &portResource{} + _ resource.ResourceWithImportState = &portResource{} +) + +func NewPort() resource.Resource { return &portResource{} } + +type portResource struct{ c *client.Client } + +type targetModel struct { + SiteID types.String `tfsdk:"site_id"` + Host types.String `tfsdk:"host"` + Port types.Int64 `tfsdk:"port"` +} + +type ruleModel struct { + Action types.String `tfsdk:"action"` + Match types.String `tfsdk:"match"` + Value types.String `tfsdk:"value"` +} + +var ( + targetType = types.ObjectType{AttrTypes: map[string]attr.Type{"site_id": types.StringType, "host": types.StringType, "port": types.Int64Type}} + ruleType = types.ObjectType{AttrTypes: map[string]attr.Type{"action": types.StringType, "match": types.StringType, "value": types.StringType}} +) + +type portModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Proto types.String `tfsdk:"proto"` + EdgeID types.String `tfsdk:"edge_id"` + BindIP types.String `tfsdk:"bind_ip"` + Port types.Int64 `tfsdk:"port"` + PortEnd types.Int64 `tfsdk:"port_end"` + Hostname types.String `tfsdk:"hostname"` + Enabled types.Bool `tfsdk:"enabled"` + Tags types.Set `tfsdk:"tags"` + ProxyProtocol types.String `tfsdk:"proxy_protocol"` + RedirectTo types.String `tfsdk:"redirect_to"` + RedirectPermanent types.Bool `tfsdk:"redirect_permanent"` + RedirectKeepPath types.Bool `tfsdk:"redirect_keep_path"` + RedirectHTTP types.Bool `tfsdk:"redirect_http"` + BackendHTTPS types.Bool `tfsdk:"backend_https"` + RewriteHost types.String `tfsdk:"rewrite_host"` + HealthPath types.String `tfsdk:"health_path"` + HealthSeconds types.Int64 `tfsdk:"health_seconds"` + Targets types.List `tfsdk:"target"` + Rules types.List `tfsdk:"rule"` + AuthSSO types.Bool `tfsdk:"auth_sso"` + AuthEmail types.Bool `tfsdk:"auth_email"` + AuthRoles types.Set `tfsdk:"auth_allowed_roles"` + AuthUsers types.Set `tfsdk:"auth_allowed_users"` + AuthMails types.Set `tfsdk:"auth_allowed_mails"` + AuthSessionHours types.Int64 `tfsdk:"auth_session_hours"` + Password types.String `tfsdk:"password"` + Pin types.String `tfsdk:"pin"` +} + +func (r *portResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_port" +} + +func emptySet() defaults.Set { return setdefault.StaticValue(setOf(nil)) } + +func (r *portResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + str := func(desc string) schema.StringAttribute { + return schema.StringAttribute{Optional: true, Computed: true, Default: stringdefault.StaticString(""), Description: desc} + } + flag := func(desc string) schema.BoolAttribute { + return schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false), Description: desc} + } + num := func(desc string) schema.Int64Attribute { + return schema.Int64Attribute{Optional: true, Computed: true, Default: int64default.StaticInt64(0), Description: desc} + } + set := func(desc string) schema.SetAttribute { + return schema.SetAttribute{Optional: true, Computed: true, ElementType: types.StringType, Default: emptySet(), Description: desc} + } + resp.Schema = schema.Schema{ + Description: "A port: a public TCP/UDP port, or an HTTP(S)/TLS hostname, on an edge, forwarded to targets at a site.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}}, + "name": schema.StringAttribute{Required: true}, + "proto": schema.StringAttribute{Required: true, Description: "tcp, udp, http, https or tls."}, + "edge_id": schema.StringAttribute{Required: true, Description: "The edge that listens."}, + "bind_ip": schema.StringAttribute{Optional: true, Computed: true, Default: stringdefault.StaticString("0.0.0.0"), Description: "Address on the edge to listen on; 0.0.0.0 is every address."}, + "port": schema.Int64Attribute{Required: true, Description: "Public port."}, + "port_end": num("Last port of a tcp/udp range (0 = just port)."), + "hostname": str("Hostname for http, https and tls; a wildcard like *.example.com is allowed."), + "enabled": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(true)}, + "tags": set("Labels for grouping and filtering in the dashboard."), + + "proxy_protocol": str("v1 or v2: prefix tcp/tls connections with a PROXY protocol header."), + "redirect_to": str("Answer with a redirect to this URL instead of forwarding (http/https)."), + "redirect_permanent": flag("Redirect with 301 instead of 302."), + "redirect_keep_path": flag("Append the visitor's path to the redirect."), + "redirect_http": flag("https: also answer on port 80 with a redirect."), + "backend_https": flag("Talk TLS (unverified) to the targets."), + "rewrite_host": str("Host header sent to the target; empty keeps the original."), + "health_path": str("http(s): GET this path for health checks; empty is a TCP connect check."), + "health_seconds": num("Seconds between health checks; 0 is the default (30), -1 turns them off."), + + "auth_sso": flag("Visitors sign in with a Hammerhead account."), + "auth_email": flag("Visitors get a one-time code by email."), + "auth_allowed_roles": set("Roles allowed to sign in with SSO."), + "auth_allowed_users": set("User IDs allowed to sign in with SSO."), + "auth_allowed_mails": set("Addresses allowed for email codes; \"*@example.com\" is fine."), + "auth_session_hours": num("How long a visitor stays signed in; 0 is the default (24)."), + "password": schema.StringAttribute{Optional: true, Sensitive: true, Description: "Shared password visitors must enter (at least 6 characters)."}, + "pin": schema.StringAttribute{Optional: true, Sensitive: true, Description: "4-12 digit PIN visitors must enter."}, + }, + Blocks: map[string]schema.Block{ + "target": schema.ListNestedBlock{ + Description: "A backend to forward to. UDP uses only the first.", + NestedObject: schema.NestedBlockObject{Attributes: map[string]schema.Attribute{ + "site_id": schema.StringAttribute{Required: true}, + "host": schema.StringAttribute{Required: true, Description: "Address of the service as seen from the site."}, + "port": schema.Int64Attribute{Required: true}, + }}, + }, + "rule": schema.ListNestedBlock{ + Description: "Access rule evaluated before authentication, top to bottom.", + NestedObject: schema.NestedBlockObject{Attributes: map[string]schema.Attribute{ + "action": schema.StringAttribute{Required: true, Description: "allow (skip auth) or deny."}, + "match": schema.StringAttribute{Required: true, Description: "path (prefix) or ip (address or CIDR)."}, + "value": schema.StringAttribute{Required: true}, + }}, + }, + }, + } +} + +func (r *portResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { + r.c = clientFrom(req.ProviderData) +} + +// toAPI builds the request. old is the prior state (nil on create); passwords are only re-sent when they change, +// because every change signs all visitors out. +func (m *portModel) toAPI(ctx context.Context, old *portModel) (client.Port, diag.Diagnostics) { + p := client.Port{ + ID: m.ID.ValueString(), Name: m.Name.ValueString(), Proto: m.Proto.ValueString(), EdgeID: m.EdgeID.ValueString(), + BindIP: m.BindIP.ValueString(), Port: int(m.Port.ValueInt64()), PortEnd: int(m.PortEnd.ValueInt64()), + Hostname: m.Hostname.ValueString(), Enabled: m.Enabled.ValueBool(), Tags: stringsOf(ctx, m.Tags), + ProxyProtocol: m.ProxyProtocol.ValueString(), RedirectTo: m.RedirectTo.ValueString(), + RedirectPermanent: m.RedirectPermanent.ValueBool(), RedirectKeepPath: m.RedirectKeepPath.ValueBool(), + RedirectHTTP: m.RedirectHTTP.ValueBool(), BackendHTTPS: m.BackendHTTPS.ValueBool(), + RewriteHost: m.RewriteHost.ValueString(), HealthPath: m.HealthPath.ValueString(), HealthSeconds: int(m.HealthSeconds.ValueInt64()), + Targets: []client.Target{}, Rules: []client.Rule{}, + Auth: client.Auth{SSO: m.AuthSSO.ValueBool(), Email: m.AuthEmail.ValueBool(), SessionHours: int(m.AuthSessionHours.ValueInt64()), + AllowedRoles: stringsOf(ctx, m.AuthRoles), AllowedUsers: stringsOf(ctx, m.AuthUsers), AllowedMails: stringsOf(ctx, m.AuthMails), + Password: !m.Password.IsNull(), Pin: !m.Pin.IsNull()}, + } + var ts []targetModel + if d := m.Targets.ElementsAs(ctx, &ts, false); d.HasError() { + return p, d + } + for _, t := range ts { + p.Targets = append(p.Targets, client.Target{SiteID: t.SiteID.ValueString(), Host: t.Host.ValueString(), Port: int(t.Port.ValueInt64())}) + } + var rs []ruleModel + if d := m.Rules.ElementsAs(ctx, &rs, false); d.HasError() { + return p, d + } + for _, x := range rs { + p.Rules = append(p.Rules, client.Rule{Action: x.Action.ValueString(), Match: x.Match.ValueString(), Value: x.Value.ValueString()}) + } + p.NewPassword = secretChange(m.Password, old, func(o *portModel) types.String { return o.Password }) + p.NewPin = secretChange(m.Pin, old, func(o *portModel) types.String { return o.Pin }) + return p, nil +} + +// secretChange returns what to send in newPassword/newPin: nil keeps the stored secret. +func secretChange(want types.String, old *portModel, get func(*portModel) types.String) *string { + var was types.String = types.StringNull() + if old != nil { + was = get(old) + } + switch { + case want.IsNull() && was.IsNull(): + return nil + case want.IsNull(): + e := "" + return &e + case want.Equal(was): + return nil + } + v := want.ValueString() + return &v +} + +// fromAPI refreshes m from the server's view; secrets the server never returns stay as they are. +func (m *portModel) fromAPI(ctx context.Context, p *client.Port) (diags diag.Diagnostics) { + m.ID, m.Name, m.Proto, m.EdgeID = types.StringValue(p.ID), types.StringValue(p.Name), types.StringValue(p.Proto), types.StringValue(p.EdgeID) + m.BindIP, m.Port, m.PortEnd = types.StringValue(p.BindIP), types.Int64Value(int64(p.Port)), types.Int64Value(int64(p.PortEnd)) + m.Hostname, m.Enabled, m.Tags = types.StringValue(p.Hostname), types.BoolValue(p.Enabled), setOf(p.Tags) + m.ProxyProtocol, m.RedirectTo = types.StringValue(p.ProxyProtocol), types.StringValue(p.RedirectTo) + m.RedirectPermanent, m.RedirectKeepPath = types.BoolValue(p.RedirectPermanent), types.BoolValue(p.RedirectKeepPath) + m.RedirectHTTP, m.BackendHTTPS = types.BoolValue(p.RedirectHTTP), types.BoolValue(p.BackendHTTPS) + m.RewriteHost, m.HealthPath, m.HealthSeconds = types.StringValue(p.RewriteHost), types.StringValue(p.HealthPath), types.Int64Value(int64(p.HealthSeconds)) + m.AuthSSO, m.AuthEmail, m.AuthSessionHours = types.BoolValue(p.Auth.SSO), types.BoolValue(p.Auth.Email), types.Int64Value(int64(p.Auth.SessionHours)) + m.AuthRoles, m.AuthUsers, m.AuthMails = setOf(p.Auth.AllowedRoles), setOf(p.Auth.AllowedUsers), setOf(p.Auth.AllowedMails) + if !p.Auth.Password { + m.Password = types.StringNull() + } + if !p.Auth.Pin { + m.Pin = types.StringNull() + } + ts := make([]targetModel, 0, len(p.Targets)) + for _, t := range p.Targets { + ts = append(ts, targetModel{types.StringValue(t.SiteID), types.StringValue(t.Host), types.Int64Value(int64(t.Port))}) + } + rs := make([]ruleModel, 0, len(p.Rules)) + for _, x := range p.Rules { + rs = append(rs, ruleModel{types.StringValue(x.Action), types.StringValue(x.Match), types.StringValue(x.Value)}) + } + var d diag.Diagnostics + m.Targets, d = types.ListValueFrom(ctx, targetType, ts) + diags.Append(d...) + m.Rules, d = types.ListValueFrom(ctx, ruleType, rs) + diags.Append(d...) + return diags +} + +func (r *portResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan portModel + if resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...); resp.Diagnostics.HasError() { + return + } + body, d := plan.toAPI(ctx, nil) + if resp.Diagnostics.Append(d...); resp.Diagnostics.HasError() { + return + } + got, err := r.c.CreatePort(body) + if err != nil { + resp.Diagnostics.AddError("Creating port", err.Error()) + return + } + plan.ID = types.StringValue(got.ID) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *portResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state portModel + if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() { + return + } + p, err := r.c.GetPort(state.ID.ValueString()) + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError("Reading port", err.Error()) + return + } + if resp.Diagnostics.Append(state.fromAPI(ctx, p)...); resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *portResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state portModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + plan.ID = state.ID + body, d := plan.toAPI(ctx, &state) + if resp.Diagnostics.Append(d...); resp.Diagnostics.HasError() { + return + } + if _, err := r.c.UpdatePort(body); err != nil { + resp.Diagnostics.AddError("Updating port", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *portResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state portModel + if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() { + return + } + if err := r.c.DeletePort(state.ID.ValueString()); err != nil && !client.IsNotFound(err) { + resp.Diagnostics.AddError("Deleting port", err.Error()) + } +} + +func (r *portResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + importByID(ctx, req, resp) +} + +func importByID(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..5c21f95 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,92 @@ +// Package provider is the Terraform provider for Hammerhead. +package provider + +import ( + "context" + "os" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + + "git.veillax.com/Veillax/hhctl/client" +) + +type hammerheadProvider struct{ version string } + +// New returns a provider factory. +func New(version string) func() provider.Provider { + return func() provider.Provider { return &hammerheadProvider{version} } +} + +type providerModel struct { + URL types.String `tfsdk:"url"` + Token types.String `tfsdk:"token"` + Org types.String `tfsdk:"org"` +} + +func (p *hammerheadProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "hammerhead" + resp.Version = p.version +} + +func (p *hammerheadProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manage a Hammerhead control plane: sites, ports, domains, edges and alert channels.", + Attributes: map[string]schema.Attribute{ + "url": schema.StringAttribute{Optional: true, Description: "Hammerhead address, e.g. https://hammerhead.example.com. Defaults to $HAMMERHEAD_URL."}, + "token": schema.StringAttribute{Optional: true, Sensitive: true, Description: "API key (hhk_...). An organization key manages that organization; edges and server alert channels need a server key. Defaults to $HAMMERHEAD_TOKEN."}, + "org": schema.StringAttribute{Optional: true, Description: "Organization ID. Optional with an organization key. Defaults to $HAMMERHEAD_ORG."}, + }, + } +} + +func orDefault(v types.String, env string) string { + if !v.IsNull() && !v.IsUnknown() { + return v.ValueString() + } + return os.Getenv(env) +} + +func (p *hammerheadProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var cfg providerModel + resp.Diagnostics.Append(req.Config.Get(ctx, &cfg)...) + if resp.Diagnostics.HasError() { + return + } + for _, v := range []struct { + name string + val types.String + }{{"url", cfg.URL}, {"token", cfg.Token}, {"org", cfg.Org}} { + if v.val.IsUnknown() { + resp.Diagnostics.AddAttributeError(path.Root(v.name), "Unknown "+v.name, "The value must be known when the provider is configured.") + } + } + if resp.Diagnostics.HasError() { + return + } + url, token := orDefault(cfg.URL, "HAMMERHEAD_URL"), orDefault(cfg.Token, "HAMMERHEAD_TOKEN") + if url == "" || token == "" { + resp.Diagnostics.AddError("Missing configuration", "Set url and token in the provider block, or HAMMERHEAD_URL and HAMMERHEAD_TOKEN.") + return + } + c := client.New(url, token, orDefault(cfg.Org, "HAMMERHEAD_ORG")) + resp.ResourceData, resp.DataSourceData = c, c +} + +func (p *hammerheadProvider) Resources(context.Context) []func() resource.Resource { + return []func() resource.Resource{NewSite, NewPort, NewDomain, NewEdge, NewAlertChannel} +} + +func (p *hammerheadProvider) DataSources(context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{NewEdgeData} +} + +// clientFrom pulls the configured client out of ProviderData (nil before Configure ran). +func clientFrom(data any) *client.Client { + c, _ := data.(*client.Client) + return c +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..1aa9219 --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,251 @@ +package provider + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + + "git.veillax.com/Veillax/hhctl/client" + "git.veillax.com/Veillax/hhctl/internal/fakehh" +) + +func TestProviderSchemasAreValid(t *testing.T) { + srv, err := providerserver.NewProtocol6WithError(New("test")())() + if err != nil { + t.Fatal(err) + } + resp, err := srv.GetProviderSchema(context.Background(), &tfprotov6.GetProviderSchemaRequest{}) + if err != nil { + t.Fatal(err) + } + for _, d := range resp.Diagnostics { + t.Errorf("%s: %s", d.Summary, d.Detail) + } + for _, name := range []string{"hammerhead_site", "hammerhead_port", "hammerhead_domain", "hammerhead_edge", "hammerhead_alert_channel"} { + if resp.ResourceSchemas[name] == nil { + t.Errorf("missing resource %s", name) + } + } + if resp.DataSourceSchemas["hammerhead_edge"] == nil { + t.Error("missing data source") + } +} + +// harness drives a resource's CRUD methods the way Terraform does, without the terraform binary. +type harness struct { + t *testing.T + res resource.Resource + sch resource.SchemaResponse + st tfsdk.State +} + +func newHarness(t *testing.T, res resource.Resource, c *client.Client) *harness { + h := &harness{t: t, res: res} + res.Schema(context.Background(), resource.SchemaRequest{}, &h.sch) + res.(resource.ResourceWithConfigure).Configure(context.Background(), resource.ConfigureRequest{ProviderData: c}, &resource.ConfigureResponse{}) + h.st = tfsdk.State{Schema: h.sch.Schema, Raw: tftypes.NewValue(h.sch.Schema.Type().TerraformType(context.Background()), nil)} + return h +} + +func (h *harness) plan(model any) tfsdk.Plan { + p := tfsdk.Plan{Schema: h.sch.Schema, Raw: tftypes.NewValue(h.sch.Schema.Type().TerraformType(context.Background()), nil)} + if d := p.Set(context.Background(), model); d.HasError() { + h.t.Fatalf("plan: %v", d) + } + return p +} + +func (h *harness) create(model any) { + h.t.Helper() + resp := resource.CreateResponse{State: h.st} + h.res.Create(context.Background(), resource.CreateRequest{Plan: h.plan(model)}, &resp) + if resp.Diagnostics.HasError() { + h.t.Fatalf("create: %v", resp.Diagnostics) + } + h.st = resp.State +} + +func (h *harness) update(model any) { + h.t.Helper() + resp := resource.UpdateResponse{State: h.st} + h.res.Update(context.Background(), resource.UpdateRequest{Plan: h.plan(model), State: h.st}, &resp) + if resp.Diagnostics.HasError() { + h.t.Fatalf("update: %v", resp.Diagnostics) + } + h.st = resp.State +} + +func (h *harness) read(model any) (gone bool) { + h.t.Helper() + resp := resource.ReadResponse{State: h.st} + h.res.Read(context.Background(), resource.ReadRequest{State: h.st}, &resp) + if resp.Diagnostics.HasError() { + h.t.Fatalf("read: %v", resp.Diagnostics) + } + if resp.State.Raw.IsNull() { + return true + } + h.st = resp.State + if d := h.st.Get(context.Background(), model); d.HasError() { + h.t.Fatalf("get: %v", d) + } + return false +} + +func (h *harness) delete() { + h.t.Helper() + resp := resource.DeleteResponse{} + h.res.Delete(context.Background(), resource.DeleteRequest{State: h.st}, &resp) + if resp.Diagnostics.HasError() { + h.t.Fatalf("delete: %v", resp.Diagnostics) + } +} + +func fixture(t *testing.T) (*fakehh.Server, *client.Client) { + fs := fakehh.New() + t.Cleanup(fs.Close) + c := client.New(fs.URL, fakehh.ServerKey, fakehh.OrgID) + return fs, c +} + +func TestSiteLifecycle(t *testing.T) { + fs, c := fixture(t) + h := newHarness(t, NewSite(), c) + m := siteModel{Name: types.StringValue("home"), Tags: setOf([]string{"lab"}), ID: types.StringUnknown(), Token: types.StringUnknown()} + h.create(m) + + var got siteModel + h.read(&got) + if got.ID.ValueString() == "" || got.Token.ValueString() == "" || got.Name.ValueString() != "home" || len(got.Tags.Elements()) != 1 { + t.Fatalf("after create: %+v", got) + } + + got.Name, got.Tags = types.StringValue("house"), setOf(nil) + h.update(got) + var again siteModel + h.read(&again) + if again.Name.ValueString() != "house" || len(again.Tags.Elements()) != 0 || again.Token != got.Token { + t.Fatalf("after update: %+v", again) + } + + h.delete() + if len(fs.Sites) != 0 || !h.read(&again) { + t.Fatal("site still exists") + } +} + +func basePort(edge, site string) portModel { + return portModel{ + ID: types.StringUnknown(), Name: types.StringValue("web"), Proto: types.StringValue("https"), EdgeID: types.StringValue(edge), + BindIP: types.StringValue("0.0.0.0"), Port: types.Int64Value(443), PortEnd: types.Int64Value(0), Hostname: types.StringValue("a.example.com"), + Enabled: types.BoolValue(true), Tags: setOf(nil), ProxyProtocol: types.StringValue(""), RedirectTo: types.StringValue(""), + RedirectPermanent: types.BoolValue(false), RedirectKeepPath: types.BoolValue(false), RedirectHTTP: types.BoolValue(true), BackendHTTPS: types.BoolValue(false), + RewriteHost: types.StringValue(""), HealthPath: types.StringValue("/up"), HealthSeconds: types.Int64Value(0), + Targets: mustList(targetType, []targetModel{{types.StringValue(site), types.StringValue("10.0.0.2"), types.Int64Value(80)}}), + Rules: mustList(ruleType, []ruleModel{{types.StringValue("deny"), types.StringValue("ip"), types.StringValue("10.0.0.0/8")}}), + AuthSSO: types.BoolValue(false), AuthEmail: types.BoolValue(false), AuthRoles: setOf(nil), AuthUsers: setOf(nil), AuthMails: setOf(nil), + AuthSessionHours: types.Int64Value(0), Password: types.StringNull(), Pin: types.StringNull(), + } +} + +func mustList(typ types.ObjectType, v any) types.List { + l, d := types.ListValueFrom(context.Background(), typ, v) + if d.HasError() { + panic(d) + } + return l +} + +func TestPortLifecycle(t *testing.T) { + fs, c := fixture(t) + h := newHarness(t, NewPort(), c) + h.create(basePort("e1", "s1")) + + var got portModel + h.read(&got) + if got.ID.ValueString() == "" || got.Port.ValueInt64() != 443 || got.HealthPath.ValueString() != "/up" || !got.RedirectHTTP.ValueBool() { + t.Fatalf("after create: %+v", got) + } + var ts []targetModel + got.Targets.ElementsAs(context.Background(), &ts, false) + if len(ts) != 1 || ts[0].Host.ValueString() != "10.0.0.2" || ts[0].SiteID.ValueString() != "s1" { + t.Fatalf("targets: %+v", ts) + } + if _, has := fs.Mappings[got.ID.ValueString()]["newPassword"]; has { + t.Fatal("no password was configured, none should be sent") + } + + // setting a password sends it once, and flags auth.password so the server keeps it + got.Password = types.StringValue("hunter22") + h.update(got) + stored := fs.Mappings[got.ID.ValueString()] + if stored["newPassword"] != "hunter22" || stored["auth"].(map[string]any)["password"] != true { + t.Fatalf("password not sent: %v", stored) + } + delete(stored, "newPassword") + // an unchanged password is not re-sent (that would sign every visitor out) + got.Enabled = types.BoolValue(false) + h.update(got) + if _, has := stored["newPassword"]; has { + t.Fatal("unchanged password was re-sent") + } + if stored["enabled"] != false { + t.Fatalf("update not applied: %v", stored) + } + // removing it from the configuration removes it on the server + got.Password = types.StringNull() + h.update(got) + if v, has := stored["newPassword"]; !has || v != "" { + t.Fatalf("password not cleared: %v", stored) + } + + h.delete() + if len(fs.Mappings) != 0 { + t.Fatal("port still exists") + } +} + +func TestDomainEdgeAndChannelLifecycle(t *testing.T) { + fs, c := fixture(t) + + e := newHarness(t, NewEdge(), c) + e.create(edgeModel{ID: types.StringUnknown(), Name: types.StringValue("fra"), TunnelAddr: types.StringValue("1.2.3.4:51820"), OrgIDs: setOf(nil), Token: types.StringUnknown(), IPs: types.ListUnknown(types.StringType)}) + var edge edgeModel + e.read(&edge) + if edge.Token.ValueString() == "" { + t.Fatalf("edge token missing: %+v", edge) + } + + d := newHarness(t, NewDomain(), c) + d.create(domainModel{ID: types.StringUnknown(), Name: types.StringValue("example.com"), EdgeID: edge.ID, IP: types.StringValue(""), Note: types.StringValue("")}) + var dom domainModel + d.read(&dom) + if dom.Name.ValueString() != "example.com" || dom.EdgeID != edge.ID { + t.Fatalf("domain: %+v", dom) + } + + ch := newHarness(t, NewAlertChannel(), c) + ch.create(channelModel{ID: types.StringUnknown(), Name: types.StringValue("hook"), Kind: types.StringValue("webhook"), Target: types.StringValue("https://hooks.example.com/secret"), + Format: types.StringValue("json"), Token: types.StringNull(), Policy: types.StringValue(""), AllowPrivate: types.BoolValue(false), Enabled: types.BoolValue(true), Server: types.BoolValue(false)}) + var got channelModel + ch.read(&got) + if got.Target.ValueString() != "https://hooks.example.com/secret" { + t.Fatalf("a webhook URL the server never returns must stay as configured, got %q", got.Target.ValueString()) + } + + ch.delete() + d.delete() + e.delete() + if len(fs.Channels)+len(fs.Domains)+len(fs.Edges) != 0 { + t.Fatal("resources left behind") + } + if !ch.read(&got) { + t.Fatal("deleted channel should read as gone") + } +} diff --git a/internal/provider/site.go b/internal/provider/site.go new file mode 100644 index 0000000..eea2bec --- /dev/null +++ b/internal/provider/site.go @@ -0,0 +1,132 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "git.veillax.com/Veillax/hhctl/client" +) + +var ( + _ resource.Resource = &siteResource{} + _ resource.ResourceWithConfigure = &siteResource{} + _ resource.ResourceWithImportState = &siteResource{} +) + +func NewSite() resource.Resource { return &siteResource{} } + +type siteResource struct{ c *client.Client } + +type siteModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Tags types.Set `tfsdk:"tags"` + Token types.String `tfsdk:"token"` +} + +func (r *siteResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_site" +} + +func (r *siteResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A site: a private network that runs the Bamboo agent and holds the real services.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}}, + "name": schema.StringAttribute{Required: true}, + "tags": schema.SetAttribute{Optional: true, Computed: true, ElementType: types.StringType, Default: setdefault.StaticValue(types.SetValueMust(types.StringType, []attr.Value{})), + Description: "Labels for grouping and filtering in the dashboard."}, + "token": schema.StringAttribute{Computed: true, Sensitive: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Description: "The token to give the Bamboo agent."}, + }, + } +} + +func (r *siteResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { + r.c = clientFrom(req.ProviderData) +} + +func stringsOf(ctx context.Context, s types.Set) []string { + out := []string{} + if !s.IsNull() && !s.IsUnknown() { + s.ElementsAs(ctx, &out, false) + } + return out +} + +func setOf(v []string) types.Set { + elems := make([]attr.Value, 0, len(v)) + for _, s := range v { + elems = append(elems, types.StringValue(s)) + } + return types.SetValueMust(types.StringType, elems) +} + +func (r *siteResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan siteModel + if resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...); resp.Diagnostics.HasError() { + return + } + s, err := r.c.CreateSite(plan.Name.ValueString(), stringsOf(ctx, plan.Tags)) + if s != nil && s.ID != "" { + plan.ID, plan.Token = types.StringValue(s.ID), types.StringValue(s.Token) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) // keep it in state even if tagging failed + } + if err != nil { + resp.Diagnostics.AddError("Creating site", err.Error()) + } +} + +func (r *siteResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state siteModel + if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() { + return + } + s, err := r.c.GetSite(state.ID.ValueString()) + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError("Reading site", err.Error()) + return + } + state.Name, state.Tags = types.StringValue(s.Name), setOf(s.Tags) + if s.Token != "" { + state.Token = types.StringValue(s.Token) + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *siteResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan siteModel + if resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...); resp.Diagnostics.HasError() { + return + } + if err := r.c.UpdateSite(plan.ID.ValueString(), plan.Name.ValueString(), stringsOf(ctx, plan.Tags)); err != nil { + resp.Diagnostics.AddError("Updating site", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *siteResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state siteModel + if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() { + return + } + if err := r.c.DeleteSite(state.ID.ValueString()); err != nil && !client.IsNotFound(err) { + resp.Diagnostics.AddError("Deleting site", err.Error()) + } +} + +func (r *siteResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + importByID(ctx, req, resp) +}