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.0 KB · 143 lines · Go Raw History
  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"