Implement the MTProto client, session bootstrap and apply

Completes the provisioner's write path. internal/tg drives BotFather as a
conversation rather than pretending it is an endpoint, creates channels,
claims usernames with fallbacks, and grants post_messages. internal/apply
sequences it: bot before administrator, test channel before public, and
state saved after every step that changed the world -- a channel that
exists but is unrecorded is worse than one that does not exist, because
the next run creates a second.

The operator session lives in OpenBao, not on disk. gotd's FileStorage
would leave a full-account credential in the working directory, where it
outlives the run and can be committed by accident.

The bot token goes straight from BotFather's reply to OpenBao and is
cleared from memory; if that write fails the error says how to recover by
hand and warns against re-running, since a retry creates a second bot.

Closes T05: the redaction salt is create-if-absent with no overwrite path,
and the test asserts it, because rotating it invalidates every longitudinal
comparison with no visible failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172sgCZEEDJcnQmr4SGDvKa

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1361245@bnt-lap001
Assistant-Session: b3b428ef-f3e6-4688-b091-01f71461d66a
This commit is contained in:
tegwick 2026-09-04 21:39:30 +02:00
parent 10018a99b6
commit 5ddfee8250
14 changed files with 1544 additions and 57 deletions

View file

@ -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 <path> [--check] show what would change; writes nothing
provision apply --spec <path> execute an approved plan
session bootstrap / session check mint and inspect the operator session
provision session bootstrap --campaign <slug> mint the operator session (interactive)
provision session check --campaign <slug> 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
}

120
cmd/provision/session.go Normal file
View file

@ -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
}