vojo/apps/ai-bot/registration.go

103 lines
3.3 KiB
Go

package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"regexp"
"gopkg.in/yaml.v3"
)
// Registration mirrors a Synapse application-service registration.yaml. Like the
// mautrix bridges, the bot GENERATES this file (random tokens) and then READS its
// own tokens back from it — so the same file is the single source of truth shared
// with Synapse, and tokens are never hand-copied into two places.
type Registration struct {
ID string `yaml:"id"`
URL string `yaml:"url"`
ASToken string `yaml:"as_token"`
HSToken string `yaml:"hs_token"`
SenderLocalpart string `yaml:"sender_localpart"`
RateLimited bool `yaml:"rate_limited"`
Namespaces RegNamespaces `yaml:"namespaces"`
}
type RegNamespaces struct {
Users []RegNamespace `yaml:"users"`
Aliases []RegNamespace `yaml:"aliases"`
Rooms []RegNamespace `yaml:"rooms"`
}
type RegNamespace struct {
Exclusive bool `yaml:"exclusive"`
Regex string `yaml:"regex"`
}
func randToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// LoadRegistration reads and validates a registration.yaml.
func LoadRegistration(path string) (*Registration, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var reg Registration
if err := yaml.Unmarshal(data, &reg); err != nil {
return nil, fmt.Errorf("parse registration %s: %w", path, err)
}
if reg.ASToken == "" || reg.HSToken == "" {
return nil, fmt.Errorf("registration %s missing as_token/hs_token", path)
}
return &reg, nil
}
// GenerateRegistration writes a fresh registration.yaml with random tokens. It
// REFUSES to overwrite an existing file — regenerating would rotate the tokens
// and break the running Synapse binding; delete the file to intentionally regen.
func GenerateRegistration(path, asURL, localpart, serverName string) error {
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("registration already exists at %s (delete it to regenerate — that rotates tokens and breaks the live Synapse binding)", path)
}
asTok, err := randToken()
if err != nil {
return err
}
hsTok, err := randToken()
if err != nil {
return err
}
reg := &Registration{
ID: "ai-bot",
URL: asURL,
ASToken: asTok,
HSToken: hsTok,
SenderLocalpart: localpart,
RateLimited: false,
Namespaces: RegNamespaces{
Users: []RegNamespace{{Exclusive: true, Regex: "@" + regexp.QuoteMeta(localpart+":"+serverName)}},
Aliases: []RegNamespace{},
Rooms: []RegNamespace{},
},
}
body, err := yaml.Marshal(reg)
if err != nil {
return err
}
header := "# Generated by `ai-bot generate-registration`. Mount this file into the\n" +
"# Synapse container and add it to app_service_config_files, then restart\n" +
"# Synapse. The bot reads its tokens from THIS file via REGISTRATION_PATH —\n" +
"# do not hand-copy the tokens elsewhere.\n"
// 0644, not 0600: this file is shared with the Synapse container, which runs
// as a DIFFERENT uid (non-root) and must be able to read it — same as the
// mautrix bridge registrations. (Token secrecy relies on host access control,
// not file mode, on the single-tenant VPS.)
return os.WriteFile(path, append([]byte(header), body...), 0o644)
}