orchard
mirrorEvery 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
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}