Add source link to each page

This commit is contained in:
Jonathon Leight committed 2026-08-09 09:25:06 -04:00
1 parent 920248a936
commit d502414fb5
9 files changed
+285 -9

No files matched your search

+87 -1
View File
@@ -10,6 +10,9 @@ it admin by running `setperm <server_pubkey> 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
```
+3 -2
View File
@@ -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 <https://github.com/jleight/meshtender>. Copyright
notices in the source stay as they are (AGPL §5(a)).
+62
View File
@@ -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")
}
})
}
}
+4
View File
@@ -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
+50
View File
@@ -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)
}
})
}
}
+7
View File
@@ -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;
}
+9 -2
View File
@@ -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"}}
<div class="row text-center align-items-center flex-row-reverse gy-2">
<div class="col-12 col-lg-auto">
@@ -127,6 +129,7 @@
<li class="list-inline-item"><a class="link-secondary" href="{{.RootURL}}/docs">Docs</a></li>
<li class="list-inline-item"><a class="link-secondary" href="{{.RootURL}}/privacy">Privacy</a></li>
<li class="list-inline-item"><a class="link-secondary" href="{{.RootURL}}/terms">Terms</a></li>
<li class="list-inline-item"><a class="link-secondary" href="{{.SourceURL}}" rel="noopener">Source</a></li>
</ul>
</div>
</div>
@@ -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"}}<!doctype html>
<html lang="en" data-bs-theme="dark" data-tz="{{.UserTZ}}">
<head>
@@ -237,6 +243,7 @@
</a>
</div>
{{template "content" .}}
<footer class="footer footer-transparent d-print-none mt-4 footer-centered">{{template "footerbody" .}}</footer>
</div>
</div>
</body>
+39 -1
View File
@@ -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)
}
}
+24 -3
View File
@@ -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)
}
}
}