repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

4.9 KB · 173 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"fmt"
  6	"io"
  7	"log/slog"
  8	"net/http"
  9	"os"
 10	"strconv"
 11	"strings"
 12	"time"
 13)
 14
 15// ntfy runs on the shared orchard-edge network and is reached by container name,
 16// which keeps the notification off the tunnel. It is deny-all, so publishing
 17// needs the write-only token in NTFY_TOKEN.
 18const (
 19	ntfyURL   = "http://orchard-ntfy:8000"
 20	ntfyTopic = "status"
 21)
 22
 23const ntfyTimeout = 5 * time.Second
 24
 25// AlertContext is the property snapshot as of the moment the alert fired, so the
 26// numbers in the notification are the ones that triggered it.
 27type AlertContext struct {
 28	ID            string
 29	Name          string
 30	URL           string
 31	CurrentStatus int64
 32	AvgResponseMS int64
 33}
 34
 35// Notifier publishes transitions. It holds a client, unlike the prober, since it
 36// talks to one host and is not measuring the handshake.
 37type Notifier struct {
 38	client *http.Client
 39	base   string
 40	topic  string
 41	token  string
 42}
 43
 44func NewNotifier() *Notifier {
 45	token := os.Getenv("NTFY_TOKEN")
 46	if token == "" {
 47		// Not fatal: refusing to start over a missing alert credential would
 48		// turn a missed notification into an outage.
 49		slog.Warn("NTFY_TOKEN is unset; alerts will be rendered and refused, not delivered",
 50			slog.String("component", "alerts"))
 51	}
 52	return &Notifier{
 53		client: &http.Client{Timeout: ntfyTimeout},
 54		base:   ntfyURL,
 55		topic:  ntfyTopic,
 56		token:  token,
 57	}
 58}
 59
 60// alertBody is split out so it can be rendered without being sent, for
 61// -preview-alert.
 62type alertBody struct {
 63	Title    string
 64	Message  string
 65	Priority string
 66	Tags     string
 67	Click    string
 68}
 69
 70// renderAlert builds the notification for a transition, false for an unknown
 71// kind.
 72func renderAlert(kind string, ctx AlertContext) (alertBody, bool) {
 73	// Absolute, because it is opened from a phone with no idea of the origin.
 74	click := baseURL + "/" + ctx.ID
 75
 76	switch kind {
 77	case "down":
 78		return alertBody{
 79			Title: ctx.Name + " is down",
 80			Message: fmt.Sprintf(
 81				"%s\nTwo consecutive checks failed. Latest status: %d.\nRolling average response: %d ms.",
 82				ctx.URL, ctx.CurrentStatus, ctx.AvgResponseMS),
 83			// high, not urgent: urgent bypasses do-not-disturb.
 84			Priority: "high",
 85			Tags:     "rotating_light",
 86			Click:    click,
 87		}, true
 88
 89	case "recovery":
 90		return alertBody{
 91			Title: ctx.Name + " is back up",
 92			Message: fmt.Sprintf(
 93				"%s\nThe latest check returned %d.\nRolling average response: %d ms.",
 94				ctx.URL, ctx.CurrentStatus, ctx.AvgResponseMS),
 95			Priority: "default",
 96			Tags:     "white_check_mark",
 97			Click:    click,
 98		}, true
 99	}
100	return alertBody{}, false
101}
102
103// Fire publishes one transition. Errors are logged and swallowed: it runs in a
104// goroutine off the scheduler's path with nobody to return to.
105func (n *Notifier) Fire(kind string, ctx AlertContext) {
106	body, ok := renderAlert(kind, ctx)
107	if !ok {
108		slog.Info(fmt.Sprintf("alert: unknown kind %q for %s", kind, ctx.URL))
109		return
110	}
111	if err := n.publish(context.Background(), body); err != nil {
112		slog.Error(fmt.Sprintf("alert: publishing %s for %s failed: %v", kind, ctx.URL, err))
113		return
114	}
115	slog.Info(fmt.Sprintf("alert: published %s for %s", kind, ctx.URL))
116}
117
118// publish posts to ntfy, whose interface is the message as the body and
119// everything else as a header.
120func (n *Notifier) publish(ctx context.Context, body alertBody) error {
121	ctx, cancel := context.WithTimeout(ctx, ntfyTimeout)
122	defer cancel()
123
124	endpoint := strings.TrimSuffix(n.base, "/") + "/" + n.topic
125	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint,
126		strings.NewReader(body.Message))
127	if err != nil {
128		return err
129	}
130	// ntfy is deny-all, so an unauthenticated publish is refused on the bridge too.
131	if n.token != "" {
132		req.Header.Set("Authorization", "Bearer "+n.token)
133	}
134	req.Header.Set("Title", body.Title)
135	req.Header.Set("Priority", body.Priority)
136	req.Header.Set("Tags", body.Tags)
137	req.Header.Set("Click", body.Click)
138
139	resp, err := n.client.Do(req)
140	if err != nil {
141		return err
142	}
143	defer resp.Body.Close()
144	// Drain so the connection can be reused.
145	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
146
147	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
148		return fmt.Errorf("ntfy returned %s", strconv.Itoa(resp.StatusCode))
149	}
150	return nil
151}
152
153// previewAlert prints what would be published, for checking the wording.
154func previewAlert(kind string) error {
155	body, ok := renderAlert(kind, AlertContext{
156		ID:            "00000000-0000-0000-0000-000000000000",
157		Name:          "example.com",
158		URL:           "https://example.com",
159		CurrentStatus: map[string]int64{"down": 503, "recovery": 200}[kind],
160		AvgResponseMS: 184,
161	})
162	if !ok {
163		return fmt.Errorf("unknown alert kind %q (use 'down' or 'recovery')", kind)
164	}
165	fmt.Printf("POST %s/%s\n", strings.TrimSuffix(ntfyURL, "/"), ntfyTopic)
166	fmt.Printf("Title:    %s\n", body.Title)
167	fmt.Printf("Priority: %s\n", body.Priority)
168	fmt.Printf("Tags:     %s\n", body.Tags)
169	fmt.Printf("Click:    %s\n\n", body.Click)
170	fmt.Println(body.Message)
171	return nil
172}