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

10.7 KB · 268 lines · Go Raw History
  1package tools
  2
  3// The estate's own sites, read only.
  4//
  5// These are the one group of tools that reach something private, so they work
  6// differently from the rest. Each one forwards the session cookie of whoever is
  7// chatting, and the site on the other end does its own check against
  8// auth.bythewood.me. Nothing here holds a credential of its own, which means
  9// this chat cannot read anything the person using it could not already open in
 10// a browser, and signing that session out stops these tools on the next call.
 11//
 12// Every one is a GET against an endpoint that only reads. There is no tool here
 13// that can change anything, and there is deliberately not going to be one.
 14
 15import (
 16	"context"
 17	"encoding/json"
 18	"errors"
 19	"fmt"
 20	"io"
 21	"net/http"
 22	"net/url"
 23	"strconv"
 24	"strings"
 25)
 26
 27// SessionCookie is the name web/session.go uses, repeated here rather than
 28// imported because tools is a leaf package.
 29const SessionCookie = "bw_session"
 30
 31// Site names resolve on the bridge, so these never leave the machine and never
 32// pass through Cloudflare. A public hostname would work and would be slower,
 33// cached, and a lie about where the data went.
 34// Vars rather than constants so a test can stand a real server up in front of
 35// one, which is the only way to check that a tool walks a repository correctly.
 36var (
 37	loggingBase   = "http://orchard-logging:8000"
 38	statusBase    = "http://orchard-status:8000"
 39	analyticsBase = "http://orchard-analytics:8000"
 40	reposBase     = "http://orchard-repos:8000"
 41	dashBase      = "http://orchard-dash:8000"
 42)
 43
 44// errEstateMissing is a 404 from one of the sites, which for a tool walking a
 45// repository is a path that is not there rather than a failure.
 46var errEstateMissing = fmt.Errorf("no such path")
 47
 48// estateGet fetches one of the sites with the caller's session on it. It does
 49// not go through get(): the Guard exists for third party endpoints that rate
 50// limit this address, and putting a container on the bridge in the penalty box
 51// would take a whole site out over a blip nobody else is throttling.
 52func estateGet(ctx context.Context, d *Deps, rawURL string, into any) error {
 53	if d.Session == "" {
 54		return fmt.Errorf("this needs you to be signed in, and the turn carried no session")
 55	}
 56	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
 57	if err != nil {
 58		return err
 59	}
 60	req.AddCookie(&http.Cookie{Name: SessionCookie, Value: d.Session})
 61	req.Header.Set("Accept", "application/json")
 62
 63	resp, err := d.HTTP.Do(req)
 64	if err != nil {
 65		return fmt.Errorf("%s is not answering: %w", hostOf(rawURL), err)
 66	}
 67	defer resp.Body.Close()
 68
 69	switch {
 70	case resp.StatusCode == http.StatusUnauthorized, resp.StatusCode == http.StatusForbidden:
 71		return fmt.Errorf("%s refused the session, so it may have been signed out", hostOf(rawURL))
 72	case resp.StatusCode == http.StatusSeeOther, resp.StatusCode == http.StatusFound:
 73		// A redirect here is the login page, which means the same thing as a
 74		// 401 and would otherwise be decoded as malformed JSON.
 75		return fmt.Errorf("%s wants a sign in", hostOf(rawURL))
 76	case resp.StatusCode == http.StatusNotFound:
 77		return errEstateMissing
 78	case resp.StatusCode >= 400:
 79		return fmt.Errorf("%s answered %d", hostOf(rawURL), resp.StatusCode)
 80	}
 81	return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(into)
 82}
 83
 84var OrchardLogs = Tool{
 85	Name: "orchard_logs",
 86	Description: "Read Isaac's own log aggregation at logging.bythewood.me: how many records and " +
 87		"errors each of his sites produced, every kind of error with the reason it gives, and the " +
 88		"busiest paths with their p95 latency. Use it for anything about whether his sites are " +
 89		"misbehaving, what is erroring, or what is slow. Each error comes back grouped, so count " +
 90		"and first_seen say how big it is and how long it has run, and the details field carries " +
 91		"the reason, which is where the actual cause is rather than in the message. Call it a " +
 92		"second time with source or contains to narrow down on one thing. Read only.",
 93	Schema: obj(map[string]any{
 94		"hours":    num("how far back to look, default 24, up to 720"),
 95		"errors":   num("how many kinds of error to return, default 20"),
 96		"source":   str("one site by name, optional, such as search or repos or chat"),
 97		"contains": str("only errors whose message, reason or path contains this, optional"),
 98	}),
 99	Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
100		q := url.Values{}
101		if h := int(argNum(a, "hours", 0)); h > 0 {
102			q.Set("hours", strconv.Itoa(h))
103		}
104		if e := int(argNum(a, "errors", 0)); e > 0 {
105			q.Set("errors", strconv.Itoa(e))
106		}
107		if v := strings.TrimSpace(argStr(a, "source")); v != "" {
108			q.Set("source", v)
109		}
110		if v := strings.TrimSpace(argStr(a, "contains")); v != "" {
111			q.Set("contains", v)
112		}
113		var out any
114		err := estateGet(ctx, d, loggingBase+"/api/summary?"+q.Encode(), &out)
115		return out, err
116	},
117}
118
119var OrchardStatus = Tool{
120	Name: "orchard_status",
121	Description: "Read Isaac's own uptime monitoring at status.bythewood.me: every property he " +
122		"watches, whether it is up, when it was last checked, its Lighthouse scores and its crawler " +
123		"state. Use it for whether a site of his is down or slow, or how it scores. Read only.",
124	Schema: obj(map[string]any{}),
125	Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
126		var out any
127		err := estateGet(ctx, d, statusBase+"/api/properties", &out)
128		return out, err
129	},
130}
131
132var OrchardAnalytics = Tool{
133	Name: "orchard_analytics",
134	Description: "Read Isaac's own analytics at analytics.bythewood.me: sessions, page views, live " +
135		"users, and the top pages, referrers, countries, browsers and devices for each property. Use " +
136		"it for anything about his traffic or where his visitors come from. Read only.",
137	Schema: obj(map[string]any{
138		"days":     num("how many days back, default 7, up to 365"),
139		"property": str("one property by name, optional, otherwise every one"),
140	}),
141	Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
142		q := url.Values{}
143		if dd := int(argNum(a, "days", 0)); dd > 0 {
144			q.Set("days", strconv.Itoa(dd))
145		}
146		if p := strings.TrimSpace(argStr(a, "property")); p != "" {
147			q.Set("property", p)
148		}
149		var out any
150		err := estateGet(ctx, d, analyticsBase+"/api/summary?"+q.Encode(), &out)
151		return out, err
152	},
153}
154
155var OrchardRepos = Tool{
156	Name: "orchard_repos",
157	Description: "Read Isaac's own git remote at repos.bythewood.me: every repository, its " +
158		"description, size, branch and tag counts, when it was last pushed, and how close it is to " +
159		"the push size limit. Use it for what he is working on or what a repository holds. Read only.",
160	Schema: obj(map[string]any{}),
161	Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
162		var out any
163		err := estateGet(ctx, d, reposBase+"/api/repos", &out)
164		return out, err
165	},
166}
167
168// OrchardCode is the other half of orchard_repos: the listing says what exists
169// and this says what is in it. Without it every question about Isaac's own code
170// ended the same way, with the model guessing raw addresses on repos and
171// github, collecting 404s, and eventually writing a file it said it had read.
172// Nine of the seventeen failed fetches on 2026-09-08 were that.
173var OrchardCode = Tool{
174	Name: "orchard_code",
175	Description: "Read the actual source of one of Isaac's repositories on repos.bythewood.me. " +
176		"Leave path empty to list the top of the repository, give a directory to list it, or give " +
177		"a file to read it. Use this for any question about how his own code works, and walk down " +
178		"to the file rather than guessing a path. Never guess a url for his code and never fetch " +
179		"one, this is the only way in. Read only.",
180	Schema: obj(map[string]any{
181		"repo": str("the repository name, as orchard_repos lists it"),
182		"path": str("a directory to list or a file to read, empty for the top level"),
183		"rev":  str("a branch, tag or commit, optional, defaults to the default branch"),
184	}, "repo"),
185	Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
186		repo := strings.Trim(strings.TrimSpace(argStr(a, "repo")), "/")
187		if repo == "" {
188			return nil, fmt.Errorf("repo is required, and orchard_repos lists the names")
189		}
190		rev := strings.TrimSpace(argStr(a, "rev"))
191		if rev == "" {
192			rev = "HEAD"
193		}
194		path := strings.Trim(strings.TrimSpace(argStr(a, "path")), "/")
195
196		// A path with no dot in its last segment is a directory far more often
197		// than not, but guessing wrong either way costs a call, so the file
198		// read is tried first and a miss falls through to the listing. That way
199		// an extensionless file still reads and a directory still lists.
200		base := reposBase + "/api/repos/" + url.PathEscape(repo)
201		if path != "" {
202			var file map[string]any
203			err := estateGet(ctx, d, base+"/file/"+url.PathEscape(rev)+"/"+escapePath(path), &file)
204			if err == nil {
205				return file, nil
206			}
207			// Only a missing file falls through. A refused session or a binary
208			// file is the answer, and listing the directory would bury it.
209			if !errors.Is(err, errEstateMissing) {
210				return nil, err
211			}
212		}
213		treeURL := base + "/tree/" + url.PathEscape(rev)
214		if path != "" {
215			treeURL += "/" + escapePath(path)
216		}
217		var tree map[string]any
218		if err := estateGet(ctx, d, treeURL, &tree); err != nil {
219			if path == "" {
220				return nil, err
221			}
222			return nil, fmt.Errorf("%s has no file or directory at %q, so list the level above it first", repo, path)
223		}
224		return tree, nil
225	},
226}
227
228// escapePath escapes each segment and keeps the separators, since the whole
229// path is one wildcard on the other side and escaping it whole would turn every
230// slash into %2F.
231func escapePath(p string) string {
232	if p == "" {
233		return ""
234	}
235	parts := strings.Split(p, "/")
236	for i, seg := range parts {
237		parts[i] = url.PathEscape(seg)
238	}
239	return strings.Join(parts, "/")
240}
241
242var OrchardDash = Tool{
243	Name: "orchard_dash",
244	Description: "Read Isaac's dashboard at dash.bythewood.me in one call: markets, Hacker News, " +
245		"Lobsters, the weather, upcoming earnings, and whether each of his sites is answering. Use it " +
246		"when a question spans several of those rather than calling each tool separately. Read only.",
247	Schema: obj(map[string]any{}),
248	Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
249		var out any
250		// dash publishes this without a session, since the page it feeds has no
251		// login, so it is the one here that works signed out.
252		req, err := http.NewRequestWithContext(ctx, http.MethodGet, dashBase+"/api/state", nil)
253		if err != nil {
254			return nil, err
255		}
256		resp, err := d.HTTP.Do(req)
257		if err != nil {
258			return nil, fmt.Errorf("dash is not answering: %w", err)
259		}
260		defer resp.Body.Close()
261		if resp.StatusCode >= 400 {
262			return nil, fmt.Errorf("dash answered %d", resp.StatusCode)
263		}
264		err = json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&out)
265		return out, err
266	},
267}