Skip to content
Merged
26 changes: 26 additions & 0 deletions changelog.d/site-listen-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
The site can serve a company network without Cloudflare: where it listens (`listen`), the link pm prints
(`site_url`) and how a request from beyond this machine gets in (`access`) are now three settings.

### Upgrade guide

1. Commit `.pm/config.toml` once `pm upgrade` (or `pm init`) has added `access = "cloudflare"` to a config that sets
`access_team`. A config with `site_url` and no `access_team` gets no access: set one with
`pm init --access cloudflare` or `pm init --access none`, or the pm service does not start.

### Added

- `pm init --listen ADDR` sets `listen`, the IP the site binds (127.0.0.1 when unset; `0.0.0.0` for every
interface), and `pm init --access cloudflare|none` sets `access`. With `access = "none"` the network is the gate:
colleagues read the site at `site_url` with no login, but replies are taken only from this machine; their pages
show the rule in place of each reply form. `pm where` and the service log state a non-loopback `listen`.

### Changed

- `access` is required once `listen` is not loopback or `site_url` is set: the pm service does not start without
it, and `pm doctor` names it.

### Security

- A request is local only when its TCP peer is loopback, its `Host` names this machine and Cloudflare's edge did not
forward it; the `Host` header alone no longer grants local trust, so a client on the network sending
`Host: localhost` is refused.
2 changes: 1 addition & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ func cmdMode(here string, on bool, stdout io.Writer) error {

func runInit(p *Parsed, here string, stdout io.Writer) error {
if imp := p.Given("import_bd", "import"); imp != nil { // the import alone, into the work store
if other := p.Given("session_start", "site_url"); other != nil {
if other := p.Given("session_start", "site_url", "listen", "access"); other != nil {
return &usageError{Cmd: p.Cmd, Msg: fmt.Sprintf("argument %s: not allowed with argument %s", imp.Name(), other.Name())}
}
if imp.Dest == "import_bd" {
Expand Down
12 changes: 7 additions & 5 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,8 +618,8 @@ var tree = &command{
{
Name: "run",
Hidden: true,
Help: "the service's process, run by its supervisor: serve the site on localhost:$PORT and push every 10 minutes",
Description: "Serve the site on localhost:$PORT (default: port in .pm/config.toml): a page is at most 10 s behind the records and the work store and states its data's age; an open page never reloads itself but shows within ~10 s that newer data exists, loaded on reload. Every 10 minutes, the first 10 after start, run pm push. Exits once .pm/config.toml pins another pm version, so the supervisor starts the installed pm again, which runs the new pin. The supervisor runs it; run it by hand only to debug, or on another PORT.",
Help: "the service's process, run by its supervisor: serve the site on $PORT at the config's listen address (127.0.0.1 when unset) and push every 10 minutes",
Description: "Serve the site on $PORT (default: port in .pm/config.toml) at listen in .pm/config.toml (127.0.0.1 when unset; beside another specific address 127.0.0.1 too), letting in each request as access says (pm init --help); refused when listen is not loopback or site_url is set and access is not: a page is at most 10 s behind the records and the work store and states its data's age; an open page never reloads itself but shows within ~10 s that newer data exists, loaded on reload. Every 10 minutes, the first 10 after start, run pm push. Exits once .pm/config.toml pins another pm version, so the supervisor starts the installed pm again, which runs the new pin. The supervisor runs it; run it by hand only to debug, or on another PORT.",
},
},
},
Expand All @@ -640,12 +640,14 @@ var tree = &command{
},
{
Name: "init",
Help: "install pm: the repo's files on first install (--site-url sets the public site link), then this clone, this worktree and the pm service (PORT=<n> sets its port, and on a first install the config's; PORT=<n> pm service install moves only the service's); session start runs it; never commits on the code branch",
Description: "Install pm, doing only what is missing. Repo, on first install only (no .pm/config.toml yet): write .pm/ (config.toml, README.md, .gitignore), pm's hook entries in .claude/settings.json and .codex/hooks.json, the Codex rule .codex/rules/pm.rules (pm runs outside Codex's sandbox), Codex's auto mode in .codex/config.toml (approval_policy on-request, approvals_reviewer auto_review, sandbox_mode workspace-write; Codex reads it in a repo you trust; a key you set to another value stays yours: pm doctor does not name it, pm upgrade keeps it, pm uninstall leaves it) and pm's .gitignore lines; create the records branch with an empty store and push it when the remote has none; print the commit to make. After that pm init leaves the repo's files alone: pm doctor reports a changed or missing piece and pm upgrade rewrites it. Clone and worktree, every run: copy this pm into the bin dir when another is there, take out Beads' hook entries and CLAUDE.md block, unset a core.hooksPath that names .pm/hooks, where an earlier pm kept its git hooks, while nothing but pm's sections is there (pm installs none; a hooks path elsewhere, or one holding hooks of yours, stays), list .pm/store/ and .pm/run/ in .git/info/exclude, check out the records store at <main checkout>/.pm/store/records if missing, give this worktree its records/ (in the main checkout the link to the store, with the store in its .claude/settings.local.json; in any other worktree its records workspace, a worktree of branch drafts/<worktree's name> at <worktree>/records in place of an earlier pm's link, where its sessions edit records) and turn off the sparse checkout an earlier pm set once HEAD and the index track no records/, add the clone's .git and the stores to the writable roots of $CODEX_HOME/config.toml, then install the pm service (pm service install) and have it attach the work store, which only the service opens (cloned from the remote's refs/pm/work, or created and pushed). Session start runs it in every worktree, and starts an installed service that does not answer; once all is set up it prints 'already set up'. Site port: a new repo's config gets $PORT, else the first free port from 8000 up that no pm service unit on this machine names; a clone's service serves on $PORT, else its unit's port, else the config's. Refuses, writing nothing, when another server holds that port, and names a free one: PORT=<n> pm init. .pm/config.toml, tracked: version (the pm every session must run; pm upgrade moves it), remote and main_branch (origin and its default branch), port (the site port) and site_url (--site-url); with site_url set, access_team (the Cloudflare Access team domain, <team>.cloudflareaccess.com) and access_aud (the Access application's AUD tag), set by hand: the site serves a request to site_url's host, or any request Cloudflare's edge forwarded (Cf-Connecting-IP), only when its Cf-Access-Jwt-Assertion verifies against them (RS256 by the team's keys, aud, iss, unexpired), and refuses every such request while either is missing; pm doctor names a missing one, and the service reads them when it starts.",
Help: "install pm: the repo's files on first install (--site-url sets the public site link, --listen and --access who reaches it), then this clone, this worktree and the pm service (PORT=<n> sets its port, and on a first install the config's; PORT=<n> pm service install moves only the service's); session start runs it; never commits on the code branch",
Description: "Install pm, doing only what is missing. Repo, on first install only (no .pm/config.toml yet): write .pm/ (config.toml, README.md, .gitignore), pm's hook entries in .claude/settings.json and .codex/hooks.json, the Codex rule .codex/rules/pm.rules (pm runs outside Codex's sandbox), Codex's auto mode in .codex/config.toml (approval_policy on-request, approvals_reviewer auto_review, sandbox_mode workspace-write; Codex reads it in a repo you trust; a key you set to another value stays yours: pm doctor does not name it, pm upgrade keeps it, pm uninstall leaves it) and pm's .gitignore lines; create the records branch with an empty store and push it when the remote has none; print the commit to make. After that pm init leaves the repo's files alone: pm doctor reports a changed or missing piece and pm upgrade rewrites it. Clone and worktree, every run: copy this pm into the bin dir when another is there, take out Beads' hook entries and CLAUDE.md block, unset a core.hooksPath that names .pm/hooks, where an earlier pm kept its git hooks, while nothing but pm's sections is there (pm installs none; a hooks path elsewhere, or one holding hooks of yours, stays), list .pm/store/ and .pm/run/ in .git/info/exclude, check out the records store at <main checkout>/.pm/store/records if missing, give this worktree its records/ (in the main checkout the link to the store, with the store in its .claude/settings.local.json; in any other worktree its records workspace, a worktree of branch drafts/<worktree's name> at <worktree>/records in place of an earlier pm's link, where its sessions edit records) and turn off the sparse checkout an earlier pm set once HEAD and the index track no records/, add the clone's .git and the stores to the writable roots of $CODEX_HOME/config.toml, then install the pm service (pm service install) and have it attach the work store, which only the service opens (cloned from the remote's refs/pm/work, or created and pushed). Session start runs it in every worktree, and starts an installed service that does not answer; once all is set up it prints 'already set up'. Site port: a new repo's config gets $PORT, else the first free port from 8000 up that no pm service unit on this machine names; a clone's service serves on $PORT, else its unit's port, else the config's. Refuses, writing nothing, when another server holds that port, and names a free one: PORT=<n> pm init. .pm/config.toml, tracked: version (the pm every session must run; pm upgrade moves it), remote and main_branch (origin and its default branch), port (the site port), site_url (--site-url), listen (--listen) and access (--access). The site is three settings: listen is the address it binds, 127.0.0.1 (this machine only) when unset; site_url is the link pm prints and the one Host besides this machine's it serves; access is how a request from beyond this machine gets in, required once listen is not loopback or site_url is set (the service does not start without it). A request is local, and served, only when its TCP peer is loopback, its Host is localhost or 127.0.0.1 and Cloudflare's edge did not forward it (Cf-Connecting-IP); any other must name site_url's host, or come through Cloudflare's edge. access = \"cloudflare\": with access_team (the Cloudflare Access team domain, <team>.cloudflareaccess.com) and access_aud (the Access application's AUD tag), set by hand, such a request is served only when its Cf-Access-Jwt-Assertion verifies against them (RS256 by the team's keys, aud, iss, unexpired), and every one is refused while either is missing. access = \"none\": the network is the gate; such a request reads the site but may not reply (its pages show no reply form, and a POST /reply is refused), since pm cannot tell the owner from anyone else on it; a request Cloudflare's edge forwarded is refused. A config with access_team and no access gets access = \"cloudflare\" from pm init. pm doctor names a missing key, and the service reads them when it starts.",
Groups: []bool{false},
Args: []arg{
{Flags: []string{"--session-start"}, Dest: "session_start", Kind: switchOn, Help: "what session start runs, without $PORT: install the pm service when it is missing and start an installed one that does not answer, so a session in a clone brings its service up; one pm service stop stopped is left stopped and reported (pm service restart starts it); a running one, a stale one included, is left as it is and reported (pm service restart restarts it)"},
{Flags: []string{"--site-url"}, Dest: "site_url", Metavar: []string{"URL"}, Kind: value, Help: "the site's public base URL (a tunnel to the pm service), written to site_url in .pm/config.toml for you to commit: every link pm prints (pm record link, pm show, pm where) uses it instead of http://localhost:<port>, and the site serves its host besides localhost, behind Cloudflare Access (access_team, access_aud); '' clears it"},
{Flags: []string{"--site-url"}, Dest: "site_url", Metavar: []string{"URL"}, Kind: value, Help: "the site's public base URL (a tunnel to the pm service), written to site_url in .pm/config.toml for you to commit: every link pm prints (pm record link, pm show, pm where) uses it instead of http://localhost:<port>, and the site serves its host besides localhost, behind access; '' clears it"},
{Flags: []string{"--listen"}, Dest: "listen", Metavar: []string{"ADDR"}, Kind: value, Help: "the IP address the site binds, written to listen in .pm/config.toml for you to commit: 0.0.0.0 serves every interface, a LAN address one (the service binds 127.0.0.1 beside it); a non-loopback one needs access; '' clears it (127.0.0.1); pm service restart applies it"},
{Flags: []string{"--access"}, Dest: "access", Choices: []string{"cloudflare", "none"}, Kind: value, Help: "how a request from beyond this machine gets in, written to access in .pm/config.toml for you to commit: cloudflare verifies a Cloudflare Access token (access_team, access_aud), none lets the network read and replies come only from this machine; required with a non-loopback listen or a site_url"},
{Flags: []string{"--import-bd"}, Dest: "import_bd", Metavar: []string{"FILE"}, Kind: value, Group: 1, Help: "only import the bd export in FILE into this clone's work store, through the pm service, as one commit; refuses a store that holds any item, and an export it cannot map; nothing else runs"},
{Flags: []string{"--import"}, Dest: "import", Metavar: []string{"FILE"}, Kind: value, Group: 1, Help: "only import the pm export in FILE (what pm export printed in another clone) into this clone's work store, ids kept, as --import-bd does"},
},
Expand Down
54 changes: 4 additions & 50 deletions internal/cli/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"

"github.com/Yeeef/pm/internal/buildinfo"
"github.com/Yeeef/pm/internal/cli/served"
Expand Down Expand Up @@ -43,7 +41,9 @@ func serviceRun(cfg config.Config, here, main, records string, stdout, stderr io
return err
}
h, err := work.NewHost(work.HostOptions{Main: main, Version: buildinfo.Version, Ops: work.Ops{
Sync: func(c context.Context, d *work.Dolt) ([]string, error) { return syncStore(c, d, main, cfg.Remote) },
Sync: func(c context.Context, d *work.Dolt) ([]string, error) {
return install.SyncStore(c, d, main, cfg.Remote)
},
Setup: func(c context.Context, d *work.Dolt) ([]string, error) {
return install.SetupStore(c, d, main, cfg.Remote)
},
Expand All @@ -54,7 +54,7 @@ func serviceRun(cfg config.Config, here, main, records string, stdout, stderr io
defer h.Close()
dir, _ := work.Locations(main)
return service.Run(service.Deps{Main: main, Records: records, Remote: cfg.Remote, MainBranch: cfg.MainBranch,
SiteURL: cfg.SiteURL, AccessTeam: cfg.AccessTeam, AccessAud: cfg.AccessAud, Port: port, Pin: cfg.Path, Spool: filepath.Join(common, service.SpoolName), WorkDir: dir,
SiteURL: cfg.SiteURL, Listen: cfg.ListenAddr(), Access: cfg.Access, AccessTeam: cfg.AccessTeam, AccessAud: cfg.AccessAud, Port: port, Pin: cfg.Path, Spool: filepath.Join(common, service.SpoolName), WorkDir: dir,
Open: func() (work.Store, error) {
return work.DialSock(h.Sock(), main, work.Options{Prefix: filepath.Base(main)})
},
Expand Down Expand Up @@ -85,52 +85,6 @@ func serviceInstall(cfg config.Config, main string) (string, error) {
return "already installed and current\n" + line, nil
}

// syncStore is the sync the pm service runs (every 600 s, and for pm_sync(): pm sync, pm push), on a connection of
// its own: pull, merge, push, said in one line, then a warning per claim the merge overrode. A store with no remote yet
// (the repo's remote was added after pm init made the store) is first attached to the config's remote, under
// work.RemoteRef, and the line says so; refused when the remote holds a work store already, which a store made apart
// from it shares no history with. The sync stops once ctx is done; a ctx done already starts none, since Dolt's first
// reach of a remote, killed, leaves a remote cache no sync repairs.
func syncStore(ctx context.Context, d *work.Dolt, main, remote string) ([]string, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
attached := ""
if _, ok, err := d.Remote(); err != nil {
return nil, err
} else if !ok {
url, err := git(main, "remote", "get-url", remote)
if err != nil {
return nil, fmt.Errorf("work store: it has no remote to sync with, and %v", err)
}
held, err := install.RemoteHasStore(ctx, main, remote) // within the sync's bound
if err != nil {
return nil, fmt.Errorf("work store: it has no remote to sync with, and %v", err)
}
if held {
return nil, fmt.Errorf("work store: it has no remote, and %s holds a work store under %s already, "+
"which this one, made apart from it, shares no history with; move %s away and run pm init, which "+
"clones it", remote, work.RemoteRef, filepath.Join(main, ".pm/store/work"))
}
if err := d.AddRemote(url); err != nil {
return nil, err
}
attached = fmt.Sprintf("attached the work store to %s under %s; ", remote, work.RemoteRef)
}
r, err := d.SyncContext(ctx)
if err != nil {
return nil, err
}
lines := []string{attached + fmt.Sprintf("synced: pulled %d commits, resolved %d items both sides changed, "+
"pushed %d commits", r.Pulled, r.Resolved, r.Pushed)}
for _, o := range r.Overrides {
lines = append(lines, fmt.Sprintf("warning: %s: the claim by %s (%s) was overridden by the later claim "+
"of %s (%s)", o.ID, o.Lost.Session, o.Lost.ClaimedAt.Format(time.RFC3339), o.Kept.Session,
o.Kept.ClaimedAt.Format(time.RFC3339)))
}
return lines, nil
}

// summarizeDay is the sync's summary step: pm day summarize from the main checkout, ok and what it did, or why not.
func summarizeDay(main string) (bool, string) {
records, err := store.Find(main)
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/served/served.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ func (s *Site) FillReplies(page, token string, replies map[string]service.Reply)
return site.FillReplies(page, token, shown)
}

func (s *Site) FillReadOnly(page, rule string) string { return site.FillReadOnly(page, rule) }

func (s *Site) FillStatus(page string, asOf time.Time, digest string, now time.Time) string {
return site.FillStatus(page, asOf, digest, now)
}
Expand Down
16 changes: 13 additions & 3 deletions internal/cli/setup/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/Yeeef/pm/internal/store"
"io"
"os/exec"
"slices"
"strings"
)

Expand Down Expand Up @@ -40,9 +41,18 @@ func Doctor(here string, stdout io.Writer) error {
for _, d := range repo {
diffs = append(diffs, fmt.Sprintf("repo: %s; run %s to rewrite it", d, fix))
}
if missing := c.AccessMissing(); len(missing) > 0 {
diffs = append(diffs, fmt.Sprintf("config: site_url is set, but %s lacks %s, so the site refuses every request "+
"to its host; set what is missing there (pm init --help)", config.Rel, strings.Join(missing, ", ")))
switch missing := c.AccessMissing(); {
case slices.Contains(missing, "access"):
diffs = append(diffs, fmt.Sprintf("config: the site is reachable beyond this machine (listen = %q, site_url = %q), "+
"but %s lacks access, so the pm service does not start; set it with pm init --access cloudflare or --access none",
c.ListenAddr(), c.SiteURL, config.Rel))
case len(missing) > 0:
diffs = append(diffs, fmt.Sprintf("config: access = \"cloudflare\", but %s lacks %s, so the site refuses every "+
"request from beyond this machine; set what is missing there (pm init --help)", config.Rel,
strings.Join(missing, ", ")))
}
if why := c.Unaddressed(); why != "" {
diffs = append(diffs, "config: "+why)
}
port, err := service.PortFor(main, int(c.Port))
if err != nil {
Expand Down
Loading
Loading