Skip to content

Serve the site on a network without Cloudflare: listen, site_url and access as separate settings (pm-site sprint 5) - #115

Merged
Yeeef merged 9 commits into
mainfrom
site-reach
Oct 25, 2026
Merged

Yeeef merged 9 commits into
mainfrom
site-reach

Conversation

@Yeeef

@Yeeef Yeeef commented Oct 17, 2026

Copy link
Copy Markdown
Owner

What

  • New .pm/config.toml settings, each written by its own pm init flag:
    • listen (--listen ADDR): the IP the site binds. 127.0.0.1 when unset. Beside a specific address other than 127.0.0.1 the service also binds 127.0.0.1, so probes, pm record link --local and a tunnel keep working. PortFree, CheckPort and FreePort bind exactly what the service binds.
    • access (--access cloudflare|none): how a request from beyond this machine gets in. It is required once listen is not loopback or site_url is set: the service refuses to start without it, pm doctor names it, and pm init refuses the flag change before writing anything.
  • Local trust is decided by the peer, not the Host header. A request is local only when its TCP peer is loopback, its Host names this machine and it has no Cf-Connecting-IP. A LAN peer sending Host: localhost is refused. The Host check stays, against rebinding domains reached from a local browser.
  • access = "none": the network reads site_url's host with no login. The owner decided that a non-local viewer may not reply:
    • their pages show the rule in place of every reply form and carry no token (Site.FillReadOnly);
    • /version hashes the page that viewer is served;
    • POST /reply gets a 403 naming the rule;
    • a request forwarded by Cloudflare's edge is refused.
  • access = "cloudflare": the Access JWT check as before.
  • Migration: pm init (session start included) and pm upgrade give a config with access_team and no access the line access = "cloudflare". It goes where ConfigText writes it, so pm doctor sees no drift. pm upgrade matters most here: it moves the pin that the restarted service reads.
  • pm doctor and a typed pm init warn when listen is on the network but site_url is unset, since every network request is then refused. pm where and the service log state a non-loopback listen.
  • Docs:
    • --help texts, with the golden files regenerated;
    • one prime.md clause;
    • changelog.d/site-listen-access.md, with an Upgrade guide step;
    • the design pages site-replies (Security rewritten by peer, Host and access), pm-product, pm-go and views-and-site.

Sprint: https://pm.yeeefs.com/sprints/pm-site-5.html

Evidence

  • make test-go: 19 packages ok. make lint: 0 issues. make test: 152 passed, 169 skipped.
  • Integration (make test-full -k): test_init_and_upgrade_keep_what_is_not_pms, test_service_refuses_the_public_host…, test_doctor_reports_each_changed…, test_service_install…, test_every_config_key…, test_reply_is_refused…: 7 passed.
  • Live, in a scratch clone with listen = "0.0.0.0", access = "none", site_url = http://byeeefy:42075; requests from the LAN address 192.168.178.115:
    • Host byeeefy:42075: 200, no token in the page;
    • Host: localhost: 403;
    • POST /reply: 403 naming the rule.
  • Live, from 127.0.0.1: GET 200; POST 303, and the reply was stored.
  • Cloudflare mode after the session-start migration: a tunnel-like request without a token got 403 "no Cloudflare Access token".
  • This repo's own tunnel waits for the pin move.

🤖 Generated with Claude Code

Yeeef and others added 9 commits October 25, 2026 15:55
Reach (listen, the bind address) and gate (access, cloudflare or none) become settings of their own beside
site_url, so a site can serve a network without Cloudflare. A listen that is not an IP or an unknown access fails
hard; AccessMissing names access once the site is exposed and the Access keys under access = "cloudflare";
Migrated is the one explicit inference, for configs from before access existed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d-only

A request is local only from a loopback peer naming this machine with no Cf-Connecting-IP: with a LAN bind, Host
alone would let any colleague claim localhost, while the Host check stays for a rebinding domain a local browser
reaches. Under access = "none" a network viewer reads (pages show the rule instead of reply forms and carry no
token) and its reply is refused, per the owner's decision: a reply instructs an agent session and pm cannot tell
the owner from a colleague. The service refuses to start exposed without access; a specific listen address also
binds 127.0.0.1 so this machine's probes and links keep working, and the port check binds what the service binds.
pm init --listen/--access write the settings and refuse an exposed config without access before any write; pm init
and pm upgrade migrate access_team configs to access = "cloudflare"; pm doctor and pm where state the new settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s once

listen = "::ffff:127.0.0.1" bound 127.0.0.1 twice: PortFree was always false and FreePort never returned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bare "[::1]" Host became "[:", and a bracketed IPv6 site_url host never matched url.Hostname's unbracketed form.
The Binds test rides along in access_test.go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both write access = "cloudflare" where ConfigText puts it and keep every other key, so pm doctor finds no drift;
without the upgrade's migration the test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only site_url's host is served beyond this machine, so listen = "0.0.0.0" without it refuses every request from the
network; both name pm init --site-url.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebased onto main, whose served-site move lowered internal/cli's size
ratchet to 7307 lines; this sprint's two pm init flags put it 2 over.
syncStore, what the pm service runs for pm_sync(), joins SetupStore,
what it runs for pm_setup(), in install/workstore.go as SyncStore.
internal/cli is down to 7263 lines and its ratchet follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Yeeef
Yeeef merged commit 548f486 into main Oct 25, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant