Skip to content

Commit 644baa0

Browse files
cansofgreaseclaude
andcommitted
First-run setup in one step, and the dashboard stays private until you open it
The first time you open Pingularity there's now a short Quick Setup: it asks how often to run speed tests, who can reach the dashboard, whether to check for updates, and optionally sets a login - then applies all of it at once. Nothing starts probing your connection until you've said go, and you can change any of it later in Settings. The dashboard now starts private everywhere - it answers only the machine it's running on until you decide otherwise. That includes Docker, which used to open itself to the network by guessing at the setup; it no longer guesses. To reach Pingularity from other devices, turn on network access in Settings or start it with -access network (in a container, -e PINGULARITY_ACCESS=network), and set a login. A container's published port stays closed until you do, so it can't be exposed by accident. Fixes that came with it: - The browser's own login popup no longer appears on top of Pingularity's, and if your session expires mid-download you're sent to the app's login, not the browser's. "Redact PII" now downloads the masked log, not the raw one. - Quick Setup works with a keyboard and a screen reader, and a login that pops up over it is usable instead of stuck behind it. - Saving one setting no longer quietly re-saves all the others (which could pin a value you'd set on the command line). - Automatic speed tests pick a server that actually answers instead of ranking an unreachable one first, don't run twice right after startup, and read FreeBSD's congestion-control list correctly. - A backup too big for the browser now tells you to copy the database file or fetch it with curl, instead of freezing the tab. - Self-built container images stop warning about a data directory they never touched, and a malformed release tag is refused instead of shipping the wrong thing. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b1cde69 commit 644baa0

35 files changed

Lines changed: 2900 additions & 411 deletions

.github/workflows/release.yml

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ name: Release
33
on:
44
push:
55
tags: ['v*']
6+
# Manual escape hatch for a stranded tag: the 2026-08-06 Actions outage
7+
# throttled webhooks and the TAG event never replayed (push events did, tag
8+
# events do not), leaving a pushed tag with no release run - the remedy was
9+
# deleting and re-pushing the tag. With this, dispatch the workflow AT the
10+
# tag ref instead (Actions UI -> Release -> Run workflow -> pick the tag).
11+
workflow_dispatch:
612

713
# Only one release run per tag at a time. Re-cutting a tag (delete + re-push, as the
814
# rc loop does) cancels the in-flight run instead of racing it, which otherwise
@@ -19,6 +25,19 @@ jobs:
1925
guard:
2026
runs-on: ubuntu-latest
2127
steps:
28+
# workflow_dispatch (the stranded-tag escape hatch) can fire on a BRANCH,
29+
# and a branch may be NAMED like a version - the SemVer check below reads
30+
# GITHUB_REF_NAME and would pass it, while GoReleaser publishes the git tag
31+
# at HEAD instead. On a v-named branch whose HEAD sits on an existing
32+
# release tag that would OVERWRITE a shipped release. Refuse anything that
33+
# is not a tag ref outright, before any checkout or token use.
34+
- name: Require a tag ref
35+
run: |
36+
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
37+
echo "::error::release runs only on a tag ref (got ${GITHUB_REF_TYPE} '${GITHUB_REF_NAME}'); dispatch the workflow with a tag ref, not a branch"
38+
exit 1
39+
fi
40+
2241
# The trigger glob 'v*' also matches junk like 'v', 'vtest', or 'v1.2' - a
2342
# stray or malformed tag must not cut a release. Enforce a proper SemVer
2443
# grammar (leading v) before any checkout or token use. This is a regex, not a
@@ -31,9 +50,21 @@ jobs:
3150
tag="${GITHUB_REF_NAME}"
3251
semver='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$'
3352
if ! printf '%s' "$tag" | grep -Eq "$semver"; then
34-
echo "::error::tag '$tag' is not a valid SemVer tag (vMAJOR.MINOR.PATCH[-prerelease][+build]); refusing to release"
53+
echo "::error::tag '$tag' is not a valid SemVer tag (vMAJOR.MINOR.PATCH[-prerelease]); refusing to release"
3554
exit 1
3655
fi
56+
# SemVer permits +build metadata, but this pipeline CANNOT ship it
57+
# safely: the prerelease test below is a shell `*-*` glob, and build
58+
# metadata like +build-1 contains a hyphen, so a STABLE tag with build
59+
# metadata would be misread as a prerelease and skip the immutable-
60+
# stable overwrite guard. Docker tags reject '+' outright as well.
61+
# Reject build metadata here rather than silently mishandle it.
62+
case "$tag" in
63+
*+*)
64+
echo "::error::tag '$tag' carries +build metadata, which this release pipeline does not support (it breaks the stable/prerelease split and Docker tagging); cut vMAJOR.MINOR.PATCH[-prerelease] instead"
65+
exit 1
66+
;;
67+
esac
3768
3869
# Stable releases are immutable: their checksums are attested in the publish
3970
# job, so silently overwriting a published stable asset would invalidate that
@@ -83,6 +114,19 @@ jobs:
83114
with:
84115
go-version-file: go.mod
85116

117+
# GoReleaser publishes the tag it resolves at HEAD (git describe --exact-match),
118+
# not GITHUB_REF_NAME. The guard job validated the ref name; assert they are the
119+
# same tag here, so a dispatch can never publish a DIFFERENT release than the one
120+
# that passed the guards.
121+
- name: Assert HEAD tag matches the ref
122+
run: |
123+
head_tag="$(git describe --exact-match --tags HEAD)" || {
124+
echo "::error::HEAD is not on an exact tag; refusing to release"; exit 1; }
125+
if [ "$head_tag" != "${GITHUB_REF_NAME}" ]; then
126+
echo "::error::HEAD tag '$head_tag' != ref '${GITHUB_REF_NAME}'; the release would publish a different version than was guarded"
127+
exit 1
128+
fi
129+
86130
- name: Log in to GHCR
87131
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
88132
with:

Dockerfile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ ARG TARGETPLATFORM
3232
COPY $TARGETPLATFORM/pingularity /pingularity
3333
# +ep: effective+permitted, so the cap is raised automatically on exec even for
3434
# a non-root user. `mkdir /data` seeds the data dir the final stage chowns.
35-
RUN setcap cap_net_raw+ep /pingularity && mkdir -p /data
35+
# The .pingularity-image-dir marker is a volume-lineage HEURISTIC (not a proof
36+
# of volume type): Docker's copy-up carries it into a fresh named volume, so the
37+
# daemon's container carve-out (store.go) can tell content that came from OUR
38+
# image from an empty PVC or a plain bind-mounted host directory, which never
39+
# carry it. A bind mount restored FROM a marked volume would carry it too - an
40+
# accepted edge, since the content is genuinely ours.
41+
RUN setcap cap_net_raw+ep /pingularity && mkdir -p /data && touch /data/.pingularity-image-dir
3642

3743
# --- final image: distroless nonroot, carrying the capped binary ---
3844
FROM gcr.io/distroless/static-debian13:nonroot@sha256:f7f8f729987ad0fdf6b05eeeae94b26e6a0f613bdf46feea7fc40f7bd72953e6

Dockerfile.iperf

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@ COPY $TARGETPLATFORM/pingularity /pingularity
3636
# preserves the security.capability xattr across the COPY into the final image
3737
# (same guarantee the default Dockerfile relies on), so the final stage needs no
3838
# libcap2-bin of its own.
39-
RUN setcap cap_net_raw+ep /pingularity && mkdir -p /data
39+
# The .pingularity-image-dir marker is a volume-lineage HEURISTIC (not a proof
40+
# of volume type): Docker's copy-up carries it into a fresh named volume, so the
41+
# daemon's container carve-out (store.go) can tell content that came from OUR
42+
# image from an empty PVC or a plain bind-mounted host directory, which never
43+
# carry it. A bind mount restored FROM a marked volume would carry it too - an
44+
# accepted edge, since the content is genuinely ours.
45+
RUN setcap cap_net_raw+ep /pingularity && mkdir -p /data && touch /data/.pingularity-image-dir
4046

4147
# --- final image: debian-slim carrying iperf3 + the capped binary ---
4248
FROM debian:13-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd
@@ -65,6 +71,13 @@ COPY --from=setcap /pingularity /pingularity
6571
# 0755 (COPY does not carry the source mode across) and the daemon spends every
6672
# start warning that its own data directory is readable by others.
6773
COPY --from=setcap --chown=65532:65532 --chmod=0700 /data /var/lib/pingularity
74+
# Belt and braces: some BuildKit versions apply COPY --chmod to the files but
75+
# not the directory itself (observed: buildx honors it, ubuntu-latest's plain
76+
# docker build does not), and this image HAS a shell to correct it. The
77+
# distroless default image cannot RUN anything; its published buildx output is
78+
# verified 0700, and the daemon tightens its own container data dir at boot
79+
# either way (store.go's container carve-out).
80+
RUN chmod 0700 /var/lib/pingularity
6881
EXPOSE 9000
6982
VOLUME /var/lib/pingularity
7083
USER pingularity

README.md

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ go build -o pingularity . # requires Go 1.25.12+; pure Go, no cgo
2121
./pingularity # probes every 5s; UI on http://localhost:9000
2222
```
2323

24-
No flags needed. The UI binds `:9000` by default, but a fresh native install
25-
starts **private**: a built-in filter answers only the machine it runs on, and
26-
other devices get `403` until you flip **Network access** on in the settings
27-
drawer's Access tab (flip it on and hit Save; the tab shows the address to use). Docker
28-
installs skip the filter - it can't work behind container networking - so
29-
there the dashboard is LAN-reachable immediately. `-listen 127.0.0.1:9000`
30-
hard-pins it to local-only at the socket level.
24+
No flags needed. The UI binds `:9000` by default, but every install starts
25+
**private**: a built-in filter answers only the machine it runs on, and other
26+
devices get `403` until you flip **Network access** on in the settings drawer's
27+
Access tab (flip it on and hit Save; the tab shows the address to use), or start
28+
with `-access network`. This is true in a container too - a published port
29+
returns `403` until you set `-access network` (or `-e PINGULARITY_ACCESS=network`),
30+
so a container is never accidentally exposed. `-listen 127.0.0.1:9000` hard-pins
31+
it to local-only at the socket level regardless.
3132

3233
Connectivity is probed over **both IPv4 and IPv6** (each as an independent
3334
quorum of three anycast anchors). IPv6 is auto-detected - skipped on IPv4-only
@@ -191,6 +192,14 @@ The image is multi-arch (amd64 + arm64). Two flags matter:
191192
Exit row shows as unavailable (everything else still works). Spelling it out
192193
keeps the command correct everywhere.
193194
195+
> **Reaching the dashboard from other devices.** Every install starts
196+
> loopback-only, containers included - it is never guessed open from the network
197+
> setup. With `--network=host` the dashboard answers on the *host's*
198+
> `localhost:9000`, but other devices on your LAN get `403` until you opt in with
199+
> `-access network` (or `-e PINGULARITY_ACCESS=network`) - set a login at the same
200+
> time. A bridged container that publishes a port with `-p` needs the same flag,
201+
> or the published port returns `403`.
202+
194203
The **`-v pingularity-data:/var/lib/pingularity`** volume is what makes updates
195204
safe: the SQLite database *and* `pingularity.key` (which encrypts saved iperf3
196205
passwords) live there. Skip the volume and a `docker pull` + recreate throws
@@ -623,8 +632,12 @@ and persist across restarts:
623632
config / latency / speed / downtime, export them to a JSON file, and import one
624633
back - time-series data is **merged** (existing/newer local rows are kept, only
625634
missing rows are added) while **config is overwritten** and reloaded live.
626-
Both ends stream, so even a multi-hundred-MB export of years of history
627-
round-trips. The import warns you when it matters: restored rows older than
635+
Both ends stream on the wire, but the *browser* download buffers the file in
636+
memory, so a very large backup (years of dense history) can outgrow the tab -
637+
the dashboard stops and says so rather than hanging. For one that big, copy the
638+
SQLite database file at the `-db` path (that single file *is* the complete
639+
backup), or stream `/api/export` straight to disk with `curl -OJ` (add
640+
`-u user:pass` when a login is set). The import warns you when it matters: restored rows older than
628641
your current retention windows will be pruned within the hour (raise
629642
retention first to keep them), and a config restore that carried "login on"
630643
without a password leaves login off until you set one.
@@ -640,15 +653,12 @@ and persist across restarts:
640653
- **Access** → access controls (changes here apply on **Save**).
641654
**Network access** decides whether other devices can reach the dashboard /
642655
API / `/metrics`, or only this machine - a live loopback filter, so remote
643-
clients get 403. It starts **off on native installs** (localhost-only until
644-
you flip it) and **on in Docker**. In a *bridged* container the network hides
645-
who a request really came from, so there the filter cannot be enforced -
646-
publish the port narrowly and use the login password instead (the API reports
647-
the difference as `local_only` vs `local_only_active`, and the tab says so). A
648-
`--network=host` container sees real peer addresses, so local-only is enforced
649-
there exactly as on a native install. The tab shows the **reachable
650-
address(es)** with port plus a static-IP hint. **Require login** (off by
651-
default) gates
656+
clients get 403. It starts **off everywhere** (localhost-only until you flip
657+
it), containers included: the loopback filter is enforced the same way in every
658+
environment, and a container that must be reachable opts in explicitly with
659+
`-access network` (or `-e PINGULARITY_ACCESS=network`) rather than being guessed
660+
open. The tab shows the **reachable address(es)** with port plus a static-IP
661+
hint. **Require login** (off by default) gates
652662
everything behind a password: browsers get a login form + session cookie,
653663
while API clients and Prometheus use HTTP Basic with the same credentials
654664
(passwords are capped at 72 bytes, the bcrypt limit). Failed logins are
@@ -733,6 +743,7 @@ the settings drawer afterward and persists across restarts.
733743
| Flag | Default | Purpose |
734744
| --- | --- | --- |
735745
| `-listen` | `:9000` | UI + metrics address (`127.0.0.1:9000` = local-only at the socket) |
746+
| `-access` | `local` | who may open the dashboard: `local` (loopback only) or `network` (reachable from the LAN - set a login). A container that publishes a port needs `network` (or `PINGULARITY_ACCESS=network`), or the published port returns 403. Also settable in the UI |
736747
| `-db` | per-OS ([details](#run-in-the-background-systemd--launchd--windows-service)) | SQLite path (dir auto-created) |
737748
| `-interval` | `5s` | time between probe rounds, `1s`-`1h` (a value saved in the UI takes precedence) |
738749
| `-timeout` | `3s` | per-target dial timeout, `1s`-`30s` (a value saved in the UI takes precedence) |
@@ -747,10 +758,19 @@ the settings drawer afterward and persists across restarts.
747758
| `-allow-host` | *(none)* | extra `Host` header values the DNS-rebinding guard accepts - only needed behind a reverse proxy on a public domain |
748759
| `-trusted-proxy` | *(none)* | proxy IPs/CIDRs whose `X-Forwarded-For` identifies the real client, so one visitor's failed logins can't rate-limit everyone behind the proxy |
749760
| `-metrics-token` | *(none)* | optional read-only token a scraper presents to `/metrics` (Bearer or Basic password) instead of the admin login, so Prometheus needn't hold an account that can change settings; only consulted when Require login is on |
761+
| `-quick-setup` | `prompt` | headless first-run: `skip` starts monitoring immediately and never shows the browser Quick Setup dialog; `prompt` leaves it for a first visit |
750762
751763
Out-of-range numeric flags are rejected at startup (and at `pingularity
752764
install`) rather than silently adjusted.
753765
766+
> **Headless installs:** a genuinely fresh install waits (monitoring paused) for
767+
> a first-run consent - either the browser **Quick Setup** dialog or an explicit
768+
> flag - so it never starts probing before someone has said to. Passing any
769+
> monitoring flag (`-speedtest`, `-speedtest-interval`, `-latency`, `-interval`)
770+
> counts as that consent; if you only tune other knobs (say `-timeout` or
771+
> `-ipv6`) pass `-quick-setup=skip` so the service starts monitoring at boot
772+
> instead of holding for the dialog.
773+
754774
## Metrics (optional)
755775
756776
> **Grafana users:** there is an official importable dashboard (latency
@@ -1064,7 +1084,11 @@ constant memory.
10641084
uptime to report, exactly as `pingularity_uptime_ratio` is then absent. A
10651085
running speedtest is reported as `speedtest_running` plus `speedtest_run_id`
10661086
(`0` when idle) - that id is what `/api/speedtest/abort` takes, so a stop can
1067-
name the run it was decided against
1087+
name the run it was decided against. A fresh install awaiting first-run consent
1088+
reports `quick_setup_pending`, and `access_local_only` mirrors the loopback-only
1089+
access filter (so a client can default the Quick Setup access choice to how the
1090+
install booted); `bridged_container` is present only in a bridged container,
1091+
where measurements describe the container network rather than the host's
10681092
- `GET /api/series?mins=…[&exclude=…]` - latency / online time series (server-side
10691093
bucketed); `exclude` drops targets from the lowest-latency line. Also takes an
10701094
absolute window as `?from=&to=` (unix seconds, half-open `[from, to)`; omit `to`
@@ -1121,6 +1145,13 @@ constant memory.
11211145
has already measured a server keeps that result; an abort before the first
11221146
result stores nothing
11231147
- `GET|POST /api/monitoring` - read / set `{enabled}` master start/stop (the power toggle)
1148+
- `POST /api/quick-setup` - apply the first-run **Quick Setup** answer in ONE
1149+
transaction (speedtest cadence, network access, update check, and an optional
1150+
login) and mark it answered so the dialog never returns; `{dismiss:true}` marks
1151+
it answered without changing anything else. `auth_enabled` must agree with
1152+
whether a `password` is sent, and it refuses (`403`) once a login is already
1153+
configured - change access under Settings then. Fresh installs only; the offer
1154+
is `quick_setup_pending` in `/api/status`
11241155
- `GET|POST /api/update` - update-check status / toggle the daily release poll
11251156
- `GET|POST /api/logs` - the About-tab log viewer: read recent lines (or
11261157
`?download=1` for a text file, still the complete buffer) / set log level, PII
@@ -1171,6 +1202,14 @@ constant memory.
11711202
> and the database file `0600` (owner-only).
11721203
> Keep it that way if you relocate the DB with `-db`.
11731204
>
1205+
> **Legacy Docker volumes:** the database file is always owner-only, but a named
1206+
> volume created by an *older* image may have a group/world-readable directory
1207+
> root (Docker's volume copy-up loosens it), and Pingularity won't silently
1208+
> re-lock a directory it can't prove is its own - so it logs a one-line notice on
1209+
> start instead. The data is already private; to clear the notice, tighten the
1210+
> directory once: `docker exec <container> chmod 700 /var/lib/pingularity`. Fresh
1211+
> volumes are recognized automatically and need nothing.
1212+
>
11741213
> The full picture - what the trust boundary is, what privilege each install
11751214
> channel runs with, what the defaults protect and how to deploy it safely - is in
11761215
> [docs/security-model.md](docs/security-model.md). To report a vulnerability,

RELEASING.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@ git push origin v1.2.3
1818

1919
The `-X main.version=` ldflag is set from the tag, so `pingularity version`
2020
reports `1.2.3`. Use a valid SemVer tag with a leading `v`
21-
(`vMAJOR.MINOR.PATCH`, optionally `-prerelease`/`+build`). The release workflow
22-
guards the tag with a SemVer regex and refuses the obviously malformed shapes
23-
(bare `v`, `v1.2`, leading-zero identifiers) before it does anything - but that
21+
(`vMAJOR.MINOR.PATCH`, optionally `-prerelease`). Do **not** append `+build`
22+
metadata: SemVer allows it, but the workflow rejects it, because the hyphen in
23+
a value like `+build-1` makes the stable-vs-prerelease shell test misfire (a
24+
stable tag would skip the immutable-release guard) and Docker tags cannot carry
25+
`+` at all. The release workflow guards the tag with a SemVer regex and refuses
26+
the obviously malformed shapes (bare `v`, `v1.2`, leading-zero identifiers, and
27+
any `+build` metadata) before it does anything - but that
2428
regex is *close to*, not exactly, SemVer, so a pathological tag can still slip
2529
past it and is then rejected by GoReleaser itself. Either way, a tag that is not
2630
valid SemVer would never register as "newer" with running installs, because the

defaults_test.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func TestDefaultSettings(t *testing.T) {
3232
SpeedtestOnReconnect: true,
3333
IPv6Mode: "on",
3434
}
35-
v := defaultSettings(cfg, false)
35+
v := defaultSettings(cfg)
3636

3737
// Mapping: each distinct input lands on its own field. A swap flips one of these.
3838
checks := []struct {
@@ -89,13 +89,20 @@ func TestDefaultSettings(t *testing.T) {
8989
t.Errorf("DegradedPingMS/SpeedBusyMbps = %v/%v, want 150/5", v.DegradedPingMS, v.SpeedBusyMbps)
9090
}
9191

92-
// AccessLocalOnly: on for a native install (LAN opt-in), off in a container
93-
// (unenforceable there). The containerized inversion is easy to get backwards.
94-
if !defaultSettings(cfg, false).AccessLocalOnly {
95-
t.Error("native install must default AccessLocalOnly=true (LAN access opt-in)")
96-
}
97-
if defaultSettings(cfg, true).AccessLocalOnly {
98-
t.Error("container install must default AccessLocalOnly=false (filter unenforceable)")
92+
// Access is EXPLICIT, not guessed: loopback-only by default everywhere, and
93+
// off ONLY when the operator set -access network. No container heuristic.
94+
if !defaultSettings(cfg).AccessLocalOnly {
95+
t.Error("default (no -access) must be loopback-only everywhere, containers included")
96+
}
97+
net := cfg
98+
net.Access = "network"
99+
if defaultSettings(net).AccessLocalOnly {
100+
t.Error("-access network must default AccessLocalOnly=false")
101+
}
102+
loc := cfg
103+
loc.Access = "local"
104+
if !defaultSettings(loc).AccessLocalOnly {
105+
t.Error("-access local must default AccessLocalOnly=true")
99106
}
100107
}
101108

0 commit comments

Comments
 (0)