Track cmd/hhctl; anchor the binary ignore rule

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 17:02:23 -06:00
1 parent bf340cdab4
commit 5be86cbbd3
3 files changed
+678 -1

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
hhctl /hhctl
*.exe *.exe
.terraform* .terraform*
*.tfstate* *.tfstate*
+585
View File
@@ -0,0 +1,585 @@
// Command hhctl manages a Hammerhead server from the command line.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
"text/tabwriter"
"git.veillax.com/Veillax/hhctl/client"
)
const usage = `hhctl manages a Hammerhead server through its API.
Usage:
hhctl [global flags] <resource> <command> [flags]
Resources and commands:
orgs list | create | delete
edges list | create | delete (server key)
sites list | create | delete
ports list | create | delete
domains list | add | delete
alerts list | channels | channel-create | channel-delete | channel-test
Global flags (or environment):
--server URL Hammerhead address (HAMMERHEAD_URL)
--token KEY API key, hhk_... (HAMMERHEAD_TOKEN)
--org ID organization to act in (HAMMERHEAD_ORG; optional with an organization key)
-o FORMAT table (default) or json
Run "hhctl <resource> <command> -h" for a command's flags.
`
type env struct {
out, err io.Writer
c *client.Client
format string
}
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, os.Getenv))
}
func run(args []string, stdout, stderr io.Writer, getenv func(string) string) int {
fs := flag.NewFlagSet("hhctl", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() { fmt.Fprint(stderr, usage) }
server := fs.String("server", getenv("HAMMERHEAD_URL"), "")
token := fs.String("token", getenv("HAMMERHEAD_TOKEN"), "")
org := fs.String("org", getenv("HAMMERHEAD_ORG"), "")
format := fs.String("o", "table", "")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
rest := fs.Args()
if len(rest) < 2 {
fs.Usage()
return 2
}
if *format != "table" && *format != "json" {
fmt.Fprintln(stderr, "hhctl: -o must be table or json")
return 2
}
if *server == "" || *token == "" {
fmt.Fprintln(stderr, "hhctl: set --server and --token (or HAMMERHEAD_URL and HAMMERHEAD_TOKEN)")
return 2
}
e := &env{out: stdout, err: stderr, c: client.New(*server, *token, *org), format: *format}
if err := e.dispatch(rest[0], rest[1], rest[2:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
fmt.Fprintln(stderr, "hhctl:", err)
return 1
}
return 0
}
func (e *env) dispatch(res, cmd string, args []string) error {
// orgs are not org-scoped; everything else needs an organization
if res != "orgs" && !(res == "edges") && !(res == "alerts" && hasFlag(args, "server")) {
if err := e.c.ResolveOrg(); err != nil {
return err
}
}
switch res + " " + cmd {
case "orgs list":
return e.orgsList()
case "orgs create":
return e.orgsCreate(args)
case "orgs delete":
return e.deleteByID("organization", args, e.c.DeleteOrg)
case "edges list":
return e.edgesList()
case "edges create":
return e.edgesCreate(args)
case "edges delete":
return e.deleteByID("edge", args, e.c.DeleteEdge)
case "sites list":
return e.sitesList()
case "sites create":
return e.sitesCreate(args)
case "sites delete":
return e.deleteByID("site", args, e.c.DeleteSite)
case "ports list":
return e.portsList()
case "ports create":
return e.portsCreate(args)
case "ports delete":
return e.deleteByID("port", args, e.c.DeletePort)
case "domains list":
return e.domainsList()
case "domains add":
return e.domainsAdd(args)
case "domains delete":
return e.deleteByID("domain", args, e.c.DeleteDomain)
case "alerts list":
return e.alertsList(args)
case "alerts channels":
return e.channelsList(args)
case "alerts channel-create":
return e.channelCreate(args)
case "alerts channel-delete":
return e.channelDelete(args)
case "alerts channel-test":
return e.channelTest(args)
}
return fmt.Errorf("unknown command %q %q (see hhctl -h)", res, cmd)
}
func hasFlag(args []string, name string) bool {
for _, a := range args {
if a == "-"+name || a == "--"+name || strings.HasPrefix(a, "-"+name+"=") || strings.HasPrefix(a, "--"+name+"=") {
return true
}
}
return false
}
// ---- output helpers ----
func (e *env) json(v any) error {
enc := json.NewEncoder(e.out)
enc.SetIndent("", " ")
return enc.Encode(v)
}
// table prints rows, or v as JSON with -o json.
func (e *env) table(v any, header []string, rows [][]string) error {
if e.format == "json" {
return e.json(v)
}
tw := tabwriter.NewWriter(e.out, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, strings.Join(header, "\t"))
for _, r := range rows {
fmt.Fprintln(tw, strings.Join(r, "\t"))
}
return tw.Flush()
}
func (e *env) created(v any, what, id string, extra ...string) error {
if e.format == "json" {
return e.json(v)
}
fmt.Fprintf(e.out, "created %s %s\n", what, id)
for i := 0; i+1 < len(extra); i += 2 {
fmt.Fprintf(e.out, "%s: %s\n", extra[i], extra[i+1])
}
return nil
}
func (e *env) flags(name string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(e.err)
return fs
}
func (e *env) deleteByID(what string, args []string, del func(string) error) error {
fs := e.flags("delete")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
return fmt.Errorf("usage: delete <%s id>", what)
}
if err := del(fs.Arg(0)); err != nil {
return err
}
fmt.Fprintf(e.out, "deleted %s %s\n", what, fs.Arg(0))
return nil
}
// multi collects a repeatable string flag.
type multi []string
func (m *multi) String() string { return strings.Join(*m, ",") }
func (m *multi) Set(s string) error { *m = append(*m, s); return nil }
func yn(b bool) string {
if b {
return "yes"
}
return "no"
}
// ---- orgs ----
func (e *env) orgsList() error {
orgs, err := e.c.Orgs()
if err != nil {
return err
}
rows := [][]string{}
for _, o := range orgs {
rows = append(rows, []string{o.ID, o.Name})
}
return e.table(orgs, []string{"ID", "NAME"}, rows)
}
func (e *env) orgsCreate(args []string) error {
fs := e.flags("orgs create")
name := fs.String("name", "", "organization name")
local := fs.Bool("allow-local", false, "let the organization use services on edge machines and private webhook targets")
if err := fs.Parse(args); err != nil {
return err
}
if *name == "" {
return errors.New("--name is required")
}
o, err := e.c.CreateOrg(*name, *local)
if err != nil {
return err
}
return e.created(o, "organization", o.ID)
}
// ---- edges ----
func (e *env) edgesList() error {
edges, err := e.c.Edges()
if err != nil {
return err
}
rows := [][]string{}
for _, x := range edges {
rows = append(rows, []string{x.ID, x.Name, x.TunnelAddr, strings.Join(x.IPs, ","), yn(x.Online), x.Version})
}
return e.table(edges, []string{"ID", "NAME", "TUNNEL", "IPS", "ONLINE", "VERSION"}, rows)
}
func (e *env) edgesCreate(args []string) error {
fs := e.flags("edges create")
name := fs.String("name", "", "edge name")
addr := fs.String("tunnel", "", "public host:port of the edge's WireGuard (UDP) endpoint")
var orgs multi
fs.Var(&orgs, "allow-org", "organization allowed to use the edge (repeatable; none = all)")
if err := fs.Parse(args); err != nil {
return err
}
if *name == "" || *addr == "" {
return errors.New("--name and --tunnel are required")
}
x, err := e.c.CreateEdge(client.Edge{Name: *name, TunnelAddr: *addr, OrgIDs: orgs})
if err != nil {
return err
}
return e.created(x, "edge", x.ID, "token", x.Token)
}
// ---- sites ----
func (e *env) sitesList() error {
sites, err := e.c.Sites()
if err != nil {
return err
}
rows := [][]string{}
for _, s := range sites {
rows = append(rows, []string{s.ID, s.Name, strings.Join(s.Tags, ","), strconv.Itoa(len(s.Connected)), s.Version})
}
return e.table(sites, []string{"ID", "NAME", "TAGS", "EDGES", "VERSION"}, rows)
}
func (e *env) sitesCreate(args []string) error {
fs := e.flags("sites create")
name := fs.String("name", "", "site name")
var tags multi
fs.Var(&tags, "tag", "tag (repeatable)")
if err := fs.Parse(args); err != nil {
return err
}
if *name == "" {
return errors.New("--name is required")
}
s, err := e.c.CreateSite(*name, tags)
if err != nil {
return err
}
return e.created(s, "site", s.ID, "token", s.Token)
}
// ---- ports ----
func (e *env) portsList() error {
ports, err := e.c.Ports()
if err != nil {
return err
}
rows := [][]string{}
for _, p := range ports {
addr := strconv.Itoa(p.Port)
if p.PortEnd > p.Port {
addr += "-" + strconv.Itoa(p.PortEnd)
}
if p.Hostname != "" {
addr = p.Hostname + ":" + addr
}
dest := p.RedirectTo
for _, t := range p.Targets {
if dest != "" {
dest += ","
}
dest += fmt.Sprintf("%s:%d", t.Host, t.Port)
}
rows = append(rows, []string{p.ID, p.Name, p.Proto, addr, dest, yn(p.Enabled)})
}
return e.table(ports, []string{"ID", "NAME", "PROTO", "PUBLIC", "TARGET", "ENABLED"}, rows)
}
func (e *env) portsCreate(args []string) error {
fs := e.flags("ports create")
file := fs.String("f", "", "read the full port definition from a JSON file (- for stdin); other flags are ignored")
name := fs.String("name", "", "name")
proto := fs.String("proto", "tcp", "tcp, udp, http, https or tls")
edge := fs.String("edge", "", "edge ID or name")
bind := fs.String("bind-ip", "0.0.0.0", "address on the edge to listen on")
port := fs.Int("port", 0, "public port")
portEnd := fs.Int("port-end", 0, "last port of a tcp/udp range")
host := fs.String("hostname", "", "hostname for http/https/tls")
redirect := fs.String("redirect-to", "", "answer with a redirect instead of forwarding (http/https)")
disabled := fs.Bool("disabled", false, "create the port switched off")
var targets, tags multi
fs.Var(&targets, "target", "backend as site:host:port; site is an ID or name (repeatable)")
fs.Var(&tags, "tag", "tag (repeatable)")
if err := fs.Parse(args); err != nil {
return err
}
var p client.Port
if *file != "" {
var r io.Reader = os.Stdin
if *file != "-" {
f, err := os.Open(*file)
if err != nil {
return err
}
defer f.Close()
r = f
}
if err := json.NewDecoder(r).Decode(&p); err != nil {
return fmt.Errorf("reading %s: %w", *file, err)
}
} else {
if *name == "" || *edge == "" || *port == 0 {
return errors.New("--name, --edge and --port are required (or use -f)")
}
st, err := e.c.State()
if err != nil {
return err
}
edgeID, err := pick("edge", *edge, edgeRefs(st))
if err != nil {
return err
}
p = client.Port{Name: *name, Proto: *proto, EdgeID: edgeID, BindIP: *bind, Port: *port, PortEnd: *portEnd,
Hostname: *host, RedirectTo: *redirect, Enabled: !*disabled, Tags: tags}
for _, t := range targets {
parts := strings.Split(t, ":")
if len(parts) != 3 {
return fmt.Errorf("--target %q: want site:host:port", t)
}
n, err := strconv.Atoi(parts[2])
if err != nil {
return fmt.Errorf("--target %q: bad port", t)
}
siteID, err := pick("site", parts[0], siteRefs(st))
if err != nil {
return err
}
p.Targets = append(p.Targets, client.Target{SiteID: siteID, Host: parts[1], Port: n})
}
}
if p.Targets == nil {
p.Targets = []client.Target{}
}
if p.Rules == nil {
p.Rules = []client.Rule{}
}
got, err := e.c.CreatePort(p)
if err != nil {
return err
}
return e.created(got, "port", got.ID)
}
type ref struct{ id, name string }
func edgeRefs(st *client.State) (r []ref) {
for _, x := range st.Edges {
r = append(r, ref{x.ID, x.Name})
}
return
}
func siteRefs(st *client.State) (r []ref) {
for _, x := range st.Sites {
r = append(r, ref{x.ID, x.Name})
}
return
}
// pick resolves an ID or a unique name.
func pick(what, want string, refs []ref) (string, error) {
found := ""
for _, r := range refs {
if r.id == want {
return r.id, nil
}
if r.name == want {
if found != "" {
return "", fmt.Errorf("more than one %s is named %q; use its ID", what, want)
}
found = r.id
}
}
if found == "" {
return "", fmt.Errorf("no %s with ID or name %q", what, want)
}
return found, nil
}
// ---- domains ----
func (e *env) domainsList() error {
ds, err := e.c.Domains()
if err != nil {
return err
}
rows := [][]string{}
for _, d := range ds {
rows = append(rows, []string{d.ID, d.Name, d.EdgeID, d.IP, d.Note})
}
return e.table(ds, []string{"ID", "NAME", "EDGE", "IP", "NOTE"}, rows)
}
func (e *env) domainsAdd(args []string) error {
fs := e.flags("domains add")
name := fs.String("name", "", "domain, like example.com")
edge := fs.String("edge", "", "edge ID or name the domain points at")
ip := fs.String("ip", "", "public IP on that edge")
note := fs.String("note", "", "note")
if err := fs.Parse(args); err != nil {
return err
}
if *name == "" {
return errors.New("--name is required")
}
d := client.Domain{Name: *name, IP: *ip, Note: *note}
if *edge != "" {
st, err := e.c.State()
if err != nil {
return err
}
if d.EdgeID, err = pick("edge", *edge, edgeRefs(st)); err != nil {
return err
}
}
got, err := e.c.CreateDomain(d)
if err != nil {
return err
}
return e.created(got, "domain", got.ID)
}
// ---- alerts ----
func (e *env) alertsList(args []string) error {
fs := e.flags("alerts list")
server := fs.Bool("server", false, "server-level alerts (needs a server key)")
if err := fs.Parse(args); err != nil {
return err
}
a, err := e.c.Alerts(*server)
if err != nil {
return err
}
rows := [][]string{}
for _, r := range a.Active {
rows = append(rows, []string{r.Kind, r.Name, r.Message, r.Since.Format("2006-01-02 15:04"), yn(r.Fired)})
}
return e.table(a.Active, []string{"KIND", "NAME", "MESSAGE", "SINCE", "NOTIFIED"}, rows)
}
func (e *env) channelsList(args []string) error {
fs := e.flags("alerts channels")
server := fs.Bool("server", false, "server-level channels (needs a server key)")
if err := fs.Parse(args); err != nil {
return err
}
a, err := e.c.Alerts(*server)
if err != nil {
return err
}
rows := [][]string{}
for _, c := range a.Channels {
rows = append(rows, []string{c.ID, c.Name, c.Kind, c.Target, yn(c.Enabled)})
}
return e.table(a.Channels, []string{"ID", "NAME", "KIND", "TARGET", "ENABLED"}, rows)
}
func (e *env) channelCreate(args []string) error {
fs := e.flags("alerts channel-create")
server := fs.Bool("server", false, "create a server-level channel")
name := fs.String("name", "", "name")
kind := fs.String("kind", "", "email, webhook or sandbar")
target := fs.String("target", "", "email address, webhook URL or Sandbar address")
format := fs.String("format", "", "webhook body: json, slack, discord or ntfy")
token := fs.String("token", "", "Sandbar API key")
policy := fs.String("policy", "", "Sandbar escalation policy")
private := fs.Bool("allow-private", false, "allow a webhook target on a private address")
disabled := fs.Bool("disabled", false, "create the channel switched off")
if err := fs.Parse(args); err != nil {
return err
}
if *name == "" || *kind == "" || *target == "" {
return errors.New("--name, --kind and --target are required")
}
ch := client.Channel{Name: *name, Kind: *kind, Target: *target, Format: *format, Token: *token, Policy: *policy, AllowPrivate: *private, Enabled: !*disabled}
id, err := e.c.CreateChannel(*server, ch)
if err != nil {
return err
}
ch.ID, ch.Token, ch.Target = id, "", ""
return e.created(ch, "channel", id)
}
func (e *env) channelDelete(args []string) error {
fs := e.flags("alerts channel-delete")
server := fs.Bool("server", false, "server-level channel")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
return errors.New("usage: alerts channel-delete [--server] <channel id>")
}
if err := e.c.DeleteChannel(*server, fs.Arg(0)); err != nil {
return err
}
fmt.Fprintf(e.out, "deleted channel %s\n", fs.Arg(0))
return nil
}
func (e *env) channelTest(args []string) error {
fs := e.flags("alerts channel-test")
server := fs.Bool("server", false, "server-level channel")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
return errors.New("usage: alerts channel-test [--server] <channel id>")
}
if err := e.c.TestChannel(*server, fs.Arg(0)); err != nil {
return err
}
fmt.Fprintln(e.out, "test alert sent")
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"bytes"
"encoding/json"
"strings"
"testing"
"git.veillax.com/Veillax/hhctl/internal/fakehh"
)
type runner struct {
t *testing.T
url string
key string
}
func (r runner) run(args ...string) (int, string, string) {
var out, errb bytes.Buffer
env := map[string]string{"HAMMERHEAD_URL": r.url, "HAMMERHEAD_TOKEN": r.key}
code := run(args, &out, &errb, func(k string) string { return env[k] })
return code, out.String(), errb.String()
}
func (r runner) ok(args ...string) string {
r.t.Helper()
code, out, errs := r.run(args...)
if code != 0 {
r.t.Fatalf("hhctl %v: exit %d: %s", args, code, errs)
}
return out
}
func TestCLICreateListDelete(t *testing.T) {
fs := fakehh.New()
defer fs.Close()
r := runner{t, fs.URL, fakehh.ServerKey}
if out := r.ok("orgs", "list"); !strings.Contains(out, "Acme") {
t.Fatalf("orgs: %s", out)
}
if out := r.ok("edges", "create", "--name", "fra", "--tunnel", "1.2.3.4:51820"); !strings.Contains(out, "token: edge-token-") {
t.Fatalf("edge create: %s", out)
}
var site struct{ ID, Token string }
if err := json.Unmarshal([]byte(r.ok("-o", "json", "sites", "create", "--name", "home", "--tag", "lab")), &site); err != nil || site.ID == "" {
t.Fatalf("site create: %v %+v", err, site)
}
// names resolve to IDs
var port struct {
ID string
Targets []struct{ SiteID string }
EdgeID string
}
out := r.ok("-o", "json", "ports", "create", "--name", "web", "--proto", "https", "--edge", "fra", "--port", "443",
"--hostname", "a.example.com", "--target", "home:10.0.0.2:80")
if err := json.Unmarshal([]byte(out), &port); err != nil || port.Targets[0].SiteID != site.ID || port.EdgeID == "" || port.EdgeID == "fra" {
t.Fatalf("port create: %v %s", err, out)
}
if out := r.ok("ports", "list"); !strings.Contains(out, "a.example.com:443") || !strings.Contains(out, "10.0.0.2:80") {
t.Fatalf("ports list: %s", out)
}
r.ok("domains", "add", "--name", "example.com", "--edge", "fra")
r.ok("alerts", "channel-create", "--name", "ops", "--kind", "email", "--target", "ops@example.com")
if out := r.ok("alerts", "channels"); !strings.Contains(out, "ops@example.com") {
t.Fatalf("channels: %s", out)
}
r.ok("ports", "delete", port.ID)
r.ok("sites", "delete", site.ID)
if out := r.ok("sites", "list"); strings.Contains(out, "home") {
t.Fatalf("site not deleted: %s", out)
}
}
func TestCLIErrors(t *testing.T) {
fs := fakehh.New()
defer fs.Close()
r := runner{t, fs.URL, fakehh.OrgKey}
if code, _, errs := r.run("ports", "create", "--name", "x", "--edge", "nope", "--port", "1"); code != 1 || !strings.Contains(errs, `no edge with ID or name "nope"`) {
t.Fatalf("unknown edge: %d %q", code, errs)
}
if code, _, errs := r.run("edges", "create", "--name", "x", "--tunnel", "a:1"); code != 1 || !strings.Contains(errs, "401") {
t.Fatalf("org key edge create: %d %q", code, errs)
}
if code, _, _ := (runner{t, fs.URL, ""}).run("sites", "list"); code != 2 {
t.Fatalf("missing token should be a usage error, got %d", code)
}
if code, _, _ := r.run("bogus", "list"); code != 1 {
t.Fatalf("unknown resource: %d", code)
}
}