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:
parent
10018a99b6
commit
5ddfee8250
14 changed files with 1544 additions and 57 deletions
|
|
@ -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
120
cmd/provision/session.go
Normal 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
|
||||
}
|
||||
48
go.mod
48
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
|
||||
)
|
||||
|
|
|
|||
113
go.sum
113
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=
|
||||
|
|
|
|||
220
internal/apply/apply.go
Normal file
220
internal/apply/apply.go
Normal file
|
|
@ -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
|
||||
}
|
||||
145
internal/secrets/secrets.go
Normal file
145
internal/secrets/secrets.go
Normal file
|
|
@ -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 ""
|
||||
}
|
||||
116
internal/secrets/secrets_test.go
Normal file
116
internal/secrets/secrets_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
50
internal/secrets/session.go
Normal file
50
internal/secrets/session.go
Normal file
|
|
@ -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
|
||||
}
|
||||
207
internal/tg/botfather.go
Normal file
207
internal/tg/botfather.go
Normal file
|
|
@ -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
|
||||
}
|
||||
92
internal/tg/botfather_test.go
Normal file
92
internal/tg/botfather_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
178
internal/tg/channels.go
Normal file
178
internal/tg/channels.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
123
internal/tg/client.go
Normal file
123
internal/tg/client.go
Normal file
|
|
@ -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) }
|
||||
50
internal/tg/live.go
Normal file
50
internal/tg/live.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue