Files
fn_registry/functions/infra/synapse_msc3861_enable.go
egutierrez daef7ea190 feat(matrix): MAS migration helpers + 2 flows + 15 issues + capability group
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>
2026-05-24 22:53:33 +02:00

532 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// --- 36. 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
}