diff --git a/main.go b/main.go
index a9e9989..437acf0 100644
--- a/main.go
+++ b/main.go
@@ -36,6 +36,7 @@ import (
"gitea.drjosh.dev/josh/jrouter/aurp"
"gitea.drjosh.dev/josh/jrouter/router"
+ "gitea.drjosh.dev/josh/jrouter/status"
"github.com/google/gopacket/pcap"
"github.com/sfiera/multitalk/pkg/ddp"
@@ -102,7 +103,13 @@ func main() {
defer cancel()
ctx, _ := signal.NotifyContext(cctx, os.Interrupt)
- // Open PCAP session
+ // --------------------------------- HTTP ---------------------------------
+ http.HandleFunc("/status", status.Handle)
+ go func() {
+ log.Print(http.ListenAndServe(":9459", nil))
+ }()
+
+ // --------------------------------- Pcap ---------------------------------
// First check the interface
iface, err := net.InterfaceByName(cfg.EtherTalk.Device)
if err != nil {
@@ -129,6 +136,12 @@ func main() {
}
defer pcapHandle.Close()
+ // -------------------------------- Tables --------------------------------
+ routes := router.NewRoutingTable()
+ zones := router.NewZoneTable()
+ zones.Upsert(cfg.EtherTalk.NetStart, cfg.EtherTalk.ZoneName, true)
+
+ // -------------------------------- Peers ---------------------------------
// Wait until all peer handlers have finished before closing the port
var handlersWG sync.WaitGroup
defer func() {
@@ -144,11 +157,6 @@ func main() {
}()
}
- // -------------------------------- Tables --------------------------------
- routes := router.NewRoutingTable()
- zones := router.NewZoneTable()
- zones.Upsert(cfg.EtherTalk.NetStart, cfg.EtherTalk.ZoneName, true)
-
// ------------------------- Configured peer setup ------------------------
if cfg.PeerListURL != "" {
log.Printf("Fetching peer list from %s...", cfg.PeerListURL)
diff --git a/status/status.go b/status/status.go
new file mode 100644
index 0000000..bd12e70
--- /dev/null
+++ b/status/status.go
@@ -0,0 +1,303 @@
+// Package status provides a status page handler, for exposing a summary of what
+// the various pieces of the agent are doing.
+//
+// Inspired heavily by Google "/statsuz" - one public example is at:
+// https://github.com/youtube/doorman/blob/master/go/status/status.go
+package status
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "net/http"
+ "os"
+ "os/user"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+)
+
+const errorTmplSrc = `
❌ {{.Operation}}:
{{.Error}}
+Raw item data:
+
{{.Item | printJSON}}
+
`
+
+var (
+ //go:embed status.html.tmpl
+ statusTmplSrc string
+
+ // Errors ignored below, as the status page is "best effort".
+ hostname, _ = os.Hostname()
+ username = func() string {
+ user, err := user.Current()
+ if err != nil {
+ return fmt.Sprintf("unknown (uid=unknown; error=%v)", err)
+ }
+ return fmt.Sprintf("%s (uid=%s)", user.Username, user.Uid)
+ }()
+ exepath, _ = os.Executable()
+ startTime = time.Now()
+
+ rootItem = &simpleItem{
+ baseItem: baseItem{
+ items: make(map[string]item),
+ },
+ }
+
+ funcMap = template.FuncMap{
+ "printJSON": printJSON,
+ }
+
+ // The inbuilt templates should always parse. Rather than use template.Must,
+ // successful parsing is enforced by the smoke tests.
+ statusTmpl, _ = template.New("status").Funcs(funcMap).Parse(statusTmplSrc)
+ errorTmpl, _ = template.New("item-error").Funcs(funcMap).Parse(errorTmplSrc)
+)
+
+type statusData struct {
+ Items map[string]item
+ Version string
+ Build string
+ Hostname string
+ Username string
+ ExePath string
+ PID int
+ Compiler string
+ RuntimeVer string
+ GOOS string
+ GOARCH string
+ NumCPU int
+ NumGoroutine int
+ StartTime string
+ StartTimeAgo time.Duration
+ CurrentTime string
+ Ctx context.Context // request context for Eval calls inside the template execution only
+}
+
+type errorData struct {
+ Operation string
+ Error error
+ Item any
+}
+
+type item interface {
+ addSubItem(string, item)
+ delSubItem(string)
+
+ Eval(context.Context) template.HTML
+ Items() map[string]item
+}
+
+type itemCtxKey struct{}
+
+func parentItem(ctx context.Context) item {
+ v := ctx.Value(itemCtxKey{})
+ if v == nil {
+ return rootItem
+ }
+ return v.(item)
+}
+
+type baseItem struct {
+ mu sync.RWMutex
+ items map[string]item
+}
+
+func (i *baseItem) addSubItem(title string, sub item) {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ i.items[title] = sub
+}
+
+func (i *baseItem) delSubItem(title string) {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ delete(i.items, title)
+}
+
+func (i *baseItem) Items() map[string]item {
+ i.mu.RLock()
+ defer i.mu.RUnlock()
+ icopy := make(map[string]item, len(i.items))
+ for k, v := range i.items {
+ icopy[k] = v
+ }
+ return icopy
+}
+
+// SimpleItem is for untemplated status items that only report a simple non-HTML string.
+type simpleItem struct {
+ baseItem
+ stat string
+}
+
+// SetStatus sets the status of the item.
+func (i *simpleItem) setStatus(s string) {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ i.stat = s
+}
+
+// Eval escapes the status string, and returns the current item value.
+func (i *simpleItem) Eval(ctx context.Context) template.HTML {
+ i.mu.RLock()
+ defer i.mu.RUnlock()
+ return template.HTML(template.HTMLEscapeString(i.stat))
+}
+
+// ItemCallback funcs are used by templated status items to provide the value to
+// hydrate the item template.
+type ItemCallback = func(context.Context) (any, error)
+
+// templatedItem uses a template to format the item.
+type templatedItem struct {
+ baseItem
+ tmpl *template.Template
+ cb ItemCallback
+}
+
+// Eval calls the item callback, and feeds the result through the item's
+// own template.
+func (i *templatedItem) Eval(ctx context.Context) template.HTML {
+ var sb strings.Builder
+
+ data, err := i.cb(ctx)
+ if err != nil {
+ errorTmpl.Execute(&sb, errorData{
+ Operation: "Error from item callback",
+ Error: err,
+ Item: data,
+ })
+ return template.HTML(sb.String())
+ }
+ if err := i.tmpl.Execute(&sb, data); err != nil {
+ errorTmpl.Execute(&sb, errorData{
+ Operation: "Error while executing item template",
+ Error: err,
+ Item: data,
+ })
+ }
+ return template.HTML(sb.String())
+}
+
+// Handle handles status page requests.
+func Handle(w http.ResponseWriter, r *http.Request) {
+ data := &statusData{
+ Items: rootItem.items,
+ Version: "TODO",
+ Build: "TODO",
+ Hostname: hostname,
+ Username: username,
+ ExePath: exepath,
+ PID: os.Getpid(),
+ Compiler: runtime.Compiler,
+ RuntimeVer: runtime.Version(),
+ GOOS: runtime.GOOS,
+ GOARCH: runtime.GOARCH,
+ NumCPU: runtime.NumCPU(),
+ NumGoroutine: runtime.NumGoroutine(),
+ StartTime: startTime.Format(time.RFC1123),
+ StartTimeAgo: time.Since(startTime),
+ CurrentTime: time.Now().Format(time.RFC1123),
+ Ctx: r.Context(),
+ }
+
+ // The status template ranges over the items.
+ rootItem.mu.RLock()
+ defer rootItem.mu.RUnlock()
+ if err := statusTmpl.Execute(w, data); err != nil {
+ errorTmpl.Execute(w, errorData{
+ Operation: "Error while executing main template",
+ Error: err,
+ Item: data,
+ })
+ }
+}
+
+// AddItem adds an item to be displayed on the status page. On each page load,
+// the item's callback is called, and the data returned used to fill the
+// HTML template in tmpl. The title should be unique (among items under this
+// parent.
+func AddItem(parent context.Context, title, tmpl string, cb func(context.Context) (any, error)) (context.Context, func()) {
+ if cb == nil {
+ cb = func(context.Context) (any, error) { return nil, nil }
+ }
+
+ item := &templatedItem{
+ baseItem: baseItem{
+ items: make(map[string]item),
+ },
+ tmpl: template.New(title).Funcs(funcMap),
+ cb: cb,
+ }
+
+ if _, err := item.tmpl.Parse(tmpl); err != nil {
+ // Insert an item, but swap the template for the error template, and
+ // wrap the callback's return in an errorData.
+ item.tmpl = errorTmpl
+ item.cb = wrapInItemError("Could not parse item template", err, cb)
+ }
+
+ pitem := parentItem(parent)
+ pitem.addSubItem(title, item)
+
+ return context.WithValue(parent, itemCtxKey{}, item), func() { pitem.delSubItem(title) }
+}
+
+// AddSimpleItem adds a simple status item. Set the value shown by the
+// item by calling setStatus.
+func AddSimpleItem(parent context.Context, title string) (ctx context.Context, setStatus func(string), done func()) {
+ item := &simpleItem{
+ baseItem: baseItem{
+ items: make(map[string]item),
+ },
+ stat: "Unknown status",
+ }
+ pitem := parentItem(parent)
+ pitem.addSubItem(title, item)
+
+ return context.WithValue(parent, itemCtxKey{}, item), item.setStatus, func() { pitem.delSubItem(title) }
+}
+
+// DelItem removes a status item, specified by the title, from a parent context.
+func DelItem(parent context.Context, title string) {
+ pitem := item(rootItem)
+ if v := parent.Value(itemCtxKey{}); v != nil {
+ pitem = v.(item)
+ }
+
+ pitem.delSubItem(title)
+}
+
+// wrapInItemError takes a callback and returns a new callback wrapping the
+// result in an errorData.
+func wrapInItemError(op string, err error, cb ItemCallback) ItemCallback {
+ return func(ctx context.Context) (any, error) {
+ if cb == nil {
+ return &errorData{
+ Operation: op,
+ Error: err,
+ }, nil
+ }
+ // Surface item callback errors first.
+ data, err2 := cb(ctx)
+ if err2 != nil {
+ return data, err2
+ }
+ // Surface the item template parse error.
+ return &errorData{
+ Operation: op,
+ Error: err,
+ Item: data,
+ }, nil
+ }
+}
+
+// printJSON is used as a fallback renderer for item status values.
+func printJSON(v any) (string, error) {
+ b, err := json.MarshalIndent(v, "", " ")
+ return string(b), err
+}
diff --git a/status/status.html.tmpl b/status/status.html.tmpl
new file mode 100644
index 0000000..b73d936
--- /dev/null
+++ b/status/status.html.tmpl
@@ -0,0 +1,81 @@
+{{- define "render_items" -}}
+ {{- range $title, $item := . -}}
+
+ {{$title}}
+ {{$item.Eval $.Ctx}}
+ {{template "render_items" $item.Items}}
+
+ {{- end -}}
+{{- end -}}
+
+
+
+ Status for buildkite-agent
+
+
+
+ Status for buildkite-agent
+
+ ⚠️ This page is intended only for debugging purposes!
+ Do not expect the structure and formatting of this page to be stable across versions.
+
+
+ {{.ExePath}}
+ Started at {{.StartTime}} ({{.StartTimeAgo}} ago)
+ Current time {{.CurrentTime}}
+ Version {{.Version}}, build {{.Build}}
+ Running in PID {{.PID}} as {{.Username}} on {{.Hostname}}
+ {{.RuntimeVer}} on {{.GOOS}}/{{.GOARCH}} compiled by {{.Compiler}}
+ {{.NumGoroutine}} goroutines running/{{.NumCPU}} logical CPUs usable
+
+ {{template "render_items" .Items}}
+
+
diff --git a/status/status_test.go b/status/status_test.go
new file mode 100644
index 0000000..4fda0e2
--- /dev/null
+++ b/status/status_test.go
@@ -0,0 +1,88 @@
+package status
+
+import (
+ "context"
+ "errors"
+ "html/template"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "runtime"
+ "testing"
+ "time"
+)
+
+func TestSmokeErrorTemplate(t *testing.T) {
+ errData := &errorData{
+ Operation: "Couldn't fluff the llamas",
+ Error: errors.New("llama comb unavailable"),
+ Item: map[string]any{
+ "llamas": "yes",
+ "alpacas": 42,
+ },
+ }
+
+ if err := errorTmpl.Execute(io.Discard, errData); err != nil {
+ t.Errorf("errorTmpl.Execute(io.Discard, errData) = %v", err)
+ }
+}
+
+func TestSmokeStatusTemplate(t *testing.T) {
+ data := &statusData{
+ Items: map[string]item{
+ "Llamas": &simpleItem{
+ stat: "✅ Llamas enabled",
+ },
+ "Alpacas": &templatedItem{
+ tmpl: template.Must(template.New("alpacas").Parse("Alpacas enabled at: {{.AlpacasEnabled}}")),
+ cb: func(context.Context) (any, error) {
+ return struct {
+ AlpacasEnabled time.Time
+ }{time.Now()}, nil
+ },
+ },
+ },
+ Version: "0.0.0",
+ Build: "1234",
+ Hostname: hostname,
+ Username: username,
+ ExePath: exepath,
+ PID: os.Getpid(),
+ Compiler: runtime.Compiler,
+ RuntimeVer: runtime.Version(),
+ GOOS: runtime.GOOS,
+ GOARCH: runtime.GOARCH,
+ NumCPU: runtime.NumCPU(),
+ NumGoroutine: runtime.NumGoroutine(),
+ StartTime: startTime.Format(time.RFC1123),
+ StartTimeAgo: time.Since(startTime),
+ CurrentTime: time.Now().Format(time.RFC1123),
+ Ctx: context.Background(),
+ }
+
+ if err := statusTmpl.Execute(io.Discard, data); err != nil {
+ t.Errorf("statusData.Execute(io.Discard, data) = %v", err)
+ }
+}
+
+func TestSmokeHandle(t *testing.T) {
+ ctx := context.Background()
+ cctx, setStat, done := AddSimpleItem(ctx, "Llamas")
+ defer done()
+ setStat("Essence of Llama")
+
+ _, setStat2, done2 := AddSimpleItem(cctx, "Kuzco")
+ defer done2()
+ setStat2("Oh, right. The poison. The poison for Kuzco, the poison chosen especially to kill Kuzco, Kuzco's poison.")
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/status", nil)
+ if err != nil {
+ t.Fatalf("http.NewReqeustWithContext(GET /status) error = %v", err)
+ }
+ rec := httptest.NewRecorder()
+ Handle(rec, req)
+ if got, want := rec.Result().StatusCode, http.StatusOK; got != want {
+ t.Errorf("Handle(rec, req): rec.Result().StatusCode = %v, want %v", got, want)
+ }
+}