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 "strings"
11 "time"
12)
13
14// The container name on the orchard-edge bridge, never the public hostname:
15// Caddy refuses every publish route on ntfy.bythewood.me, so publishing is
16// reachable only from the bridge even holding the write token.
17//
18// A topic of its own, and a write token only this site holds. The alert topics
19// would not do: the token in status' and logging' .env files can publish to
20// those, so a copy of either would be able to mint its own login codes.
21const (
22 ntfyURL = "http://orchard-ntfy:8000"
23 ntfyTopic = "auth"
24)
25
26const ntfyTimeout = 5 * time.Second
27
28type Notifier struct {
29 client *http.Client
30 base string
31 topic string
32 token string
33}
34
35func NewNotifier() *Notifier {
36 token := os.Getenv("AUTH_NTFY_TOKEN")
37 if token == "" {
38 slog.Warn("AUTH_NTFY_TOKEN is unset; login codes cannot be delivered, so only recovery codes will work",
39 slog.String("component", "auth"))
40 }
41 return &Notifier{
42 client: &http.Client{Timeout: ntfyTimeout},
43 base: ntfyURL,
44 topic: ntfyTopic,
45 token: token,
46 }
47}
48
49func (n *Notifier) configured() bool { return n.token != "" }
50
51type message struct {
52 Title string
53 Body string
54 Priority string
55 Tags string
56 Click string
57}
58
59// codeMessage carries where the login was asked from, which is the phishing
60// defence: a code arriving from somewhere the phone is not is a code not to
61// type. CISA and Microsoft's answer to MFA fatigue is the same idea.
62//
63// Priority low, so a flood of these lands silently in the drawer instead of
64// buzzing. The alert for a session that actually opened is the loud one.
65func codeMessage(code string, c reqContext) message {
66 return message{
67 Title: "Login code " + code,
68 Body: fmt.Sprintf("Asked for from %s (%s).\nIf that is not you, ignore this and the code expires on its own.", c.Where(), c.IP),
69 Priority: "low",
70 Tags: "closed_lock_with_key",
71 }
72}
73
74func sessionMessage(c reqContext, how string) message {
75 return message{
76 Title: "New login to bythewood.me",
77 Body: fmt.Sprintf("Signed in with %s from %s (%s).\n%s", how, c.Where(), c.IP, c.UA),
78 Priority: "high",
79 Tags: "key",
80 Click: baseURL + "/sessions",
81 }
82}
83
84func recoveryMessage(c reqContext, remaining int) message {
85 return message{
86 Title: "A recovery code was used",
87 Body: fmt.Sprintf("Signed in from %s (%s) without the phone.\n%d codes left, and regenerating replaces all of them.",
88 c.Where(), c.IP, remaining),
89 Priority: "high",
90 Tags: "rotating_light",
91 Click: baseURL + "/security",
92 }
93}
94
95// publish posts to ntfy: the message is the body and everything else a header.
96func (n *Notifier) publish(ctx context.Context, m message) error {
97 ctx, cancel := context.WithTimeout(ctx, ntfyTimeout)
98 defer cancel()
99
100 endpoint := strings.TrimSuffix(n.base, "/") + "/" + n.topic
101 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(m.Body))
102 if err != nil {
103 return err
104 }
105 // ntfy is deny-all, so an unauthenticated publish is refused even from
106 // inside the bridge.
107 if n.token != "" {
108 req.Header.Set("Authorization", "Bearer "+n.token)
109 }
110 req.Header.Set("Title", m.Title)
111 req.Header.Set("Priority", m.Priority)
112 req.Header.Set("Tags", m.Tags)
113 if m.Click != "" {
114 req.Header.Set("Click", m.Click)
115 }
116
117 resp, err := n.client.Do(req)
118 if err != nil {
119 return err
120 }
121 defer resp.Body.Close()
122 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
123
124 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
125 return fmt.Errorf("ntfy returned %d", resp.StatusCode)
126 }
127 return nil
128}
129
130// notify publishes without a caller to return to, for the alerts that must
131// never fail the request that triggered them.
132func (n *Notifier) notify(m message) {
133 if err := n.publish(context.Background(), m); err != nil {
134 slog.Error("publishing an alert failed",
135 slog.String("component", "auth"),
136 slog.Any("err", err))
137 }
138}
139
140// ntfyPublicURL is what a phone points at, as opposed to ntfyURL, which is the
141// bridge address this process publishes to.
142const ntfyPublicURL = "https://ntfy.bythewood.me"