From d502414fb527728df835b7ba71ea72252366da7e Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sun, 9 Aug 2026 09:25:06 -0400 Subject: [PATCH] Add source link to each page --- README.md | 88 +++++++++++++++++++++++++++++- TRADEMARKS.md | 5 +- internal/e2e/footer_source_test.go | 62 +++++++++++++++++++++ internal/web/env.go | 4 ++ internal/web/footer_source_test.go | 50 +++++++++++++++++ internal/web/static/app.css | 7 +++ internal/web/templates/base.html | 11 +++- internal/web/version.go | 40 +++++++++++++- internal/web/version_test.go | 27 ++++++++- 9 files changed, 285 insertions(+), 9 deletions(-) create mode 100644 internal/e2e/footer_source_test.go create mode 100644 internal/web/footer_source_test.go diff --git a/README.md b/README.md index 511aeb7..fbf1542 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ it admin by running `setperm 3` on their repeater. Because every through that one server identity, MeshTender can mediate *which users* may control a repeater — so a repeater can be shared without ever handing out keys, and access can be revoked instantly. +AGPL-3.0, with the name and logo reserved. That combination is deliberate and the reasoning is +worth reading before you deploy your own copy: [License, and why AGPL](#license-and-why-agpl). + ## How the hardware control path works The server owns all MeshCore crypto, but the radio (a MeshCore **KISS modem**) is plugged into the @@ -209,6 +212,12 @@ dependencies are limited to permissive licenses — the allowed set is `AllowedS in the test tree. Attribution obligations are met by `THIRD-PARTY-NOTICES.md` and by keeping the copyright banners in the vendored front-end files. +MeshTender is itself AGPL-3.0, which sounds like a contradiction and isn't: licensing copyleft +*out* doesn't oblige us to accept copyleft *in*. A GPL or AGPL dependency compiled into the binary +would end the sole copyright holder's ability to release MeshTender under any other terms later, +and LGPL/MPL attach per-file obligations that fit a statically linked, fully embedded Go binary +badly. The full reasoning sits at `AllowedSPDX`. + `mise run licenses` enforces it: it scans the whole Go module graph (binary, test, and `browser`-tagged) with `google/licensecheck`, plus the manifest of things Go tooling can't see — vendored front-end files, icon artwork, the base image, external services — and fails if anything @@ -247,10 +256,17 @@ $ curl -s https://meshtender.com/version "os": "linux", "arch": "amd64", "executableSHA256": "9f2c…", - "imageDigest": "sha256:a41b…" + "imageDigest": "sha256:a41b…", + "source": "https://github.com/jleight/meshtender", + "license": "AGPL-3.0-only" } ``` +`source` and `license` are the AGPL section 13 source offer in machine-readable form +(the footer of every page carries the same link). Unlike the fields below them they're +compiled-in constants rather than measured facts — an assertion by whoever built the +binary, which `commit` then lets you check. + Then rebuild that commit for that platform and compare digests: ```sh @@ -283,6 +299,76 @@ Two things change the digest, and both are intentional: - **The platform must match.** `mise run image` targets `linux/amd64`; pass `--platform linux/arm64` to verify that variant. +## License, and why AGPL + +MeshTender is free software under the **GNU AGPL v3.0** ([`LICENSE`](LICENSE)). Its **name and +logo are not** ([`TRADEMARKS.md`](TRADEMARKS.md)). Both of those follow from the same fact about +what this app is, so it's worth explaining rather than leaving as a puzzle. + +### The permission problem MeshTender exists to solve + +MeshCore's repeater ACL has four roles — guest (0), read-only (1), read-write (2), admin (3) — but +only one of them can run commands. A remote CLI command is processed if and only if the sender is +admin (`if (type == PAYLOAD_TYPE_TXT_MSG && … && client->isAdmin())`, `simple_repeater/MyMesh.cpp`). +There is no "may set transmit power but not rewrite the region table" role, and guest is available +to anyone with a blank password. So any tool that runs commands on your repeater on your behalf +holds **full admin**, because nothing weaker can do anything at all. + +MeshTender's whole job is to put the missing permission system on top of that single grant: a +per-user command allowlist that denies by default, a per-org ceiling the repeater owner opts into, +an audit log of who ran what and when, and revocation that takes effect immediately without +touching the hardware. An org can share repeater administration under limits MeshCore itself cannot +express. + +### Why that argues for one instance, not many + +Those limits are enforced by the server, which means whoever runs the server is *outside* them: +they hold the key, and every restriction is theirs to lift. That is unavoidable, and it decides +who should be running it. + +If an org's admins host their own instance, then that org's members are back to trusting those +admins with unrestricted access to their repeaters — the exact situation MeshTender exists to fix, +now with a UI implying the restrictions are real. The configuration that actually delivers the +guarantee is one instance run by someone who isn't a party to any org's internal trust question. +For most MeshCore projects self-hosting is the obviously correct answer; here it quietly removes +the point of the software. + +### Which means trusting me, so here is what that is worth + +I'm aware this is a hard sell, and it should be. What I can offer: + +- **Distance.** Administering a repeater takes a modem within LoRa range of it. I'm not near your + mesh, so the identity key on my server is not usefully exploitable by me except *through this + app* — where it is logged. +- **The audit log.** Every command carries who sent it and when, per repeater, visible to the + people who granted access. +- **Revocation on the hardware.** You granted access on the device with `setperm`, and you can + withdraw it there the same way, whatever the server does. See the [docs](https://meshtender.com/docs). +- **Open source.** The thing above is only checkable if you can read what's running, which is why + this repository exists. +- **Reproducible builds.** Rebuild the commit `/version` reports and confirm the digest matches, so + "the source is public" and "the source is what's deployed" are separate, checkable claims. See + [Verifying a build](#verifying-a-build). + +None of that is proof. Together it's the strongest position I know how to take given a protocol +with no permissions. + +AGPL closes the remaining hole. If someone does run a modified copy as a service, section 13 +requires that its users can get *its* source — so their instance can be audited exactly the way +this one can, rather than being an unauditable server wearing familiar software's clothes. + +### Self-hosting and forking + +The license permits it and this file isn't going to pretend otherwise; the paragraphs above are an +argument, not a restriction. If you run a modified copy, the AGPL asks you to publish your source +and offer it to your users — `SourceURL` in `internal/web/version.go` is the constant behind the +footer link and the `/version` field, so point it at your fork. Separately, the MeshTender name and +mark are reserved, so your instance needs its own: [`TRADEMARKS.md`](TRADEMARKS.md) says exactly +what to change. + +Contributions aren't being accepted yet — the licensing questions that come with them (inbound +terms, and whether relicensing later stays possible) haven't been settled. + ## Layout ``` diff --git a/TRADEMARKS.md b/TRADEMARKS.md index 7221c92..505dfca 100644 --- a/TRADEMARKS.md +++ b/TRADEMARKS.md @@ -56,8 +56,9 @@ You must: not recolor, trace, or adapt them. 3. **Publish your source.** AGPL section 13 requires that users interacting with your instance over a network be able to get the source it is running, including - your modifications. A link to your fork, reachable from the running service, is - the usual way to satisfy this. + your modifications. Point `SourceURL` in `internal/web/version.go` at your fork: + it is what the footer link on every page and the `/version` endpoint report, so + leaving it as ours would advertise source you are not running. 4. **Keep the attribution.** Say somewhere visible that your service is based on MeshTender, with a link to . Copyright notices in the source stay as they are (AGPL §5(a)). diff --git a/internal/e2e/footer_source_test.go b/internal/e2e/footer_source_test.go new file mode 100644 index 0000000..0287c2f --- /dev/null +++ b/internal/e2e/footer_source_test.go @@ -0,0 +1,62 @@ +//go:build browser + +package e2e + +import ( + "testing" + + cdplog "github.com/chromedp/cdproto/log" + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" +) + +// TestFooterSourceLinkOnEverySurface: the footer's source link is how the running +// service satisfies AGPL section 13, so it has to be present and clickable on all +// three hosts — including the sign-in host, whose centered layout builds its own +// page chrome rather than reusing the standard one. +// +// A browser test rather than only a Go one because the obligation is about what a +// visitor can actually reach: a link present in the HTML but overlaid, empty, or +// dropped by a layout that forgot the footer would satisfy a string assertion and +// not a person. +func TestFooterSourceLinkOnEverySurface(t *testing.T) { + srv := newE2EServer(t) + + const wantHref = "https://github.com/jleight/meshtender" + + for _, tc := range []struct { + name string + url string + }{ + {"root", srv.rootURL + "/"}, + {"signin", srv.authURL + "/login"}, + {"signup", srv.authURL + "/signup"}, + } { + t.Run(tc.name, func(t *testing.T) { + bctx, cancel, watch := startBrowser(t) + defer cancel() + + var href, text string + if err := chromedp.Run(bctx, + network.Enable(), + cdplog.Enable(), + chromedp.Navigate(tc.url), + // Waiting on visibility is the point: it fails if a layout renders the + // footer off-page or collapsed rather than merely including the markup. + chromedp.WaitVisible(`footer a[href*="github.com"]`, chromedp.ByQuery), + chromedp.AttributeValue(`footer a[href*="github.com"]`, "href", &href, nil, chromedp.ByQuery), + chromedp.Text(`footer a[href*="github.com"]`, &text, chromedp.ByQuery), + ); err != nil { + t.Fatalf("%s: %v", tc.url, err) + } + watch.assertClean(t) + + if href != wantHref { + t.Errorf("source link href = %q, want %q", href, wantHref) + } + if text != "Source" { + t.Errorf("source link text = %q, want %q", text, "Source") + } + }) + } +} diff --git a/internal/web/env.go b/internal/web/env.go index 1d7122a..361fa6e 100644 --- a/internal/web/env.go +++ b/internal/web/env.go @@ -321,6 +321,10 @@ func (rn *Renderer) render(w http.ResponseWriter, r *http.Request, status int, p data["AppURL"] = originFor(rn.cfg, r, rn.cfg.PrimaryHost) data["AuthURL"] = originFor(rn.cfg, r, rn.cfg.AuthHost) data["RootURL"] = originFor(rn.cfg, r, rn.cfg.RootHost) + // SourceURL backs the footer's source link, which is how every page satisfies + // the AGPL section 13 obligation to offer this server's source to the people + // using it. Injected here rather than per-handler so no page can omit it. + data["SourceURL"] = SourceURL // LogoutURL: sign-out is a POST that revokes the login row, so it must target a // host that owns a /logout endpoint AND holds this browser's session. The app // host, auth host, and custom org domains all do (relative "/logout", a diff --git a/internal/web/footer_source_test.go b/internal/web/footer_source_test.go new file mode 100644 index 0000000..b5c6efa --- /dev/null +++ b/internal/web/footer_source_test.go @@ -0,0 +1,50 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestEveryLayoutFooterOffersSource: AGPL section 13 obliges a network deployment +// to offer its source to the people using it, and the footer link is how this one +// does. That makes the link a licensing obligation rather than a nicety — it has +// to appear on every surface, so a page rendered on the auth or app host is as +// covered as the public root. +// +// footerbody is shared by every layout, but the URL is injected by the renderer, +// so a layout could render the footer with .SourceURL unset and silently emit an +// empty href. +func TestEveryLayoutFooterOffersSource(t *testing.T) { + t.Parallel() + + // The layouts base.html defines. Each renders footerbody; each has to end up + // with a working link. + for _, layout := range []string{"base", "rootbase", "authbase", "landingbase"} { + t.Run(layout, func(t *testing.T) { + t.Parallel() + e := testEnv(t) + if e.Renderer.pages["error.html"].Lookup(layout) == nil { + t.Fatalf("base.html no longer defines a %q layout; update this list", layout) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "https://app.example/nope", nil) + e.Renderer.render(rec, req, http.StatusNotFound, "error.html", map[string]any{ + "Layout": layout, + "Status": http.StatusNotFound, "Title": "Page not found", "Message": "gone", + }) + + body := rec.Body.String() + want := `href="` + SourceURL + `"` + if !strings.Contains(body, want) { + t.Errorf("%s footer has no source link (%s)", layout, want) + } + if !strings.Contains(body, ">Source<") { + t.Errorf("%s footer links the source without labeling it, so a reader cannot tell "+ + "what it offers", layout) + } + }) + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 8858542..1341708 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -232,3 +232,10 @@ code.pubkey { border-color: var(--tblr-primary) !important; box-shadow: 0 0 0 1px var(--tblr-primary); } + +/* Footer on the centered auth layout (authbase). footerbody right-packs its two + columns, which suits a full-width page but reads as misaligned under a narrow + centered card, so center them here instead. */ +.footer-centered .row { + justify-content: center; +} diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index c2fc5f1..9f89f1b 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -116,7 +116,9 @@ {{/* footerbody is the one footer used by every layout, so the legal links can't go missing on a surface. /privacy and /terms are served by the root host - only, hence absolute URLs: the app and auth hosts have no such routes. */}} + only, hence absolute URLs: the app and auth hosts have no such routes. + The source link is the AGPL section 13 offer — the source of the program + you are interacting with, reachable from every page. */}} {{define "footerbody"}}
@@ -127,6 +129,7 @@
  • Docs
  • Privacy
  • Terms
  • +
  • Source
  • @@ -215,7 +218,10 @@ {{/* authbase is a standalone, centered layout for the sign-in / sign-up pages — no navbar or page chrome, just a brand logo above the page's content card. - Pages opt in with {{define "base"}}{{template "authbase" .}}{{end}}. */}} + Pages opt in with {{define "base"}}{{template "authbase" .}}{{end}}. + It still carries footerbody: the legal links and the AGPL section 13 source + offer are owed on every surface, and someone signing in is as much a user of + this server as someone already signed in. */}} {{define "authbase"}} @@ -237,6 +243,7 @@ {{template "content" .}} + diff --git a/internal/web/version.go b/internal/web/version.go index f701ff4..2f1256e 100644 --- a/internal/web/version.go +++ b/internal/web/version.go @@ -3,6 +3,8 @@ package web import ( "net/http" "time" + + "github.com/jleight/meshtender/internal/buildinfo" ) // VersionPath is the public build-provenance endpoint. It lives on the root @@ -10,12 +12,47 @@ import ( // people this endpoint exists for are exactly the ones who can't sign in. const VersionPath = "/version" +// SourceURL is where the source of this program is published, and License is the +// terms it is published under. +// +// This is how the deployment satisfies AGPL section 13: anyone interacting with +// the server over a network is entitled to the source it is running, so every +// page footer links here and /version reports it in machine-readable form. It +// pairs with the build provenance below — the source offer names a repository, +// and /version names the commit within it. +// +// Deliberately a constant rather than configuration. An operator running a +// MODIFIED copy owes their users THEIR source, not ours, so this value must +// change in the same commit that changes the code it points at; an env var would +// let a modified deployment keep advertising upstream. Forks: edit this, and see +// TRADEMARKS.md for what else has to change. +const ( + SourceURL = "https://github.com/jleight/meshtender" + License = "AGPL-3.0-only" +) + // versionMaxAge is how long a client may cache the answer. The payload only // changes when the process is replaced, so this is purely about repeat requests; // it is short enough that a verifier polling across a deploy sees the new build // promptly. const versionMaxAge = time.Minute +// versionResponse is the /version payload: the build facts, plus the licensing +// and source-location constants above. +// +// The two are kept in separate types because they are different kinds of claim. +// buildinfo.Info is derived — stamped by the toolchain or measured at runtime — +// while these two are simply asserted by whoever compiled the binary. Embedding +// flattens them into one JSON object without pretending the constants were +// attested by anything. +type versionResponse struct { + buildinfo.Info + // Source is where this program's source is published (AGPL §13). + Source string `json:"source"` + // License is the SPDX identifier of its terms. + License string `json:"license"` +} + // VersionJSON reports what this binary was built from, so anyone can rebuild the // named commit and check that they get the same artifact (see "Verifying a // build" in README.md). @@ -28,7 +65,8 @@ const versionMaxAge = time.Minute // A side-effect-free GET, so it satisfies the root host's rule (see // docs/auth-cross-host.md) that no state-changing request lives there. func (e *Env) VersionJSON(w http.ResponseWriter, r *http.Request) { - if err := ServeJSONCached(w, r, versionMaxAge, e.Build); err != nil { + payload := versionResponse{Info: e.Build, Source: SourceURL, License: License} + if err := ServeJSONCached(w, r, versionMaxAge, payload); err != nil { e.ServerError(w, r, "could not report build information", err) } } diff --git a/internal/web/version_test.go b/internal/web/version_test.go index 05d1666..267a5b4 100644 --- a/internal/web/version_test.go +++ b/internal/web/version_test.go @@ -36,12 +36,33 @@ func TestVersionJSONReportsTheBuild(t *testing.T) { t.Errorf("Content-Type = %q, want JSON", ct) } - var got buildinfo.Info + var got versionResponse if err := json.NewDecoder(res.Body).Decode(&got); err != nil { t.Fatalf("decode: %v", err) } - if got != testBuild() { - t.Errorf("reported %+v, want %+v", got, testBuild()) + if got.Info != testBuild() { + t.Errorf("reported %+v, want %+v", got.Info, testBuild()) + } + if got.Source != SourceURL || got.License != License { + t.Errorf("reported source/license %q/%q, want %q/%q", got.Source, got.License, SourceURL, License) + } +} + +// TestVersionJSONAlwaysOffersSource: the source location and license are the +// AGPL section 13 offer in machine-readable form, so unlike the build stamps they +// must be present on every build — including a from-source run with no VCS +// stamps, where an omitempty slip would leave a verifier with nothing to fetch. +func TestVersionJSONAlwaysOffersSource(t *testing.T) { + t.Parallel() + e := &Env{Build: buildinfo.Info{Go: "go1.26.5", OS: "darwin", Arch: "arm64"}} + rec := httptest.NewRecorder() + e.VersionJSON(rec, httptest.NewRequest(http.MethodGet, VersionPath, nil)) + + body := rec.Body.String() + for _, want := range []string{`"source":"` + SourceURL + `"`, `"license":"` + License + `"`} { + if !strings.Contains(body, want) { + t.Errorf("body missing %s, got %s", want, body) + } } }