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,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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func notFound(what string) error { return &APIError{http.StatusNotFound, "no such " + what} }
|
||||
|
||||
// ---- organizations ----
|
||||
|
||||
func (c *Client) Orgs() ([]Org, error) {
|
||||
var out []Org
|
||||
return out, c.do("GET", "/api/orgs", nil, &out)
|
||||
}
|
||||
|
||||
// CreateOrg needs a server key.
|
||||
func (c *Client) CreateOrg(name string, allowLocal bool) (*Org, error) {
|
||||
var out Org
|
||||
err := c.do("POST", "/api/orgs", map[string]any{"name": name, "allowLocal": allowLocal}, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) DeleteOrg(id string) error { return c.do("DELETE", "/api/orgs/"+esc(id), nil, nil) }
|
||||
|
||||
func (c *Client) State() (*State, error) {
|
||||
p, err := c.orgPath("/state")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out State
|
||||
return &out, c.do("GET", p, nil, &out)
|
||||
}
|
||||
|
||||
// ---- edges ----
|
||||
|
||||
func (c *Client) Edges() ([]Edge, error) {
|
||||
var out []Edge
|
||||
return out, c.do("GET", "/api/edges", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) GetEdge(id string) (*Edge, error) {
|
||||
edges, err := c.Edges()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range edges {
|
||||
if edges[i].ID == id {
|
||||
return &edges[i], nil
|
||||
}
|
||||
}
|
||||
return nil, notFound("edge")
|
||||
}
|
||||
|
||||
// CreateEdge needs a server key. The result carries the edge's token.
|
||||
func (c *Client) CreateEdge(e Edge) (*Edge, error) {
|
||||
var out Edge
|
||||
err := c.do("POST", "/api/edges", e, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) UpdateEdge(e Edge) (*Edge, error) {
|
||||
var out Edge
|
||||
err := c.do("PUT", "/api/edges/"+esc(e.ID), e, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) DeleteEdge(id string) error { return c.do("DELETE", "/api/edges/"+esc(id), nil, nil) }
|
||||
|
||||
// ---- sites ----
|
||||
|
||||
func (c *Client) Sites() ([]Site, error) {
|
||||
st, err := c.State()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st.Sites, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetSite(id string) (*Site, error) {
|
||||
sites, err := c.Sites()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range sites {
|
||||
if sites[i].ID == id {
|
||||
return &sites[i], nil
|
||||
}
|
||||
}
|
||||
return nil, notFound("site")
|
||||
}
|
||||
|
||||
// CreateSite returns the site including its agent token.
|
||||
func (c *Client) CreateSite(name string, tags []string) (*Site, error) {
|
||||
p, err := c.orgPath("/sites")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out Site
|
||||
if err := c.do("POST", p, map[string]string{"name": name}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
if err := c.UpdateSite(out.ID, name, tags); err != nil {
|
||||
return &out, err
|
||||
}
|
||||
out.Tags = tags
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateSite(id, name string, tags []string) error {
|
||||
p, err := c.orgPath("/sites/%s", esc(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
return c.do("PUT", p, map[string]any{"name": name, "tags": tags}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteSite(id string) error {
|
||||
p, err := c.orgPath("/sites/%s", esc(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do("DELETE", p, nil, nil)
|
||||
}
|
||||
|
||||
// ---- domains ----
|
||||
|
||||
func (c *Client) Domains() ([]Domain, error) {
|
||||
st, err := c.State()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st.Domains, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetDomain(id string) (*Domain, error) {
|
||||
ds, err := c.Domains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range ds {
|
||||
if ds[i].ID == id {
|
||||
return &ds[i], nil
|
||||
}
|
||||
}
|
||||
return nil, notFound("domain")
|
||||
}
|
||||
|
||||
func (c *Client) CreateDomain(d Domain) (*Domain, error) {
|
||||
p, err := c.orgPath("/domains")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out Domain
|
||||
return &out, c.do("POST", p, d, &out)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteDomain(id string) error {
|
||||
p, err := c.orgPath("/domains/%s", esc(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do("DELETE", p, nil, nil)
|
||||
}
|
||||
|
||||
// ---- ports ----
|
||||
|
||||
func (c *Client) Ports() ([]Port, error) {
|
||||
st, err := c.State()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st.Mappings, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetPort(id string) (*Port, error) {
|
||||
ps, err := c.Ports()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range ps {
|
||||
if ps[i].ID == id {
|
||||
return &ps[i], nil
|
||||
}
|
||||
}
|
||||
return nil, notFound("port")
|
||||
}
|
||||
|
||||
func (c *Client) CreatePort(pt Port) (*Port, error) {
|
||||
p, err := c.orgPath("/mappings")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out Port
|
||||
return &out, c.do("POST", p, pt, &out)
|
||||
}
|
||||
|
||||
// UpdatePort replaces the port; every field is overwritten.
|
||||
func (c *Client) UpdatePort(pt Port) (*Port, error) {
|
||||
p, err := c.orgPath("/mappings/%s", esc(pt.ID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out Port
|
||||
return &out, c.do("PUT", p, pt, &out)
|
||||
}
|
||||
|
||||
func (c *Client) DeletePort(id string) error {
|
||||
p, err := c.orgPath("/mappings/%s", esc(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do("DELETE", p, nil, nil)
|
||||
}
|
||||
|
||||
// ---- alert channels (the organization's, or with server=true the server-level ones) ----
|
||||
|
||||
func (c *Client) alertPath(server bool, format string, args ...any) (string, error) {
|
||||
if server {
|
||||
return "/api/admin/alerts" + fmt.Sprintf(format, args...), nil
|
||||
}
|
||||
return c.orgPath("/alerts"+format, args...)
|
||||
}
|
||||
|
||||
func (c *Client) Alerts(server bool) (*Alerts, error) {
|
||||
p, err := c.alertPath(server, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out Alerts
|
||||
return &out, c.do("GET", p, nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) CreateChannel(server bool, ch Channel) (string, error) {
|
||||
p, err := c.alertPath(server, "/channels")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
err = c.do("POST", p, ch, &out)
|
||||
return out.ID, err
|
||||
}
|
||||
|
||||
func (c *Client) UpdateChannel(server bool, ch Channel) error {
|
||||
p, err := c.alertPath(server, "/channels/%s", esc(ch.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do("PUT", p, ch, nil)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteChannel(server bool, id string) error {
|
||||
p, err := c.alertPath(server, "/channels/%s", esc(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do("DELETE", p, nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) TestChannel(server bool, id string) error {
|
||||
p, err := c.alertPath(server, "/channels/%s/test", esc(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do("POST", p, nil, nil)
|
||||
}
|
||||
|
||||
// CreateOrgKey returns the key's id and its secret token (shown once).
|
||||
func (c *Client) CreateOrgKey(name string) (id, key string, err error) {
|
||||
p, err := c.orgPath("/apikeys")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
err = c.do("POST", p, map[string]string{"name": name}, &out)
|
||||
return out.ID, out.Key, err
|
||||
}
|
||||
+134
@@ -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"`
|
||||
}
|
||||
Reference in new issue
Block a user