mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-10-22 21:22:04 +00:00
Add source link to each page
This commit is contained in:
1 parent
920248a936
commit
d502414fb5
9 files changed
+285
-9
No files matched your search
@@ -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
@@ -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)).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user