140 lines
4.6 KiB
Go
140 lines
4.6 KiB
Go
// Package loadharness implements the real-MTProto capacity harness used by
|
|
// cmd/telesrv-load. It deliberately uses the published gotd fork instead of
|
|
// server-internal handlers or direct database fixtures.
|
|
package loadharness
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const ManifestVersion = 1
|
|
|
|
// Endpoint is the immutable wire target shared by provisioning and runs.
|
|
type Endpoint struct {
|
|
Address string `json:"address"`
|
|
DC int `json:"dc"`
|
|
APIID int `json:"api_id"`
|
|
APIHash string `json:"api_hash"`
|
|
RSAKeyPath string `json:"rsa_key_path"`
|
|
Obfuscated bool `json:"obfuscated"`
|
|
PFS bool `json:"pfs"`
|
|
TempKeyTTL int `json:"temp_key_ttl_seconds,omitempty"`
|
|
}
|
|
|
|
// SessionRecord maps one physical MTProto session file to one logical account.
|
|
// It contains routing facts only; auth key material remains in encrypted files.
|
|
type SessionRecord struct {
|
|
Index int `json:"index"`
|
|
AccountIndex int `json:"account_index"`
|
|
DeviceIndex int `json:"device_index"`
|
|
Phone string `json:"phone"`
|
|
FirstName string `json:"first_name"`
|
|
SessionFile string `json:"session_file"`
|
|
UserID int64 `json:"user_id"`
|
|
AccessHash int64 `json:"access_hash"`
|
|
}
|
|
|
|
// Manifest never embeds session encryption keys, auth keys, phone-code hashes
|
|
// or raw server errors. It does contain generated test phone/user routing data,
|
|
// so it remains a controlled run artifact and is not copied into RunReport.
|
|
type Manifest struct {
|
|
Version int `json:"version"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Endpoint Endpoint `json:"endpoint"`
|
|
Sessions []SessionRecord `json:"sessions"`
|
|
}
|
|
|
|
func (e Endpoint) Validate() error {
|
|
if strings.TrimSpace(e.Address) == "" {
|
|
return errors.New("endpoint address is required")
|
|
}
|
|
if e.DC == 0 {
|
|
return errors.New("endpoint DC must be non-zero")
|
|
}
|
|
if e.APIID <= 0 || strings.TrimSpace(e.APIHash) == "" {
|
|
return errors.New("endpoint api_id and api_hash are required")
|
|
}
|
|
if strings.TrimSpace(e.RSAKeyPath) == "" {
|
|
return errors.New("endpoint RSA key path is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manifest) Validate() error {
|
|
if m == nil {
|
|
return errors.New("nil manifest")
|
|
}
|
|
if m.Version != ManifestVersion {
|
|
return fmt.Errorf("manifest version %d, want %d", m.Version, ManifestVersion)
|
|
}
|
|
if err := m.Endpoint.Validate(); err != nil {
|
|
return err
|
|
}
|
|
indices := make(map[int]struct{}, len(m.Sessions))
|
|
files := make(map[string]struct{}, len(m.Sessions))
|
|
for _, session := range m.Sessions {
|
|
if session.Index < 0 || session.AccountIndex < 0 || session.DeviceIndex < 0 {
|
|
return fmt.Errorf("session %d has a negative index", session.Index)
|
|
}
|
|
if _, ok := indices[session.Index]; ok {
|
|
return fmt.Errorf("duplicate session index %d", session.Index)
|
|
}
|
|
indices[session.Index] = struct{}{}
|
|
if strings.TrimSpace(session.Phone) == "" || strings.TrimSpace(session.SessionFile) == "" {
|
|
return fmt.Errorf("session %d is missing phone or session_file", session.Index)
|
|
}
|
|
clean := filepath.Clean(session.SessionFile)
|
|
if filepath.IsAbs(clean) || clean == "." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || clean == ".." {
|
|
return fmt.Errorf("session %d has unsafe session_file %q", session.Index, session.SessionFile)
|
|
}
|
|
if _, ok := files[clean]; ok {
|
|
return fmt.Errorf("duplicate session file %q", clean)
|
|
}
|
|
files[clean] = struct{}{}
|
|
if session.UserID <= 0 {
|
|
return fmt.Errorf("session %d has no provisioned user_id", session.Index)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func LoadManifest(path string) (*Manifest, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read manifest: %w", err)
|
|
}
|
|
var manifest Manifest
|
|
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&manifest); err != nil {
|
|
return nil, fmt.Errorf("decode manifest: %w", err)
|
|
}
|
|
if err := manifest.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(manifest.Sessions, func(i, j int) bool { return manifest.Sessions[i].Index < manifest.Sessions[j].Index })
|
|
return &manifest, nil
|
|
}
|
|
|
|
func WriteManifest(path string, manifest *Manifest) error {
|
|
if err := manifest.Validate(); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode manifest: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
return writeFileAtomic(path, data, 0o600)
|
|
}
|
|
|
|
func resolveSessionPath(manifestPath string, record SessionRecord) string {
|
|
return filepath.Join(filepath.Dir(manifestPath), filepath.FromSlash(record.SessionFile))
|
|
}
|