daef7ea190
Helper functions (matrix-mas capability group): - mas_client_register_bash_infra: register/sync OAuth clients via mas-cli - mas_syn2mas_migration_bash_infra: dry-run + apply user migration to MAS - synapse_msc3861_enable_go_infra: edit homeserver.yaml MSC3861 block (with diff) - wellknown_oidc_patch_go_infra: patch well-known JSON with msc2965.authentication - synapse_login_flows_check_go_infra: health-check post-migration login flows Flows + issues for custom Matrix clients (PC + Android): - 0010 matrix-client-pc: Wails + React+Mantine (issues 0147-0153) - 0011 matrix-client-android: Kotlin + Compose (issues 0154-0161) - 0162 enable MAS as auth provider (Synapse delegate) — EXECUTED on VPS - 0163 custom admin panel propio (sustituye synapse-admin) Production state (organic-machine.com): - Synapse migrated SQLite -> Postgres - MSC3861 active, password_config disabled - 21 users + 41 access_tokens migrated via syn2mas - 4 MAS clients registered (element, matrix_pc, matrix_android, admin_panel) - synapse-admin container removed + Coolify route deleted - well-known patched with org.matrix.msc2965.authentication Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
532 lines
15 KiB
Go
532 lines
15 KiB
Go
package infra
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
// SynapseMsc3861Config holds parameters for enabling MSC3861 (MAS) in homeserver.yaml.
|
||
type SynapseMsc3861Config struct {
|
||
// HomeserverYamlPath is the absolute path to the homeserver.yaml file.
|
||
HomeserverYamlPath string
|
||
// MasEndpoint is the internal MAS URL (e.g. http://mas:8080/).
|
||
MasEndpoint string
|
||
// MasSecret is the shared_secret hex (64 hex chars, 32 bytes) matching mas/config.yaml::matrix.secret.
|
||
MasSecret string
|
||
// BackupDir is the directory where the original file backup is stored.
|
||
BackupDir string
|
||
// DryRun: if true, compute diff only without writing files.
|
||
DryRun bool
|
||
}
|
||
|
||
// SynapseMsc3861Result holds the output of SynapseMsc3861Enable.
|
||
type SynapseMsc3861Result struct {
|
||
// BackupPath is the path of the backup file created (empty if DryRun=true).
|
||
BackupPath string
|
||
// LinesAdded is the number of added lines in the diff.
|
||
LinesAdded int
|
||
// LinesRemoved is the number of removed lines in the diff.
|
||
LinesRemoved int
|
||
// Diff is the unified diff string between original and modified content.
|
||
Diff string
|
||
}
|
||
|
||
// hexPattern matches exactly 64 lowercase hex characters.
|
||
var hexPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||
|
||
// SynapseMsc3861Enable edits a Synapse homeserver.yaml to enable MSC3861 (Matrix Authentication Service).
|
||
//
|
||
// Steps:
|
||
// 1. Validate inputs.
|
||
// 2. Backup the original file to BackupDir.
|
||
// 3. Parse the YAML using the yaml.v3 Node API (preserves comments).
|
||
// 4. Uncomment / add the matrix_authentication_service block.
|
||
// 5. Ensure experimental_features.msc3861.enabled = true.
|
||
// 6. Ensure password_config.enabled = false.
|
||
// 7. Compute a unified diff.
|
||
// 8. Write the result unless DryRun=true.
|
||
func SynapseMsc3861Enable(cfg SynapseMsc3861Config) (SynapseMsc3861Result, error) {
|
||
var result SynapseMsc3861Result
|
||
|
||
// --- 1. Validate inputs ---
|
||
if cfg.HomeserverYamlPath == "" {
|
||
return result, fmt.Errorf("HomeserverYamlPath is required")
|
||
}
|
||
if _, err := os.Stat(cfg.HomeserverYamlPath); err != nil {
|
||
return result, fmt.Errorf("HomeserverYamlPath %q not found: %w", cfg.HomeserverYamlPath, err)
|
||
}
|
||
if cfg.MasEndpoint == "" {
|
||
return result, fmt.Errorf("MasEndpoint is required")
|
||
}
|
||
if !strings.HasPrefix(cfg.MasEndpoint, "http://") && !strings.HasPrefix(cfg.MasEndpoint, "https://") {
|
||
return result, fmt.Errorf("MasEndpoint must start with http:// or https://")
|
||
}
|
||
if !hexPattern.MatchString(cfg.MasSecret) {
|
||
return result, fmt.Errorf("MasSecret must be exactly 64 lowercase hex characters (32 bytes)")
|
||
}
|
||
if cfg.BackupDir == "" {
|
||
return result, fmt.Errorf("BackupDir is required")
|
||
}
|
||
|
||
// --- Read original file ---
|
||
originalBytes, err := os.ReadFile(cfg.HomeserverYamlPath)
|
||
if err != nil {
|
||
return result, fmt.Errorf("reading homeserver.yaml: %w", err)
|
||
}
|
||
originalContent := string(originalBytes)
|
||
|
||
// --- 2. Backup ---
|
||
if !cfg.DryRun {
|
||
if err := os.MkdirAll(cfg.BackupDir, 0o755); err != nil {
|
||
return result, fmt.Errorf("creating backup dir %q: %w", cfg.BackupDir, err)
|
||
}
|
||
ts := time.Now().Unix()
|
||
backupName := fmt.Sprintf("homeserver_%d.yaml", ts)
|
||
backupPath := filepath.Join(cfg.BackupDir, backupName)
|
||
if err := os.WriteFile(backupPath, originalBytes, 0o644); err != nil {
|
||
return result, fmt.Errorf("writing backup: %w", err)
|
||
}
|
||
result.BackupPath = backupPath
|
||
}
|
||
|
||
// --- 3–6. Modify content using line-level and YAML node processing ---
|
||
modifiedContent, err := applyMsc3861Edits(originalContent, cfg.MasEndpoint, cfg.MasSecret)
|
||
if err != nil {
|
||
return result, fmt.Errorf("applying MSC3861 edits: %w", err)
|
||
}
|
||
|
||
// --- 7. Compute diff ---
|
||
diff := unifiedDiff("homeserver.yaml (original)", "homeserver.yaml (modified)", originalContent, modifiedContent)
|
||
result.Diff = diff
|
||
|
||
added, removed := countDiffLines(diff)
|
||
result.LinesAdded = added
|
||
result.LinesRemoved = removed
|
||
|
||
// --- 8. Write if not DryRun ---
|
||
if !cfg.DryRun {
|
||
if err := os.WriteFile(cfg.HomeserverYamlPath, []byte(modifiedContent), 0o644); err != nil {
|
||
return result, fmt.Errorf("writing modified homeserver.yaml: %w", err)
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// applyMsc3861Edits performs all required YAML edits on the raw content string.
|
||
// It uses a line-based approach so that comments are preserved exactly.
|
||
func applyMsc3861Edits(content, masEndpoint, masSecret string) (string, error) {
|
||
// We work line-by-line for the commented-block replacement and password_config,
|
||
// then use yaml.v3 Node API for experimental_features.msc3861.
|
||
|
||
lines := strings.Split(content, "\n")
|
||
|
||
lines = enableMasBlock(lines, masEndpoint, masSecret)
|
||
lines = setPasswordConfigDisabled(lines)
|
||
|
||
modified := strings.Join(lines, "\n")
|
||
|
||
// Now handle experimental_features.msc3861 via yaml.v3 Node API.
|
||
modified, err := ensureExperimentalMsc3861(modified)
|
||
if err != nil {
|
||
return "", fmt.Errorf("updating experimental_features: %w", err)
|
||
}
|
||
|
||
return modified, nil
|
||
}
|
||
|
||
// masBlockTemplate is the YAML block we want active in the file.
|
||
func masBlockLines(endpoint, secret string) []string {
|
||
return []string{
|
||
"matrix_authentication_service:",
|
||
" enabled: true",
|
||
fmt.Sprintf(" endpoint: %q", endpoint),
|
||
fmt.Sprintf(" secret: %q", secret),
|
||
}
|
||
}
|
||
|
||
// enableMasBlock finds the commented-out matrix_authentication_service block
|
||
// (lines starting with "# matrix_authentication_service:") or an existing active
|
||
// block, and replaces/inserts the correct active block.
|
||
func enableMasBlock(lines []string, endpoint, secret string) []string {
|
||
// Patterns to detect the section.
|
||
commentedHeader := regexp.MustCompile(`^#\s*matrix_authentication_service:`)
|
||
activeHeader := regexp.MustCompile(`^matrix_authentication_service:`)
|
||
commentedSubkey := regexp.MustCompile(`^#\s+\w`)
|
||
|
||
newBlock := masBlockLines(endpoint, secret)
|
||
|
||
var result []string
|
||
i := 0
|
||
injected := false
|
||
|
||
for i < len(lines) {
|
||
line := lines[i]
|
||
|
||
if commentedHeader.MatchString(line) && !injected {
|
||
// Replace the commented block (consume commented sub-lines too).
|
||
result = append(result, newBlock...)
|
||
injected = true
|
||
i++
|
||
// Skip subsequent commented sub-lines belonging to this block.
|
||
for i < len(lines) && commentedSubkey.MatchString(lines[i]) {
|
||
i++
|
||
}
|
||
continue
|
||
}
|
||
|
||
if activeHeader.MatchString(line) && !injected {
|
||
// Already active — replace it to ensure correct values.
|
||
result = append(result, newBlock...)
|
||
injected = true
|
||
i++
|
||
// Skip existing sub-lines (indented).
|
||
for i < len(lines) && (strings.HasPrefix(lines[i], " ") || lines[i] == "") {
|
||
// Stop at the next top-level key.
|
||
if lines[i] != "" && !strings.HasPrefix(lines[i], " ") {
|
||
break
|
||
}
|
||
if strings.HasPrefix(lines[i], " ") {
|
||
i++
|
||
continue
|
||
}
|
||
break
|
||
}
|
||
continue
|
||
}
|
||
|
||
result = append(result, line)
|
||
i++
|
||
}
|
||
|
||
if !injected {
|
||
// Block not found anywhere — append at end (before trailing blank lines).
|
||
result = append(result, "")
|
||
result = append(result, newBlock...)
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// setPasswordConfigDisabled ensures `password_config:\n enabled: false` in the file.
|
||
func setPasswordConfigDisabled(lines []string) []string {
|
||
headerRe := regexp.MustCompile(`^password_config:`)
|
||
commentedRe := regexp.MustCompile(`^#\s*password_config:`)
|
||
|
||
var result []string
|
||
i := 0
|
||
injected := false
|
||
|
||
for i < len(lines) {
|
||
line := lines[i]
|
||
|
||
if commentedRe.MatchString(line) && !injected {
|
||
// Replace commented block.
|
||
result = append(result, "password_config:")
|
||
result = append(result, " enabled: false")
|
||
injected = true
|
||
i++
|
||
for i < len(lines) && regexp.MustCompile(`^#\s+\w`).MatchString(lines[i]) {
|
||
i++
|
||
}
|
||
continue
|
||
}
|
||
|
||
if headerRe.MatchString(line) && !injected {
|
||
// Active block — update or add enabled: false sub-key.
|
||
result = append(result, line)
|
||
injected = true
|
||
i++
|
||
foundEnabled := false
|
||
var subLines []string
|
||
for i < len(lines) && strings.HasPrefix(lines[i], " ") {
|
||
sl := lines[i]
|
||
if regexp.MustCompile(`^\s+enabled:`).MatchString(sl) {
|
||
subLines = append(subLines, " enabled: false")
|
||
foundEnabled = true
|
||
} else {
|
||
subLines = append(subLines, sl)
|
||
}
|
||
i++
|
||
}
|
||
if !foundEnabled {
|
||
subLines = append([]string{" enabled: false"}, subLines...)
|
||
}
|
||
result = append(result, subLines...)
|
||
continue
|
||
}
|
||
|
||
result = append(result, line)
|
||
i++
|
||
}
|
||
|
||
if !injected {
|
||
result = append(result, "")
|
||
result = append(result, "password_config:")
|
||
result = append(result, " enabled: false")
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// ensureExperimentalMsc3861 uses yaml.v3 Node API to set
|
||
// experimental_features.msc3861.enabled = true preserving other keys.
|
||
func ensureExperimentalMsc3861(content string) (string, error) {
|
||
var doc yaml.Node
|
||
if err := yaml.Unmarshal([]byte(content), &doc); err != nil {
|
||
return content, fmt.Errorf("yaml unmarshal: %w", err)
|
||
}
|
||
|
||
if doc.Kind == 0 {
|
||
// Empty document — append the block.
|
||
return content + "\nexperimental_features:\n msc3861:\n enabled: true\n", nil
|
||
}
|
||
|
||
root := &doc
|
||
if root.Kind == yaml.DocumentNode && len(root.Content) > 0 {
|
||
root = root.Content[0]
|
||
}
|
||
if root.Kind != yaml.MappingNode {
|
||
return content, fmt.Errorf("unexpected root YAML node kind %v", root.Kind)
|
||
}
|
||
|
||
// Find or create experimental_features.
|
||
expNode := findMappingValue(root, "experimental_features")
|
||
if expNode == nil {
|
||
// Append experimental_features block.
|
||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "experimental_features"}
|
||
valNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||
root.Content = append(root.Content, keyNode, valNode)
|
||
expNode = valNode
|
||
}
|
||
|
||
// Find or create msc3861 under experimental_features.
|
||
mscNode := findMappingValue(expNode, "msc3861")
|
||
if mscNode == nil {
|
||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "msc3861"}
|
||
valNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||
expNode.Content = append(expNode.Content, keyNode, valNode)
|
||
mscNode = valNode
|
||
}
|
||
|
||
// Set enabled: true inside msc3861.
|
||
enabledNode := findMappingValue(mscNode, "enabled")
|
||
if enabledNode == nil {
|
||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"}
|
||
valNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "true"}
|
||
mscNode.Content = append(mscNode.Content, keyNode, valNode)
|
||
} else {
|
||
enabledNode.Value = "true"
|
||
enabledNode.Tag = "!!bool"
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
enc := yaml.NewEncoder(&buf)
|
||
enc.SetIndent(2)
|
||
if err := enc.Encode(&doc); err != nil {
|
||
return content, fmt.Errorf("yaml marshal: %w", err)
|
||
}
|
||
if err := enc.Close(); err != nil {
|
||
return content, fmt.Errorf("yaml encoder close: %w", err)
|
||
}
|
||
|
||
return buf.String(), nil
|
||
}
|
||
|
||
// findMappingValue returns the value node for the given key in a mapping node, or nil.
|
||
func findMappingValue(node *yaml.Node, key string) *yaml.Node {
|
||
if node.Kind != yaml.MappingNode {
|
||
return nil
|
||
}
|
||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||
if node.Content[i].Value == key {
|
||
return node.Content[i+1]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// unifiedDiff produces a simple unified diff between two texts.
|
||
func unifiedDiff(fromLabel, toLabel, original, modified string) string {
|
||
if original == modified {
|
||
return ""
|
||
}
|
||
origLines := strings.Split(original, "\n")
|
||
modLines := strings.Split(modified, "\n")
|
||
|
||
var sb strings.Builder
|
||
fmt.Fprintf(&sb, "--- %s\n", fromLabel)
|
||
fmt.Fprintf(&sb, "+++ %s\n", toLabel)
|
||
|
||
// Simple LCS-based diff using a greedy approach (good enough for YAML files).
|
||
lcs := computeLCS(origLines, modLines)
|
||
formatDiff(&sb, origLines, modLines, lcs)
|
||
|
||
return sb.String()
|
||
}
|
||
|
||
// computeLCS computes the longest common subsequence indices for two string slices.
|
||
// Returns a slice of (origIdx, modIdx) pairs.
|
||
type lcsEntry struct{ o, m int }
|
||
|
||
func computeLCS(a, b []string) []lcsEntry {
|
||
la, lb := len(a), len(b)
|
||
// dp[i][j] = LCS length for a[:i], b[:j]
|
||
dp := make([][]int, la+1)
|
||
for i := range dp {
|
||
dp[i] = make([]int, lb+1)
|
||
}
|
||
for i := 1; i <= la; i++ {
|
||
for j := 1; j <= lb; j++ {
|
||
if a[i-1] == b[j-1] {
|
||
dp[i][j] = dp[i-1][j-1] + 1
|
||
} else if dp[i-1][j] >= dp[i][j-1] {
|
||
dp[i][j] = dp[i-1][j]
|
||
} else {
|
||
dp[i][j] = dp[i][j-1]
|
||
}
|
||
}
|
||
}
|
||
// Backtrack.
|
||
var result []lcsEntry
|
||
i, j := la, lb
|
||
for i > 0 && j > 0 {
|
||
if a[i-1] == b[j-1] {
|
||
result = append([]lcsEntry{{i - 1, j - 1}}, result...)
|
||
i--
|
||
j--
|
||
} else if dp[i-1][j] >= dp[i][j-1] {
|
||
i--
|
||
} else {
|
||
j--
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// formatDiff writes unified diff hunks.
|
||
func formatDiff(sb *strings.Builder, orig, mod []string, lcs []lcsEntry) {
|
||
const ctx = 3
|
||
|
||
// Build change regions.
|
||
var hunks []diffHunk
|
||
lcsIdx := 0
|
||
oi, mi := 0, 0
|
||
|
||
flushHunk := func(ho1, ho2, hm1, hm2 int) {
|
||
// Add context lines.
|
||
ctxStart := ho1 - ctx
|
||
if ctxStart < 0 {
|
||
ctxStart = 0
|
||
}
|
||
ctxEnd := ho2 + ctx
|
||
if ctxEnd > len(orig) {
|
||
ctxEnd = len(orig)
|
||
}
|
||
ctxMStart := hm1 - ctx
|
||
if ctxMStart < 0 {
|
||
ctxMStart = 0
|
||
}
|
||
ctxMEnd := hm2 + ctx
|
||
if ctxMEnd > len(mod) {
|
||
ctxMEnd = len(mod)
|
||
}
|
||
|
||
var lines []string
|
||
// Leading context.
|
||
for k := ctxStart; k < ho1; k++ {
|
||
lines = append(lines, " "+orig[k])
|
||
}
|
||
// Removals.
|
||
for k := ho1; k < ho2; k++ {
|
||
lines = append(lines, "-"+orig[k])
|
||
}
|
||
// Additions.
|
||
for k := hm1; k < hm2; k++ {
|
||
lines = append(lines, "+"+mod[k])
|
||
}
|
||
// Trailing context.
|
||
for k := ho2; k < ctxEnd; k++ {
|
||
lines = append(lines, " "+orig[k])
|
||
}
|
||
_ = ctxMStart
|
||
_ = ctxMEnd
|
||
|
||
hunks = append(hunks, diffHunk{ctxStart, ctxEnd, ctxMStart, ctxMEnd, lines})
|
||
}
|
||
|
||
for lcsIdx <= len(lcs) {
|
||
var lo, lm int
|
||
if lcsIdx < len(lcs) {
|
||
lo = lcs[lcsIdx].o
|
||
lm = lcs[lcsIdx].m
|
||
} else {
|
||
lo = len(orig)
|
||
lm = len(mod)
|
||
}
|
||
|
||
if oi < lo || mi < lm {
|
||
flushHunk(oi, lo, mi, lm)
|
||
}
|
||
|
||
if lcsIdx < len(lcs) {
|
||
oi = lcs[lcsIdx].o + 1
|
||
mi = lcs[lcsIdx].m + 1
|
||
}
|
||
lcsIdx++
|
||
}
|
||
|
||
// Merge overlapping hunks and print.
|
||
merged := mergeHunks(hunks)
|
||
for _, h := range merged {
|
||
fmt.Fprintf(sb, "@@ -%d,%d +%d,%d @@\n", h.o1+1, h.o2-h.o1, h.m1+1, h.m2-h.m1)
|
||
for _, l := range h.lines {
|
||
sb.WriteString(l)
|
||
sb.WriteByte('\n')
|
||
}
|
||
}
|
||
}
|
||
|
||
type diffHunk struct {
|
||
o1, o2, m1, m2 int
|
||
lines []string
|
||
}
|
||
|
||
func mergeHunks(hunks []diffHunk) []diffHunk {
|
||
var result []diffHunk
|
||
for _, dh := range hunks {
|
||
if len(result) > 0 && dh.o1 <= result[len(result)-1].o2 {
|
||
prev := &result[len(result)-1]
|
||
if dh.o2 > prev.o2 {
|
||
prev.o2 = dh.o2
|
||
}
|
||
if dh.m2 > prev.m2 {
|
||
prev.m2 = dh.m2
|
||
}
|
||
prev.lines = append(prev.lines, dh.lines...)
|
||
} else {
|
||
result = append(result, dh)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// countDiffLines counts added (+) and removed (-) lines in a unified diff.
|
||
func countDiffLines(diff string) (added, removed int) {
|
||
for _, line := range strings.Split(diff, "\n") {
|
||
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
|
||
added++
|
||
} else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
|
||
removed++
|
||
}
|
||
}
|
||
return
|
||
}
|