diff --git a/changelog.d/site-listen-access.md b/changelog.d/site-listen-access.md new file mode 100644 index 00000000..bc431cdb --- /dev/null +++ b/changelog.d/site-listen-access.md @@ -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. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c8311d9f..9a58f39b 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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" { diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 417e317a..baa4e821 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -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.", }, }, }, @@ -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= sets its port, and on a first install the config's; PORT= 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
/.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/ at /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= 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, .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= sets its port, and on a first install the config's; PORT= 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
/.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/ at /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= 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, .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:, 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:, 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"}, }, diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 37d95ac4..c4ad0028 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -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" @@ -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) }, @@ -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)}) }, @@ -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) diff --git a/internal/cli/served/served.go b/internal/cli/served/served.go index f2764c61..3979facf 100644 --- a/internal/cli/served/served.go +++ b/internal/cli/served/served.go @@ -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) } diff --git a/internal/cli/setup/doctor.go b/internal/cli/setup/doctor.go index 41fbeee8..c2aa670a 100644 --- a/internal/cli/setup/doctor.go +++ b/internal/cli/setup/doctor.go @@ -12,6 +12,7 @@ import ( "github.com/Yeeef/pm/internal/store" "io" "os/exec" + "slices" "strings" ) @@ -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 { diff --git a/internal/cli/setup/init.go b/internal/cli/setup/init.go index 08d8931b..f11f5838 100644 --- a/internal/cli/setup/init.go +++ b/internal/cli/setup/init.go @@ -16,10 +16,12 @@ import ( "github.com/Yeeef/pm/internal/store" "github.com/Yeeef/pm/internal/work" "io" + "net" "net/url" "os" "path/filepath" "regexp" + "slices" "strings" ) @@ -61,7 +63,46 @@ func checkSiteURL(u string) error { func siteURLArg(v string) string { return strings.TrimRight(config.PyStrip(v), "/") } -var siteLine = regexp.MustCompile(`(?m)^site_url\s*=.*\n?`) +// keyOrder is the order install.ConfigText writes the config's keys in, which pm doctor compares the file with: read +// from a config that sets every key. +var keyOrder = func() []string { + var keys []string + all := install.ConfigText(install.Settings{Remote: "r", MainBranch: "m", Port: 1, SiteURL: "s", Listen: "l", Access: "a", + AccessTeam: "t", AccessAud: "u", SummaryRuntime: "c", DesignSources: []string{"d"}}) + for _, line := range strings.Split(strings.TrimSpace(all), "\n") { + keys = append(keys, strings.TrimSpace(strings.SplitN(line, "=", 2)[0])) + } + return keys +}() + +// setKey sets key to the TOML string v in the config file at path, in place of its line, else before the first key +// ConfigText writes after it, else appended; "" removes it. +func setKey(path, key, v string) error { + b, err := os.ReadFile(path) + if err != nil { + return err + } + text, line := string(b), "" + if v != "" { + line = key + ` = "` + v + "\"\n" + } + if loc := regexp.MustCompile(`(?m)^` + key + `\s*=.*\n?`).FindStringIndex(text); loc != nil { + text = text[:loc[0]] + line + text[loc[1]:] + return os.WriteFile(path, []byte(text), 0o644) + } + for _, later := range keyOrder[slices.Index(keyOrder, key)+1:] { + if loc := regexp.MustCompile(`(?m)^` + later + `\s*=`).FindStringIndex(text); loc != nil && v != "" { + return os.WriteFile(path, []byte(text[:loc[0]]+line+text[loc[0]:]), 0o644) + } + } + if v != "" { + if text != "" && !strings.HasSuffix(text, "\n") { + text += "\n" + } + text += line + } + return os.WriteFile(path, []byte(text), 0o644) +} // setupSiteURL stores the repo's public site URL as site_url in .pm/config.toml ("" removes it), for the owner to // commit; "" when nothing changed. @@ -76,23 +117,7 @@ func setupSiteURL(v string, c config.Config, main string) (string, error) { if u == old { return "", nil } - b, err := os.ReadFile(c.Path) - if err != nil { - return "", err - } - text, line := string(b), "" - if u != "" { - line = `site_url = "` + u + "\"\n" - } - if loc := siteLine.FindStringIndex(text); loc != nil { - text = text[:loc[0]] + line + text[loc[1]:] - } else if u != "" { - if text != "" && !strings.HasSuffix(text, "\n") { - text += "\n" - } - text += line - } - if err := os.WriteFile(c.Path, []byte(text), 0o644); err != nil { + if err := setKey(c.Path, "site_url", u); err != nil { return "", err } if u != "" { @@ -109,9 +134,104 @@ func setupSiteURL(v string, c config.Config, main string) (string, error) { return fmt.Sprintf("site URL cleared (was %s) in %s; commit it; links use http://localhost:%d", old, c.Path, port), nil } +// reach is pm init's --listen and --access, each "" when not given; "" for --listen clears it (the site binds +// loopback again). +type reach struct { + listen, access string + listenGiven, accessGiven bool +} + +func reachArgs(p Arguments) reach { + return reach{listen: config.PyStrip(p.Get("listen")), access: p.Get("access"), + listenGiven: len(p.ValuesOf("listen")) > 0, accessGiven: len(p.ValuesOf("access")) > 0} +} + +// check refuses a --listen that is not an IP address. +func (r reach) check() error { + if r.listen != "" && net.ParseIP(r.listen) == nil { + return fmt.Errorf("--listen %q is not an IP address like %s (this machine only) or 0.0.0.0 (every interface)", + r.listen, config.Loopback) + } + return nil +} + +// needsAccess refuses settings that expose the site with no access, which the pm service would not start on. +func needsAccess(c config.Config) error { + if slices.Contains(c.AccessMissing(), "access") { + return fmt.Errorf("the site would be reachable beyond this machine (listen = %q, site_url = %q) with no access "+ + "set, which the pm service does not start on: give --access cloudflare (Cloudflare Access verifies each "+ + "request) or --access none (the network is the gate); pm init wrote nothing", c.ListenAddr(), c.SiteURL) + } + return nil +} + +// planConfig is whether a later pm init migrates c to access = "cloudflare"; refused when the config it would write +// exposes the site with no access after a flag changed it. +func planConfig(c config.Config, siteArg string, siteGiven bool, r reach) (bool, error) { + want, migrate := c.Migrated() + migrate = migrate && !r.accessGiven + if siteGiven { + want.SiteURL = siteURLArg(siteArg) + } + if r.listenGiven { + want.Listen = r.listen + } + if r.accessGiven { + want.Access = r.access + } + if siteGiven || r.listenGiven || r.accessGiven { + return migrate, needsAccess(want) + } + return migrate, nil +} + +// setupConfig is a later pm init's writes to .pm/config.toml, each said: the migration that gives a config with the +// Cloudflare Access keys and no access access = "cloudflare" (what it meant before access existed), then +// --site-url, --listen and --access as given. Refused, writing nothing, when the result would expose the site with no +// access. +func setupConfig(c config.Config, main, siteArg string, siteGiven bool, r reach) ([]string, error) { + migrate, err := planConfig(c, siteArg, siteGiven, r) + if err != nil { + return nil, err + } + var said []string + if migrate { + if err := setKey(c.Path, "access", "cloudflare"); err != nil { + return nil, err + } + said = append(said, fmt.Sprintf(`access set to "cloudflare" in %s, as its access_team means; commit it`, c.Path)) + } + if siteGiven { + line, err := setupSiteURL(siteArg, c, main) + if err != nil { + return nil, err + } + if line != "" { + said = append(said, line) + } + } + for _, kv := range []struct { + key, old, v string + given bool + }{{"listen", c.Listen, r.listen, r.listenGiven}, {"access", c.Access, r.access, r.accessGiven}} { + if !kv.given || kv.v == kv.old { + continue + } + if err := setKey(c.Path, kv.key, kv.v); err != nil { + return nil, err + } + if kv.v == "" { + said = append(said, fmt.Sprintf("%s cleared (was %s) in %s; commit it", kv.key, kv.old, c.Path)) + } else { + said = append(said, fmt.Sprintf("%s set to %s in %s; commit it; pm service restart applies it", kv.key, kv.v, c.Path)) + } + } + return said, nil +} + // initSettings is a new repo's settings: the remote origin, which must exist, and its default branch (origin/HEAD), // else the branch checked out; port is the site port. -func initSettings(top, siteArg string, given bool, port int) (install.Settings, error) { +func initSettings(top, siteArg string, given bool, r reach, port int) (install.Settings, error) { remote := "origin" if install.RemoteURL(top, remote) == "" { return install.Settings{}, fmt.Errorf("this repo has no remote %s; pm keeps the records branch and the work store "+ @@ -137,7 +257,10 @@ func initSettings(top, siteArg string, given bool, port int) (install.Settings, return install.Settings{}, err } } - return install.Settings{Remote: remote, MainBranch: branch, Port: port, SiteURL: u}, nil + if err := needsAccess(config.Config{SiteURL: u, Listen: r.listen, Access: r.access}); err != nil { + return install.Settings{}, err + } + return install.Settings{Remote: remote, MainBranch: branch, Port: port, SiteURL: u, Listen: r.listen, Access: r.access}, nil } // worktreeChanges is each path git status shows in top (untracked files one by one), in its order, with its status and, @@ -228,11 +351,15 @@ func Init(p Arguments, here string, stdout io.Writer) error { } sessionStart := p.Get("session_start") == "true" siteArg, siteGiven := p.Get("site_url"), len(p.ValuesOf("site_url")) > 0 + r := reachArgs(p) + if err := r.check(); err != nil { + return err + } fresh := !isFile(filepath.Join(top, config.Rel)) if err := checkInitWorktree(here, top, main, fresh); err != nil { return err } - s, err := settingsForInit(here, top, main, siteArg, siteGiven, fresh) + s, err := settingsForInit(here, top, main, siteArg, siteGiven, r, fresh) if err != nil { return err } @@ -246,7 +373,14 @@ func Init(p Arguments, here string, stdout io.Writer) error { return err } } - if err := service.CheckPort(main, port); err != nil { // refused before anything is written + listen := s.Listen + if r.listenGiven { + listen = r.listen + } + if listen == "" { + listen = config.Loopback + } + if err := service.CheckPort(main, listen, port); err != nil { // refused before anything is written return err } } @@ -260,7 +394,7 @@ func Init(p Arguments, here string, stdout io.Writer) error { } } var out, written []string - done, err := initSteps(here, top, main, s, fresh, sessionStart, siteArg, siteGiven, &out, &written) + done, err := initSteps(here, top, main, s, fresh, sessionStart, siteArg, siteGiven, r, &out, &written) if err != nil { hint, herr := commitHint(top, before, written) if herr != nil || hint == "" { @@ -279,6 +413,11 @@ func Init(p Arguments, here string, stdout io.Writer) error { lines = append(lines, l) } } + if c, err := config.Load(here); err == nil && !sessionStart { // a typed pm init says what the config leaves unserved + if why := c.Unaddressed(); why != "" { + lines = append(lines, "warning: "+why) + } + } if !sessionStart { // session start runs no Codex: it would cost seconds at every start skips, _, err := install.CodexTrust(main, install.CodexTrustTimeout) if err != nil { @@ -293,7 +432,7 @@ func Init(p Arguments, here string, stdout io.Writer) error { // initSteps is pm init's writes, in order; out and written say how far it got when one fails. The repo's half (pm's // pieces, the records branch) runs only when fresh. func initSteps(here, top, main string, s install.Settings, fresh, sessionStart bool, siteArg string, siteGiven bool, - out, written *[]string) (string, error) { + r reach, out, written *[]string) (string, error) { said, err := install.InstallBinary() // the service's unit and the hooks run the bin-dir pm, not this one if err != nil { return "", err @@ -301,7 +440,7 @@ func initSteps(here, top, main string, s install.Settings, fresh, sessionStart b if said != "" { *out = append(*out, said) } - if err := initRepo(here, top, main, s, fresh, siteArg, siteGiven, out, written); err != nil { + if err := initRepo(here, top, main, s, fresh, siteArg, siteGiven, r, out, written); err != nil { return "", err } done, err := install.SetupClone(here, s.Remote) @@ -346,14 +485,18 @@ func checkInitWorktree(here, top, main string, fresh bool) error { return nil } -func settingsForInit(here, top, main, siteArg string, siteGiven, fresh bool) (install.Settings, error) { +func settingsForInit(here, top, main, siteArg string, siteGiven bool, r reach, fresh bool) (install.Settings, error) { var s install.Settings if fresh { // the site port: $PORT, else the clone's unit's, else the first free one no pm unit here names - port, err := service.PortFor(main, service.FreePort()) + listen := r.listen + if listen == "" { + listen = config.Loopback + } + port, err := service.PortFor(main, service.FreePort(listen)) if err != nil { return install.Settings{}, err } - if s, err = initSettings(top, siteArg, siteGiven, port); err != nil { + if s, err = initSettings(top, siteArg, siteGiven, r, port); err != nil { return install.Settings{}, err } } else { @@ -367,11 +510,14 @@ func settingsForInit(here, top, main, siteArg string, siteGiven, fresh bool) (in return install.Settings{}, err } } + if _, err := planConfig(c, siteArg, siteGiven, r); err != nil { // before anything is written + return install.Settings{}, err + } } return s, nil } -func initRepo(here, top, main string, s install.Settings, fresh bool, siteArg string, siteGiven bool, out, written *[]string) error { +func initRepo(here, top, main string, s install.Settings, fresh bool, siteArg string, siteGiven bool, r reach, out, written *[]string) error { if fresh { planned, err := install.Plan(top, s) if err != nil { @@ -401,18 +547,16 @@ func initRepo(here, top, main string, s install.Settings, fresh bool, siteArg st return err } *out = append(*out, install.Said(planned)...) - } else if siteGiven { + } else { c, err := config.Load(here) if err != nil { return err } - said, err := setupSiteURL(siteArg, c, main) + said, err := setupConfig(c, main, siteArg, siteGiven, r) if err != nil { return err } - if said != "" { - *out = append(*out, said) - } + *out = append(*out, said...) } return nil } diff --git a/internal/cli/setup/upgrade.go b/internal/cli/setup/upgrade.go index 0df900f0..4a032626 100644 --- a/internal/cli/setup/upgrade.go +++ b/internal/cli/setup/upgrade.go @@ -59,7 +59,8 @@ func Upgrade(p Arguments, here string, stdout io.Writer) error { return err } fmt.Fprint(stdout, guide) - planned, err := install.Rewrite(top, install.SettingsOf(c)) + migrated, _ := c.Migrated() // the pin it moves to reads access: the rewrite carries it, so the service starts on it + planned, err := install.Rewrite(top, install.SettingsOf(migrated)) if err != nil { return err } diff --git a/internal/cli/store_commands_test.go b/internal/cli/store_commands_test.go index 94def18f..22264e55 100644 --- a/internal/cli/store_commands_test.go +++ b/internal/cli/store_commands_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/Yeeef/pm/internal/install" "github.com/Yeeef/pm/internal/session" "github.com/Yeeef/pm/internal/work" "github.com/Yeeef/pm/internal/work/worktest" @@ -64,7 +65,7 @@ func newFixture(t *testing.T) *fixture { t.Cleanup(func() { os.RemoveAll(main) }) f := &fixture{t: t, main: main} worktest.ServeAt(t, main, work.Ops{Sync: func(c context.Context, d *work.Dolt) ([]string, error) { - return syncStore(c, d, main, "origin") + return install.SyncStore(c, d, main, "origin") }}) d := must(f.open()) defer d.Shutdown() diff --git a/internal/cli/testdata/help.golden b/internal/cli/testdata/help.golden index d72c9f2e..323faa2e 100644 --- a/internal/cli/testdata/help.golden +++ b/internal/cli/testdata/help.golden @@ -27,7 +27,7 @@ positional arguments: doctor compare every managed piece with what this pm writes, and the clone and worktree with what pm init makes; report each difference, exit 1 on any (in a checkout whose pin is not the version the clone's pm service runs, the work store cannot be checked: its line names the mismatch and the fix); fail when the CLI that summary_runtime in .pm/config.toml names (claude or codex) is not on PATH, since the pm service summarizes with it, and never for the other runtime, whose sessions, and so whose owner-request judge, run only where it is installed; on Linux, where Codex is installed, fail when its sandbox cannot start (codex sandbox -- true: bwrap cannot make its namespaces, as under Ubuntu 24.04's AppArmor), naming the cause and the fix, which pm doctor leaves to the user; and name a pm release newer than the pin, with its notes (or say the release list cannot be read), and the launcher's pins that no checkout on this machine pins, which pm upgrade prunes: both leave the exit code alone upgrade first print the upgrade guide of each release the move crosses, after the pin up to this pm, oldest first, from the release notes this pm was built with (no network; it fails, writing nothing, when a crossed release's guide is missing); then move the pin in .pm/config.toml to this pm and rewrite every managed piece as it writes them (the fix pm doctor names for a changed one), removing the pre-package harness's and the ones an earlier pm wrote and this one retired (the records/ copy and guard workflows, the pre-commit section); writes files and prints the commit to make, never commits; then lists the clone's other worktrees whose checked-out branch pins another pm, where pm refuses every work-store command once the main checkout pins this one, each with 'merge or rebase it onto the pin move'; last, prune the launcher's pins (/pm/pins//pm) that no checkout on this machine pins (each worktree of this clone and of every clone with a pm service unit), keeping each pin's sha256 so a later download is checked against it, and pruning none when any of those cannot be read uninstall remove pm's pieces from this worktree (hook entries, .gitignore block, .pm/, an earlier pm's workflows and git hook sections in .pm/hooks) and the clone's and machine's setup (the store checkout, records/ links, records workspaces and their drafts/ branches and an earlier pm's sparse checkouts in every worktree, refused while a workspace has uncommitted edits; the pm service, this clone's Codex writable_roots, pm's lines in .git/info/exclude, a core.hooksPath an earlier pm pointed at .pm/hooks, the work store (refused while it holds what the remote lacks; with the pm service stopped, it runs pm service run for that check)); keeps the records branch and the remote's work store (refs/pm/work); never commits - init 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= sets its port, and on a first install the config's; PORT= pm service install moves only the service's); session start runs it; never commits on the code branch + init 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= sets its port, and on a first install the config's; PORT= pm service install moves only the service's); session start runs it; never commits on the code branch where list every location with its state: the store, this checkout and its records/ (the main checkout's link to the store, or a worktree's records workspace: its branch, uncommitted files, conflict markers and how far it is behind the records branch), the work store (in a checkout whose pin is not the version the clone's pm service runs, the mismatch and the fix: merge or rebase onto main's pin move, or pm service restart), the Codex sandbox roots, the pm service, the last push, and the site; 'pm where records' prints only the store's path clean list each worktree with keep or remove and the reason; --apply removes the agent worktrees (.claude/worktrees/) that no live session owns and whose work is saved, and the interactive-mode files of sessions idle for a day interactive turn the owner-request Stop check off for this session, so asks in chat pass, until pm autopilot, and print the interactive request rule; run it only when the owner asks @@ -71,7 +71,7 @@ positional arguments: doctor compare every managed piece with what this pm writes, and the clone and worktree with what pm init makes; report each difference, exit 1 on any (in a checkout whose pin is not the version the clone's pm service runs, the work store cannot be checked: its line names the mismatch and the fix); fail when the CLI that summary_runtime in .pm/config.toml names (claude or codex) is not on PATH, since the pm service summarizes with it, and never for the other runtime, whose sessions, and so whose owner-request judge, run only where it is installed; on Linux, where Codex is installed, fail when its sandbox cannot start (codex sandbox -- true: bwrap cannot make its namespaces, as under Ubuntu 24.04's AppArmor), naming the cause and the fix, which pm doctor leaves to the user; and name a pm release newer than the pin, with its notes (or say the release list cannot be read), and the launcher's pins that no checkout on this machine pins, which pm upgrade prunes: both leave the exit code alone upgrade first print the upgrade guide of each release the move crosses, after the pin up to this pm, oldest first, from the release notes this pm was built with (no network; it fails, writing nothing, when a crossed release's guide is missing); then move the pin in .pm/config.toml to this pm and rewrite every managed piece as it writes them (the fix pm doctor names for a changed one), removing the pre-package harness's and the ones an earlier pm wrote and this one retired (the records/ copy and guard workflows, the pre-commit section); writes files and prints the commit to make, never commits; then lists the clone's other worktrees whose checked-out branch pins another pm, where pm refuses every work-store command once the main checkout pins this one, each with 'merge or rebase it onto the pin move'; last, prune the launcher's pins (/pm/pins//pm) that no checkout on this machine pins (each worktree of this clone and of every clone with a pm service unit), keeping each pin's sha256 so a later download is checked against it, and pruning none when any of those cannot be read uninstall remove pm's pieces from this worktree (hook entries, .gitignore block, .pm/, an earlier pm's workflows and git hook sections in .pm/hooks) and the clone's and machine's setup (the store checkout, records/ links, records workspaces and their drafts/ branches and an earlier pm's sparse checkouts in every worktree, refused while a workspace has uncommitted edits; the pm service, this clone's Codex writable_roots, pm's lines in .git/info/exclude, a core.hooksPath an earlier pm pointed at .pm/hooks, the work store (refused while it holds what the remote lacks; with the pm service stopped, it runs pm service run for that check)); keeps the records branch and the remote's work store (refs/pm/work); never commits - init 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= sets its port, and on a first install the config's; PORT= pm service install moves only the service's); session start runs it; never commits on the code branch + init 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= sets its port, and on a first install the config's; PORT= pm service install moves only the service's); session start runs it; never commits on the code branch push sync the work store, summarize today and push the records branch; what the pm service runs every 10 minutes, not a session command where list every location with its state: the store, this checkout and its records/ (the main checkout's link to the store, or a worktree's records workspace: its branch, uncommitted files, conflict markers and how far it is behind the records branch), the work store (in a checkout whose pin is not the version the clone's pm service runs, the mismatch and the fix: merge or rebase onto main's pin move, or pm service restart), the Codex sandbox roots, the pm service, the last push, and the site; 'pm where records' prints only the store's path clean list each worktree with keep or remove and the reason; --apply removes the agent worktrees (.claude/worktrees/) that no live session owns and whose work is saved, and the interactive-mode files of sessions idle for a day @@ -846,7 +846,7 @@ positional arguments: restart restart the service and wait for its site to answer; when it fails, raise an action for the owner and add a bug task stop stop this clone's service and keep it stopped, at login and at session start, until pm service restart or pm init logs print the end of the service's log,
/.pm/run/service.log - run the service's process, run by its supervisor: serve the site on localhost:$PORT and push every 10 minutes + run 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 options: -h, --help show this help message and exit @@ -889,7 +889,7 @@ options: ===== pm service run --help usage: pm service run [-h] -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. +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. options: -h, --help show this help message and exit @@ -910,17 +910,20 @@ usage: pm uninstall [-h] options: -h, --help show this help message and exit ===== pm init --help -usage: pm init [-h] [--session-start] [--site-url URL] [--import-bd FILE | - --import FILE] +usage: pm init [-h] [--session-start] [--site-url URL] [--listen ADDR] + [--access {cloudflare,none}] [--import-bd FILE | --import FILE] -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
/.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/ at /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= 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, .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. +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
/.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/ at /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= 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, .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. options: - -h, --help show this help message and exit - --session-start 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) - --site-url URL 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:, and the site serves its host besides localhost, behind Cloudflare Access (access_team, access_aud); '' clears it - --import-bd FILE 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 - --import FILE 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 + -h, --help show this help message and exit + --session-start 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) + --site-url URL 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:, and the site serves its host besides localhost, behind access; '' clears it + --listen ADDR 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 + --access {cloudflare,none} + 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 + --import-bd FILE 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 + --import FILE 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 ===== pm push --help usage: pm push [-h] diff --git a/internal/cli/where.go b/internal/cli/where.go index 81f2258c..c23921cb 100644 --- a/internal/cli/where.go +++ b/internal/cli/where.go @@ -153,6 +153,6 @@ func whereAll(here string) (string, error) { if err != nil { return "", err } - out = append(out, fmt.Sprintf("site %s (served by the pm service)", url)) + out = append(out, fmt.Sprintf("site %s (served by the pm service%s)", url, cfg.Reach(", "))) return strings.Join(out, "\n"), nil } diff --git a/internal/config/config.go b/internal/config/config.go index 8c612f75..34cb3d5d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ package config import ( "errors" "fmt" + "net" "os" "path/filepath" "slices" @@ -27,9 +28,16 @@ const ( // keys are the config's keys and the Python type each must have, in KEYS order. var keys = map[string]string{"version": "str", "remote": "str", "main_branch": "str", "port": "int", "site_url": "str", - "access_team": "str", "access_aud": "str", "summary_runtime": "str", + "listen": "str", "access": "str", "access_team": "str", "access_aud": "str", "summary_runtime": "str", "design_sources": "list"} +// Accesses are access's values: how the site lets in a request from beyond this machine. cloudflare verifies the +// Cloudflare Access token (access_team, access_aud); none lets it read, the network being the gate. +var Accesses = []string{"cloudflare", "none"} + +// Loopback is the HTTP bind address when listen is unset: this machine only. +const Loopback = "127.0.0.1" + // SummaryRuntimes are summary_runtime's values: the agent runtime whose CLI pm day summarize asks. var SummaryRuntimes = []string{"claude", "codex"} var required = []string{"version", "remote", "main_branch", "port"} @@ -47,6 +55,8 @@ type Config struct { MainBranch string Port int64 SiteURL string // "" when unset: links use http://localhost: + Listen string // the site's HTTP bind address, an IP; "" when unset: Loopback (ListenAddr) + Access string // access, cloudflare or none; "" when unset AccessTeam string // the Cloudflare Access team domain, .cloudflareaccess.com; "" when unset AccessAud string // the Access application's AUD tag; "" when unset SummaryRuntime string // summary_runtime, claude or codex; "" when unset @@ -64,12 +74,71 @@ func (c Config) Summary() string { return c.SummaryRuntime } -// AccessMissing is the Cloudflare Access keys a set site_url needs and the config lacks: the site verifies Access on -// every request to site_url's host, and refuses them all without these. +// Migrated is c with access = "cloudflare" when it sets the Cloudflare Access keys and no access, as a config from +// before access existed meant; true when it changed. pm init and pm upgrade write it; nothing else infers access. +func (c Config) Migrated() (Config, bool) { + if c.Access != "" || c.AccessTeam == "" { + return c, false + } + c.Access = "cloudflare" + return c, true +} + +// ListenAddr is the address the site binds: listen, else Loopback. +func (c Config) ListenAddr() string { + if c.Listen == "" { + return Loopback + } + return c.Listen +} + +// Reach is where the site listens beyond 127.0.0.1, as pm where and the pm service's log state it: "" for the default +// listen, else lead and "listening on , access ". +func (c Config) Reach(lead string) string { + if c.ListenAddr() == Loopback { + return "" + } + access := c.Access + if access == "" { + access = "unset" + } + return lead + "listening on " + c.ListenAddr() + ", access " + access +} + +// Unaddressed is why the site, listening beyond loopback with no site_url, serves nobody on the network, or "": a +// request from beyond this machine is served only on site_url's host. +func (c Config) Unaddressed() string { + if c.SiteURL != "" || IsLoopback(c.ListenAddr()) { + return "" + } + return fmt.Sprintf("listen = %q, but no site_url is set, so the site refuses every request from the network: only "+ + "site_url's host is served beyond this machine; set the URL colleagues open with pm init --site-url "+ + "http://:", c.ListenAddr()) +} + +// Exposed is whether a request from beyond this machine can reach the site: listen is not a loopback address, or +// site_url is set (a tunnel brings its requests to loopback). +func (c Config) Exposed() bool { return c.SiteURL != "" || !IsLoopback(c.ListenAddr()) } + +// IsLoopback is whether addr, an IP, is a loopback address (127.0.0.0/8, ::1). +func IsLoopback(addr string) bool { + ip := net.ParseIP(addr) + return ip != nil && ip.IsLoopback() +} + +// AccessMissing is the keys the site's gate needs and the config lacks: access once the site is exposed (the pm +// service refuses to start without it), and with access = "cloudflare" the Cloudflare Access keys, without which the +// site refuses every request from beyond this machine. func (c Config) AccessMissing() []string { + if c.Access == "" { + if c.Exposed() { + return []string{"access"} + } + return nil + } var missing []string for _, k := range [][2]string{{"access_team", c.AccessTeam}, {"access_aud", c.AccessAud}} { - if c.SiteURL != "" && k[1] == "" { + if c.Access == "cloudflare" && k[1] == "" { missing = append(missing, k[0]) } } @@ -193,6 +262,18 @@ func Read(cwd string) (Config, error) { strings.Join(SummaryRuntimes, ", "))} } } + listen, access := "", "" + if v, ok := data["listen"].(string); ok { + if listen = PyStrip(v); net.ParseIP(listen) == nil { + return Config{}, &Error{fmt.Sprintf("%s: listen is %q; set it to an IP address the site binds, like %s (this "+ + "machine only) or 0.0.0.0 (every interface)", path, v, Loopback)} + } + } + if v, ok := data["access"].(string); ok { + if access = PyStrip(v); !slices.Contains(Accesses, access) { + return Config{}, &Error{fmt.Sprintf("%s: access is %q; set it to one of %s", path, v, strings.Join(Accesses, ", "))} + } + } site, _ := data["site_url"].(string) team, _ := data["access_team"].(string) aud, _ := data["access_aud"].(string) @@ -206,7 +287,7 @@ func Read(cwd string) (Config, error) { } return Config{Path: path, Version: data["version"].(string), Remote: data["remote"].(string), MainBranch: data["main_branch"].(string), Port: data["port"].(int64), - SiteURL: strings.TrimRight(PyStrip(site), "/"), AccessTeam: PyStrip(team), AccessAud: PyStrip(aud), + SiteURL: strings.TrimRight(PyStrip(site), "/"), Listen: listen, Access: access, AccessTeam: PyStrip(team), AccessAud: PyStrip(aud), SummaryRuntime: summary, DesignSources: sources}, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1093bbae..2f5e551d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -25,7 +25,7 @@ func TestConfigTextKeepsEveryKey(t *testing.T) { t.Fatal(err) } text := "version = \"0.8.2\"\nremote = \"origin\"\nmain_branch = \"main\"\nport = 8001\n" + - "site_url = \"https://pm.example.com\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud\"\n" + + "site_url = \"https://pm.example.com\"\nlisten = \"0.0.0.0\"\naccess = \"cloudflare\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud\"\n" + "summary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n" for _, k := range config.Keys() { if !strings.Contains("\n"+text, "\n"+k+" = ") { @@ -50,3 +50,103 @@ func TestConfigTextKeepsEveryKey(t *testing.T) { t.Errorf("rewritten config reads %+v, want %+v; the rewrite:\n%s", got, want, rewritten) } } + +// writeConfig writes a git repo whose config is the required keys and extra, and reads it. +func writeConfig(t *testing.T, extra string) (config.Config, error) { + t.Helper() + dir := t.TempDir() + if out, err := exec.Command("git", "init", "-q", dir).CombinedOutput(); err != nil { + t.Fatalf("git init: %v %s", err, out) + } + path := filepath.Join(dir, config.Rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + text := "version = \"0.8.2\"\nremote = \"origin\"\nmain_branch = \"main\"\nport = 8001\n" + extra + if err := os.WriteFile(path, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + return config.Read(dir) +} + +// TestListenAndAccessAreCheckedAndTheGateNamesWhatItLacks: listen is an IP and access one of its values, else the +// config fails hard; once the site is exposed (a non-loopback listen, or a site_url) access is required, and access = +// "cloudflare" needs the Access keys. AccessMissing is what pm doctor names and the pm service refuses to start on. +func TestListenAndAccessAreCheckedAndTheGateNamesWhatItLacks(t *testing.T) { + for _, c := range []struct{ extra, said string }{ + {"listen = \"my-host\"\n", `listen is "my-host"; set it to an IP address`}, + {"listen = \"\"\n", `listen is ""; set it to an IP address`}, + {"access = \"open\"\n", `access is "open"; set it to one of cloudflare, none`}, + } { + if _, err := writeConfig(t, c.extra); err == nil || !strings.Contains(err.Error(), c.said) { + t.Errorf("%q: %v, want %q", c.extra, err, c.said) + } + } + keys := "access_team = \"t.cloudflareaccess.com\"\naccess_aud = \"aud\"\n" + for _, c := range []struct { + extra, listen string + exposed bool + missing []string + }{ + {"", "127.0.0.1", false, nil}, + {"listen = \"::1\"\n", "::1", false, nil}, + {"listen = \"127.0.0.2\"\n", "127.0.0.2", false, nil}, + {"listen = \"0.0.0.0\"\n", "0.0.0.0", true, []string{"access"}}, + {"listen = \"192.168.1.5\"\n", "192.168.1.5", true, []string{"access"}}, + {"site_url = \"https://pm.example.com\"\n", "127.0.0.1", true, []string{"access"}}, + {"site_url = \"https://pm.example.com\"\n" + keys, "127.0.0.1", true, []string{"access"}}, // pm init migrates it + {"listen = \"0.0.0.0\"\naccess = \"none\"\n", "0.0.0.0", true, nil}, + {"listen = \"0.0.0.0\"\naccess = \"cloudflare\"\n", "0.0.0.0", true, []string{"access_team", "access_aud"}}, + {"site_url = \"https://pm.example.com\"\naccess = \"cloudflare\"\n" + keys, "127.0.0.1", true, nil}, + {"access = \"cloudflare\"\n", "127.0.0.1", false, []string{"access_team", "access_aud"}}, + } { + cfg, err := writeConfig(t, c.extra) + if err != nil { + t.Fatalf("%q: %v", c.extra, err) + } + if cfg.ListenAddr() != c.listen || cfg.Exposed() != c.exposed || !reflect.DeepEqual(cfg.AccessMissing(), c.missing) { + t.Errorf("%q: listen %s exposed %v missing %v, want %s %v %v", c.extra, cfg.ListenAddr(), cfg.Exposed(), + cfg.AccessMissing(), c.listen, c.exposed, c.missing) + } + } +} + +// Migrated gives a config from before access existed (the Access keys, no access) access = "cloudflare", which its +// keys meant, and changes no other config. +func TestMigratedGivesAccessKeysWithNoAccessCloudflare(t *testing.T) { + for _, c := range []struct { + in config.Config + want string + ok bool + }{ + {config.Config{SiteURL: "https://pm.example.com", AccessTeam: "t", AccessAud: "a"}, "cloudflare", true}, + {config.Config{AccessTeam: "t", Access: "none"}, "none", false}, + {config.Config{SiteURL: "https://pm.example.com"}, "", false}, + {config.Config{}, "", false}, + } { + got, ok := c.in.Migrated() + if got.Access != c.want || ok != c.ok { + t.Errorf("%+v: access %q %v, want %q %v", c.in, got.Access, ok, c.want, c.ok) + } + } +} + +// A non-loopback listen with no site_url serves nobody beyond this machine: a request from the network must name +// site_url's host. Unaddressed says so, naming --site-url; it is "" otherwise. +func TestUnaddressedNamesSiteURLForANetworkListenWithoutOne(t *testing.T) { + for _, c := range []struct { + in config.Config + said bool + }{ + {config.Config{Listen: "0.0.0.0", Access: "none"}, true}, + {config.Config{Listen: "192.168.1.5", Access: "cloudflare"}, true}, + {config.Config{Listen: "0.0.0.0", Access: "none", SiteURL: "http://devbox:8123"}, false}, + {config.Config{}, false}, + {config.Config{SiteURL: "https://pm.example.com", Access: "cloudflare"}, false}, + } { + got := c.in.Unaddressed() + if (got != "") != c.said || (c.said && !strings.Contains(got, "pm init --site-url")) { + t.Errorf("%+v: %q", c.in, got) + } + } +} diff --git a/internal/install/golden_test.go b/internal/install/golden_test.go index bb0ac974..636cf236 100644 --- a/internal/install/golden_test.go +++ b/internal/install/golden_test.go @@ -23,8 +23,8 @@ import ( var update = flag.Bool("update", false, "rewrite testdata/pieces.json from the results now") -var goldenSettings = []Settings{{"origin", "main", 8123, "", "", "", "", nil}, - {"upstream", "trunk", 8000, "https://pm.example.com", "team.cloudflareaccess.com", "aud-tag", "codex", +var goldenSettings = []Settings{{"origin", "main", 8123, "", "", "", "", "", "", nil}, + {"upstream", "trunk", 8000, "https://pm.example.com", "0.0.0.0", "cloudflare", "team.cloudflareaccess.com", "aud-tag", "codex", []string{"internal/**", "prime.md"}}} // Inputs, mostly from the setup tests' fixtures: Beads' hook file with the repo's own lines, settings files of the diff --git a/internal/install/pieces.go b/internal/install/pieces.go index b5f246f0..ab0af9cc 100644 --- a/internal/install/pieces.go +++ b/internal/install/pieces.go @@ -67,6 +67,8 @@ type Settings struct { MainBranch string Port int SiteURL string + Listen string // listen, "" when unset (the site binds loopback) + Access string // access, cloudflare or none; "" when unset AccessTeam string // the Cloudflare Access keys, set by hand: carried so a config holding them is pm's own AccessAud string // SummaryRuntime is summary_runtime, set by hand ("" when unset): carried so a config holding it is pm's own @@ -79,7 +81,7 @@ type Settings struct { // to, so every key the config reads is here. func SettingsOf(c config.Config) Settings { return Settings{Remote: c.Remote, MainBranch: c.MainBranch, Port: int(c.Port), SiteURL: c.SiteURL, - AccessTeam: c.AccessTeam, AccessAud: c.AccessAud, SummaryRuntime: c.SummaryRuntime, DesignSources: c.DesignSources} + Listen: c.Listen, Access: c.Access, AccessTeam: c.AccessTeam, AccessAud: c.AccessAud, SummaryRuntime: c.SummaryRuntime, DesignSources: c.DesignSources} } // Piece is one managed piece. A text is nil when the file is absent. @@ -100,7 +102,7 @@ func ptr(s string) *string { return &s } // ConfigText is .pm/config.toml as pm init writes it. func ConfigText(s Settings) string { site := "" - for _, kv := range [][2]string{{"site_url", s.SiteURL}, {"access_team", s.AccessTeam}, {"access_aud", s.AccessAud}, + for _, kv := range [][2]string{{"site_url", s.SiteURL}, {"listen", s.Listen}, {"access", s.Access}, {"access_team", s.AccessTeam}, {"access_aud", s.AccessAud}, {"summary_runtime", s.SummaryRuntime}} { if kv[1] != "" { site += kv[0] + " = " + tomlString(kv[1]) + "\n" diff --git a/internal/install/setup_test.go b/internal/install/setup_test.go index 6c6d9a49..e1ed1184 100644 --- a/internal/install/setup_test.go +++ b/internal/install/setup_test.go @@ -247,7 +247,7 @@ func TestExcludeRoundTrip(t *testing.T) { // pieceAt is the piece at rel. func pieceAt(t *testing.T, rel string) Piece { t.Helper() - for _, p := range Pieces(Settings{"origin", "main", 8000, "", "", "", "", nil}) { + for _, p := range Pieces(Settings{"origin", "main", 8000, "", "", "", "", "", "", nil}) { if p.Rel == rel { return p } @@ -282,7 +282,7 @@ func writeTree(t *testing.T, top string, files map[string]string) { // pieces out, keeps pm's entries and everything else byte for byte, writes pm's own hook files, and leaves .beads/ // alone; then pm doctor's drift is empty and a second rewrite plans nothing. func TestRewriteTakesOutBeadsPieces(t *testing.T) { - s := Settings{"origin", "main", 8000, "", "", "", "", nil} + s := Settings{"origin", "main", 8000, "", "", "", "", "", "", nil} top := t.TempDir() // an event Beads' removal empties keeps its place where pm writes its own group userClaude := "{\n \"hooks\": {\n \"SessionStart\": [],\n \"PreToolUse\": [\n {\n \"hooks\": [\n {\n" + @@ -460,7 +460,7 @@ func TestRetireHooksPath(t *testing.T) { // pm upgrade's rewrite takes an earlier pm's hook files out, and .pm/hooks with them once nothing else is left there; a // file of the user's keeps the directory. func TestWriteTakesAnEmptyHooksDirectoryAway(t *testing.T) { - s := Settings{"origin", "main", 8000, "", "", "", "", nil} + s := Settings{"origin", "main", 8000, "", "", "", "", "", "", nil} old := func(name string) string { return shebang + "# --- BEGIN PM v0.9.1 ---\npm hook git-" + name + " \"$@\" || exit $?\n# --- END PM ---\n" } @@ -492,7 +492,7 @@ func TestWriteTakesAnEmptyHooksDirectoryAway(t *testing.T) { // a repo without .beads/: the legacy check, pm doctor's drift and pm upgrade's rewrite read past it. Any other error // reading under .beads/ still fails. func TestABeadsFileIsNoBeads(t *testing.T) { - s := Settings{"origin", "main", 8000, "", "", "", "", nil} + s := Settings{"origin", "main", 8000, "", "", "", "", "", "", nil} top := t.TempDir() if err := os.WriteFile(filepath.Join(top, ".beads"), []byte("Beads is retired here.\n"), 0o644); err != nil { t.Fatal(err) diff --git a/internal/install/testdata/pieces.json b/internal/install/testdata/pieces.json index 5c05c750..4517cf5e 100644 --- a/internal/install/testdata/pieces.json +++ b/internal/install/testdata/pieces.json @@ -522,7 +522,7 @@ "rel": ".pm/config.toml", "text": null, "present": "false", - "apply": "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n", + "apply": "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\nlisten = \"0.0.0.0\"\naccess = \"cloudflare\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n", "remove": null, "drift": [ ".pm/config.toml: pm's part is missing" @@ -533,7 +533,7 @@ "rel": ".pm/config.toml", "text": "", "present": "true", - "apply": "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n", + "apply": "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\nlisten = \"0.0.0.0\"\naccess = \"cloudflare\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n", "remove": null, "drift": [ ".pm/config.toml: pm's part is missing" @@ -544,7 +544,7 @@ "rel": ".pm/config.toml", "text": "old\n", "present": "true", - "apply": "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n", + "apply": "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\nlisten = \"0.0.0.0\"\naccess = \"cloudflare\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n", "remove": null, "drift": [ ".pm/config.toml: pm's part differs from what pm 0.9.9 writes" @@ -1569,7 +1569,7 @@ ], [ ".pm/config.toml", - "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n" + "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\nlisten = \"0.0.0.0\"\naccess = \"cloudflare\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n" ] ], "drift": [ @@ -1613,7 +1613,7 @@ ], [ ".pm/config.toml", - "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n" + "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\nlisten = \"0.0.0.0\"\naccess = \"cloudflare\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n" ] ], "removals": [] @@ -1653,7 +1653,7 @@ ], [ ".pm/config.toml", - "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n" + "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\nlisten = \"0.0.0.0\"\naccess = \"cloudflare\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n" ] ], "drift": [ @@ -1698,7 +1698,7 @@ ], [ ".pm/config.toml", - "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n" + "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\nlisten = \"0.0.0.0\"\naccess = \"cloudflare\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n" ] ], "removals": [ @@ -1722,7 +1722,7 @@ ".gitignore": "*.log\nbuild/\n# --- BEGIN PM ---\n# each worktree's records/ is a link to the clone's records store\n/records\n# per-machine Claude Code settings: pm adds the store's absolute path to them\n/.claude/settings.local.json\n# --- END PM ---\n", ".pm/.gitignore": "store/\nrun/\n", ".pm/README.md": "# pm\n\nThis repo uses pm: the work store holds the work, Markdown records hold the context, and pm writes both and serves\nthem as a site.\n\n- Install the pinned version (`version` in `config.toml`):\n `curl -fsSL https://github.com/Yeeef/pm/releases/download/pm-v\u003cversion\u003e/install.sh | sh`, then run `pm init`\n in each clone.\n- The work store is a Dolt database each clone keeps at `.pm/store/work`; pm syncs it through the remote's\n `refs/pm/work`.\n- Records live on the `records` branch. Each clone checks it out once at `.pm/store/records`, and each worktree reads\n it through `records/`, a git-ignored link; no other branch tracks `records/`.\n- Agents get pm's rules and the project's state from hooks (`pm prime`, `pm hook \u003cname\u003e`); run `pm --help` for the\n commands.\n- `store/` and `run/` here are per clone and git-ignored; `config.toml`, this file and `.gitignore` are tracked.\n", - ".pm/config.toml": "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n", + ".pm/config.toml": "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\nlisten = \"0.0.0.0\"\naccess = \"cloudflare\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n", ".pm/hooks/pre-commit": "#!/usr/bin/env sh\n# --- BEGIN BEADS INTEGRATION v1.3.1 ---\n# beads' part\n# --- END BEADS INTEGRATION v1.3.1 ---\n\n# mine\necho done\n" }, "plan": [], @@ -1775,7 +1775,7 @@ ".gitignore": "*.log\nbuild/\n# --- BEGIN PM ---\n# each worktree's records/ is a link to the clone's records store\n/records\n# per-machine Claude Code settings: pm adds the store's absolute path to them\n/.claude/settings.local.json\n# --- END PM ---\n", ".pm/.gitignore": "store/\nrun/\n", ".pm/README.md": "# pm\n\nThis repo uses pm: the work store holds the work, Markdown records hold the context, and pm writes both and serves\nthem as a site.\n\n- Install the pinned version (`version` in `config.toml`):\n `curl -fsSL https://github.com/Yeeef/pm/releases/download/pm-v\u003cversion\u003e/install.sh | sh`, then run `pm init`\n in each clone.\n- The work store is a Dolt database each clone keeps at `.pm/store/work`; pm syncs it through the remote's\n `refs/pm/work`.\n- Records live on the `records` branch. Each clone checks it out once at `.pm/store/records`, and each worktree reads\n it through `records/`, a git-ignored link; no other branch tracks `records/`.\n- Agents get pm's rules and the project's state from hooks (`pm prime`, `pm hook \u003cname\u003e`); run `pm --help` for the\n commands.\n- `store/` and `run/` here are per clone and git-ignored; `config.toml`, this file and `.gitignore` are tracked.\n", - ".pm/config.toml": "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n", + ".pm/config.toml": "version = \"0.9.9\" # the pm version every session must run\nremote = \"upstream\"\nmain_branch = \"trunk\"\nport = 8000 # the site port; the PORT environment variable overrides it for one run\nsite_url = \"https://pm.example.com\"\nlisten = \"0.0.0.0\"\naccess = \"cloudflare\"\naccess_team = \"team.cloudflareaccess.com\"\naccess_aud = \"aud-tag\"\nsummary_runtime = \"codex\"\ndesign_sources = [\"internal/**\", \"prime.md\"]\n", ".pm/hooks/post-checkout": "#!/usr/bin/env sh\n# --- BEGIN PM v0.9.1 ---\npm hook git-post-checkout \"$@\" || exit $?\n# --- END PM ---\n", ".pm/hooks/pre-commit": "#!/usr/bin/env sh\n# --- BEGIN BEADS INTEGRATION v1.3.1 ---\n# beads' part\n# --- END BEADS INTEGRATION v1.3.1 ---\n# --- BEGIN PM v0.3.0 ---\npm hook git-pre-commit \"$@\" || exit $?\n# --- END PM ---\n\n# mine\necho done\n" }, diff --git a/internal/install/workstore.go b/internal/install/workstore.go index ebd75abb..ec08416d 100644 --- a/internal/install/workstore.go +++ b/internal/install/workstore.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "path/filepath" "time" "github.com/Yeeef/pm/internal/proc" @@ -152,6 +153,52 @@ func SetupStore(c context.Context, d *work.Dolt, main, remote string) ([]string, return append(out, fmt.Sprintf("pushed the work store to %s's %s", remote, work.RemoteRef)), 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 := RemoteURL(main, remote) + if url == "" { + return nil, fmt.Errorf("work store: it has no remote to sync with, and this repo has no remote %s", remote) + } + held, err := 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 +} + // WorkDrift is how the clone's work store differs from what pm init makes, one line each; a service that does not // answer, or a clone with no store, is one such line. func WorkDrift(main, remote string) ([]string, error) { diff --git a/internal/service/access.go b/internal/service/access.go index 083bbfac..6d7a1614 100644 --- a/internal/service/access.go +++ b/internal/service/access.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "math/big" + "net" "net/http" "net/url" "strconv" @@ -196,25 +197,51 @@ func decodePart(part string, v any) error { return nil } -// hostName is a Host header's name: without the port, lower case. +// hostName is a Host header's name: without the port and an IPv6 address's brackets, lower case, as url.Hostname gives +// site_url's. func hostName(host string) string { - if i := strings.LastIndex(host, ":"); i >= 0 { - host = host[:i] + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } else { // no port: a bare name, a bracketed or a bare IPv6 address + host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") } return strings.ToLower(host) } -// refusal is why a request is refused, or "". The service binds 127.0.0.1, so a Host other than this machine's names -// a rebinding domain, except site_url's: the tunnel brings that, so the request must carry a Cloudflare Access token -// that verifies against the team's keys, for this application, unexpired. A request Cloudflare's edge forwarded -// (EdgeHeader) needs the token whatever its Host, so a tunnel that rewrites Host to this machine's does not skip -// Access. It fails closed: without the Access keys in the config, or the team's keys, every such request is refused. -func (s *server) refusal(r *http.Request) string { - name := hostName(r.Host) +// local is whether r comes from this machine: its TCP peer is a loopback address, Cloudflare's edge did not forward it +// (EdgeHeader; cloudflared connects from loopback), and its Host names this machine. The peer decides, not the Host: +// with a non-loopback listen, a client on the network could send Host: localhost. The Host is checked too, since a +// browser here that a rebinding domain points at 127.0.0.1 connects from loopback. +func local(r *http.Request) bool { _, edge := r.Header[http.CanonicalHeaderKey(EdgeHeader)] - if (name == "127.0.0.1" || name == "localhost") && !edge { + peer, _, err := net.SplitHostPort(r.RemoteAddr) + name := hostName(r.Host) + return err == nil && config.IsLoopback(peer) && !edge && (name == "127.0.0.1" || name == "localhost" || name == "::1") +} + +// readOnly is whether r may read but not reply: with access = "none" pm cannot tell the owner from anyone else on the +// network, so a reply, which becomes an instruction to an agent session, is taken only from this machine. +func (s *server) readOnly(r *http.Request) bool { return s.d.Access == "none" && !local(r) } + +// ReadOnlyRule is why a non-local viewer cannot reply under access = "none": the 403 a reply gets, and the note in +// place of each reply form on its pages. +const ReadOnlyRule = `replies are taken only from this machine: access = "none" in ` + config.Rel + ` lets the network ` + + `read the site, and pm cannot tell the owner from anyone else on it; reply on this machine, or set access = ` + + `"cloudflare"` + +// refusal is why a request is refused, or "". A local request is served. Any other must name site_url's host (else +// its Host names a rebinding domain, or a peer on the network claims this machine's name), unless Cloudflare's edge forwarded it (EdgeHeader), so a tunnel that rewrites +// Host to this machine's does not skip Access. Then access decides: "none" lets it in, the network being the gate, +// except an edge-forwarded one, which came from the internet; "cloudflare" (or no access, which only a loopback, +// site_url-less config has: then only a tunnel's request gets here) needs a Cloudflare Access token that verifies +// against the team's keys, for this application, unexpired. It fails closed: without the Access keys in the config, +// or the team's keys, every such request is refused. +func (s *server) refusal(r *http.Request) string { + if local(r) { return "" } + name := hostName(r.Host) + _, edge := r.Header[http.CanonicalHeaderKey(EdgeHeader)] public := "" if u, err := url.Parse(s.d.SiteURL); err == nil { public = strings.ToLower(u.Hostname()) @@ -227,6 +254,13 @@ func (s *server) refusal(r *http.Request) string { return fmt.Sprintf("refused: Host %s is neither this machine nor the configured site_url; open the site at %s", shown, s.siteURL()) } + if s.d.Access == "none" { + if edge { + return fmt.Sprintf(`refused: Cloudflare's edge forwarded this request, and access = "none" in %s lets in `+ + `only the network's; set access = "cloudflare" to serve a tunnel`, config.Rel) + } + return "" + } var missing []string if s.d.AccessTeam == "" { missing = append(missing, "access_team (the Cloudflare Access team domain, .cloudflareaccess.com)") diff --git a/internal/service/access_test.go b/internal/service/access_test.go index d190b834..f86370e6 100644 --- a/internal/service/access_test.go +++ b/internal/service/access_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "crypto" "crypto/rand" "crypto/rsa" @@ -9,9 +10,14 @@ import ( "encoding/json" "io" "math/big" + "net" "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" + "regexp" + "slices" "strings" gosync "sync" "testing" @@ -114,9 +120,18 @@ func (k *teamKeys) sign(key *rsa.PrivateKey, head, claims map[string]any) string return signed + "." + base64.RawURLEncoding.EncodeToString(sig) } -// request is a GET of / with Host host and the headers (name, value pairs). +// lan is a peer on the network, not this machine. +const lan = "192.0.2.1:1234" + +// request is a GET of / from a peer on the network (lan) with Host host and the headers (name, value pairs). func request(host string, headers ...string) *http.Request { + return from(lan, host, headers...) +} + +// from is request from the TCP peer peer. +func from(peer, host string, headers ...string) *http.Request { r := httptest.NewRequest("GET", "http://127.0.0.1/", nil) + r.RemoteAddr = peer r.Host = host for i := 0; i+1 < len(headers); i += 2 { r.Header.Set(headers[i], headers[i+1]) @@ -124,8 +139,14 @@ func request(host string, headers ...string) *http.Request { return r } +// accessServer is a server with access = "cloudflare" once site_url is set, as pm init writes it; without a site_url +// no access, which only a loopback listen allows. func accessServer(siteURL, accessTeam, accessAud string) *server { - return &server{d: Deps{SiteURL: siteURL, AccessTeam: accessTeam, AccessAud: accessAud, Port: 8000}} + access := "" + if siteURL != "" { + access = "cloudflare" + } + return &server{d: Deps{SiteURL: siteURL, Access: access, AccessTeam: accessTeam, AccessAud: accessAud, Port: 8000}} } func TestThePublicHostNeedsACloudflareAccessTokenThatVerifies(t *testing.T) { @@ -238,22 +259,31 @@ func TestWithoutTheAccessKeysEveryPublicRequestIsRefusedNamingThem(t *testing.T) } } -func TestLocalhostIsServedAsBeforeAndAnEdgeForwardedRequestNeedsTheTokenWhateverItsHost(t *testing.T) { +func TestALoopbackPeerNamingThisMachineIsLocalAndAnEdgeForwardedRequestNeedsTheTokenWhateverItsHost(t *testing.T) { k := newTeamKeys(t) for _, s := range []*server{accessServer(site, team, aud), accessServer("", "", ""), accessServer(site, "", "")} { - for _, host := range []string{"127.0.0.1:8000", "localhost", "LocalHost:1"} { - if got := s.refusal(request(host)); got != "" { - t.Errorf("site %q, Host %s: %q", s.d.SiteURL, host, got) + for _, peer := range []string{"127.0.0.1:50000", "[::1]:50000", "127.0.0.2:1"} { + for _, host := range []string{"127.0.0.1:8000", "localhost", "LocalHost:1", "[::1]:8000"} { + if got := s.refusal(from(peer, host)); got != "" { + t.Errorf("site %q, peer %s, Host %s: %q", s.d.SiteURL, peer, host, got) + } } } - for _, host := range []string{"evil.example:80", ""} { // a rebinding domain, and no Host at all - shown := "None" - if host != "" { - shown = `"` + host + `"` + // a rebinding domain and no Host, from this machine (a browser a rebinding domain points here) or the network; + // and a peer on the network claiming this machine's name: none is local + for _, peer := range []string{"127.0.0.1:50000", lan} { + for _, host := range []string{"evil.example:80", ""} { + want := "refused: Host " + map[bool]string{true: "None", false: `"` + host + `"`}[host == ""] + + " is neither this machine nor the configured site_url; open the site at " + if got := s.refusal(from(peer, host, AccessHeader, k.sign(nil, nil, nil))); !strings.HasPrefix(got, want) { + t.Errorf("site %q, peer %s, Host %q: got %q, want %q…", s.d.SiteURL, peer, host, got, want) + } } - want := "refused: Host " + shown + " is neither this machine nor the configured site_url; open the site at " - if got := s.refusal(request(host, AccessHeader, k.sign(nil, nil, nil))); !strings.HasPrefix(got, want) { - t.Errorf("site %q, Host %q: got %q, want %q…", s.d.SiteURL, host, got, want) + } + for _, host := range []string{"localhost", "127.0.0.1:8000", "LocalHost:1"} { + want := "refused: Host \"" + host + "\" is neither this machine nor the configured site_url; open the site at " + if got := s.refusal(request(host)); !strings.HasPrefix(got, want) { + t.Errorf("site %q, a LAN peer with Host %q: got %q, want %q…", s.d.SiteURL, host, got, want) } } } @@ -261,20 +291,57 @@ func TestLocalhostIsServedAsBeforeAndAnEdgeForwardedRequestNeedsTheTokenWhatever t.Errorf("site_url's host with no site_url set: %q", got) } s := accessServer(site, team, aud) - for _, host := range []string{"localhost", "127.0.0.1:8000", "evil.example"} { // a tunnel that rewrites Host - if got := s.refusal(request(host, EdgeHeader, "203.0.113.9")); !strings.HasPrefix(got, "refused: the request carries no Cloudflare Access token") { - t.Errorf("edge-forwarded, Host %s, no token: %q", host, got) + for _, peer := range []string{"127.0.0.1:50000", lan} { // cloudflared connects from loopback + for _, host := range []string{"localhost", "127.0.0.1:8000", "evil.example"} { // a tunnel that rewrites Host + if got := s.refusal(from(peer, host, EdgeHeader, "203.0.113.9")); !strings.HasPrefix(got, "refused: the request carries no Cloudflare Access token") { + t.Errorf("edge-forwarded from %s, Host %s, no token: %q", peer, host, got) + } + if got := s.refusal(from(peer, host, EdgeHeader, "203.0.113.9", AccessHeader, k.sign(nil, nil, nil))); got != "" { + t.Errorf("edge-forwarded from %s, Host %s, a valid token: %q", peer, host, got) + } } - if got := s.refusal(request(host, EdgeHeader, "203.0.113.9", AccessHeader, k.sign(nil, nil, nil))); got != "" { - t.Errorf("edge-forwarded, Host %s, a valid token: %q", host, got) + } +} + +// With access = "none" the network is the gate: a peer on it reads site_url's host with no token, but its Host must +// still be site_url's (the rebinding guard), it is never local (so read-only: run_test.go has the reply), and a +// request Cloudflare's edge forwarded, which came from the internet, is refused. +func TestAccessNoneLetsTheNetworkReadSiteURLsHostOnly(t *testing.T) { + s := &server{d: Deps{SiteURL: "http://devbox:8123", Listen: "0.0.0.0", Access: "none", Port: 8123}} + for _, host := range []string{"devbox:8123", "DEVBOX", "devbox"} { + if got := s.refusal(request(host)); got != "" { + t.Errorf("access none, LAN peer, Host %s: %q", host, got) + } + if !s.readOnly(request(host)) { + t.Errorf("access none, LAN peer, Host %s: may reply", host) + } + } + for _, host := range []string{"localhost", "127.0.0.1:8123", "evil.example", ""} { + if got := s.refusal(request(host)); !strings.Contains(got, "is neither this machine nor the configured site_url; open the site at http://devbox:8123") { + t.Errorf("access none, LAN peer, Host %q: %q", host, got) + } + } + for _, peer := range []string{lan, "127.0.0.1:50000"} { + if got := s.refusal(from(peer, "devbox:8123", EdgeHeader, "203.0.113.9")); !strings.Contains(got, `Cloudflare's edge forwarded this request, and access = "none"`) { + t.Errorf("access none, edge-forwarded from %s: %q", peer, got) } } + if got := s.refusal(from("127.0.0.1:50000", "localhost")); got != "" || s.readOnly(from("127.0.0.1:50000", "localhost")) { + t.Errorf("access none, loopback peer: refusal %q, read-only %v", got, s.readOnly(from("127.0.0.1:50000", "localhost"))) + } + // this machine's browser on site_url's name is not local either: it reads, and replies from localhost + if got := s.refusal(from("127.0.0.1:50000", "devbox:8123")); got != "" || !s.readOnly(from("127.0.0.1:50000", "devbox:8123")) { + t.Errorf("access none, loopback peer on site_url's host: refusal %q", got) + } + if accessServer(site, team, aud).readOnly(request("pm.example.com")) { + t.Error("access cloudflare: a viewer through Access is read-only") + } } func TestTheServiceChecksAccessOnEveryRequestAndARefusalNamesNoStore(t *testing.T) { k := newTeamKeys(t) w := newFakeWork(need("p-1.2.1", work.Decision, "")) - s := serve(t, w, "", func(d *Deps) { d.SiteURL, d.AccessTeam, d.AccessAud = site, team, aud }) + s := serve(t, w, "", func(d *Deps) { d.SiteURL, d.Access, d.AccessTeam, d.AccessAud = site, "cloudflare", team, aud }) tok := s.token() // from localhost, served as before do := func(method, path, host string, body url.Values, headers ...string) (response, string) { t.Helper() @@ -336,3 +403,151 @@ func TestTheServiceChecksAccessOnEveryRequestAndARefusalNamesNoStore(t *testing. } eventually(t, "stored", func() bool { return len(w.item("p-1.2.1").Comments) == 1 }) } + +// Under access = "none" a viewer on the network reads the pages, with the rule in place of each reply form and no +// token, and its reply is refused naming the rule; this machine's viewer replies as before. /version digests the page +// each viewer is served, so neither sees a newer page that is not there. +func TestUnderAccessNoneANetworkViewerReadsWithNoReplyFormAndItsReplyIsRefused(t *testing.T) { + w := newFakeWork(need("p-1.2.1", work.Decision, "")) + main := shortTemp(t) + for _, d := range []string{".pm/store/records", ".git"} { + if err := os.MkdirAll(filepath.Join(main, d), 0o755); err != nil { + t.Fatal(err) + } + } + s, err := newServer(Deps{Main: main, Records: store(main), Remote: "origin", MainBranch: "main", + SiteURL: "http://devbox:8123", Listen: "0.0.0.0", Access: "none", Port: 8123, + Spool: filepath.Join(main, ".git", SpoolName), WorkDir: main, Open: w.Open, Mark: w.Mark, Site: &fakeSite{}, + Out: io.Discard, Log: io.Discard}) + if err != nil { + t.Fatal(err) + } + defer s.stop() + s.setSnap(s.refresh(nil)) + do := func(peer, method, path, host string, body url.Values) (int, string) { + t.Helper() + var rd io.Reader + if body != nil { + rd = strings.NewReader(body.Encode()) + } + r := httptest.NewRequest(method, "http://"+host+path, rd) + r.RemoteAddr, r.Host = peer, host + if body != nil { + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + rec := httptest.NewRecorder() + s.ServeHTTP(rec, r) + return rec.Code, rec.Body.String() + } + statusRE := regexp.MustCompile(`\[status (\w+)\]`) + for _, c := range []struct { + peer, host, slot string + }{ + {lan, "devbox:8123", "[read-only p-1.2.1: " + ReadOnlyRule + "]"}, + {"127.0.0.1:50000", "devbox:8123", "[read-only p-1.2.1: " + ReadOnlyRule + "]"}, // this machine, on the network's name + {"127.0.0.1:50000", "localhost:8123", "[form p-1.2.1 token=" + s.token + " "}, + } { + code, body := do(c.peer, "GET", "/", c.host, nil) + if code != 200 || !strings.Contains(body, c.slot) || (c.host != "localhost:8123" && strings.Contains(body, s.token)) { + t.Fatalf("GET / from %s Host %s: %d %q, want %q", c.peer, c.host, code, body, c.slot) + } + code, version := do(c.peer, "GET", "/version?page=/", c.host, nil) + if m := statusRE.FindStringSubmatch(body); code != 200 || m == nil || !strings.Contains(version, `"page":"`+m[1]+`"`) { + t.Errorf("/version from %s Host %s: %d %q, page %q", c.peer, c.host, code, version, body) + } + } + form := url.Values{"token": {s.token}, "id": {"p-1.2.1"}, "text": {"yes"}} + if code, body := do(lan, "POST", "/reply", "devbox:8123", form); code != 403 || body != "refused: "+ReadOnlyRule+"\n" { + t.Errorf("a reply from the network: %d %q", code, body) + } + if code, body := do("127.0.0.1:50000", "POST", "/reply", "localhost:8123", form); code != 303 { + t.Errorf("a reply from this machine: %d %q", code, body) + } +} + +// The service does not start when the site is exposed (a non-loopback listen, or a site_url) and access is unset, +// naming the key. +func TestTheServiceRefusesToStartExposedWithNoAccess(t *testing.T) { + for _, set := range []func(*Deps){ + func(d *Deps) { d.Listen = "0.0.0.0" }, + func(d *Deps) { d.SiteURL = site }, + } { + d := Deps{Main: "m", Records: "r", Spool: "s", Pin: "p", WorkDir: "w", Listen: "127.0.0.1", Open: newFakeWork().Open, + Mark: func() (string, error) { return "", nil }, Sync: func(context.Context) ([]string, error) { return nil, nil }, + GC: func(context.Context) error { return nil }, Site: &fakeSite{}, Summarize: func() (bool, string) { return true, "" }, + Out: io.Discard, Log: io.Discard} + set(&d) + err := Run(d) + if err == nil || !strings.Contains(err.Error(), "sets no access, so the pm service does not start: set access = \"cloudflare\"") { + t.Errorf("listen %s site_url %q: %v", d.Listen, d.SiteURL, err) + } + } +} + +// Binds is the listen address, and 127.0.0.1 beside a specific other one, where this machine's probes reach it. +func TestBindsAddLoopbackBesideASpecificAddress(t *testing.T) { + for listen, want := range map[string][]string{"127.0.0.1": {"127.0.0.1"}, "0.0.0.0": {"0.0.0.0"}, "::": {"::"}, + "192.168.1.5": {"192.168.1.5", "127.0.0.1"}, "::1": {"::1", "127.0.0.1"}} { + if got := Binds(listen); !slices.Equal(got, want) { + t.Errorf("Binds(%s) = %v, want %v", listen, got, want) + } + } + held, err := net.Listen("tcp", "0.0.0.0:0") // the wildcard held: 127.0.0.1 at its port is not free either + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := held.Close(); err != nil { + t.Error(err) + } + }) + addr, ok := held.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("%v is not a TCP address", held.Addr()) + } + port := addr.Port + for _, listen := range []string{"0.0.0.0", "127.0.0.1"} { + if PortFree(listen, port) { + t.Errorf("listen %s: port %d free while 0.0.0.0 holds it", listen, port) + } + } +} + +// Binds compares addresses, not spellings: 127.0.0.1 written another way binds once, so its port is free. +func TestBindsTakesAnyLoopbackSpellingOf127001Once(t *testing.T) { + for _, listen := range []string{"::ffff:127.0.0.1", "127.000.000.001"} { + if ip := net.ParseIP(listen); ip == nil { + continue // a spelling this Go does not parse is refused by config.Read + } + if got := Binds(listen); !slices.Equal(got, []string{listen}) { + t.Errorf("Binds(%s) = %v, want it alone", listen, got) + } + } + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr, ok := l.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("%v is not a TCP address", l.Addr()) + } + if err := l.Close(); err != nil { + t.Fatal(err) + } + if !PortFree("::ffff:127.0.0.1", addr.Port) { + t.Errorf("listen ::ffff:127.0.0.1: port %d not free", addr.Port) + } +} + +// hostName is a Host header's name without the port, brackets or case, with or without a port. +func TestHostNameStripsThePortAndIPv6Brackets(t *testing.T) { + for host, want := range map[string]string{"[::1]": "::1", "[::1]:8000": "::1", "LocalHost": "localhost", + "localhost:1": "localhost", "pm.example.com:443": "pm.example.com", "": "", "::1": "::1"} { + if got := hostName(host); got != want { + t.Errorf("hostName(%q) = %q, want %q", host, got, want) + } + } + if !local(from("[::1]:5", "[::1]")) || !local(from("127.0.0.1:5", "[::1]:8000")) { + t.Error("an IPv6 loopback Host from a loopback peer is not local") + } +} diff --git a/internal/service/fake_test.go b/internal/service/fake_test.go index a568b875..95f0002a 100644 --- a/internal/service/fake_test.go +++ b/internal/service/fake_test.go @@ -286,6 +286,12 @@ func (f *fakeSite) FillReplies(page, token string, replies map[string]Reply) str }) } +func (f *fakeSite) FillReadOnly(page, rule string) string { + return slot.ReplaceAllStringFunc(page, func(m string) string { + return fmt.Sprintf("[read-only %s: %s]", slot.FindStringSubmatch(m)[1], rule) + }) +} + func (f *fakeSite) FillStatus(page string, asOf time.Time, digest string, now time.Time) string { return strings.Replace(page, StatusSlot, "[status "+digest+"]", 1) } @@ -351,7 +357,7 @@ func helperService(main string) int { var port int fmt.Sscan(os.Getenv("PORT"), &port) pin := filepath.Join(main, ".pm/config.toml") - err := Run(Deps{Main: main, Records: store(main), Remote: "origin", MainBranch: "main", Port: port, Pin: pin, + err := Run(Deps{Main: main, Records: store(main), Remote: "origin", MainBranch: "main", Listen: "127.0.0.1", Port: port, Pin: pin, Spool: filepath.Join(main, ".git", SpoolName), WorkDir: main, Open: w.Open, Mark: w.Mark, Sync: w.Sync, GC: w.GC, Site: &fakeSite{}, Summarize: func() (bool, string) { return true, "nothing to summarize" }, Style: []byte("body{}"), Out: os.Stdout, Log: os.Stderr}) diff --git a/internal/service/lifecycle.go b/internal/service/lifecycle.go index 2cc1c2d6..14e56fa9 100644 --- a/internal/service/lifecycle.go +++ b/internal/service/lifecycle.go @@ -160,15 +160,58 @@ func enable(main, kind string) error { return checked("systemctl", "--user", "enable", filepath.Base(UnitFile(main, kind))) } -// PortFree is whether a server could bind 127.0.0.1:port now, as the service does: with SO_REUSEADDR (Go sets it on -// a listener), so a port whose last connections linger in TIME_WAIT, as after this clone's service stopped, is free. -func PortFree(port int) bool { - l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port)) +// Binds is the addresses the service binds for listen: listen, and beside a specific address other than 127.0.0.1 +// also 127.0.0.1, where this machine's probes (Answering), links (pm record link --local) and a tunnel reach it. A +// wildcard (0.0.0.0, ::) takes 127.0.0.1's connections already, and binding both would conflict. Addresses compare as +// IPs, so ::ffff:127.0.0.1 is 127.0.0.1. +func Binds(listen string) []string { + if ip := net.ParseIP(listen); ip != nil && (ip.Equal(net.ParseIP(config.Loopback)) || ip.IsUnspecified()) { + return []string{listen} + } + return []string{listen, config.Loopback} +} + +// listen binds each of Binds(addr) at port, the first one's port when port is 0; on a failure none stays bound. +func listen(addr string, port int) ([]net.Listener, error) { + var lns []net.Listener + for _, a := range Binds(addr) { + if first, ok := firstAddr(lns); ok { + port = first.Port + } + l, err := net.Listen("tcp", net.JoinHostPort(a, strconv.Itoa(port))) + if err != nil { + return nil, errors.Join(err, closeAll(lns)) + } + lns = append(lns, l) + } + return lns, nil +} + +// firstAddr is the first listener's TCP address; false when there is none. +func firstAddr(lns []net.Listener) (*net.TCPAddr, bool) { + if len(lns) == 0 { + return nil, false + } + a, ok := lns[0].Addr().(*net.TCPAddr) + return a, ok +} + +func closeAll(lns []net.Listener) error { + var errs []error + for _, l := range lns { + errs = append(errs, l.Close()) + } + return errors.Join(errs...) +} + +// PortFree is whether a server could bind port on Binds(addr) now, and let it go, as the service does: with SO_REUSEADDR (Go sets it +// on a listener), so a port whose last connections linger in TIME_WAIT, as after this clone's service stopped, is free. +func PortFree(addr string, port int) bool { + lns, err := listen(addr, port) if err != nil { return false } - l.Close() - return true + return closeAll(lns) == nil } // Served is what answers on a port: the store it renders and the pm build it runs, by StoreHeader and VersionHeader @@ -193,11 +236,11 @@ var probe = Answering // store is the clone's records store, resolved: what its service names in StoreHeader. func store(main string) string { return resolve(filepath.Join(main, config.Store)) } -// CheckPort refuses a site port another server holds. This clone's own pm service on it is fine (pm init run again), +// CheckPort refuses a site port another server holds on Binds(listen). This clone's own pm service on it is fine (pm init run again), // and so is a port this clone's installed unit serves on where nothing answers: its own service hung, which install // restarts. -func CheckPort(main string, port int) error { - if PortFree(port) { +func CheckPort(main, listen string, port int) error { + if PortFree(listen, port) { return nil } served := probe(port) @@ -216,7 +259,7 @@ func CheckPort(main string, port int) error { what = "a server that is not pm" } return refuse("the site port :%d is held by %s, so the pm service could not serve there; pm init wrote nothing. "+ - "Run PORT=%d pm init (a free port), or stop what holds :%d", port, what, FreePort(), port) + "Run PORT=%d pm init (a free port), or stop what holds :%d", port, what, FreePort(listen), port) } // LockInstall holds the clone's install lock,
/.pm/run/install.lock, waiting for another holder: two diff --git a/internal/service/run.go b/internal/service/run.go index f86424c4..f8e95f3c 100644 --- a/internal/service/run.go +++ b/internal/service/run.go @@ -20,6 +20,7 @@ import ( "path" "path/filepath" "regexp" + "slices" "strings" gosync "sync" "sync/atomic" @@ -54,6 +55,9 @@ type Site interface { // FillReplies replaces each awaiting card's reply slot with its form, carrying token for the POST, and shows the // replies still on their way to the work store, by need id. FillReplies(page, token string, replies map[string]Reply) string + // FillReadOnly replaces each reply slot with the note rule, for a viewer who may read but not reply: no form, no + // token. + FillReadOnly(page, rule string) string // FillStatus fills the page's StatusSlot: the time its data is current as of, its digest (which the page's // reload script compares with /version) and now. FillStatus(page string, asOf time.Time, digest string, now time.Time) string @@ -84,6 +88,8 @@ type Deps struct { Main, Records string // the main checkout, and the records store (named in StoreHeader) Remote, MainBranch string // the config's SiteURL string // the config's site_url, "" when unset + Listen string // the config's listen, else config.Loopback: the address the site binds (Binds) + Access string // the config's access, "" when unset: see refusal // AccessTeam and AccessAud are the config's Cloudflare Access keys, "" when unset (then every request through the // tunnel is refused): see refusal. AccessTeam, AccessAud string @@ -215,8 +221,8 @@ func (s *server) poke() { } } -// Run is pm service run: serve the site on 127.0.0.1:Port and sync every pmsync.Interval, the first that long after -// start. A request never waits on a store: it renders from the last snapshot, which the refresher keeps current. Each +// Run is pm service run: serve the site on Binds(Listen) at Port and sync every pmsync.Interval, the first that long after +// start; refused when the site is exposed (config.Config.Exposed) with no access. A request never waits on a store: it renders from the last snapshot, which the refresher keeps current. Each // look (every ServeCheck, sooner after a request or a reply) stamps the records and reads the work store's change // mark, and only when one moved reads the items; a state that fails to render is read again // under the records store's shared lock, and then served as the error, never as an old page. A reply is checked @@ -230,36 +236,36 @@ func (s *server) poke() { // way runs to its end or its timeout, as the host's statement does). func Run(d Deps) error { if d.Open == nil || d.Mark == nil || d.Sync == nil || d.GC == nil || d.Site == nil || d.Summarize == nil || d.Out == nil || d.Log == nil || - d.Main == "" || d.Records == "" || d.Spool == "" || d.Pin == "" || d.WorkDir == "" { + d.Main == "" || d.Records == "" || d.Spool == "" || d.Pin == "" || d.WorkDir == "" || d.Listen == "" { return errors.New("pm service run: a dependency is missing") } - tok := make([]byte, 32) - if _, err := rand.Read(tok); err != nil { + if missing := (config.Config{SiteURL: d.SiteURL, Listen: d.Listen, Access: d.Access}).AccessMissing(); slices.Contains(missing, "access") { + return &Error{fmt.Sprintf("the site is reachable beyond this machine (listen = %q, site_url = %q), but %s sets "+ + "no access, so the pm service does not start: set access = \"cloudflare\" (Cloudflare Access verifies each "+ + "request) or access = \"none\" (the network is the gate) there, with pm init --access, and restart the pm service", + d.Listen, d.SiteURL, config.Rel)} + } + s, err := newServer(d) + if err != nil { return err } - s := &server{d: d, texts: Texts{d.Main, d.Remote, d.MainBranch}, token: base64.RawURLEncoding.EncodeToString(tok), - replies: map[string]Reply{}, merges: map[string]bool{}, swept: map[string]sweepState{}, - queue: make(chan job, 1024), sweepDue: make(chan struct{}, 1), wake: make(chan struct{}, 1), stopped: make(chan string, 1), - serveCheck: ServeCheck, mergePoll: MergePoll, moved: make(chan struct{})} - ctx, stop := context.WithCancel(context.Background()) - s.stop, s.done = stop, ctx.Done() defer s.stop() - _, err := exec.LookPath("gh") + _, err = exec.LookPath("gh") s.gh = err == nil - ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", d.Port)) + lns, err := listen(d.Listen, d.Port) if err != nil { return err } + ln := lns[0] srv := &http.Server{Handler: s, ReadHeaderTimeout: 30 * time.Second} s.setSnap(s.refresh(nil)) // the first load has data to serve pending, err := Pending(d.Spool) if err != nil { - ln.Close() - return err + return errors.Join(err, closeAll(lns)) } waits, err := s.serveWaits() if err != nil { - return errors.Join(err, ln.Close()) + return errors.Join(err, closeAll(lns)) } s.spawn(s.writer) // before the pending replies are queued, so any number of them fits for _, e := range pending { // replies a crash or kill left pending @@ -277,8 +283,15 @@ func Run(d Deps) error { s.spawn(s.collector) port := ln.Addr().(*net.TCPAddr).Port fmt.Fprintf(d.Out, "Serving http://localhost:%d; each page states the age of its data, at most %d s behind unless "+ - "it says so; the work store reread when it changes; pushing every %d min\n", port, ServeBehind, - int(pmsync.Interval.Minutes())) + "it says so; the work store reread when it changes; pushing every %d min%s\n", port, ServeBehind, + int(pmsync.Interval.Minutes()), (config.Config{Listen: d.Listen, Access: d.Access}).Reach("; ")) + for _, l := range lns[1:] { // loopback beside a specific listen address: Shutdown closes it and ends this Serve + go func() { + if err := srv.Serve(l); !errors.Is(err, http.ErrServerClosed) { + s.logf("error: serving on %s: %v", l.Addr(), err) + } + }() + } err = srv.Serve(ln) s.stop() _ = srv.Shutdown(context.Background()) // the requests under way end; the refresher already shut it down on a pin move @@ -295,6 +308,21 @@ func Run(d Deps) error { } } +// newServer is the server Run runs on d, with a new reply token; s.stop ends what it starts. +func newServer(d Deps) (*server, error) { + tok := make([]byte, 32) + if _, err := rand.Read(tok); err != nil { + return nil, err + } + s := &server{d: d, texts: Texts{d.Main, d.Remote, d.MainBranch}, token: base64.RawURLEncoding.EncodeToString(tok), + replies: map[string]Reply{}, merges: map[string]bool{}, swept: map[string]sweepState{}, + queue: make(chan job, 1024), sweepDue: make(chan struct{}, 1), wake: make(chan struct{}, 1), stopped: make(chan string, 1), + serveCheck: ServeCheck, mergePoll: MergePoll, moved: make(chan struct{})} + ctx, stop := context.WithCancel(context.Background()) + s.stop, s.done = stop, ctx.Done() + return s, nil +} + // put queues a job for the writer; it never blocks a request (the queue holds 1,024), nor outlasts Run (a job put // once Run stopped is dropped; a reply stays in the spool for the next start). A sweep takes no queue place: it marks // one due, and asks made while one is due add nothing. @@ -441,9 +469,10 @@ func errorPage(title, heading, body string) string { "
" + StatusSlot + "

" + heading + "

" + body + "
" } -// page is the status and HTML of the page at path from the current snapshot, its reply slots filled and its status -// slot left for FillStatus, and that snapshot. -func (s *server) page(path string) (int, string, *snapshot) { +// page is the status and HTML of the page at path from the current snapshot, its reply slots filled (for a readOnly +// viewer with ReadOnlyRule in place of each form, and no token) and its status slot left for FillStatus, and that +// snapshot. +func (s *server) page(path string, readOnly bool) (int, string, *snapshot) { snap := s.snap.Load() if snap.err != "" { return 500, errorPage("Render failed", "The site did not render", "
"+html.EscapeString(snap.err)+
@@ -488,6 +517,9 @@ func (s *server) page(path string) (int, string, *snapshot) {
 		}
 	}
 	s.mu.Unlock()
+	if readOnly {
+		return r.code, s.d.Site.FillReadOnly(text, ReadOnlyRule), snap
+	}
 	return r.code, s.d.Site.FillReplies(text, s.token, shown), snap
 }
 
@@ -609,7 +641,7 @@ func (s *server) get(w http.ResponseWriter, r *http.Request) {
 		if err != nil {
 			u = &url.URL{Path: target}
 		}
-		_, text, snap := s.page(pageOf(u))
+		_, text, snap := s.page(pageOf(u), s.readOnly(r))
 		body, err := json.Marshal(struct {
 			AsOf float64 `json:"asof"`
 			Page string  `json:"page"`
@@ -624,7 +656,7 @@ func (s *server) get(w http.ResponseWriter, r *http.Request) {
 		s.image(w, strings.TrimPrefix(file, "/"), draft, ctype)
 		return
 	}
-	code, text, snap := s.page(pageOf(r.URL))
+	code, text, snap := s.page(pageOf(r.URL), s.readOnly(r))
 	text = s.d.Site.FillStatus(text, snap.asOf, digest(text), time.Now())
 	s.reply(w, code, "text/html; charset=utf-8", []byte(text))
 }
@@ -741,6 +773,10 @@ func (s *server) post(w http.ResponseWriter, r *http.Request) {
 		s.reply(w, 404, "text/plain; charset=utf-8", []byte("only /reply takes a POST\n"))
 		return
 	}
+	if s.readOnly(r) {
+		s.reply(w, 403, "text/plain; charset=utf-8", []byte("refused: "+ReadOnlyRule+"\n"))
+		return
+	}
 	raw, err := io.ReadAll(io.LimitReader(r.Body, MaxReply+1))
 	if err != nil {
 		s.reply(w, 400, "text/plain; charset=utf-8", []byte("refused: the body could not be read\n"))
diff --git a/internal/service/run_test.go b/internal/service/run_test.go
index 6fddedd2..8e5592d0 100644
--- a/internal/service/run_test.go
+++ b/internal/service/run_test.go
@@ -97,7 +97,7 @@ func serveEvery(t *testing.T, w *fakeWork, main string, mergePoll time.Duration,
 		spool: filepath.Join(main, ".git", SpoolName), out: &syncBuffer{}, log: &syncBuffer{}, done: make(chan error, 1)}
 	s.setPin(Version())
 	go func() {
-		d := Deps{Main: main, Records: store(main), Remote: "origin", MainBranch: "main", Port: 0, Pin: s.pin,
+		d := Deps{Main: main, Records: store(main), Remote: "origin", MainBranch: "main", Listen: "127.0.0.1", Port: 0, Pin: s.pin,
 			Spool: s.spool, WorkDir: main, Open: w.Open, Mark: w.Mark, Sync: w.Sync, GC: w.GC, Site: s.site,
 			Summarize: func() (bool, string) { return true, "summarized" }, Style: []byte("body{}"), Out: s.out,
 			Log: s.log}
@@ -627,7 +627,7 @@ func TestAFailedOpenIsTriedAgainAtTheNextLookAndAnOversizedReplyIsRefused(t *tes
 		spool: filepath.Join(main, SpoolName), out: &syncBuffer{}, log: &syncBuffer{}, done: make(chan error, 1)}
 	s.setPin(Version())
 	go func() {
-		s.done <- Run(Deps{Main: main, Records: store(main), Remote: "origin", MainBranch: "main", Pin: s.pin,
+		s.done <- Run(Deps{Main: main, Records: store(main), Remote: "origin", MainBranch: "main", Listen: "127.0.0.1", Pin: s.pin,
 			Spool: s.spool, WorkDir: main, Open: open, Mark: w.Mark, Sync: w.Sync, GC: w.GC, Site: s.site,
 			Summarize: func() (bool, string) { return true, "" }, Style: []byte("x"), Out: s.out, Log: s.log})
 	}()
diff --git a/internal/service/unit.go b/internal/service/unit.go
index e481d891..f7784055 100644
--- a/internal/service/unit.go
+++ b/internal/service/unit.go
@@ -339,11 +339,12 @@ func UnitPorts() map[int]bool {
 // FirstPort is where a new repo's site port starts: the first free one from here.
 var FirstPort = 8000
 
-// FreePort is the first port from FirstPort up that is free and no pm service unit on this machine names.
-func FreePort() int {
+// FreePort is the first port from FirstPort up that is free on Binds(listen) and no pm service unit on this machine
+// names.
+func FreePort(listen string) int {
 	taken := UnitPorts()
 	port := FirstPort
-	for taken[port] || !PortFree(port) {
+	for taken[port] || !PortFree(listen, port) {
 		port++
 	}
 	return port
diff --git a/internal/service/unit_test.go b/internal/service/unit_test.go
index b2f4e9ef..c6d8fc71 100644
--- a/internal/service/unit_test.go
+++ b/internal/service/unit_test.go
@@ -9,6 +9,8 @@ import (
 	"strings"
 	"testing"
 	"time"
+
+	"github.com/Yeeef/pm/internal/config"
 )
 
 func TestCheckPortTakesItsOwnUnitsPortWhereNothingAnswers(t *testing.T) {
@@ -23,7 +25,7 @@ func TestCheckPortTakesItsOwnUnitsPortWhereNothingAnswers(t *testing.T) {
 	}
 	defer held.Close()
 	port := held.Addr().(*net.TCPAddr).Port
-	if err := CheckPort(main, port); err == nil || !strings.Contains(err.Error(),
+	if err := CheckPort(main, config.Loopback, port); err == nil || !strings.Contains(err.Error(),
 		"the site port :"+strconv.Itoa(port)+" is held by a process that does not answer HTTP") {
 		t.Fatal(err)
 	}
@@ -31,12 +33,12 @@ func TestCheckPortTakesItsOwnUnitsPortWhereNothingAnswers(t *testing.T) {
 	os.MkdirAll(filepath.Dir(unit), 0o755)
 	b, _ := UnitBytes(main, PlatformKind(), "/bin", port+1, Command("/pm"))
 	os.WriteFile(unit, b, 0o644)
-	if err := CheckPort(main, port); err == nil { // its unit serves on another port
+	if err := CheckPort(main, config.Loopback, port); err == nil { // its unit serves on another port
 		t.Fatal("refused nothing")
 	}
 	b, _ = UnitBytes(main, PlatformKind(), "/bin", port, Command("/pm"))
 	os.WriteFile(unit, b, 0o644)
-	if err := CheckPort(main, port); err != nil {
+	if err := CheckPort(main, config.Loopback, port); err != nil {
 		t.Fatal(err)
 	}
 	// The unit files pm writes for each case of testdata/units, byte for byte: the files Python pm 0.1.x wrote for the
diff --git a/internal/site/serve.go b/internal/site/serve.go
index 6811af85..c2857097 100644
--- a/internal/site/serve.go
+++ b/internal/site/serve.go
@@ -156,6 +156,12 @@ func FillReplies(page, token string, replies map[string]Reply) string {
 	})
 }
 
+// FillReadOnly is page with each card's reply slot replaced by rule, why this viewer cannot reply: a page served to a
+// viewer who may read but not reply carries no form and no token.
+func FillReadOnly(page, rule string) string {
+	return slotRE.ReplaceAllLiteralString(page, `

No reply from here: `+pyEscape(rule)+`.

`) +} + // ServeBehind is how many seconds a served page may be behind the records and the work store before it says so. const ServeBehind = 10 diff --git a/internal/site/serve_test.go b/internal/site/serve_test.go index bec173c7..2c9c4442 100644 --- a/internal/site/serve_test.go +++ b/internal/site/serve_test.go @@ -45,6 +45,15 @@ func TestGoldenFillRepliesAndStatus(t *testing.T) { } } +// A read-only viewer's page holds the rule, escaped, in place of every reply slot: no form, no token. +func TestFillReadOnlyPutsTheRuleInPlaceOfEveryForm(t *testing.T) { + got := FillReadOnly("", `access = "none"`) + note := `

No reply from here: access = "none".

` + if got != ""+note+note+"" { + t.Errorf("got %s", got) + } +} + // TestAReplyToASessionWithNoInboxWaitsForIt: a session that stores no inbox gets no push, so a reply to its need is // not "not running": the card after the reply and the need's thread say it waits for that session, which pm show flags // it to and pm reply read prints it for; a need with an inbox, a Claude Code socket or a Codex route, keeps the not-delivered text. diff --git a/prime.md b/prime.md index a0827d04..89b0d2d2 100644 --- a/prime.md +++ b/prime.md @@ -121,7 +121,7 @@ Use raw HTML only for what Markdown cannot show, such as a mock-up. - close a sprint: `pm sprint close `, once its report is written, its tasks are closed and its PR review is closed as merged. For a PR merged with no review: `pm sprint close --merged --pr URL`. It then publishes the sprint's design page drafts, from every worktree, in one records commit. A draft that overlaps another commit is named: run any pm write in that worktree, resolve the conflict markers, `pm commit --sprint ` the page, and close again; `--discard --reason "…"` drops the draft instead. - find a link: Give the owner a record's URL from `pm record link `; never a `records/…` path or a URL you built. - Report where pm got in your way: `pm feedback add [--project NAME]`, once, with what happened and what would have helped. -- setup pm: `pm init` installs pm in the repo, clone and worktree, doing only what is missing: the repo's files and hooks, the records store and workspace, and the pm service. `pm init --site-url URL` sets the public site link. +- setup pm: `pm init` installs pm in the repo, clone and worktree, doing only what is missing: the repo's files and hooks, the records store and workspace, and the pm service. `pm init --site-url URL` sets the public site link; `--listen ADDR` and `--access cloudflare|none` set who reaches it. Less frequent, each detailed in its `--help`: diff --git a/size_test.go b/size_test.go index dc750511..3a876e0e 100644 --- a/size_test.go +++ b/size_test.go @@ -15,7 +15,7 @@ const maxPackageLines = 5000 // oversized holds the packages already past the cap at the size each may reach, a ratchet: lower an entry as its // package shrinks, and drop it once the package fits the cap. var oversized = map[string]int{ - "internal/cli": 7301, + "internal/cli": 7257, } // TestPackagesStayUnderTheSizeCap counts each package directory's lines in its non-test .go files. diff --git a/tests/test_config.py b/tests/test_config.py index 487723be..c1effa4f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,7 +15,7 @@ COMMANDS = [("show",), ("where",), ("prime", "--subagent"), ("hook", "stop")] CONFIG = ".pm/config.toml" -KEYS = ("version", "remote", "main_branch", "port", "site_url", "access_team", "access_aud", "summary_runtime", "design_sources") # what the config accepts +KEYS = ("version", "remote", "main_branch", "port", "site_url", "listen", "access", "access_team", "access_aud", "summary_runtime", "design_sources") # what the config accepts def test_every_config_key_is_named_in_some_commands_help(repo): diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 53e7dad3..6ba4963b 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -257,18 +257,55 @@ def launched(pin: str, *args: str) -> subprocess.CompletedProcess: # as the ins # --site-url is the one repo write a later pm init makes, on request; a bad URL is refused before any write res = pm(existing, "init", "--site-url", "pm.example.com") assert res.returncode == 1 and "is not an http(s) base URL" in res.stderr and git(existing, "status", "--porcelain") == "" + # a site_url exposes the site, so it needs access; a --listen that is not an IP is refused, both before any write res = pm(existing, "init", "--site-url", "https://pm.example.com/") + assert res.returncode == 1 and "with no access set, which the pm service does not start on: give --access " \ + "cloudflare" in res.stderr and git(existing, "status", "--porcelain") == "", res.stderr + res = pm(existing, "init", "--listen", "my-host") + assert res.returncode == 1 and '--listen "my-host" is not an IP address' in res.stderr + # a network listen with no site_url serves nobody on the network (only site_url's host is): init and doctor say so + res = pm(existing, "init", "--listen", "0.0.0.0", "--access", "none") + warning = ('listen = "0.0.0.0", but no site_url is set, so the site refuses every request from the network: only ' + "site_url's host is served beyond this machine; set the URL colleagues open with pm init --site-url") + assert res.returncode == 0 and f"warning: {warning}" in res.stdout, res.stdout + code, lines = doctor(existing) + assert code == 1 and any(line.startswith(f"config: {warning}") for line in lines), lines + res = pm(existing, "init", "--listen", "", "--access", "cloudflare") + assert res.returncode == 0 and "warning" not in res.stdout and "listen cleared" in res.stdout, res.stdout + res = pm(existing, "init", "--site-url", "https://pm.example.com/", "--access", "cloudflare") assert res.returncode == 0 and "git add -- .pm/config.toml && " in res.stdout, res.stdout - assert tomllib.loads((existing / ".pm/config.toml").read_text())["site_url"] == "https://pm.example.com" + config = tomllib.loads((existing / ".pm/config.toml").read_text()) + assert (config["site_url"], config["access"]) == ("https://pm.example.com", "cloudflare") assert settings.read_bytes() == changed - # a site_url needs the Access keys: doctor names the missing ones, and the config holding them is pm's own + # access = "cloudflare" needs the Access keys: doctor names the missing ones, and the config holding them is pm's own + code, lines = doctor(existing) + assert code == 1 and 'config: access = "cloudflare", but .pm/config.toml lacks access_team, access_aud, so the ' \ + "site refuses every request from beyond this machine; set what is missing there (pm init " \ + "--help)" in lines, lines + # a config from before access existed (the Access keys, no access) is named by doctor, and pm init (session start + # runs it) migrates it to access = "cloudflare", which it meant + config = existing / ".pm/config.toml" + text = config.read_text().replace('access = "cloudflare"\n', "") + old = text + 'access_team = "team.cloudflareaccess.com"\naccess_aud = "aud-tag"\nsummary_runtime = "codex"\n' + config.write_text(old) code, lines = doctor(existing) - assert code == 1 and "config: site_url is set, but .pm/config.toml lacks access_team, access_aud, so the site " \ - "refuses every request to its host; set what is missing there (pm init --help)" in lines, lines - with (existing / ".pm/config.toml").open("a") as f: - f.write('access_team = "team.cloudflareaccess.com"\naccess_aud = "aud-tag"\n') + assert code == 1 and "lacks access, so the pm service does not start; set it with pm init --access cloudflare or " \ + "--access none" in "\n".join(lines), lines + # the migrated config is the old one with access in the place pm's writer gives it (ConfigText), every other key + # kept, so doctor finds no drift in it + migrated = old.replace('access_team = ', 'access = "cloudflare"\naccess_team = ', 1) + res = pm(existing, "init", "--session-start") + assert res.returncode == 0 and 'access set to "cloudflare"' in res.stdout, res.stdout + assert config.read_text() == migrated code, lines = doctor(existing) assert code == 1 and reported(lines, ["repo: .claude/settings.json: pm's part differs"]), lines + # pm upgrade, which moves the pin a restarted service reads, migrates the same config the same way + config.write_text(old) + res = pm(existing, "upgrade") + assert res.returncode == 0, res.stderr + assert config.read_text() == migrated + code, lines = doctor(existing) + assert not any(line.startswith(("repo: .pm/config.toml", "config:")) for line in lines), lines OLD_PRE_COMMIT = '#!/usr/bin/env sh\n# --- BEGIN PM v0.3.0 ---\npm hook git-pre-commit "$@" || exit $?\n# --- END PM ---\n' diff --git a/tests/test_pm.py b/tests/test_pm.py index e74b986e..3b045043 100644 --- a/tests/test_pm.py +++ b/tests/test_pm.py @@ -1927,7 +1927,7 @@ def get(url: str, path: str, host: str | None = None, headers: dict | None = Non assert "X-PM-Store" not in refused and "X-PM-Version" not in refused, refused return code, body - write_config(repo.root, site_url="https://pm.example.com", access_aud="aud-tag") + write_config(repo.root, site_url="https://pm.example.com", access="cloudflare", access_aud="aud-tag") with serving(repo) as (url, _): assert get(url, "")[0] == 200 for path in ("", "style.css", "version"): @@ -1939,8 +1939,8 @@ def get(url: str, path: str, host: str | None = None, headers: dict | None = Non assert code == 403 and "lacks access_team" in said code, said = get(url, "", "evil.example") # a rebinding domain reads no page assert code == 403 and "is neither this machine" in said - write_config(repo.root, site_url="https://pm.example.com", access_team="team.cloudflareaccess.com", - access_aud="aud-tag") + write_config(repo.root, site_url="https://pm.example.com", access="cloudflare", + access_team="team.cloudflareaccess.com", access_aud="aud-tag") with serving(repo) as (url, _): token = page_token(url) code, _, said = post_reply(url, {"token": token, "id": "repo-demo.1.2", "text": "Small."},