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
2026-09-04 21:39:30 +02:00
|
|
|
// 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"
|
2026-09-04 22:27:01 +02:00
|
|
|
"path/filepath"
|
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
2026-09-04 21:39:30 +02:00
|
|
|
"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"))
|
2026-09-04 22:27:01 +02:00
|
|
|
// Fall back to the token sink the bao and vault CLIs already use, so an
|
|
|
|
|
// operator who has logged in once does not have to re-export a secret --
|
|
|
|
|
// and so a token never has to be typed into a shell that records history.
|
|
|
|
|
token := firstNonEmpty(os.Getenv("BAO_TOKEN"), os.Getenv("VAULT_TOKEN"), tokenFromDisk())
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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
2026-09-04 22:12:56 +02:00
|
|
|
// Name the variable that is actually missing. "set both" sends someone
|
|
|
|
|
// checking the one they already set.
|
|
|
|
|
switch {
|
|
|
|
|
case addr == "" && token == "":
|
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
2026-09-04 21:39:30 +02:00
|
|
|
return nil, fmt.Errorf("OpenBao is not configured: set BAO_ADDR and BAO_TOKEN " +
|
|
|
|
|
"(see docs/seeding-runbook.md)")
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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
2026-09-04 22:12:56 +02:00
|
|
|
case addr == "":
|
|
|
|
|
return nil, fmt.Errorf("BAO_ADDR is not set (BAO_TOKEN is)")
|
|
|
|
|
case token == "":
|
2026-09-04 22:27:01 +02:00
|
|
|
return nil, fmt.Errorf("no OpenBao token: set BAO_TOKEN, or run `bao login` "+
|
|
|
|
|
"to write ~/.vault-token (BAO_ADDR is %s)", addr)
|
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
2026-09-04 21:39:30 +02:00
|
|
|
}
|
2026-09-04 23:03:52 +02:00
|
|
|
// The fleet convention, confirmed with the owner:
|
|
|
|
|
// platform/workloads/<domain>/<workload>/<bundle>, per
|
|
|
|
|
// ops-warden/wiki/CredentialRouting.md and railiance-platform.
|
|
|
|
|
//
|
|
|
|
|
// The campaign sits between the workload and the bundle so that two
|
|
|
|
|
// campaigns on one interface cannot read each other's credentials -- the
|
|
|
|
|
// bundle then names the secret itself (operator-app, bot-token, ...).
|
|
|
|
|
// Both halves stay overridable: the layout is the owner's to change, not
|
|
|
|
|
// this repository's.
|
|
|
|
|
mount := firstNonEmpty(os.Getenv("BAO_MOUNT"), "platform")
|
|
|
|
|
prefix := firstNonEmpty(os.Getenv("FLUID_BAO_PREFIX"),
|
|
|
|
|
"workloads/infotech/fluid-telegram/"+campaign)
|
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
2026-09-04 21:39:30 +02:00
|
|
|
return &Store{
|
|
|
|
|
addr: strings.TrimSuffix(addr, "/"),
|
|
|
|
|
token: token,
|
|
|
|
|
mount: mount,
|
2026-09-04 22:32:09 +02:00
|
|
|
prefix: strings.Trim(prefix, "/"),
|
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
2026-09-04 21:39:30 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 22:27:01 +02:00
|
|
|
// tokenFromDisk reads ~/.vault-token, the file `bao login` writes. Absence is
|
|
|
|
|
// not an error: it is one of several ways a token may be supplied.
|
|
|
|
|
func tokenFromDisk() string {
|
|
|
|
|
home, err := os.UserHomeDir()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
raw, err := os.ReadFile(filepath.Join(home, ".vault-token"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimSpace(string(raw))
|
|
|
|
|
}
|
|
|
|
|
|
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
2026-09-04 21:39:30 +02:00
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
|
|
|
for _, v := range vals {
|
|
|
|
|
if v != "" {
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|