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>
This commit is contained in:
@@ -0,0 +1,531 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user