hhctl CLI and Terraform provider for Hammerhead
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bf340cdab4
19 files changed
+2442
No files matched your search
@@ -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"})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user