diff --git a/.gitignore b/.gitignore index 27a78f0..b572cf6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -hhctl +/hhctl *.exe .terraform* *.tfstate* diff --git a/cmd/hhctl/main.go b/cmd/hhctl/main.go new file mode 100644 index 0000000..8809cd4 --- /dev/null +++ b/cmd/hhctl/main.go @@ -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] [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 -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] ") + } + 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] ") + } + if err := e.c.TestChannel(*server, fs.Arg(0)); err != nil { + return err + } + fmt.Fprintln(e.out, "test alert sent") + return nil +} diff --git a/cmd/hhctl/main_test.go b/cmd/hhctl/main_test.go new file mode 100644 index 0000000..3804d8f --- /dev/null +++ b/cmd/hhctl/main_test.go @@ -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) + } +}