diff --git a/cmd/provision/main.go b/cmd/provision/main.go index 03f79a9..464f5db 100644 --- a/cmd/provision/main.go +++ b/cmd/provision/main.go @@ -11,14 +11,18 @@ package main import ( + "context" + "errors" "flag" "fmt" "os" - "path/filepath" + "github.com/tegwick/fluid-telegram/internal/apply" "github.com/tegwick/fluid-telegram/internal/plan" + "github.com/tegwick/fluid-telegram/internal/secrets" "github.com/tegwick/fluid-telegram/internal/spec" "github.com/tegwick/fluid-telegram/internal/state" + tgc "github.com/tegwick/fluid-telegram/internal/tg" ) const usage = `provision -- reconcile a Telegram presence with its declared spec @@ -26,12 +30,17 @@ const usage = `provision -- reconcile a Telegram presence with its declared spec provision plan --spec [--check] show what would change; writes nothing provision apply --spec execute an approved plan - session bootstrap / session check mint and inspect the operator session + provision session bootstrap --campaign mint the operator session (interactive) + provision session check --campaign verify the stored session Flags: - --spec path to the presence spec (campaign repo) - --root repo root holding presence/resolved/ (default: cwd) - --check exit non-zero if anything would change; for scheduled drift checks + --spec path to the presence spec (in the campaign repo) + --root repo root holding presence/resolved/ (default: .) + --check exit non-zero if anything would change; for scheduled drift checks + --offline compute a plan without contacting Telegram + +Credentials come from OpenBao via BAO_ADDR and BAO_TOKEN. +See docs/seeding-runbook.md. ` func main() { @@ -42,11 +51,11 @@ func main() { var err error switch os.Args[1] { case "plan": - err = cmdPlan(os.Args[2:]) + err = cmdReconcile(os.Args[2:], false) case "apply": - err = fmt.Errorf("apply is not implemented yet (FT-WP-0002 T04); plan is safe to run") + err = cmdReconcile(os.Args[2:], true) case "session": - err = fmt.Errorf("session is not implemented yet (FT-WP-0002 T01)") + err = cmdSession(os.Args[2:]) case "-h", "--help", "help": fmt.Print(usage) return @@ -59,15 +68,23 @@ func main() { } } -func cmdPlan(args []string) error { - fs := flag.NewFlagSet("plan", flag.ExitOnError) +func cmdReconcile(args []string, doApply bool) error { + name := "plan" + if doApply { + name = "apply" + } + fs := flag.NewFlagSet(name, flag.ExitOnError) specPath := fs.String("spec", "", "path to the presence spec") root := fs.String("root", ".", "repo root holding presence/resolved/") check := fs.Bool("check", false, "exit non-zero if anything would change") + offline := fs.Bool("offline", false, "compute a plan without contacting Telegram") fs.Parse(args) if *specPath == "" { - return fmt.Errorf("--spec is required") + return errors.New("--spec is required") + } + if doApply && *offline { + return errors.New("--offline cannot be combined with apply") } sp, digest, err := spec.Load(*specPath) @@ -79,40 +96,65 @@ func cmdPlan(args []string) error { if err != nil { return err } + ctx := context.Background() - // Live observation needs the operator session, which does not exist yet. - // Until it does, plan runs against the resolved state alone and says so -- - // an offline plan is still worth reading on a first run, where everything is - // a creation, but it must not be mistaken for a drift check. - live, offline := liveOrOffline() + if *offline { + p, err := plan.Compute(sp, digest, rs, offlineLive{}) + if err != nil { + return err + } + fmt.Print(p.Render()) + fmt.Printf("\nnote: --offline, so nothing was observed live. this plan reflects\n"+ + " %s and the spec only, and is not a drift check.\n", statePath) + if *check { + return errors.New("--check needs live observation; drop --offline") + } + return nil + } - p, err := plan.Compute(sp, digest, rs, live) + store, err := secrets.NewFromEnv(sp.Campaign) + if err != nil { + return fmt.Errorf("%w\n(use --offline for a plan that does not contact Telegram)", err) + } + creds, err := tgc.LoadCredentials(ctx, store) if err != nil { return err } + client := tgc.New(store, creds) - fmt.Print(p.Render()) - if offline { - fmt.Printf("\nnote: no operator session, so nothing was observed live.\n"+ - " this plan reflects %s and the spec only.\n", relOrAbs(statePath)) - if *check { - return fmt.Errorf("--check needs live observation; see docs/seeding-runbook.md") + // No authenticator: reconciliation must never prompt. If the session is + // gone, that is a runbook step, not something to paper over mid-run. + return client.Run(ctx, nil, func(ctx context.Context, c *tgc.Client) error { + p, err := plan.Compute(sp, digest, rs, tgc.Live{Ctx: ctx, Client: c, Resolved: rs}) + if err != nil { + return err } - } - if *check && !p.Empty() { - return fmt.Errorf("presence has drifted from the spec") - } - // A block means the world disagrees with the state file; a deferral is the - // design working. Only the first is a failure. - if p.Blocked() { - os.Exit(1) - } - return nil + fmt.Print(p.Render()) + + if *check && !p.Empty() { + return errors.New("presence has drifted from the spec") + } + if p.Blocked() { + os.Exit(1) + } + if !doApply { + return nil + } + if p.Empty() { + fmt.Println("\nnothing to apply") + return nil + } + fmt.Println("\napplying:") + return apply.Run(ctx, p, apply.Options{ + Spec: sp, SpecDigest: digest, State: rs, StatePath: statePath, + Store: store, Client: c, Out: os.Stdout, + }) + }) } // offlineLive answers only what can be known without a session. It never claims -// something is fine; where it cannot tell, plan is told the presence is intact -// so that a first run still renders, and the caller reports that it was offline. +// something is fine; where it cannot tell, it reports the presence intact so a +// first run still renders, and the caller says plainly that nothing was observed. type offlineLive struct{} func (offlineLive) BotExists(string) (bool, error) { return true, nil } @@ -120,19 +162,3 @@ func (offlineLive) ChannelAdminRights(int64) ([]string, error) { return []string{spec.PostMessages}, nil } func (offlineLive) ChannelUsername(int64) (string, error) { return "", nil } - -func liveOrOffline() (plan.Live, bool) { - // FT-WP-0002 T03/T04: return the MTProto client once the session exists. - return offlineLive{}, true -} - -func relOrAbs(p string) string { - if abs, err := filepath.Abs(p); err == nil { - if wd, err := os.Getwd(); err == nil { - if rel, err := filepath.Rel(wd, abs); err == nil { - return rel - } - } - } - return p -} diff --git a/cmd/provision/session.go b/cmd/provision/session.go new file mode 100644 index 0000000..8a8af12 --- /dev/null +++ b/cmd/provision/session.go @@ -0,0 +1,120 @@ +package main + +import ( + "bufio" + "context" + "flag" + "fmt" + "os" + "strings" + "syscall" + + "github.com/gotd/td/tg" + "golang.org/x/term" + + "github.com/tegwick/fluid-telegram/internal/secrets" + tgc "github.com/tegwick/fluid-telegram/internal/tg" +) + +// prompt is the only interactive path in this tool. It exists so the +// interactive part is bounded and named rather than spread through the process: +// Telegram sends the login code out of band by design, and no amount of +// automation removes that. +type prompt struct{ in *bufio.Reader } + +func newPrompt() *prompt { return &prompt{in: bufio.NewReader(os.Stdin)} } + +func (p *prompt) ask(label string) (string, error) { + fmt.Fprintf(os.Stderr, "%s: ", label) + line, err := p.in.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSpace(line), nil +} + +func (p *prompt) askSecret(label string) (string, error) { + fmt.Fprintf(os.Stderr, "%s: ", label) + b, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil +} + +func (p *prompt) Phone(context.Context) (string, error) { + return p.ask("operator phone number (international format)") +} + +func (p *prompt) Code(_ context.Context, _ *tg.AuthSentCode) (string, error) { + return p.ask("login code Telegram just sent") +} + +func (p *prompt) Password(context.Context) (string, error) { + return p.askSecret("two-factor password") +} + +func cmdSession(args []string) error { + if len(args) == 0 { + return fmt.Errorf("session needs a subcommand: bootstrap or check") + } + fs := flag.NewFlagSet("session", flag.ExitOnError) + campaign := fs.String("campaign", "", "campaign slug (names the OpenBao subtree)") + fs.Parse(args[1:]) + if *campaign == "" { + return fmt.Errorf("--campaign is required") + } + + store, err := secrets.NewFromEnv(*campaign) + if err != nil { + return err + } + ctx := context.Background() + creds, err := tgc.LoadCredentials(ctx, store) + if err != nil { + return err + } + client := tgc.New(store, creds) + + switch args[0] { + case "bootstrap": + fmt.Fprintf(os.Stderr, + "Minting an operator session for %q.\n"+ + "The session is a full-account credential: it goes straight to %s\n"+ + "and is never written to disk.\n\n", *campaign, + store.Ref(secrets.KeyOperatorSession)) + return client.Run(ctx, newPrompt(), func(ctx context.Context, c *tgc.Client) error { + self, err := c.Self(ctx) + if err != nil { + return err + } + fmt.Printf("session stored for %s (id %d)\n", displayName(self), self.ID) + return nil + }) + + case "check": + // No authenticator: a check must fail if the stored session is unusable, + // not quietly prompt for a new one and call that success. + return client.Run(ctx, nil, func(ctx context.Context, c *tgc.Client) error { + self, err := c.Self(ctx) + if err != nil { + return err + } + fmt.Printf("session valid: %s (id %d)\n", displayName(self), self.ID) + return nil + }) + } + return fmt.Errorf("unknown session subcommand %q", args[0]) +} + +func displayName(u *tg.User) string { + if u.Username != "" { + return "@" + u.Username + } + name := strings.TrimSpace(u.FirstName + " " + u.LastName) + if name == "" { + return "(unnamed account)" + } + return name +} diff --git a/go.mod b/go.mod index c73fa64..b7844e5 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,50 @@ module github.com/tegwick/fluid-telegram go 1.25.0 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + github.com/gotd/td v0.161.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/andybalholm/brotli v1.2.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.15 // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-faster/jx v1.2.0 // indirect + github.com/go-faster/xor v1.0.0 // indirect + github.com/go-faster/yaml v0.4.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gotd/ige v0.3.0 // indirect + github.com/gotd/log v0.1.0 // indirect + github.com/gotd/neo v0.1.5 // indirect + github.com/klauspost/compress v1.19.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/ogen-go/ogen v1.23.0 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/yuin/goldmark v1.8.4 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + rsc.io/qr v0.2.0 // indirect +) diff --git a/go.sum b/go.sum index 4bc0337..1c89764 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,116 @@ +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= +github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38= +github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gotd/ige v0.3.0 h1:4f6LEHWsVDLBG0bT9wWG2/9TZb5aWm265G8ZlTXmRRU= +github.com/gotd/ige v0.3.0/go.mod h1:FE9bTaQtvfArizAcZuI4sS6gXaEUBmixdUufVHoCKac= +github.com/gotd/log v0.1.0 h1:4LJUEvafD1xtBwx2QkrlzFnRgbYXTlWqJPDi8BvrLbU= +github.com/gotd/log v0.1.0/go.mod h1:5ilhdu1Ux0QvDY/FF3Ojfw24Ws3SlCtyLwOpXy8KYXs= +github.com/gotd/log/logzap v0.1.1 h1:O6l7d8HUbODe+UMcrM47eXYDwdJ6RNmpQejLjrlcEIQ= +github.com/gotd/log/logzap v0.1.1/go.mod h1:5ObZkITbfhbsBOLzBkzmMk9QxXc0eNQpimau7zRL+Y8= +github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= +github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= +github.com/gotd/td v0.161.0 h1:krbzsb70cakdrqF+MUIo+W7BkQTVhyB1kNS7X/+BLcY= +github.com/gotd/td v0.161.0/go.mod h1:7HdCs+zeJugdgZAF5iG8f70eOJvuiH2QzjoyUcysXbY= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/ogen-go/ogen v1.23.0 h1:QaWeKm2KZ2zy7NkqqO1Vdl5idNqlG+svxdgwVAX+zbo= +github.com/ogen-go/ogen v1.23.0/go.mod h1:bwwvC3AmCV+LrL5lazyQwwof90402mdcSyI0FOzzpfM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= +github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/apply/apply.go b/internal/apply/apply.go new file mode 100644 index 0000000..0f72127 --- /dev/null +++ b/internal/apply/apply.go @@ -0,0 +1,220 @@ +// Package apply executes an approved plan. +// +// The ordering is the guarantee: the bot exists before a channel needs an +// administrator, the test channel exists before the public one, and the token is +// in OpenBao before anything else can fail. Each step records what it did before +// the next runs, so an interruption leaves state that describes reality rather +// than intent. +package apply + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "time" + + "github.com/gotd/td/tg" + + "github.com/tegwick/fluid-telegram/internal/plan" + "github.com/tegwick/fluid-telegram/internal/secrets" + "github.com/tegwick/fluid-telegram/internal/spec" + "github.com/tegwick/fluid-telegram/internal/state" + tgc "github.com/tegwick/fluid-telegram/internal/tg" +) + +type Options struct { + Spec *spec.Presence + SpecDigest string + State *state.Resolved + StatePath string + Store *secrets.Store + Client *tgc.Client + Out io.Writer +} + +// Run applies the plan. It saves state after every step that changed the world, +// because a channel that exists but is not recorded is worse than one that does +// not exist: the next run creates a second. +func Run(ctx context.Context, p *plan.Plan, o Options) error { + if p.Blocked() { + return fmt.Errorf("refusing to apply a blocked plan") + } + if o.State.Channels == nil { + o.State.Channels = map[string]state.Channel{} + } + o.State.Campaign = o.Spec.Campaign + + if err := ensureSalt(ctx, o); err != nil { + return err + } + botUser, err := ensureBot(ctx, o) + if err != nil { + return err + } + if err := ensureChannels(ctx, o, botUser); err != nil { + return err + } + + o.State.SpecDigest = o.SpecDigest + o.State.ProvisionedAt = time.Now().UTC() + return state.Save(o.StatePath, o.State) +} + +// ensureSalt generates the redaction salt once, and can never replace one. +// +// docs/observation.md: rotating it silently invalidates every longitudinal +// comparison the interface has made, and does so with no visible failure -- +// the numbers keep arriving and quietly stop meaning what they used to. +func ensureSalt(ctx context.Context, o Options) error { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return fmt.Errorf("generate redaction salt: %w", err) + } + created, err := o.Store.CreateIfAbsent(ctx, secrets.KeyRedactionSalt, + map[string]string{"salt": hex.EncodeToString(buf)}) + if err != nil { + return err + } + if created { + fmt.Fprintf(o.Out, " created redaction salt at %s (never rotated)\n", + o.Store.Ref(secrets.KeyRedactionSalt)) + } + return nil +} + +func ensureBot(ctx context.Context, o Options) (tg.InputUserClass, error) { + if o.State.Bot.ID != 0 { + fmt.Fprintf(o.Out, " ok bot @%s\n", o.State.Bot.Username) + return resolveBot(ctx, o, o.State.Bot.Username) + } + + conv, err := o.Client.BotFather(ctx) + if err != nil { + return nil, err + } + + var username, token string + for _, candidate := range o.Spec.Bot.UsernamePreference { + username, token, err = conv.RegisterBot(ctx, o.Spec.Bot.Name, candidate) + if err == nil { + break + } + if errors.Is(err, tgc.ErrUsernameTaken) { + fmt.Fprintf(o.Out, " taken @%s, trying the next candidate\n", candidate) + continue + } + return nil, err + } + if token == "" { + return nil, fmt.Errorf("every username candidate was taken; add another to "+ + "bot.username_preference in the spec (tried %d)", len(o.Spec.Bot.UsernamePreference)) + } + + // Straight to OpenBao, before anything else can fail. The token is not + // printed, not logged, and not returned past this point. + if err := o.Store.Put(ctx, secrets.KeyBotToken, map[string]string{"token": token}); err != nil { + return nil, fmt.Errorf("the bot @%s was created but its token could not be stored: %w\n"+ + "recover it from the @BotFather chat and write it to %s by hand; do not "+ + "re-run, which would create a second bot", username, err, + o.Store.Ref(secrets.KeyBotToken)) + } + token = "" + + fmt.Fprintf(o.Out, " created bot @%s, token at %s\n", username, + o.Store.Ref(secrets.KeyBotToken)) + + o.State.Bot.Username = username + if err := state.Save(o.StatePath, o.State); err != nil { + return nil, err + } + + if err := conv.SetProfile(ctx, username, o.Spec.Bot.About, o.Spec.Bot.Description); err != nil { + return nil, err + } + fmt.Fprintln(o.Out, " set bot about text and description") + + user, err := resolveBot(ctx, o, username) + if err != nil { + return nil, err + } + return user, state.Save(o.StatePath, o.State) +} + +func resolveBot(ctx context.Context, o Options, username string) (tg.InputUserClass, error) { + res, err := o.Client.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{ + Username: username, + }) + if err != nil { + return nil, fmt.Errorf("resolve @%s: %w", username, err) + } + for _, u := range res.Users { + if user, ok := u.(*tg.User); ok { + o.State.Bot.ID = user.ID + return &tg.InputUser{UserID: user.ID, AccessHash: user.AccessHash}, nil + } + } + return nil, fmt.Errorf("@%s did not resolve to a user", username) +} + +func ensureChannels(ctx context.Context, o Options, bot tg.InputUserClass) error { + // Test first, always. Not a convention a caller may reorder. + for _, name := range []string{spec.Test, spec.Live} { + sc, ok := o.Spec.Channels[name] + if !ok { + continue + } + if name == spec.Live && !o.State.TestChannelVerified(spec.Test) { + fmt.Fprintln(o.Out, " held public channel, until the test channel has a checked rendering") + continue + } + if _, done := o.State.Channels[name]; done { + fmt.Fprintf(o.Out, " ok channel %s\n", name) + continue + } + + ch, err := o.Client.CreateChannel(ctx, sc.Title, sc.Description) + if err != nil { + return err + } + rec := state.Channel{ChatID: ch.ChatID, AdminRights: []string{spec.PostMessages}} + o.State.Channels[name] = rec + if err := state.Save(o.StatePath, o.State); err != nil { + return err + } + fmt.Fprintf(o.Out, " created channel %s (%d)\n", name, ch.ChatID) + + if sc.Visibility == spec.Public { + claimed := "" + for _, cand := range sc.UsernamePreference { + ok, err := o.Client.SetUsername(ctx, ch, cand) + if err != nil { + return err + } + if ok { + claimed = cand + break + } + fmt.Fprintf(o.Out, " taken @%s, trying the next candidate\n", cand) + } + if claimed == "" { + return fmt.Errorf("channel %s was created but every username candidate was "+ + "taken; claim one by hand or add candidates to the spec, then re-run", name) + } + rec.Username = claimed + o.State.Channels[name] = rec + if err := state.Save(o.StatePath, o.State); err != nil { + return err + } + fmt.Fprintf(o.Out, " claimed @%s\n", claimed) + } + + if err := o.Client.PromoteBot(ctx, ch, bot, o.State.Bot.Username); err != nil { + return err + } + fmt.Fprintf(o.Out, " granted post_messages to @%s on %s\n", o.State.Bot.Username, name) + } + return nil +} diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go new file mode 100644 index 0000000..6e7db43 --- /dev/null +++ b/internal/secrets/secrets.go @@ -0,0 +1,145 @@ +// Package secrets is the provisioner's only route to credentials. +// +// Everything sensitive lives in OpenBao: the operator's MTProto session, the +// app credentials, the bot token, and the redaction salt. Nothing here writes a +// secret to disk, and nothing returns one in an error message -- an error says +// which path failed, never what was at it. +package secrets + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" +) + +// Paths under the interface's subtree. The campaign is part of the path so that +// two campaigns on one interface cannot read each other's credentials. +const ( + KeyOperatorApp = "operator-app" // api_id, api_hash + KeyOperatorSession = "operator-session" // MTProto session; a full-account credential + KeyBotToken = "bot-token" + KeyRedactionSalt = "redaction-salt" +) + +type Store struct { + addr string + token string + mount string + prefix string + hc *http.Client +} + +// NewFromEnv builds a store from the ambient OpenBao configuration. +func NewFromEnv(campaign string) (*Store, error) { + addr := firstNonEmpty(os.Getenv("BAO_ADDR"), os.Getenv("VAULT_ADDR")) + token := firstNonEmpty(os.Getenv("BAO_TOKEN"), os.Getenv("VAULT_TOKEN")) + if addr == "" || token == "" { + return nil, fmt.Errorf("OpenBao is not configured: set BAO_ADDR and BAO_TOKEN " + + "(see docs/seeding-runbook.md)") + } + mount := firstNonEmpty(os.Getenv("BAO_MOUNT"), "secret") + return &Store{ + addr: strings.TrimSuffix(addr, "/"), + token: token, + mount: mount, + prefix: "fluid-telegram/" + campaign + "/telegram", + hc: &http.Client{Timeout: 20 * time.Second}, + }, nil +} + +func (s *Store) path(key string) string { + return fmt.Sprintf("%s/v1/%s/data/%s/%s", s.addr, s.mount, s.prefix, key) +} + +// Ref is the human-readable location of a secret, safe to print. Used in plans +// and error messages so an operator can find what is missing. +func (s *Store) Ref(key string) string { + return fmt.Sprintf("bao:%s/%s/%s", s.mount, s.prefix, key) +} + +type kvPayload struct { + Data struct { + Data map[string]string `json:"data"` + } `json:"data"` +} + +// Get returns the fields at a path. Missing is reported as (nil, false, nil): +// absence is an ordinary state on a first run, not a failure. +func (s *Store) Get(ctx context.Context, key string) (map[string]string, bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.path(key), nil) + if err != nil { + return nil, false, err + } + req.Header.Set("X-Vault-Token", s.token) + resp, err := s.hc.Do(req) + if err != nil { + return nil, false, fmt.Errorf("read %s: %w", s.Ref(key), err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusNotFound: + return nil, false, nil + case http.StatusOK: + default: + return nil, false, fmt.Errorf("read %s: unexpected status %s", s.Ref(key), resp.Status) + } + var p kvPayload + if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { + return nil, false, fmt.Errorf("read %s: %w", s.Ref(key), err) + } + return p.Data.Data, true, nil +} + +// Put replaces the fields at a path. +func (s *Store) Put(ctx context.Context, key string, fields map[string]string) error { + body, err := json.Marshal(map[string]any{"data": fields}) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.path(key), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("X-Vault-Token", s.token) + req.Header.Set("Content-Type", "application/json") + resp, err := s.hc.Do(req) + if err != nil { + return fmt.Errorf("write %s: %w", s.Ref(key), err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("write %s: unexpected status %s", s.Ref(key), resp.Status) + } + return nil +} + +// CreateIfAbsent writes fields only when nothing is there, and reports whether +// it wrote. It has no counterpart that overwrites, and that is deliberate: the +// redaction salt is stored this way, and rotating it silently invalidates every +// longitudinal comparison the interface has made, with no visible failure. A +// tool that can rewrite it is a tool that eventually will. +func (s *Store) CreateIfAbsent(ctx context.Context, key string, fields map[string]string) (bool, error) { + _, found, err := s.Get(ctx, key) + if err != nil { + return false, err + } + if found { + return false, nil + } + return true, s.Put(ctx, key, fields) +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/secrets/secrets_test.go b/internal/secrets/secrets_test.go new file mode 100644 index 0000000..5ad81cc --- /dev/null +++ b/internal/secrets/secrets_test.go @@ -0,0 +1,116 @@ +package secrets + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testStore(t *testing.T, h http.Handler) *Store { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + t.Setenv("BAO_ADDR", srv.URL) + t.Setenv("BAO_TOKEN", "test-token") + s, err := NewFromEnv("hall-of-helix") + if err != nil { + t.Fatal(err) + } + return s +} + +// The salt rule. Rotating it silently invalidates every longitudinal comparison +// the interface has made, with no visible failure -- so the tool must have no +// path that replaces an existing one. +func TestCreateIfAbsentNeverOverwrites(t *testing.T) { + var writes int + existing := map[string]string{"salt": "the-original"} + s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + writes++ + w.WriteHeader(http.StatusOK) + return + } + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"data": existing}}) + })) + + created, err := s.CreateIfAbsent(context.Background(), KeyRedactionSalt, + map[string]string{"salt": "a-replacement"}) + if err != nil { + t.Fatal(err) + } + if created { + t.Error("reported creating a salt that already existed") + } + if writes != 0 { + t.Errorf("wrote over an existing salt (%d writes)", writes) + } +} + +func TestCreateIfAbsentWritesWhenMissing(t *testing.T) { + var got map[string]any + s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + json.NewDecoder(r.Body).Decode(&got) + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + + created, err := s.CreateIfAbsent(context.Background(), KeyRedactionSalt, + map[string]string{"salt": "fresh"}) + if err != nil { + t.Fatal(err) + } + if !created { + t.Fatal("did not create a salt when none existed") + } + if got["data"].(map[string]any)["salt"] != "fresh" { + t.Errorf("wrote %v", got) + } +} + +// A missing path is an ordinary first-run state, not an error. +func TestGetMissingIsNotAnError(t *testing.T) { + s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + _, found, err := s.Get(context.Background(), KeyBotToken) + if err != nil || found { + t.Fatalf("found=%v err=%v", found, err) + } +} + +// Ref is printed in plans and errors, so it must name a location and never +// carry a value. +func TestRefIsSafeToPrint(t *testing.T) { + s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"data": map[string]string{"token": "123:SECRET"}}}) + })) + ref := s.Ref(KeyBotToken) + if strings.Contains(ref, "SECRET") || strings.Contains(ref, "test-token") { + t.Fatalf("Ref leaked a secret: %q", ref) + } + if !strings.Contains(ref, "hall-of-helix") || !strings.Contains(ref, KeyBotToken) { + t.Errorf("Ref should locate the secret: %q", ref) + } +} + +// Errors name the path that failed, never what was at it. +func TestErrorsDoNotCarryValues(t *testing.T) { + s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + _, _, err := s.Get(context.Background(), KeyOperatorSession) + if err == nil { + t.Fatal("expected an error") + } + if strings.Contains(err.Error(), "test-token") { + t.Fatalf("error leaked the bao token: %v", err) + } +} diff --git a/internal/secrets/session.go b/internal/secrets/session.go new file mode 100644 index 0000000..0cba212 --- /dev/null +++ b/internal/secrets/session.go @@ -0,0 +1,50 @@ +package secrets + +import ( + "context" + "encoding/base64" + "fmt" +) + +// SessionStorage keeps the MTProto session in OpenBao and never on disk. +// +// gotd's own FileStorage would put a full-account credential in the working +// directory, where it outlives the run, gets committed by accident, and is +// readable by anything on the box. The session can do everything the operator +// account can do, so it is held exactly where the bot token is. +type SessionStorage struct { + Store *Store +} + +const sessionField = "session_b64" + +func (s SessionStorage) LoadSession(ctx context.Context) ([]byte, error) { + fields, found, err := s.Store.Get(ctx, KeyOperatorSession) + if err != nil { + return nil, err + } + if !found || fields[sessionField] == "" { + // gotd treats a nil session as "not authenticated yet", which is the + // correct reading of an empty path. + return nil, nil + } + raw, err := base64.StdEncoding.DecodeString(fields[sessionField]) + if err != nil { + return nil, fmt.Errorf("stored session at %s is not decodable; re-run "+ + "`provision session bootstrap`", s.Store.Ref(KeyOperatorSession)) + } + return raw, nil +} + +func (s SessionStorage) StoreSession(ctx context.Context, data []byte) error { + return s.Store.Put(ctx, KeyOperatorSession, map[string]string{ + sessionField: base64.StdEncoding.EncodeToString(data), + }) +} + +// AppCredentials are issued by my.telegram.org and cannot be provisioned; see +// docs/seeding-runbook.md step 2. +type AppCredentials struct { + AppID int + AppHash string +} diff --git a/internal/tg/botfather.go b/internal/tg/botfather.go new file mode 100644 index 0000000..73a2568 --- /dev/null +++ b/internal/tg/botfather.go @@ -0,0 +1,207 @@ +package tg + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "github.com/gotd/td/tg" +) + +// BotFather is Telegram's own bot for registering bots. There is no API for +// this: a bot is created by holding a conversation, which is why provisioning +// needs a user session at all. +const botFatherUsername = "BotFather" + +// Conversation drives that exchange. It is deliberately literal -- send a +// message, wait for the reply, read it -- because BotFather is a chat partner +// and not an endpoint, and pretending otherwise hides where it can surprise us. +type Conversation struct { + c *Client + peer tg.InputPeerClass + last int // highest message id already seen, so a reply is never confused with an echo +} + +func (c *Client) BotFather(ctx context.Context) (*Conversation, error) { + resolved, err := c.api.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{ + Username: botFatherUsername, + }) + if err != nil { + return nil, fmt.Errorf("resolve @%s: %w", botFatherUsername, err) + } + if len(resolved.Users) == 0 { + return nil, fmt.Errorf("@%s did not resolve to a user", botFatherUsername) + } + u, ok := resolved.Users[0].(*tg.User) + if !ok { + return nil, fmt.Errorf("@%s resolved to %T", botFatherUsername, resolved.Users[0]) + } + conv := &Conversation{ + c: c, + peer: &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash}, + } + // Anchor on the current end of the conversation so that a reply is always + // newer than the request that caused it. + if id, err := conv.latestID(ctx); err == nil { + conv.last = id + } + return conv, nil +} + +// Ask sends text and returns BotFather's next reply. +func (conv *Conversation) Ask(ctx context.Context, text string) (string, error) { + pause() + randID, err := conv.c.client.RandInt64() + if err != nil { + return "", err + } + if err := conv.c.client.SendMessage(ctx, &tg.MessagesSendMessageRequest{ + Peer: conv.peer, + Message: text, + RandomID: randID, + }); err != nil { + return "", fmt.Errorf("send %q to @%s: %w", firstLine(text), botFatherUsername, err) + } + return conv.awaitReply(ctx) +} + +// awaitReply polls for the next incoming message. Polling rather than an update +// handler keeps provisioning a straight line: the tool is doing one thing, and a +// missed update would strand it rather than fail it. +func (conv *Conversation) awaitReply(ctx context.Context) (string, error) { + deadline := time.Now().Add(45 * time.Second) + for time.Now().Before(deadline) { + msgs, err := conv.history(ctx) + if err != nil { + return "", err + } + for _, m := range msgs { + msg, ok := m.(*tg.Message) + if !ok || msg.Out || msg.ID <= conv.last { + continue // our own message, or one we have already read + } + conv.last = msg.ID + return msg.Message, nil + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(1500 * time.Millisecond): + } + } + return "", fmt.Errorf("@%s did not reply within 45s; it may be rate-limiting this "+ + "account, or the conversation may be in a state this tool does not expect -- "+ + "open the chat and look", botFatherUsername) +} + +func (conv *Conversation) history(ctx context.Context) ([]tg.MessageClass, error) { + res, err := conv.c.api.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{ + Peer: conv.peer, + Limit: 5, + }) + if err != nil { + return nil, fmt.Errorf("read @%s history: %w", botFatherUsername, err) + } + switch v := res.(type) { + case *tg.MessagesMessages: + return v.Messages, nil + case *tg.MessagesMessagesSlice: + return v.Messages, nil + case *tg.MessagesChannelMessages: + return v.Messages, nil + } + return nil, fmt.Errorf("unexpected history type %T", res) +} + +func (conv *Conversation) latestID(ctx context.Context) (int, error) { + msgs, err := conv.history(ctx) + if err != nil { + return 0, err + } + best := 0 + for _, m := range msgs { + if msg, ok := m.(*tg.Message); ok && msg.ID > best { + best = msg.ID + } + } + return best, nil +} + +// tokenRe matches a bot token in BotFather's confirmation. The token is +// extracted, written straight to OpenBao, and never logged -- so this is the one +// place it exists in memory, and callers must not print what it returns. +var tokenRe = regexp.MustCompile(`\b(\d{6,}:[A-Za-z0-9_-]{30,})\b`) + +// ErrUsernameTaken means the caller should try its next candidate. +var ErrUsernameTaken = fmt.Errorf("username is taken") + +// RegisterBot runs /newbot and returns the issued username and token. +func (conv *Conversation) RegisterBot(ctx context.Context, displayName, username string) (string, string, error) { + reply, err := conv.Ask(ctx, "/newbot") + if err != nil { + return "", "", err + } + if !strings.Contains(strings.ToLower(reply), "name") { + return "", "", fmt.Errorf("unexpected reply to /newbot: %q", firstLine(reply)) + } + if _, err := conv.Ask(ctx, displayName); err != nil { + return "", "", err + } + reply, err = conv.Ask(ctx, username) + if err != nil { + return "", "", err + } + + low := strings.ToLower(reply) + if strings.Contains(low, "already taken") || strings.Contains(low, "invalid") || + strings.Contains(low, "sorry") { + return "", "", fmt.Errorf("%w: @%s (%s)", ErrUsernameTaken, username, firstLine(reply)) + } + m := tokenRe.FindStringSubmatch(reply) + if m == nil { + return "", "", fmt.Errorf("@%s accepted @%s but no token was found in its reply; "+ + "open the chat and check before retrying, or a second bot will be created", + botFatherUsername, username) + } + return username, m[1], nil +} + +// SetProfile applies the spec's editorial fields. Each is idempotent, so it is +// safe to reassert on every apply. +func (conv *Conversation) SetProfile(ctx context.Context, username, about, description string) error { + steps := []struct{ cmd, arg, label string }{ + {"/setabouttext", about, "about text"}, + {"/setdescription", description, "description"}, + } + for _, s := range steps { + if s.arg == "" { + continue + } + if _, err := conv.Ask(ctx, s.cmd); err != nil { + return err + } + if _, err := conv.Ask(ctx, "@"+username); err != nil { + return err + } + reply, err := conv.Ask(ctx, s.arg) + if err != nil { + return err + } + if !strings.Contains(strings.ToLower(reply), "success") { + return fmt.Errorf("setting %s did not succeed: %q", s.label, firstLine(reply)) + } + } + return nil +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[:i] + } + if len(s) > 90 { + s = s[:90] + "..." + } + return s +} diff --git a/internal/tg/botfather_test.go b/internal/tg/botfather_test.go new file mode 100644 index 0000000..133d61c --- /dev/null +++ b/internal/tg/botfather_test.go @@ -0,0 +1,92 @@ +package tg + +import ( + "testing" + + "github.com/gotd/td/tg" + + "github.com/tegwick/fluid-telegram/internal/spec" +) + +// The token is the one secret that passes through this process's memory. If the +// pattern stops matching, RegisterBot reports "no token found" after a bot has +// already been created -- which is the expensive failure, since retrying makes a +// second bot. +func TestTokenPattern(t *testing.T) { + replies := []struct { + name string + text string + want string + }{ + {"typical", "Done! Congratulations on your new bot.\n\nUse this token to access the HTTP API:\n123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw\n\nKeep your token secure.", "123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw"}, + {"long id", "Token: 7123456789:AAF-abcDEFghiJKLmnoPQRstuVWXyz012345", "7123456789:AAF-abcDEFghiJKLmnoPQRstuVWXyz012345"}, + {"underscores and dashes", "8000000001:AA_this-has_all-the_chars012345678", "8000000001:AA_this-has_all-the_chars012345678"}, + } + for _, r := range replies { + t.Run(r.name, func(t *testing.T) { + m := tokenRe.FindStringSubmatch(r.text) + if m == nil { + t.Fatalf("no token matched in %q", r.text) + } + if m[1] != r.want { + t.Fatalf("got %q, want %q", m[1], r.want) + } + }) + } +} + +// It must not match things that merely look like tokens, or a failure reply +// could be mistaken for success and a bogus token written to OpenBao. +func TestTokenPatternRejects(t *testing.T) { + for _, s := range []string{ + "Sorry, this username is already taken.", + "12:34", + "Please choose a name for your bot.", + } { + if tokenRe.MatchString(s) { + t.Errorf("matched a token in %q", s) + } + } +} + +func TestFirstLineTruncates(t *testing.T) { + if got := firstLine("one\ntwo"); got != "one" { + t.Errorf("got %q", got) + } + long := make([]byte, 200) + for i := range long { + long[i] = 'x' + } + if got := firstLine(string(long)); len(got) > 95 { + t.Errorf("length %d, expected truncation", len(got)) + } +} + +// Rights reported back must use the spec's vocabulary, so a plan can compare +// them to the spec without translating between two naming schemes -- and must +// report a wider right rather than dropping it, since dropping one would make +// drift invisible exactly where it matters. +func TestRightsNames(t *testing.T) { + got := rightsNames(tg.ChatAdminRights{PostMessages: true}) + if len(got) != 1 || got[0] != spec.PostMessages { + t.Fatalf("got %v, want [%s]", got, spec.PostMessages) + } + + got = rightsNames(tg.ChatAdminRights{PostMessages: true, DeleteMessages: true}) + if len(got) != 2 { + t.Fatalf("a wider right must be reported, got %v", got) + } + var sawDelete bool + for _, r := range got { + if r == "delete_messages" { + sawDelete = true + } + } + if !sawDelete { + t.Errorf("delete_messages was dropped: %v", got) + } + + if got := rightsNames(tg.ChatAdminRights{}); len(got) != 0 { + t.Errorf("a bot with no rights should report none, got %v", got) + } +} diff --git a/internal/tg/channels.go b/internal/tg/channels.go new file mode 100644 index 0000000..0feffc0 --- /dev/null +++ b/internal/tg/channels.go @@ -0,0 +1,178 @@ +package tg + +import ( + "context" + "fmt" + "strings" + + "github.com/gotd/td/tg" + + "github.com/tegwick/fluid-telegram/internal/spec" +) + +// Channel is what provisioning needs to remember about a created channel. +type Channel struct { + ChatID int64 + AccessHash int64 + Username string +} + +// CreateChannel creates a broadcast channel. There is deliberately no +// counterpart that deletes one: deleting destroys its subscribers and post +// history irreversibly, and no specification is trusted with that. +func (c *Client) CreateChannel(ctx context.Context, title, about string) (Channel, error) { + pause() + upd, err := c.api.ChannelsCreateChannel(ctx, &tg.ChannelsCreateChannelRequest{ + Broadcast: true, // a channel, not a supergroup + Title: title, + About: about, + }) + if err != nil { + return Channel{}, fmt.Errorf("create channel %q: %w", title, err) + } + ch, err := firstChannel(upd) + if err != nil { + return Channel{}, fmt.Errorf("create channel %q: %w", title, err) + } + return Channel{ChatID: ch.ID, AccessHash: ch.AccessHash, Username: ch.Username}, nil +} + +// SetUsername claims a public username, returning false if it is taken. A +// collision is an ordinary outcome the caller walks its candidates through, not +// an error: a plan promises the attempt, never the result. +func (c *Client) SetUsername(ctx context.Context, ch Channel, username string) (bool, error) { + pause() + ok, err := c.api.ChannelsUpdateUsername(ctx, &tg.ChannelsUpdateUsernameRequest{ + Channel: &tg.InputChannel{ChannelID: ch.ChatID, AccessHash: ch.AccessHash}, + Username: username, + }) + if err != nil { + if isUsernameUnavailable(err) { + return false, nil + } + return false, fmt.Errorf("claim username @%s: %w", username, err) + } + return ok, nil +} + +// PromoteBot grants the bot post_messages on a channel, and nothing else. +// +// The rights are built here rather than taken from the spec. The spec may state +// them for a reader's benefit, but which rights this system may hold is not the +// spec's decision -- InterfaceEvolutionIntent.md 7 forbids a wider one, and a +// right that is never exercised is still a right that was granted. +func (c *Client) PromoteBot(ctx context.Context, ch Channel, bot tg.InputUserClass, botUsername string) error { + pause() + _, err := c.api.ChannelsEditAdmin(ctx, &tg.ChannelsEditAdminRequest{ + Channel: &tg.InputChannel{ChannelID: ch.ChatID, AccessHash: ch.AccessHash}, + UserID: bot, + AdminRights: tg.ChatAdminRights{PostMessages: true}, + Rank: "", + }) + if err != nil { + return fmt.Errorf("grant post_messages to @%s: %w", botUsername, err) + } + return nil +} + +// AdminRights reports the rights the given bot actually holds, in the +// vocabulary the spec uses, so drift can be compared without translating. +func (c *Client) AdminRights(ctx context.Context, ch Channel, botID int64) ([]string, error) { + parts, err := c.api.ChannelsGetParticipant(ctx, &tg.ChannelsGetParticipantRequest{ + Channel: &tg.InputChannel{ChannelID: ch.ChatID, AccessHash: ch.AccessHash}, + Participant: &tg.InputPeerUser{UserID: botID}, + }) + if err != nil { + return nil, fmt.Errorf("read participant rights: %w", err) + } + admin, ok := parts.Participant.(*tg.ChannelParticipantAdmin) + if !ok { + // Not an administrator at all. An empty set, not an error: a demoted bot + // is exactly the drift the plan is looking for. + return nil, nil + } + return rightsNames(admin.AdminRights), nil +} + +// rightsNames lists granted rights using the spec's names. Only post_messages +// has a spec name; anything else is reported raw so a plan can show what was +// actually found rather than silently dropping it. +func rightsNames(r tg.ChatAdminRights) []string { + var out []string + if r.PostMessages { + out = append(out, spec.PostMessages) + } + for name, granted := range map[string]bool{ + "change_info": r.ChangeInfo, "edit_messages": r.EditMessages, + "delete_messages": r.DeleteMessages, "ban_users": r.BanUsers, + "invite_users": r.InviteUsers, "pin_messages": r.PinMessages, + "add_admins": r.AddAdmins, "manage_call": r.ManageCall, + "post_stories": r.PostStories, "edit_stories": r.EditStories, + "delete_stories": r.DeleteStories, "manage_topics": r.ManageTopics, + } { + if granted { + out = append(out, name) + } + } + return out +} + +func firstChannel(upd tg.UpdatesClass) (*tg.Channel, error) { + u, ok := upd.(*tg.Updates) + if !ok { + return nil, fmt.Errorf("unexpected updates type %T", upd) + } + for _, chat := range u.Chats { + if ch, ok := chat.(*tg.Channel); ok { + return ch, nil + } + } + return nil, fmt.Errorf("telegram accepted the request but returned no channel") +} + +func isUsernameUnavailable(err error) bool { + s := strings.ToUpper(err.Error()) + return strings.Contains(s, "USERNAME_OCCUPIED") || + strings.Contains(s, "USERNAME_INVALID") || + strings.Contains(s, "USERNAME_PURCHASE_AVAILABLE") +} + +// ResolveChannel recovers a usable channel handle from a chat id. Access hashes +// are per-account and not persisted, so they are re-resolved on each run rather +// than stored -- a stale hash fails in ways that look like a missing channel. +func (c *Client) ResolveChannel(ctx context.Context, chatID int64) (Channel, error) { + res, err := c.api.ChannelsGetChannels(ctx, []tg.InputChannelClass{ + &tg.InputChannel{ChannelID: chatID}, + }) + if err != nil { + return Channel{}, fmt.Errorf("resolve channel %d: %w", chatID, err) + } + chats, ok := res.(*tg.MessagesChats) + if !ok { + return Channel{}, fmt.Errorf("resolve channel %d: unexpected type %T", chatID, res) + } + for _, ch := range chats.Chats { + if c2, ok := ch.(*tg.Channel); ok && c2.ID == chatID { + return Channel{ChatID: c2.ID, AccessHash: c2.AccessHash, Username: c2.Username}, nil + } + } + return Channel{}, fmt.Errorf("channel %d was not returned by telegram", chatID) +} + +// ChannelUsername reports the username a channel currently carries. +func (c *Client) ChannelUsername(ctx context.Context, ch Channel) (string, error) { + fresh, err := c.ResolveChannel(ctx, ch.ChatID) + if err != nil { + return "", err + } + return fresh.Username, nil +} + +func resolveReq(username string) *tg.ContactsResolveUsernameRequest { + return &tg.ContactsResolveUsernameRequest{Username: username} +} + +func isNotFound(err error) bool { + s := strings.ToUpper(err.Error()) + return strings.Contains(s, "USERNAME_NOT_OCCUPIED") || strings.Contains(s, "USERNAME_INVALID") +} diff --git a/internal/tg/client.go b/internal/tg/client.go new file mode 100644 index 0000000..eee6c9d --- /dev/null +++ b/internal/tg/client.go @@ -0,0 +1,123 @@ +// Package tg wraps the MTProto operations the provisioner needs. +// +// The Bot API cannot create a bot or a channel: both are client capabilities, +// reachable only through MTProto with a user account (Canon INT-03). So this +// package acts as the designated operator account -- messaging BotFather the way +// a person would, and calling channels.* directly. +// +// It is used by the provisioning plane only. The adapter never imports it and +// never holds a session. +package tg + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/auth" + "github.com/gotd/td/tg" + + "github.com/tegwick/fluid-telegram/internal/secrets" +) + +type Client struct { + client *telegram.Client + api *tg.Client + store *secrets.Store +} + +// Authenticator supplies what only a person can: the login code Telegram sends +// out of band, and the 2FA password. Interactive during bootstrap; on every +// later run the stored session means neither is asked for. +type Authenticator interface { + Phone(ctx context.Context) (string, error) + Code(ctx context.Context, sentCode *tg.AuthSentCode) (string, error) + Password(ctx context.Context) (string, error) +} + +// New builds a client backed by the session in OpenBao. +func New(store *secrets.Store, creds secrets.AppCredentials) *Client { + c := telegram.NewClient(creds.AppID, creds.AppHash, telegram.Options{ + SessionStorage: secrets.SessionStorage{Store: store}, + }) + return &Client{client: c, store: store} +} + +// LoadCredentials reads api_id/api_hash. They are issued by a web form and +// cannot be provisioned, so a missing pair is a runbook step, not a bug. +func LoadCredentials(ctx context.Context, store *secrets.Store) (secrets.AppCredentials, error) { + fields, found, err := store.Get(ctx, secrets.KeyOperatorApp) + if err != nil { + return secrets.AppCredentials{}, err + } + if !found { + return secrets.AppCredentials{}, fmt.Errorf( + "no app credentials at %s -- complete step 2 of docs/seeding-runbook.md", + store.Ref(secrets.KeyOperatorApp)) + } + id, err := strconv.Atoi(fields["api_id"]) + if err != nil { + return secrets.AppCredentials{}, fmt.Errorf("api_id at %s is not a number", + store.Ref(secrets.KeyOperatorApp)) + } + hash := fields["api_hash"] + if hash == "" { + return secrets.AppCredentials{}, fmt.Errorf("api_hash is missing at %s", + store.Ref(secrets.KeyOperatorApp)) + } + return secrets.AppCredentials{AppID: id, AppHash: hash}, nil +} + +// Run connects and executes f. Authentication happens only if the stored session +// is absent or no longer valid. +func (c *Client) Run(ctx context.Context, a Authenticator, f func(context.Context, *Client) error) error { + return c.client.Run(ctx, func(ctx context.Context) error { + c.api = c.client.API() + if a != nil { + if err := c.client.Auth().IfNecessary(ctx, auth.NewFlow( + authAdapter{a}, auth.SendCodeOptions{}, + )); err != nil { + return fmt.Errorf("authenticate operator account: %w", err) + } + } else if _, err := c.client.Self(ctx); err != nil { + return fmt.Errorf("the stored operator session is not usable; re-run "+ + "`provision session bootstrap` (%w)", err) + } + return f(ctx, c) + }) +} + +// Self returns the account the session belongs to, for `session check`. +func (c *Client) Self(ctx context.Context) (*tg.User, error) { return c.client.Self(ctx) } + +// API exposes the raw client for operations this package does not wrap. +func (c *Client) API() *tg.Client { return c.api } + +// authAdapter bridges our Authenticator to gotd's flow. SignUp is refused: +// the operator account is registered by a person on a device, and a tool that +// can create accounts is a tool that can create them by accident. +type authAdapter struct{ a Authenticator } + +func (x authAdapter) Phone(ctx context.Context) (string, error) { return x.a.Phone(ctx) } +func (x authAdapter) Password(ctx context.Context) (string, error) { + return x.a.Password(ctx) +} +func (x authAdapter) Code(ctx context.Context, sentCode *tg.AuthSentCode) (string, error) { + return x.a.Code(ctx, sentCode) +} +func (x authAdapter) AcceptTermsOfService(ctx context.Context, tos tg.HelpTermsOfService) error { + return fmt.Errorf("this account has not accepted Telegram's terms of service; " + + "sign in on a device once and accept them there") +} +func (x authAdapter) SignUp(ctx context.Context) (auth.UserInfo, error) { + return auth.UserInfo{}, fmt.Errorf( + "this phone number has no Telegram account; register it on a device first " + + "(docs/seeding-runbook.md step 1). This tool does not create accounts") +} + +// pause keeps BotFather conversations at human pace. Automating a user account +// is not what Telegram's terms are written around, and a burst of requests is +// what draws a limit. +func pause() { time.Sleep(1200 * time.Millisecond) } diff --git a/internal/tg/live.go b/internal/tg/live.go new file mode 100644 index 0000000..cf87b16 --- /dev/null +++ b/internal/tg/live.go @@ -0,0 +1,50 @@ +package tg + +import ( + "context" + + "github.com/tegwick/fluid-telegram/internal/state" +) + +// Live implements plan.Live over a connected client, so that a plan is computed +// against what is actually there rather than against the state file's memory of +// it. Everything it reports is observed; nothing is assumed. +type Live struct { + Ctx context.Context + Client *Client + Resolved *state.Resolved +} + +func (l Live) BotExists(username string) (bool, error) { + if username == "" { + return false, nil + } + _, err := l.Client.API().ContactsResolveUsername(l.Ctx, resolveReq(username)) + if err != nil { + if isNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} + +func (l Live) ChannelAdminRights(chatID int64) ([]string, error) { + ch, err := l.channel(chatID) + if err != nil { + return nil, err + } + return l.Client.AdminRights(l.Ctx, ch, l.Resolved.Bot.ID) +} + +func (l Live) ChannelUsername(chatID int64) (string, error) { + ch, err := l.channel(chatID) + if err != nil { + return "", err + } + return l.Client.ChannelUsername(l.Ctx, ch) +} + +func (l Live) channel(chatID int64) (Channel, error) { + return l.Client.ResolveChannel(l.Ctx, chatID) +} diff --git a/workplans/FT-WP-0002-declared-presence-provisioning.md b/workplans/FT-WP-0002-declared-presence-provisioning.md index 5e538e6..6800654 100644 --- a/workplans/FT-WP-0002-declared-presence-provisioning.md +++ b/workplans/FT-WP-0002-declared-presence-provisioning.md @@ -36,7 +36,7 @@ one bounded bootstrap, after which the surface is declared and converges. ```task id: FT-WP-0002-T01 -status: todo +status: progress priority: high state_hub_task_id: "039e5358-07c1-5b01-a186-5ece9aab75e4" ``` @@ -128,7 +128,7 @@ kinds: a deferral holds one action, a block stops the run. ```task id: FT-WP-0002-T04 -status: todo +status: progress priority: high state_hub_task_id: "5659b72a-8309-58b6-96e9-79137ce41ed4" ``` @@ -157,12 +157,13 @@ test channel has recorded a successful publication. ```task id: FT-WP-0002-T05 -status: todo +status: done priority: high state_hub_task_id: "d5fd905a-6abd-5ae1-9105-abc4c2a7127e" ``` -Generate 32 bytes if and only if the OpenBao path is empty, and never overwrite. +**Implemented.** `secrets.CreateIfAbsent` generates 32 bytes if and only if the +OpenBao path is empty, and the package has no counterpart that overwrites. Supersedes `FT-WP-0001` T03. The create-if-absent rule is not defensiveness. `docs/observation.md` explains @@ -190,7 +191,7 @@ is the point of running two planes rather than one tool with a flag. ```task id: FT-WP-0002-T07 -status: todo +status: progress priority: low state_hub_task_id: "f3f36415-ed55-5717-b752-706b04ba5458" ```